* 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:
@@ -32,6 +32,10 @@ const { getRoadmapPhaseWithFallback } = roadmapModule;
|
||||
import gapCheckerModule = require('./gap-checker.cjs');
|
||||
const { runGapAnalysis } = gapCheckerModule;
|
||||
import { routeProhibitionEnforcement } from './prohibition-enforcement.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import gatePredicateEval = require('./gate-predicate-evaluator.cjs');
|
||||
const { evaluatePredicate } = gatePredicateEval;
|
||||
import { execTool } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -882,6 +886,106 @@ interface RouteCheckCommandOptions {
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
// ─── predicate (generic gate-predicate evaluator, #2008) ──────────────────────
|
||||
|
||||
/**
|
||||
* Production subprocess binding for the gate-predicate evaluator. Wraps the
|
||||
* bounded `execTool` seam (shell-command-projection) as a `runBoundedShell`
|
||||
* the pure evaluator consumes. `sh -c` runs the interpolated command; the
|
||||
* subprocess inherits the process env and is killed (SIGTERM) on timeout.
|
||||
*
|
||||
* `timedOut` is derived from the kill signal: spawnSync sets `signal: 'SIGTERM'`
|
||||
* when the `timeout` fires, distinct from a normal non-zero exit code. A command
|
||||
* that self-terminates with SIGTERM is indistinguishable at this seam and is
|
||||
* reported as a timeout — either way the gate blocks (non-zero), so the outcome
|
||||
* is fail-closed and correct. See ADR-2008.
|
||||
*/
|
||||
function buildPredicateDeps() {
|
||||
return {
|
||||
runBoundedShell(opts: { command: string; cwd: string; timeoutMs: number }): {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
signal: NodeJS.Signals | null;
|
||||
timedOut: boolean;
|
||||
} {
|
||||
const r = execTool('sh', ['-c', opts.command], { cwd: opts.cwd, timeout: opts.timeoutMs });
|
||||
return {
|
||||
exitCode: r.exitCode,
|
||||
stdout: r.stdout,
|
||||
stderr: r.stderr,
|
||||
signal: r.signal,
|
||||
timedOut: r.signal === 'SIGTERM',
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse `--flag value` pairs from an args array into a map (last write wins). */
|
||||
function parsePredicateFlags(args: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (typeof a !== 'string') continue;
|
||||
if (!a.startsWith('--')) continue;
|
||||
const key = a.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (key.length > 0 && typeof next === 'string' && !next.startsWith('--')) {
|
||||
out[key] = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `check predicate` — generic evaluator for capability gate `check.predicate`
|
||||
* blocks (#2008). The workflow gate-dispatch invokes this for any gate whose
|
||||
* `check` carries a `predicate` (instead of a `query`); the predicate object is
|
||||
* passed as `--predicate '<json>'`. Emits the standard `{ block, message,
|
||||
* details? }` gate contract on success. A malformed predicate / unknown kind
|
||||
* THROWS inside the evaluator and is mapped here to `error()` (non-zero exit),
|
||||
* which the workflow's two-step gate contract treats as a step-1 command failure
|
||||
* routed per the gate's `onError`.
|
||||
*
|
||||
* Invocation:
|
||||
* gsd_run check predicate --predicate '<json>' \
|
||||
* [--phase-dir <dir>] [--phase-number <n>] [--phase-req-ids <ids>] --raw
|
||||
*
|
||||
* The subprocess runs at the runtime project root (the `cwd` passed to this
|
||||
* router), inheriting the process env. Interpolation placeholders
|
||||
* ${PHASE_NUMBER}/${PHASE_DIR}/${PHASE_REQ_IDS} are substituted from the flags.
|
||||
*/
|
||||
function cmdCheckPredicate(projectDir: string, args: string[], raw: boolean): void {
|
||||
const flags = parsePredicateFlags(args);
|
||||
const predicateJson = flags['predicate'];
|
||||
if (!predicateJson) {
|
||||
error('predicate requires --predicate <json> (the gate hook check.predicate object)', ERROR_REASON.SDK_MISSING_ARG);
|
||||
return;
|
||||
}
|
||||
let predicate: unknown;
|
||||
try {
|
||||
predicate = JSON.parse(predicateJson);
|
||||
} catch {
|
||||
error('predicate --predicate value must be valid JSON', ERROR_REASON.USAGE);
|
||||
return;
|
||||
}
|
||||
const ctx = {
|
||||
cwd: projectDir,
|
||||
phaseNumber: flags['phase-number'],
|
||||
phaseDir: flags['phase-dir'],
|
||||
phaseReqIds: flags['phase-req-ids'],
|
||||
};
|
||||
let result;
|
||||
try {
|
||||
result = evaluatePredicate(predicate, ctx, buildPredicateDeps());
|
||||
} catch (e) {
|
||||
error(`gate predicate evaluation failed: ${(e as Error).message}`, ERROR_REASON.USAGE);
|
||||
return;
|
||||
}
|
||||
output(result, raw, undefined);
|
||||
}
|
||||
|
||||
function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
|
||||
// Normalize dots to hyphens in the subcommand so both forms are accepted.
|
||||
// This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates)
|
||||
@@ -934,6 +1038,15 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
|
||||
cmdVerifyCodebaseDrift(cwd, raw);
|
||||
return;
|
||||
}
|
||||
if (subcommand === 'predicate') {
|
||||
// Generic gate-predicate evaluator (#2008). The workflow gate-dispatch calls
|
||||
// this for any gate whose `check` carries a `predicate` (instead of a `query`),
|
||||
// passing the predicate object as --predicate '<json>'. NOTE: unlike the
|
||||
// `check.query` subcommands above (which take positional phase args), this
|
||||
// subcommand parses --flag value pairs.
|
||||
cmdCheckPredicate(cwd, args, raw);
|
||||
return;
|
||||
}
|
||||
if (subcommand === 'prohibition-enforcement') {
|
||||
// The deterministic test-tier prohibition PRODUCER/gate (#1259, ADR-550 D5d). Locates the
|
||||
// wired mechanical check (node-test or lint-rule), confirms fail-first, runs it, builds
|
||||
@@ -942,7 +1055,7 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
|
||||
routeProhibitionEnforcement(args, raw);
|
||||
return;
|
||||
}
|
||||
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, predicate, prohibition-enforcement, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
}
|
||||
|
||||
export = {
|
||||
@@ -953,4 +1066,7 @@ export = {
|
||||
computeUiSafetyGate,
|
||||
cmdGapAnalysisPlanPost,
|
||||
cmdTddReviewCheckpoint,
|
||||
cmdCheckPredicate,
|
||||
buildPredicateDeps,
|
||||
parsePredicateFlags,
|
||||
};
|
||||
|
||||
204
src/gate-predicate-evaluator.cts
Normal file
204
src/gate-predicate-evaluator.cts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user