ADR-084: Outbound-call chokepoint — shared toolkit for external invocations
Status: Accepted Date: 2026-04-24 Related: ADR-083 (rule promotion engine — same defense-in-depth lineage), ADR-072 (infrastructure as playbooks — uses the same ansible/ssh boundary), finding #021 (admin-merges.log contract drift — motivating incident)
Decision (one paragraph)
Every side-effecting call to an external system — gh, git, ssh, curl, ansible, docker, psql, gcloud — flows through a shared toolkit script in devtools/ before reaching the real binary. The toolkit is two layers: a thin per-tool wrapper (e.g. github.sh) that normalises auth / retry / timeout / exit-code / logging, and higher-level operation functions built on top (e.g. github.sh pr admin-merge) that encode Gyrum-specific conventions (audit-log emission, persona-approval thresholds, hard-wrap detection in PR bodies, etc.). Internal callers — every bash script in devtools, every Claude Code agent brief, every operator workflow — invoke the toolkit, not the raw tool. Bypass requires editing the toolkit itself, which reviewers see.
Context
The drift classes we keep fixing
Today's session alone had three recurring failure modes that bare-tool invocations enable:
Audit-log contract drift.
complete-pr.shwrote 8 columns to~/.gyrum/admin-merges.log;gyrum-agent-report's verifier expected a 9th column and substring-matched to cover the drift. Finding #021 traced it to two callsites owning independent format strings. Structural fix (devtools #98 / v0.1.66) added the 9th column in one place, but the root cause is "N call sites, N format implementations". A chokepoint collapses them into one.Hard-wrap commit message bodies. User feedback on 2026-04-24: PR descriptions carry visible mid-sentence line breaks because commit message bodies are hard-wrapped at 72 cols and GitHub reflows the whole thing. Every commit I author has the drift; every PR body
gyrum-complete-prsyncs inherits it. Agithub.sh commitfunction with an unwrap filter solves it structurally — the rule becomes a property of the tool, not a property of my prompt.Flag drift across
ghcallsites.gyrum-complete-pr,gyrum-review-pr,gyrum-start-work,gyrum-agent-reporteach callghwith slightly different opinions about--draft,--label,--body-file,--jsonfield selection. When GitHub changes a flag semantic (e.g.gh pr mergedefault strategy), each callsite updates independently. A shared wrapper updates once.
Why chokepoints beat conventions
Per ADR-083's defense-in-depth framing, enforcement sits on a gradient: prose rule < structural check < structural default. A shared toolkit turns an "always invoke the tool this way" convention into a default that the build won't let you bypass — every non-toolkit gh call in scripts/ fails a structural gate. This is the same philosophy as ADR-083's deny-list becoming a build-time check: make the right thing the only thing, not the recommended thing.
What exists today
The pattern is already partially present. gyrum-catalog wraps SSH targets with a typed Target struct and a runner.Run() entry point — every catalog consumer shells through that runner, not through raw ssh. The lesson from ADR-064 is that a wrapper earns its place when it enforces something (Target validation, port typing, deny-listed host checks) rather than merely passing args through. This ADR generalises that lesson to every other external tool we touch.
Two-layer shape
Layer 1 — thin per-tool wrappers
One wrapper per external tool, each ~100 lines of bash (or Go where the surface area justifies it). Responsibilities:
- Normalise env (inherit
GH_TOKENfrom~/.config/gyrum/control-plane.env, refuse to log it) - Retry transient failures (rate-limit 429, connection reset) with capped exponential backoff
- Enforce a timeout (default 30s, configurable per call)
- Normalise exit codes (distinguish "tool failed" from "tool not installed")
- Emit structured logs to
~/.gyrum/outbound.jsonl— one line per call with tool / args / exit / duration — for post-hoc debugging and rate-limit analysis
The wrapper does not encode policy. github.sh gh-raw pr view 199 is still a valid escape hatch for one-off exploration; it gives you the env+retry+logging+timeout envelope but passes through whatever you asked for.
Layer 2 — high-level operation functions
Built on Layer 1. Each function encodes a Gyrum convention that we've paid for once by getting it wrong:
github.sh pr create— refuses hard-wrapped body, refuses a missing--repoflag, always opens as draftgithub.sh pr merge— verifies 2/3 persona approvals, refuses--squash, writes tag after mergegithub.sh pr admin-merge— requires--reason, writes 9-column audit row, refuses when a structural blocker remainsgithub.sh pr comment— unwraps body paragraphs before postinggithub.sh pr review-post— same unwrap, attached to the persona-comment pipeline
Callers import Layer 2 only; Layer 1 is exec'd transparently from inside it. Layer 2 is where the chokepoint enforcement lives, and where the tests live.
Consequences
Positive
- Every rule becomes code-and-forget once it's in a Layer 2 function. Structural-replies beat prose rules — this ADR applies that principle to outbound I/O specifically.
- One place to add observability (the
~/.gyrum/outbound.jsonljournal) without touching N callsites. - Agent briefs that forget the rule still produce correct output, because the agent's
ghcall goes through the wrapper on PATH. - Refactoring a tool flag change is a one-line PR, not N-site grep-and-replace.
Negative / risks
- Over-abstraction: a wrapper that adds ceremony without enforcing anything is reviewer noise that will be deleted. Every function must earn its place by enforcing a rule or removing a duplication. When in doubt, don't wrap.
- Test flakiness: mocking
ghvia PATH shims is bash-fiddly; tests that spin up fake binaries are harder to reason about than pure unit tests. Mitigation — where surface area justifies Go (e.g.github.shhas ~50 functions), rewrite Layer 1 in Go with structured testability; keep bash for the thin tools. - Agent sub-process leak: Claude Code's Agent tool spawns sub-processes that inherit the user's
PATH. If a wrapper script isn't on that PATH ahead of the real binary, the agent bypasses it silently. Mitigation — Phase 5 wiresgyrum-setupto prepend~/.gyrum/devtools/bin/to agent-spawn PATHs. - Bypass temptation: operators frustrated by a slow gate will
command ghorghdirectly with full paths. Mitigation — the structural gate (Phase 3) greps for bareghin committed bash, so bypass shows up in review.
Phases
Strangler rollout — no big-bang.
Phase 1 — ship github.sh (Layer 2 subset)
Functions: pr-create, pr-merge, pr-admin-merge, pr-view, pr-comment, pr-review-post. TDD'd with PATH-shim tests (fake gh that records args). Lives at devtools/github.sh. Adds gyrum-gh CLI wrapper to ~/.local/bin/. Does not force migration — callsites stay as-is.
Phase 2 — migrate callsites
One PR per callsite family. Converts complete-pr.sh, review-pr.sh, start-work.sh, agent-report.sh, create-playbook.sh, workspace.sh to call github.sh functions instead of raw gh. Each PR ~50-100 lines of diff; gates unchanged; behaviour unchanged.
Phase 3 — structural gate
A pre-push check (or gyrum-review-pr structural gate) greps every bash file under scripts/ in every gyrum repo for bare gh or gh$. Refuses with a pointer to the nearest github.sh function. Bypass requires adding an inline exception comment with a reason, which reviewers see.
Phase 4 — extend to other tools
git.sh (with unwrap-filter on commit message bodies, exhaustive test coverage on the diff-size check). ssh.sh (target validation via gyrum-catalog). ansible.sh (inventory resolution via gyrum-catalog). curl.sh (for non-GitHub HTTP calls — structured logging mostly). psql.sh (query-template lint, read-only / write-only mode flag).
Phase 5 — agent PATH injection
gyrum-setup writes export PATH="$HOME/.gyrum/devtools/bin:$PATH" into the shell profile so agent sub-processes inherit the wrapper-first PATH. Combined with Phase 3's grep, this closes the sub-process leak without prose rules in every agent brief.
Open questions
- Language choice for Layer 1. Bash is the zero-install default; Go gives real testability and a compilation gate. Hybrid plausible — bash tools stay bash,
github.shandgit.shget Go rewrites once they exceed ~200 lines. Decide per-tool when the wrapper lands. - Observability destination.
~/.gyrum/outbound.jsonlis local-first; eventually we want a fleet view. Warp has a journal already (agent-reports.jsonl); consider folding outbound calls into the same journal rather than running a parallel log. - Gyrum Agent tool integration. This ADR cannot force Claude Code's Agent tool to route through the wrapper — the tool spawns processes with the user's PATH. Phase 5 is our best leverage, but a persistent agent bypass (e.g. absolute-path
/usr/bin/gh) remains possible. Accept this risk — it's out of scope for the toolkit and falls under "agent briefs should name the wrapper". - External-call categories outside scope. Database queries via ORMs, HTTP calls from backend Go services, and script-level
find/awk/jqare explicitly out of scope. The chokepoint pattern applies to externally-visible side-effects (GitHub state, host mutations, deploys) — not to every subprocess invocation.