* 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:
158
docs/adr/2008-command-exit-zero-gate.md
Normal file
158
docs/adr/2008-command-exit-zero-gate.md
Normal 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`
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user