Files
msd-core/src/gate-predicate-evaluator.cts
Tom Boucher 8de2ff9121 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
2026-07-05 14:04:29 -04:00

205 lines
8.4 KiB
TypeScript

/**
* Gate Predicate Evaluator — issue #2008 / ADR-2008
*
* Pure, deps-injected evaluator for capability gate `check.predicate` blocks.
*
* Background: the loop-resolver renders active gate hooks (carrying their
* `check.predicate` declarations) but, prior to #2008, nothing EVALUATED a
* declared predicate for non-built-in capabilities — `check.query` was the only
* enforced shape (dispatched via `gsd_run check <query>`), and `check.predicate`
* was declaration-only. This module is the generic evaluation path.
*
* The workflow gate-dispatch calls this evaluator (via the `gsd_run check predicate`
* subcommand in check-command-router) for any gate whose `check` carries a
* `predicate` instead of a `query`. The evaluator dispatches by `predicate.kind`
* and returns the existing `{ block, message }` gate contract. A THROWN error
* (malformed predicate / unknown kind) is mapped by the CLI wrapper to a
* non-zero check-command exit, which the workflow's two-step gate contract treats
* as a step-1 command failure (routed per the gate's `onError`).
*
* Built-in kind (v1): `command-exit-zero` — run a declared command in a bounded
* `sh -c` subprocess at the project root, inheriting the process env; exit 0 =>
* pass, non-zero => block, timeout => block. The production runBoundedShell
* binding is shell-command-projection.execTool (bounded spawnSync). See ADR-2008
* for the full sandbox contract.
*
* This is a leaf pure module: no fs, no child_process, no config — the subprocess
* seam is injected so the evaluator is trivially testable without spawning.
*/
// ─── Public constants ─────────────────────────────────────────────────────────
/** Default command timeout for `command-exit-zero` (30s). Matches execTool default. */
const COMMAND_EXIT_ZERO_DEFAULT_TIMEOUT_MS = 30_000;
/** Hard cap on the stderr/stdout tail embedded in the gate `message`. */
const COMMAND_MAX_OUTPUT_CHARS = 2000;
/** Hard cap on the declared command length (defense-in-depth against ARGV overflow / abuse). */
const COMMAND_MAX_LENGTH = 4096;
/** Predicate kinds this evaluator recognises (extensible — add to KIND_TABLE). */
const EVALUATOR_KINDS = Object.freeze(['command-exit-zero']);
/** Placeholders interpolated into a declared command, in addition to sh's own vars. */
const INTERPOLATION_VAR_NAMES = Object.freeze(['PHASE_NUMBER', 'PHASE_DIR', 'PHASE_REQ_IDS']);
// ─── Types (internal; runtime API is the `export =` block) ────────────────────
interface PredicateContext {
/** Project root — also the cwd of the bounded subprocess. */
cwd: string;
phaseNumber?: string;
phaseDir?: string;
phaseReqIds?: string;
}
interface BoundedShellResult {
exitCode: number | null;
stdout: string;
stderr: string;
signal: NodeJS.Signals | null;
timedOut: boolean;
}
interface PredicateDeps {
runBoundedShell(opts: { command: string; cwd: string; timeoutMs: number }): BoundedShellResult;
}
interface PredicateResult {
block: boolean;
message: string;
details?: Record<string, unknown>;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const INTERPOLATION_RE = /\$\{(PHASE_NUMBER|PHASE_DIR|PHASE_REQ_IDS)\}/g;
/** Replace the three known ${PHASE_*} placeholders with context values (undefined => ''). */
function interpolate(command: string, ctx: PredicateContext): string {
return command.replace(INTERPOLATION_RE, (_whole, name: string): string => {
if (name === 'PHASE_NUMBER') return ctx.phaseNumber ?? '';
if (name === 'PHASE_DIR') return ctx.phaseDir ?? '';
if (name === 'PHASE_REQ_IDS') return ctx.phaseReqIds ?? '';
return '';
});
}
/** Cap a string at COMMAND_MAX_OUTPUT_CHARS so gate messages stay context-bounded. */
function trimToMax(s: string): string {
return s.length > COMMAND_MAX_OUTPUT_CHARS ? s.slice(0, COMMAND_MAX_OUTPUT_CHARS) : s;
}
function isNonEmptyString(v: unknown): v is string {
return typeof v === 'string' && v.trim().length > 0;
}
// ─── Kind: command-exit-zero ──────────────────────────────────────────────────
function evaluateCommandExitZero(
predicate: Record<string, unknown>,
ctx: PredicateContext,
deps: PredicateDeps,
): PredicateResult {
const command = predicate['command'];
if (!isNonEmptyString(command)) {
throw new Error('command-exit-zero predicate requires a non-empty string "command"');
}
if (command.length > COMMAND_MAX_LENGTH) {
throw new Error(`command-exit-zero predicate "command" exceeds max length ${COMMAND_MAX_LENGTH}`);
}
let timeoutMs = COMMAND_EXIT_ZERO_DEFAULT_TIMEOUT_MS;
const rawTimeout = predicate['timeout'];
if (rawTimeout !== undefined) {
if (typeof rawTimeout !== 'number' || !Number.isFinite(rawTimeout) || rawTimeout <= 0) {
throw new Error('command-exit-zero predicate "timeout" must be a positive finite number (seconds)');
}
timeoutMs = Math.floor(rawTimeout * 1000);
}
const interpolated = interpolate(command, ctx);
const res = deps.runBoundedShell({ command: interpolated, cwd: ctx.cwd, timeoutMs });
if (res.timedOut) {
return {
block: true,
message: trimToMax(`command timed out after ${Math.round(timeoutMs / 1000)}s: ${res.stderr || interpolated}`),
details: { kind: 'command-exit-zero', timedOut: true, signal: res.signal },
};
}
if (res.exitCode === 0) {
return {
block: false,
message: 'command exited 0',
details: { kind: 'command-exit-zero', exitCode: 0 },
};
}
// Non-zero (incl. null exit from a signal kill) => block. Surface code + stderr/stdout tail.
const code = res.exitCode === null ? '<none>' : String(res.exitCode);
const tail = trimToMax(res.stderr || res.stdout || '');
const message = tail ? `command exited ${code}: ${tail}` : `command exited ${code}`;
return {
block: true,
message: trimToMax(message),
details: { kind: 'command-exit-zero', exitCode: res.exitCode, signal: res.signal },
};
}
// ─── Kind dispatch table ──────────────────────────────────────────────────────
const KIND_TABLE: Record<string, (p: Record<string, unknown>, ctx: PredicateContext, deps: PredicateDeps) => PredicateResult> = {
'command-exit-zero': evaluateCommandExitZero,
};
// ─── Public entry point ───────────────────────────────────────────────────────
/**
* Evaluate a capability gate `check.predicate`.
*
* Returns `{ block, message }` for any successfully-recognised predicate.
* THROWS for a malformed predicate, missing context/deps, or unknown `kind` —
* the CLI wrapper converts a throw into a non-zero check-command exit so the
* workflow's `onError` (step-1) contract applies. This keeps the gate fail-closed
* without conflating an evaluator bug with a legitimate gate-block decision.
*/
function evaluatePredicate(predicate: unknown, context: unknown, deps: unknown): PredicateResult {
if (!predicate || typeof predicate !== 'object' || Array.isArray(predicate)) {
throw new Error('predicate must be an object');
}
const ctx = context as PredicateContext;
if (!ctx || typeof ctx.cwd !== 'string' || ctx.cwd.length === 0) {
throw new Error('predicate context requires a non-empty "cwd"');
}
const d = deps as PredicateDeps;
if (!d || typeof d.runBoundedShell !== 'function') {
throw new Error('predicate deps require a "runBoundedShell" function');
}
const pred = predicate as Record<string, unknown>;
const kind = pred['kind'];
if (typeof kind !== 'string' || kind.length === 0) {
throw new Error('predicate.kind must be a non-empty string');
}
const handler = KIND_TABLE[kind];
if (!handler) {
throw new Error(`Unknown predicate kind: "${kind}". Known kinds: ${EVALUATOR_KINDS.join(', ')}`);
}
return handler(pred, ctx, d);
}
export = {
evaluatePredicate,
evaluateCommandExitZero,
interpolate,
COMMAND_EXIT_ZERO_DEFAULT_TIMEOUT_MS,
COMMAND_MAX_OUTPUT_CHARS,
COMMAND_MAX_LENGTH,
EVALUATOR_KINDS,
INTERPOLATION_VAR_NAMES,
};