session-orchestrator 3.17.0 → 3.20.0

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 (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -14,13 +14,21 @@
14
14
  * G2 command present + string
15
15
  * G3 bypass: allow-destructive-ops: true in Session Config → exit 0
16
16
  * G4 policy load: .orchestrator/policy/blocked-commands.json
17
- * Missing exit 0 (warn). Malformed → exit 0 (warn).
17
+ * #972 floor/overlay merge (scripts/lib/blocked-commands-policy.mjs):
18
+ * pluginRoot policy = floor, cwd/projectDir policy = overlay (add or
19
+ * escalate only). Overlay failures fail-to-floor; only "no usable policy
20
+ * anywhere" keeps the historical fail-open (exit 0 + warn).
18
21
  * G5 rule evaluation per rule in policy.rules
19
- * severity:"block" → exit 2 with deny message
22
+ * severity:"block" → deny envelope on stdout via emitDeny, exit 0 (#906)
20
23
  * severity:"warn" → emit warning, exit 0 (allow)
21
24
  * Special cases:
22
25
  * git-stash-any: only warn when stash is non-empty
23
26
  * rm-rf-destructive: path exception for .orchestrator/tmp and node_modules
27
+ * rule.type === 'redirect-truncate' (#983): decided by redirect TARGET
28
+ * via redirectRuleMatches (target-denylist globs), NEVER by the
29
+ * generic pattern path — its `pattern: ">"` would FP-match nearly
30
+ * every redirect. Unresolved targets (variable/substitution) warn
31
+ * on stderr (fail-visible) and never block.
24
32
  * G6 no match → exit 0
25
33
  *
26
34
  * Telemetry (Epic #803 process-safety dimension): best-effort
@@ -31,54 +39,194 @@
31
39
  * finalized; a telemetry failure never changes the guard's decision.
32
40
  */
33
41
 
34
- import { readStdin, emitAllow } from '../scripts/lib/io.mjs';
35
- import { resolveProjectDir, resolvePluginRoot } from '../scripts/lib/platform.mjs';
36
- import { commandMatchesBlocked, tokenizeCommand } from '../scripts/lib/hardening.mjs';
37
- import { readConfigFile } from '../scripts/lib/config.mjs';
38
- import { readJson } from '../scripts/lib/common.mjs';
39
- import { emitEvent } from '../scripts/lib/events.mjs';
40
- import fs, { existsSync } from 'node:fs';
42
+ import fs from 'node:fs';
41
43
  import os from 'node:os';
42
44
  import path from 'node:path';
43
45
  import crypto from 'node:crypto';
46
+ import { pathToFileURL } from 'node:url';
44
47
 
45
48
  import { shouldRunHook } from './_lib/profile-gate.mjs';
46
49
  // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
47
50
  if (!shouldRunHook('pre-bash-destructive-guard')) process.exit(0);
48
51
 
49
- // Module-level policy cache (issue #250). Safe because each hook invocation runs as
50
- // an isolated Node subprocess state is fresh per process, never shared across calls.
51
- // Cache is invalidated on:
52
- // (a) resolved policy path changes (different projectDir/CWD)
53
- // (b) file mtime advances (user edited the policy)
54
- // Any stat/read error skip cache + fall back to uncached read (fail-safe).
55
- let _cachedPolicy = null;
56
- let _cachedPolicyPath = null;
57
- let _cachedPolicyMtimeMs = null;
58
-
59
- async function loadPolicyCached(policyPath) {
60
- try {
61
- const stat = await fs.promises.stat(policyPath);
62
- const mtimeMs = stat.mtimeMs;
63
- if (
64
- _cachedPolicy !== null &&
65
- _cachedPolicyPath === policyPath &&
66
- _cachedPolicyMtimeMs === mtimeMs
67
- ) {
68
- return _cachedPolicy;
52
+ // ---------------------------------------------------------------------------
53
+ // #992late-bound repo dependencies
54
+ //
55
+ // These used to be STATIC imports. A SyntaxError in any of them failed at ESM
56
+ // LINK time, before the first statement here ran: node exited 1 with 0 bytes on
57
+ // stdout, and the `main().catch(...)` handler at the bottom of this file was
58
+ // structurally unreachable. Under the exit-0 PreToolUse protocol (#906) that
59
+ // crash is, on the only decision-bearing channel, INDISTINGUISHABLE from an
60
+ // explicit `emitAllow()` — the guard failed open and silently.
61
+ //
62
+ // Binding them late (dynamic `import()` inside `bootstrap()`, below) turns that
63
+ // link-time crash into a catchable runtime error, which is what makes the
64
+ // banner + HEAD-fallback in `_lib/guard-source-loader.mjs` reachable at all.
65
+ // The command-blocker half is held as a NAMESPACE object (`blocker.*`) rather
66
+ // than six destructured bindings — see the `blocker` docblock below for why
67
+ // that single change removes the shape-check drift that made a 4-of-6-missing
68
+ // HEAD copy read as "still armed".
69
+ //
70
+ // `profile-gate.mjs` stays static on purpose — it has ZERO imports of its own
71
+ // and gates whether this hook runs at all.
72
+ // ---------------------------------------------------------------------------
73
+ /** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
74
+ /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
75
+ /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
76
+ /** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
77
+ let resolveProjectDir;
78
+ let resolvePluginRoot;
79
+ /**
80
+ * The whole `command-blocker.mjs` namespace (W4 B6: one direct import path, not
81
+ * via the hardening.mjs barrel, which does not re-export the #982/#983
82
+ * primitives).
83
+ *
84
+ * Held as ONE object rather than destructured into six bindings on purpose: the
85
+ * required-export list then exists in exactly one place — the `requires` array
86
+ * on the `blocker` spec passed to `armGuard` (#993), which validates both the
87
+ * working-tree and the HEAD copy against it. The previous split (six names
88
+ * destructured here, two of them checked in the loader) is what let a HEAD copy
89
+ * missing four exports banner "DEGRADED — still armed" and then fail open on
90
+ * every command.
91
+ *
92
+ * @type {Record<string, Function>|null}
93
+ */
94
+ let blocker = null;
95
+ let readConfigFile;
96
+ let loadEffectivePolicy;
97
+ let isSessionConfigHeading;
98
+ let emitEvent;
99
+
100
+ /**
101
+ * Labels of guard modules that loaded from HEAD rather than the working tree
102
+ * (#993/#995). Populated by `bootstrap()` from `armGuard`'s return; read by
103
+ * `main()`, which pushes a visible-channel DEGRADED notice when it is non-empty —
104
+ * the stderr degradation banner alone is invisible under the exit-0 protocol.
105
+ *
106
+ * @type {string[]}
107
+ */
108
+ let degradedLabels = [];
109
+
110
+ const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
111
+
112
+ /** This hook's name — threaded into both guard banners (#993: no hard-wired literal). */
113
+ const HOOK_NAME = 'pre-bash-destructive-guard';
114
+
115
+ /**
116
+ * The two consequence blocks, spliced VERBATIM into the DEGRADED and GUARD
117
+ * INACTIVE banners (#993). Byte-identical to the pre-#993 inline banner text so
118
+ * the #992 banner-visibility tests stay green.
119
+ */
120
+ const GUARD_CONSEQUENCE = {
121
+ degraded: [
122
+ ' Consequence: destructive-command enforcement IS still armed, but it is evaluating the',
123
+ ' COMMITTED (HEAD) command lexer — any uncommitted change to that file is NOT in effect.',
124
+ ],
125
+ inactive: [
126
+ ' Consequence: destructive Bash commands (git reset --hard, rm -rf, git push --force,',
127
+ ' git stash, redirect-truncate of protected artefacts) are NOT being blocked. This is a',
128
+ ' BROKEN GUARD, not a policy decision — do not route around it, repair it.',
129
+ ],
130
+ };
131
+
132
+ /**
133
+ * Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
134
+ * is one of the ones that may have failed to load.
135
+ *
136
+ * @returns {string}
137
+ */
138
+ function bannerProjectDir() {
139
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
140
+ }
141
+
142
+ /**
143
+ * Bind every repo dependency. Throws on any load failure; the caller banners.
144
+ *
145
+ * Order matters only for cost: the cheap plain imports run first, so a broken
146
+ * `io.mjs` never pays for a pointless `git show` on `command-blocker.mjs`.
147
+ *
148
+ * @returns {Promise<void>}
149
+ */
150
+ async function bootstrap() {
151
+ const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
152
+
153
+ const { armGuard } = await import('./_lib/guard-source-loader.mjs');
154
+ // #993: one generic loader for every repo dependency. Only `blocker` opts into
155
+ // the `git show HEAD:` fallback (dependency-free, so data:-URL loadable) and
156
+ // carries the COMPLETE required-export set — validated on the working-tree AND
157
+ // the HEAD copy, so a partial namespace banners GUARD INACTIVE rather than
158
+ // arming a guard that fails open per command. A load failure of any entry
159
+ // throws; the caller banners GUARD INACTIVE.
160
+ const { modules, degraded } = await armGuard(
161
+ {
162
+ io: { specifier: lib('io.mjs') },
163
+ platform: { specifier: lib('platform.mjs') },
164
+ config: { specifier: lib('config.mjs') },
165
+ blockedCommandsPolicy: { specifier: lib('blocked-commands-policy.mjs') },
166
+ sectionExtractor: { specifier: lib('config', 'section-extractor.mjs') },
167
+ events: { specifier: lib('events.mjs') },
168
+ blocker: {
169
+ specifier: lib('command-blocker.mjs'),
170
+ headFallback: true,
171
+ requires: [
172
+ 'tokenizeCommand',
173
+ 'commandMatchesBlocked',
174
+ 'extractRedirectTargets',
175
+ 'redirectRuleMatches',
176
+ 'redirectSpanEnd',
177
+ 'resolveSegmentVerb',
178
+ 'splitChainSegments',
179
+ ],
180
+ },
181
+ },
182
+ {
183
+ hookName: HOOK_NAME,
184
+ repoRoot: PLUGIN_ROOT,
185
+ projectDir: bannerProjectDir(),
186
+ consequence: GUARD_CONSEQUENCE,
69
187
  }
70
- const fresh = await readJson(policyPath);
71
- _cachedPolicy = fresh;
72
- _cachedPolicyPath = policyPath;
73
- _cachedPolicyMtimeMs = mtimeMs;
74
- return fresh;
75
- } catch {
76
- // On any error (stat failure, read failure), re-throw to let caller's existing
77
- // try/catch handle the "malformed policy" branch. Do NOT poison the cache.
78
- return readJson(policyPath);
79
- }
188
+ );
189
+
190
+ ({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
191
+ ({ resolveProjectDir, resolvePluginRoot } = modules.platform);
192
+ ({ readConfigFile } = modules.config);
193
+ ({ loadEffectivePolicy } = modules.blockedCommandsPolicy);
194
+ ({ isSessionConfigHeading } = modules.sectionExtractor);
195
+ ({ emitEvent } = modules.events);
196
+ blocker = modules.blocker;
197
+ degradedLabels = degraded;
198
+ }
199
+
200
+ /**
201
+ * Flush the aggregated allow-with-notice channel (#995).
202
+ *
203
+ * Warnings raised during rule evaluation accumulate in `notices` and are emitted
204
+ * ONCE, here, on the VISIBLE stdout channel via `emitWarn` (allow-with-notice) —
205
+ * else a plain `emitAllow`. Both exit 0 and never return, so this is always the
206
+ * last statement on an allow path.
207
+ *
208
+ * Why aggregate instead of `emitWarn`-ing inline at each warn site: `emitWarn`
209
+ * is `@returns never`, so an inline call mid-loop would exit BEFORE a later
210
+ * `block` rule was evaluated — flipping a would-be DENY into an ALLOW-with-notice
211
+ * (measured: `bash <33×-c>` overshoots the recursion cap, and a command that
212
+ * both trips the cap AND carries `rm -rf src/` would wave the delete through).
213
+ * A block, when it fires, exits via `emitDeny` and these notices are simply
214
+ * dropped: DENY wins, and the stderr copies of each notice remain for CI/debug.
215
+ *
216
+ * @param {string[]} notices
217
+ * @returns {never}
218
+ */
219
+ function flushNotices(notices) {
220
+ return notices.length > 0 ? emitWarn(notices.join('\n')) : emitAllow();
80
221
  }
81
222
 
223
+ // Module-level per-path policy cache (issue #250, extended for the #972
224
+ // floor/overlay merge: one Map entry per policy path instead of a single-path
225
+ // triple, so floor and overlay invalidate independently on mtime advance).
226
+ // Safe because each hook invocation runs as an isolated Node subprocess —
227
+ // state is fresh per process, never shared across calls.
228
+ const _policyCache = new Map();
229
+
82
230
  // ---------------------------------------------------------------------------
83
231
  // Internal helpers
84
232
  // ---------------------------------------------------------------------------
@@ -115,14 +263,28 @@ function resolveSessionId(input) {
115
263
  }
116
264
 
117
265
  /**
118
- * Block a command: write structured deny JSON to stdout + exit 2.
119
- * Uses raw process.exit(2) rather than emitDeny to emit the exact
120
- * multi-line message format required by the spec.
266
+ * Block a command: emit the PreToolUse deny envelope via emitDeny (exit 0).
267
+ *
268
+ * Until #906 this used a raw `process.exit(2)` plus its own stdout write,
269
+ * justified by a claim that the multi-line message "required by the spec"
270
+ * could not go through emitDeny. That claim was false in both halves:
271
+ * - Claude Code DISCARDS stdout on exit 2 and reads stderr instead — and
272
+ * this function wrote nothing to stderr, so the operator saw only
273
+ * `hook error: … No stderr output`, i.e. what looks like a crash. This
274
+ * was the single worst instance of that symptom in the repo.
275
+ * - emitDeny preserves multi-line reasons verbatim: JSON.stringify escapes
276
+ * the newlines, so the exact 4-line reason below round-trips unchanged
277
+ * inside `permissionDecisionReason` on ONE stdout line (verified against
278
+ * this very reason string). The operator additionally gets the first line
279
+ * as the `systemMessage` headline.
121
280
  *
122
281
  * Emits a best-effort `orchestrator.destructive_guard.blocked` telemetry
123
- * event BEFORE exiting. Emission failure (e.g. unwritable events.jsonl path)
282
+ * event BEFORE denying. Emission failure (e.g. unwritable events.jsonl path)
124
283
  * must NEVER change the block outcome — the guard's block-decision is
125
- * strictly independent of telemetry success.
284
+ * strictly independent of telemetry success. emitDeny never returns, so it
285
+ * MUST stay the last statement here.
286
+ *
287
+ * @returns {Promise<never>}
126
288
  */
127
289
  async function blockCommand(pattern, ruleId, rationale, command, sessionId) {
128
290
  const reason = [
@@ -142,36 +304,8 @@ async function blockCommand(pattern, ruleId, rationale, command, sessionId) {
142
304
  // Best-effort — telemetry must never block or alter the guard decision.
143
305
  }
144
306
 
145
- // Structured deny for Claude Code hook protocol
146
- process.stdout.write(JSON.stringify({ permissionDecision: 'deny', reason }) + '\n');
147
- process.exit(2);
148
- }
149
-
150
- /**
151
- * Resolve the policy file path, searching in priority order:
152
- * 1. <CWD>/.orchestrator/policy/blocked-commands.json
153
- * 2. <CLAUDE_PROJECT_DIR>/.orchestrator/policy/blocked-commands.json
154
- * 3. <CLAUDE_PLUGIN_ROOT>/.orchestrator/policy/blocked-commands.json
155
- * Returns the first existing path, or null if none found.
156
- */
157
- function resolvePolicyPath(projectDir) {
158
- const candidates = [
159
- path.join(process.cwd(), '.orchestrator', 'policy', 'blocked-commands.json'),
160
- ];
161
-
162
- if (projectDir && projectDir !== process.cwd()) {
163
- candidates.push(path.join(projectDir, '.orchestrator', 'policy', 'blocked-commands.json'));
164
- }
165
-
166
- const pluginRoot = resolvePluginRoot();
167
- if (pluginRoot) {
168
- candidates.push(path.join(pluginRoot, '.orchestrator', 'policy', 'blocked-commands.json'));
169
- }
170
-
171
- for (const candidate of candidates) {
172
- if (existsSync(candidate)) return candidate;
173
- }
174
- return null;
307
+ // Structured deny for the Claude Code PreToolUse hook protocol. Never returns.
308
+ emitDeny(reason);
175
309
  }
176
310
 
177
311
  /**
@@ -201,72 +335,123 @@ async function isGitStashNonEmpty(projectDir) {
201
335
  * chained commands (`rm -rf /tmp/x; rm -rf src/`). Quote-aware via
202
336
  * tokenizeCommand so a path with spaces inside quotes is one target.
203
337
  *
204
- * Returns an array of target path strings (possibly empty). The caller treats
205
- * the rm-rf rule as "allowed" only when EVERY returned target is allowlisted.
338
+ * Wrapper-aware (#982 T2): the segment verb resolves through the transparent
339
+ * wrappers in WRAPPER_UNWRAP via resolveSegmentVerb `sudo rm -rf /tmp/ok`,
340
+ * `timeout 5 rm -rf /x`, `nohup rm -rf …` parse their REAL targets instead of
341
+ * falling back to the empty-target conservative block. Redirect tokens and
342
+ * their operands are skipped (#983): they are IO targets, not rm targets.
343
+ *
344
+ * Returns BOTH halves of the operand/redirect split (merge of the two
345
+ * 2026-08-03 guard sessions — the two protection layers are complementary):
346
+ * - `targets` — the rm operands (what gets deleted). Judged against the
347
+ * rule's `path-allowlist` by the caller.
348
+ * - `writeTargets` — the files each WRITE redirect (`truncate` or `append`
349
+ * mode) in the same invocation clobbers-or-creates. Read redirects,
350
+ * here-docs/here-strings and fd duplications (`2>&1`) contribute nothing.
351
+ * The caller holds these to the SAME allowlist (with `/dev/null` carved
352
+ * out via isNullSink), because `rm -rf /tmp/ok > src/important.ts` empties
353
+ * a project file on the strength of an allowlisted rm operand. This is the
354
+ * rm-context layer; protected artefacts (`> CLAUDE.md` — `> AGENTS.md` on
355
+ * Codex CLI, the same truncation class) are ADDITIONALLY
356
+ * denied command-independently by the `redirect-truncate-protected`
357
+ * policy rule (rule 14).
358
+ *
359
+ * The caller treats the rm-rf rule as "allowed" only when EVERY operand and
360
+ * EVERY write-redirect target passes its respective check — a segment whose
361
+ * pattern matched but whose verb does NOT resolve to `rm` (e.g. an interpreter
362
+ * payload) contributes no targets and stays fail-closed.
206
363
  *
207
364
  * @param {string} command
208
- * @returns {string[]}
365
+ * @returns {{targets: string[], writeTargets: string[]}}
209
366
  */
210
367
  function parseRmTargets(command) {
211
- const tokens = tokenizeCommand(command);
212
368
  const targets = [];
213
- let i = 0;
369
+ const writeTargets = [];
214
370
 
215
- while (i < tokens.length) {
216
- const verb = tokens[i].text.replace(/^.*\//, ''); // basename
217
- const isOperator = !tokens[i].quoted && /^(;|&&|\|\||\||&)$/.test(tokens[i].text);
218
- if (isOperator) { i++; continue; }
219
- if (verb !== 'rm') { i++; continue; }
371
+ for (const segment of blocker.splitChainSegments(blocker.tokenizeCommand(command))) {
372
+ const { verb, index } = blocker.resolveSegmentVerb(segment);
373
+ if (verb !== 'rm') continue;
220
374
 
221
- // Consume this `rm` invocation's args until the next chain operator.
222
- i++; // skip `rm`
223
375
  let seenDashDash = false;
224
- while (i < tokens.length) {
225
- const tok = tokens[i];
226
- // Stop at unquoted chain operators — they delimit the next command.
227
- if (!tok.quoted && /^(;|&&|\|\||\||&)$/.test(tok.text)) break;
228
- if (!seenDashDash && tok.text === '--') { seenDashDash = true; i++; continue; }
229
- // A flag is unquoted and starts with '-' (and is not the bare '-' stdin marker).
230
- if (!seenDashDash && !tok.quoted && tok.text.startsWith('-') && tok.text !== '-') {
231
- i++;
376
+ for (let i = index + 1; i < segment.length; i++) {
377
+ const tok = segment[i];
378
+ if (tok.redirect) {
379
+ const end = blocker.redirectSpanEnd(segment, i);
380
+ const mode = tok.redirect.mode;
381
+ if ((mode === 'truncate' || mode === 'append') && end > i) {
382
+ const operand = segment[end];
383
+ if (operand && !(!operand.quoted && /^(;|&&|\|\||\||&)$/.test(operand.text))) {
384
+ writeTargets.push(operand.text);
385
+ }
386
+ }
387
+ i = end;
232
388
  continue;
233
389
  }
390
+ if (!seenDashDash && tok.text === '--') { seenDashDash = true; continue; }
391
+ // A flag is unquoted and starts with '-' (and is not the bare '-' stdin marker).
392
+ if (!seenDashDash && !tok.quoted && tok.text.startsWith('-') && tok.text !== '-') continue;
234
393
  targets.push(tok.text);
235
- i++;
236
394
  }
237
395
  }
238
396
 
239
- return targets;
397
+ return { targets, writeTargets };
398
+ }
399
+
400
+ /**
401
+ * `/dev/null` is the one write-redirect destination that destroys nothing: it is
402
+ * a character device, so `>` on it neither truncates nor creates a file. It is
403
+ * also the single most common rm-plumbing target (`rm -rf /tmp/x 2> /dev/null`).
404
+ *
405
+ * Deliberately NOT folded into isRmPathAllowed: `rm -rf /dev/null` must stay
406
+ * blocked — deleting the device node is destructive, writing to it is not.
407
+ *
408
+ * @param {string} targetPath
409
+ * @returns {boolean}
410
+ */
411
+ function isNullSink(targetPath) {
412
+ return Boolean(targetPath)
413
+ && path.isAbsolute(targetPath)
414
+ && path.normalize(targetPath) === path.join(path.sep, 'dev', 'null');
240
415
  }
241
416
 
242
417
  /**
243
418
  * Detect whether the command contains an UNQUOTED `rm` invocation carrying BOTH
244
419
  * recursive (`-r`/`-R`/`--recursive`) AND force (`-f`/`--force`) semantics,
245
- * including combined/short forms (`-rf`, `-fr`, `-r -f`). This catches flag-form
246
- * variants the literal "rm -rf" pattern misses (#641 gap closure) while staying
247
- * consistent with the quoted-payload guard: an `rm` that appears only inside a
248
- * quoted token is NOT treated as an invocation here.
420
+ * including combined/short forms (`-rf`, `-fr`, `-r -f`) and the long-flag pair
421
+ * (`--recursive --force`). This catches flag-form variants the literal "rm -rf"
422
+ * pattern misses (#641 gap closure) while staying consistent with the
423
+ * quoted-payload guard: an `rm` that appears only inside a quoted token is NOT
424
+ * treated as an invocation here.
425
+ *
426
+ * Wrapper-aware (#982 T2) + redirect-skip (#983) — same segment mechanics as
427
+ * parseRmTargets above.
428
+ *
429
+ * Redirect operators and their targets are skipped for the same reason as in
430
+ * parseRmTargets: a redirect target is a filename the shell consumes, so
431
+ * `rm -r /tmp/x > -f` must not read `-f` as rm's force flag.
432
+ *
433
+ * This walker deliberately DISCARDS redirect spans entirely. Its question is
434
+ * only "does this command contain a recursive+force rm at all", i.e. whether
435
+ * the rm-rf rule MATCHES — it never decides allow-vs-deny. Write-redirect
436
+ * targets are judged once, by the `redirect-truncate-protected` policy rule
437
+ * (rule 14), never here — a second collector would give the guard a silently
438
+ * divergent opinion about the same fact.
249
439
  *
250
440
  * @param {string} command
251
441
  * @returns {boolean}
252
442
  */
253
443
  function commandHasRecursiveForceRm(command) {
254
- const tokens = tokenizeCommand(command);
255
- let i = 0;
256
- while (i < tokens.length) {
257
- const tok = tokens[i];
258
- const verb = tok.text.replace(/^.*\//, ''); // basename
259
- if (tok.quoted || verb !== 'rm') { i++; continue; }
260
-
261
- // Scan this rm invocation's flags until the next unquoted chain operator.
262
- i++; // skip `rm`
444
+ for (const segment of blocker.splitChainSegments(blocker.tokenizeCommand(command))) {
445
+ const { verb, index } = blocker.resolveSegmentVerb(segment);
446
+ if (verb !== 'rm' || segment[index].quoted) continue;
447
+
263
448
  let recursive = false;
264
449
  let force = false;
265
450
  let seenDashDash = false;
266
- while (i < tokens.length) {
267
- const t = tokens[i];
268
- if (!t.quoted && /^(;|&&|\|\||\||&)$/.test(t.text)) break;
269
- if (!seenDashDash && t.text === '--') { seenDashDash = true; i++; continue; }
451
+ for (let i = index + 1; i < segment.length; i++) {
452
+ const t = segment[i];
453
+ if (t.redirect) { i = blocker.redirectSpanEnd(segment, i); continue; }
454
+ if (!seenDashDash && t.text === '--') { seenDashDash = true; continue; }
270
455
  if (!seenDashDash && !t.quoted && t.text.startsWith('-') && t.text !== '-') {
271
456
  if (t.text === '--recursive') recursive = true;
272
457
  else if (t.text === '--force') force = true;
@@ -275,16 +460,138 @@ function commandHasRecursiveForceRm(command) {
275
460
  if (/[rR]/.test(t.text)) recursive = true;
276
461
  if (/f/.test(t.text)) force = true;
277
462
  }
278
- i++;
279
- continue;
280
463
  }
281
- i++; // non-flag arg (a target) — skip
464
+ // non-flag arg (a target) — skip
282
465
  }
283
466
  if (recursive && force) return true;
284
467
  }
285
468
  return false;
286
469
  }
287
470
 
471
+ /** Probe operator per redirect mode — see findMatchedRedirectEntry. */
472
+ const REDIRECT_PROBE_OPS = { truncate: '>', append: '>>', read: '<' };
473
+
474
+ /**
475
+ * Identify WHICH resolved redirect entry a matched redirect-truncate rule hit,
476
+ * so the deny reason can name the target (#983). redirectRuleMatches returns
477
+ * only a boolean and its glob matcher is internal to command-blocker.mjs —
478
+ * rather than duplicate the glob logic here (it would be the third copy), each
479
+ * candidate target is re-probed through redirectRuleMatches with a minimal
480
+ * single-redirect command. Resolved targets are guaranteed free of `$` and
481
+ * backticks (those are reported `unresolved`), so the quoted probe round-trips
482
+ * the target text exactly.
483
+ *
484
+ * `repoRoot` MUST be the same value the deciding redirectRuleMatches call used
485
+ * (#988 T1): an absolute/`~` target only matches when the root is supplied, so
486
+ * dropping it here would leave `hit === null` for exactly the targets the new
487
+ * resolution added and the deny reason would silently fall back to the pattern.
488
+ *
489
+ * @param {object} rule — the redirect-truncate policy rule
490
+ * @param {Array<{ target: string|null, mode: string, unresolved?: boolean }>} entries
491
+ * @param {string} repoRoot — absolute repo root, forwarded to redirectRuleMatches
492
+ * @returns {{ target: string, mode: string }|null}
493
+ */
494
+ function findMatchedRedirectEntry(rule, entries, repoRoot) {
495
+ for (const entry of entries) {
496
+ if (entry.unresolved) continue;
497
+ const op = REDIRECT_PROBE_OPS[entry.mode] ?? '>';
498
+ const probe = `${op} "${entry.target.replace(/[\\"]/g, '\\$&')}"`;
499
+ if (blocker.redirectRuleMatches(rule, probe, { repoRoot })) return entry;
500
+ }
501
+ return null;
502
+ }
503
+
504
+ /**
505
+ * Expand a LEADING `$TMPDIR` / `${TMPDIR}` reference in an rm target token.
506
+ *
507
+ * The hook is a PreToolUse gate: it sees the raw, UNEXPANDED shell string.
508
+ * `rm -rf "${TMPDIR}"scratch` therefore arrives as the token `${TMPDIR}scratch`,
509
+ * which `path.isAbsolute()` reads as a RELATIVE path — so the #641 `wasAbsolute`
510
+ * gate (correctly, on its own terms) refuses to match it against any /tmp-class
511
+ * prefix, and the path is instead resolved against the PROJECT dir. A legitimate
512
+ * temp cleanup is blocked (#935 cause 1). Substituting the value the shell would
513
+ * have substituted, BEFORE absoluteness is judged, is the narrow fix.
514
+ *
515
+ * This does NOT by itself widen the safe set: the expansion result still has to
516
+ * land under a CONFINED allowlist prefix (see resolveAllowlistPrefixes), so an
517
+ * inherited `TMPDIR=/etc` expands to `/etc/...` and is still blocked (#642).
518
+ *
519
+ * Deliberately narrow: only TMPDIR, only in leading position, only when its
520
+ * value is absolute, and byte-for-byte as the shell would splice it (no invented
521
+ * separator — `TMPDIR=/tmp` + `${TMPDIR}x` really is `/tmpx`, not `/tmp/x`).
522
+ * A generic env-expander would be a far larger attack surface.
523
+ *
524
+ * @param {string} targetPath
525
+ * @returns {string}
526
+ */
527
+ function expandTmpdirToken(targetPath) {
528
+ const m = /^\$(?:TMPDIR\b|\{TMPDIR\})/.exec(targetPath);
529
+ if (!m) return targetPath;
530
+ const value = process.env.TMPDIR || os.tmpdir();
531
+ if (!value || !path.isAbsolute(value)) return targetPath;
532
+ return value + targetPath.slice(m[0].length);
533
+ }
534
+
535
+ /**
536
+ * Canonicalise `absPath` by realpath-ing the deepest EXISTING ancestor and
537
+ * re-attaching the non-existent suffix.
538
+ *
539
+ * `fs.realpathSync.native` throws ENOENT on a path that does not exist — which
540
+ * is the NORMAL case here: `rm -rf` frequently targets something already gone,
541
+ * and a policy prefix (`$TMPDIR` of another boot session) need not exist either.
542
+ * The upward walk mirrors the identical pattern in hooks/enforce-scope.mjs
543
+ * (SECURITY-REQ-03) rather than inventing a second one.
544
+ *
545
+ * Any other fs error → return the lexical input unchanged. That is the fail-safe
546
+ * direction: the caller then compares lexically, i.e. exactly the pre-#935
547
+ * behaviour, never a broader one.
548
+ *
549
+ * @param {string} absPath
550
+ * @returns {string}
551
+ */
552
+ function canonicalizeAncestors(absPath) {
553
+ let ancestor = absPath;
554
+ const segments = [];
555
+ for (;;) {
556
+ try {
557
+ const real = fs.realpathSync.native(ancestor);
558
+ return segments.length ? path.join(real, ...segments.reverse()) : real;
559
+ } catch (err) {
560
+ // ENOENT: nothing at this level yet. ENOTDIR: a FILE sits in the chain.
561
+ // Both mean "keep walking up"; anything else (ELOOP, EACCES) → lexical.
562
+ if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') return absPath;
563
+ const parent = path.dirname(ancestor);
564
+ if (parent === ancestor) return absPath; // reached the fs root, nothing existed
565
+ segments.push(path.basename(ancestor));
566
+ ancestor = parent;
567
+ }
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Canonical LOCATION of an `rm` target.
573
+ *
574
+ * `rm -rf X` removes X ITSELF: when X is a symlink the link is unlinked and what
575
+ * it points at is untouched. So the final segment must NOT be symlink-resolved —
576
+ * only its parent chain. Resolving the leaf would be both wrong about what rm
577
+ * does and unsafe: `<project>/link-to-tmp` would canonicalise to /private/tmp and
578
+ * read as a safe temp target while the delete lands on project content.
579
+ *
580
+ * Canonicalising the parent chain keeps the judgement honest in both directions:
581
+ * - `/tmp/link-to-etc/x` canonicalises OUT of the temp allowlist → blocked.
582
+ * The pre-#935 literal prefix match allowed it (measured, real hole).
583
+ * - `$TMPDIR/x` and its `/private/var/folders/...` canonical spelling collapse
584
+ * onto the same string → both allowed, no literal-form guessing.
585
+ *
586
+ * @param {string} abs — already `path.normalize`d absolute path
587
+ * @returns {string}
588
+ */
589
+ function canonicalizeRmTarget(abs) {
590
+ const parent = path.dirname(abs);
591
+ if (parent === abs) return abs; // filesystem root
592
+ return path.join(canonicalizeAncestors(parent), path.basename(abs));
593
+ }
594
+
288
595
  /**
289
596
  * Return true when a single path is a safe `rm -rf` target.
290
597
  *
@@ -293,7 +600,7 @@ function commandHasRecursiveForceRm(command) {
293
600
  * - <projectRoot>/node_modules (any depth)
294
601
  * - /tmp/ (any depth) — agent-owned scratch
295
602
  * - /private/tmp/ (any depth) — macOS canonical /tmp
296
- * - resolved os.tmpdir() / $TMPDIR (any depth)
603
+ * - resolved os.tmpdir() / $TMPDIR (any depth), in either spelling
297
604
  *
298
605
  * The /tmp-class prefixes come from the rule's optional `path-allowlist` and are
299
606
  * resolved at runtime here.
@@ -307,12 +614,17 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
307
614
  if (!targetPath) return false;
308
615
 
309
616
  const base = projectDir || process.cwd();
310
- const wasAbsolute = path.isAbsolute(targetPath);
617
+ // Restore the shell's own substitution before judging absoluteness (#935).
618
+ const effective = expandTmpdirToken(targetPath);
619
+ const wasAbsolute = path.isAbsolute(effective);
311
620
 
312
621
  // Project-relative safe dirs (always allowed, independent of the rule allowlist).
622
+ // Deliberately LEXICAL: a project-relative candidate is never symlink-resolved,
623
+ // otherwise a link inside the project pointing at /tmp would read as safe and
624
+ // `rm -rf <that link>` — which destroys project content — would be allowed.
313
625
  const abs = wasAbsolute
314
- ? path.normalize(targetPath)
315
- : path.resolve(base, targetPath);
626
+ ? path.normalize(effective)
627
+ : path.resolve(base, effective);
316
628
 
317
629
  const safeProjectDirs = [
318
630
  path.join(base, '.orchestrator', 'tmp'),
@@ -328,9 +640,14 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
328
640
  // /tmp prefix just because the project dir itself happens to live under /tmp
329
641
  // (the case on CI runners where os.tmpdir() === /tmp). #641.
330
642
  if (wasAbsolute) {
643
+ // Compare CANONICAL forms on both sides. Literal matching made the verdict
644
+ // depend on which spelling of the same directory was typed (`/var/folders/…`
645
+ // allowed, `/private/var/folders/…` blocked — #935 cause 2) and let a symlink
646
+ // under /tmp launder a non-temp destination into the allowlist.
647
+ const canonTarget = canonicalizeRmTarget(abs);
331
648
  for (const prefix of resolveAllowlistPrefixes(ruleAllowlist)) {
332
649
  // The target must be the prefix dir itself or a descendant of it.
333
- if (abs === prefix || abs.startsWith(prefix + path.sep)) return true;
650
+ if (canonTarget === prefix || canonTarget.startsWith(prefix + path.sep)) return true;
334
651
  }
335
652
  }
336
653
 
@@ -342,23 +659,51 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
342
659
  * prefixes. `$TMPDIR` expands to env.TMPDIR (if set) and os.tmpdir(); literal
343
660
  * paths are normalised. Trailing slashes are stripped for prefix comparison.
344
661
  *
662
+ * Every prefix is emitted in BOTH its lexical and its canonical (realpath)
663
+ * spelling, because the target it is compared against is canonicalised too
664
+ * (see canonicalizeRmTarget) — and when realpath is unavailable both sides fall
665
+ * back to lexical together, so the pair never drifts apart.
666
+ *
345
667
  * @param {string[]} ruleAllowlist
346
668
  * @returns {string[]} normalised absolute prefixes (no trailing slash)
347
669
  */
348
670
  function resolveAllowlistPrefixes(ruleAllowlist) {
349
671
  const out = new Set();
672
+ const strip = (p) => path.normalize(p).replace(/[/\\]+$/, '');
673
+
674
+ // Canonical macOS spellings are listed EXPLICITLY next to their symlink form:
675
+ // /tmp → /private/tmp and /var/folders → /private/var/folders. A TMPDIR handed
676
+ // to us already canonicalised (what fs.realpath returns on macOS) previously
677
+ // failed this confinement check and contributed NO prefix at all, so EVERY
678
+ // $TMPDIR target was blocked (#935 cause 2).
679
+ const tempRoots = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders']
680
+ .map((p) => path.normalize(p));
681
+ const underTempRoot = (p) =>
682
+ tempRoots.some((root) => p === root || p.startsWith(root + path.sep));
683
+
684
+ // Operator-authored, VCS-reviewed policy literals (`/tmp/`, `/private/tmp/`).
350
685
  const add = (p) => {
351
686
  if (!p) return;
352
- const norm = path.normalize(p).replace(/[/\\]+$/, '');
353
- if (norm) out.add(norm);
687
+ const lex = strip(p);
688
+ if (!lex) return;
689
+ out.add(lex);
690
+ const canon = strip(canonicalizeAncestors(lex));
691
+ if (canon) out.add(canon);
354
692
  };
355
- const tempRoots = ['/tmp', '/private/tmp', '/var/folders'].map((p) => path.normalize(p));
693
+
694
+ // Environment-derived (`$TMPDIR`) — attacker-influencable, hence DOUBLE
695
+ // confinement: the value must sit under a temp root both as written AND after
696
+ // symlink resolution. Checking only the lexical form would let a `TMPDIR`
697
+ // pointing at a symlink under /tmp (e.g. /tmp/evil → <project>) promote the
698
+ // project itself to an allowlisted prefix (#642 confinement, extended).
356
699
  const addTemp = (p) => {
357
700
  if (!p || !path.isAbsolute(p)) return;
358
- const norm = path.normalize(p).replace(/[/\\]+$/, '');
359
- if (tempRoots.some((root) => norm === root || norm.startsWith(root + path.sep))) {
360
- out.add(norm);
361
- }
701
+ const lex = strip(p);
702
+ if (!lex || !underTempRoot(lex)) return;
703
+ const canon = strip(canonicalizeAncestors(lex));
704
+ if (!canon || !underTempRoot(canon)) return;
705
+ out.add(lex);
706
+ out.add(canon);
362
707
  };
363
708
 
364
709
  for (const entry of Array.isArray(ruleAllowlist) ? ruleAllowlist : []) {
@@ -392,6 +737,22 @@ async function main() {
392
737
  const projectDir = resolveProjectDir();
393
738
  const sessionId = resolveSessionId(input);
394
739
 
740
+ // #995 — aggregated allow-with-notice channel. Warn-severity matches and
741
+ // fail-visible markers accumulate here (stderr copies are kept for parity) and
742
+ // flush ONCE, on the visible stdout channel, only after EVERY rule has been
743
+ // evaluated. See flushNotices for why inline emitWarn would fail open.
744
+ const notices = [];
745
+ // A guard armed from HEAD (working-tree module unparseable) is a visible-channel
746
+ // concern too: the DEGRADED banner rides stderr only, which exit-0 discards. The
747
+ // stderr banner already fired once-per-session inside armGuard; surface it on
748
+ // stdout so a degraded ALLOW is not silently indistinguishable from a healthy one.
749
+ if (degradedLabels.length > 0) {
750
+ notices.push(
751
+ `${HOOK_NAME}: DEGRADED — guard module(s) loaded from HEAD, not your working tree ` +
752
+ `(${degradedLabels.join(', ')}); uncommitted changes to them are NOT in effect. See #992.`
753
+ );
754
+ }
755
+
395
756
  // G3 — bypass: allow-destructive-ops: true in Session Config
396
757
  // Note: parseSessionConfig only returns known fields; allow-destructive-ops is
397
758
  // a new field, so we parse the raw markdown for it directly.
@@ -402,7 +763,10 @@ async function main() {
402
763
  const lines = mdContent.split(/\r?\n/);
403
764
  let inConfig = false;
404
765
  for (const line of lines) {
405
- if (line === '## Session Config') { inConfig = true; continue; }
766
+ // SSOT predicate (#968) never re-derive this comparison. A local copy
767
+ // that drifts LOOSER than the extractor silently disagrees with the
768
+ // runtime about where the config block starts.
769
+ if (isSessionConfigHeading(line)) { inConfig = true; continue; }
406
770
  if (inConfig && /^## /.test(line)) break;
407
771
  if (inConfig) {
408
772
  const m = line.match(/^\s*(?:-\s+\*\*)?allow-destructive-ops(?::\*\*)?\s*:\s*(\S+)/);
@@ -416,42 +780,82 @@ async function main() {
416
780
  // No config file or parse error — proceed to policy check
417
781
  }
418
782
 
419
- // G4 — policy load
420
- const policyPath = resolvePolicyPath(projectDir);
421
- if (!policyPath) {
422
- process.stderr.write(
423
- '⚠ pre-bash-destructive-guard: policy file not found ' +
424
- '(.orchestrator/policy/blocked-commands.json) skipping guard\n'
425
- );
426
- return emitAllow();
427
- }
428
-
429
- let policy;
430
- try {
431
- policy = await loadPolicyCached(policyPath);
432
- } catch {
433
- process.stderr.write(
434
- '⚠ pre-bash-destructive-guard: policy file is malformed (invalid JSON) — skipping guard\n'
435
- );
436
- return emitAllow();
437
- }
438
-
439
- if (!policy || !Array.isArray(policy.rules)) {
440
- process.stderr.write(
441
- '⚠ pre-bash-destructive-guard: policy file missing .rules array — skipping guard\n'
442
- );
443
- return emitAllow();
783
+ // G4 — policy load (#972: floor/overlay merge, not first-hit-wins).
784
+ // The plugin-root policy is the FLOOR; a cwd/projectDir policy is an OVERLAY
785
+ // that can only add rules or escalate severity — an empty or malformed
786
+ // consumer policy fails TO THE FLOOR instead of silently disarming the guard.
787
+ // Only when NO usable policy exists at all does the guard keep its documented
788
+ // fail-open (rules === null → emitAllow with a stderr warning).
789
+ const { rules, warnings } = await loadEffectivePolicy({
790
+ cwd: process.cwd(),
791
+ projectDir,
792
+ pluginRoot: resolvePluginRoot(),
793
+ cache: _policyCache,
794
+ });
795
+ for (const warning of warnings) {
796
+ process.stderr.write(`⚠ pre-bash-destructive-guard: ${warning}\n`);
444
797
  }
798
+ // No usable policy anywhere → documented total fail-open. Flush any notice
799
+ // already queued (a DEGRADED head-fallback notice), else a plain allow: the
800
+ // policy-load warnings above stay stderr-only, as they always have.
801
+ if (!Array.isArray(rules)) return flushNotices(notices);
445
802
 
446
803
  // G5 — rule evaluation
447
- for (const rule of policy.rules) {
804
+ for (const rule of rules) {
448
805
  const { id, pattern, severity, rationale = '' } = rule;
449
806
 
807
+ // #983 — redirect-truncate rules are decided by redirect TARGET via
808
+ // redirectRuleMatches, NEVER by the generic pattern path below: their
809
+ // `pattern: ">"` substring-matches virtually every redirect (measured
810
+ // interim FP: `bash -c 'echo a > b'` denied), so this branch must fully
811
+ // shadow the pattern match for this rule class.
812
+ if (rule.type === 'redirect-truncate') {
813
+ const modes = new Set(
814
+ Array.isArray(rule.modes) && rule.modes.length > 0 ? rule.modes : ['truncate']
815
+ );
816
+ const collected = blocker.extractRedirectTargets(command);
817
+ const entries = collected.filter((e) => modes.has(e.mode));
818
+ // Recursion-cap markers (#988 T2) carry `mode: null` by construction — a
819
+ // bare `modes.has(e.mode)` filter drops them, which would make the new
820
+ // marker unobservable here. Keep them alongside the mode-matching ones.
821
+ const unresolved = collected.filter(
822
+ (e) => e.unresolved && (e.mode === null || modes.has(e.mode))
823
+ );
824
+ if (unresolved.length > 0) {
825
+ // Variable/substitution operands are never match candidates (#641 FP
826
+ // class) — surface them instead of guessing; never block on a guess.
827
+ // Same for a payload subtree a recursion cap cut off: the cap stays,
828
+ // its effect stops being silent.
829
+ const reasons = [
830
+ ...new Set(unresolved.map((e) => e.reason ?? 'variable/substitution')),
831
+ ].join(', ');
832
+ // #995 — the #988-T2 recursion-cap marker used to reach stderr only, so
833
+ // under exit-0 a budget-exhausted command (`bash <33×-c>` hiding
834
+ // `> CLAUDE.md`) allowed with an INVISIBLE notice. Aggregate onto the
835
+ // visible channel; keep the stderr copy for parity.
836
+ const msg = `pre-bash-destructive-guard: unresolved redirect target (${reasons}) — not matched (fail-visible)`;
837
+ process.stderr.write(`⚠ ${msg}\n`);
838
+ notices.push(msg);
839
+ }
840
+ if (!blocker.redirectRuleMatches(rule, command, { repoRoot: projectDir })) continue;
841
+ if (severity !== 'block') {
842
+ const msg = `pre-bash-destructive-guard: redirect target matched (rule: ${id}) — ${rationale}`;
843
+ process.stderr.write(`⚠ ${msg}\n`);
844
+ notices.push(msg); // #995 — visible on the allow-with-notice channel
845
+ continue;
846
+ }
847
+ // Reason stays short (stdout-budget): operator + target, never the command.
848
+ const hit = findMatchedRedirectEntry(rule, entries, projectDir);
849
+ const label = hit ? `${REDIRECT_PROBE_OPS[hit.mode] ?? '>'} ${hit.target}` : pattern;
850
+ await blockCommand(label, id, rationale, command, sessionId);
851
+ continue; // unreachable (blockCommand never returns) — kept for clarity
852
+ }
853
+
450
854
  // The rm-rf-destructive rule also fires for recursive+force rm flag variants
451
855
  // the literal "rm -rf" pattern misses (`rm -r -f`, `rm -fr`) — #641 gap closure.
452
856
  const matched = id === 'rm-rf-destructive'
453
- ? (commandMatchesBlocked(command, pattern) || commandHasRecursiveForceRm(command))
454
- : commandMatchesBlocked(command, pattern);
857
+ ? (blocker.commandMatchesBlocked(command, pattern) || commandHasRecursiveForceRm(command))
858
+ : blocker.commandMatchesBlocked(command, pattern);
455
859
  if (!matched) continue;
456
860
 
457
861
  if (severity === 'warn') {
@@ -463,9 +867,9 @@ async function main() {
463
867
  continue;
464
868
  }
465
869
  }
466
- process.stderr.write(
467
- `⚠ pre-bash-destructive-guard: '${pattern}' (rule: ${id}) — ${rationale}\n`
468
- );
870
+ const msg = `pre-bash-destructive-guard: '${pattern}' (rule: ${id}) — ${rationale}`;
871
+ process.stderr.write(`⚠ ${msg}\n`);
872
+ notices.push(msg); // #995 — visible on the allow-with-notice channel
469
873
  // Best-effort telemetry — must never affect the warn/allow outcome.
470
874
  try {
471
875
  await emitEvent('orchestrator.destructive_guard.warned', {
@@ -484,14 +888,25 @@ async function main() {
484
888
  // Special: rm-rf-destructive — path exception
485
889
  if (id === 'rm-rf-destructive') {
486
890
  const ruleAllowlist = Array.isArray(rule['path-allowlist']) ? rule['path-allowlist'] : [];
487
- const targets = parseRmTargets(command);
891
+ const { targets, writeTargets } = parseRmTargets(command);
488
892
  // Allow ONLY when there is at least one target AND every target is
489
893
  // allowlisted. An unparseable command (no targets) or any non-allowlisted
490
894
  // target → block (conservative). This makes mixed chains like
491
895
  // `rm -rf /tmp/x; rm -rf src/` block on the src/ target.
896
+ //
897
+ // Every WRITE-redirect target of the same invocation must clear the SAME
898
+ // allowlist (with /dev/null carved out): `>` truncates its target before
899
+ // rm ever runs, so without this the allow path waved through
900
+ // `rm -rf /tmp/ok > src/important.ts` on the strength of an allowlisted
901
+ // rm operand. Protected artefacts (`> CLAUDE.md`) are ADDITIONALLY
902
+ // covered command-independently by rule 14 (redirect-truncate-protected)
903
+ // — two complementary layers from the two 2026-08-03 guard sessions.
492
904
  const allAllowed =
493
905
  targets.length > 0 &&
494
- targets.every((t) => isRmPathAllowed(t, projectDir, ruleAllowlist));
906
+ targets.every((t) => isRmPathAllowed(t, projectDir, ruleAllowlist)) &&
907
+ writeTargets.every(
908
+ (t) => isNullSink(t) || isRmPathAllowed(t, projectDir, ruleAllowlist)
909
+ );
495
910
  if (allAllowed) {
496
911
  // Safe paths only (.orchestrator/tmp, node_modules, /tmp, $TMPDIR) — allow
497
912
  continue;
@@ -502,8 +917,45 @@ async function main() {
502
917
  // Unknown severity → skip (conservative allow for unknown future severities)
503
918
  }
504
919
 
505
- // G6 — no blocking match
506
- return emitAllow();
920
+ // G6 — no blocking match. Flush the aggregated allow-with-notice channel
921
+ // (#995): emitWarn if any notice queued (visible), else a silent allow.
922
+ return flushNotices(notices);
923
+ }
924
+
925
+ // ---------------------------------------------------------------------------
926
+ // Entry point (#992)
927
+ //
928
+ // TWO distinct failure classes, two distinct banners — do not merge them:
929
+ //
930
+ // 1. LOAD failure (`bootstrap()` throws): the guard never armed. Nothing was
931
+ // evaluated, so the fail-open is total. This used to be a bare exit-1
932
+ // crash with 0 bytes of stdout — indistinguishable from an allow, and
933
+ // therefore invisible. Now it exits 0 (still fail-open, so a broken module
934
+ // cannot brick the session) but SAYS SO, loudly, once per session.
935
+ // 2. RUNTIME failure inside `main()`: pre-existing behaviour, unchanged.
936
+ // The guard armed and then tripped over a specific command; that is a
937
+ // narrower blast radius and keeps its historical `internal error` line.
938
+ // ---------------------------------------------------------------------------
939
+ try {
940
+ await bootstrap();
941
+ } catch (loadError) {
942
+ try {
943
+ const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
944
+ // Unthrottled by design: EVERY call in an unarmed session says so (#992
945
+ // hardening — the once-per-session marker it used to pass through was
946
+ // suppressible by a bare `touch` on a derivable tmp path). hookName is
947
+ // threaded explicitly (#993 — no hard-wired literal in the loader).
948
+ emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
949
+ } catch {
950
+ // Last resort: even the banner helper failed to load. Emit unconditionally
951
+ // (no once-per-session keying) — repeated noise beats a silent disarm.
952
+ process.stderr.write(
953
+ '🚨 pre-bash-destructive-guard: GUARD INACTIVE — module load failed ' +
954
+ `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
955
+ 'Destructive Bash commands are NOT being blocked. See issue #992.\n'
956
+ );
957
+ }
958
+ process.exit(0); // fail-open, but no longer fail-silent
507
959
  }
508
960
 
509
961
  // Top-level error handler — never let exit 1 leak