feat(#2008): generic command-exit-zero gate-predicate evaluator (#2011)

* feat(#2008): add generic command-exit-zero gate-predicate evaluator

Third-party capability gates declared via check.predicate were rendered for
display but never evaluated (only built-in check.query gates fired; the
security capability's gate worked solely via a hard-coded ship.md branch).

Add a generic, deps-injected gate-predicate evaluator (src/gate-predicate-evaluator.cts)
that dispatches by predicate.kind. Built-in kind: command-exit-zero — runs a
bounded sh -c command at the project root (via shell-command-projection.execTool),
inherits env, exit 0 => pass, non-zero => block, timeout => block, fail-closed.

Wire a 'check predicate' subcommand into check-command-router.cts and extend
the three generic workflow gate-dispatch sites (execute:wave:post, execute:post,
plan:post) to route check.predicate gates to the new evaluator. The two-step
gate contract (command-failure => onError; block => halt) is unchanged.

- src/gate-predicate-evaluator.cts: pure leaf, KIND_TABLE extensible
- src/check-command-router.cts: cmdCheckPredicate + buildPredicateDeps + parsePredicateFlags
- docs/adr/2008-*, docs/reference/gate-predicates.md, docs/how-to/command-exit-zero-gate.md
- tests: 38 unit + integration tests (exit mapping, timeout, interpolation,
  property-based bijection, malformed-predicate fail-closed, real subprocess e2e)

Closes #2008

* docs(#2008): backfill changeset pr number 2011
This commit is contained in:
Tom Boucher
2026-07-05 14:04:29 -04:00
committed by GitHub
parent 97730e59a1
commit 8de2ff9121
31 changed files with 1238 additions and 38 deletions

View File

@@ -337,6 +337,7 @@
"federated-config.cjs",
"frontmatter.cjs",
"gap-checker.cjs",
"gate-predicate-evaluator.cjs",
"git-base-branch.cjs",
"graphify-command-router.cjs",
"graphify.cjs",

View File

@@ -0,0 +1,158 @@
# ADR-2008: Generic gate-predicate evaluator (`command-exit-zero`)
| | |
|---|---|
| **Status** | Accepted |
| **Date** | 2026-07-04 |
| **Issue** | [#2008 — No generic evaluator for third-party capability gates](https://github.com/open-gsd/gsd-core/issues/2008) |
| **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:
1. A generic `check.predicate` evaluator covering `artifact-frontmatter-equals`
and future kinds.
2. A documented, sandboxed **`command-exit-zero`** gate 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 one built-in kind,
`command-exit-zero`, 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):
```json
"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.cts` owns
`evaluatePredicate(predicate, context, deps)`. It is fully deps-injected (the
subprocess seam is `runBoundedShell`) — no fs, no child_process, no config —
so it is trivially unit-testable without spawning. A `KIND_TABLE` dispatches
by `predicate.kind`; adding a future kind is a one-line registration.
- `check-command-router.cts` gains a `predicate` subcommand
(`gsd_run check predicate --predicate '<json>' [--phase-dir …] [--phase-number …]
[--phase-req-ids …] --raw`). It parses flags, builds the production deps
(wrapping `execTool`), calls `evaluatePredicate`, and emits the standard
`{ block, message, details? }` envelope via `output()`.
- The three generic workflow gate-dispatch sites — `execute:wave:post`,
`execute:post`, `plan:post` — now branch on the gate's `check` shape:
`check.query` → existing `gsd_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-equals`
to retire the hard-coded `ship.md` security branch) register in `KIND_TABLE`
without 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 direct `sh` parent —
`execTool` kills the direct child on SIGTERM, not the whole process group.
This is the same property the existing `check.query` gates 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:** `sh` must be present. On Windows without a POSIX shell
(git-bash / WSL), `command-exit-zero` gates fail-closed with exit 127. This
matches the runtime's existing bash dependency for workflows.
## Out of scope
- Implementing the `artifact-frontmatter-equals` kind (the maintainer chose
Option 2 over Option 1; the kind table is structured for it but it is not
registered).
- Retiring the hard-coded `security` branch in `ship.md`.
- `check.agentVerdict` evaluation (advisory, `blocking:false`-forced, separate
concern).
- Process-group kill on timeout.
## References
- Issue: [#2008](https://github.com/open-gsd/gsd-core/issues/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`

View File

@@ -63,6 +63,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop
| [1593-skill-mapping-converter-methodology.md](1593-skill-mapping-converter-methodology.md) | Skill mapping & converter methodology across runtimes | Accepted |
| [1769-state-md-transition-module.md](1769-state-md-transition-module.md) | STATE.md Transition Module — intent-based transitions over scattered RMW callbacks | Proposed |
| [1817-state-md-rebuild-derivability-contract.md](1817-state-md-rebuild-derivability-contract.md) | STATE.md rebuild — derivability contract (capstone 11th transition) | Accepted |
| [2008-command-exit-zero-gate.md](2008-command-exit-zero-gate.md) | Generic gate-predicate evaluator with a `command-exit-zero` kind (#2008) | Accepted |
## Seam map

View File

@@ -0,0 +1,146 @@
# How-to: add a `command-exit-zero` gate to a capability
> **Diátaxis quadrant:** How-To. A step-by-step recipe for a capability author
> who wants a gate that runs a shell command and blocks the loop on non-zero
> exit. For the full specification, see
> [Reference: gate predicates](../reference/gate-predicates.md).
## When to use this
Use a `command-exit-zero` gate when your capability can be verified by an
existing command-line check — a test runner, a linter, a custom validator, a
git-hook-style probe. The gate runs your command at a loop point you choose and
blocks the loop (advisory or hard) when it exits non-zero.
This is the generic extension path introduced in #2008 / ADR-2008. Before it,
only built-in `check.query` gates could fire; a third-party capability's
declared gate was display-only.
## Prerequisites
- A capability with a `capability.json` (see ADR-0894).
- A command that exits `0` on success and non-zero on failure, runnable via
`sh`. On Windows, `sh` must be present (git-bash / WSL) — otherwise the gate
fails closed with exit 127.
## Step 1 — author the command
Write a command that follows the **exit-0-on-success** contract. It receives
the project root as its cwd and the inherited process environment.
```bash
# Example: a bundled check script shipped with the capability
node "${GSD_CAP_DIR}/checks/pre-ship.js"
```
You may interpolate three loop-context placeholders directly into the command:
| Placeholder | Meaning |
|---|---|
| `${PHASE_NUMBER}` | the active phase number (e.g. `03`) |
| `${PHASE_DIR}` | the active phase directory |
| `${PHASE_REQ_IDS}` | the phase's requirement ids |
Anything else (`${HOME}`, `$VAR`, etc.) is left for `sh` to interpret against
the inherited env.
## Step 2 — declare the gate
Add a `gates` entry to your `capability.json`. Pick the loop `point` (one of
the 12 canonical points), set `blocking` and `onError`, and gate it on a
config key with `when`:
```json
{
"gates": [
{
"point": "ship:pre",
"check": {
"predicate": {
"kind": "command-exit-zero",
"command": "node \"${GSD_CAP_DIR}/checks/pre-ship.js\" \"${PHASE_DIR}\"",
"timeout": 30
}
},
"when": "my_cap.enabled",
"blocking": true,
"onError": "halt"
}
]
}
```
Field rules (enforced by the evaluator):
- `predicate.command` — required, non-empty string, ≤ 4096 chars.
- `predicate.timeout` — optional, positive finite number of seconds (default 30).
- `predicate.kind` — must be exactly `"command-exit-zero"`.
## Step 3 — choose `blocking` and `onError`
| Field | Effect |
|---|---|
| `blocking: true` | A `block: true` result **halts** the loop at this point. |
| `blocking: false` | Advisory — the gate prints its `message` and the loop continues. |
| `onError: "halt"` | If the check command itself fails (malformed predicate, unknown kind, or a command that cannot be evaluated), halt. |
| `onError: "skip"` | On a check-command failure, warn and continue (do not read `block`). |
> A non-zero exit of **your** command is a gate *result* (`block: true`), not a
> check-command failure — `onError` does not apply to it. `onError` covers only
> the case where the gate could not be evaluated at all.
## Step 4 — test the gate directly
You can run the evaluator standalone to verify your predicate before wiring it
into a loop point:
```bash
gsd_run check predicate \
--predicate '{"kind":"command-exit-zero","command":"node checks/pre-ship.js \"${PHASE_DIR}\"","timeout":30}' \
--phase-dir ".planning/phases/03-my-phase" \
--phase-number "03" \
--raw
```
Expected output on success:
```json
{ "block": false, "message": "command exited 0", "details": { "kind": "command-exit-zero", "exitCode": 0 } }
```
Expected output when your command fails (e.g. exit 1):
```json
{ "block": true, "message": "command exited 1: <stderr tail>", "details": { "kind": "command-exit-zero", "exitCode": 1, "signal": null } }
```
## Step 5 — verify it fires in the loop
Once declared and the capability is active, the loop-resolver renders the gate
at your chosen point. The workflow gate-dispatch (`execute:wave:post`,
`execute:post`, `plan:post`, `ship:pre`, …) detects the `predicate` shape and
dispatches the evaluator automatically — no further wiring needed.
```bash
gsd_run loop render-hooks ship:pre --raw
```
## Gotchas
- **Backgrounded children escape the timeout.** `command: "sleep 100 &"` returns
before the sleep finishes; the timeout kills the `sh` parent, not the
backgrounded child. Keep your command foreground, or have it self-manage its
children. (ADR-2008, documented limitation.)
- **`sh` must be on PATH.** On Windows without git-bash/WSL, the gate fails
closed (exit 127). This matches the runtime's existing bash dependency.
- **Output is trimmed.** Only the last 2000 chars of stderr/stdout surface in
the gate `message`. Emit a concise diagnostic; don't rely on grepping long
output downstream.
- **Env is inherited.** The command sees the full GSD process environment. Do
not put secrets in the command string; read them from env or a file like any
shell script.
## Related
- [Reference: gate predicates](../reference/gate-predicates.md)
- [ADR-2008](../adr/2008-command-exit-zero-gate.md)

View File

@@ -0,0 +1,117 @@
# Gate predicates (reference)
> **Diátaxis quadrant:** Reference. This is the canonical specification of the
> capability gate `check.predicate` evaluation path. For a step-by-step
> authoring guide, see [How-to: add a command-exit-zero gate](../how-to/command-exit-zero-gate.md).
A capability gate's `check` block carries exactly one of three shapes
(`query`, `predicate`, `agentVerdict`), enforced by the registry validator
(`capability-validator.cjs:validateGate`). This page documents the
**`predicate`** shape and the kinds the built-in evaluator recognises.
## Declaration
```json
"gates": [
{
"point": "<loop-point>",
"check": {
"predicate": {
"kind": "<kind>",
"<kind-specific fields>"
}
},
"when": "<config-key>",
"blocking": true,
"onError": "halt"
}
]
```
The gate envelope (`point`, `when`, `blocking`, `onError`) follows the standard
contract documented in ADR-0894 (capability declaration format) and the
`Loop Host Contract` glossary entry in `CONTEXT.md`. This page covers only
`check.predicate`.
## Evaluation path
1. The loop-resolver (`gsd-tools loop render-hooks <point>`) renders the active
gate hook (including its `check.predicate` declaration) to the workflow.
2. The workflow gate-dispatch reads the hook in-context and, when the `check`
shape is `predicate`, runs:
```bash
gsd_run check predicate --predicate '<predicate JSON>' [--phase-dir …] [--phase-number …] [--phase-req-ids …] --raw
```
3. `check-command-router.cts:cmdCheckPredicate` parses the predicate, builds the
production subprocess binding, and calls
`gate-predicate-evaluator.cjs:evaluatePredicate`, which dispatches by
`predicate.kind`.
4. The evaluator returns the standard gate envelope:
```json
{ "block": <bool>, "message": "<string>", "details": { … } }
```
5. The workflow applies the **two-step gate contract** unchanged:
- **Step 1** — if the check command itself failed (non-zero exit, e.g. a
malformed predicate / unknown kind), route per `onError` (`halt` or `skip`).
- **Step 2** — if the command succeeded, a `blocking: true` gate halts on
`block: true`; an advisory gate shows `message` and continues.
## Built-in kinds
### `command-exit-zero`
Runs a declared command in a bounded `sh -c` subprocess; **exit 0 → pass,
non-zero → block, timeout → block.** See ADR-2008 for the full sandbox
contract.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
| `kind` | string | yes | — | Must be `"command-exit-zero"` |
| `command` | string | yes | — | The shell command. Non-empty, ≤ 4096 chars |
| `timeout` | number | no | `30` | Positive finite number, seconds |
**Interpolation.** Before execution, three placeholders are substituted from
the gate context; all others are left untouched for `sh` to interpret:
| Placeholder | Source | Workflow flag |
|---|---|---|
| `${PHASE_NUMBER}` | the active phase number | `--phase-number` |
| `${PHASE_DIR}` | the active phase directory | `--phase-dir` |
| `${PHASE_REQ_IDS}` | the phase's requirement ids | `--phase-req-ids` |
An undefined placeholder interpolates to the empty string.
**Sandbox.** cwd = project root; env = inherited from the GSD process; killed
(SIGTERM) on timeout. The command runs as the user, on the user's machine —
there is no sandbox boundary vs. the user's own shell. See ADR-2008 "Trust
model".
**Result mapping.**
| Command outcome | `block` | `message` |
|---|---|---|
| exit 0 | `false` | `command exited 0` |
| exit N (non-zero) | `true` | `command exited N: <stderr/stdout tail, ≤2000 chars>` |
| timeout (SIGTERM) | `true` | `command timed out after <s>s: <tail>` |
| `sh` missing (ENOENT, exit 127) | `true` | `command exited 127: sh: not found` |
**Validation errors (throw → check-command failure → Step-1 / `onError`).**
- Missing, non-string, empty, or whitespace-only `command`.
- `command` longer than 4096 chars.
- `timeout` present but not a positive finite number.
- Unknown `kind`.
## Extensibility
The evaluator dispatches through a `KIND_TABLE`. Adding a new built-in kind is
a one-line registration in `gate-predicate-evaluator.cts` — no workflow changes
required, since the workflow dispatches any `check.predicate` to the same
`gsd_run check predicate` subcommand.
## Related
- [ADR-2008](../adr/2008-command-exit-zero-gate.md) — full decision record.
- [How-to: add a command-exit-zero gate](../how-to/command-exit-zero-gate.md).
- ADR-0894 — capability declaration format.
- `src/gate-predicate-evaluator.cts`, `src/check-command-router.cts`.