diff --git a/.github/workflows/README.md b/.github/workflows/README.md index ed901df9f..f79d0c7b6 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -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 diff --git a/.github/workflows/patrol-qualification-live.yml b/.github/workflows/patrol-qualification-live.yml deleted file mode 100644 index d3d09510b..000000000 --- a/.github/workflows/patrol-qualification-live.yml +++ /dev/null @@ -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. diff --git a/.github/workflows/patrol-qualification-regression.yml b/.github/workflows/patrol-qualification-regression.yml index 7b4491aa6..894c2689c 100644 --- a/.github/workflows/patrol-qualification-regression.yml +++ b/.github/workflows/patrol-qualification-regression.yml @@ -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: diff --git a/docs/AI_PATROL_QUALIFICATION.md b/docs/AI_PATROL_QUALIFICATION.md index 8a1720074..b2cdb2ed7 100644 --- a/docs/AI_PATROL_QUALIFICATION.md +++ b/docs/AI_PATROL_QUALIFICATION.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 01505359f..c9042d2fc 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/frontend-modern/public/docs/AI_PATROL_QUALIFICATION.md b/frontend-modern/public/docs/AI_PATROL_QUALIFICATION.md index 8a1720074..b2cdb2ed7 100644 --- a/frontend-modern/public/docs/AI_PATROL_QUALIFICATION.md +++ b/frontend-modern/public/docs/AI_PATROL_QUALIFICATION.md @@ -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 diff --git a/internal/ai/patrol_manual_acceptance_test.go b/internal/ai/patrol_manual_acceptance_test.go index 27f68f79c..4e1269fc5 100644 --- a/internal/ai/patrol_manual_acceptance_test.go +++ b/internal/ai/patrol_manual_acceptance_test.go @@ -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) } diff --git a/scripts/check_workflow_trust.py b/scripts/check_workflow_trust.py index 166247333..752cde111 100644 --- a/scripts/check_workflow_trust.py +++ b/scripts/check_workflow_trust.py @@ -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") diff --git a/scripts/tests/test_workflow_trust.py b/scripts/tests/test_workflow_trust.py index 7b8244cc5..17c1c846f 100644 --- a/scripts/tests/test_workflow_trust.py +++ b/scripts/tests/test_workflow_trust.py @@ -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: {}