Files
msd-core/docs/reference/gate-predicates.md
sim 374300da17 fix(#4652): confine every boundary that joins argv to a managed root
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>
2026-09-12 09:43:50 -04:00

6.5 KiB

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.

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

  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:
    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:
    { "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.