ADR-2008: Generic gate-predicate evaluator
August 3, 2026 · View on GitHub
| Status | Accepted |
| Date | 2026-07-04 |
| Issue | #2008 — No generic evaluator for third-party capability gates |
| Supersedes | — |
| Amends | ADR-0894 (capability declaration format — check.predicate evaluation path) |
Context
Capability gates are declared in capability.json under gates[].check. The
registry validator (capability-validator.cjs:validateGate) recognises three
mutually-exclusive check shapes — query, predicate, and agentVerdict —
and the loop-resolver (loop-resolver.cts:renderLoopHooks) renders all active
gates to the workflow gate-dispatch sites.
Prior to this ADR, only check.query was enforced: every workflow
gate-dispatch site ran gsd_run check ${hook.check.query}, which routes through
the fixed if-chain in check-command-router.cts:routeCheckCommand. A
check.predicate (e.g. the security capability's
artifact-frontmatter-equals declaration) was declaration-only — rendered
for display, never evaluated. The security capability's threats_open == 0
enforcement worked only because ship.md hard-codes a capId == "security"
prose branch that reads SECURITY.md frontmatter directly. There was no
extension path for a third-party capability's gate to actually fire.
Issue #2008 asked for a generic, data-driven gate-evaluation path. Two candidate directions were proposed:
- A generic
check.predicateevaluator coveringartifact-frontmatter-equalsand future kinds. - A documented, sandboxed
command-exit-zerogate kind — "run<cmd>, block on non-zero exit" — matching the git-hook / CI-runner enforcement shape.
The maintainer chose Option 2 (issue comment, 2026-07-04).
Decision
Add a generic gate-predicate evaluation path with two built-in kinds,
command-exit-zero and artifact-frontmatter-equals, scoped as follows.
Declaration shape
A capability declares a command-exit-zero gate under the existing
check.predicate block (already a first-class, validator-accepted shape):
"gates": [
{
"point": "ship:pre",
"check": {
"predicate": {
"kind": "command-exit-zero",
"command": "node scripts/check.sh \"${PHASE_DIR}\"",
"timeout": 30
}
},
"when": "my_cap.enabled",
"blocking": true,
"onError": "halt"
}
]
predicate.command is required (non-empty string, ≤ 4096 chars).
predicate.timeout is optional (positive finite number, seconds; default 30).
Sandbox contract (the security core of this ADR)
| Axis | Value | Rationale |
|---|---|---|
| Interpreter | sh -c (via shell-command-projection.execTool) | Cross-platform with the runtime's existing bash dependency; one string, no argv array to author |
| cwd | Project root (the runtime cwd) | Matches the user's working context; the same root existing check.query gates operate from |
| Environment | Inherit process env | The command runs as the user, on the user's machine, in the project they are working on — no sandbox boundary is crossed vs. the user's own shell. Override via the command itself (env VAR=x ...) |
| Timeout | Default 30s; overridable per-gate | Bounded execution is non-negotiable; an unbounded gate could hang the loop forever |
| Interpolation | ${PHASE_NUMBER}, ${PHASE_DIR}, ${PHASE_REQ_IDS} substituted from gate context; undefined → ''; all other ${X} left untouched for sh to interpret | Parity with the context existing check.query gates already receive |
| Result mapping | exit 0 → block:false; non-zero → block:true; timeout (SIGTERM) → block:true (timed_out); sh missing (ENOENT, exit 127) → block:true | Fail-closed: every non-zero outcome blocks. A blocking gate with block:true halts per the existing two-step gate contract |
| Output cap | stderr/stdout tail embedded in message trimmed to 2000 chars | Keeps the GATE_RESULT payload context-bounded |
Evaluation path
- A new pure leaf module
src/gate-predicate-evaluator.ctsownsevaluatePredicate(predicate, context, deps). It is fully deps-injected (the subprocess seam isrunBoundedShell) — no fs, no child_process, no config — so it is trivially unit-testable without spawning. AKIND_TABLEdispatches bypredicate.kind; adding a future kind is a one-line registration. check-command-router.ctsgains apredicatesubcommand (gsd_run check predicate --predicate '<json>' [--phase-dir …] [--phase-number …] [--phase-req-ids …] --raw). It parses flags, builds the production deps (wrappingexecTool), callsevaluatePredicate, and emits the standard{ block, message, details? }envelope viaoutput().- The three generic workflow gate-dispatch sites —
execute:wave:post,execute:post,plan:post— now branch on the gate'scheckshape:check.query→ existinggsd_run check <query>path;check.predicate→gsd_run check predicate. The two-step Step-1 (command-failure →onError) / Step-2 (block→ halt) contract is unchanged — the predicate path emits the same envelope and the same check-command-failure semantics.
Fail-closed mapping for malformed predicates
evaluatePredicate throws for a malformed predicate (missing/non-string
command, non-positive timeout, oversized command, unknown kind). The CLI
wrapper maps a throw to error() (non-zero exit), which the workflow treats as
a Step-1 command failure — routed per the gate's onError (halt or
skip). This deliberately does not conflate an evaluator bug with a
legitimate gate-block decision: a recognised predicate returns
{ block, … }; an unrecognised one fails the check command.
Trust model
Capabilities are opt-in installs (like npm packages): installing one already
trusts it to ship skills, agents, and hooks that run arbitrary code.
command-exit-zero is therefore not a new trust boundary — it is another
code-execution path for already-trusted capabilities. Security does not rely on
secrecy (Kerckhoffs): the command is declared in plain JSON, and safety comes
from bounded timeout + fail-closed mapping + the opt-in install, not from
hiding the mechanism.
Consequences
- Positive: Third-party capability gates now actually fire. The path is
generic; future predicate kinds (e.g. a revival of
artifact-frontmatter-equalsto retire the hard-codedship.mdsecurity branch) register inKIND_TABLEwithout further workflow changes. - Positive: The evaluator is a pure leaf with injected I/O, matching the ADR-857 module decomposition and the repo's test conventions.
- Negative / documented limitation: A command that backgrounds a child
(
sleep 100 &) can outlive the timeout kill of its directshparent —execToolkills the direct child on SIGTERM, not the whole process group. This is the same property the existingcheck.querygates have (they too can spawn long-running subprocesses). Full process-group kill is a future hardening, out of scope here; the threat is a malicious capability, which is already trusted. - Negative:
shmust be present. On Windows without a POSIX shell (git-bash / WSL),command-exit-zerogates fail-closed with exit 127. This matches the runtime's existing bash dependency for workflows.
Out of scope
- Retiring the hard-coded
securitybranch inship.md. check.agentVerdictevaluation (advisory,blocking:false-forced, separate concern).- Process-group kill on timeout.
References
- Issue: #2008
- Parent bundle: #2004
- ADR-0894 (capability declaration format)
- ADR-0857 (capability system / module decomposition)
src/gate-predicate-evaluator.cts,src/check-command-router.cts- Diátaxis docs:
docs/reference/gate-predicates.md,docs/how-to/command-exit-zero-gate.md