session-orchestrator 3.17.0 → 3.19.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 (187) 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 +105 -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/docs/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. 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,109 @@
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;
69
- }
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
- }
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
+ let resolveProjectDir;
77
+ let resolvePluginRoot;
78
+ /**
79
+ * The whole `command-blocker.mjs` namespace (W4 B6: one direct import path, not
80
+ * via the hardening.mjs barrel, which does not re-export the #982/#983
81
+ * primitives).
82
+ *
83
+ * Held as ONE object rather than destructured into six bindings on purpose: the
84
+ * required-export list then exists in exactly one place —
85
+ * `COMMAND_BLOCKER_EXPORTS` in `_lib/guard-source-loader.mjs`, which validates
86
+ * both the working-tree and the HEAD copy against it. The previous split (six
87
+ * names destructured here, two of them checked there) is what let a HEAD copy
88
+ * missing four exports banner "DEGRADED — still armed" and then fail open on
89
+ * every command.
90
+ *
91
+ * @type {Record<string, Function>|null}
92
+ */
93
+ let blocker = null;
94
+ let readConfigFile;
95
+ let loadEffectivePolicy;
96
+ let isSessionConfigHeading;
97
+ let emitEvent;
98
+
99
+ const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
100
+
101
+ /**
102
+ * Project dir for banner keying, resolved WITHOUT `platform.mjs` — that module
103
+ * is one of the ones that may have failed to load.
104
+ *
105
+ * @returns {string}
106
+ */
107
+ function bannerProjectDir() {
108
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
80
109
  }
81
110
 
111
+ /**
112
+ * Bind every repo dependency. Throws on any load failure; the caller banners.
113
+ *
114
+ * Order matters only for cost: the cheap plain imports run first, so a broken
115
+ * `io.mjs` never pays for a pointless `git show` on `command-blocker.mjs`.
116
+ *
117
+ * @returns {Promise<void>}
118
+ */
119
+ async function bootstrap() {
120
+ ({ readStdin, emitAllow, emitDeny } = await import('../scripts/lib/io.mjs'));
121
+ ({ resolveProjectDir, resolvePluginRoot } = await import('../scripts/lib/platform.mjs'));
122
+ ({ readConfigFile } = await import('../scripts/lib/config.mjs'));
123
+ ({ loadEffectivePolicy } = await import('../scripts/lib/blocked-commands-policy.mjs'));
124
+ ({ isSessionConfigHeading } = await import('../scripts/lib/config/section-extractor.mjs'));
125
+ ({ emitEvent } = await import('../scripts/lib/events.mjs'));
126
+
127
+ const { loadCommandBlocker } = await import('./_lib/guard-source-loader.mjs');
128
+ // Throws unless the loaded namespace carries the COMPLETE required export set
129
+ // (working-tree copy or HEAD fallback alike) — the caller then banners
130
+ // GUARD INACTIVE rather than arming a guard that cannot evaluate.
131
+ ({ module: blocker } = await loadCommandBlocker({
132
+ specifier: pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'command-blocker.mjs')).href,
133
+ repoRoot: PLUGIN_ROOT,
134
+ projectDir: bannerProjectDir(),
135
+ }));
136
+ }
137
+
138
+ // Module-level per-path policy cache (issue #250, extended for the #972
139
+ // floor/overlay merge: one Map entry per policy path instead of a single-path
140
+ // triple, so floor and overlay invalidate independently on mtime advance).
141
+ // Safe because each hook invocation runs as an isolated Node subprocess —
142
+ // state is fresh per process, never shared across calls.
143
+ const _policyCache = new Map();
144
+
82
145
  // ---------------------------------------------------------------------------
83
146
  // Internal helpers
84
147
  // ---------------------------------------------------------------------------
@@ -115,14 +178,28 @@ function resolveSessionId(input) {
115
178
  }
116
179
 
117
180
  /**
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.
181
+ * Block a command: emit the PreToolUse deny envelope via emitDeny (exit 0).
182
+ *
183
+ * Until #906 this used a raw `process.exit(2)` plus its own stdout write,
184
+ * justified by a claim that the multi-line message "required by the spec"
185
+ * could not go through emitDeny. That claim was false in both halves:
186
+ * - Claude Code DISCARDS stdout on exit 2 and reads stderr instead — and
187
+ * this function wrote nothing to stderr, so the operator saw only
188
+ * `hook error: … No stderr output`, i.e. what looks like a crash. This
189
+ * was the single worst instance of that symptom in the repo.
190
+ * - emitDeny preserves multi-line reasons verbatim: JSON.stringify escapes
191
+ * the newlines, so the exact 4-line reason below round-trips unchanged
192
+ * inside `permissionDecisionReason` on ONE stdout line (verified against
193
+ * this very reason string). The operator additionally gets the first line
194
+ * as the `systemMessage` headline.
121
195
  *
122
196
  * Emits a best-effort `orchestrator.destructive_guard.blocked` telemetry
123
- * event BEFORE exiting. Emission failure (e.g. unwritable events.jsonl path)
197
+ * event BEFORE denying. Emission failure (e.g. unwritable events.jsonl path)
124
198
  * must NEVER change the block outcome — the guard's block-decision is
125
- * strictly independent of telemetry success.
199
+ * strictly independent of telemetry success. emitDeny never returns, so it
200
+ * MUST stay the last statement here.
201
+ *
202
+ * @returns {Promise<never>}
126
203
  */
