@webpieces/ai-hook-rules 0.4.622 → 0.4.624

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +49 -19
  2. package/package.json +2 -2
  3. package/src/adapters/hook-core.js +13 -14
  4. package/src/adapters/hook-core.js.map +1 -1
  5. package/src/bin/hook-registration.d.ts +160 -50
  6. package/src/bin/hook-registration.js +227 -96
  7. package/src/bin/hook-registration.js.map +1 -1
  8. package/src/bin/managed-env.d.ts +46 -0
  9. package/src/bin/managed-env.js +50 -0
  10. package/src/bin/managed-env.js.map +1 -0
  11. package/src/bin/setup.d.ts +1 -2
  12. package/src/bin/setup.js +38 -46
  13. package/src/bin/setup.js.map +1 -1
  14. package/src/bin/shim-audit-log.js +12 -1
  15. package/src/bin/shim-audit-log.js.map +1 -1
  16. package/src/bin/shim-deny-reason.d.ts +6 -0
  17. package/src/bin/shim-deny-reason.js +82 -0
  18. package/src/bin/shim-deny-reason.js.map +1 -0
  19. package/src/bin/shim.d.ts +0 -1
  20. package/src/bin/shim.js +3 -57
  21. package/src/bin/shim.js.map +1 -1
  22. package/src/bin/upgrade-shim.js +140 -30
  23. package/src/bin/upgrade-shim.js.map +1 -1
  24. package/src/core/decision-log.d.ts +3 -3
  25. package/src/core/decision-log.js +8 -8
  26. package/src/core/decision-log.js.map +1 -1
  27. package/src/core/effective-tree.d.ts +5 -2
  28. package/src/core/effective-tree.js +1 -1
  29. package/src/core/effective-tree.js.map +1 -1
  30. package/src/core/l0-matrix.js +15 -13
  31. package/src/core/l0-matrix.js.map +1 -1
  32. package/src/core/l1-doc.js +29 -68
  33. package/src/core/l1-doc.js.map +1 -1
  34. package/src/core/l1-rows.d.ts +17 -9
  35. package/src/core/l1-rows.js +18 -13
  36. package/src/core/l1-rows.js.map +1 -1
  37. package/src/core/log-stream.d.ts +6 -4
  38. package/src/core/log-stream.js +6 -4
  39. package/src/core/log-stream.js.map +1 -1
  40. package/src/core/log-streams.d.ts +13 -3
  41. package/src/core/log-streams.js +15 -5
  42. package/src/core/log-streams.js.map +1 -1
  43. package/src/core/runner.d.ts +1 -2
  44. package/src/core/runner.js +29 -24
  45. package/src/core/runner.js.map +1 -1
  46. package/src/core/version-sync.d.ts +67 -0
  47. package/src/core/version-sync.js +148 -0
  48. package/src/core/version-sync.js.map +1 -0
  49. package/src/core/webpieces-versions.d.ts +83 -0
  50. package/src/core/webpieces-versions.js +169 -0
  51. package/src/core/webpieces-versions.js.map +1 -0
  52. package/templates/ai-hook.sh +15 -4
  53. package/templates/claude-settings-hook.json +6 -3
  54. package/src/bin/guarantee-root.d.ts +0 -95
  55. package/src/bin/guarantee-root.js +0 -297
  56. package/src/bin/guarantee-root.js.map +0 -1
  57. package/src/core/coordinator-worktree.d.ts +0 -61
  58. package/src/core/coordinator-worktree.js +0 -94
  59. package/src/core/coordinator-worktree.js.map +0 -1
  60. package/templates/guarantee-root.sh +0 -113
