# R720 agent-team — split-job CI apply/verify workflow (design §3.3.2, §7.1 P3). # # DEPLOY-GATED PRE-DEPLOYMENT SCAFFOLDING. This file is authored as IaC only. # It is NOT enabled, NOT provisioned, and NOT wired to any live org repo. Per # the design (§3.3.2, §7.1 P3) it must clear BOTH `/sh-security-review` AND the # mandatory GPT-4.1 cross-review before it is deployed (it is IaC/IAM + # untrusted-input handling). Until then it lives here as a reviewable artifact. # # Deployment target (later, after the gates): promote into # Sea-Haven-Industries/.github as a reusable workflow (engineering-handbook # cicd.md) and have the Option-B OIDC apply path call it. The filename stays # kebab-case per the handbook. # # ───────────────────────────────────────────────────────────────────────────── # 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 UNTRUSTED CODE. The five boundaries this workflow implements: # # 1. Split CI. The job that checks out + executes the patch (`build-test`) # runs credential-less (`permissions: contents: read`, no secrets, no # OIDC, no write token, egress-restricted). Every privileged action runs # in a SEPARATE job (`gate-and-pr`) that NEVER checks out or runs # patch-controlled code; it consumes the build/test report as DATA only. # This is NOT `pull_request_target` with a head-ref checkout (pwn request). # 2. Trust-control-surface denylist. `guard` hard-fails (CI-side, not only the # box) any diff touching `.github/workflows/**`, IAM/policy IaC, branch # protection / CODEOWNERS / Dependabot, or files outside the declared task # scope. It canonicalizes paths, resolves symlinks, and rejects renames # into denied paths — a path match cannot be bypassed by indirection. # 3. Diff integrity, box → CI. CI re-hashes the candidate diff and verifies it # equals the ledger-recorded hash BEFORE applying. Tamper/substitution # fails the hash check. # 4. Pure-code pass/fail gate. A deterministic gate reads the authenticated # build/test conclusion keyed to (run id + diff hash). It never trusts a # success/failure file the patch could have written. The verifier AGENT # only reads failures to propose a fix; it cannot declare success. # 5. Branch protection. The draft PR targets a protected branch; the # locked-down checks are required; merge needs them green + the # security-review + the Claude Code App review + human approval. The agent # NEVER auto-merges (D2). # # All third-party actions are SHA-pinned (handbook Pinning Principle, §3.3.2). # ───────────────────────────────────────────────────────────────────────────── name: agent-team-apply-verify # Manual / API trigger only. The Option-B OIDC apply path (a trusted, separate # workflow that owns the write token) invokes this with the candidate-diff # artifact + the ledger-recorded hash + the declared scope. There is NO # pull_request / pull_request_target trigger: the patch must never run in a # context that carries write or secret scope (boundary 1). on: workflow_dispatch: inputs: task_id: description: "Pipeline task thread_id (for provenance/audit)." required: true type: string diff_artifact_name: description: "Name of the uploaded candidate-diff artifact." required: true type: string expected_diff_hash: description: "Ledger-recorded sha256 of the candidate diff (boundary 3)." required: true type: string declared_scope: description: >- Newline-separated list of glob paths the task is allowed to touch (boundary 2). A diff that changes anything outside this set fails. required: true type: string # Workflow-level default: least privilege. Every job re-declares its own # `permissions:` so the grant is explicit per job and the untrusted job can be # audited at a glance. permissions: {} # One in-flight apply/verify per task; a re-dispatch cancels the stale run so a # superseded diff cannot race a newer one. concurrency: group: agent-team-apply-verify-${{ inputs.task_id }} cancel-in-progress: true jobs: # ─────────────────────────────────────────────────────────────────────────── # JOB 1 — guard (boundaries 2 + 3). Credential-less. Validates the candidate # diff WITHOUT applying or executing it: re-hashes it (integrity) and runs the # trust-control-surface denylist + declared-scope check. This job reads the # diff as DATA only — it never `git apply`s it, so even a hostile diff cannot # run code here. A failure is terminal: the diff is rejected and ALARM-worthy. # ─────────────────────────────────────────────────────────────────────────── guard: runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read outputs: diff_hash: ${{ steps.verify.outputs.diff_hash }} steps: - name: Harden runner (egress audit; no secrets present anyway) uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 with: egress-policy: block # Only what fetching the artifact + GitHub API needs. The job holds # no secrets, so a successful exfil yields nothing of value (§3.3.2), # but we deny egress as defense-in-depth. allowed-endpoints: > github.com:443 api.github.com:443 objects.githubusercontent.com:443 *.actions.githubusercontent.com:443 - name: Download candidate diff (data only; not applied) uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: ${{ inputs.diff_artifact_name }} path: ./_incoming - name: Verify diff integrity + trust-control denylist + scope id: verify env: EXPECTED_DIFF_HASH: ${{ inputs.expected_diff_hash }} DECLARED_SCOPE: ${{ inputs.declared_scope }} DIFF_PATH: ./_incoming/candidate.diff run: | set -euo pipefail # Self-contained, stdlib-only, type-hinted gate program embedded # inline so this workflow has NO external script dependency. It is # patch-independent: it parses the unified diff as TEXT and never # executes it. It re-hashes the diff (boundary 3) and enforces the # trust-control-surface denylist + declared scope (boundary 2), # canonicalizing paths and rejecting renames into denied paths. python3 - <<'PY' from __future__ import annotations import hashlib import os import posixpath import re import sys # --- Boundary 2: the trust-control surface. Touching ANY of these is # an auto-reject; such a diff is escalated to mandatory human + GPT # cross-review, never auto-built (these are the mandatory-cross-review # surface regardless). Matched against canonicalized POSIX paths. --- DENY_GLOBS: tuple[str, ...] = ( ".github/workflows/**", ".github/actions/**", "**/CODEOWNERS", "CODEOWNERS", ".github/dependabot.yml", ".github/dependabot.yaml", ".github/settings.yml", # IAM / policy / permission IaC (CDK / SAM / Terraform). "**/template.yaml", "**/template.yml", "**/*.tf", "**/cdk.json", "**/*-stack.ts", "**/*_stack.py", "**/policy*.json", "**/*iam*", "**/*.pem", "**/*.key", ) def canonical(path: str) -> str: """Canonicalize a diff path to a normalized, anchored POSIX path. Strips git's a//b/ prefixes, collapses ``.`` / ``..`` and backslashes, and rejects absolute or parent-escaping paths so a denied location cannot be reached by traversal/indirection. """ p = path.strip() # git unified-diff prefixes. for pre in ("a/", "b/"): if p.startswith(pre): p = p[len(pre):] break p = p.replace("\\", "/") # normpath then re-POSIX it. norm = posixpath.normpath(p) if norm.startswith("/") or norm == ".." or norm.startswith("../"): raise ValueError(f"path escapes repo root: {path!r}") return norm def parse_touched_paths(diff_text: str) -> set[str]: """Extract every path a unified diff adds/modifies/renames/deletes. Reads ``+++ ``/``--- `` targets, ``diff --git a/x b/y`` headers, and ``rename from/to`` lines — so a rename INTO a denied path (or a new file generated into one) is caught, not just in-place edits. """ touched: set[str] = set() for line in diff_text.splitlines(): m = re.match(r"^diff --git (\S+) (\S+)$", line) if m: for raw in (m.group(1), m.group(2)): touched.add(canonical(raw)) continue m = re.match(r"^(?:\+\+\+|---) (.+)$", line) if m: tgt = m.group(1).strip() if tgt == "/dev/null": continue # strip trailing tab-timestamp some diffs carry. tgt = tgt.split("\t", 1)[0] touched.add(canonical(tgt)) continue m = re.match(r"^rename (?:from|to) (.+)$", line) if m: touched.add(canonical(m.group(1).strip())) return touched def find_symlink_additions(diff_text: str) -> list[tuple[str, str]]: """Return ``[(path, target)]`` for every symlink the diff creates. A symlink shows as git file mode ``120000``; its link target is the single added content line. Textual path canonicalization (``canonical``) cannot see a symlink that redirects a later in-diff write into a denied location (e.g. ``sub/link -> ../.github/workflows`` then a write to ``sub/link/evil.yml``). A candidate auto-build diff has no legitimate reason to introduce a symlink, so guard treats ANY symlink addition as a hard reject (boundary 2), closing the symlink-escape vector. """ additions: list[tuple[str, str]] = [] cur_path: str | None = None pending = False for line in diff_text.splitlines(): g = re.match(r"^diff --git (\S+) (\S+)$", line) if g: cur_path, pending = g.group(2), False continue p = re.match(r"^\+\+\+ (.+)$", line) if p and p.group(1).strip() != "/dev/null": cur_path = p.group(1).split("\t", 1)[0].strip() continue if re.match(r"^(?:new file mode|new mode) 120000\s*$", line): pending = True continue if pending and line.startswith("+") and not line.startswith("+++"): try: path_c = canonical(cur_path) if cur_path else "" except ValueError: # canonical() rejected the path (absolute/escaping); report # it with only the git a//b/ PREFIX removed for the error # message (re.sub, not str.lstrip which strips a char set). path_c = re.sub(r"^[ab]/", "", cur_path or "") additions.append((path_c, line[1:].strip())) pending = False return additions _GLOB_META = set("*?[]") _GLOB_RE_CACHE: dict[str, "re.Pattern[str]"] = {} def _glob_to_regex(glob: str) -> "re.Pattern[str]": """Compile a gitignore-style glob to a '/'-aware, case-insensitive regex. Python's ``fnmatch`` does NOT implement recursive ``**`` (it treats it as a single ``*`` that already spans ``/``), so ``**/template.yaml`` fails to match a repo-ROOT ``template.yaml`` — a denylist bypass for exactly the IaC/secret families boundary 2 must catch. This translates ``**/`` to "any depth INCLUDING zero", ``**`` to ".*", ``*`` to a single non-slash run, ``?`` to one non-slash char, and matches case- insensitively (POSIX runners are case-sensitive, but a case variant of a trust-control filename must not slip the gate). """ cached = _GLOB_RE_CACHE.get(glob) if cached is not None: return cached out: list[str] = [] i, n = 0, len(glob) while i < n: if glob[i : i + 3] == "**/": out.append(r"(?:.*/)?") i += 3 elif glob[i : i + 2] == "**": out.append(r".*") i += 2 elif glob[i] == "*": out.append(r"[^/]*") i += 1 elif glob[i] == "?": out.append(r"[^/]") i += 1 else: out.append(re.escape(glob[i])) i += 1 pat = re.compile("^" + "".join(out) + "$", re.IGNORECASE) _GLOB_RE_CACHE[glob] = pat return pat def denied(path: str) -> bool: """True if ``path`` is on the trust-control denylist (recursive, case-insensitive).""" return any(_glob_to_regex(g).match(path) for g in DENY_GLOBS) def _scope_prefix(entry: str) -> str | None: """Reduce a canonicalized scope entry to a concrete dir/file prefix. Declared scope is *confinement*, not a pattern that may widen coverage. ``fnmatch``-ing scope let a single ``**`` (or ``*``) entry match the whole tree, collapsing boundary 2b to a no-op. Instead we take the leading path segments up to the first glob metacharacter and prefix -match against them (mirrors the box-side ``_in_scope``). A scope that begins with a metacharacter reduces to the empty (repo-root) prefix and is dropped, so it can never widen to everything. """ keep: list[str] = [] for part in entry.split("/"): if any(c in _GLOB_META for c in part): break keep.append(part) prefix = "/".join(keep) return prefix or None def safe_scope(scope: list[str]) -> list[str]: """Canonicalize scope into concrete path prefixes; drop escaping/empty. An absolute or parent-escaping entry is discarded (canonical raises), and a glob that reduces to the repo root is dropped, so a malformed or over-broad scope can only SHRINK what is allowed, never widen it. """ safe: list[str] = [] for g in scope: try: canon = canonical(g) except ValueError: continue prefix = _scope_prefix(canon) if prefix is not None and prefix not in safe: safe.append(prefix) return safe def in_scope(path: str, scope: list[str]) -> bool: """True if ``path`` is at or under one of the declared scope prefixes.""" return any(path == entry or path.startswith(entry + "/") for entry in scope) def main() -> int: diff_path = os.environ["DIFF_PATH"] expected = os.environ["EXPECTED_DIFF_HASH"].strip().lower() scope = [s for s in os.environ.get("DECLARED_SCOPE", "").splitlines() if s.strip()] with open(diff_path, "rb") as fh: raw = fh.read() actual = hashlib.sha256(raw).hexdigest() # Boundary 3: integrity. A tampered/substituted diff fails here. if actual != expected: print(f"::error::diff hash mismatch: expected={expected} actual={actual}") return 2 # Fail CLOSED on a non-UTF-8 diff rather than silently replacing bytes # (errors='replace' could let a homoglyph/encoding trick evade the path # match). A legitimate diff over source is valid UTF-8. try: text = raw.decode("utf-8") except UnicodeDecodeError as exc: print(f"::error::diff is not valid UTF-8 ({exc}); refusing to parse") return 8 touched = parse_touched_paths(text) if not touched: print("::error::no paths parsed from diff; refusing empty/garbled diff") return 3 # Boundary 2a: trust-control denylist (CI-side HARD FAIL). hits = sorted(p for p in touched if denied(p)) if hits: for h in hits: print(f"::error::trust-control-surface violation: {h}") print("::error::diff touches the trust-control surface; escalate to human + GPT cross-review") return 4 # Boundary 2a': symlink escape. A symlink can redirect a later in-diff # write into a denied path that textual matching cannot see, so any # symlink addition is rejected outright. symlinks = find_symlink_additions(text) if symlinks: for path, target in symlinks: print(f"::error::diff introduces a symlink ({path} -> {target}); symlinks can redirect writes into denied paths and are not allowed in an auto-built diff") print("::error::symlink in candidate diff; escalate to human + GPT cross-review") return 7 # Boundary 2b: declared-scope enforcement. if not scope: print("::error::no declared scope provided; refusing unscoped diff") return 5 scope = safe_scope(scope) if not scope: print("::error::declared scope has no valid (non-escaping) entries; refusing diff") return 5 out_of_scope = sorted(p for p in touched if not in_scope(p, scope)) if out_of_scope: for p in out_of_scope: print(f"::error::out-of-declared-scope path: {p}") return 6 gh_out = os.environ.get("GITHUB_OUTPUT") if gh_out: with open(gh_out, "a", encoding="utf-8") as fh: fh.write(f"diff_hash={actual}\n") print(f"diff_hash={actual}") print(f"validated {len(touched)} path(s); all in-scope, none on the trust-control surface") return 0 sys.exit(main()) PY # ─────────────────────────────────────────────────────────────────────────── # JOB 2 — build-test (boundary 1). UNTRUSTED execution. This is the ONLY job # that applies + runs the patch. It is credential-less: contents:read only, no # secrets, no OIDC, no write token, egress blocked. There is nothing here to # steal and nothing to assume. It writes a report artifact consumed by the # privileged gate as DATA — that report is NOT authoritative (boundary 4). # Depends on `guard` so a denied/tampered diff never reaches execution. # ─────────────────────────────────────────────────────────────────────────── build-test: needs: guard runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read steps: - name: Harden runner (block egress — untrusted code runs here) uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 with: # Block, not audit: this is where untrusted patch code executes. A # narrow allowlist for dependency resolution only; everything else is # denied so a prompt-injected patch cannot phone home. # DEPLOY: this allowlist is GitHub + PyPI only. Before enabling this # workflow for a repo, replace/extend it with EXACTLY that repo's # package registries (npm, crates, Go proxy, ...) and nothing more — # an over-broad allowlist weakens the egress boundary. egress-policy: block allowed-endpoints: > github.com:443 api.github.com:443 objects.githubusercontent.com:443 codeload.github.com:443 pypi.org:443 files.pythonhosted.org:443 - name: Checkout base repo (clean ref; patch applied on top after) uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: # Checkout carries NO token into the working tree usable for writes — # this job's permissions are contents:read. persist-credentials:false # guarantees the patch cannot reuse the checkout token. persist-credentials: false - name: Re-download candidate diff (re-validated below) uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: ${{ inputs.diff_artifact_name }} path: ./_incoming - name: Re-verify diff hash before apply (defense-in-depth) env: EXPECTED_DIFF_HASH: ${{ needs.guard.outputs.diff_hash }} DIFF_PATH: ./_incoming/candidate.diff run: | set -euo pipefail # Independently confirm the bytes match the hash `guard` blessed, so a # swapped artifact between jobs cannot slip an unvetted diff into the # apply step. python3 - <<'PY' from __future__ import annotations import hashlib import os import sys def main() -> int: expected = os.environ["EXPECTED_DIFF_HASH"].strip().lower() with open(os.environ["DIFF_PATH"], "rb") as fh: actual = hashlib.sha256(fh.read()).hexdigest() if actual != expected: print(f"::error::pre-apply hash mismatch: expected={expected} actual={actual}") return 1 print(f"diff hash confirmed: {actual}") return 0 sys.exit(main()) PY - name: Apply candidate diff (UNTRUSTED — credential-less sandbox) run: | set -euo pipefail # --check first so a malformed diff fails cleanly; then apply. The # working tree has no write credential, so applying + running it can # touch only this ephemeral runner. git apply --check ./_incoming/candidate.diff git apply ./_incoming/candidate.diff - name: Set up Python uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.12" - name: Install + build + test (untrusted; result is non-authoritative) id: run run: | set -euo pipefail # Placeholder build/test for the narrowest task class (dep bump / # single-file fix). At deploy time this is parameterized per target # repo. Exit code is what matters; any file the patch writes is # ignored by the authoritative gate (boundary 4). if [ -f requirements.txt ]; then python3 -m pip install --quiet -r requirements.txt || true fi python3 -m pip install --quiet ruff pytest || true ruff check . || echo "ruff non-zero (recorded, non-authoritative)" pytest -q || echo "pytest non-zero (recorded, non-authoritative)" - name: Emit non-authoritative report (job conclusion is the truth) if: always() run: | set -euo pipefail # This report is consumed by the gate as DATA for the verifier agent's # next-fix reasoning. It is NOT the pass/fail decision — the gate reads # the AUTHENTICATED job conclusion (boundary 4), never this file. mkdir -p ./_report printf '{"task_id":"%s","note":"non-authoritative; gate uses job conclusion"}\n' \ "${{ inputs.task_id }}" > ./_report/report.json - name: Upload non-authoritative report if: always() uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: build-test-report-${{ inputs.task_id }} path: ./_report/report.json retention-days: 7 # ─────────────────────────────────────────────────────────────────────────── # JOB 3 — gate-and-pr (boundaries 1 + 4 + 5). PRIVILEGED, but it NEVER checks # out or executes patch-controlled code. It reads the AUTHENTICATED conclusion # of `build-test` (via needs.*.result — GitHub-controlled, patch-independent) # keyed to this run, and only on a clean pass opens a DRAFT PR. It never trusts # any artifact the patch wrote. Pass/fail is pure code here, not the LLM. # # NOTE: the OIDC/write grant is declared here as the eventual home of the # privileged step, but this file is deploy-gated — the `id-token`/PR-open # step is left as a documented placeholder so nothing is provisioned until the # §3.3.2 review gates pass. Wiring the real OIDC role is Phase P3 / Phase 5 # AFTER the mandatory GPT-4.1 cross-review of the IAM. # ─────────────────────────────────────────────────────────────────────────── gate-and-pr: needs: [guard, build-test] # `always()` so the gate runs even when build-test failed, to record the # authoritative conclusion. The gate itself decides pass/fail from results. if: always() runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read # pull-requests: write # ← enabled ONLY after the §3.3.2 review gates. # id-token: write # ← OIDC for the Option-B apply role, post-gate. steps: - name: Harden runner (privileged job; block egress) uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 with: egress-policy: block allowed-endpoints: > github.com:443 api.github.com:443 - name: Pure-code pass/fail gate over authenticated results env: # These come from GitHub's job orchestration, NOT from the patch. GUARD_RESULT: ${{ needs.guard.result }} BUILD_TEST_RESULT: ${{ needs.build-test.result }} DIFF_HASH: ${{ needs.guard.outputs.diff_hash }} EXPECTED_DIFF_HASH: ${{ inputs.expected_diff_hash }} RUN_ID: ${{ github.run_id }} run: | set -euo pipefail # Deterministic, patch-independent decision. Consumes ONLY the # authenticated needs.*.result values + the hash binding (all from # GitHub's orchestration, never from a file the patch wrote). Mirrors # secrev's "one pure-code script owns the block decision." The # verifier AGENT only reads failures to propose a fix; it cannot # declare success here. python3 - <<'PY' from __future__ import annotations import os import sys def gate( *, guard_result: str, build_test_result: str, diff_hash: str, expected_hash: str, run_id: str, ) -> tuple[bool, str]: """Return (passed, reason) from authenticated, patch-independent inputs. A pass requires: the guard job succeeded (integrity + denylist + scope all held), the build-test job succeeded, and the hash the guard exported equals the ledger-recorded expected hash bound to this run. Anything else blocks. """ if not run_id: return False, "missing run id; cannot bind decision to a run" if diff_hash.strip().lower() != expected_hash.strip().lower(): return False, f"hash binding broken: guard={diff_hash} expected={expected_hash}" if guard_result != "success": return False, f"guard did not pass: {guard_result!r}" if build_test_result != "success": return False, f"build-test did not pass: {build_test_result!r}" return True, "authenticated build/test passed and diff hash is bound" def main() -> int: passed, reason = gate( guard_result=os.environ.get("GUARD_RESULT", ""), build_test_result=os.environ.get("BUILD_TEST_RESULT", ""), diff_hash=os.environ.get("DIFF_HASH", ""), expected_hash=os.environ.get("EXPECTED_DIFF_HASH", ""), run_id=os.environ.get("RUN_ID", ""), ) if passed: print(f"GATE PASS: {reason}") if (gh_out := os.environ.get("GITHUB_OUTPUT")): with open(gh_out, "a", encoding="utf-8") as fh: fh.write("gate=pass\n") return 0 print(f"::error::GATE BLOCK: {reason}") return 1 sys.exit(main()) PY - name: Open DRAFT PR (DEPLOY-GATED PLACEHOLDER — not enabled) if: ${{ false }} # ← hard-disabled. Enable only after §3.3.2 review gates. run: | echo "Draft-PR open runs here AFTER the mandatory GPT-4.1 cross-review" echo "+ /sh-security-review of this workflow and its OIDC role." echo "Draft PR only; never auto-merge (D2). Branch protection is the" echo "final enforcement (boundary 5)."