From e77ec6514f67ee6ba5343537db1745c990c4e875 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 22 Jun 2026 17:33:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(agent-team):=20P3-flip=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20expand=20denylist=20vectors=20(=C2=A74.2)=20+=20run?= =?UTF-8?q?ner-trust=20assertion=20(=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First controls of the P3-live-flip Phase-1 CI hardening (workflow stays INERT; this only tightens the trust boundary). Whole Phase-1 surface is gated by /sh-security-review + GPT-4.1 cross-review before any flip. §4.2 — expand the trust-control denylist with direct code-execution / supply-chain vectors, kept byte-identical across all three copies (ci_gate.DENYLIST_GLOBS + the guard + post-build inline DENY_GLOBS), drift-guarded: .gitmodules, .husky/**, .githooks/**, .gitattributes, .npmrc, and generated/build artifacts (__generated__, *.generated.*, dist/**, build/**, *.min.js). Deliberate: lockfiles are NOT wholesale denied — lockfile-postinstall RCE is already contained by the credential-less egress-blocked build sandbox, and the Tier-3 dep-CVE fixer rewrites lockfiles to produce its draft PRs; a blanket deny would make it un-shippable. Flagged in-code for the security gate. Direct code-execution config (hooks/filters/npmrc/submodules) is the actual §4.2 RCE surface. §4.1 — runner-trust: assert no job (esp. the privileged gate-and-pr) can run on a self-hosted/user-provided runner; all must be GitHub-hosted. 998 tests pass, ruff clean. --- agent-team/agent_team/ci_gate.py | 32 +++++++++++++++++++ agent-team/ci/agent-team-apply-verify.yml | 30 +++++++++++++++++ .../test_apply_verify_workflow_hardening.py | 25 +++++++++++++++ agent-team/tests/test_ci_gate.py | 25 +++++++++++++++ 4 files changed, 112 insertions(+) diff --git a/agent-team/agent_team/ci_gate.py b/agent-team/agent_team/ci_gate.py index 6b813cc..f6aa8b1 100644 --- a/agent-team/agent_team/ci_gate.py +++ b/agent-team/agent_team/ci_gate.py @@ -129,6 +129,38 @@ DENYLIST_GLOBS: tuple[str, ...] = ( "**/*policy*.json", "**/*.pem", "**/*.key", + # --- P3-flip §4.2: direct code-execution / supply-chain vectors --- + # Submodule pointers / config — a changed submodule pulls in arbitrary + # external code at the pinned commit. + ".gitmodules", + "**/.gitmodules", + # Git hook directories / hook-path redirection — code that runs on git ops. + # (`.git/hooks` itself is never in a checkout; `.husky` / `.githooks` are the + # in-tree hook dirs a repo points `core.hooksPath` at.) + ".husky/**", + "**/.husky/**", + ".githooks/**", + "**/.githooks/**", + # .gitattributes — clean/smudge filters execute arbitrary processes on checkout. + ".gitattributes", + "**/.gitattributes", + # npm/registry config — can inject install scripts, a hostile registry, or auth. + ".npmrc", + "**/.npmrc", + # Generated / build artifacts — codegen output is not reviewable as source, so + # a diff that writes into it is escalated to a human rather than auto-built. + "**/__generated__/**", + "**/*.generated.*", + "**/dist/**", + "**/build/**", + "**/*.min.js", + # NOTE (deliberate, for the security gate): lockfiles are NOT wholesale denied + # here. Lockfile-postinstall RCE is already contained by the credential-less, + # egress-blocked build-test sandbox, and the Tier-3 dep-CVE fixer legitimately + # rewrites lockfiles to produce its draft PRs — a wholesale lockfile deny would + # make the fixer un-shippable. The direct code-execution config above (hooks, + # filters, .npmrc, submodules) is the actual §4.2 RCE surface. Revisit if the + # fixer's lockfile writes ever need a scoped allow vs a general deny. ) # Authenticated GitHub run conclusions that count as a recognised failure (the diff --git a/agent-team/ci/agent-team-apply-verify.yml b/agent-team/ci/agent-team-apply-verify.yml index 21589fd..40c000d 100644 --- a/agent-team/ci/agent-team-apply-verify.yml +++ b/agent-team/ci/agent-team-apply-verify.yml @@ -190,6 +190,21 @@ jobs: "**/*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: @@ -629,6 +644,21 @@ jobs: "**/*policy*.json", "**/*.pem", "**/*.key", + ".gitmodules", + "**/.gitmodules", + ".husky/**", + "**/.husky/**", + ".githooks/**", + "**/.githooks/**", + ".gitattributes", + "**/.gitattributes", + ".npmrc", + "**/.npmrc", + "**/__generated__/**", + "**/*.generated.*", + "**/dist/**", + "**/build/**", + "**/*.min.js", ) _GLOB_META = set("*?[]") diff --git a/agent-team/tests/test_apply_verify_workflow_hardening.py b/agent-team/tests/test_apply_verify_workflow_hardening.py index d873d01..4fc08bb 100644 --- a/agent-team/tests/test_apply_verify_workflow_hardening.py +++ b/agent-team/tests/test_apply_verify_workflow_hardening.py @@ -320,6 +320,31 @@ def test_three_trust_control_denylists_are_identical() -> None: ) +# --------------------------------------------------------------------------- # +# §4.1 runner-trust: no job may run on a self-hosted / user-provided runner +# --------------------------------------------------------------------------- # + + +def test_no_job_uses_a_self_hosted_runner() -> None: + """§4.1: every job — ESPECIALLY the privileged gate-and-pr — must run on a + GitHub-hosted runner. A self-hosted/user-provided runner can be + attacker-influenced and must never be able to pick up the privileged job.""" + github_hosted_prefixes = ("ubuntu-", "windows-", "macos-") + jobs = _doc()["jobs"] + assert jobs, "workflow defines no jobs" + for name, job in jobs.items(): + runs_on = job.get("runs-on") + labels = [runs_on] if isinstance(runs_on, str) else list(runs_on or []) + assert labels, f"job {name!r} has no runs-on" + assert "self-hosted" not in labels, ( + f"job {name!r} must not run on a self-hosted runner" + ) + assert all( + any(label.startswith(p) for p in github_hosted_prefixes) + for label in labels + ), f"job {name!r} runs-on must be GitHub-hosted, got {labels!r}" + + # --------------------------------------------------------------------------- # # FIX-2 / INJ-02: robust NUL-delimited post-build denied-path parsing # --------------------------------------------------------------------------- # diff --git a/agent-team/tests/test_ci_gate.py b/agent-team/tests/test_ci_gate.py index cdce4e9..41f8e00 100644 --- a/agent-team/tests/test_ci_gate.py +++ b/agent-team/tests/test_ci_gate.py @@ -93,6 +93,21 @@ def test_clean_diff_has_no_violations() -> None: "deploy/iam/role.json", "stacks/policies/admin.json", "modules/main.tf", + # P3-flip §4.2 — direct code-execution / supply-chain vectors. + ".gitmodules", + "vendor/.gitmodules", + ".husky/pre-commit", + "frontend/.husky/commit-msg", + ".githooks/pre-push", + ".gitattributes", + "pkg/.gitattributes", + ".npmrc", + "web/.npmrc", + "src/__generated__/schema.ts", + "api/types.generated.ts", + "web/dist/bundle.js", + "service/build/output.o", + "assets/app.min.js", ], ) def test_denylisted_paths_flagged(path: str) -> None: @@ -100,6 +115,16 @@ def test_denylisted_paths_flagged(path: str) -> None: assert violations, f"expected {path!r} to be denylisted" +def test_lockfiles_are_NOT_denylisted_so_the_tier3_fixer_can_ship() -> None: + """Deliberate (§4.2 note): lockfile RCE is contained by the credential-less + egress-blocked build sandbox, and the dep-CVE fixer rewrites lockfiles to + produce draft PRs — so lockfiles are intentionally NOT on the denylist.""" + for lock in ("package-lock.json", "web/yarn.lock", "poetry.lock", "Cargo.lock"): + assert denylist_violations(_diff_for(lock)) == [], ( + f"{lock!r} must not be denylisted (would break the Tier-3 fixer)" + ) + + def test_rename_into_workflow_is_flagged() -> None: diff = ( "diff --git a/safe.txt b/.github/workflows/evil.yml\n"