@@ -1 +1 @@
1
- {"version":3,"file":"shim-audit-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-audit-log.ts"],"names":[],"mappings":";;;AAAA,0DAAgG;AAChG,qDAAqD;AAErD,2DAA0E;AAE1E,8EAA8E;AAC9E,mDAAmD;AACnD,8FAA8F;AAC9F,uGAAuG;AACvG,qGAAqG;AACrG,mGAAmG;AACnG,mDAAmD;AACnD,EAAE;AACF,qGAAqG;AACrG,mGAAmG;AACnG,uGAAuG;AACvG,EAAE;AACF,sGAAsG;AACtG,sGAAsG;AACtG,uGAAuG;AACvG,oGAAoG;AACpG,sGAAsG;AACtG,sGAAsG;AACtG,qGAAqG;AACrG,yFAAyF;AACzF,EAAE;AACF,iCAAiC;AACjC,oGAAoG;AACpG,sGAAsG;AACtG,wDAAwD;AACxD,oEAAoE;AACpE,8EAA8E;AAE9E;;;;;GAKG;AACU,QAAA,kBAAkB,GAAG,GAAG,GAAG,IAAI,CAAC;AAE7C;;;;;;;;;;;;;;;GAeG;AACU,QAAA,iBAAiB,GAAG;IAC7B,gBAAgB,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY;IAC9E,MAAM,EAAE,YAAY,EAAE,aAAa;CAC7B,CAAC;AAEX;;;;;;GAMG;AACU,QAAA,eAAe,GAAG,CAAC,GAAG,kCAAiB,EAAE,8BAAa,CAAU,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,kBAAkB,GAAG;;;;;2CAKS,gCAAiB,IAAI,6BAAc;;;;;;;;;;;;;;+BAc/C,gCAAiB,IAAI,6BAAc;;;;;+BAKnC,gCAAiB,IAAI,iCAAkB,aAAa,6BAAc;;EAE/F,CAAC;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACU,QAAA,SAAS,GAAG;;EAEvB,0BAAkB;;;;;;;;;0BASM,4BAAc;;;;;;;;;;;;;;;;sBAgBlB,MAAM,CAAC,0BAAkB,CAAC;;;EAG9C,CAAC","sourcesContent":["import { LOGS_STATE_DIR, WORKTREE_STATE_DIR, WEBPIECES_TMP_DIR } from '@webpieces/rules-config';\nimport { L0_SHIM_STREAM } from '../core/log-streams';\n\nimport { L0_FAULT_NONE, L0_SH_FAULT_CODES } from '../core/l0-fault-codes';\n\n// ---------------------------------------------------------------------------\n// THE L0 AUDIT LOG, in POSIX sh — the shim half of\n// `.webpieces/**/logs/L0-shim/<session>-<agent|coordinator>-<binName>.log`. The writer key is\n// the sh twin of ai-hook-rules' LogStream.writerFile(): wp-ai-guards-hook and wp-ai-rules-hook are run\n// IN PARALLEL by Claude Code on every file edit, so an unsplit name means two writers, one file, and\n// torn appends above PIPE_BUF. A payload with no session_id renders 'unknown', never a bare name —\n// there is no un-prefixed spelling on either side.\n//\n// Split out of ./shim.ts (which renders the shim body) purely so both stay readable; shim.ts splices\n// these fragments in verbatim and re-exports the constants. Like l0-allowlist.ts, this module must\n// stay dependency-light: the shim it renders has to work on a tree too broken to load the rule engine.\n//\n// ─── What changed, and why it is not just a bigger log ─────────────────────────────────────────────\n// This log used to be a FAULT log wearing an audit log's name. `wp_log` fired only on the fail-closed\n// path (ALLOW-READ / ALLOW-CONFIG / ALLOW-CURE / DENY*), so a HEALTHY call — the overwhelming majority\n// — exec'd the bin and recorded nothing at all. You could therefore never answer \"what did L0 do to\n// this tool call?\", only \"what did L0 do on the calls where L0 was already broken\". Absence of a line\n// meant either \"healthy\" or \"the shim never ran\", and those are the two answers you most need to tell\n// apart. Every path now logs exactly one line, including the pass-through, so the file can be diffed\n// against the documented matrix in guards/L0-tooling.md rather than merely spot-checked.\n//\n// Two more defects went with it:\n// • it wrote to a hardcoded `$ROOT/.webpieces/logs`, so every worktree's lines landed in one flat\n// file (or, worse, in whichever tree happened to hold the shim) instead of the per-tree namespace\n// the L1 binary has used since the state-dir split;\n// • it had NO rotation, on a file now written on EVERY tool call.\n// ---------------------------------------------------------------------------\n\n/**\n * Rotation threshold, in bytes — 512 KB, the SAME number and the same `.1.log` naming as\n * decision-log.ts / rejection-log.ts / main-sync-log.ts. Deliberately identical rather than merely\n * similar: two log families in one directory with two different retention rules is a trap for whoever\n * later tries to reason about how much history they still have.\n */\nexport const SHIM_LOG_MAX_BYTES = 512 * 1024;\n\n/**\n * The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.\n *\n * The three ALLOW-* and three DENY-* labels are the ones this log has always used and are kept\n * verbatim, so anything already grepping them keeps working. `PASS-BIN-*` is new: it is the healthy\n * case the log used to be silent about.\n *\n * PASS-BIN-ALLOW no sh-side fault; the bin ran and returned 0 → matrix row 1 (no fault → L1)\n * PASS-BIN-BLOCK no sh-side fault; the bin ran and returned 2 → matrix row 1; a LATER layer blocked\n * ALLOW-READ allowlist entry 1 (any Read) → PASS, terminal here (use case 10)\n * ALLOW-CONFIG allowlist entry 2 (webpieces.config.json) → PASS, terminal here\n * ALLOW-CURE allowlist entries 3-8 (a cure command) → ALLOW\n * DENY fault X, not on the allowlist → BLOCK_AI_CURE\n * DENY-STALE fault D, not on the allowlist → BLOCK_AI_CURE\n * DENY-BROKEN fault K, not on the allowlist → BLOCK_AI_CURE\n */\nexport const SHIM_LOG_VERDICTS = [\n 'PASS-BIN-ALLOW', 'PASS-BIN-BLOCK', 'ALLOW-READ', 'ALLOW-CONFIG', 'ALLOW-CURE',\n 'DENY', 'DENY-STALE', 'DENY-BROKEN',\n] as const;\n\n/**\n * The sh-side L0 fault codes, IMPORTED from the one codebook (../core/l0-fault-codes) rather than\n * retyped here — the letters in this file and the letters in `L0_FAULTS` have to be the same letters or\n * the log cannot be reconciled against the matrix. `-` means \"no sh-side fault\": the shim cannot\n * classify S / C / Y, which the BINARY detects and stamps onto its OWN streams with the same `fault=`\n * field, so a `-` here is a statement about this layer only, never a claim that nothing was wrong.\n */\nexport const SHIM_LOG_FAULTS = [...L0_SH_FAULT_CODES, L0_FAULT_NONE] as const;\n\n/**\n * Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +\n * `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.\n *\n * sh cannot import TypeScript, so this derivation is duplicated by necessity; the mitigation is\n * `shim-audit-log.spec.ts`, which runs THIS function through a real /bin/sh in real git worktrees and\n * asserts it returns exactly what `dotWebpieces.worktreeName()` returns. If the two ever disagree the\n * lock goes red rather than the logs quietly splitting in half.\n *\n * It asks git the SAME question the TS side asks — `--git-dir` vs `--git-common-dir`, which differ if\n * and only if this is a linked worktree — but in ONE `rev-parse` (it accepts both flags and prints a\n * line each) rather than two, because this runs on the blocking path of every tool call.\n *\n * The tree is derived from the PAYLOAD's `cwd` (Claude Code documents it as the working directory the\n * hook was invoked from), not from `$ROOT`. `$ROOT` is where the shim FILE lives and stays the anchor\n * for what the drift guard MEASURES — this fragment changes only where the log is WRITTEN.\n *\n * Fails soft, exactly like the TS side: when git cannot answer, the log collapses to\n * `<cwd>/.webpieces/logs`, which is the pre-change behaviour.\n */\nexport const RESOLVE_LOG_DIR_SH = `wp_resolve_log_dir() {\n _wp_rp=\"$(git -C \"$WP_CWD\" rev-parse --git-dir --git-common-dir 2>/dev/null)\"\n _wp_gd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 1p)\"\n _wp_cd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 2p)\"\n if [ -z \"$_wp_gd\" ] || [ -z \"$_wp_cd\" ]; then\n WP_TREE=primary; WP_LOG_DIR=\"$WP_CWD/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"; return 0\n fi\n # git prints a BARE .git from the primary clone and an absolute path from a linked worktree; the TS\n # twin runs path.resolve(cwd, printed), so do the same before comparing or taking a basename.\n case \"$_wp_gd\" in /*) : ;; *) _wp_gd=\"$WP_CWD/$_wp_gd\" ;; esac\n case \"$_wp_cd\" in /*) : ;; *) _wp_cd=\"$WP_CWD/$_wp_cd\" ;; esac\n # The primary clone's root is the parent of the SHARED git dir — declining any layout whose shared\n # dir is not named .git (a bare repo, --separate-git-dir), same test as primaryRoot().\n _wp_primary=\"$WP_CWD\"\n case \"$_wp_cd\" in\n */.git) [ -d \"\\${_wp_cd%/*}\" ] && _wp_primary=\"\\${_wp_cd%/*}\" ;;\n esac\n if [ \"$_wp_gd\" = \"$_wp_cd\" ]; then\n WP_TREE=primary\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"\n else\n # git's OWN name for the worktree (the basename of <primary>/.git/worktrees/<name>), not the\n # directory's basename — two worktrees under different parents may share a directory name.\n WP_TREE=\"\\${_wp_gd##*/}\"\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${WORKTREE_STATE_DIR}/$WP_TREE/${LOGS_STATE_DIR}\"\n fi\n}`;\n\n/**\n * Shell fragment: the audit-log writer itself — `wp_log <fault> <verdict>`, one tab-separated line.\n *\n * FORMAT (7 fields, tab-separated, append-only):\n * <iso-ts> <bin-name> <tool> tree=<name|primary> fault=<D|X|K|-> <VERDICT> <command>\n *\n * `tree=` and `fault=` are the two fields that make the file reconcilable against guards/L0-tooling.md:\n * the first says WHICH checkout produced the line (a shared log across seven worktrees is otherwise\n * unreadable), the second says which of the six documented faults the sh half detected. The verdict\n * keeps its historical spelling and stays adjacent to the command, so `grep 'DENY-STALE\\\\t'` still\n * finds what it always found.\n *\n * NEVER breaks or blocks the hook: the whole body is wrapped so a failure of any kind — unwritable\n * directory, read-only filesystem, missing `git` — is swallowed, and nothing is ever written to\n * stdout (stdout is the PreToolUse decision channel; a stray byte there corrupts allow/deny).\n *\n * The log dir is resolved LAZILY on first use so a call that never logs never pays for the git probe.\n */\nexport const WP_LOG_SH = `WP_TREE=\"\"\nWP_LOG_DIR=\"\"\n${RESOLVE_LOG_DIR_SH}\nwp_clean() { # one path segment from an UNTRUSTED payload id — twin of LogStream's segment()\n printf '%s' \"$1\" | tr -c 'A-Za-z0-9._-' '_' | sed -e 's/\\\\.\\\\{2,\\\\}/_/g' -e 's/^\\\\.\\\\{1,\\\\}/_/' | cut -c1-64\n}\nwp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label\n {\n [ -n \"$WP_LOG_DIR\" ] || wp_resolve_log_dir\n # The LAYER is the directory and the WRITER is the file — same layout the TS writers use, spelled\n # from the same constant so the two halves cannot drift apart.\n _wp_sd=\"$WP_LOG_DIR/${L0_SHIM_STREAM}\"\n mkdir -p \"$_wp_sd\" 2>/dev/null || return 0\n # Same writer key as LogStream.writerFile(): <session>-<agent|coordinator>-<hook>.log. $BIN_NAME\n # IS the hook discriminator here (wp-ai-guards-hook vs wp-ai-rules-hook), and Claude Code runs those\n # two IN PARALLEL on every file edit — without this prefix they append to ONE file and tear above\n # PIPE_BUF. An empty session id renders 'unknown' — this has no bare-name branch, matching\n # LogStream.writerFile(), which has none either.\n # ALWAYS prefixed - a missing session_id renders as 'unknown', never as the shared bare name.\n # Gating this on a non-empty id would drop both parallel hooks back onto one file, which is the\n # torn-append case this exists to remove. Twin of LogStream.writerFile(), which has no bare branch.\n _wp_pfx=\"$(wp_clean \"\\${WP_SID:-unknown}\")-$(wp_clean \"\\${WP_AID:-coordinator}\")-$BIN_NAME\"\n _wp_f=\"$_wp_sd/\\${_wp_pfx}.log\"\n # Rotate at the SAME 512 KB into the SAME .1.log sibling as every JS-side webpieces log. This runs\n # on every tool call, so it is one wc and no more; a size we cannot read counts as 0 (no rotation).\n _wp_sz=\"$(wc -c < \"$_wp_f\" 2>/dev/null | tr -d ' ')\"\n case \"$_wp_sz\" in ''|*[!0-9]*) _wp_sz=0 ;; esac\n [ \"$_wp_sz\" -gt ${String(SHIM_LOG_MAX_BYTES)} ] && mv -f \"$_wp_f\" \"$_wp_sd/\\${_wp_pfx}.1.log\" 2>/dev/null\n printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\n' \"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"$BIN_NAME\" \"$TOOL\" \"tree=$WP_TREE\" \"fault=$1\" \"$2\" \"$CMD_LOG\" >> \"$_wp_f\"\n } 2>/dev/null || true\n}`;\n"]}
1
+ {"version":3,"file":"shim-audit-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-audit-log.ts"],"names":[],"mappings":";;;AAAA,0DAAgG;AAChG,qDAAqD;AAErD,2DAA0E;AAE1E,8EAA8E;AAC9E,mDAAmD;AACnD,8FAA8F;AAC9F,uGAAuG;AACvG,qGAAqG;AACrG,mGAAmG;AACnG,mDAAmD;AACnD,EAAE;AACF,qGAAqG;AACrG,mGAAmG;AACnG,uGAAuG;AACvG,EAAE;AACF,sGAAsG;AACtG,sGAAsG;AACtG,uGAAuG;AACvG,oGAAoG;AACpG,sGAAsG;AACtG,sGAAsG;AACtG,qGAAqG;AACrG,yFAAyF;AACzF,EAAE;AACF,iCAAiC;AACjC,oGAAoG;AACpG,sGAAsG;AACtG,wDAAwD;AACxD,oEAAoE;AACpE,8EAA8E;AAE9E;;;;;GAKG;AACU,QAAA,kBAAkB,GAAG,GAAG,GAAG,IAAI,CAAC;AAE7C;;;;;;;;;;;;;;;GAeG;AACU,QAAA,iBAAiB,GAAG;IAC7B,gBAAgB,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY;IAC9E,MAAM,EAAE,YAAY,EAAE,aAAa;CAC7B,CAAC;AAEX;;;;;;GAMG;AACU,QAAA,eAAe,GAAG,CAAC,GAAG,kCAAiB,EAAE,8BAAa,CAAU,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,kBAAkB,GAAG;;;;;2CAKS,gCAAiB,IAAI,6BAAc;;;;;;;;;;;;;;+BAc/C,gCAAiB,IAAI,6BAAc;;;;;+BAKnC,gCAAiB,IAAI,iCAAkB,aAAa,6BAAc;;EAE/F,CAAC;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACU,QAAA,SAAS,GAAG;;EAEvB,0BAAkB;;;;;;;;;0BASM,4BAAc;;;;;;;;;;;;;;;;sBAgBlB,MAAM,CAAC,0BAAkB,CAAC;;;;;;;;;;;;;;EAc9C,CAAC","sourcesContent":["import { LOGS_STATE_DIR, WORKTREE_STATE_DIR, WEBPIECES_TMP_DIR } from '@webpieces/rules-config';\nimport { L0_SHIM_STREAM } from '../core/log-streams';\n\nimport { L0_FAULT_NONE, L0_SH_FAULT_CODES } from '../core/l0-fault-codes';\n\n// ---------------------------------------------------------------------------\n// THE L0 AUDIT LOG, in POSIX sh — the shim half of\n// `.webpieces/**/logs/L0-shim/<session>-<agent|coordinator>-<binName>.log`. The writer key is\n// the sh twin of ai-hook-rules' LogStream.writerFile(): wp-ai-guards-hook and wp-ai-rules-hook are run\n// IN PARALLEL by Claude Code on every file edit, so an unsplit name means two writers, one file, and\n// torn appends above PIPE_BUF. A payload with no session_id renders 'unknown', never a bare name —\n// there is no un-prefixed spelling on either side.\n//\n// Split out of ./shim.ts (which renders the shim body) purely so both stay readable; shim.ts splices\n// these fragments in verbatim and re-exports the constants. Like l0-allowlist.ts, this module must\n// stay dependency-light: the shim it renders has to work on a tree too broken to load the rule engine.\n//\n// ─── What changed, and why it is not just a bigger log ─────────────────────────────────────────────\n// This log used to be a FAULT log wearing an audit log's name. `wp_log` fired only on the fail-closed\n// path (ALLOW-READ / ALLOW-CONFIG / ALLOW-CURE / DENY*), so a HEALTHY call — the overwhelming majority\n// — exec'd the bin and recorded nothing at all. You could therefore never answer \"what did L0 do to\n// this tool call?\", only \"what did L0 do on the calls where L0 was already broken\". Absence of a line\n// meant either \"healthy\" or \"the shim never ran\", and those are the two answers you most need to tell\n// apart. Every path now logs exactly one line, including the pass-through, so the file can be diffed\n// against the documented matrix in guards/L0-tooling.md rather than merely spot-checked.\n//\n// Two more defects went with it:\n// • it wrote to a hardcoded `$ROOT/.webpieces/logs`, so every worktree's lines landed in one flat\n// file (or, worse, in whichever tree happened to hold the shim) instead of the per-tree namespace\n// the L1 binary has used since the state-dir split;\n// • it had NO rotation, on a file now written on EVERY tool call.\n// ---------------------------------------------------------------------------\n\n/**\n * Rotation threshold, in bytes — 512 KB, the SAME number and the same `.1.log` naming as\n * decision-log.ts / rejection-log.ts / main-sync-log.ts. Deliberately identical rather than merely\n * similar: two log families in one directory with two different retention rules is a trap for whoever\n * later tries to reason about how much history they still have.\n */\nexport const SHIM_LOG_MAX_BYTES = 512 * 1024;\n\n/**\n * The verdict vocabulary one shim invocation can record, and how each maps to guards/L0-tooling.md.\n *\n * The three ALLOW-* and three DENY-* labels are the ones this log has always used and are kept\n * verbatim, so anything already grepping them keeps working. `PASS-BIN-*` is new: it is the healthy\n * case the log used to be silent about.\n *\n * PASS-BIN-ALLOW no sh-side fault; the bin ran and returned 0 → matrix row 1 (no fault → L1)\n * PASS-BIN-BLOCK no sh-side fault; the bin ran and returned 2 → matrix row 1; a LATER layer blocked\n * ALLOW-READ allowlist entry 1 (any Read) → PASS, terminal here (use case 10)\n * ALLOW-CONFIG allowlist entry 2 (webpieces.config.json) → PASS, terminal here\n * ALLOW-CURE allowlist entries 3-8 (a cure command) → ALLOW\n * DENY fault X, not on the allowlist → BLOCK_AI_CURE\n * DENY-STALE fault D, not on the allowlist → BLOCK_AI_CURE\n * DENY-BROKEN fault K, not on the allowlist → BLOCK_AI_CURE\n */\nexport const SHIM_LOG_VERDICTS = [\n 'PASS-BIN-ALLOW', 'PASS-BIN-BLOCK', 'ALLOW-READ', 'ALLOW-CONFIG', 'ALLOW-CURE',\n 'DENY', 'DENY-STALE', 'DENY-BROKEN',\n] as const;\n\n/**\n * The sh-side L0 fault codes, IMPORTED from the one codebook (../core/l0-fault-codes) rather than\n * retyped here — the letters in this file and the letters in `L0_FAULTS` have to be the same letters or\n * the log cannot be reconciled against the matrix. `-` means \"no sh-side fault\": the shim cannot\n * classify S / C / Y, which the BINARY detects and stamps onto its OWN streams with the same `fault=`\n * field, so a `-` here is a statement about this layer only, never a claim that nothing was wrong.\n */\nexport const SHIM_LOG_FAULTS = [...L0_SH_FAULT_CODES, L0_FAULT_NONE] as const;\n\n/**\n * Shell fragment: derive WHERE this call's log belongs — the sh TWIN of `DotWebpieces.local()` +\n * `worktreeName()` + `primaryRoot()` in @webpieces/rules-config.\n *\n * sh cannot import TypeScript, so this derivation is duplicated by necessity; the mitigation is\n * `shim-audit-log.spec.ts`, which runs THIS function through a real /bin/sh in real git worktrees and\n * asserts it returns exactly what `dotWebpieces.worktreeName()` returns. If the two ever disagree the\n * lock goes red rather than the logs quietly splitting in half.\n *\n * It asks git the SAME question the TS side asks — `--git-dir` vs `--git-common-dir`, which differ if\n * and only if this is a linked worktree — but in ONE `rev-parse` (it accepts both flags and prints a\n * line each) rather than two, because this runs on the blocking path of every tool call.\n *\n * The tree is derived from the PAYLOAD's `cwd` (Claude Code documents it as the working directory the\n * hook was invoked from), not from `$ROOT`. `$ROOT` is where the shim FILE lives and stays the anchor\n * for what the drift guard MEASURES — this fragment changes only where the log is WRITTEN.\n *\n * Fails soft, exactly like the TS side: when git cannot answer, the log collapses to\n * `<cwd>/.webpieces/logs`, which is the pre-change behaviour.\n */\nexport const RESOLVE_LOG_DIR_SH = `wp_resolve_log_dir() {\n _wp_rp=\"$(git -C \"$WP_CWD\" rev-parse --git-dir --git-common-dir 2>/dev/null)\"\n _wp_gd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 1p)\"\n _wp_cd=\"$(printf '%s\\\\n' \"$_wp_rp\" | sed -n 2p)\"\n if [ -z \"$_wp_gd\" ] || [ -z \"$_wp_cd\" ]; then\n WP_TREE=primary; WP_LOG_DIR=\"$WP_CWD/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"; return 0\n fi\n # git prints a BARE .git from the primary clone and an absolute path from a linked worktree; the TS\n # twin runs path.resolve(cwd, printed), so do the same before comparing or taking a basename.\n case \"$_wp_gd\" in /*) : ;; *) _wp_gd=\"$WP_CWD/$_wp_gd\" ;; esac\n case \"$_wp_cd\" in /*) : ;; *) _wp_cd=\"$WP_CWD/$_wp_cd\" ;; esac\n # The primary clone's root is the parent of the SHARED git dir — declining any layout whose shared\n # dir is not named .git (a bare repo, --separate-git-dir), same test as primaryRoot().\n _wp_primary=\"$WP_CWD\"\n case \"$_wp_cd\" in\n */.git) [ -d \"\\${_wp_cd%/*}\" ] && _wp_primary=\"\\${_wp_cd%/*}\" ;;\n esac\n if [ \"$_wp_gd\" = \"$_wp_cd\" ]; then\n WP_TREE=primary\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${LOGS_STATE_DIR}\"\n else\n # git's OWN name for the worktree (the basename of <primary>/.git/worktrees/<name>), not the\n # directory's basename — two worktrees under different parents may share a directory name.\n WP_TREE=\"\\${_wp_gd##*/}\"\n WP_LOG_DIR=\"$_wp_primary/${WEBPIECES_TMP_DIR}/${WORKTREE_STATE_DIR}/$WP_TREE/${LOGS_STATE_DIR}\"\n fi\n}`;\n\n/**\n * Shell fragment: the audit-log writer itself — `wp_log <fault> <verdict>`, one tab-separated line.\n *\n * FORMAT (7 fields, tab-separated, append-only):\n * <iso-ts> <bin-name> <tool> tree=<name|primary> fault=<D|X|K|-> <VERDICT> <command>\n *\n * `tree=` and `fault=` are the two fields that make the file reconcilable against guards/L0-tooling.md:\n * the first says WHICH checkout produced the line (a shared log across seven worktrees is otherwise\n * unreadable), the second says which of the six documented faults the sh half detected. The verdict\n * keeps its historical spelling and stays adjacent to the command, so `grep 'DENY-STALE\\\\t'` still\n * finds what it always found.\n *\n * NEVER breaks or blocks the hook: the whole body is wrapped so a failure of any kind — unwritable\n * directory, read-only filesystem, missing `git` — is swallowed, and nothing is ever written to\n * stdout (stdout is the PreToolUse decision channel; a stray byte there corrupts allow/deny).\n *\n * The log dir is resolved LAZILY on first use so a call that never logs never pays for the git probe.\n */\nexport const WP_LOG_SH = `WP_TREE=\"\"\nWP_LOG_DIR=\"\"\n${RESOLVE_LOG_DIR_SH}\nwp_clean() { # one path segment from an UNTRUSTED payload id — twin of LogStream's segment()\n printf '%s' \"$1\" | tr -c 'A-Za-z0-9._-' '_' | sed -e 's/\\\\.\\\\{2,\\\\}/_/g' -e 's/^\\\\.\\\\{1,\\\\}/_/' | cut -c1-64\n}\nwp_log() { # $1 = L0 fault code (D|X|K|-), $2 = verdict label\n {\n [ -n \"$WP_LOG_DIR\" ] || wp_resolve_log_dir\n # The LAYER is the directory and the WRITER is the file — same layout the TS writers use, spelled\n # from the same constant so the two halves cannot drift apart.\n _wp_sd=\"$WP_LOG_DIR/${L0_SHIM_STREAM}\"\n mkdir -p \"$_wp_sd\" 2>/dev/null || return 0\n # Same writer key as LogStream.writerFile(): <session>-<agent|coordinator>-<hook>.log. $BIN_NAME\n # IS the hook discriminator here (wp-ai-guards-hook vs wp-ai-rules-hook), and Claude Code runs those\n # two IN PARALLEL on every file edit — without this prefix they append to ONE file and tear above\n # PIPE_BUF. An empty session id renders 'unknown' — this has no bare-name branch, matching\n # LogStream.writerFile(), which has none either.\n # ALWAYS prefixed - a missing session_id renders as 'unknown', never as the shared bare name.\n # Gating this on a non-empty id would drop both parallel hooks back onto one file, which is the\n # torn-append case this exists to remove. Twin of LogStream.writerFile(), which has no bare branch.\n _wp_pfx=\"$(wp_clean \"\\${WP_SID:-unknown}\")-$(wp_clean \"\\${WP_AID:-coordinator}\")-$BIN_NAME\"\n _wp_f=\"$_wp_sd/\\${_wp_pfx}.log\"\n # Rotate at the SAME 512 KB into the SAME .1.log sibling as every JS-side webpieces log. This runs\n # on every tool call, so it is one wc and no more; a size we cannot read counts as 0 (no rotation).\n _wp_sz=\"$(wc -c < \"$_wp_f\" 2>/dev/null | tr -d ' ')\"\n case \"$_wp_sz\" in ''|*[!0-9]*) _wp_sz=0 ;; esac\n [ \"$_wp_sz\" -gt ${String(SHIM_LOG_MAX_BYTES)} ] && mv -f \"$_wp_f\" \"$_wp_sd/\\${_wp_pfx}.1.log\" 2>/dev/null\n # shim= and bin= are the two facts this log could not previously answer, and they are the ones that\n # decide whether a tree was governed by its OWN release or a borrowed one:\n # shim= WHICH COPY OF ai-hook.sh RAN — $ROOT, resolved from $0. The file is TRACKED, so every\n # worktree carries the version at ITS commit; settings.json registers it ABSOLUTE, so the copy\n # that runs is the SESSION ROOT's. Logged rather than assumed.\n # bin= WHICH TREE SUPPLIED THE BINARY — $BIN_ROOT, the upward walk's answer. A fresh linked\n # worktree has no node_modules, so this is normally the PRIMARY even when shim= is not:\n # per-tree governance is the script and the config, never the enforcement code.\n # Until these existed, no log at any layer recorded either (L-1 logged neither, L0 logged neither,\n # only L1 logged root=/projectDir=), so \"which hook governed this call\" had to be inferred. Compare\n # shim= against bin= to see a borrow, and either against the tree to see a straddle.\n printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\n' \"$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"$BIN_NAME\" \"$TOOL\" \"tree=$WP_TREE\" \"shim=$ROOT\" \"bin=$BIN_ROOT\" \"fault=$1\" \"$2\" \"$CMD_LOG\" >> \"$_wp_f\"\n } 2>/dev/null || true\n}`;\n"]}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * THE FAIL-CLOSED DENY TEXT for a drifted managed hook surface (L0 fault S) — its own module because
3
+ * shim.ts is at its line cap and this is one cohesive unit: the words a blocked agent reads, and
4
+ * nothing else. It imports FROM shim.ts and is never imported BY it, so the graph stays acyclic.
5
+ */
6
+ export declare function shimStaleDenyReason(installedVersion: string, root: string, drifted: readonly string[], inSubagent: boolean): string;
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.shimStaleDenyReason = shimStaleDenyReason;
4
+ const rules_config_1 = require("@webpieces/rules-config");
5
+ const l0_allowlist_1 = require("./l0-allowlist");
6
+ const managed_env_1 = require("./managed-env");
7
+ const shim_1 = require("./shim");
8
+ /**
9
+ * THE FAIL-CLOSED DENY TEXT for a drifted managed hook surface (L0 fault S) — its own module because
10
+ * shim.ts is at its line cap and this is one cohesive unit: the words a blocked agent reads, and
11
+ * nothing else. It imports FROM shim.ts and is never imported BY it, so the graph stays acyclic.
12
+ */
13
+ // The fail-closed deny text for a drifted MANAGED HOOK SURFACE, built from the single-source cure
14
+ // constants + NO_CHAINING_RULE.
15
+ //
16
+ // `drifted` names WHICH of the three managed things moved — .claude/webpieces/ai-hook.sh, the
17
+ // .claude/settings.json hook registration, and its managed env entry
18
+ // CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR (see hook-registration.ts). It is REQUIRED, not optional:
19
+ // this used to be a shim-only message, and an optional list would let a caller silently keep emitting
20
+ // the one-file text after the surface grew — which is the "two spellings of one thing" shape the
21
+ // compatibility policy rejects.
22
+ //
23
+ // It was FOUR. guarantee-root.sh (L-1) is gone: the guard hooks are registered ABSOLUTE now, so the
24
+ // launch guarantee L-1 provided is structural and there is no second .sh file to keep byte-locked.
25
+ //
26
+ // `installedVersion` names WHICH webpieces the cure re-arms to (the binary is that
27
+ // version); pass '' to omit the note rather than print an empty one. `root` is the tree the deciding
28
+ // binary GOVERNS (governingShimRoot) — naming it, and anchoring the cure to it with a leading
29
+ // `cd <root> &&` (which CD_PREFIX_*_ANCHORED already tolerates, locked by a unit test), is what keeps
30
+ // the cure curable when the AI's cwd is a DIFFERENT tree than the one being judged. Pass '' to omit.
31
+ //
32
+ // The cause list is deliberately a LIST: it used to assert flatly "(it was reverted or hand-edited)",
33
+ // which is frequently FALSE — the common case is a shim whose logic simply predates this binary — and
34
+ // that false certainty sent a real agent hunting for a tamper that never happened.
35
+ //
36
+ // WHERE IT WAS MEASURED, in the deny itself and not only in the logs. #574 put `root=` and
37
+ // `projectDir=` on every L1 invocation line (see decision-log / ClaudeEnv.projectDirForLog, whose
38
+ // `<unset>` token keeps "variable absent" distinguishable from "set to empty"). The log is forensics
39
+ // AFTER the fact; the deny is what a blocked agent reads IN the moment, and the absence of exactly
40
+ // these two fields is what sent a real agent chasing the wrong mechanism for four cures. Same field
41
+ // names on purpose, so the deny text and the log lines grep together.
42
+ //
43
+ // CONSTRAINT: the returned string must contain no `"` and no `\` — it is JSON-serialized by denyJson()
44
+ // (a stray quote/backslash would corrupt the PreToolUse decision payload, not just the text). That is an
45
+ // INVARIANT, not a hope, so every interpolated path is STRIPPED of both rather than trusted — a
46
+ // Windows-style path or an odd directory name must not be able to corrupt the decision. Locked by unit
47
+ // tests. An unusual root is also dropped from the `cd` cure rather than quoted (CD_PREFIX would reject it).
48
+ //
49
+ // `inSubagent` comes from the PreToolUse payload's `agent_id`, which Claude Code delivers on stdin and
50
+ // populates ONLY off the main loop (main falls back to the session id, so the field is absent there).
51
+ // `agent_type` is NOT usable for this — it is always populated and discriminates nothing. A subagent
52
+ // needs one extra sentence, because the hooks that are blocking it resolve through $CLAUDE_PROJECT_DIR,
53
+ // which names the MAIN tree: a cure run only in its own worktree cannot lift the block. It is a
54
+ // REQUIRED parameter for the same reason `drifted` is — an optional flag would let a caller keep
55
+ // emitting the main-loop text from a subagent, which is the case that most needs the extra line.
56
+ // webpieces-disable no-function-outside-class -- pure string builder over exported constants; the single source of the self-guard deny text now that the sh copy is gone.
57
+ function shimStaleDenyReason(installedVersion, root, drifted, inSubagent) {
58
+ const verNote = installedVersion ? ` (installed version ${installedVersion})` : '';
59
+ const what = drifted.join(', ');
60
+ const safeRoot = root.replace(/["\\]/g, '');
61
+ const projectDir = rules_config_1.claudeEnv.projectDirForLog().replace(/["\\]/g, '');
62
+ // Tested against the RAW root, never the stripped one: stripping is a display-safety measure, and
63
+ // cd-anchoring to a path we just mangled would prescribe a cd into a directory that does not exist.
64
+ // A root CD_PREFIX cannot express is simply not offered as a `cd` (raw ok ⇒ safeRoot === root).
65
+ const cdOk = root !== '' && /^[A-Za-z0-9._/@~+-]+$/.test(root);
66
+ // Agreement is the routine case; DISAGREEMENT is the signature of the session-root-vs-cwd split this
67
+ // guard was rewritten to make unconstructible, so it gets said out loud rather than left to inference.
68
+ const verdict = safeRoot === projectDir
69
+ ? 'These two AGREE, so this is the ordinary case - the tree you are in is the tree being judged.'
70
+ : 'These two DISAGREE - the tree being judged is NOT the one CLAUDE_PROJECT_DIR names, so cure the root= tree specifically and do not assume your current directory is it.';
71
+ const rootNote = safeRoot === '' ? '' : ` WHERE THIS WAS MEASURED: root=${safeRoot} (the tree the RUNNING guard binary itself came from - that is the tree whose shim must change), projectDir=${projectDir} (CLAUDE_PROJECT_DIR as this process sees it; <unset> means the variable is absent, which is not the same as set-but-empty). ${verdict}`;
72
+ const upgrade = cdOk ? `cd ${safeRoot} && ${l0_allowlist_1.UPGRADE_SHIM_CMD}` : l0_allowlist_1.UPGRADE_SHIM_CMD;
73
+ // OPTION 2 is a relative-path `cp`, so it is even MORE cwd-sensitive than OPTION 1 — anchor it too.
74
+ const restore = cdOk ? `cd ${safeRoot} && ${l0_allowlist_1.RESTORE_SHIM_CMD}` : l0_allowlist_1.RESTORE_SHIM_CMD;
75
+ // The subagent sentence goes BEFORE the options, so it is read before a cure is chosen rather than
76
+ // after one has already been run in the wrong tree.
77
+ const subagentNote = inSubagent
78
+ ? ' YOU ARE RUNNING IN A SUBAGENT: the hooks blocking you resolve through CLAUDE_PROJECT_DIR, which names the MAIN tree and not yours, so a cure run only here CANNOT lift this block - the MAIN tree is where pnpm install and the repair have to happen, because the hooks execute the release INSTALLED IN THAT TREE, not the one in this worktree. Running the repair in THIS worktree afterwards is also correct and is the aligned end state: it is what makes this tree right once its own branch is the one being judged.'
79
+ : '';
80
+ return `❌ webpieces-managed hook surface was changed: ${what} no longer matches what the INSTALLED @webpieces/ai-hook-rules${verNote} expects (reverted, hand-edited, or predating this binary - a settings.json still on an OLDER form, including the three-hook RELATIVE form with guarantee-root.sh, reports here too).${rootNote} webpieces manages THREE things together and they only work as a set: ${shim_1.SHIM_MARKER} (the guard shim, registered ABSOLUTE via $CLAUDE_PROJECT_DIR so the MAIN tree governs every tree - a worktree never had its own binary anyway, it borrows the main tree's by walking up), the .claude/settings.json entries that register it, and the .claude/settings.json env entry ${managed_env_1.BASH_CWD_ENV_KEY}=${managed_env_1.BASH_CWD_ENV_VALUE} (which pins the Bash cwd to the project root, identically for every subagent because settings env is inherited). They are GENERATED and committed by webpieces - they must NOT be reverted or edited by hand, and the fail-closed logic cannot be trusted while any of them differs. Every OTHER tool call is blocked until all three match again. ${subagentNote} THIS IS NOT A DEADLOCK: both options below are explicitly ALLOWED through while this guard is up, so run one YOURSELF now - do not hand it back to the human. OPTION 1 (preferred, and the ONLY option that repairs all three - it regenerates the shim, rewrites the settings.json registration to the two-hook ABSOLUTE form (removing any older entries, including the retired guarantee-root.sh hook), AND sets the managed env entry; it touches no config, and it imports only fs/path so it runs on a broken tree) - run EXACTLY this command: '${upgrade}'. HOW TO TELL OPTION 1 WORKED, because up to and including 0.4.588 it silently did NOTHING (it had no process entry point, so it printed nothing and exited 0 - which is why this guard could look like a deadlock): a working OPTION 1 PRINTS a line per repaired surface and re-checks all three afterwards, exiting NON-ZERO and naming whatever still differs. So EMPTY OUTPUT means the cure did not run at all - do not re-run it in a loop; upgrade @webpieces/ai-hook-rules, use OPTION 2 for the shim, and say plainly that OPTION 1 is inert in this release. OPTION 2 (a PARTIAL fallback - it repairs ONE of the three, ${shim_1.SHIM_MARKER}, and nothing else; pick it only when the installed @webpieces/ai-hook-rules is OLDER than 0.4.408 so wp-upgrade-shim does not exist yet, then upgrade @webpieces and run OPTION 1 to finish the job. Claude Code's own permission prompt may ask you to confirm the file overwrite, and that prompt is NOT this guard) - run EXACTLY this command: '${restore}'. Do NOT use the bare '${l0_allowlist_1.INSTALL_HOOKS_CMD}' here: it also migrates your config and PROMPTS for a hook target twice, which hangs a non-interactive session. ${shim_1.NO_CHAINING_RULE} Do NOT revert these files again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.`;
81
+ }
82
+ //# sourceMappingURL=shim-deny-reason.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shim-deny-reason.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim-deny-reason.ts"],"names":[],"mappings":";;AAuDA,kDAwBC;AA/ED,0DAAoD;AAEpD,iDAAuF;AACvF,+CAAqE;AACrE,iCAAuD;AAEvD;;;;GAIG;AACH,kGAAkG;AAClG,gCAAgC;AAChC,EAAE;AACF,8FAA8F;AAC9F,qEAAqE;AACrE,qGAAqG;AACrG,sGAAsG;AACtG,iGAAiG;AACjG,gCAAgC;AAChC,EAAE;AACF,oGAAoG;AACpG,mGAAmG;AACnG,EAAE;AACF,mFAAmF;AACnF,qGAAqG;AACrG,8FAA8F;AAC9F,sGAAsG;AACtG,qGAAqG;AACrG,EAAE;AACF,sGAAsG;AACtG,sGAAsG;AACtG,mFAAmF;AACnF,EAAE;AACF,2FAA2F;AAC3F,kGAAkG;AAClG,qGAAqG;AACrG,mGAAmG;AACnG,oGAAoG;AACpG,sEAAsE;AACtE,EAAE;AACF,uGAAuG;AACvG,yGAAyG;AACzG,gGAAgG;AAChG,uGAAuG;AACvG,4GAA4G;AAC5G,EAAE;AACF,uGAAuG;AACvG,sGAAsG;AACtG,qGAAqG;AACrG,wGAAwG;AACxG,gGAAgG;AAChG,iGAAiG;AACjG,iGAAiG;AACjG,0KAA0K;AAC1K,SAAgB,mBAAmB,CAAC,gBAAwB,EAAE,IAAY,EAAE,OAA0B,EAAE,UAAmB;IACvH,MAAM,OAAO,GAAG,gBAAgB,CAAC,CAAC,CAAC,uBAAuB,gBAAgB,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IACnF,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChC,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IAC5C,MAAM,UAAU,GAAG,wBAAS,CAAC,gBAAgB,EAAE,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IACtE,kGAAkG;IAClG,oGAAoG;IACpG,gGAAgG;IAChG,MAAM,IAAI,GAAG,IAAI,KAAK,EAAE,IAAI,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC/D,qGAAqG;IACrG,uGAAuG;IACvG,MAAM,OAAO,GAAG,QAAQ,KAAK,UAAU;QACnC,CAAC,CAAC,+FAA+F;QACjG,CAAC,CAAC,yKAAyK,CAAC;IAChL,MAAM,QAAQ,GAAG,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,kCAAkC,QAAQ,+GAA+G,UAAU,gIAAgI,OAAO,EAAE,CAAC;IACrV,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,MAAM,QAAQ,OAAO,+BAAgB,EAAE,CAAC,CAAC,CAAC,+BAAgB,CAAC;IAClF,oGAAoG;IACpG,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,MAAM,QAAQ,OAAO,+BAAgB,EAAE,CAAC,CAAC,CAAC,+BAAgB,CAAC;IAClF,mGAAmG;IACnG,oDAAoD;IACpD,MAAM,YAAY,GAAG,UAAU;QAC3B,CAAC,CAAC,ggBAAggB;QAClgB,CAAC,CAAC,EAAE,CAAC;IACT,OAAO,iDAAiD,IAAI,iEAAiE,OAAO,wLAAwL,QAAQ,yEAAyE,kBAAW,0RAA0R,8BAAgB,IAAI,gCAAkB,uVAAuV,YAAY,2hBAA2hB,OAAO,wmBAAwmB,kBAAW,wVAAwV,OAAO,2BAA2B,gCAAiB,oHAAoH,uBAAgB,0IAA0I,CAAC;AAC92F,CAAC","sourcesContent":["import { claudeEnv } from '@webpieces/rules-config';\n\nimport { INSTALL_HOOKS_CMD, RESTORE_SHIM_CMD, UPGRADE_SHIM_CMD } from './l0-allowlist';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\nimport { NO_CHAINING_RULE, SHIM_MARKER } from './shim';\n\n/**\n * THE FAIL-CLOSED DENY TEXT for a drifted managed hook surface (L0 fault S) — its own module because\n * shim.ts is at its line cap and this is one cohesive unit: the words a blocked agent reads, and\n * nothing else. It imports FROM shim.ts and is never imported BY it, so the graph stays acyclic.\n */\n// The fail-closed deny text for a drifted MANAGED HOOK SURFACE, built from the single-source cure\n// constants + NO_CHAINING_RULE.\n//\n// `drifted` names WHICH of the three managed things moved — .claude/webpieces/ai-hook.sh, the\n// .claude/settings.json hook registration, and its managed env entry\n// CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR (see hook-registration.ts). It is REQUIRED, not optional:\n// this used to be a shim-only message, and an optional list would let a caller silently keep emitting\n// the one-file text after the surface grew — which is the \"two spellings of one thing\" shape the\n// compatibility policy rejects.\n//\n// It was FOUR. guarantee-root.sh (L-1) is gone: the guard hooks are registered ABSOLUTE now, so the\n// launch guarantee L-1 provided is structural and there is no second .sh file to keep byte-locked.\n//\n// `installedVersion` names WHICH webpieces the cure re-arms to (the binary is that\n// version); pass '' to omit the note rather than print an empty one. `root` is the tree the deciding\n// binary GOVERNS (governingShimRoot) — naming it, and anchoring the cure to it with a leading\n// `cd <root> &&` (which CD_PREFIX_*_ANCHORED already tolerates, locked by a unit test), is what keeps\n// the cure curable when the AI's cwd is a DIFFERENT tree than the one being judged. Pass '' to omit.\n//\n// The cause list is deliberately a LIST: it used to assert flatly \"(it was reverted or hand-edited)\",\n// which is frequently FALSE — the common case is a shim whose logic simply predates this binary — and\n// that false certainty sent a real agent hunting for a tamper that never happened.\n//\n// WHERE IT WAS MEASURED, in the deny itself and not only in the logs. #574 put `root=` and\n// `projectDir=` on every L1 invocation line (see decision-log / ClaudeEnv.projectDirForLog, whose\n// `<unset>` token keeps \"variable absent\" distinguishable from \"set to empty\"). The log is forensics\n// AFTER the fact; the deny is what a blocked agent reads IN the moment, and the absence of exactly\n// these two fields is what sent a real agent chasing the wrong mechanism for four cures. Same field\n// names on purpose, so the deny text and the log lines grep together.\n//\n// CONSTRAINT: the returned string must contain no `\"` and no `\\` — it is JSON-serialized by denyJson()\n// (a stray quote/backslash would corrupt the PreToolUse decision payload, not just the text). That is an\n// INVARIANT, not a hope, so every interpolated path is STRIPPED of both rather than trusted — a\n// Windows-style path or an odd directory name must not be able to corrupt the decision. Locked by unit\n// tests. An unusual root is also dropped from the `cd` cure rather than quoted (CD_PREFIX would reject it).\n//\n// `inSubagent` comes from the PreToolUse payload's `agent_id`, which Claude Code delivers on stdin and\n// populates ONLY off the main loop (main falls back to the session id, so the field is absent there).\n// `agent_type` is NOT usable for this — it is always populated and discriminates nothing. A subagent\n// needs one extra sentence, because the hooks that are blocking it resolve through $CLAUDE_PROJECT_DIR,\n// which names the MAIN tree: a cure run only in its own worktree cannot lift the block. It is a\n// REQUIRED parameter for the same reason `drifted` is — an optional flag would let a caller keep\n// emitting the main-loop text from a subagent, which is the case that most needs the extra line.\n// webpieces-disable no-function-outside-class -- pure string builder over exported constants; the single source of the self-guard deny text now that the sh copy is gone.\nexport function shimStaleDenyReason(installedVersion: string, root: string, drifted: readonly string[], inSubagent: boolean): string {\n const verNote = installedVersion ? ` (installed version ${installedVersion})` : '';\n const what = drifted.join(', ');\n const safeRoot = root.replace(/[\"\\\\]/g, '');\n const projectDir = claudeEnv.projectDirForLog().replace(/[\"\\\\]/g, '');\n // Tested against the RAW root, never the stripped one: stripping is a display-safety measure, and\n // cd-anchoring to a path we just mangled would prescribe a cd into a directory that does not exist.\n // A root CD_PREFIX cannot express is simply not offered as a `cd` (raw ok ⇒ safeRoot === root).\n const cdOk = root !== '' && /^[A-Za-z0-9._/@~+-]+$/.test(root);\n // Agreement is the routine case; DISAGREEMENT is the signature of the session-root-vs-cwd split this\n // guard was rewritten to make unconstructible, so it gets said out loud rather than left to inference.\n const verdict = safeRoot === projectDir\n ? 'These two AGREE, so this is the ordinary case - the tree you are in is the tree being judged.'\n : 'These two DISAGREE - the tree being judged is NOT the one CLAUDE_PROJECT_DIR names, so cure the root= tree specifically and do not assume your current directory is it.';\n const rootNote = safeRoot === '' ? '' : ` WHERE THIS WAS MEASURED: root=${safeRoot} (the tree the RUNNING guard binary itself came from - that is the tree whose shim must change), projectDir=${projectDir} (CLAUDE_PROJECT_DIR as this process sees it; <unset> means the variable is absent, which is not the same as set-but-empty). ${verdict}`;\n const upgrade = cdOk ? `cd ${safeRoot} && ${UPGRADE_SHIM_CMD}` : UPGRADE_SHIM_CMD;\n // OPTION 2 is a relative-path `cp`, so it is even MORE cwd-sensitive than OPTION 1 — anchor it too.\n const restore = cdOk ? `cd ${safeRoot} && ${RESTORE_SHIM_CMD}` : RESTORE_SHIM_CMD;\n // The subagent sentence goes BEFORE the options, so it is read before a cure is chosen rather than\n // after one has already been run in the wrong tree.\n const subagentNote = inSubagent\n ? ' YOU ARE RUNNING IN A SUBAGENT: the hooks blocking you resolve through CLAUDE_PROJECT_DIR, which names the MAIN tree and not yours, so a cure run only here CANNOT lift this block - the MAIN tree is where pnpm install and the repair have to happen, because the hooks execute the release INSTALLED IN THAT TREE, not the one in this worktree. Running the repair in THIS worktree afterwards is also correct and is the aligned end state: it is what makes this tree right once its own branch is the one being judged.'\n : '';\n return `❌ webpieces-managed hook surface was changed: ${what} no longer matches what the INSTALLED @webpieces/ai-hook-rules${verNote} expects (reverted, hand-edited, or predating this binary - a settings.json still on an OLDER form, including the three-hook RELATIVE form with guarantee-root.sh, reports here too).${rootNote} webpieces manages THREE things together and they only work as a set: ${SHIM_MARKER} (the guard shim, registered ABSOLUTE via $CLAUDE_PROJECT_DIR so the MAIN tree governs every tree - a worktree never had its own binary anyway, it borrows the main tree's by walking up), the .claude/settings.json entries that register it, and the .claude/settings.json env entry ${BASH_CWD_ENV_KEY}=${BASH_CWD_ENV_VALUE} (which pins the Bash cwd to the project root, identically for every subagent because settings env is inherited). They are GENERATED and committed by webpieces - they must NOT be reverted or edited by hand, and the fail-closed logic cannot be trusted while any of them differs. Every OTHER tool call is blocked until all three match again. ${subagentNote} THIS IS NOT A DEADLOCK: both options below are explicitly ALLOWED through while this guard is up, so run one YOURSELF now - do not hand it back to the human. OPTION 1 (preferred, and the ONLY option that repairs all three - it regenerates the shim, rewrites the settings.json registration to the two-hook ABSOLUTE form (removing any older entries, including the retired guarantee-root.sh hook), AND sets the managed env entry; it touches no config, and it imports only fs/path so it runs on a broken tree) - run EXACTLY this command: '${upgrade}'. HOW TO TELL OPTION 1 WORKED, because up to and including 0.4.588 it silently did NOTHING (it had no process entry point, so it printed nothing and exited 0 - which is why this guard could look like a deadlock): a working OPTION 1 PRINTS a line per repaired surface and re-checks all three afterwards, exiting NON-ZERO and naming whatever still differs. So EMPTY OUTPUT means the cure did not run at all - do not re-run it in a loop; upgrade @webpieces/ai-hook-rules, use OPTION 2 for the shim, and say plainly that OPTION 1 is inert in this release. OPTION 2 (a PARTIAL fallback - it repairs ONE of the three, ${SHIM_MARKER}, and nothing else; pick it only when the installed @webpieces/ai-hook-rules is OLDER than 0.4.408 so wp-upgrade-shim does not exist yet, then upgrade @webpieces and run OPTION 1 to finish the job. Claude Code's own permission prompt may ask you to confirm the file overwrite, and that prompt is NOT this guard) - run EXACTLY this command: '${restore}'. Do NOT use the bare '${INSTALL_HOOKS_CMD}' here: it also migrates your config and PROMPTS for a hook target twice, which hangs a non-interactive session. ${NO_CHAINING_RULE} Do NOT revert these files again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.`;\n}\n"]}
package/src/bin/shim.d.ts CHANGED
@@ -9,5 +9,4 @@ export declare function healShim(cwd: string): void;
9
9
  export declare function governingShimRoot(moduleDir?: string): string | null;
10
10
  export declare function committedShimStale(root?: string | null): boolean;
11
11
  export declare function isShimCureCommand(command: string): boolean;
12
- export declare function shimStaleDenyReason(installedVersion: string, root: string, drifted: readonly string[]): string;
13
12
  export declare function installedShimRulesVersion(): string;
package/src/bin/shim.js CHANGED
@@ -8,7 +8,6 @@ exports.healShim = healShim;
8
8
  exports.governingShimRoot = governingShimRoot;
9
9
  exports.committedShimStale = committedShimStale;
10
10
  exports.isShimCureCommand = isShimCureCommand;
11
- exports.shimStaleDenyReason = shimStaleDenyReason;
12
11
  exports.installedShimRulesVersion = installedShimRulesVersion;
13
12
  const tslib_1 = require("tslib");
14
13
  const fs = tslib_1.__importStar(require("fs"));
@@ -18,7 +17,6 @@ const l0_fault_codes_1 = require("../core/l0-fault-codes");
18
17
  const to_error_1 = require("../core/to-error");
19
18
  const l0_allowlist_1 = require("./l0-allowlist");
20
19
  const shim_audit_log_1 = require("./shim-audit-log");
21
- const guarantee_root_1 = require("./guarantee-root");
22
20
  // The allowlist moved to ./l0-allowlist (this module was over the file-size limit); re-exported here so
23
21
  // every existing `from './shim'` import keeps working and there is still ONE name to import L0 by.
24
22
  tslib_1.__exportStar(require("./l0-allowlist"), exports);
@@ -199,7 +197,7 @@ WP_INSTALL_CMD="pnpm install"
199
197
  WP_BORROW_NOTE=""
200
198
  if [ "$BIN_ROOT" != "$ROOT" ]; then
201
199
  WP_INSTALL_CMD="cd $ROOT && pnpm install"
202
- WP_BORROW_NOTE=" NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up - which is only correct while the two agree on the version. Run the install HERE, in this tree, so it gets its own node_modules at its own pin."
200
+ WP_BORROW_NOTE=" NOTE: this tree ($ROOT) has NO node_modules of its own, so the guard binary was inherited from $BIN_ROOT by walking up. For a linked WORKTREE that is the DESIGNED state, not a gap - the guard hooks are registered absolute, so the main tree governs every tree and a worktree needs no install of its own. What matters is that the two trees agree on the VERSION, and because the pin is tracked in git the reliable way to get that is the same git hash in both, then ONE 'pnpm install' in the main tree. Installing HERE is legitimate too (adding a dependency does it), but then this tree's own @webpieces must match the main tree's. If you genuinely need a DIFFERENT version, use a separate clone rather than a worktree."
203
201
  fi`;
204
202
  // Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.
205
203
  //
@@ -427,8 +425,8 @@ function renderShim() {
427
425
  # the hook has a stable entry point even when node_modules is absent. Safe to delete along with the
428
426
  # matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.
429
427
  #
430
- # Usage (wired into .claude/settings.json, RELATIVE so each git tree runs its own copy):
431
- # sh ".claude/webpieces/ai-hook.sh" <bin-name>
428
+ # Usage (wired into .claude/settings.json, ABSOLUTE so the MAIN tree governs every tree):
429
+ # sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" <bin-name>
432
430
  BIN_NAME="$1"
433
431
  shift
434
432
  # Resolve the tree relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the
@@ -618,58 +616,6 @@ function isShimCureCommand(command) {
618
616
  const cmd = command.trim();
619
617
  return l0_allowlist_1.INSTALL_HOOKS_ALLOW_JS.test(cmd) || l0_allowlist_1.UPGRADE_SHIM_ALLOW_JS.test(cmd) || l0_allowlist_1.RESTORE_SHIM_ALLOW_JS.test(cmd);
620
618
  }
621
- // The fail-closed deny text for a drifted MANAGED HOOK SURFACE, built from the single-source cure
622
- // constants + NO_CHAINING_RULE.
623
- //
624
- // `drifted` names WHICH of the three managed things moved — .claude/webpieces/ai-hook.sh,
625
- // .claude/webpieces/guarantee-root.sh, and the .claude/settings.json hook registration (see
626
- // hook-registration.ts). It is REQUIRED, not optional: this used to be a shim-only message, and an
627
- // optional list would let a caller silently keep emitting the one-file text after the surface grew to
628
- // three — which is the "two spellings of one thing" shape the compatibility policy rejects.
629
- //
630
- // `installedVersion` names WHICH webpieces the cure re-arms to (the binary is that
631
- // version); pass '' to omit the note rather than print an empty one. `root` is the tree the deciding
632
- // binary GOVERNS (governingShimRoot) — naming it, and anchoring the cure to it with a leading
633
- // `cd <root> &&` (which CD_PREFIX_*_ANCHORED already tolerates, locked by a unit test), is what keeps
634
- // the cure curable when the AI's cwd is a DIFFERENT tree than the one being judged. Pass '' to omit.
635
- //
636
- // The cause list is deliberately a LIST: it used to assert flatly "(it was reverted or hand-edited)",
637
- // which is frequently FALSE — the common case is a shim whose logic simply predates this binary — and
638
- // that false certainty sent a real agent hunting for a tamper that never happened.
639
- //
640
- // WHERE IT WAS MEASURED, in the deny itself and not only in the logs. #574 put `root=` and
641
- // `projectDir=` on every L1 invocation line (see decision-log / ClaudeEnv.projectDirForLog, whose
642
- // `<unset>` token keeps "variable absent" distinguishable from "set to empty"). The log is forensics
643
- // AFTER the fact; the deny is what a blocked agent reads IN the moment, and the absence of exactly
644
- // these two fields is what sent a real agent chasing the wrong mechanism for four cures. Same field
645
- // names on purpose, so the deny text and the log lines grep together.
646
- //
647
- // CONSTRAINT: the returned string must contain no `"` and no `\` — it is JSON-serialized by denyJson()
648
- // (a stray quote/backslash would corrupt the PreToolUse decision payload, not just the text). That is an
649
- // INVARIANT, not a hope, so every interpolated path is STRIPPED of both rather than trusted — a
650
- // Windows-style path or an odd directory name must not be able to corrupt the decision. Locked by unit
651
- // tests. An unusual root is also dropped from the `cd` cure rather than quoted (CD_PREFIX would reject it).
652
- // webpieces-disable no-function-outside-class -- pure string builder over exported constants; the single source of the self-guard deny text now that the sh copy is gone.
653
- function shimStaleDenyReason(installedVersion, root, drifted) {
654
- const verNote = installedVersion ? ` (installed version ${installedVersion})` : '';
655
- const what = drifted.join(', ');
656
- const safeRoot = root.replace(/["\\]/g, '');
657
- const projectDir = rules_config_1.claudeEnv.projectDirForLog().replace(/["\\]/g, '');
658
- // Tested against the RAW root, never the stripped one: stripping is a display-safety measure, and
659
- // cd-anchoring to a path we just mangled would prescribe a cd into a directory that does not exist.
660
- // A root CD_PREFIX cannot express is simply not offered as a `cd` (raw ok ⇒ safeRoot === root).
661
- const cdOk = root !== '' && /^[A-Za-z0-9._/@~+-]+$/.test(root);
662
- // Agreement is the routine case; DISAGREEMENT is the signature of the session-root-vs-cwd split this
663
- // guard was rewritten to make unconstructible, so it gets said out loud rather than left to inference.
664
- const verdict = safeRoot === projectDir
665
- ? 'These two AGREE, so this is the ordinary case - the tree you are in is the tree being judged.'
666
- : 'These two DISAGREE - the tree being judged is NOT the one CLAUDE_PROJECT_DIR names, so cure the root= tree specifically and do not assume your current directory is it.';
667
- const rootNote = safeRoot === '' ? '' : ` WHERE THIS WAS MEASURED: root=${safeRoot} (the tree the RUNNING guard binary itself came from - that is the tree whose shim must change), projectDir=${projectDir} (CLAUDE_PROJECT_DIR as this process sees it; <unset> means the variable is absent, which is not the same as set-but-empty). ${verdict}`;
668
- const upgrade = cdOk ? `cd ${safeRoot} && ${l0_allowlist_1.UPGRADE_SHIM_CMD}` : l0_allowlist_1.UPGRADE_SHIM_CMD;
669
- // OPTION 2 is a relative-path `cp`, so it is even MORE cwd-sensitive than OPTION 1 — anchor it too.
670
- const restore = cdOk ? `cd ${safeRoot} && ${l0_allowlist_1.RESTORE_SHIM_CMD}` : l0_allowlist_1.RESTORE_SHIM_CMD;
671
- return `❌ webpieces-managed hook surface was changed: ${what} no longer matches what the INSTALLED @webpieces/ai-hook-rules${verNote} expects (reverted, hand-edited, or predating this binary - a settings.json still on the OLD two-absolute-hook form reports here too).${rootNote} webpieces manages THREE things together and they only work as a set: ${exports.SHIM_MARKER} (the guard shim, registered RELATIVE so each git tree runs its own release), ${guarantee_root_1.GUARANTEE_ROOT_MARKER} (the L-1 hook, registered ABSOLUTE, which refuses any cd that would park the shell where the relative hooks cannot launch - without it an unresolvable hook is a SILENT UNGUARDED ALLOW), and the .claude/settings.json entries that register them. They are GENERATED and committed by webpieces - they must NOT be reverted or edited by hand, and the fail-closed logic cannot be trusted while any of them differs. Every OTHER tool call is blocked until all three match again. THIS IS NOT A DEADLOCK: both options below are explicitly ALLOWED through while this guard is up, so run one YOURSELF now - do not hand it back to the human. OPTION 1 (preferred, and the ONLY option that repairs all three - it regenerates both .sh files AND rewrites the settings.json registration to the three-hook form, removing the old absolute entries; it touches no config, and it imports only fs/path so it runs on a broken tree; needs installed @webpieces/ai-hook-rules 0.4.408 or newer) - run EXACTLY this command: '${upgrade}'. HOW TO TELL OPTION 1 WORKED, because up to and including 0.4.588 it silently did NOTHING (it had no process entry point, so it printed nothing and exited 0 - which is why this guard could look like a deadlock): a working OPTION 1 PRINTS a line per repaired surface and re-checks all three afterwards, exiting NON-ZERO and naming whatever still differs. So EMPTY OUTPUT means the cure did not run at all - do not re-run it in a loop; upgrade @webpieces/ai-hook-rules, use OPTION 2 for the shim, and say plainly that OPTION 1 is inert in this release. OPTION 2 (a PARTIAL fallback - it repairs ONE of the three, ${exports.SHIM_MARKER}, and nothing else; pick it only when the installed @webpieces/ai-hook-rules is OLDER than 0.4.408 so wp-upgrade-shim does not exist yet, then upgrade @webpieces and run OPTION 1 to finish the job. Claude Code's own permission prompt may ask you to confirm the file overwrite, and that prompt is NOT this guard) - run EXACTLY this command: '${restore}'. Do NOT use the bare '${l0_allowlist_1.INSTALL_HOOKS_CMD}' here: it also migrates your config and PROMPTS for a hook target twice, which hangs a non-interactive session. ${exports.NO_CHAINING_RULE} Do NOT revert these files again - if you meant to remove @webpieces/ai-hook-rules, delete its hooks from .claude/settings.json instead.`;
672
- }
673
619
  // The installed @webpieces/ai-hook-rules version, for shimStaleDenyReason's note. The binary IS this
674
620
  // package, so it reads its OWN package.json (two dirs up from src/bin). Best-effort: '' on any failure,
675
621
  // which shimStaleDenyReason renders as no note rather than a broken one.