127
204
  async function blockCommand(pattern, ruleId, rationale, command, sessionId) {
128
205
  const reason = [
@@ -142,36 +219,8 @@ async function blockCommand(pattern, ruleId, rationale, command, sessionId) {
142
219
  // Best-effort — telemetry must never block or alter the guard decision.
143
220
  }
144
221
 
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;
222
+ // Structured deny for the Claude Code PreToolUse hook protocol. Never returns.
223
+ emitDeny(reason);
175
224
  }
176
225
 
177
226
  /**
@@ -193,6 +242,29 @@ async function isGitStashNonEmpty(projectDir) {
193
242
  }
194
243
  }
195
244
 
245
+ /**
246
+ * Given a redirect token at `segment[i]`, return the index of the LAST token
247
+ * belonging to that redirect (the operand word, when one exists). Redirect
248
+ * operators and their operands name IO targets, not command arguments —
249
+ * `rm -rf /tmp/ok > out.log` must not collect `>` or `out.log` as rm targets
250
+ * (#983 FP fix). `dup` (`2>&1`) carries no operand word.
251
+ *
252
+ * @param {Array<{ text: string, quoted: boolean, redirect?: object }>} segment
253
+ * @param {number} i — index of the redirect token
254
+ * @returns {number}
255
+ */
256
+ function redirectSpanEnd(segment, i) {
257
+ const tok = segment[i];
258
+ // `dup` (2>&1) carries its target inline; `heredoc` consumes its delimiter in
259
+ // the lexer (the body arrives as a separate QUOTED token) — neither owns an
260
+ // operand word, and claiming one would swallow the next real token.
261
+ const hasOperandWord = tok.redirect.mode !== 'dup' && tok.redirect.mode !== 'heredoc';
262
+ if (hasOperandWord && i + 1 < segment.length && !segment[i + 1].redirect) {
263
+ return i + 1;
264
+ }
265
+ return i;
266
+ }
267
+
196
268
  /**
197
269
  * Parse ALL non-flag path arguments from every `rm` invocation in a command.
198
270
  *
@@ -201,72 +273,123 @@ async function isGitStashNonEmpty(projectDir) {
201
273
  * chained commands (`rm -rf /tmp/x; rm -rf src/`). Quote-aware via
202
274
  * tokenizeCommand so a path with spaces inside quotes is one target.
203
275
  *
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.
276
+ * Wrapper-aware (#982 T2): the segment verb resolves through the transparent
277
+ * wrappers in WRAPPER_UNWRAP via resolveSegmentVerb `sudo rm -rf /tmp/ok`,
278
+ * `timeout 5 rm -rf /x`, `nohup rm -rf …` parse their REAL targets instead of
279
+ * falling back to the empty-target conservative block. Redirect tokens and
280
+ * their operands are skipped (#983): they are IO targets, not rm targets.
281
+ *
282
+ * Returns BOTH halves of the operand/redirect split (merge of the two
283
+ * 2026-08-03 guard sessions — the two protection layers are complementary):
284
+ * - `targets` — the rm operands (what gets deleted). Judged against the
285
+ * rule's `path-allowlist` by the caller.
286
+ * - `writeTargets` — the files each WRITE redirect (`truncate` or `append`
287
+ * mode) in the same invocation clobbers-or-creates. Read redirects,
288
+ * here-docs/here-strings and fd duplications (`2>&1`) contribute nothing.
289
+ * The caller holds these to the SAME allowlist (with `/dev/null` carved
290
+ * out via isNullSink), because `rm -rf /tmp/ok > src/important.ts` empties
291
+ * a project file on the strength of an allowlisted rm operand. This is the
292
+ * rm-context layer; protected artefacts (`> CLAUDE.md` — `> AGENTS.md` on
293
+ * Codex CLI, the same truncation class) are ADDITIONALLY
294
+ * denied command-independently by the `redirect-truncate-protected`
295
+ * policy rule (rule 14).
296
+ *
297
+ * The caller treats the rm-rf rule as "allowed" only when EVERY operand and
298
+ * EVERY write-redirect target passes its respective check — a segment whose
299
+ * pattern matched but whose verb does NOT resolve to `rm` (e.g. an interpreter
300
+ * payload) contributes no targets and stays fail-closed.
206
301
  *
207
302
  * @param {string} command
208
- * @returns {string[]}
303
+ * @returns {{targets: string[], writeTargets: string[]}}
209
304
  */
210
305
  function parseRmTargets(command) {
211
- const tokens = tokenizeCommand(command);
212
306
  const targets = [];
213
- let i = 0;
307
+ const writeTargets = [];
214
308
 
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; }
309
+ for (const segment of blocker.splitChainSegments(blocker.tokenizeCommand(command))) {
310
+ const { verb, index } = blocker.resolveSegmentVerb(segment);
311
+ if (verb !== 'rm') continue;
220
312
 
221
- // Consume this `rm` invocation's args until the next chain operator.
222
- i++; // skip `rm`
223
313
  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++;
314
+ for (let i = index + 1; i < segment.length; i++) {
315
+ const tok = segment[i];
316
+ if (tok.redirect) {
317
+ const end = redirectSpanEnd(segment, i);
318
+ const mode = tok.redirect.mode;
319
+ if ((mode === 'truncate' || mode === 'append') && end > i) {
320
+ const operand = segment[end];
321
+ if (operand && !(!operand.quoted && /^(;|&&|\|\||\||&)$/.test(operand.text))) {
322
+ writeTargets.push(operand.text);
323
+ }
324
+ }
325
+ i = end;
232
326
  continue;
233
327
  }
328
+ if (!seenDashDash && tok.text === '--') { seenDashDash = true; continue; }
329
+ // A flag is unquoted and starts with '-' (and is not the bare '-' stdin marker).
330
+ if (!seenDashDash && !tok.quoted && tok.text.startsWith('-') && tok.text !== '-') continue;
234
331
  targets.push(tok.text);
235
- i++;
236
332
  }
237
333
  }
238
334
 
239
- return targets;
335
+ return { targets, writeTargets };
336
+ }
337
+
338
+ /**
339
+ * `/dev/null` is the one write-redirect destination that destroys nothing: it is
340
+ * a character device, so `>` on it neither truncates nor creates a file. It is
341
+ * also the single most common rm-plumbing target (`rm -rf /tmp/x 2> /dev/null`).
342
+ *
343
+ * Deliberately NOT folded into isRmPathAllowed: `rm -rf /dev/null` must stay
344
+ * blocked — deleting the device node is destructive, writing to it is not.
345
+ *
346
+ * @param {string} targetPath
347
+ * @returns {boolean}
348
+ */
349
+ function isNullSink(targetPath) {
350
+ return Boolean(targetPath)
351
+ && path.isAbsolute(targetPath)
352
+ && path.normalize(targetPath) === path.join(path.sep, 'dev', 'null');
240
353
  }
