Merge privileged runner trust candidate for coordination

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-04 09:39:27 +01:00
9 changed files with 183 additions and 140 deletions
+6
View File
@@ -63,6 +63,12 @@ dependency caches, direct Actions caches, and external BuildKit cache imports:
cache contents are unsigned mutable build input, while provenance only records
what the workflow produced. Read-only jobs may still cache locked dependencies.
Those privileged jobs also run only on a reviewed, literal GitHub-hosted image.
Persistent self-hosted runners can retain executable state from an earlier job,
and dynamic runner expressions can silently move a credential boundary. Local
self-hosted acceleration therefore remains free of repository secrets and
read-only.
Passing data through `env` does not make it safe to append to the runner's
`GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, or `GITHUB_STATE` command files.
The audit follows workflow data and values read from the event payload through
@@ -1,100 +0,0 @@
name: Patrol Live Qualification
on:
schedule:
- cron: "41 2 * * *"
workflow_dispatch:
inputs:
repeat_profile:
description: Manifest repetition profile
required: true
default: nightly
type: choice
options:
- development
- nightly
- qualification
authorize_live_faults:
description: Authorize reversible faults in the dedicated canary lab
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: patrol-live-qualification
cancel-in-progress: false
jobs:
watch-live-lab:
name: Watch / ${{ matrix.scenario }}
if: >-
vars.PULSE_PATROL_QUAL_LIVE_ENABLED == 'true' &&
(github.event_name == 'schedule' || inputs.authorize_live_faults == true)
runs-on: [self-hosted, patrol-qualification-lab]
environment: patrol-qualification-lab
timeout-minutes: 180
strategy:
fail-fast: false
max-parallel: 1
matrix:
scenario:
- watch.healthy-mixed
- watch.docker-unhealthy
- watch.existing-finding-reconfirmation
- watch.docker-restart-loop
- watch.correlated-dependency
- watch.two-independent-faults
- watch.prompt-injection-label
steps:
- name: Checkout pinned source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: false
- name: Validate qualification catalogue
run: go run ./cmd/patrol-qualify -mode validate
- name: Run reversible Watch qualification
env:
PULSE_QUALIFY_PASSWORD: ${{ secrets.PULSE_PATROL_QUAL_PASSWORD }}
PULSE_QUAL_URL: ${{ vars.PULSE_PATROL_QUAL_URL }}
PULSE_QUAL_USER: ${{ vars.PULSE_PATROL_QUAL_USER }}
PULSE_QUAL_MODEL: ${{ vars.PULSE_PATROL_QUAL_MODEL }}
PULSE_QUAL_EXPECTED_VERSION: ${{ vars.PULSE_PATROL_QUAL_EXPECTED_VERSION }}
PULSE_QUAL_DOCKER_CONTEXT: ${{ vars.PULSE_PATROL_QUAL_DOCKER_CONTEXT }}
PULSE_QUAL_ARTIFACT_ROOT: ${{ vars.PULSE_PATROL_QUAL_ARTIFACT_ROOT }}
DISPATCH_REPEAT_PROFILE: ${{ inputs.repeat_profile }}
run: |
test -n "$PULSE_QUALIFY_PASSWORD"
test -n "$PULSE_QUAL_URL"
test -n "$PULSE_QUAL_USER"
test -n "$PULSE_QUAL_DOCKER_CONTEXT"
test -n "$PULSE_QUAL_EXPECTED_VERSION"
test -n "$PULSE_QUAL_ARTIFACT_ROOT"
repeat_profile="${DISPATCH_REPEAT_PROFILE:-nightly}"
artifact_root="$PULSE_QUAL_ARTIFACT_ROOT/${GITHUB_RUN_ID}/${{ matrix.scenario }}"
go run ./cmd/patrol-qualify \
-mode live \
-scenario "${{ matrix.scenario }}" \
-url "$PULSE_QUAL_URL" \
-user "$PULSE_QUAL_USER" \
-model "$PULSE_QUAL_MODEL" \
-expected-pulse-version "$PULSE_QUAL_EXPECTED_VERSION" \
-docker-context "$PULSE_QUAL_DOCKER_CONTEXT" \
-repeat-profile "$repeat_profile" \
-artifacts "$artifact_root" \
-authorize-live-faults
# Raw reports stay on the access-controlled lab runner. They can contain
# private resource identity and must not be uploaded to a public Actions
# artifact. Publish only a separately reviewed comparison.md/json.
@@ -4,7 +4,6 @@ on:
pull_request:
paths:
- ".github/workflows/patrol-qualification-regression.yml"
- ".github/workflows/patrol-qualification-live.yml"
- "cmd/patrol-qualify/**"
- "internal/ai/qualification/**"
- "internal/ai/patrol*.go"
@@ -14,11 +13,12 @@ on:
- "internal/api/ai_handlers_patrol_actions_additional_test.go"
- "tests/qualification/patrol/**"
- "docs/AI_PATROL_QUALIFICATION.md"
- "docs/release-control/v6/internal/subsystems/ai-runtime.md"
- "frontend-modern/public/docs/AI_PATROL_QUALIFICATION.md"
push:
branches: [main]
paths:
- ".github/workflows/patrol-qualification-regression.yml"
- ".github/workflows/patrol-qualification-live.yml"
- "cmd/patrol-qualify/**"
- "internal/ai/qualification/**"
- "internal/ai/patrol*.go"
@@ -27,6 +27,9 @@ on:
- "internal/api/ai_handlers.go"
- "internal/api/ai_handlers_patrol_actions_additional_test.go"
- "tests/qualification/patrol/**"
- "docs/AI_PATROL_QUALIFICATION.md"
- "docs/release-control/v6/internal/subsystems/ai-runtime.md"
- "frontend-modern/public/docs/AI_PATROL_QUALIFICATION.md"
schedule:
- cron: "23 3 * * *"
workflow_dispatch:
+13 -17
View File
@@ -512,26 +512,22 @@ the provider exposes them.
- Pull requests: schema/catalog validation, unit tests, strict parsing,
scorer replay, transcript replay, and no credentials or homelab access.
- Nightly: recorded regression corpus plus a small Watch live-lab sample on a
dedicated self-hosted runner. Results are diagnostic until the required
repeat count is complete.
- Release qualification: pinned Pulse revision and disposable canary lab,
all Watch scenarios first, then investigation, then separately authorized
rejection and approved-remediation tracks. Artifacts must be retained
outside the working tree with checksums.
- Nightly: recorded regression corpus on a fresh GitHub-hosted runner. No
private-lab credential or live fault capability enters repository Actions.
- Live and release qualification: pinned Pulse revision and disposable canary
lab, all Watch scenarios first, then investigation, then separately
authorized rejection and approved-remediation tracks. Artifacts must be
retained outside the working tree with checksums.
- Production: observation only. Never manufacture a qualification fault in
production infrastructure.
`.github/workflows/patrol-qualification-live.yml` implements the opt-in
nightly Watch lab. It runs only on a runner labelled
`patrol-qualification-lab`, behind the `patrol-qualification-lab` environment,
and only when `PULSE_PATROL_QUAL_LIVE_ENABLED=true`. The environment supplies
the Pulse URL/user/password, explicit Docker context, exact expected Pulse
runtime version, optional model override, and an access-controlled runner-local
artifact root. Raw reports are
deliberately not uploaded to public Actions artifacts because they can contain
private resource identity. The seven Watch scenarios run sequentially so
model overrides and Patrol run association cannot race.
Run live qualification directly from a disposable canary-lab host with the
Pulse URL/user/password, explicit Docker context, exact expected Pulse runtime
version, optional model override, and an access-controlled artifact root. The
repository does not schedule this work on a self-hosted Actions runner: a
persistent runner can retain executable state between jobs, and the live
reports can contain private resource identity. Keep the seven Watch scenarios
sequential so model overrides and Patrol run association cannot race.
## Product decisions
@@ -6669,6 +6669,14 @@ baseline before fault injection. The Watch correlation and Pro investigation
fixtures use Alpine's `nc` applet for the disposable HTTP dependency; a missing
optional daemon must fail baseline convergence and never be scored as a model
miss.
Repository Actions may validate the Patrol catalogue and replay recorded
qualification evidence only on a fresh GitHub-hosted runner. Live and release
qualification require private-lab credentials, fault authority, and reports
that can contain private resource identity, so they must run directly from a
disposable canary-lab host at a pinned Pulse revision, never from a persistent
self-hosted Actions runner. Keep raw artifacts outside the working tree in an
access-controlled root with checksums, and execute the reviewed Watch scenarios
sequentially so model overrides and run association cannot race.
Governed Docker remediation qualification must not define a deliberate
container stop as a required Watch finding. Patrol intentionally suppresses an
ordinary stopped workload unless independent evidence establishes that it was
@@ -512,26 +512,22 @@ the provider exposes them.
- Pull requests: schema/catalog validation, unit tests, strict parsing,
scorer replay, transcript replay, and no credentials or homelab access.
- Nightly: recorded regression corpus plus a small Watch live-lab sample on a
dedicated self-hosted runner. Results are diagnostic until the required
repeat count is complete.
- Release qualification: pinned Pulse revision and disposable canary lab,
all Watch scenarios first, then investigation, then separately authorized
rejection and approved-remediation tracks. Artifacts must be retained
outside the working tree with checksums.
- Nightly: recorded regression corpus on a fresh GitHub-hosted runner. No
private-lab credential or live fault capability enters repository Actions.
- Live and release qualification: pinned Pulse revision and disposable canary
lab, all Watch scenarios first, then investigation, then separately
authorized rejection and approved-remediation tracks. Artifacts must be
retained outside the working tree with checksums.
- Production: observation only. Never manufacture a qualification fault in
production infrastructure.
`.github/workflows/patrol-qualification-live.yml` implements the opt-in
nightly Watch lab. It runs only on a runner labelled
`patrol-qualification-lab`, behind the `patrol-qualification-lab` environment,
and only when `PULSE_PATROL_QUAL_LIVE_ENABLED=true`. The environment supplies
the Pulse URL/user/password, explicit Docker context, exact expected Pulse
runtime version, optional model override, and an access-controlled runner-local
artifact root. Raw reports are
deliberately not uploaded to public Actions artifacts because they can contain
private resource identity. The seven Watch scenarios run sequentially so
model overrides and Patrol run association cannot race.
Run live qualification directly from a disposable canary-lab host with the
Pulse URL/user/password, explicit Docker context, exact expected Pulse runtime
version, optional model override, and an access-controlled artifact root. The
repository does not schedule this work on a self-hosted Actions runner: a
persistent runner can retain executable state between jobs, and the live
reports can contain private resource identity. Keep the seven Watch scenarios
sequential so model overrides and Patrol run association cannot race.
## Product decisions
@@ -98,6 +98,9 @@ func TestForcePatrolAcceptanceOwnsStatusAndProducesOneHistoryRecord(t *testing.T
if history[0].ID != acceptance.RunID {
t.Fatalf("history run id = %q, want %q", history[0].ID, acceptance.RunID)
}
if history[0].TriggerReason != string(TriggerReasonManual) {
t.Fatalf("history trigger = %q, want direct manual run", history[0].TriggerReason)
}
if calls := providerCalls.Load(); calls != 1 {
t.Fatalf("provider calls after completion = %d, want 1", calls)
}
+88 -4
View File
@@ -198,11 +198,11 @@ NONEMPTY_FLOW_MAPPING_RE = re.compile(
r'''^\s*(?:-\s*)?(?:[A-Za-z0-9_.-]+|"[^"]+"|'[^']+')'''
r"\s*:\s*\{(?!\s*\}\s*$)"
)
# OIDC-backed delivery identity is only trusted when GitHub owns the runner
# Secrets and delivery identity are only trusted when GitHub owns the runner
# lifecycle. Keep the accepted image labels explicit and reviewable so a job
# cannot move to persistent or dynamically selected compute without changing
# this contract. These are the hosted images used by Pulse's attestation jobs.
TRUSTED_OIDC_RUNNER_LABELS = frozenset(
# this contract.
TRUSTED_PRIVILEGED_RUNNER_LABELS = frozenset(
{"ubuntu-24.04", "windows-2025", "macos-15"}
)
@@ -1107,7 +1107,7 @@ def _audit_oidc_runner_trust(path: Path, lines: list[str]) -> list[Finding]:
continue
if (
len(runner_declarations) != 1
or runner_declarations[0][1] not in TRUSTED_OIDC_RUNNER_LABELS
or runner_declarations[0][1] not in TRUSTED_PRIVILEGED_RUNNER_LABELS
):
finding_index = (
runner_declarations[0][0] if runner_declarations else job_index
@@ -1125,6 +1125,89 @@ def _audit_oidc_runner_trust(path: Path, lines: list[str]) -> list[Finding]:
return findings
def _audit_privileged_runner_trust(path: Path, lines: list[str]) -> list[Finding]:
"""Keep secrets and repository write authority off mutable runner boundaries."""
findings: list[Finding] = []
jobs_index = next(
(
index
for index, line in enumerate(lines)
if JOBS_RE.match(line.split("#", 1)[0])
),
len(lines),
)
jobs_end = (
_mapping_end_index(lines, jobs_index)
if jobs_index < len(lines)
else len(lines)
)
top_level_write = any(
not match.group(1)
and _permission_mapping_has_write(lines, index)
for index, line in enumerate(lines)
if (match := PERMISSIONS_RE.match(line.split("#", 1)[0]))
)
top_level_secret = _has_secret_reference(
lines[:jobs_index] + lines[jobs_end:]
)
for job_index, end_index, _ in _runner_job_ranges(lines):
job_lines = lines[job_index:end_index]
direct_indent = _direct_mapping_indent(lines, job_index, end_index)
if direct_indent is None:
continue
permission_indexes = [
index
for index in range(job_index + 1, end_index)
if (
(match := PERMISSIONS_RE.match(lines[index].split("#", 1)[0]))
and len(match.group(1)) == direct_indent
)
]
job_write = (
any(
_permission_mapping_has_write(lines, index, end_index)
for index in permission_indexes
)
if permission_indexes
else top_level_write
)
if not (top_level_secret or _has_secret_reference(job_lines) or job_write):
continue
runner_declarations: list[tuple[int, str]] = []
for index in range(job_index + 1, end_index):
code = lines[index].split("#", 1)[0]
match = re.match(
rf"^(\s*){_yaml_key('runs-on')}\s*:\s*(.*?)\s*$", code
)
if match and len(match.group(1)) == direct_indent:
runner_declarations.append(
(index, match.group(2).strip().strip("'\""))
)
# A reusable-workflow caller cannot choose compute. Its called local
# jobs are audited independently, including inherited secrets and
# permissions at their actual runner boundary.
if not runner_declarations:
continue
if (
len(runner_declarations) != 1
or runner_declarations[0][1] not in TRUSTED_PRIVILEGED_RUNNER_LABELS
):
findings.append(
Finding(
path,
runner_declarations[0][0] + 1,
"secret- or write-capable jobs must use exactly one reviewed "
"literal GitHub-hosted runner label; persistent, dynamic, or "
"self-hosted runners can retain credentials or code between jobs",
)
)
return findings
def _audit_privileged_job_caches(path: Path, lines: list[str]) -> list[Finding]:
"""Keep unsigned cache state out of credential- and write-capable jobs."""
findings: list[Finding] = []
@@ -1269,6 +1352,7 @@ def audit_workflow(path: Path) -> list[Finding]:
findings = _audit_yaml_trust_shape(path, lines)
findings.extend(_audit_runner_job_timeouts(path, lines))
findings.extend(_audit_oidc_runner_trust(path, lines))
findings.extend(_audit_privileged_runner_trust(path, lines))
findings.extend(_audit_privileged_job_caches(path, lines))
findings.extend(_audit_workflow_run_trigger(path, lines))
has_workflow_run_trigger = _has_trigger(lines, "workflow_run")
+47
View File
@@ -204,6 +204,53 @@ jobs:
any("trusted delivery identity" in finding for finding in findings)
)
def test_secrets_and_write_permissions_require_hosted_runner(self) -> None:
for privileged in (
"""permissions: {}
jobs:
privileged:
runs-on: self-hosted
timeout-minutes: 10
env:
TOKEN: ${{ secrets.DEPLOY_TOKEN }}
steps:
- run: echo safe
""",
"""permissions:
contents: write
jobs:
privileged:
runs-on: ${{ inputs.runner }}
timeout-minutes: 10
steps:
- run: echo safe
""",
):
with self.subTest(privileged=privileged):
findings = self.audit(privileged)
self.assertTrue(
any("retain credentials or code" in finding for finding in findings)
)
findings = self.audit(
"""permissions: {}
jobs:
privileged:
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
TOKEN: ${{ secrets.DEPLOY_TOKEN }}
steps:
- run: echo safe
accelerated:
runs-on: [self-hosted, Linux, X64, build]
timeout-minutes: 10
steps:
- run: echo credential-free
"""
)
self.assertEqual(findings, [])
def test_reusable_workflow_caller_owns_oidc_runner_boundary(self) -> None:
findings = self.audit(
"""permissions: {}