# agent-team/ci — split-job CI apply/verify workflow (Plane-2 leaf) > **MOVED + LIVE (2026-06-22):** the workflow is now a registered GitHub Actions > workflow at **`.github/workflows/agent-team-apply-verify.yml`** (repo root) — > GitHub Actions only runs workflows under `.github/workflows/`, so the prior > `agent-team/ci/` location was inert scaffolding. The privileged steps are > flipped live, gated by the `agent-apply` environment's required reviewer; the > trusted-dispatcher transport is `agent_team/dispatcher.py`. This directory now > holds docs only. The **split-job CI apply/verify workflow** 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-review` AND 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, > the `agent-apply` environment with a required reviewer, and branch protection, > then flip the step's `if:`). 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-pr` job authenticates with a > GitHub App installation token (`pull-requests: write`) minted at run time by > SHA-pinned `actions/create-github-app-token`. There is **no AWS and no > cloud-OIDC** anywhere in this workflow. The required human reviewer lives on > the `agent-apply` GitHub 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: 1. **Split CI — untrusted execution is credential-less.** The job that checks out and runs the diff (`build-test`) runs with `permissions: 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 **separate `gate-and-pr` job that never checks out or executes patch-controlled code** — it consumes the build/test report as **data only**. There is **no `pull_request_target` + head-ref checkout** (the "pwn request" anti-pattern). The `build-test` job 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. 2. **Trust-control-surface denylist (CI-side hard fail).** The `guard` job 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 inspects `rename from/to` headers**, 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. 3. **Diff integrity, box → CI.** The builder records the candidate diff's sha256 in the task ledger (foundation `agent_team.state_store` content-hash idiom). CI **re-hashes the diff and verifies it equals the recorded hash before applying** (in both `guard` and again pre-apply in `build-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. 4. **Pure-code pass/fail gate over authenticated results.** Mirroring secrev's "one pure-code script owns the block decision," the `gate-and-pr` gate reads **only** the authenticated `needs.*.result` job 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. 5. **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. ## Run-name correlation (box dispatcher → run_id) The box-side dispatcher (`agent_team/dispatcher.py`) triggers this workflow with `gh workflow run`, which does **not** return the resulting run id. The dispatcher must still resolve that `run_id` so the verifier's read-only CI-result fetcher can poll the correct run (a `None`/unfound run_id fails closed → the verifier gate BLOCKs / the task parks; never a vacuous pass). The correlation key is the workflow **run name**: ```yaml run-name: "agent-team-apply ${{ inputs.task_id }}" ``` Why the run name and not the workflow input or the head branch: - `gh run list --json name` exposes the run name, but **workflow inputs are not queryable** via the run list, so the `task_id` cannot be matched on the input. - A `workflow_dispatch` run reports against the **`main` ref**, not the dispatch head branch, so the branch is not a usable discriminator either. So the dispatcher polls `gh run list` read-only, matches the row whose `name` equals `agent-team-apply ` (mirrored in code by `dispatcher.run_name_for`), and bounds the match to runs created after the dispatch watermark. The workflow's `concurrency` group already guarantees a single in-flight run per `task_id`, so the run name plus the dispatched-at floor identify the dispatched run unambiguously even under many simultaneous dispatches; the pure `dispatcher.select_run_id` then applies the anti-stale tie-break (skip a superseded cancelled run sharing the name, prefer the later-created `databaseId`). This `run-name` is **additive**: it adds no job, permission, secret, or trigger, and changes no privileged step — it only surfaces the dispatching task's id for correlation. Because the file is nonetheless a CI trust-boundary workflow (untrusted-input handling), the edit is **flagged for the C1 re-run of `/sh-security-review` AND the mandatory GPT-4.1 cross-review** before it ships on this branch, matching the in-YAML `P3-BOX-INTEGRATION` comment. ## 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`; its `candidate_diff` + `diff_hash` fields (§3.3) are exactly the `expected_diff_hash` this workflow consumes, and `ci_results` is 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.db` schema (`pending_questions` / `budget_ledger` live 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): 1. `/sh-security-review` over this workflow (untrusted-input handling + CI trust boundary). 2. 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.) 3. **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 the `AGENT_APPLY_APP_ID` / `AGENT_APPLY_APP_PRIVATE_KEY` secrets. - Create the `agent-apply` GitHub 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 to `GITHUB_TOKEN`). 4. A documented, **exercised** rollback (uninstall the App, remove the environment, revert the workflow). 5. 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 to `Sea-Haven-Industries/.github`. Draft PRs only; never auto-merge.