241
354
 
242
355
  /**
243
356
  * Detect whether the command contains an UNQUOTED `rm` invocation carrying BOTH
244
357
  * 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.
358
+ * including combined/short forms (`-rf`, `-fr`, `-r -f`) and the long-flag pair
359
+ * (`--recursive --force`). This catches flag-form variants the literal "rm -rf"
360
+ * pattern misses (#641 gap closure) while staying consistent with the
361
+ * quoted-payload guard: an `rm` that appears only inside a quoted token is NOT
362
+ * treated as an invocation here.
363
+ *
364
+ * Wrapper-aware (#982 T2) + redirect-skip (#983) — same segment mechanics as
365
+ * parseRmTargets above.
366
+ *
367
+ * Redirect operators and their targets are skipped for the same reason as in
368
+ * parseRmTargets: a redirect target is a filename the shell consumes, so
369
+ * `rm -r /tmp/x > -f` must not read `-f` as rm's force flag.
370
+ *
371
+ * This walker deliberately DISCARDS redirect spans entirely. Its question is
372
+ * only "does this command contain a recursive+force rm at all", i.e. whether
373
+ * the rm-rf rule MATCHES — it never decides allow-vs-deny. Write-redirect
374
+ * targets are judged once, by the `redirect-truncate-protected` policy rule
375
+ * (rule 14), never here — a second collector would give the guard a silently
376
+ * divergent opinion about the same fact.
249
377
  *
250
378
  * @param {string} command
251
379
  * @returns {boolean}
252
380
  */
