* feat(agent-team): read-only CI-result fetcher for P3 verify gate (opt-in, inert)
ci_fetcher.py: fail-closed CiResultFetcher reading the GitHub Actions run
conclusion via a read-only PAT (AGENT_TEAM_CI_READ_TOKEN→GITHUB_TOKEN), returns
{run_id,conclusion,diff_hash} or None on any error. Data-fetcher only — ci_gate
owns the verdict; never writes, no OIDC/AWS, never reads patch artifacts.
coordinator gains opt-in gated_build_verify_wiring() composing it via
bind_ci_result_fetcher; NOT wired into the default run-team.py path. 20 tests.
* harden(agent-team): P3 apply/verify workflow — GitHub App token, CWE-94, fail-closed
Decision-1 auth model: gate-and-pr uses a GitHub App installation token
(pull-requests:write) behind the agent-apply environment; ALL OIDC/id-token/AWS
removed. Hardening: task_id env-indirection (CWE-94 — GitHub expands ${{ }} into
the run shell before exec, so %s/quoting is insufficient); run-id pinning on both
download-artifact; post-build denied-path check (build-hook writes into denied
paths fail the job); empty-hash fail-closed in BOTH the embedded gate (fixed a
real ''=='' pass bug) and ci_gate.py. App-token + draft-PR steps stay if:${{ false }}
until provisioning (App + environment + branch protection). +17 tests.
* harden(agent-team): apply P3-live security-gate fixes (GPT-4.1 xreview + sh-security-review)
BLOCK-1/FIX-4: gate-and-pr re-comments pull-requests:write + environment:agent-apply
(provisioning-time uncomment) and gains needs.guard/build-test=='success' job guard —
zero privilege until provisioning. BLOCK-2/3+FIX-5: ci_fetcher validates run_id (^[0-9]{1,20}$),
owner/repo (^[A-Za-z0-9_.-]{1,100}$), and fetched_id (int) — fail closed, no SSRF/path
injection. FIX-1: conclusion allowlist. FIX-3: api_root removed from public builder (no
injectable endpoint). INJ-02: post-build denied-path check uses NUL-delimited git output +
explicit rename parsing, no backslash mangling, non-UTF8=violation. INJ-03: all three trust-
control denylists unified to one 22-entry union + drift-guard test. Q1: documented run_id/
diff_hash trust source (dispatcher/ledger only). 884 tests, ruff clean. Privileged steps stay
if:${{ false }} until provisioning.
* build(security-review): prune .claude worktrees from deterministic scanners
Agent worktrees under .claude/worktrees/ are full repo copies; the cfn-lint
find|xargs template scan overflowed ('command line cannot be assembled') and the
pre-push hook fail-closed to BLOCK whenever a worktree was present. Prune .claude
in the cfn-lint find + semgrep/checkov excludes, and gitignore .claude/ so it is
never scanned or committed. Unblocks main-tree pushes during parallel agent work.
11 KiB
agent-team/ci — split-job CI apply/verify workflow (Plane-2 leaf)
Pre-deployment scaffolding for the R720 agent-team SDLC pipeline. This directory
holds the split-job CI apply/verify workflow that turns a builder agent's
untrusted candidate diff into a verified draft PR — the §3.3.2 trust
boundary, Phase P3 (§7.1) of ../../docs/r720-agent-team-design.md.
STATUS: DEPLOY-GATED. NOT ENABLED, NOT PROVISIONED. This is authored as files only. Per the design (§3.3.2, §7.1 P3) the workflow must clear BOTH
/sh-security-reviewAND the mandatory GPT-4.1 cross-review before deployment, because it is untrusted-input handling + a CI trust boundary. The privileged draft-PR step is hard-disabled (if: ${{ false }}); the ONLY remaining step to go live is the provisioning flip (create the GitHub App, theagent-applyenvironment with a required reviewer, and branch protection, then flip the step'sif:). Nothing here is wired to a live org repo.AUTH MODEL (LOCKED): GitHub App installation token — ZERO cloud credentials, no token-federation. The privileged
gate-and-prjob authenticates with a GitHub App installation token (pull-requests: write) minted at run time by SHA-pinnedactions/create-github-app-token. There is no AWS and no cloud-OIDC anywhere in this workflow. The required human reviewer lives on theagent-applyGitHub Environment (configured at provisioning, not in YAML).
Files
| File | What it is |
|---|---|
agent-team-apply-verify.yml |
The split-job workflow. Self-contained: the load-bearing gate logic (diff integrity, trust-control denylist, declared-scope check, pure-code pass/fail) is embedded inline as stdlib-only, type-hinted Python heredocs, so the workflow has no external script dependency. |
README.md |
This file. |
The filename is kebab-case per the handbook. Deployment target (later, after the
gates): promote into Sea-Haven-Industries/.github as a reusable workflow
(engineering-handbook/cicd.md); the trusted apply path (which owns the GitHub
App write token) invokes it.
The trust boundary (design §3.3.2)
The builder agents are semi-trusted: an LLM that read repo content can be wrong or prompt-injected, so the candidate diff is treated as untrusted code. The threat is that executing it in CI with org credentials lets a bad diff exfiltrate secrets, assume the deploy role, or tamper with other repos. The workflow implements all five boundaries:
- Split CI — untrusted execution is credential-less. The job that checks
out and runs the diff (
build-test) runs withpermissions: contents: read, no secrets, no App token, no write token, and egress blocked (harden-runner). The patch executes only there, where there is nothing to steal and nothing to assume. Every privileged action (the GitHub App token mint, the draft-PR open) runs in a separategate-and-prjob that never checks out or executes patch-controlled code — it consumes the build/test report as data only. There is nopull_request_target+ head-ref checkout (the "pwn request" anti-pattern). Thebuild-testjob also runs a post-build denied-path check: after the build/test step, it diffs the working tree against the committed patched baseline and fails if a build hook (setup.py, conftest, postinstall, Makefile) wrote into the trust-control surface or out of declared scope — closing the build-hook write vector. - Trust-control-surface denylist (CI-side hard fail). The
guardjob rejects any diff that touches.github/workflows/**, IAM/policy IaC (CDK/SAM/Terraform), branch-protection /CODEOWNERS/ Dependabot config, or files outside the task's declared scope. The match is not naive: it canonicalizes paths, rejects parent-directory traversal, and inspectsrename from/toheaders, so a rename into a denied path — or path indirection — cannot bypass it. Such a diff is escalated to mandatory human + GPT cross-review, never auto-built. - Diff integrity, box → CI. The builder records the candidate diff's sha256
in the task ledger (foundation
agent_team.state_storecontent-hash idiom). CI re-hashes the diff and verifies it equals the recorded hash before applying (in bothguardand again pre-apply inbuild-test). Precisely: this is an unkeyed hash that binds the bytes CI applies to the hash the dispatcher recorded — it detects accidental corruption or substitution of the artifact in transit between the trusted dispatcher and CI. It does not prove the diff matches the approved plan, and it cannot defend a compromised box that generates both the diff and its hash (see "Threat-model honesty" below). A keyed/attested binding to the approval record is a later hardening. - Pure-code pass/fail gate over authenticated results. Mirroring secrev's
"one pure-code script owns the block decision," the
gate-and-prgate reads only the authenticatedneeds.*.resultjob conclusions (GitHub-controlled, patch-independent) keyed to the run id + diff hash. It never trusts a success/failure file or artifact the patch could have written. The verifier agent only reads failures to propose the next fix; it cannot declare success. - Branch protection is the final enforcement. The draft PR targets a protected branch; the locked-down checks are required; merge needs them green plus the security-review, the Claude Code App review, and human approval. The agent never auto-merges (D2).
Threat-model honesty (§3.3.2). The diff hash protects box → CI transit (tamper/substitution), not a box that is itself compromised — the box is in the trusted computing base for what it proposes. The real backstops against a bad box are the credential-less build job, the trust-control-surface denylist, and above all the human review of the draft PR plus required checks (nothing merges without Adam).
Job graph
workflow_dispatch (task_id, diff_artifact_name, expected_diff_hash, declared_scope)
│
▼
guard contents:read, no secrets — validates the diff as DATA:
(boundaries 2,3) re-hash + denylist + scope. Never applies it.
│ (needs)
▼
build-test contents:read, no secrets, no App token, egress blocked —
(boundary 1) the ONLY job that applies + runs the UNTRUSTED patch.
│ (needs) Emits a NON-authoritative report artifact.
▼
gate-and-pr privileged home, but NEVER checks out patch code. Pure-code
(boundaries 1,4,5) gate over authenticated needs.*.result → DRAFT PR
(hard-disabled until the review gates pass).
permissions: {} at the workflow level (least privilege); each job re-declares
its own grant explicitly. The trigger is workflow_dispatch only — the patch
never runs in a context carrying write or secret scope.
SHA-pinned actions (handbook Pinning Principle, §3.3.2)
Every third-party action is pinned to a full commit SHA with the human-readable tag in a trailing comment:
| Action | SHA | Tag |
|---|---|---|
actions/checkout |
11bd71901bbe5b1630ceea73d27597364c9af683 |
v4.2.2 |
actions/download-artifact |
fa0a91b85d4f404e444e00e005971372dc801d16 |
v4.1.8 |
actions/upload-artifact |
b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 |
v4.4.3 |
actions/setup-python |
0b93645e9fea7318ecaed2b359559ac225c90a2b |
v5.3.0 |
actions/create-github-app-token |
5d869da34e18e7287c1daad50e0b8ea0f506ce69 |
v1.11.0 |
step-security/harden-runner |
0080882f6c36860b6ba35c610c98ce87d4e2f26f |
v2.10.2 |
Relationship to the foundation
This leaf imports the committed Plane-2 foundation contracts verbatim (it does not redefine them):
- The diff-hash recorded in the ledger and re-checked in CI is the same
content-hash idiom as
agent_team.state_store.compute_content_hash(§6.7). - The task this workflow verifies is an
agent_team.task_model.TaskRecord; itscandidate_diff+diff_hashfields (§3.3) are exactly theexpected_diff_hashthis workflow consumes, andci_resultsis what the verifier writes back from the authenticated gate (boundary 4). - The ledger that records provenance (diff hash, run id, gate decision) is the
agent_team.dbschema (pending_questions/budget_ledgerlive there; per-task CI provenance is recorded against the task thread).
Tests
The workflow's embedded gate logic (diff integrity, the trust-control denylist
with path-canonicalization + rename/copy/delete detection, the symlink-escape
reject, and declared-scope enforcement) is stdlib-only, type-hinted, and
ruff-clean, and is covered by a committed, runnable suite:
../tests/test_ci_gate_workflow.py extracts the inline guard script from this
YAML and executes it against good and adversarial diffs — clean in-scope,
hash mismatch, workflow delete, copy-into-denied, symlink addition, non-UTF-8,
out-of-scope, unscoped, and escaping-scope. Run it with the rest of the suite:
python3 -m pytest agent-team/tests/ -q from the repo root. (The claim that the
gate is "verified" is therefore backed by that test, not by authoring alone.)
Deploy gating (do NOT skip)
Before this ships (§3.3.2, §7.1 P3):
/sh-security-reviewover this workflow (untrusted-input handling + CI trust boundary).- Mandatory GPT-4.1 cross-review of the workflow. (No IAM/cloud role is involved — the auth model is a GitHub App installation token, not OIDC/AWS.)
- Provisioning (the single remaining step to go live):
- Create the GitHub App with a single permission (
pull-requests: write), install it on the target repo, and store its id + private key as theAGENT_APPLY_APP_ID/AGENT_APPLY_APP_PRIVATE_KEYsecrets. - Create the
agent-applyGitHub Environment with a required reviewer (and optional wait timer) — this is the human gate, configured on the Environment, not in YAML. - Configure branch protection on the target branch (required checks + human approval).
- Issue the read-only token for the CI-result fetcher
(
AGENT_TEAM_CI_READ_TOKEN, falling back toGITHUB_TOKEN).
- Create the GitHub App with a single permission (
- A documented, exercised rollback (uninstall the App, remove the environment, revert the workflow).
- Only then: flip the App-token + draft-PR steps'
if: ${{ false }}to the live condition documented in the workflow (always() && needs.guard.result=='success' && needs.build-test.result=='success' && steps.gate.outputs.gate=='pass'), and promote toSea-Haven-Industries/.github. Draft PRs only; never auto-merge.