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>
153 lines
6.5 KiB
Markdown
153 lines
6.5 KiB
Markdown
# 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.
|
|
|
|
**`--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`.
|
|
- `command` longer than 4096 chars.
|
|
- `timeout` present 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 `artifact` string.
|
|
- Missing or empty `field` string.
|
|
- Missing `equals` value.
|
|
- 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](../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`.
|