253
381
  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`
382
+ for (const segment of blocker.splitChainSegments(blocker.tokenizeCommand(command))) {
383
+ const { verb, index } = blocker.resolveSegmentVerb(segment);
384
+ if (verb !== 'rm' || segment[index].quoted) continue;
385
+
263
386
  let recursive = false;
264
387
  let force = false;
265
388
  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; }
389
+ for (let i = index + 1; i < segment.length; i++) {
390
+ const t = segment[i];
391
+ if (t.redirect) { i = redirectSpanEnd(segment, i); continue; }
392
+ if (!seenDashDash && t.text === '--') { seenDashDash = true; continue; }
270
393
  if (!seenDashDash && !t.quoted && t.text.startsWith('-') && t.text !== '-') {
271
394
  if (t.text === '--recursive') recursive = true;
272
395
  else if (t.text === '--force') force = true;
@@ -275,16 +398,138 @@ function commandHasRecursiveForceRm(command) {
275
398
  if (/[rR]/.test(t.text)) recursive = true;
276
399
  if (/f/.test(t.text)) force = true;
277
400
  }
278
- i++;
279
- continue;
280
401
  }
281
- i++; // non-flag arg (a target) — skip
402
+ // non-flag arg (a target) — skip
282
403
  }
283
404
  if (recursive && force) return true;
284
405
  }
285
406
  return false;
286
407
  }
287
408
 
409
+ /** Probe operator per redirect mode — see findMatchedRedirectEntry. */
410
+ const REDIRECT_PROBE_OPS = { truncate: '>', append: '>>', read: '<' };
411
+
412
+ /**
413
+ * Identify WHICH resolved redirect entry a matched redirect-truncate rule hit,
414
+ * so the deny reason can name the target (#983). redirectRuleMatches returns
415
+ * only a boolean and its glob matcher is internal to command-blocker.mjs —
416
+ * rather than duplicate the glob logic here (it would be the third copy), each
417
+ * candidate target is re-probed through redirectRuleMatches with a minimal
418
+ * single-redirect command. Resolved targets are guaranteed free of `$` and
419
+ * backticks (those are reported `unresolved`), so the quoted probe round-trips
420
+ * the target text exactly.
421
+ *
422
+ * `repoRoot` MUST be the same value the deciding redirectRuleMatches call used
423
+ * (#988 T1): an absolute/`~` target only matches when the root is supplied, so
424
+ * dropping it here would leave `hit === null` for exactly the targets the new
425
+ * resolution added and the deny reason would silently fall back to the pattern.
426
+ *
427
+ * @param {object} rule — the redirect-truncate policy rule
428
+ * @param {Array<{ target: string|null, mode: string, unresolved?: boolean }>} entries
429
+ * @param {string} repoRoot — absolute repo root, forwarded to redirectRuleMatches
430
+ * @returns {{ target: string, mode: string }|null}
431
+ */
432
+ function findMatchedRedirectEntry(rule, entries, repoRoot) {
433
+ for (const entry of entries) {
434
+ if (entry.unresolved) continue;
435
+ const op = REDIRECT_PROBE_OPS[entry.mode] ?? '>';
436
+ const probe = `${op} "${entry.target.replace(/[\\"]/g, '\\$&')}"`;
437
+ if (blocker.redirectRuleMatches(rule, probe, { repoRoot })) return entry;
438
+ }
439
+ return null;
440
+ }
441
+
442
+ /**
443
+ * Expand a LEADING `$TMPDIR` / `${TMPDIR}` reference in an rm target token.
444
+ *
445
+ * The hook is a PreToolUse gate: it sees the raw, UNEXPANDED shell string.
446
+ * `rm -rf "${TMPDIR}"scratch` therefore arrives as the token `${TMPDIR}scratch`,
447
+ * which `path.isAbsolute()` reads as a RELATIVE path — so the #641 `wasAbsolute`
448
+ * gate (correctly, on its own terms) refuses to match it against any /tmp-class
449
+ * prefix, and the path is instead resolved against the PROJECT dir. A legitimate
450
+ * temp cleanup is blocked (#935 cause 1). Substituting the value the shell would
451
+ * have substituted, BEFORE absoluteness is judged, is the narrow fix.
452
+ *
453
+ * This does NOT by itself widen the safe set: the expansion result still has to
454
+ * land under a CONFINED allowlist prefix (see resolveAllowlistPrefixes), so an
455
+ * inherited `TMPDIR=/etc` expands to `/etc/...` and is still blocked (#642).
456
+ *
457
+ * Deliberately narrow: only TMPDIR, only in leading position, only when its
458
+ * value is absolute, and byte-for-byte as the shell would splice it (no invented
459
+ * separator — `TMPDIR=/tmp` + `${TMPDIR}x` really is `/tmpx`, not `/tmp/x`).
460
+ * A generic env-expander would be a far larger attack surface.
461
+ *
462
+ * @param {string} targetPath
463
+ * @returns {string}
464
+ */
465
+ function expandTmpdirToken(targetPath) {
466
+ const m = /^\$(?:TMPDIR\b|\{TMPDIR\})/.exec(targetPath);
467
+ if (!m) return targetPath;
468
+ const value = process.env.TMPDIR || os.tmpdir();
469
+ if (!value || !path.isAbsolute(value)) return targetPath;
470
+ return value + targetPath.slice(m[0].length);
471
+ }
472
+
473
+ /**
474
+ * Canonicalise `absPath` by realpath-ing the deepest EXISTING ancestor and
475
+ * re-attaching the non-existent suffix.
476
+ *
477
+ * `fs.realpathSync.native` throws ENOENT on a path that does not exist — which
478
+ * is the NORMAL case here: `rm -rf` frequently targets something already gone,
479
+ * and a policy prefix (`$TMPDIR` of another boot session) need not exist either.
480
+ * The upward walk mirrors the identical pattern in hooks/enforce-scope.mjs
481
+ * (SECURITY-REQ-03) rather than inventing a second one.
482
+ *
483
+ * Any other fs error → return the lexical input unchanged. That is the fail-safe
484
+ * direction: the caller then compares lexically, i.e. exactly the pre-#935
485
+ * behaviour, never a broader one.
486
+ *
487
+ * @param {string} absPath
488
+ * @returns {string}
489
+ */
490
+ function canonicalizeAncestors(absPath) {
491
+ let ancestor = absPath;
492
+ const segments = [];
493
+ for (;;) {
494
+ try {
495
+ const real = fs.realpathSync.native(ancestor);
496
+ return segments.length ? path.join(real, ...segments.reverse()) : real;
497
+ } catch (err) {
498
+ // ENOENT: nothing at this level yet. ENOTDIR: a FILE sits in the chain.
499
+ // Both mean "keep walking up"; anything else (ELOOP, EACCES) → lexical.
500
+ if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') return absPath;
501
+ const parent = path.dirname(ancestor);
502
+ if (parent === ancestor) return absPath; // reached the fs root, nothing existed
503
+ segments.push(path.basename(ancestor));
504
+ ancestor = parent;
505
+ }
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Canonical LOCATION of an `rm` target.
511
+ *
512
+ * `rm -rf X` removes X ITSELF: when X is a symlink the link is unlinked and what
513
+ * it points at is untouched. So the final segment must NOT be symlink-resolved —
514
+ * only its parent chain. Resolving the leaf would be both wrong about what rm
515
+ * does and unsafe: `<project>/link-to-tmp` would canonicalise to /private/tmp and
516
+ * read as a safe temp target while the delete lands on project content.
517
+ *
518
+ * Canonicalising the parent chain keeps the judgement honest in both directions:
519
+ * - `/tmp/link-to-etc/x` canonicalises OUT of the temp allowlist → blocked.
520
+ * The pre-#935 literal prefix match allowed it (measured, real hole).
521
+ * - `$TMPDIR/x` and its `/private/var/folders/...` canonical spelling collapse
522
+ * onto the same string → both allowed, no literal-form guessing.
523
+ *
524
+ * @param {string} abs — already `path.normalize`d absolute path
525
+ * @returns {string}
526
+ */
527
+ function canonicalizeRmTarget(abs) {
528
+ const parent = path.dirname(abs);
529
+ if (parent === abs) return abs; // filesystem root
530
+ return path.join(canonicalizeAncestors(parent), path.basename(abs));
531
+ }
532
+
288
533
  /**
289
534
  * Return true when a single path is a safe `rm -rf` target.
290
535
  *
@@ -293,7 +538,7 @@ function commandHasRecursiveForceRm(command) {
293
538
  * - <projectRoot>/node_modules (any depth)
294
539
  * - /tmp/ (any depth) — agent-owned scratch
295
540
  * - /private/tmp/ (any depth) — macOS canonical /tmp
296
- * - resolved os.tmpdir() / $TMPDIR (any depth)
541
+ * - resolved os.tmpdir() / $TMPDIR (any depth), in either spelling
297
542
  *
298
543
  * The /tmp-class prefixes come from the rule's optional `path-allowlist` and are
299
544
  * resolved at runtime here.
@@ -307,12 +552,17 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
307
552
  if (!targetPath) return false;
308
553
 
309
554
  const base = projectDir || process.cwd();
310
- const wasAbsolute = path.isAbsolute(targetPath);
555
+ // Restore the shell's own substitution before judging absoluteness (#935).
556
+ const effective = expandTmpdirToken(targetPath);
557
+ const wasAbsolute = path.isAbsolute(effective);
311
558
 
312
559
  // Project-relative safe dirs (always allowed, independent of the rule allowlist).
560
+ // Deliberately LEXICAL: a project-relative candidate is never symlink-resolved,
561
+ // otherwise a link inside the project pointing at /tmp would read as safe and
562
+ // `rm -rf <that link>` — which destroys project content — would be allowed.
313
563
  const abs = wasAbsolute
314
- ? path.normalize(targetPath)
315
- : path.resolve(base, targetPath);
564
+ ? path.normalize(effective)
565
+ : path.resolve(base, effective);
316
566
 
317
567
  const safeProjectDirs = [
318
568
  path.join(base, '.orchestrator', 'tmp'),
@@ -328,9 +578,14 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
328
578
  // /tmp prefix just because the project dir itself happens to live under /tmp
329
579
  // (the case on CI runners where os.tmpdir() === /tmp). #641.
330
580
  if (wasAbsolute) {
581
+ // Compare CANONICAL forms on both sides. Literal matching made the verdict
582
+ // depend on which spelling of the same directory was typed (`/var/folders/…`
583
+ // allowed, `/private/var/folders/…` blocked — #935 cause 2) and let a symlink
584
+ // under /tmp launder a non-temp destination into the allowlist.
585
+ const canonTarget = canonicalizeRmTarget(abs);
331
586
  for (const prefix of resolveAllowlistPrefixes(ruleAllowlist)) {
332
587
  // The target must be the prefix dir itself or a descendant of it.
333
- if (abs === prefix || abs.startsWith(prefix + path.sep)) return true;
588
+ if (canonTarget === prefix || canonTarget.startsWith(prefix + path.sep)) return true;
334
589
  }
335
590
  }
336
591
 
@@ -342,23 +597,51 @@ function isRmPathAllowed(targetPath, projectDir, ruleAllowlist = []) {
342
597
  * prefixes. `$TMPDIR` expands to env.TMPDIR (if set) and os.tmpdir(); literal
343
598
  * paths are normalised. Trailing slashes are stripped for prefix comparison.
344
599
  *
600
+ * Every prefix is emitted in BOTH its lexical and its canonical (realpath)
601
+ * spelling, because the target it is compared against is canonicalised too
602
+ * (see canonicalizeRmTarget) — and when realpath is unavailable both sides fall
603
+ * back to lexical together, so the pair never drifts apart.
604
+ *
345
605
  * @param {string[]} ruleAllowlist
346
606
  * @returns {string[]} normalised absolute prefixes (no trailing slash)
347
607
  */
348
608
  function resolveAllowlistPrefixes(ruleAllowlist) {
349
609
  const out = new Set();
610
+ const strip = (p) => path.normalize(p).replace(/[/\\]+$/, '');
611
+
612
+ // Canonical macOS spellings are listed EXPLICITLY next to their symlink form:
613
+ // /tmp → /private/tmp and /var/folders → /private/var/folders. A TMPDIR handed
614
+ // to us already canonicalised (what fs.realpath returns on macOS) previously
615
+ // failed this confinement check and contributed NO prefix at all, so EVERY
616
+ // $TMPDIR target was blocked (#935 cause 2).
617
+ const tempRoots = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders']
618
+ .map((p) => path.normalize(p));
619
+ const underTempRoot = (p) =>
620
+ tempRoots.some((root) => p === root || p.startsWith(root + path.sep));
621
+
622
+ // Operator-authored, VCS-reviewed policy literals (`/tmp/`, `/private/tmp/`).
350
623
  const add = (p) => {
351
624
  if (!p) return;
352
- const norm = path.normalize(p).replace(/[/\\]+$/, '');
353
- if (norm) out.add(norm);
625
+ const lex = strip(p);
626
+ if (!lex) return;
627
+ out.add(lex);
628
+ const canon = strip(canonicalizeAncestors(lex));
629
+ if (canon) out.add(canon);
354
630
  };
355
- const tempRoots = ['/tmp', '/private/tmp', '/var/folders'].map((p) => path.normalize(p));
631
+
632
+ // Environment-derived (`$TMPDIR`) — attacker-influencable, hence DOUBLE
633
+ // confinement: the value must sit under a temp root both as written AND after
634
+ // symlink resolution. Checking only the lexical form would let a `TMPDIR`
635
+ // pointing at a symlink under /tmp (e.g. /tmp/evil → <project>) promote the
636
+ // project itself to an allowlisted prefix (#642 confinement, extended).
356
637
  const addTemp = (p) => {
357
638
  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
- }
639
+ const lex = strip(p);
640
+ if (!lex || !underTempRoot(lex)) return;
641
+ const canon = strip(canonicalizeAncestors(lex));
642
+ if (!canon || !underTempRoot(canon)) return;
643
+ out.add(lex);
644
+ out.add(canon);
362
645
  };
363
646
 
364
647
  for (const entry of Array.isArray(ruleAllowlist) ? ruleAllowlist : []) {
@@ -402,7 +685,10 @@ async function main() {
402
685
  const lines = mdContent.split(/\r?\n/);
403
686
  let inConfig = false;
404
687
  for (const line of lines) {
405
- if (line === '## Session Config') { inConfig = true; continue; }
688
+ // SSOT predicate (#968) never re-derive this comparison. A local copy
689
+ // that drifts LOOSER than the extractor silently disagrees with the
690
+ // runtime about where the config block starts.
691
+ if (isSessionConfigHeading(line)) { inConfig = true; continue; }
406
692
  if (inConfig && /^## /.test(line)) break;
407
693
  if (inConfig) {
408
694
  const m = line.match(/^\s*(?:-\s+\*\*)?allow-destructive-ops(?::\*\*)?\s*:\s*(\S+)/);
@@ -416,42 +702,75 @@ async function main() {
416
702
  // No config file or parse error — proceed to policy check
417
703
  }
418
704
 
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();
705
+ // G4 — policy load (#972: floor/overlay merge, not first-hit-wins).
706
+ // The plugin-root policy is the FLOOR; a cwd/projectDir policy is an OVERLAY
707
+ // that can only add rules or escalate severity — an empty or malformed
708
+ // consumer policy fails TO THE FLOOR instead of silently disarming the guard.
709
+ // Only when NO usable policy exists at all does the guard keep its documented
710
+ // fail-open (rules === null → emitAllow with a stderr warning).
711
+ const { rules, warnings } = await loadEffectivePolicy({
712
+ cwd: process.cwd(),
713
+ projectDir,
714
+ pluginRoot: resolvePluginRoot(),
715
+ cache: _policyCache,
716
+ });
717
+ for (const warning of warnings) {
718
+ process.stderr.write(`⚠ pre-bash-destructive-guard: ${warning}\n`);
444
719
  }
720
+ if (!Array.isArray(rules)) return emitAllow();
445
721
 
446
722
  // G5 — rule evaluation
447
- for (const rule of policy.rules) {
723
+ for (const rule of rules) {
448
724
  const { id, pattern, severity, rationale = '' } = rule;
449
725
 
726
+ // #983 — redirect-truncate rules are decided by redirect TARGET via
727
+ // redirectRuleMatches, NEVER by the generic pattern path below: their
728
+ // `pattern: ">"` substring-matches virtually every redirect (measured
729
+ // interim FP: `bash -c 'echo a > b'` denied), so this branch must fully
730
+ // shadow the pattern match for this rule class.
731
+ if (rule.type === 'redirect-truncate') {
732
+ const modes = new Set(
733
+ Array.isArray(rule.modes) && rule.modes.length > 0 ? rule.modes : ['truncate']
734
+ );
735
+ const collected = blocker.extractRedirectTargets(command);
736
+ const entries = collected.filter((e) => modes.has(e.mode));
737
+ // Recursion-cap markers (#988 T2) carry `mode: null` by construction — a
738
+ // bare `modes.has(e.mode)` filter drops them, which would make the new
739
+ // marker unobservable here. Keep them alongside the mode-matching ones.
740
+ const unresolved = collected.filter(
741
+ (e) => e.unresolved && (e.mode === null || modes.has(e.mode))
742
+ );
743
+ if (unresolved.length > 0) {
744
+ // Variable/substitution operands are never match candidates (#641 FP
745
+ // class) — surface them instead of guessing; never block on a guess.
746
+ // Same for a payload subtree a recursion cap cut off: the cap stays,
747
+ // its effect stops being silent.
748
+ const reasons = [
749
+ ...new Set(unresolved.map((e) => e.reason ?? 'variable/substitution')),
750
+ ].join(', ');
751
+ process.stderr.write(
752
+ `⚠ pre-bash-destructive-guard: unresolved redirect target (${reasons}) — not matched (fail-visible)\n`
753
+ );
754
+ }
755
+ if (!blocker.redirectRuleMatches(rule, command, { repoRoot: projectDir })) continue;
756
+ if (severity !== 'block') {
757
+ process.stderr.write(
758
+ `⚠ pre-bash-destructive-guard: redirect target matched (rule: ${id}) — ${rationale}\n`
759
+ );
760
+ continue;
761
+ }
762
+ // Reason stays short (stdout-budget): operator + target, never the command.
763
+ const hit = findMatchedRedirectEntry(rule, entries, projectDir);
764
+ const label = hit ? `${REDIRECT_PROBE_OPS[hit.mode] ?? '>'} ${hit.target}` : pattern;
765
+ await blockCommand(label, id, rationale, command, sessionId);
766
+ continue; // unreachable (blockCommand never returns) — kept for clarity
767
+ }
768
+
450
769
  // The rm-rf-destructive rule also fires for recursive+force rm flag variants
451
770
  // the literal "rm -rf" pattern misses (`rm -r -f`, `rm -fr`) — #641 gap closure.
452
771
  const matched = id === 'rm-rf-destructive'
453
- ? (commandMatchesBlocked(command, pattern) || commandHasRecursiveForceRm(command))
454
- : commandMatchesBlocked(command, pattern);
772
+ ? (blocker.commandMatchesBlocked(command, pattern) || commandHasRecursiveForceRm(command))
773
+ : blocker.commandMatchesBlocked(command, pattern);
455
774
  if (!matched) continue;
456
775
 
457
776
  if (severity === 'warn') {
@@ -484,14 +803,25 @@ async function main() {
484
803
  // Special: rm-rf-destructive — path exception
485
804
  if (id === 'rm-rf-destructive') {
486
805
  const ruleAllowlist = Array.isArray(rule['path-allowlist']) ? rule['path-allowlist'] : [];
487
- const targets = parseRmTargets(command);
806
+ const { targets, writeTargets } = parseRmTargets(command);
488
807
  // Allow ONLY when there is at least one target AND every target is
489
808
  // allowlisted. An unparseable command (no targets) or any non-allowlisted
490
809
  // target → block (conservative). This makes mixed chains like
491
810
  // `rm -rf /tmp/x; rm -rf src/` block on the src/ target.
811
+ //
812
+ // Every WRITE-redirect target of the same invocation must clear the SAME
813
+ // allowlist (with /dev/null carved out): `>` truncates its target before
814
+ // rm ever runs, so without this the allow path waved through
815
+ // `rm -rf /tmp/ok > src/important.ts` on the strength of an allowlisted
816
+ // rm operand. Protected artefacts (`> CLAUDE.md`) are ADDITIONALLY
817
+ // covered command-independently by rule 14 (redirect-truncate-protected)
818
+ // — two complementary layers from the two 2026-08-03 guard sessions.
492
819
  const allAllowed =
493
820
  targets.length > 0 &&
494
- targets.every((t) => isRmPathAllowed(t, projectDir, ruleAllowlist));
821
+ targets.every((t) => isRmPathAllowed(t, projectDir, ruleAllowlist)) &&
822
+ writeTargets.every(
823
+ (t) => isNullSink(t) || isRmPathAllowed(t, projectDir, ruleAllowlist)
824
+ );
495
825
  if (allAllowed) {
496
826
  // Safe paths only (.orchestrator/tmp, node_modules, /tmp, $TMPDIR) — allow
497
827
  continue;
@@ -506,6 +836,41 @@ async function main() {
506
836
  return emitAllow();
507
837
  }
508
838
 
839
+ // ---------------------------------------------------------------------------
840
+ // Entry point (#992)
841
+ //
842
+ // TWO distinct failure classes, two distinct banners — do not merge them:
843
+ //
844
+ // 1. LOAD failure (`bootstrap()` throws): the guard never armed. Nothing was
845
+ // evaluated, so the fail-open is total. This used to be a bare exit-1
846
+ // crash with 0 bytes of stdout — indistinguishable from an allow, and
847
+ // therefore invisible. Now it exits 0 (still fail-open, so a broken module
848
+ // cannot brick the session) but SAYS SO, loudly, once per session.
849
+ // 2. RUNTIME failure inside `main()`: pre-existing behaviour, unchanged.
850
+ // The guard armed and then tripped over a specific command; that is a
851
+ // narrower blast radius and keeps its historical `internal error` line.
852
+ // ---------------------------------------------------------------------------
853
+ try {
854
+ await bootstrap();
855
+ } catch (loadError) {
856
+ try {
857
+ const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
858
+ // Unthrottled by design: EVERY call in an unarmed session says so (#992
859
+ // hardening — the once-per-session marker it used to pass through was
860
+ // suppressible by a bare `touch` on a derivable tmp path).
861
+ emitGuardInactiveBanner({ error: loadError });
862
+ } catch {
863
+ // Last resort: even the banner helper failed to load. Emit unconditionally
864
+ // (no once-per-session keying) — repeated noise beats a silent disarm.
865
+ process.stderr.write(
866
+ '🚨 pre-bash-destructive-guard: GUARD INACTIVE — module load failed ' +
867
+ `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
868
+ 'Destructive Bash commands are NOT being blocked. See issue #992.\n'
869
+ );
870
+ }
871
+ process.exit(0); // fail-open, but no longer fail-silent
872
+ }
873
+
509
874
  // Top-level error handler — never let exit 1 leak
510
875
  main().catch((e) => {
511
876
  process.stderr.write(