diff --git a/.github/workflows/agent-team-apply-verify.yml b/.github/workflows/agent-team-apply-verify.yml index 04ebe1c..077b9d9 100644 --- a/.github/workflows/agent-team-apply-verify.yml +++ b/.github/workflows/agent-team-apply-verify.yml @@ -1057,17 +1057,29 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 # PROVISIONING-TIME PRIVILEGE (BLOCK-1 / FIX-4 / QUESTION-2). Privileged - # WS3 2026-06-23: the agent-apply GitHub Environment gate is REMOVED to - # enable auto-dispatch from the coordinator (attended deploy no longer - # requires a human reviewer to click Approve in the GitHub UI for each run). - # Compensating controls replace the required-reviewer hold: - # (1) a Slack notice is posted to #agent-team on every draft-PR open - # (AGENT_TEAM_SLACK_WEBHOOK_URL secret — must be provisioned); - # (2) an audit log line is emitted unconditionally so every run is - # traceable in the job log. - # OUTSTANDING (attended — do NOT merge until done): - # * Provision AGENT_TEAM_SLACK_WEBHOOK_URL as a repo secret. - # * Verify the Slack notice arrives in #agent-team on the first live run. + # declarations must be PROVISIONING-time, not live: an `environment:` that + # does not exist yet and a `pull-requests: write` grant are unprotected holes + # if declared before the `agent-apply` environment (with its required + # reviewer) is created. So both stay COMMENTED here — the exact deploy-gated + # pattern the removed cloud token-federation grant used — and are uncommented + # at provisioning AFTER the environment exists. Today this job is + # credential-less and runs ONLY the pure-code gate. + # + # The `agent-apply` GitHub Environment is the human-gate home: its REQUIRED + # REVIEWER (and optional wait timer / branch policy) is configured on the + # Environment at PROVISIONING — GitHub holds the job here until a human + # approves. This cannot be authored in YAML; the `environment:` reference is + # the hook the provisioning step attaches the reviewer to. + # FLIPPED LIVE 2026-06-22 (provisioning done: agent-apply env + required + # reviewer amoussa1229 + the GitHub App secrets exist). GitHub holds this job + # at the environment gate until the human reviewer approves each run. + # MANDATORY INVARIANTS (do not remove): (1) the agent-apply environment's + # required reviewer is the human gate — removing/weakening it makes the + # privileged job auto-run; (2) runs-on stays GitHub-hosted (never self-hosted) + # — a self-hosted runner could be attacker-influenced. Both are asserted by + # tests/test_apply_verify_workflow_hardening.py. + environment: + name: agent-apply permissions: contents: read # The ONLY privileged grant: open a draft PR via the App installation @@ -1249,37 +1261,12 @@ jobs: --head "$HEAD_BRANCH" echo "draft PR opened (task=$TASK_ID, head=$HEAD_BRANCH); never auto-merged." - - name: "Post Slack notice to #agent-team on PR open (compensating control)" - # WS3 compensating control: notify #agent-team whenever a draft PR opens. - # Requires AGENT_TEAM_SLACK_WEBHOOK_URL provisioned as a repo secret. - # Step runs only on a clean gate pass (same condition as the draft-PR - # step) and skips gracefully when the webhook secret is absent. - if: >- - always() - && needs.guard.result == 'success' - && needs.build-test.result == 'success' - && steps.gate.outputs.gate == 'pass' - env: - TASK_ID: ${{ inputs.task_id }} - DIFF_HASH: ${{ needs.guard.outputs.diff_hash }} - RUN_ID: ${{ github.run_id }} - GH_REPO: ${{ github.repository }} - SLACK_WEBHOOK_URL: ${{ secrets.AGENT_TEAM_SLACK_WEBHOOK_URL }} - run: | - set -euo pipefail - if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then - echo "AGENT_TEAM_SLACK_WEBHOOK_URL not set; skipping Slack notice" - exit 0 - fi - payload=$(printf '{"text":"[agent-apply] draft PR opened — task=%s diff=%s repo=%s run=%s"}' \ - "$TASK_ID" "$DIFF_HASH" "$GH_REPO" "$RUN_ID") - curl -fsSL -X POST -H 'Content-type: application/json' \ - --data "$payload" "$SLACK_WEBHOOK_URL" - echo "Slack notice sent to #agent-team" - - name: "Emit audit log entry (task + diff hash + gate result)" - # WS3 compensating control: unconditional audit trail for every run so - # every dispatch attempt is visible in the job log regardless of outcome. + # WS3 detective control (additive): an unconditional audit trail for + # every run so every dispatch attempt is traceable in the job log, + # regardless of outcome. This SUPPLEMENTS — it does not replace — the + # agent-apply environment's required-reviewer gate, which remains the + # preventive human approval before this privileged job runs. if: always() env: TASK_ID: ${{ inputs.task_id }} diff --git a/agent-team/tests/test_apply_verify_workflow_hardening.py b/agent-team/tests/test_apply_verify_workflow_hardening.py index d6a5f27..9379ea7 100644 --- a/agent-team/tests/test_apply_verify_workflow_hardening.py +++ b/agent-team/tests/test_apply_verify_workflow_hardening.py @@ -169,12 +169,9 @@ def test_app_token_step_gated_on_pure_code_pass() -> None: def test_gate_job_privileged_declarations_are_live() -> None: - # WS3: the agent-apply environment gate is REMOVED to enable auto-dispatch. - # Compensating controls (Slack notice + audit log) replace the - # required-reviewer hold. Assert: - # (1) permissions are unchanged (contents: read, pull-requests: write only), - # (2) the environment block is ABSENT, - # (3) both compensating steps are present. + # Provisioning done: the gate-and-pr job now binds the agent-apply environment + # (its required reviewer gates every run) and grants exactly pull-requests: + # write — nothing more. contents stays read; no token-federation/id-token. job = _doc()["jobs"]["gate-and-pr"] perms = job.get("permissions") or {} assert perms.get("contents") == "read" @@ -183,17 +180,16 @@ def test_gate_job_privileged_declarations_are_live() -> None: ) assert "id-token" not in perms env = job.get("environment") - assert env is None, ( - "gate-and-pr must NOT bind the agent-apply environment (WS3: removed; " - "Slack-notify + audit-log steps are the compensating controls)" + env_name = env.get("name") if isinstance(env, dict) else env + assert env_name == "agent-apply", ( + "gate-and-pr must bind the agent-apply environment (required-reviewer gate)" ) - # Compensating controls must be present. + # WS3 additive detective control: an audit-log step supplements (never + # replaces) the required-reviewer gate. Assert it is present so it cannot + # silently regress. step_names = [s.get("name", "").lower() for s in job.get("steps", [])] - assert any("slack" in n for n in step_names), ( - "gate-and-pr must have a Slack-notify compensating step" - ) assert any("audit" in n for n in step_names), ( - "gate-and-pr must have an audit-log compensating step" + "gate-and-pr must keep the audit-log compensating step" )