diff --git a/.github/workflows/plan-marker-validate.yml b/.github/workflows/plan-marker-validate.yml index eed2b3c..aee0f67 100644 --- a/.github/workflows/plan-marker-validate.yml +++ b/.github/workflows/plan-marker-validate.yml @@ -84,6 +84,9 @@ jobs: - name: Run stop-gate regression run: bash tests/test-stop-gate.sh + - name: Run enforce-contract --gate stop relocation + parity (RFC-008 P3b-1) + run: node tests/test-enforce-contract.mjs + - name: Run preflight-gate regression tests run: bash tests/test-preflight-gate.sh diff --git a/plugins/claude-code/hooks/stop-gate.sh b/plugins/claude-code/hooks/stop-gate.sh index 75dcabf..45abbbb 100755 --- a/plugins/claude-code/hooks/stop-gate.sh +++ b/plugins/claude-code/hooks/stop-gate.sh @@ -12,7 +12,10 @@ set -e # Architecture (RFC-003 Phase 3b primitive; Phase 2 will subsume into # adapters/claude-code/capabilities/enforcement.mjs — see RFC-003 # §Considerations — #128 stop-gate alignment): -# - Decision logic lives in core: `node em-recall.mjs --gate stop`. +# - Decision logic lives in the enforcement layer: +# `node enforce-contract.mjs --gate stop` (RFC-008 P3b-1 — relocated from +# the substrate's `em-recall.mjs --gate stop`, byte-identical; em-recall's +# --gate handler is deleted in P3d). # - This shell script is a thin runtime adapter: # 1. Reads stdin (Claude Code hook input JSON). # 2. Honors `stop_hook_active` early-exit (mandatory infinite-loop @@ -53,12 +56,18 @@ fi CWD="$(echo "$INPUT" | jq -r '.cwd // ""' 2>/dev/null || echo "")" [ -z "$CWD" ] && CWD="$(pwd)" -# Resolve em-recall.mjs at canonical global install path. The hook does not -# attempt to use the in-repo script — production hooks invoke globally +# Resolve enforce-contract.mjs at canonical global install path. The hook does +# not attempt to use the in-repo script — production hooks invoke globally # installed copies, which is what install.mjs --install-hooks deploys. -EM_RECALL="$HOME/.episodic-memory/scripts/em-recall.mjs" -if [ ! -f "$EM_RECALL" ]; then - echo '{"decision": "block", "reason": "stop-gate.sh: em-recall.mjs not found at canonical global path. Re-run install.mjs."}' +# +# RFC-008 P3b-1: the stop decision moved OUT of the memory substrate +# (em-recall.mjs --gate stop) INTO the enforcement layer (enforce-contract.mjs), +# byte-identical. CLASS-C(c): this loud-fail-if-missing is PRESERVED on the +# repoint — a missing/erroring binary MUST block loud, never degrade to +# allow-always. +ENFORCE="$HOME/.episodic-memory/scripts/enforce-contract.mjs" +if [ ! -f "$ENFORCE" ]; then + echo '{"decision": "block", "reason": "stop-gate.sh: enforce-contract.mjs not found at canonical global path. Re-run install.mjs."}' exit 0 fi @@ -82,17 +91,17 @@ fi MY_SID="$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")" # Invoke core decision logic. Capture stdout; fail-loud envelope on error. -# Repo-root resolution in em-recall.mjs (resolveRepoRoot module-load) now +# Repo-root resolution in enforce-contract.mjs (resolveRepoRoot module-load) now # resolves from the cwd we just cd'd to — i.e., the project the hook input # named, not the hook process's inherited cwd. if [ -n "$MY_SID" ]; then - DECISION="$(node "$EM_RECALL" --gate stop --session-id "$MY_SID" 2>/dev/null)" || { - echo '{"decision": "block", "reason": "stop-gate.sh: em-recall --gate stop exited non-zero. Re-run install.mjs --install-hooks."}' + DECISION="$(node "$ENFORCE" --gate stop --session-id "$MY_SID" 2>/dev/null)" || { + echo '{"decision": "block", "reason": "stop-gate.sh: enforce-contract --gate stop exited non-zero. Re-run install.mjs --install-hooks."}' exit 0 } else - DECISION="$(node "$EM_RECALL" --gate stop 2>/dev/null)" || { - echo '{"decision": "block", "reason": "stop-gate.sh: em-recall --gate stop exited non-zero. Re-run install.mjs --install-hooks."}' + DECISION="$(node "$ENFORCE" --gate stop 2>/dev/null)" || { + echo '{"decision": "block", "reason": "stop-gate.sh: enforce-contract --gate stop exited non-zero. Re-run install.mjs --install-hooks."}' exit 0 } fi diff --git a/scripts/enforce-contract.mjs b/scripts/enforce-contract.mjs new file mode 100644 index 0000000..ebced9a --- /dev/null +++ b/scripts/enforce-contract.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +/** + * enforce-contract.mjs — Enforcement thin-waist (RFC-008 P3b, R1). + * + * P3b-1 scope: the `stop` gate decision, RELOCATED VERBATIM from em-recall.mjs's + * `--gate stop` handler into the enforcement layer. This is the R1 strong-form + * correction — the memory substrate (em-store / em-recall / em-search) MUST own + * ZERO enforcement code (RFC-008:83,85); em-recall's surviving `--gate stop` + * handler is the last violator and is DELETED in P3d once this consumer migrates. + * + * The claude-code stop decision is purely marker-state (RFC-008:464 — "the `stop` + * gate is NOT per-label … it reads marker state, not command labels"), so this + * slice reads NO contract / registry / config / events files. The contract-driven + * effective-tier layer (effective_tier = min(harness, contract, config), the + * `plugins/_index.json` capability lookup, the per-project clamp, and CLASS-C(a) + * fail-closed-on-`unsupported`) is INERT for claude-code — min(STRONG,STRONG, + * identity)=STRONG→refuse_stop reproduces today's unconditional behavior — and so + * defers to P3b-2, landing with its real dependencies (an install-runtime contract + * deploy + the P4 config schema). See docs/rfcs/RFC-008/P3-thin-waist.md. + * + * Behavior is byte-identical to `em-recall --gate stop` — stdout, exit code, + * stderr (modulo the script-name prefix), and no marker side-effects — proven by + * tests/test-enforce-contract.mjs (parity suite vs em-recall). + * + * Marker reads are owned by scripts/lib/marker-state.mjs (the R1-owned reader + * extracted in P3a). This module performs ZERO marker logic of its own — it only + * orchestrates the marker-state helpers into the stop decision. + */ + +import fs from 'fs' +import { fileURLToPath } from 'node:url' +import { resolveRepoRoot } from './lib/local-dir.mjs' +import { + BASELINE_NAME, + writeMarkerPath, + namespacedMarkerBasenameForSession, +} from './lib/marker-paths.mjs' +import { + _maxMtimeAcrossRootsStrict, + _maxMtimeAcrossRootsForPlanMarkerStrict, + resolveOwnSessionMarkerRead, + stopGateCarveOutApplies, +} from './lib/marker-state.mjs' +import { validateSessionId } from './lib/session-id.mjs' + +/** + * decideStop — pure stop-gate decision (no I/O, no process exit). + * + * Relocated VERBATIM from em-recall.mjs:141-217 (R1). em-recall's handler had + * three terminal control-flow points; all translate to a `return` here so the + * function is pure and the CLI wrapper is the sole I/O boundary: + * em-recall:182 process.exit(0) → return null (plan-pending allow) + * em-recall:211 console.log({…}) → return {decision,reason} (block) + * em-recall:216 process.exit(0) → return null (carve-out / no-marker allow) + * + * @param {{repoRoot: string, sid: string|null}} opts + * repoRoot — the gate root (caller resolves via resolveRepoRoot() from cwd, the + * same module-load semantics as em-recall.mjs:48; stop-gate.sh `cd`s + * to the hook input `.cwd` before spawning, so cwd IS the project). + * sid — validated own-session id, or null (legacy-literal-only mode). + * @returns {{decision:'block', reason:string} | null} null = allow stop. + */ +export function decideStop({ repoRoot, sid }) { + // #178 F1: defer stop-gate when plan is ACTIVELY pending at EITHER root. + // The plan-gate blocks Write/Bash while .plan-approval-pending exists at + // either root, creating an unrecoverable triangle when stop-gate ALSO + // blocks. The exemption narrows to ACTIVE plan-pending only (mtime > + // baseline) — orphan plan-pending falls through to the existing carve-out. + // + // Strict-lstat semantics via _maxMtimeAcrossRootsStrict (codex round-3 F11 + // + round-6 F17): ENOENT skips; any other lstat error (EACCES, ENOTDIR, + // EIO, ELOOP) → hadOtherError → fail closed. Symlink at EITHER root → fail + // closed (same-class with carve-out symmetric defense). + // + // Dual-root semantics (codex round-2 F8): plan-pending and baseline are BOTH + // evaluated across primary and legacy. + // + // #268 fix E19: plan-pending deferral fires for ANY plan-marker variant + // (legacy literal OR any suffixed) — own session or other. + const planPending = _maxMtimeAcrossRootsForPlanMarkerStrict(repoRoot) + const baseStrict = _maxMtimeAcrossRootsStrict(repoRoot, BASELINE_NAME) + if ( + planPending.anyExisted && !planPending.hadSymlink && !planPending.hadOtherError && + baseStrict.anyExisted && !baseStrict.hadSymlink && !baseStrict.hadOtherError && + planPending.mtime > baseStrict.mtime + ) { + return null // em-recall:182 process.exit(0) + } + + // Rank-2: session-aware reads. Resolution order for each quartet member: + // 1. /.checkpoints/. 2. /.claude/. + // 3. /.checkpoints/ 4. /.claude/ + // When sid is null (invalid/missing), only steps 3-4 are checked (graceful + // degrade per codex R2 Q3). Other sessions' suffixed markers are NOT probed. + const preReqPath = resolveOwnSessionMarkerRead(repoRoot, '.checkpoint-required', sid) + const postDonePath = resolveOwnSessionMarkerRead(repoRoot, '.post-checkpoint-done', sid) + let postDoneSize = 0 + if (postDonePath) { + try { postDoneSize = fs.statSync(postDonePath).size } catch {} + } + if (preReqPath && postDoneSize === 0) { + if (!stopGateCarveOutApplies(repoRoot, sid)) { + // Block-message path: emit suffixed write path when sid is valid; legacy + // literal otherwise. Agent's block-write goes to the suffixed path. + const writeBasename = sid + ? namespacedMarkerBasenameForSession('.post-checkpoint-done', sid) + : '.post-checkpoint-done' + const writePath = writeMarkerPath(repoRoot, writeBasename) + const reason = `Post-implementation checkpoint required. Write the Rule 18 post-implementation checkpoint block to ${writePath} (must be non-empty), then end your turn again. Hook: stop-gate.sh.` + return { decision: 'block', reason } // em-recall:211 console.log + } + // else: carve-out applies — allow (return null below). + } + // Otherwise: allow stop. Empty stdout on Stop = allow Claude to stop. + return null // em-recall:216 process.exit(0) +} + +// --------------------------------------------------------------------------- +// CLI — the ONLY I/O + process.exit boundary. Invoked by hooks/stop-gate.sh as +// `node enforce-contract.mjs --gate stop [--session-id ]`. Empty stdout = +// allow; `{decision:"block", reason}` = block. process.exit(0) on every decision +// path (exit-code parity with em-recall — a non-zero block would trip +// stop-gate.sh's `|| {block}` envelope and double-emit). +// --------------------------------------------------------------------------- +// Robust main-module detection. A plain `import.meta.url === pathToFileURL(argv[1])` +// compare FAILS when the install path contains a symlink component (macOS +// /var→/private/var, /tmp→/private/tmp, a symlinked $HOME or .episodic-memory): +// import.meta.url is canonical while pathToFileURL(argv[1]) is not, so isMain +// would be false, the CLI block would silently no-op, and the stop gate would +// degrade to allow-always — a fail-OPEN bug. realpath BOTH sides so a symlinked +// install path still resolves as main. (Caught by test-stop-gate.sh's +// /var/folders fixture during P3b-1 E2E; pinned by test-enforce-contract.mjs +// "CLI via symlinked path".) +const isMain = (() => { + if (!process.argv[1]) return false + try { + return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)) + } catch { + return false + } +})() +if (isMain) { + const argv = process.argv.slice(2) + const flag = (name) => { + const i = argv.indexOf(name) + if (i === -1 || i + 1 >= argv.length) return undefined + return argv[i + 1] + } + + const VALID_GATES = ['stop'] + const gateFlag = flag('--gate') + if (gateFlag !== 'stop') { + const got = gateFlag === undefined ? 'none' : `"${gateFlag}"` + console.log(JSON.stringify({ status: 'error', message: `enforce-contract: --gate stop is required (got ${got}). Valid gates: ${VALID_GATES.join(', ')}` })) + process.exit(1) + } + + // --session-id parse + validate — verbatim semantics from em-recall.mjs:78-86. + // Missing/invalid → legacy-literal-only mode (hook reliability outweighs strict + // contract per codex R2 Q3). The stderr warning is reproduced verbatim with the + // script-name prefix retargeted (the only allowed parity delta vs em-recall). + const sessionIdFlag = flag('--session-id') + let mySid = null + if (sessionIdFlag !== undefined) { + if (sessionIdFlag !== '' && validateSessionId(sessionIdFlag)) { + mySid = sessionIdFlag + } else if (sessionIdFlag !== '') { + process.stderr.write(`enforce-contract: warn — --session-id "${sessionIdFlag}" failed validateSessionId; legacy-literal-only mode\n`) + } + } + + // Gate root resolved from cwd (em-recall.mjs:48 parity). stop-gate.sh `cd`s to + // the hook input `.cwd` before spawning, so this converges with the project the + // hook named (closes #106 worktree-orphan for this gate). + const repoRoot = resolveRepoRoot() + const decision = decideStop({ repoRoot, sid: mySid }) + if (decision) { + console.log(JSON.stringify(decision)) + } + process.exit(0) +} diff --git a/tests/test-enforce-contract.mjs b/tests/test-enforce-contract.mjs new file mode 100644 index 0000000..5c9053e --- /dev/null +++ b/tests/test-enforce-contract.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node +// test-enforce-contract.mjs — RFC-008 P3b-1. +// +// enforce-contract.mjs `--gate stop` is a PURE RELOCATION of em-recall.mjs's +// `--gate stop` handler into the enforcement layer (R1 strong form). These tests +// pin two things: +// (A) decideStop() — the relocated marker logic as a PURE function +// (block / allow / carve-out / plan-pending / symlink-fail-closed / null-sid). +// (B) PARITY — `enforce-contract --gate stop` is byte-identical to +// `em-recall --gate stop` on stdout AND exit-code AND stderr (modulo the +// script-name prefix) AND no-marker-side-effect. This is the R1 relocation +// invariant; em-recall's --gate handler is deleted in P3d, so this suite is +// the regression fixture that guards the migration until then. +// +// Negative-scenario-planner findings folded: +// G-B: decideStop is pure (the 3 em-recall exits → returns); CLI is sole I/O. +// G-A: stderr is in the parity tuple (the invalid-sid warning the hook's +// 2>/dev/null would otherwise hide). +// G-C: this suite stages its OWN module imports (no piggyback on em-recall). +// G-D: no-side-effect = before/after dir-entry set-equality. + +import fs from 'fs' +import os from 'os' +import path from 'path' +import { execSync, spawnSync } from 'child_process' +import { fileURLToPath } from 'url' + +import { decideStop } from '../scripts/enforce-contract.mjs' +import { + PRIMARY_MARKER_DIR, + LEGACY_MARKER_DIR, + BASELINE_NAME, + PLAN_MARKER_LEGACY_BASENAME, + primaryMarkerPath, + legacyMarkerPath, + writeMarkerPath, + namespacedMarkerBasenameForSession, +} from '../scripts/lib/marker-paths.mjs' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const REPO = path.resolve(__dirname, '..') +const ENFORCE = path.join(REPO, 'scripts', 'enforce-contract.mjs') +const EM_RECALL = path.join(REPO, 'scripts', 'em-recall.mjs') + +let pass = 0 +let fail = 0 +const failures = [] +function ok(name) { pass++; console.log(` ✓ ${name}`) } +function bad(name, detail) { fail++; failures.push(`${name}: ${detail}`); console.log(` ✗ ${name}: ${detail}`) } +function eq(name, actual, expected) { + if (actual === expected) ok(name) + else bad(name, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) +} +function truthy(name, v, detail) { if (v) ok(name); else bad(name, detail || 'expected truthy') } + +// Non-git repo with both marker roots — for the pure decideStop() tests where we +// pass repoRoot explicitly (no resolveRepoRoot involved). +function mkRepo() { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'enforce-contract-')) + fs.mkdirSync(path.join(repo, PRIMARY_MARKER_DIR), { recursive: true }) + fs.mkdirSync(path.join(repo, LEGACY_MARKER_DIR), { recursive: true }) + return repo +} +// Git repo with marker roots — for PARITY subprocess tests (both scripts call +// resolveRepoRoot() from cwd, which needs a git work tree to converge). +function mkGitRepo() { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'enforce-parity-')) + execSync('git init -q -b main', { cwd: repo }) + execSync('git config user.email test@example.com', { cwd: repo }) + execSync('git config user.name test', { cwd: repo }) + fs.writeFileSync(path.join(repo, 'README.md'), 'x\n') + execSync('git add . && git commit -q -m init', { cwd: repo, shell: '/bin/bash' }) + fs.mkdirSync(path.join(repo, PRIMARY_MARKER_DIR), { recursive: true }) + fs.mkdirSync(path.join(repo, LEGACY_MARKER_DIR), { recursive: true }) + return repo +} +function writeMarker(p, mtimeSec, content = '') { + fs.writeFileSync(p, content) + if (mtimeSec !== undefined) fs.utimesSync(p, mtimeSec, mtimeSec) +} +// Snapshot the set of marker-dir entries (for the no-side-effect assertion). +function snapshotMarkers(repo) { + const out = {} + for (const dir of [PRIMARY_MARKER_DIR, LEGACY_MARKER_DIR]) { + try { out[dir] = fs.readdirSync(path.join(repo, dir)).sort().join(',') } + catch { out[dir] = '' } + } + return JSON.stringify(out) +} + +console.log('=== A: decideStop() pure-function unit cases ===') + +// A1 — no markers → allow (null) +{ + const repo = mkRepo() + eq('A1: no markers → null (allow)', decideStop({ repoRoot: repo, sid: null }), null) +} + +// A2 — .checkpoint-required armed (no baseline) + no post-done → BLOCK +{ + const repo = mkRepo() + writeMarker(primaryMarkerPath(repo, '.checkpoint-required'), 200) + const d = decideStop({ repoRoot: repo, sid: null }) + truthy('A2: armed + no post-done + no baseline → block', d && d.decision === 'block', `got ${JSON.stringify(d)}`) + if (d) eq('A2: block reason names the legacy-literal write path', + d.reason.includes(writeMarkerPath(repo, '.post-checkpoint-done')), true) +} + +// A3 — armed + post-checkpoint-done NON-EMPTY → allow (null) +{ + const repo = mkRepo() + writeMarker(primaryMarkerPath(repo, '.checkpoint-required'), 200) + writeMarker(primaryMarkerPath(repo, '.post-checkpoint-done'), 200, 'done block\n') + eq('A3: armed + non-empty post-done → null (allow)', decideStop({ repoRoot: repo, sid: null }), null) +} + +// A4 — carve-out applies (baseline newer than all signals) → allow (null) +{ + const repo = mkRepo() + // baseline newest (300); checkpoint-required older (100) → carve-out applies. + writeMarker(primaryMarkerPath(repo, BASELINE_NAME), 300) + writeMarker(primaryMarkerPath(repo, '.checkpoint-required'), 100) + eq('A4: carve-out (baseline > signals) → null (allow)', decideStop({ repoRoot: repo, sid: null }), null) +} + +// A5 — plan-pending ACTIVE (mtime > baseline) → deferral allow even when armed +{ + const repo = mkRepo() + writeMarker(primaryMarkerPath(repo, BASELINE_NAME), 100) + writeMarker(primaryMarkerPath(repo, PLAN_MARKER_LEGACY_BASENAME), 300) // active plan-pending + writeMarker(primaryMarkerPath(repo, '.checkpoint-required'), 300) // also armed mid-session + eq('A5: active plan-pending > baseline → null (deferral allow)', decideStop({ repoRoot: repo, sid: null }), null) +} + +// A6 — symlinked baseline → carve-out fails CLOSED → block (when armed + empty) +{ + const repo = mkRepo() + writeMarker(primaryMarkerPath(repo, '.checkpoint-required'), 200) + // baseline as a symlink → stopGateCarveOutApplies returns false (fail-closed). + fs.symlinkSync('/nonexistent', primaryMarkerPath(repo, BASELINE_NAME)) + const d = decideStop({ repoRoot: repo, sid: null }) + truthy('A6: symlinked baseline + armed → block (fail-closed)', d && d.decision === 'block', `got ${JSON.stringify(d)}`) +} + +// A7 — null sid → legacy-literal resolution (armed legacy literal) → block +{ + const repo = mkRepo() + writeMarker(legacyMarkerPath(repo, '.checkpoint-required'), 200) + const d = decideStop({ repoRoot: repo, sid: null }) + truthy('A7: null sid + legacy-literal armed → block', d && d.decision === 'block', `got ${JSON.stringify(d)}`) +} + +// A8 — own-session suffixed marker → resolved; block reason uses suffixed path +{ + const repo = mkRepo() + const sid = 'sess-a8' + writeMarker(primaryMarkerPath(repo, namespacedMarkerBasenameForSession('.checkpoint-required', sid)), 200) + const d = decideStop({ repoRoot: repo, sid }) + truthy('A8: own-session suffixed armed → block', d && d.decision === 'block', `got ${JSON.stringify(d)}`) + if (d) eq('A8: block reason names the suffixed write path', + d.reason.includes(writeMarkerPath(repo, namespacedMarkerBasenameForSession('.post-checkpoint-done', sid))), true) +} + +console.log('') +console.log('=== B: parity vs em-recall --gate stop (stdout + exit + stderr + no-side-effect) ===') + +// Run a script's `--gate stop` in a given cwd; returns {stdout, stderr, status}. +function runGate(script, cwd, extraArgs = []) { + const r = spawnSync('node', [script, '--gate', 'stop', ...extraArgs], { cwd, encoding: 'utf8' }) + return { stdout: r.stdout, stderr: r.stderr, status: r.status } +} +// Normalize the only allowed stderr delta: the script-name prefix. +function normPrefix(s) { + return s.replace(/^(em-recall|enforce-contract): /gm, '