From 89854ba8e1a0cbb80e720412bd87a8ee67f37f82 Mon Sep 17 00:00:00 2001 From: LantisPrime Date: Sat, 20 Jun 2026 15:47:33 +0800 Subject: [PATCH 1/5] =?UTF-8?q?refactor(rfc-008):=20P4d=20ESC-S1=20?= =?UTF-8?q?=E2=80=94=20shared=20repo-source=20predicate=20lib=20+=20checkp?= =?UTF-8?q?oint-gate=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the 'is this a gated repo-source write?' predicate out of checkpoint-gate.sh into a new shared lib plugins/claude-code/hooks/lib/repo-source.sh (Rule 14: ONE definition, to be adopted by plan-gate.sh in ESC-S2). checkpoint-gate's _tool_call_targets_repo_source() becomes a thin wrapper over the lib's _tool_targets_repo_source_shared(), retaining only the checkpoint-gate-LOCAL _path_verdict_downgrades arming bypass (deliberately NOT shared). The lib adds two carve-outs vs the original: explicit .episodic-memory/** (was caught only by gitignore) and the new docs/plans/** allow (intended, REQ-10) — so plan files are never gated (locked R1). Fail-closed preserved: missing lib now in the lib-presence guard; empty path -> gate. Tests: new tests/test-enforcement-scope.mjs t_checkpoint_parity 3/3 (real deployed gate via mock install); checkpoint-gate regression 373/0; plan-gate 36/0 (untouched). Code review (negative-scenario-reviewer): ACCEPT, no blocker; 9/9 canonicalizer differential match, F1 fail-closed scenario run. Reply episode 20260620-074612. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/claude-code/hooks/checkpoint-gate.sh | 188 ++----------------- plugins/claude-code/hooks/lib/repo-source.sh | 95 ++++++++++ tests/test-enforcement-scope.mjs | 91 +++++++++ 3 files changed, 203 insertions(+), 171 deletions(-) create mode 100644 plugins/claude-code/hooks/lib/repo-source.sh create mode 100644 tests/test-enforcement-scope.mjs diff --git a/plugins/claude-code/hooks/checkpoint-gate.sh b/plugins/claude-code/hooks/checkpoint-gate.sh index 1222481..52ab128 100755 --- a/plugins/claude-code/hooks/checkpoint-gate.sh +++ b/plugins/claude-code/hooks/checkpoint-gate.sh @@ -94,8 +94,8 @@ esac # Use BASH_SOURCE for symlink safety. HOOK_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" LIB_DIR="$HOOK_DIR/lib" -if [ ! -f "$LIB_DIR/command-classifier.sh" ] || [ ! -f "$LIB_DIR/repo-root.sh" ] || [ ! -f "$LIB_DIR/marker-paths.sh" ] || [ ! -f "$LIB_DIR/session-id.sh" ]; then - echo '{"decision": "block", "reason": "checkpoint-gate.sh: hooks/lib/ not found alongside hook (need command-classifier.sh, repo-root.sh, marker-paths.sh, session-id.sh). Re-run install.mjs --install-hooks."}' +if [ ! -f "$LIB_DIR/command-classifier.sh" ] || [ ! -f "$LIB_DIR/repo-root.sh" ] || [ ! -f "$LIB_DIR/marker-paths.sh" ] || [ ! -f "$LIB_DIR/session-id.sh" ] || [ ! -f "$LIB_DIR/repo-source.sh" ]; then + echo '{"decision": "block", "reason": "checkpoint-gate.sh: hooks/lib/ not found alongside hook (need command-classifier.sh, repo-root.sh, marker-paths.sh, session-id.sh, repo-source.sh). Re-run install.mjs --install-hooks."}' exit 0 fi # shellcheck disable=SC1091 @@ -106,6 +106,8 @@ source "$LIB_DIR/command-classifier.sh" source "$LIB_DIR/marker-paths.sh" # shellcheck disable=SC1091 source "$LIB_DIR/session-id.sh" +# shellcheck disable=SC1091 +source "$LIB_DIR/repo-source.sh" REPO_ROOT="$(resolve_repo_root "$CWD")" @@ -842,183 +844,27 @@ _bash_reason_is_unevaluated_novel() { # (methodology negotiation, recommended land-then-review per shape (b)). # --------------------------------------------------------------------------- -# Canonicalize a path that may not exist, may be a broken symlink, or may -# have symlinked ancestors. macOS- and Linux-safe. -# -# Codex R2/P1: existing file/symlink-file targets must canonicalize via -# parent-pwd-P + readlink loop, not walk-up-only. -# Codex R3/P1: broken symlink leaves [ -e ] false, so we also accept [ -L ] -# as "existing surface to resolve." -_canonicalize_possibly_nonexistent() { - local p="$1" - case "$p" in /*) ;; *) p="$PWD/$p" ;; esac - - if [ -e "$p" ] || [ -L "$p" ]; then - if [ -d "$p" ]; then - (cd "$p" 2>/dev/null && pwd -P) || printf '%s' "$p" - return - fi - local parent leaf parent_canon resolved hops=0 - parent="$(dirname "$p")" - leaf="$(basename "$p")" - parent_canon="$( (cd "$parent" 2>/dev/null && pwd -P) || printf '%s' "$parent" )" - resolved="$parent_canon/$leaf" - while [ -L "$resolved" ] && [ $hops -lt 32 ]; do - local target rp_parent rp_leaf rp_parent_canon - target="$(readlink "$resolved")" || break - case "$target" in - /*) resolved="$target" ;; - *) resolved="$(dirname "$resolved")/$target" ;; - esac - hops=$((hops+1)) - rp_parent="$(dirname "$resolved")" - rp_leaf="$(basename "$resolved")" - rp_parent_canon="$( (cd "$rp_parent" 2>/dev/null && pwd -P) || printf '%s' "$rp_parent" )" - resolved="$rp_parent_canon/$rp_leaf" - done - printf '%s' "$resolved" - return - fi - - # Nonexistent and not a symlink: walk up to nearest existing ancestor. - local tail="" cur="$p" - while [ -n "$cur" ] && [ ! -e "$cur" ] && [ ! -L "$cur" ]; do - tail="/$(basename "$cur")${tail}" - local up - up="$(dirname "$cur")" - [ "$up" = "$cur" ] && break - cur="$up" - done - if [ -e "$cur" ] || [ -L "$cur" ]; then - if [ -d "$cur" ]; then - local cur_canon - cur_canon="$( (cd "$cur" 2>/dev/null && pwd -P) || printf '%s' "$cur" )" - printf '%s%s' "$cur_canon" "$tail" - else - # Non-directory ancestor (file or broken symlink) — recurse on $cur. - # Bounded: recursive call hits the first branch immediately (depth ≤ 1). - local cur_canon - cur_canon="$(_canonicalize_possibly_nonexistent "$cur")" - printf '%s%s' "$cur_canon" "$tail" - fi - else - printf '%s' "$p" - fi -} +# _canonicalize_possibly_nonexistent() moved to lib/repo-source.sh (Rule 14: +# ONE definition, shared with plan-gate.sh). Sourced above. # Decide whether the current tool call targets project source. Returns 0 # (yes, repo-touching, block as normal) or 1 (no, off-repo, allow). # -# Bash (codex R1 6b): all non-marker_write Bash returns 0. Smart-arming -# does NOT relieve Bash friction — that's a separate classifier PR. -# Edit/Write/MultiEdit/NotebookEdit: compare FILE_PATH against REPO_ROOT -# via both raw-prefix (catches symlink-out author intent) AND canonical- -# prefix (catches symlink-in / traversal / nonexistent paths). +# Path/label authority is the SHARED predicate (lib/repo-source.sh, §12) — +# the same one plan-gate.sh consults (Rule 14: ONE definition). It covers the +# .review-store / .checkpoints / .git / .episodic-memory / docs/plans carve-outs, +# raw+canonical repo-prefix membership, and the .gitignore deferral. The agent +# path-verdict downgrade below is checkpoint-gate-LOCAL (arming bypass) and is +# deliberately NOT part of the shared predicate / NOT adopted by plan-gate. _tool_call_targets_repo_source() { local repo_root="$1" tool="$2" file_path="$3" label="$4" - - if [ "$tool" = "Bash" ]; then - # Legitimate marker_write allowances exit 0 in the upstream Bash branch - # BEFORE reaching the pre-block site, so any Bash that falls through to - # the predicate is by construction "needs the pre-block." Including - # marker_write that wasn't approved upstream (e.g. POST_DONE write when - # POST_REQ not armed — test 17 regression class). - # PR-B2 §14-F4(a): nonsrc_write joins read_only as "not repo source" for - # consistency with the verdict inversion. (This Bash branch is currently - # reached only defensively — the gate's lone caller is Edit/Write-cased — - # but keeping it aligned with the LABEL taxonomy avoids a latent surprise - # if a future caller routes Bash through this predicate.) - case "$label" in - read_only|nonsrc_write) return 1 ;; - *) return 0 ;; - esac - fi - - # Edit/Write/MultiEdit/NotebookEdit branch. - # Empty path = defensive conservative-block (per R4/P1 fix: relative path - # with no absolute cwd authority sets FILE_PATH="" upstream). - if [ -z "$file_path" ]; then - return 0 - fi - - local repo_canon - repo_canon="$( (cd "$repo_root" 2>/dev/null && pwd -P) || printf '%s' "$repo_root" )" - local fp_canon - fp_canon="$(_canonicalize_possibly_nonexistent "$file_path")" - - # Is the target inside the repo at all? Raw-prefix (codex R1 attack class 3 — - # symlink-out author intent) OR canonical-prefix (symlink-in / traversal / - # nonexistent leaf). Off-repo (memory, skills, settings) → not repo source. - local in_repo=1 - case "$file_path" in - "$repo_root"/*|"$repo_root") in_repo=0 ;; - esac - if [ "$in_repo" != "0" ]; then - case "$fp_canon" in - "$repo_canon"/*|"$repo_canon") in_repo=0 ;; - esac - fi - [ "$in_repo" = "0" ] || return 1 - - # In-repo target. Four downgrades make it NOT count as repo source (PR-B2 §11): - # - # (1) .review-store/ carve-out — second-opinion review artifacts the harness - # stages in-project (.review-store/) are never repo source, so a review - # write must never arm the pre-checkpoint. (.review-store/ is untracked - # but NOT in .gitignore, so it needs its own arm independent of (1c).) - case "$fp_canon" in - "$repo_canon"/.review-store|"$repo_canon"/.review-store/*) return 1 ;; - esac - # - # (1b) .checkpoints/ carve-out — gate infrastructure (markers, classify cache, - # the runbook-ack marker, and the pending command-files the command- - # classification deny-hint itself tells the agent to write to - # /.checkpoints/classify/pending-*.cmd) is never repo source. - # Without this, that prescribed write arms the pre-checkpoint and - # deadlocks the classify protocol (reproduced 2026-05-27). Marker CONTENT - # validation still happens in the marker_write path; this governs ARMING - # only. Canonical-anchored + kept as a hard infra invariant independent of - # .gitignore drift (it ships gitignored, but the gate must never arm on - # its own substrate even if a user edits .gitignore). - case "$fp_canon" in - "$repo_canon"/.checkpoints|"$repo_canon"/.checkpoints/*) return 1 ;; - esac - # - # (1b') .git/ carve-out — git internals (commit-message scratch, PR-body files, - # rebase/merge todo lists, hooks, refs, index) are never tracked repo - # source. git check-ignore does NOT flag .git/ (it is structurally excluded - # from the worktree, not via .gitignore — verified: `git check-ignore - # .git/` exits 1), so without this an Edit/Write under .git/ returns 0 - # (repo source) and would arm the pre-checkpoint AND let EDIT 3 clear a - # satisfied post-checkpoint on a NON-source write. Live E2E (this PR) hit - # exactly that: writing the PR body to .git/ cleared POST_DONE between the - # push and gh pr create. Canonical-anchored; a hard infra invariant - # independent of .gitignore (git never tracks its own dir). No bypass: - # .git/ content is not the worktree, so this cannot smuggle tracked source - # past the gate, and the push-gate still independently blocks pushes. - case "$fp_canon" in - "$repo_canon"/.git|"$repo_canon"/.git/*) return 1 ;; - esac - # - # (1c) .gitignore carve-out — a gitignored target is by definition NOT tracked - # repo source (covers .episodic-memory/ episodes, scratch/, analysis/, - # node_modules/, .codex/, etc.). Defer to git's own notion of "source" - # rather than enumerating directories — gating on an open-ended directory - # list is the enumeration treadmill. Fail-closed: git absent / path - # outside the worktree / not-ignored → fall through and arm conservatively. - if command -v git >/dev/null 2>&1 \ - && git -C "$repo_canon" check-ignore -q -- "$fp_canon" 2>/dev/null; then + # Shared path/label authority (incl. docs/plans carve-out, §12). + _tool_targets_repo_source_shared "$repo_root" "$tool" "$file_path" "$label" || return 1 + # checkpoint-gate-LOCAL arming bypass: agent path-verdict downgrade (NOT shared; + # NOT adopted by plan-gate). Edit/Write only. + if [ "$tool" != "Bash" ] && [ -n "$file_path" ] && _path_verdict_downgrades "$file_path"; then return 1 fi - # - # (2) Path verdict — the agent classified THIS target nonsrc_write/read_only - # via classifier-marker.mjs --target-path. Verdict-over-heuristic - # inversion (de-assume the pure path heuristic): a plan/scratch/doc/ - # generated file the agent declares non-source does not arm. - if _path_verdict_downgrades "$file_path"; then - return 1 - fi - return 0 } diff --git a/plugins/claude-code/hooks/lib/repo-source.sh b/plugins/claude-code/hooks/lib/repo-source.sh new file mode 100644 index 0000000..5115d62 --- /dev/null +++ b/plugins/claude-code/hooks/lib/repo-source.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# repo-source.sh — shared "is this a gated repo-source write?" predicate. +# Sourced by checkpoint-gate.sh AND plan-gate.sh (Rule 14: ONE definition, no drift). +# Pure path/label logic. NO agent self-verdict here (that stays checkpoint-gate-local). + +_canonicalize_possibly_nonexistent() { + local p="$1" + case "$p" in /*) ;; *) p="$PWD/$p" ;; esac + if [ -e "$p" ] || [ -L "$p" ]; then + if [ -d "$p" ]; then + (cd "$p" 2>/dev/null && pwd -P) || printf '%s' "$p" + return + fi + local parent leaf parent_canon resolved hops=0 + parent="$(dirname "$p")"; leaf="$(basename "$p")" + parent_canon="$( (cd "$parent" 2>/dev/null && pwd -P) || printf '%s' "$parent" )" + resolved="$parent_canon/$leaf" + while [ -L "$resolved" ] && [ $hops -lt 32 ]; do + local target + target="$(readlink "$resolved")" || break + case "$target" in + /*) resolved="$target" ;; + *) resolved="$(dirname "$resolved")/$target" ;; + esac + hops=$((hops+1)) + local rp_parent rp_leaf rp_parent_canon + rp_parent="$(dirname "$resolved")"; rp_leaf="$(basename "$resolved")" + rp_parent_canon="$( (cd "$rp_parent" 2>/dev/null && pwd -P) || printf '%s' "$rp_parent" )" + resolved="$rp_parent_canon/$rp_leaf" + done + printf '%s' "$resolved"; return + fi + local tail="" cur="$p" + while [ -n "$cur" ] && [ ! -e "$cur" ] && [ ! -L "$cur" ]; do + tail="/$(basename "$cur")${tail}" + local up; up="$(dirname "$cur")" + [ "$up" = "$cur" ] && break + cur="$up" + done + if [ -e "$cur" ] || [ -L "$cur" ]; then + if [ -d "$cur" ]; then + local cur_canon; cur_canon="$( (cd "$cur" 2>/dev/null && pwd -P) || printf '%s' "$cur" )" + printf '%s%s' "$cur_canon" "$tail" + else + local cur_canon; cur_canon="$(_canonicalize_possibly_nonexistent "$cur")" + printf '%s%s' "$cur_canon" "$tail" + fi + else + printf '%s' "$p" + fi +} + +# §12.1 contract. 0 = gated repo source, 1 = ALLOW. Fail-closed: empty path → 0. +_path_is_repo_source() { + local repo_root="$1" file_path="$2" + [ -n "$file_path" ] || return 0 + local repo_canon fp_canon + repo_canon="$( (cd "$repo_root" 2>/dev/null && pwd -P) || printf '%s' "$repo_root" )" + fp_canon="$(_canonicalize_possibly_nonexistent "$file_path")" + local in_repo=1 + case "$file_path" in "$repo_root"/*|"$repo_root") in_repo=0 ;; esac + if [ "$in_repo" != 0 ]; then + case "$fp_canon" in "$repo_canon"/*|"$repo_canon") in_repo=0 ;; esac + fi + [ "$in_repo" = 0 ] || return 1 + case "$fp_canon" in + "$repo_canon"/.episodic-memory|"$repo_canon"/.episodic-memory/*) return 1 ;; + "$repo_canon"/.checkpoints|"$repo_canon"/.checkpoints/*) return 1 ;; + "$repo_canon"/.review-store|"$repo_canon"/.review-store/*) return 1 ;; + "$repo_canon"/.git|"$repo_canon"/.git/*) return 1 ;; + "$repo_canon"/docs/plans|"$repo_canon"/docs/plans/*) return 1 ;; + esac + if command -v git >/dev/null 2>&1 \ + && git -C "$repo_canon" check-ignore -q -- "$fp_canon" 2>/dev/null; then + return 1 + fi + return 0 +} + +# §12.2 contract. 0 = gated repo-source write, 1 = ALLOW. +_tool_targets_repo_source_shared() { + local repo_root="$1" tool="$2" path="$3" label="$4" + if [ "$tool" = "Bash" ]; then + case "$label" in + read_only|nonsrc_write) return 1 ;; + shared_write|unsafe_complex|push_or_pr_create) + if [ -n "$path" ]; then + _path_is_repo_source "$repo_root" "$path"; return $? + fi + return 0 ;; + *) return 0 ;; + esac + fi + _path_is_repo_source "$repo_root" "$path" +} diff --git a/tests/test-enforcement-scope.mjs b/tests/test-enforcement-scope.mjs new file mode 100644 index 0000000..41baa9d --- /dev/null +++ b/tests/test-enforcement-scope.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// test-enforcement-scope.mjs — RFC-008 P4d "enforcement scope correction" (ESC) suite. +// +// Proves the gates gate ONLY repo-source writes (locked R1/R2/R3): episodes, +// reads, plan files, and off-repo writes are never blocked; repo-source writes +// while a marker is pending still block. +// +// Every test drives the REAL DEPLOYED gate (/.claude/hooks/.sh) +// via runHook against an isolated HOME + git mock project installed by the REAL +// install.mjs — no stubs, no mental tracing +// (feedback_mock_project_test_not_mental_trace, feedback_verify_strong_claim). +// +// Slice ladder: ESC-S1 lands the checkpoint-gate parity test below. ESC-S2 adds +// the plan-gate tests (t_em_allowed_all_states, t_planfile_allowed, +// t_nonsrc_carveouts_allowed, t_reposrc_write_blocked, t_read_always_allowed, +// t_empty_path_blocks_clean, t_consult_fail_closed, t_offrepo_write_allowed, +// t_offrepo_redirect_allowed, t_perproject_isolation, t_missing_lib_fails_closed, +// t_multi_redirect_mixed). ESC-S3 adds t_no_global_touch + full E2E. +// +// Requires bash + jq on PATH (CI ubuntu-latest has both). Zero deps beyond the harness. + +import fs from 'node:fs' +import path from 'node:path' +import { mkMock, runInstall, runHook } from './lib/activation-scoping-harness.mjs' + +let pass = 0, fail = 0 +const ok = (n) => { pass++; console.log(` ✓ ${n}`) } +const bad = (n, d) => { fail++; console.log(` ✗ ${n}: ${d}`) } + +const SID = '11111111-2222-3333-4444-555555555555' +const isBlock = (o) => /"decision"\s*:\s*"block"/.test(o.stdout || '') + +// Install one mock project with enforcement gates deployed per-project. +function freshProject(label) { + const M = mkMock(label) + const r = runInstall({ + home: M.home, project: M.project, callerCwd: M.callerCwd, + flags: ['--install-enforcement'], + }) + if (r.status !== 0) { + console.error(`install failed (${label}):`, r.stderr) + process.exit(1) + } + M.planGate = path.join(M.project, '.claude', 'hooks', 'plan-gate.sh') + M.ckptGate = path.join(M.project, '.claude', 'hooks', 'checkpoint-gate.sh') + return M +} + +const fire = (M, gate, tool, input) => runHook( + gate, + { tool_name: tool, tool_input: input, cwd: M.project, session_id: SID }, + { home: M.home, project: M.project }, +) + +// "arm ckpt" — the checkpoint pre-gate would engage on the next repo-source write. +function armCkpt(M) { + fs.mkdirSync(path.join(M.project, '.checkpoints'), { recursive: true }) + fs.mkdirSync(path.join(M.project, '.claude'), { recursive: true }) + fs.writeFileSync(path.join(M.project, '.checkpoints', '.checkpoint-required'), '') + fs.writeFileSync(path.join(M.project, '.claude', '.checkpoint-required'), '') +} + +const join = (M, rel) => path.join(M.project, rel) + +// ── t_checkpoint_parity (ESC-S1) ──────────────────────────────────────────── +// checkpoint-gate adopts the shared predicate. Parity: a repo-source write still +// engages; the NEW docs/plans carve-out allows; the .episodic-memory substrate +// (gitignored) still allows pre/post (F5). Asserted at the observable runHook +// verdict, never an internal predicate return (§14 note). +function t_checkpoint_parity() { + const M = freshProject('esc-ckpt-parity') + armCkpt(M) + + const src = fire(M, M.ckptGate, 'Write', { file_path: join(M, 'scripts/foo.mjs') }) + const planf = fire(M, M.ckptGate, 'Write', { file_path: join(M, 'docs/plans/x.md') }) + const sub = fire(M, M.ckptGate, 'Write', { file_path: join(M, '.episodic-memory/x.json') }) + + if (isBlock(src)) ok('t_checkpoint_parity: repo-source (scripts/foo.mjs) → engages (block)') + else bad('t_checkpoint_parity: scripts/foo.mjs', `expected block, got stdout="${(src.stdout || '').trim()}" stderr=${(src.stderr || '').slice(-300)}`) + + if (!isBlock(planf)) ok('t_checkpoint_parity: docs/plans/x.md → allowed (new carve-out)') + else bad('t_checkpoint_parity: docs/plans/x.md', `expected allow, got stdout="${(planf.stdout || '').trim()}"`) + + if (!isBlock(sub)) ok('t_checkpoint_parity: .episodic-memory/x.json → allowed (substrate, gitignored)') + else bad('t_checkpoint_parity: .episodic-memory/x.json', `expected allow, got stdout="${(sub.stdout || '').trim()}"`) +} + +t_checkpoint_parity() + +console.log(`\n${fail === 0 ? 'PASS' : 'FAIL'} — ${pass} passed, ${fail} failed`) +process.exit(fail === 0 ? 0 : 1) From 1bf968ac1c031684363e6d1b9953782b9856cfa2 Mon Sep 17 00:00:00 2001 From: LantisPrime Date: Sat, 20 Jun 2026 16:09:30 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(rfc-008):=20P4d=20ESC-S2=20=E2=80=94?= =?UTF-8?q?=20plan-gate=20gates=20only=20repo-source=20writes=20+=20classi?= =?UTF-8?q?fier=20redirect=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan-gate.sh: stop blanket-blocking every Write/Bash while a plan-approval marker is pending. Delegate to ESC-S1's shared predicate (lib/repo-source.sh) so ONLY a repo-source write gates — episodes, reads, NEW plan files (docs/plans), carve-outs, and off-repo writes are never blocked (locked R1/R2/R3). read_only Bash and own-session plan-marker rm/touch still exit 0 early (BLOCKER-B1 cross-session narrowing preserved). Relative Bash redirect TARGET normalized vs CWD; fail-closed lib guard adds repo-source.sh; block message rewritten. command-classifier.sh: the 4 shell-redirect emit arms now populate the TARGET field (REQ-5) instead of an empty middle field, so plan-gate can localize the redirect destination. F2: >1 non-marker redirect clears the (ambiguous) target so plan-gate gates conservatively rather than last-target-wins. lib/repo-source.sh: F1 fix (from S2 code review) — a '..'-traversal path skips the raw-prefix short-circuit so off-repo '..'-relative writes are permitted (R3), matching the absolute-collapsed verdict; the symlink-OUT class-3 defense (no-'..' paths) is preserved. checkpoint-gate parity intact (373/0). Tests: tests/test-enforcement-scope.mjs 16/16 on the real deployed gates (mock install + runHook) — R1/R2/R3 end-to-end incl. F1 regression + traversal-back-into -repo still blocks. Regression: checkpoint-gate 373/0, plan-gate 36/0, classifier 430/0. Review (negative-scenario-reviewer): ACCEPT-WITH-MODIFICATION, no fail-OPEN; F1 fixed inline, F2 deferred to #405 (OD-3/NF-2 residual), F3 NIT. Reply 20260620-080240. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hooks/lib/command-classifier.sh | 15 +- plugins/claude-code/hooks/lib/repo-source.sh | 11 +- plugins/claude-code/hooks/plan-gate.sh | 67 +++--- tests/test-enforcement-scope.mjs | 211 ++++++++++++++++-- 4 files changed, 256 insertions(+), 48 deletions(-) diff --git a/plugins/claude-code/hooks/lib/command-classifier.sh b/plugins/claude-code/hooks/lib/command-classifier.sh index 28cd990..6991b3c 100755 --- a/plugins/claude-code/hooks/lib/command-classifier.sh +++ b/plugins/claude-code/hooks/lib/command-classifier.sh @@ -1136,6 +1136,7 @@ _classify_segment() { # default. local r local has_nonmarker_redirect=0 + local nonmarker_redir_target="" nonmarker_redir_count=0 for r in ${REDIRS[@]+"${REDIRS[@]}"}; do local rop="${r%% *}" local rtarget="${r#* }" @@ -1219,10 +1220,16 @@ _classify_segment() { ;; *) has_nonmarker_redirect=1 + nonmarker_redir_count=$((nonmarker_redir_count+1)) + nonmarker_redir_target="$rtarget" ;; esac done + # F2: >1 non-marker redirect → target is ambiguous (mixed source/off-repo + # destinations). Clear it so plan-gate localizes nothing and gates conservatively. + [ "$nonmarker_redir_count" -gt 1 ] && nonmarker_redir_target="" + # ---- Strip leading env-assignment tokens (VAR=value) ---- # #268 fix F17/F18: also count how many env-prefix tokens were stripped # so plan-marker helper detection (below) can reject command-local env @@ -1740,7 +1747,7 @@ _classify_segment() { printf '%s\n' "$__rd_mv" return 0 fi - printf '%s\t\t%s\n' "shared_write" "readonly_cmd_redirected" + printf '%s\t%s\t%s\n' "shared_write" "$nonmarker_redir_target" "readonly_cmd_redirected" return 0 fi printf '%s\t\t%s\n' "read_only" "readonly_cmd" @@ -1755,7 +1762,7 @@ _classify_segment() { printf '%s\n' "$__ec_mv" return 0 fi - printf '%s\t\t%s\n' "shared_write" "echo_redirected" + printf '%s\t%s\t%s\n' "shared_write" "$nonmarker_redir_target" "echo_redirected" return 0 fi printf '%s\t\t%s\n' "read_only" "echo_or_printf" @@ -1779,7 +1786,7 @@ _classify_segment() { case "$_np_sub" in install|i|ci|add) if [ "$has_nonmarker_redirect" = "1" ]; then - printf '%s\t\t%s\n' "shared_write" "pkg_install_redirected" + printf '%s\t%s\t%s\n' "shared_write" "$nonmarker_redir_target" "pkg_install_redirected" return 0 fi printf '%s\t\t%s\n' "nonsrc_write" "pkg_install" @@ -1795,7 +1802,7 @@ _classify_segment() { # Files later added to the dir classify on their own. A redirect still # demotes to shared_write (the redirect target may be repo source). if [ "$has_nonmarker_redirect" = "1" ]; then - printf '%s\t\t%s\n' "shared_write" "dir_cmd_redirected" + printf '%s\t%s\t%s\n' "shared_write" "$nonmarker_redir_target" "dir_cmd_redirected" return 0 fi printf '%s\t\t%s\n' "nonsrc_write" "dir_create_remove" diff --git a/plugins/claude-code/hooks/lib/repo-source.sh b/plugins/claude-code/hooks/lib/repo-source.sh index 5115d62..cec87ad 100644 --- a/plugins/claude-code/hooks/lib/repo-source.sh +++ b/plugins/claude-code/hooks/lib/repo-source.sh @@ -57,8 +57,17 @@ _path_is_repo_source() { local repo_canon fp_canon repo_canon="$( (cd "$repo_root" 2>/dev/null && pwd -P) || printf '%s' "$repo_root" )" fp_canon="$(_canonicalize_possibly_nonexistent "$file_path")" + # Raw-prefix catches symlink-OUT author intent (a real path literally under the + # repo root that a symlink would resolve outside) → treat as in-repo. BUT a `..` + # traversal segment makes a raw path spuriously match "$repo_root"/* while + # resolving off-repo, so skip the raw short-circuit for those and let + # canonicalization decide (R3: off-repo `..`-relative writes must be permitted, + # not over-blocked; the absolute-collapsed form must give the same verdict). local in_repo=1 - case "$file_path" in "$repo_root"/*|"$repo_root") in_repo=0 ;; esac + case "$file_path" in + ../*|*/../*|*/..|..) ;; # has .. traversal → canonical-only + "$repo_root"/*|"$repo_root") in_repo=0 ;; + esac if [ "$in_repo" != 0 ]; then case "$fp_canon" in "$repo_canon"/*|"$repo_canon") in_repo=0 ;; esac fi diff --git a/plugins/claude-code/hooks/plan-gate.sh b/plugins/claude-code/hooks/plan-gate.sh index 2b9603d..2a14347 100755 --- a/plugins/claude-code/hooks/plan-gate.sh +++ b/plugins/claude-code/hooks/plan-gate.sh @@ -51,8 +51,8 @@ esac # Use BASH_SOURCE so symlinked hook invocations resolve correctly. HOOK_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" LIB_DIR="$HOOK_DIR/lib" -if [ ! -f "$LIB_DIR/command-classifier.sh" ] || [ ! -f "$LIB_DIR/repo-root.sh" ] || [ ! -f "$LIB_DIR/marker-paths.sh" ] || [ ! -f "$LIB_DIR/session-id.sh" ]; then - echo '{"decision": "block", "reason": "plan-gate.sh: hooks/lib/ not found alongside hook (need command-classifier.sh, repo-root.sh, marker-paths.sh, session-id.sh). Re-run install.mjs --install-hooks."}' +if [ ! -f "$LIB_DIR/command-classifier.sh" ] || [ ! -f "$LIB_DIR/repo-root.sh" ] || [ ! -f "$LIB_DIR/marker-paths.sh" ] || [ ! -f "$LIB_DIR/session-id.sh" ] || [ ! -f "$LIB_DIR/repo-source.sh" ]; then + echo '{"decision": "block", "reason": "plan-gate.sh: hooks/lib/ not found alongside hook (need command-classifier.sh, repo-root.sh, marker-paths.sh, session-id.sh, repo-source.sh). Re-run install.mjs --install-hooks."}' exit 0 fi # shellcheck disable=SC1091 @@ -63,6 +63,8 @@ source "$LIB_DIR/command-classifier.sh" source "$LIB_DIR/marker-paths.sh" # shellcheck disable=SC1091 source "$LIB_DIR/session-id.sh" +# shellcheck disable=SC1091 +source "$LIB_DIR/repo-source.sh" REPO_ROOT="$(resolve_repo_root "$CWD")" @@ -156,38 +158,53 @@ if ! $OWN_MARKER_EXISTS && ! $LEGACY_MARKER_EXISTS; then exit 0 fi -# Classifier-driven Bash gating +# Classifier-driven gating — gate ONLY a repo-source write (R1/R2/R3). if [ "$TOOL_NAME" = "Bash" ]; then COMMAND="$(echo "$INPUT" | jq -r '.tool_input.command // ""')" # PR-A P1.1: thread parsed .cwd (absolute-normalized above) as authoritative - # caller cwd. See checkpoint-gate.sh:662 for rationale + codex R1 P1 evidence. + # caller cwd. See checkpoint-gate.sh cwd-binding rationale + codex R1 P1 evidence. RESULT="$(classify_command "$COMMAND" "$REPO_ROOT" "$CWD")" LABEL="${RESULT%% *}" REST="${RESULT#* }" TARGET="${REST%% *}" - case "$LABEL" in - read_only) - exit 0 - ;; - marker_write) - # Allow plan-marker rm/touch under primary or legacy marker dir, but - # ONLY for THIS session's basename (legacy suffix-less OR - # `.plan-approval-pending.`). Other-session suffixed forms - # are NOT allowed — that's the codex r1 BLOCKER-B1 fix: without this - # narrowing, session A could `rm` session B's marker via direct Bash. - target_basename="${TARGET##*/}" - target_dir="${TARGET%/*}" - own_session_basename="$(plan_marker_basename_for_session "$MY_SID")" - if [ "$target_basename" = "$PLAN_MARKER_LEGACY_BASENAME" ] || [ "$target_basename" = "$own_session_basename" ]; then - if [ "$target_dir" = "$REPO_ROOT/$PRIMARY_MARKER_DIR" ] || [ "$target_dir" = "$REPO_ROOT/$LEGACY_MARKER_DIR" ]; then - exit 0 - fi + # read_only → always allowed (R2 / REQ-6) + [ "$LABEL" = "read_only" ] && exit 0 + + # own-session plan-marker rm/touch → allow (deadlock prevention; unchanged + # BLOCKER-B1 narrowing — other-session suffixed markers are NOT allowed). + if [ "$LABEL" = "marker_write" ]; then + target_basename="${TARGET##*/}" + target_dir="${TARGET%/*}" + own_session_basename="$(plan_marker_basename_for_session "$MY_SID")" + if [ "$target_basename" = "$PLAN_MARKER_LEGACY_BASENAME" ] || [ "$target_basename" = "$own_session_basename" ]; then + if [ "$target_dir" = "$REPO_ROOT/$PRIMARY_MARKER_DIR" ] || [ "$target_dir" = "$REPO_ROOT/$LEGACY_MARKER_DIR" ]; then + exit 0 fi - ;; - esac + fi + fi + + # Absolute-normalize a relative redirect TARGET against the caller cwd so the + # predicate localizes it correctly (R3 off-repo redirects). + case "$TARGET" in /*|"") ;; *) TARGET="$CWD/$TARGET" ;; esac + + # Gate ONLY a repo-source Bash write; nonsrc_write/off-repo/carve-out → allow. + if ! _tool_targets_repo_source_shared "$REPO_ROOT" "Bash" "$TARGET" "$LABEL"; then + exit 0 + fi +else + # Edit/Write/MultiEdit/NotebookEdit + FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.notebook_path // ""')" + case "$FILE_PATH" in /*|"") ;; *) FILE_PATH="$CWD/$FILE_PATH" ;; esac + if ! _path_is_repo_source "$REPO_ROOT" "$FILE_PATH"; then + exit 0 + fi fi -# Marker exists (own session or legacy) — block. +# Repo-source write while plan approval pending → block (R2). Episodes, non-repo, +# plan files, and reads are not blocked BY THIS gating step. (NF-1: the F14 +# invalid-session fail-closed path at plan-gate.sh above is an orthogonal +# security block that does gate Bash em-* under a missing/forged session_id — an +# accepted fail-closed exception; Claude Code always supplies a valid session_id.) jq -nc --arg path "$PLAN_PENDING_W" \ - '{decision: "block", reason: ("Plan approval pending. Review the plan above and approve before implementation. To approve, say \"go\" or \"approved\". The " + $path + " marker will be removed and implementation will proceed.")}' + '{decision: "block", reason: ("Plan approval pending. Repo-source write blocked until the plan above is approved (say \"go\" or \"approved\"). Non-repo writes, episodes, plan files, and reads are NOT blocked. Marker: " + $path)}' diff --git a/tests/test-enforcement-scope.mjs b/tests/test-enforcement-scope.mjs index 41baa9d..b293e3a 100644 --- a/tests/test-enforcement-scope.mjs +++ b/tests/test-enforcement-scope.mjs @@ -10,18 +10,15 @@ // install.mjs — no stubs, no mental tracing // (feedback_mock_project_test_not_mental_trace, feedback_verify_strong_claim). // -// Slice ladder: ESC-S1 lands the checkpoint-gate parity test below. ESC-S2 adds -// the plan-gate tests (t_em_allowed_all_states, t_planfile_allowed, -// t_nonsrc_carveouts_allowed, t_reposrc_write_blocked, t_read_always_allowed, -// t_empty_path_blocks_clean, t_consult_fail_closed, t_offrepo_write_allowed, -// t_offrepo_redirect_allowed, t_perproject_isolation, t_missing_lib_fails_closed, -// t_multi_redirect_mixed). ESC-S3 adds t_no_global_touch + full E2E. +// Slice ladder: ESC-S1 landed t_checkpoint_parity (checkpoint-gate adopts the +// shared lib). ESC-S2 adds the plan-gate tests below + F1 (missing-lib fail-closed, +// both gates) + F2 (multi-redirect mixed). ESC-S3 adds t_no_global_touch + CI wire. // // Requires bash + jq on PATH (CI ubuntu-latest has both). Zero deps beyond the harness. import fs from 'node:fs' import path from 'node:path' -import { mkMock, runInstall, runHook } from './lib/activation-scoping-harness.mjs' +import { mkMock, runInstall, runHook, deployedScript } from './lib/activation-scoping-harness.mjs' let pass = 0, fail = 0 const ok = (n) => { pass++; console.log(` ✓ ${n}`) } @@ -52,7 +49,17 @@ const fire = (M, gate, tool, input) => runHook( { home: M.home, project: M.project }, ) -// "arm ckpt" — the checkpoint pre-gate would engage on the next repo-source write. +const join = (M, rel) => path.join(M.project, rel) + +// "arm plan" — write the legacy (suffix-less) plan-approval marker. plan-gate +// honors it in .checkpoints/ (primary) and .claude/ (legacy read path). +function armPlan(M) { + fs.mkdirSync(path.join(M.project, '.checkpoints'), { recursive: true }) + fs.mkdirSync(path.join(M.project, '.claude'), { recursive: true }) + fs.writeFileSync(path.join(M.project, '.checkpoints', '.plan-approval-pending'), '') +} + +// "arm ckpt" — the checkpoint pre-gate engages on the next repo-source write. function armCkpt(M) { fs.mkdirSync(path.join(M.project, '.checkpoints'), { recursive: true }) fs.mkdirSync(path.join(M.project, '.claude'), { recursive: true }) @@ -60,32 +67,200 @@ function armCkpt(M) { fs.writeFileSync(path.join(M.project, '.claude', '.checkpoint-required'), '') } -const join = (M, rel) => path.join(M.project, rel) +const cfgPath = (M) => path.join(M.project, '.episodic-memory', 'enforce-config.json') // ── t_checkpoint_parity (ESC-S1) ──────────────────────────────────────────── // checkpoint-gate adopts the shared predicate. Parity: a repo-source write still // engages; the NEW docs/plans carve-out allows; the .episodic-memory substrate -// (gitignored) still allows pre/post (F5). Asserted at the observable runHook -// verdict, never an internal predicate return (§14 note). +// (gitignored) still allows. Asserted at the observable runHook verdict (§14 note). function t_checkpoint_parity() { const M = freshProject('esc-ckpt-parity') armCkpt(M) - const src = fire(M, M.ckptGate, 'Write', { file_path: join(M, 'scripts/foo.mjs') }) const planf = fire(M, M.ckptGate, 'Write', { file_path: join(M, 'docs/plans/x.md') }) const sub = fire(M, M.ckptGate, 'Write', { file_path: join(M, '.episodic-memory/x.json') }) - if (isBlock(src)) ok('t_checkpoint_parity: repo-source (scripts/foo.mjs) → engages (block)') - else bad('t_checkpoint_parity: scripts/foo.mjs', `expected block, got stdout="${(src.stdout || '').trim()}" stderr=${(src.stderr || '').slice(-300)}`) - + else bad('t_checkpoint_parity: scripts/foo.mjs', `expected block, got "${(src.stdout || '').trim()}" stderr=${(src.stderr || '').slice(-300)}`) if (!isBlock(planf)) ok('t_checkpoint_parity: docs/plans/x.md → allowed (new carve-out)') - else bad('t_checkpoint_parity: docs/plans/x.md', `expected allow, got stdout="${(planf.stdout || '').trim()}"`) + else bad('t_checkpoint_parity: docs/plans/x.md', `expected allow, got "${(planf.stdout || '').trim()}"`) + if (!isBlock(sub)) ok('t_checkpoint_parity: .episodic-memory/x.json → allowed (substrate)') + else bad('t_checkpoint_parity: .episodic-memory/x.json', `expected allow, got "${(sub.stdout || '').trim()}"`) +} + +// ── t_em_allowed_all_states (R1, REQ-1) ───────────────────────────────────── +// em-store (nonsrc_write) is NEVER blocked by either gate, in any marker state. +function t_em_allowed_all_states() { + const M = freshProject('esc-em') + const cmd = `node ${deployedScript(M.home, 'em-store.mjs')} --project test --category decision --summary x --body y` + const states = [ + ['no-marker', () => {}], + ['arm-plan', () => armPlan(M)], + ['arm-plan+ckpt', () => { armPlan(M); armCkpt(M) }], + ] + let allOk = true + for (const [name, arm] of states) { + arm() + for (const gate of [['plan', M.planGate], ['ckpt', M.ckptGate]]) { + const o = fire(M, gate[1], 'Bash', { command: cmd }) + if (isBlock(o)) { allOk = false; bad(`t_em_allowed_all_states[${name}/${gate[0]}]`, `blocked: "${(o.stdout || '').trim()}"`) } + } + } + if (allOk) ok('t_em_allowed_all_states: em-store allowed by both gates across 3 marker states (6 calls)') +} + +// ── t_planfile_allowed (R1, REQ-2) ────────────────────────────────────────── +function t_planfile_allowed() { + const M = freshProject('esc-planfile') + armPlan(M) + const o = fire(M, M.planGate, 'Write', { file_path: join(M, 'docs/plans/new.md') }) + if (!isBlock(o)) ok('t_planfile_allowed: Write docs/plans/new.md under pending plan → allowed') + else bad('t_planfile_allowed', `expected allow, got "${(o.stdout || '').trim()}"`) +} + +// ── t_nonsrc_carveouts_allowed (R1) ───────────────────────────────────────── +function t_nonsrc_carveouts_allowed() { + const M = freshProject('esc-carveouts') + armPlan(M) + const rels = ['.episodic-memory/x.json', '.checkpoints/y', '.review-store/z.md', '.git/COMMIT_EDITMSG'] + let allOk = true + for (const rel of rels) { + const o = fire(M, M.planGate, 'Write', { file_path: join(M, rel) }) + if (isBlock(o)) { allOk = false; bad(`t_nonsrc_carveouts_allowed[${rel}]`, `blocked: "${(o.stdout || '').trim()}"`) } + } + if (allOk) ok('t_nonsrc_carveouts_allowed: .episodic-memory/.checkpoints/.review-store/.git writes allowed') +} - if (!isBlock(sub)) ok('t_checkpoint_parity: .episodic-memory/x.json → allowed (substrate, gitignored)') - else bad('t_checkpoint_parity: .episodic-memory/x.json', `expected allow, got stdout="${(sub.stdout || '').trim()}"`) +// ── t_empty_path_blocks_clean (REQ-12) ────────────────────────────────────── +function t_empty_path_blocks_clean() { + const M = freshProject('esc-empty') + armPlan(M) + const ckDir = path.join(M.project, '.checkpoints') + const before = fs.readdirSync(ckDir).sort() + const o = fire(M, M.planGate, 'Write', {}) + const after = fs.readdirSync(ckDir).sort() + const unchanged = JSON.stringify(before) === JSON.stringify(after) + if (isBlock(o) && unchanged) ok('t_empty_path_blocks_clean: empty path → block, no marker leaked') + else bad('t_empty_path_blocks_clean', `block=${isBlock(o)} unchanged=${unchanged} before=${before} after=${after}`) +} + +// ── t_read_always_allowed (R2, REQ-6) ─────────────────────────────────────── +function t_read_always_allowed() { + const M = freshProject('esc-read') + armPlan(M) + const r1 = fire(M, M.planGate, 'Read', { file_path: join(M, 'scripts/x.mjs') }) + const r2 = fire(M, M.planGate, 'Bash', { command: 'ls -la' }) + if (!isBlock(r1) && !isBlock(r2)) ok('t_read_always_allowed: Read + read-only Bash allowed under pending plan') + else bad('t_read_always_allowed', `read=${isBlock(r1)} ls=${isBlock(r2)}`) +} + +// ── t_reposrc_write_blocked (R2, REQ-7) ───────────────────────────────────── +function t_reposrc_write_blocked() { + const M = freshProject('esc-reposrc') + armPlan(M) + const o = fire(M, M.planGate, 'Write', { file_path: join(M, 'scripts/foo.mjs') }) + if (isBlock(o)) ok('t_reposrc_write_blocked: Write scripts/foo.mjs under pending plan → block') + else bad('t_reposrc_write_blocked', `expected block, got "${(o.stdout || '').trim()}"`) +} + +// ── t_consult_fail_closed (REQ-9, EC6) ────────────────────────────────────── +// Garbage enforce-config → resolver errors → fail-CLOSED (ENFORCE, not silenced). +function t_consult_fail_closed() { + const M = freshProject('esc-failclosed') + armPlan(M) + fs.writeFileSync(cfgPath(M), '{bad json') + const o = fire(M, M.planGate, 'Write', { file_path: join(M, 'scripts/foo.mjs') }) + if (isBlock(o)) ok('t_consult_fail_closed: garbage enforce-config → repo-source write still blocked') + else bad('t_consult_fail_closed', `expected block, got "${(o.stdout || '').trim()}"`) +} + +// ── t_offrepo_write_allowed (R3, EC2) ─────────────────────────────────────── +function t_offrepo_write_allowed() { + const M = freshProject('esc-offrepo') + armPlan(M) + const a = fire(M, M.planGate, 'Write', { file_path: '/tmp/esc-offrepo.txt' }) + // axis-4: a /var-symlinked path that canonicalizes to /private/var on macOS. + const b = fire(M, M.planGate, 'Write', { file_path: '/var/tmp/esc-offrepo-var.txt' }) + if (!isBlock(a) && !isBlock(b)) ok('t_offrepo_write_allowed: /tmp + /var/tmp off-repo writes allowed') + else bad('t_offrepo_write_allowed', `tmp=${isBlock(a)} var=${isBlock(b)}`) +} + +// ── t_offrepo_redirect_allowed (R3, REQ-5, EC3) ───────────────────────────── +function t_offrepo_redirect_allowed() { + const M = freshProject('esc-offrepo-redir') + armPlan(M) + const o = fire(M, M.planGate, 'Bash', { command: 'echo x > /tmp/esc-redir.txt' }) + if (!isBlock(o)) ok('t_offrepo_redirect_allowed: echo x > /tmp/... → allowed (TARGET localized off-repo)') + else bad('t_offrepo_redirect_allowed', `expected allow, got "${(o.stdout || '').trim()}"`) +} + +// ── t_perproject_isolation (R5, EC5) ──────────────────────────────────────── +// Project A active:false silences; project B active:true blocks. No cross-silence. +function t_perproject_isolation() { + const A = freshProject('esc-iso-a') + const B = freshProject('esc-iso-b') + fs.writeFileSync(cfgPath(A), '{"active":false}\n') + fs.writeFileSync(cfgPath(B), '{"active":true}\n') + armPlan(A); armPlan(B) + const oa = fire(A, A.planGate, 'Write', { file_path: join(A, 'scripts/foo.mjs') }) + const ob = fire(B, B.planGate, 'Write', { file_path: join(B, 'scripts/foo.mjs') }) + if (!isBlock(oa) && isBlock(ob)) ok('t_perproject_isolation: A(active:false)→allow, B(active:true)→block — no cross-silence') + else bad('t_perproject_isolation', `A=${isBlock(oa)} B=${isBlock(ob)}`) +} + +// ── t_missing_lib_fails_closed (F1) ───────────────────────────────────────── +// Removing the deployed repo-source.sh fails CLOSED on BOTH gates. +function t_missing_lib_fails_closed() { + const M = freshProject('esc-missinglib') + armPlan(M); armCkpt(M) + fs.rmSync(path.join(M.project, '.claude', 'hooks', 'lib', 'repo-source.sh')) + const p = fire(M, M.planGate, 'Write', { file_path: join(M, 'scripts/foo.mjs') }) + const c = fire(M, M.ckptGate, 'Write', { file_path: join(M, 'scripts/foo.mjs') }) + if (isBlock(p) && isBlock(c)) ok('t_missing_lib_fails_closed: missing repo-source.sh → both gates fail CLOSED') + else bad('t_missing_lib_fails_closed', `plan=${isBlock(p)} ckpt=${isBlock(c)}`) +} + +// ── t_multi_redirect_mixed (F2) ───────────────────────────────────────────── +// Two non-marker redirects (one repo-source, one off-repo) → ambiguous target +// cleared → conservative gate (block), not last-target-wins. +function t_multi_redirect_mixed() { + const M = freshProject('esc-multiredir') + armPlan(M) + const o = fire(M, M.planGate, 'Bash', { command: 'echo x > scripts/foo.mjs 2> /tmp/log' }) + if (isBlock(o)) ok('t_multi_redirect_mixed: echo > scripts/foo.mjs 2> /tmp/log → block (no last-target-wins)') + else bad('t_multi_redirect_mixed', `expected block, got "${(o.stdout || '').trim()}"`) +} + +// ── t_offrepo_relative_escape (R3, F1) ────────────────────────────────────── +// A relative `..`-escape writes OUTSIDE the repo → must be ALLOWED, matching the +// absolute-collapsed form (no `..`-overblock). And traversal BACK into the repo +// must still BLOCK (no fail-OPEN introduced by the `..` carve). +function t_offrepo_relative_escape() { + const M = freshProject('esc-rel-escape') + armPlan(M) + const w = fire(M, M.planGate, 'Write', { file_path: '../escape.txt' }) + const r = fire(M, M.planGate, 'Bash', { command: 'echo hi > ../escape2.txt' }) + const back = fire(M, M.planGate, 'Bash', { command: 'echo x > docs/../scripts/evil.mjs' }) + if (!isBlock(w) && !isBlock(r) && isBlock(back)) { + ok('t_offrepo_relative_escape: ../escape Write+redirect → allow (R3); docs/../scripts/evil.mjs → block (no fail-open)') + } else { + bad('t_offrepo_relative_escape', `write=${isBlock(w)} redirect=${isBlock(r)} back-into-repo=${isBlock(back)}`) + } } t_checkpoint_parity() +t_offrepo_relative_escape() +t_em_allowed_all_states() +t_planfile_allowed() +t_nonsrc_carveouts_allowed() +t_empty_path_blocks_clean() +t_read_always_allowed() +t_reposrc_write_blocked() +t_consult_fail_closed() +t_offrepo_write_allowed() +t_offrepo_redirect_allowed() +t_perproject_isolation() +t_missing_lib_fails_closed() +t_multi_redirect_mixed() console.log(`\n${fail === 0 ? 'PASS' : 'FAIL'} — ${pass} passed, ${fail} failed`) process.exit(fail === 0 ? 0 : 1) From 4b235cfadc27086bb49c0f93c29a2625819cd1e6 Mon Sep 17 00:00:00 2001 From: LantisPrime Date: Sat, 20 Jun 2026 16:43:46 +0800 Subject: [PATCH 3/5] =?UTF-8?q?test(rfc-008):=20P4d=20ESC-S3=20=E2=80=94?= =?UTF-8?q?=20wire=20enforcement-scope=20E2E=20into=20CI=20+=20t=5Fno=5Fgl?= =?UTF-8?q?obal=5Ftouch=20(REQ-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3.1: add `node tests/test-enforcement-scope.mjs` to plan-marker-validate.yml (additive, grouped after the P12 global-clean guardrail). Add t_no_global_touch (REQ-11 / P12): installs via --install-enforcement (same path the suite drives) and asserts hookCodeFilesInGlobalScope(home) === [] — the substrate stays hook-free in global scope; enforcement lands per-project only. Suite: 17 passed, 0 failed against the real deployed gate hooks (runHook). Step 3.2 global deploy + 3.3 deploy-audit deferred to the post-merge moment per the deploy-after-merge discipline (ESC still on its feature branch). Step 3.4: OD-1 stop-gate stale-marker hygiene filed as #408 (5-field defer). Classifier-marker friction tracked separately as #407 (off ESC critical path). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/plan-marker-validate.yml | 3 +++ tests/test-enforcement-scope.mjs | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/plan-marker-validate.yml b/.github/workflows/plan-marker-validate.yml index 0f13c53..d407660 100644 --- a/.github/workflows/plan-marker-validate.yml +++ b/.github/workflows/plan-marker-validate.yml @@ -138,6 +138,9 @@ jobs: - name: Run P12 global-clean guardrail (RFC-008 P4d — global scope has ZERO enforcement hooks/scripts/libs/config across every install variant) run: node tests/test-p12-global-clean.mjs + - name: Run enforcement-scope correction E2E (RFC-008 P4d ESC — gates block ONLY repo-source writes; episodes/reads/plan-files/off-repo allowed; per-project, no global touch) + run: node tests/test-enforcement-scope.mjs + - name: Run install-manifest scope + migration-cutover (RFC-008 P4d — enforcement scope:project excluded from global cutover) run: node tests/test-migration-cutover.mjs diff --git a/tests/test-enforcement-scope.mjs b/tests/test-enforcement-scope.mjs index b293e3a..81c708b 100644 --- a/tests/test-enforcement-scope.mjs +++ b/tests/test-enforcement-scope.mjs @@ -18,7 +18,7 @@ import fs from 'node:fs' import path from 'node:path' -import { mkMock, runInstall, runHook, deployedScript } from './lib/activation-scoping-harness.mjs' +import { mkMock, runInstall, runHook, deployedScript, hookCodeFilesInGlobalScope } from './lib/activation-scoping-harness.mjs' let pass = 0, fail = 0 const ok = (n) => { pass++; console.log(` ✓ ${n}`) } @@ -247,8 +247,24 @@ function t_offrepo_relative_escape() { } } +// ── t_no_global_touch (REQ-11, P12) ───────────────────────────────────────── +// ESC deploys enforcement gates per-project only; the global ~/.claude/hooks/ +// tree stays empty of enforcement code. Asserts the substrate-stays-hook-free +// invariant for the exact --install-enforcement path this suite drives (the +// same install freshProject() uses), not a stub. +function t_no_global_touch() { + const M = freshProject('esc-no-global') + const globalHookCode = hookCodeFilesInGlobalScope(M.home) + if (globalHookCode.length === 0) { + ok('t_no_global_touch: ~/.claude/hooks/ has zero enforcement code after --install-enforcement (REQ-11)') + } else { + bad('t_no_global_touch', `expected [] (P12 global-clean), got ${JSON.stringify(globalHookCode)}`) + } +} + t_checkpoint_parity() t_offrepo_relative_escape() +t_no_global_touch() t_em_allowed_all_states() t_planfile_allowed() t_nonsrc_carveouts_allowed() From 8be1085204ef9edc9e5d38eab48599d7a85a78b7 Mon Sep 17 00:00:00 2001 From: LantisPrime Date: Sat, 20 Jun 2026 17:05:13 +0800 Subject: [PATCH 4/5] =?UTF-8?q?test(rfc-008):=20P4d=20ESC=20=E2=80=94=20fi?= =?UTF-8?q?x=20cross-session=20harness=20drift=20+=20repo-source=20probe?= =?UTF-8?q?=20targets=20(CI=20regression)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESC-S2 made repo-source.sh a REQUIRED lib for plan-gate.sh, but test-plan-marker-cross-session.mjs staged a hardcoded lib list that omitted it. plan-gate.sh's lib-missing guard then blocked EVERY call — flipping X1/X12 (expect-allow) red while X2/X3 (expect-block) passed spuriously off the block. Two-part fix: - mkTmpRepo now stages the WHOLE lib dir (readdir, not a hardcoded list) so a gate gaining a new required lib can't silently re-break this harness. - X1/X2/X3 use a repo-source target (scripts/x.mjs) instead of off-repo /tmp/x: under ESC R3 an off-repo write is correctly ALLOWED, so the block/allow probes must target repo source. X9 keeps /tmp/x on purpose (F14 fail-closed fires before the path check — off-repo makes it a stronger test). Local: node tests/test-plan-marker-cross-session.mjs → 30 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test-plan-marker-cross-session.mjs | 25 ++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/tests/test-plan-marker-cross-session.mjs b/tests/test-plan-marker-cross-session.mjs index 28f8efc..0e89171 100644 --- a/tests/test-plan-marker-cross-session.mjs +++ b/tests/test-plan-marker-cross-session.mjs @@ -49,12 +49,18 @@ function mkTmpRepo() { fs.mkdirSync(path.join(real, '.claude'), { recursive: true }) // Real git init so the resolver finds this as the repo root. spawnSync('git', ['init', '-q'], { cwd: real }) - // Stage the hook + libs the gate sources. + // Stage the hook + ALL libs it sources. Copy the WHOLE lib dir (not a + // hardcoded list): plan-gate.sh hard-fails (lib-missing block) if any required + // lib is absent, so a gate gaining a new required lib must not silently drift + // this harness. Regression: RFC-008 P4d ESC added repo-source.sh as a required + // lib; the old hardcoded list omitted it, blocking every plan-gate call and + // flipping X1/X12 (expect-allow) red while expect-block tests passed spuriously. const hooksLib = path.join(real, 'hooks', 'lib') fs.mkdirSync(hooksLib, { recursive: true }) fs.cpSync(path.join(REPO, 'plugins', 'claude-code', 'hooks', 'plan-gate.sh'), path.join(real, 'hooks', 'plan-gate.sh')) - for (const lib of ['command-classifier.sh', 'repo-root.sh', 'marker-paths.sh', 'session-id.sh']) { - fs.cpSync(path.join(REPO, 'plugins', 'claude-code', 'hooks', 'lib', lib), path.join(hooksLib, lib)) + const srcLib = path.join(REPO, 'plugins', 'claude-code', 'hooks', 'lib') + for (const lib of fs.readdirSync(srcLib)) { + if (lib.endsWith('.sh')) fs.cpSync(path.join(srcLib, lib), path.join(hooksLib, lib)) } cleanups.push(() => fs.rmSync(real, { recursive: true, force: true })) return real @@ -97,18 +103,24 @@ function check(cond, label) { } // ---------------- X1: A's orphan, session B Write → ALLOW (the #268 fix) ---- +// Target is a REPO-SOURCE path: under ESC (R3) an off-repo write would allow +// regardless of the #268 logic, masking this test. A repo-source target proves +// the cross-session orphan genuinely does NOT block a write that otherwise would. { const root = mkTmpRepo() fs.writeFileSync(path.join(root, '.checkpoints', '.plan-approval-pending.session-A'), '') - const r = runPlanGate({ root, sid: 'session-B', toolName: 'Write', toolInput: { file_path: '/tmp/x' } }) + const r = runPlanGate({ root, sid: 'session-B', toolName: 'Write', toolInput: { file_path: path.join(root, 'scripts', 'x.mjs') } }) check(r.decision === 'allow', `X1 cross-session orphan → ALLOW (the #268 fix; got ${r.decision})`) } // ---------------- X2: A's own marker, session A → BLOCK --------------------- +// Repo-source target: ESC (R3) gates ONLY repo-source writes, so an off-repo +// /tmp path would (correctly) ALLOW even with the own marker present. The +// "own marker → block" invariant holds for a repo-source write. { const root = mkTmpRepo() fs.writeFileSync(path.join(root, '.checkpoints', '.plan-approval-pending.session-A'), '') - const r = runPlanGate({ root, sid: 'session-A', toolName: 'Write', toolInput: { file_path: '/tmp/x' } }) + const r = runPlanGate({ root, sid: 'session-A', toolName: 'Write', toolInput: { file_path: path.join(root, 'scripts', 'x.mjs') } }) check(r.decision === 'block', `X2 own marker → BLOCK (got ${r.decision})`) } @@ -116,7 +128,8 @@ function check(cond, label) { { const root = mkTmpRepo() fs.writeFileSync(path.join(root, '.checkpoints', '.plan-approval-pending'), '') - const r = runPlanGate({ root, sid: 'session-A', toolName: 'Write', toolInput: { file_path: '/tmp/x' } }) + // Repo-source target (see X2): ESC gates only repo-source writes under R3. + const r = runPlanGate({ root, sid: 'session-A', toolName: 'Write', toolInput: { file_path: path.join(root, 'scripts', 'x.mjs') } }) check(r.decision === 'block', `X3 legacy → BLOCK (got ${r.decision})`) } From 6fa87af6037ecd8c995eceb2d071ab9cfd9e8935 Mon Sep 17 00:00:00 2001 From: LantisPrime Date: Sat, 20 Jun 2026 17:21:45 +0800 Subject: [PATCH 5/5] ci: re-trigger checks on fresh merge ref (GitHub dropped the 8be1085 synchronize) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No code change — the prior push's PR-synchronize webhook was dropped, so the pull_request merge ref (refs/pull/409/merge) was stale and CI ran the pre-fix tree. This empty commit forces a fresh synchronize + merge-ref recompute so CI executes the harness fix in 8be1085. Co-Authored-By: Claude Opus 4.8 (1M context)