Phase 2 of epic #4636, absorbing #4327 and #4354. Implements ADR-4650 decision 3: containment is a boundary concern — the predicate runs where external input enters, not at whichever interior call site remembered. Four boundaries now validate against their managed root and reject with a USAGE-shaped error before touching the filesystem: todo complete <name> -> todosDir(cwd) check predicate --phase-dir <dir> -> projectDir check decision-coverage-plan <dir> -> projectDir (via resolvePath) check gap-analysis.plan-post <dir> -> projectDir #4327 understated its own severity. It reports that a traversal name "resolves outside the todos root", which reads as an information leak. Measured, it was destructive: the command exited 0, MOVED the outside file into completed/, and unlinked the original. cmdTodoComplete ends in fs.unlinkSync(sourcePath), so an unconfined name consumed across the boundary rather than merely reading across it. Validation now precedes every fs call — existsSync, readFileSync, ensureDir, writeSync, unlinkSync — and both halves of the move are confined, so neither source nor destination can land outside the root. --dry-run is rejected on the same terms; a preview must not leak a resolved outside path either. #4354 reproduces exactly: a BLOCKING gate returned block:false sourced entirely from a SECURITY.md in a caller-chosen directory outside the project. THE HARDER HALF, found by the isolated adversarial review of the first attempt: validating a path and then using a DIFFERENT one closes nothing. The first fix validated `--phase-dir` joined against `--cwd`, then passed the RAW unjoined value into the predicate context. gate-predicate-evaluator uses it as-is and findPhaseArtifact resolves a relative path against the REAL process cwd — so validation and the read used two different roots whenever process.cwd() differed from --cwd. Reproduced: running from a directory holding a plan with `secret_field: LEAKED_VALUE`, a predicate declared against an empty --cwd project exited 0 and returned "actual":"LEAKED_VALUE". The rule now applied at all three router sites: **use the validated resolved path, never the raw input.** Independently re-verified after the fix — the lookup resolves in the --cwd project and no value leaks. gate-predicate-evaluator.cts is untouched and still imports no fs. Confining in the router is what keeps that pure-leaf contract intact AND covers ${PHASE_DIR} interpolation into command-exit-zero, which an evaluator-local fix would have missed entirely. Also fixed, same review: `todo complete .` and `..` passed containment (they resolve to the pending dir, which IS inside the root) and then threw an uncaught EISDIR with an absolute-path stack trace. Now a clean USAGE rejection naming the real reason — "todo name is not a file" — rather than borrowing the escape message, which would have stated something false. Ripples discharged BEFORE the verification checkpoint rather than after, per the Phase 1 retrospective: docs/reference/gate-predicates.md and docs/CLI-TOOLS.md document the new constraints, CONTEXT.md records why containment lives at the router rather than the evaluator, the changeset is written, and the install-tree goldens were regenerated to confirm unchanged (no new shipped file) rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.5 KiB
Gate predicates (reference)
Diátaxis quadrant: Reference. This is the canonical specification of the capability gate
check.predicateevaluation path. For a step-by-step authoring guide, see How-to: add a command-exit-zero gate.
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
"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
- The loop-resolver (
gsd-tools loop render-hooks <point>) renders the active gate hook (including itscheck.predicatedeclaration) to the workflow. - The workflow gate-dispatch reads the hook in-context and, when the
checkshape ispredicate, runs:gsd_run check predicate --predicate '<predicate JSON>' [--phase-dir …] [--phase-number …] [--phase-req-ids …] --raw check-command-router.cts:cmdCheckPredicateparses the predicate, builds the production subprocess binding, and callsgate-predicate-evaluator.cjs:evaluatePredicate, which dispatches bypredicate.kind.- The evaluator returns the standard gate envelope:
{ "block": <bool>, "message": "<string>", "details": { … } } - 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(haltorskip). - Step 2 — if the command succeeded, a
blocking: truegate halts onblock: true; an advisory gate showsmessageand continues.
- Step 1 — if the check command itself failed (non-zero exit, e.g. a
malformed predicate / unknown kind), route per
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.
--phase-dir is confined to the project. The value is validated to resolve
inside the project root before any predicate is evaluated; one that escapes is
rejected as a usage error rather than evaluated. This applies to both kinds —
artifact-frontmatter-equals resolves its artifact under that directory, and
command-exit-zero interpolates it into ${PHASE_DIR} — so an unconfined value
would let a blocking gate return block: false on evidence from a directory
the caller chose (#4354). An absolute path inside the project is still accepted;
absolute is not a synonym for escaping.
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. commandlonger than 4096 chars.timeoutpresent but not a positive finite number.- Unknown
kind.
artifact-frontmatter-equals
Reads a Markdown file with YAML frontmatter from the current phase directory (or falls back to the project root for project-level artifacts) and compares a field's value to the declared expectation. The value is matched using loosely typed string comparison or exact matching, where numeric expectations will safely match stringified numeric frontmatter values.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
kind |
string | yes | — | Must be "artifact-frontmatter-equals" |
artifact |
string | yes | — | Suffix or exact filename (e.g. WINDOWS.md) |
field |
string | yes | — | Frontmatter key to read |
equals |
any | yes | — | Expected value (compared with string coercion) |
Result mapping.
| Command outcome | block |
message |
|---|---|---|
Value matches equals |
false |
Frontmatter field "<field>" matches expected value (<expected>) |
| Value mismatch | true |
Frontmatter field "<field>" in <artifact> is <actual>, expected <expected> |
| Artifact file not found | true |
Artifact matching <artifact> not found in <targetDir> |
Validation errors (throw → check-command failure → Step-1 / onError).
- Missing or empty
artifactstring. - Missing or empty
fieldstring. - Missing
equalsvalue. - File read or YAML parsing failure (I/O errors).
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 — full decision record.
- How-to: add a command-exit-zero gate.
- ADR-0894 — capability declaration format.
src/gate-predicate-evaluator.cts,src/check-command-router.cts.