ADR-0001: Add docker, strace, and security/SBOM CLIs to exec.ALLOWED_BINARIES¶
Status: Accepted Date: 2026-05-14 Tags: registry · tool-use · security · allowlist · supply-chain · localv2-conformance Related: Phase 0 ADR-0006, Phase 1 ADR-0001, 02-ADR-0002, 02-ADR-0005, production ADR-0005
Context¶
Phase 2 lands Layer B/C/G probes that fundamentally cannot be implemented in-process: scip-typescript is a Node binary that emits a binary SCIP index; syft and grype are Go binaries that build SBOMs and CVE diffs against built images; semgrep runs rule packs over the source tree; gitleaks walks .git/ history for credential patterns; docker builds the analyzed-repo's container and strace (Linux) attaches to it for RuntimeTraceProbe. Each is named by localv2.md §5.2–5.6 as the canonical tool for its layer (phase-arch-design.md §"Tool-readiness extended"; final-design.md §"Resource & cost profile").
Phase 0 froze codegenie.exec.run_allowlisted as the subprocess chokepoint and ALLOWED_BINARIES as the auditable list of binaries any probe may invoke (Phase 0 §"Chokepoints"). The list grows by addition: Phase 1 added node for the --version cross-check via Phase 1 ADR-0001, and that precedent is now invoked seven more times. The synthesis (final-design.md §"External CLI runtime additions to ALLOWED_BINARIES") explicitly enumerates the set; the architect (phase-arch-design.md §"Path to production end state" row 1) names this as ADR-worthy because each addition is a new structural attack surface.
The decision is not whether to add these binaries — every input lens agrees we must — but how: one omnibus ADR that records the policy and lists the additions, vs. seven separate ADRs (one per binary), vs. silent expansion of ALLOWED_BINARIES with a code comment. The critic flagged silent expansion as the exact "Phase 0 chokepoint edit" Phase 0 said it would refuse without governance.
Options considered¶
- Option A — silent extension of
ALLOWED_BINARIES. Drop the names into the constant; rely on PR review to catch new entries. Pattern: none — anti-pattern (registry-without-record). Fast to land; the audit trail is git-blame; the policy ("when is a new binary acceptable?") is undocumented and grows by accretion. - Option B — one ADR per binary. Mirror Phase 1 ADR-0001's shape —
0001-add-docker.md,0002-add-strace.md, … Maximum traceability per binary; eight ADRs in one phase is ceremony given the additions share a single rationale (Layer B/C/G probes need them) and a single set of mitigations. - Option C — one omnibus ADR for the Phase 2 batch + the policy ("a binary added to
ALLOWED_BINARIESrequires an ADR; Phase-2 batch counts as one because all entries are Layer B/C/G probe requirements named inlocalv2.md"). Pattern: Registry pattern —ALLOWED_BINARIESis the kernel-side registry; this ADR is its load-time governance gate.
Decision¶
Adopt Option C — one Phase-2 omnibus ADR. exec.ALLOWED_BINARIES is extended additively with ten new entries: docker, strace, semgrep, syft, grype, gitleaks, scip-typescript, tree-sitter, ast-grep, ripgrep. Each entry is justified by exactly one Layer B/C/G probe named in localv2.md §5.2–5.6; each entry's binary runs only through Phase 0's run_allowlisted chokepoint, wrapped by Phase 2's run_external_cli for Layer B/G or invoked directly with explicit hardening flags for Layer C (per 02-ADR-0003's scheduling layer and 02-ADR-0005's persistence policy). Pattern: Registry — kernel-side allowlist as a data-driven extension primitive. Future binaries land via the same pattern: one ADR per phase batch, additions justified by named-trigger probes, the chokepoint surface (run_allowlisted's signature) untouched.
Amendment (2026-05-15, S1-06 AC-10): the original Decision named only the named-trigger binaries (docker, strace, semgrep, syft, grype, gitleaks, scip-typescript, tree-sitter). phase-arch-design.md §"Component design" #3 run_external_cli and final-design.md §"Components" §3 _run_external_cli both enumerate ast-grep and ripgrep as Layer B/G CLIs routed through run_external_cli. The two are added under the same governance rationale and are pinned to named-trigger probes:
ast-grep— named-trigger probelocalv2.md §5.6 G2: structural-pattern Layer G scanner over a tree-sitter grammar. Hardening flags constructed at the call site; no shell expansion; output is value-typed JSON.ripgrep— named-trigger probelocalv2.md §5.6 G3: curated literal-pattern Layer G scanner; also the default fallback path forExternalDocsIndexProbe(D9) perfinal-design.md §"Tradeoffs"row 23 (tantivyis opt-in only). Hardening flags constructed at the call site; output is line-by-line UTF-8.
The total Phase-2 addition is therefore ten entries, taking ALLOWED_BINARIES from Phase 1's {"git", "node"} (size 2) to the Phase-2-end closed set of size 12. All other paragraphs of this ADR (Pattern fit, Consequences, Reversibility, Evidence/sources) apply to the ten-entry set unchanged.
Correction (2026-05-19, phase-shakedown F-06): the 2026-05-15 amendment named ripgrep — the package name (homebrew / apt). Every other entry in ALLOWED_BINARIES is a binary name (the value matched against argv[0] by run_allowlisted). ripgrep installs the binary rg; the curated-pattern probe (localv2.md §5.6 G3) invokes rg and was failing DisallowedSubprocessError on every smoke run because "ripgrep" != "rg". Allowlist entry corrected to rg; the named-trigger probe and governance rationale are unchanged. This is a name-correction, not a new admission — the size-12 / size-16 closed-set claims stand.
Tradeoffs¶
| Gain | Cost |
|---|---|
| Audit-trail is one document instead of eight; the policy ("when is a new binary acceptable?") is stated once, in one place | Per-binary justification is terser than Phase 1 ADR-0001's shape; a future reader looking up "why gitleaks" finds it in a table row, not in a dedicated ADR |
Phase 0's chokepoint surface (run_allowlisted signature, ALLOWED_BINARIES location) survives unchanged — extension by addition (commitment §2.5) |
Ten new CVE feeds to follow (docker, syft, grype, gitleaks, semgrep, ast-grep, ripgrep, scip-typescript, tree-sitter, strace); pip-audit does not cover non-PyPI binaries — host-hygiene concern delegated to OS package managers |
Each binary is invoked with explicit hardening flags constructed at the call site (docker run --network=none --cap-drop=ALL --security-opt=no-new-privileges; semgrep --metrics=off; gitleaks --no-banner) — the chokepoint stays a value-typed argv, no string interpolation |
Hardening flags are author-supplied at the call site; a probe author who forgets --network=none ships a less-isolated tool. Mitigated by tests/adv/phase02/test_adversarial_dockerfile.py exercising the cap-drop path |
The "must be invoked via run_allowlisted or run_external_cli" rule is structurally enforceable — Phase 0's forbidden-patterns pre-commit can be extended to ban direct subprocess.run / asyncio.create_subprocess_exec repo-wide if it isn't already |
The forbidden-patterns net for Phase 2 grows; one more category to maintain |
| Cost stays at $0/run — these tools have local-binary deployment shapes; no SaaS, no API keys, no token cost | Wall-clock cost is real: semgrep ~200 MB RSS + 8 s on a 5k-file repo; docker build 30–60 s cold; strace -f adds ~5–10 % overhead to traced binaries |
Layer C tools (docker, strace) do not route through run_external_cli — they use run_allowlisted directly with hardening flags inline (final-design §3 tradeoffs accepted) |
Two subprocess pathways exist in Phase 2 by design: run_external_cli for B/G families, run_allowlisted("docker", …) for C. Auditing "where do we shell out" is now grep "run_external_cli\|run_allowlisted" — two patterns, not one |
Pattern fit¶
Pattern: Registry — kernel-side allowlist as a data-driven extension primitive (design-patterns-toolkit.md §"Registry pattern"). The toolkit's prescription is "a registry is a dict; the decorator is def register(name): …. Stay that simple." ALLOWED_BINARIES is the simplest possible shape — a frozenset[str] — and this ADR is its governance gate. The pattern's failure mode the toolkit warns against ("a registry that does more than registration — eager validation, side effects, cross-references at registration time") is avoided: the binaries are validated on use (Phase 0 tool-readiness check at CLI startup), not at registration time. The omnibus-ADR shape is the "register at import time" analogue for governance — one record per phase batch, not per entry.
Consequences¶
src/codegenie/exec.pyline listingALLOWED_BINARIESgrows by the ten named entries. The constant remains a literal frozenset; no dynamic discovery, no env-var overrides.- The CLI tool-readiness check (
src/codegenie/cli.py) gains ten new entries with the install commands fromlocalv2.md §6printed when a mandatory tool is missing. Optional tools (straceon macOS,bubblewrapon any host) print a one-line startup warning and continue. run_external_cli(Phase 2 new module function; see 02-ADR-0003 for its place in the registry/scheduling story) wrapsrun_allowlistedfor the seven Layer B/G binaries;RuntimeTraceProbecallsrun_allowlisted("docker", …)directly with the documented hardening flags.pyproject.toml's runtime closure (per Phase 0 ADR-0006) gains thepy-tree-sitternamed-trigger dep — originally 02-ADR-0002, supplemented 2026-05-17 by 02-ADR-0011 which adds the per-language grammar wheels (tree-sitter-typescript,tree-sitter-javascript). The other seven binaries ship as system tools, not as PyPI packages;gitleaks-pythonandscip-python(bindings beyond whatpy-tree-sitterprovides) remain explicitly rejected perfinal-design.md "Resource & cost profile". Phase 8+ language additions (tree-sitter-python,tree-sitter-java) are additive per 02-ADR-0011 — one dep line per language, no further ADR amendment required so long as the named-trigger discipline holds.- A future binary addition triggers an ADR amendment to this one OR a new phase-level ADR following the same shape: name the named-trigger probe in
localv2.md, list the hardening flags, accept the CVE-feed cost. No silent additions. dockeradded toALLOWED_BINARIESis the named upgrade door for production ADR-0012 — Phase 5's microVM substitution is "amend the probe module, not the chokepoint signature." The forward path is preserved without speculative_run_in_containerindirection (refused perfinal-design.md §"Patterns rejected" #3).bwrap/bubblewrapis intentionally NOT inALLOWED_BINARIES(S1-06 AC-10/AC-15 amendment).run_external_cli(S1-07) invokesbwrapfrom insidesrc/codegenie/exec.pyitself as a hardening wrapper overargv, not as a probe-callable tool. The structural defenses (_filter_envenv-by-omission, no shell,tests/adv/test_no_shell_true.pysingle-file invariant) all apply because the invocation lives inside the chokepoint module. The closed-set guard intests/unit/test_exec.py::test_allowed_binaries_closed_set_regressionpinsbwrapandbubblewrapto NOT be inALLOWED_BINARIES. This records the wrapper-pattern exception as a load-bearing decision so the policy survives any future refactor.
Reversibility¶
High. Each entry is a one-line edit to a frozenset and a deletion of the probe that uses it. The CVE-feed-watching commitment is operational hygiene, not code; dropping a binary is dropping its tool-readiness check and the probe call site. The chokepoint surface is untouched, so no consumer-side reshaping is needed if (e.g.) gitleaks is replaced by trufflehog in a later phase — that's a one-row addition to this ADR's table and a probe rewrite, not a contract change.
Evidence / sources¶
../final-design.md §"External CLI runtime additions to ALLOWED_BINARIES"— the enumerated list and itslocalv2.md §6install commands../final-design.md §"Components" §3 _run_external_cli— chokepoint composition rationale../phase-arch-design.md §"Component design" §3 run_external_cliand§"Component summary table"rows 5, 6../phase-arch-design.md §"Path to production end state" row 1— the named ADR slot this fills../critique.md §"Attacks on the security-first design" §"Hidden assumptions" #1—bubblewrapavailability framing applied here as "binary is hardening, not contract"- Phase 0 ADR-0006 — the
gatherextras shape this composes with - Phase 1 ADR-0001 — the precedent for ADR-gated
ALLOWED_BINARIESadditions - Production ADR-0005 — composes with: every binary added here is deterministic, no LLM cost
- Production ADR-0012 — Phase 5 microVM is the named upgrade door for
docker's call sites