session-orchestrator 3.16.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 (220) 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 +130 -412
  6. package/README.md +17 -12
  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/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  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 +97 -0
  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/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,977 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * post-bash-write-verify.mjs — PostToolUse hook (matcher `Bash`): report
4
+ * working-tree changes a Bash call made OUTSIDE the wave's `allowedPaths`.
5
+ *
6
+ * ## Why this hook exists (#915, follow-up to #906 / #800)
7
+ *
8
+ * `hooks/enforce-scope.mjs` returns `emitAllow()` for every tool that is not
9
+ * `Edit` / `Write` / `MultiEdit`. A Bash call therefore never reaches a path
10
+ * check, and `echo x > out-of-scope.mjs` bypasses the entire scope-enforcement
11
+ * layer. The PreToolUse half of the fix (`bash-write-guard` in
12
+ * `hooks/enforce-commands.mjs`, #800) parses write targets out of the command
13
+ * string — which is heuristic, which is why it ships OFF by default.
14
+ *
15
+ * This hook is the non-heuristic complement: it does not guess what the command
16
+ * meant, it observes what the filesystem actually shows afterwards. That is why
17
+ * it can default to ENABLED where `bash-write-guard` cannot.
18
+ *
19
+ * ## Warn-only, by construction
20
+ *
21
+ * PostToolUse fires AFTER the command ran; there is nothing left to block. The
22
+ * hook writes one stderr line and one PostToolUse `additionalContext` string,
23
+ * and always exits 0. Escalation to a blocking guard is not a switch on THIS
24
+ * hook — it is flipping `enforcement-gates.bash-write-guard` to `true`, which
25
+ * this hook exists to supply the evidence for. See
26
+ * `docs/session-config-reference.md` § Bash-Write Verify.
27
+ *
28
+ * ## Decision flow (early-exit)
29
+ *
30
+ * G1 profile gate (`SO_HOOK_PROFILE` / `SO_DISABLED_HOOKS`)
31
+ * G2 tool_name === 'Bash'
32
+ * G3 wave-scope.json exists (disappearance after a recorded scope → notice)
33
+ * G4 gates['bash-write-verify'] !== false (absent ⇒ ENABLED)
34
+ * G5 enforcement !== 'off'
35
+ * G6 git status delta vs. snapshot → filter → report NEW out-of-scope paths
36
+ *
37
+ * G4/G5 are evaluated on the scope state RECORDED IN THE SNAPSHOT — the state
38
+ * in effect BEFORE the observed call — never on the live file (#938 vector 1):
39
+ * a Bash call that rewrites wave-scope.json to `enforcement: "off"` cannot
40
+ * silence the hook for its own evaluation. The rewrite itself is always
41
+ * reported (content-hash comparison against the snapshot, because the file is
42
+ * typically gitignored and thus invisible to `git status`), and the new value
43
+ * takes effect from the NEXT call on.
44
+ *
45
+ * With no TRUSTWORTHY snapshot, G4/G5 evaluate as `strict` (#944/#945) — never
46
+ * from the live file. The old live fallback read as a first-call convenience but
47
+ * was a one-command silencer: the sidecar path is `sha1(repoRoot)` and therefore
48
+ * exactly computable, so `rm -f` on it re-creates the "no snapshot" precondition
49
+ * ON DEMAND, not only on the genuine first call. One Bash call could delete the
50
+ * sidecar, write `enforcement: "off"`, and write out of scope — and be judged
51
+ * under the value it had just installed.
52
+ *
53
+ * "Trustworthy" is a statement about the record's CONTENT, not the file's
54
+ * existence. Keyed on `snapshot === null`, the #944 repair closed one state of
55
+ * four at unchanged attacker cost — `rm -f <path>` simply becomes `echo '{}' >
56
+ * <path>`, and `{}` / `{"scopeState":{"hash":"…"}}` were both back to total
57
+ * silence. `trustedScopeState()` requires a `SNAPSHOT_SCHEMA` marker this build
58
+ * writes plus a well-formed `scopeState`, so a forgery has to state the very
59
+ * value it wants unstated. Both effects of that condition — evaluating the call
60
+ * strict AND emitting the lost-state notice — ride the same predicate; splitting
61
+ * them restores the path report while silently dropping the tamper trail.
62
+ *
63
+ * ## Three properties that keep this from becoming noise
64
+ *
65
+ * 1. **Delta, not absolute.** A bare `git status` per Bash call reports the
66
+ * CUMULATIVE dirty tree: once one file is edited, every later Bash call in
67
+ * the session fires. Noise on that scale gets the guard switched off, which
68
+ * reproduces exactly the bypass pressure #915 fights. Only paths new
69
+ * relative to the previous invocation are reported.
70
+ * 2. **Report-once.** A reported path is folded into the snapshot, so a file
71
+ * written by ten consecutive `sed -i` calls warns once, not ten times.
72
+ * 3. **Re-baseline without amnesia (#938).** No snapshot yet (first Bash call
73
+ * of a session — or a deleted sidecar, vector 2), or a changed
74
+ * `allowedPaths` signature (new wave, vector 3): fold the current dirty set
75
+ * into the snapshot, but stay silent ONLY about paths whose mtime predates
76
+ * the wave-scope.json write. That dirt predates the wave; blaming the
77
+ * observed call for it is a false positive. Dirt NEWER than the scope file
78
+ * appeared during this wave — a re-baseline (which the observed actor can
79
+ * force by deleting the `$TMPDIR` sidecar) must not swallow it.
80
+ * 4. **A lost snapshot is named, not assumed benign (#945).** "Sidecar deleted"
81
+ * and "genuine first call" are bit-identical at the read (`readSnapshot()`
82
+ * returns null for both), so the difference must come from an out-of-band
83
+ * clock. Two are consulted: the wave-scope.json mtime (#938) and — because
84
+ * a `touch` on the scope file neutralises exactly that one — the session
85
+ * start time from `.orchestrator/current-session.json`. A legitimate first
86
+ * Bash call sits seconds-to-minutes after session start; a mid-session
87
+ * `rm -f` sits hours after it, and no `touch` moves that marker.
88
+ *
89
+ * ## Ignore list (contract, not implementation detail)
90
+ *
91
+ * `git status --porcelain` already omits `.gitignore`d files, which in THIS
92
+ * repo covers most sibling-hook writes. The explicit list below exists because
93
+ * the guard must not depend on a consumer repo's `.gitignore` being complete.
94
+ *
95
+ * | class | patterns |
96
+ * |--------------------------------|-----------------------------------------------------|
97
+ * | sibling-hook + own event writes| `.orchestrator/{metrics,debug,eval}/**`, `current-session.json`, `host.json`, `session.lock`, `state.lock`, `STATE.md` |
98
+ * | coordinator status files | `.claude|.codex|.cursor|.pi/{STATE.md,hooks.json,metrics/**,worktrees/**,*.lock}` |
99
+ * | package-manager artefacts | `node_modules/**`, `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb` |
100
+ * | build / coverage output | `coverage/**`, `dist/**`, `build/**`, `.next/**`, `junit.xml`, `*.log` |
101
+ * | tmp+rename residue | `*.tmp`, `*.tmp-*`, `*.tmp.*`, `.tmp.*` (io.mjs `writeJsonAtomicSync`, `post-tool-failure-corrective-context.mjs` `.tmp-ptf-*`) |
102
+ * | OS noise | `.DS_Store`, `Thumbs.db` |
103
+ *
104
+ * `wave-scope.json` is deliberately ABSENT from the list since #938: the
105
+ * scope-control file is the one path whose change must ALWAYS be surfaced. An
106
+ * ignore entry here plus a `.gitignore` entry made it doubly invisible, so the
107
+ * observed actor could rewrite the gate unreported. Because the file is
108
+ * typically gitignored, `git status` cannot see it anyway — change detection
109
+ * for it is content-hash-based against the snapshot (`formatControlNotice`).
110
+ *
111
+ * The hook's OWN snapshot lives in `$TMPDIR`, never inside the repo, so it is
112
+ * structurally incapable of reporting its own bookkeeping.
113
+ *
114
+ * ## Measured (2026-07-30, this repo, 1 507 tracked files)
115
+ *
116
+ * hook end-to-end, per Bash call 94.8 ms
117
+ * ├─ node cold start (every hook pays this) ~67 ms (enforce-commands.mjs: 66.9 ms)
118
+ * └─ marginal cost of the git status added here ~28 ms
119
+ * git … --porcelain -z --untracked-files=all 25.0 ms (=normal: 28.7 ms)
120
+ * corpus: 2 528 Bash calls across 41 of 51 archived transcripts
121
+ * → 732 calls (29.0 %) contain any write construct
122
+ * → 23 calls (0.91 %) wrote a real in-repo non-ignored path
123
+ * → per session: median 1 distinct path, max 5 (⇒ ~1 warning/session)
124
+ *
125
+ * ## stdout discipline
126
+ *
127
+ * The payload names changed files, so it is genuinely capable of growing past
128
+ * the 65 536-byte kernel pipe buffer. `console.log` + `process.exit()` would
129
+ * silently drop the tail on macOS (Node docs, "process I/O": pipes are async on
130
+ * macOS) — the #906 fail-open class. Two independent bounds apply: the payload
131
+ * is clamped (MAX_REPORTED_PATHS / MAX_CONTEXT_CHARS) and the write goes
132
+ * through `writeStdoutLineSync`, which loops `fs.writeSync(1, …)` to
133
+ * completion.
134
+ *
135
+ * ## PSA
136
+ *
137
+ * `git status` only. `--no-optional-locks` is load-bearing: without it `git
138
+ * status` opportunistically refreshes (and locks) `.git/index`, which races a
139
+ * parallel session's index write — PSA-007's whole concern. No git-write
140
+ * command is ever issued.
141
+ */
142
+
143
+ import { shouldRunHook } from './_lib/profile-gate.mjs';
144
+ // Exit 0 immediately (silent no-op) when disabled via profile/env (#211).
145
+ if (!shouldRunHook('post-bash-write-verify')) process.exit(0);
146
+
147
+ import path from 'node:path';
148
+ import os from 'node:os';
149
+ import { fileURLToPath } from 'node:url';
150
+ import { createHash } from 'node:crypto';
151
+ import { execFileSync } from 'node:child_process';
152
+ import { readFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
153
+
154
+ import { readStdin, writeStdoutLineSync } from '../scripts/lib/io.mjs';
155
+ import { resolveProjectDir } from '../scripts/lib/platform.mjs';
156
+ import { findScopeFile, pathMatchesPattern } from '../scripts/lib/hardening.mjs';
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Constants
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /** Wall-clock ceiling for the `git status` child process. */
163
+ const GIT_TIMEOUT_MS = 3_000;
164
+
165
+ /** Ceiling for `git status` stdout (a pathological tree is not worth reporting). */
166
+ const GIT_MAX_BUFFER = 4 * 1024 * 1024;
167
+
168
+ /** Max paths named in the report; the rest collapse into a "+N more" tail. */
169
+ export const MAX_REPORTED_PATHS = 20;
170
+
171
+ /** Hard ceiling on the emitted `additionalContext` string. */
172
+ export const MAX_CONTEXT_CHARS = 1_500;
173
+
174
+ /** Snapshot entries retained; bounds the sidecar for a pathological tree. */
175
+ const MAX_SNAPSHOT_PATHS = 5_000;
176
+
177
+ /**
178
+ * Sidecar schema version, written by `writeSnapshot` on EVERY write and required
179
+ * by `trustedScopeState` before any recorded value is believed.
180
+ *
181
+ * Its job is not versioning — it is making the alt-schema exception forgeable
182
+ * only by writing the thing it wants to hide. The exception exists so that a
183
+ * sidecar left by an OLDER build (no `scopeState` at all) does not make every
184
+ * in-flight session emit once on deploy. Keyed on the ABSENCE of a field, that
185
+ * exception is free to an attacker: `echo '{}' > <sidecar>` reproduces the
186
+ * old-build shape exactly. Keyed on the PRESENCE of a marker only the current
187
+ * build writes, a forgery must supply `schema` AND a plausible `scopeState`
188
+ * containing the enforcement value — i.e. it must state the value it is trying
189
+ * to keep unstated.
190
+ *
191
+ * Cost of the bump: one strict-evaluated call (and, if a clock finds the state
192
+ * old, one advisory line) per repo on the deploy that lands it. Bump this
193
+ * whenever the recorded shape changes; never read a version other than this one
194
+ * as trustworthy.
195
+ */
196
+ const SNAPSHOT_SCHEMA = 2;
197
+
198
+ /**
199
+ * A missing snapshot while the WAVE SCOPE is at least this old is suspicious
200
+ * (#938 fix 3): a genuine first Bash call happens minutes after the coordinator
201
+ * wrote wave-scope.json, whereas a long-running wave whose sidecar suddenly
202
+ * vanished points at `rm -f $TMPDIR/so-bash-write-verify/…`.
203
+ *
204
+ * Known blind spot, and the reason the session-age signal below exists: this
205
+ * clock is the mtime of a file the observed actor can write. `touch
206
+ * .claude/wave-scope.json` (content unchanged → no control notice either)
207
+ * resets the age to zero and neutralises the very heuristic meant to catch the
208
+ * deletion it accompanies (#945).
209
+ */
210
+ const SNAPSHOT_MISSING_SUSPICION_MS = 10 * 60 * 1000;
211
+
212
+ /**
213
+ * A missing snapshot this long after the SESSION started is suspicious (#945).
214
+ *
215
+ * The complement to the constant above, chosen because it is not the mtime of
216
+ * anything the scope-file write touches: `.orchestrator/current-session.json`
217
+ * carries the session start timestamp, so a `touch` on wave-scope.json moves it
218
+ * not at all. The threshold matches the scope-age one — both encode "a genuine
219
+ * first Bash call is minutes away from its trigger, not tens of minutes".
220
+ *
221
+ * Honest about what it is NOT: this marker is a plain file, writable by the
222
+ * same Bash call. It raises the cost of a silent deletion from one `rm` to a
223
+ * second, differently-shaped forgery; it is not tamper-proofing. And like the
224
+ * scope-age signal it produces exactly one false alarm after a `$TMPDIR` purge
225
+ * or a reboot mid-session — the same trade already accepted for #938 fix 3.
226
+ */
227
+ const SESSION_AGE_SUSPICION_MS = 10 * 60 * 1000;
228
+
229
+ /**
230
+ * Repo-relative paths whose changes are never attributable to a scope
231
+ * violation. Applied ON TOP of `.gitignore` — see the header table for the
232
+ * class each entry belongs to and why the redundancy with `.gitignore` is
233
+ * deliberate.
234
+ */
235
+ export const IGNORED_PATH_PATTERNS = Object.freeze([
236
+ // sibling-hook + own event writes
237
+ /^\.orchestrator\/(metrics|debug|eval)\//,
238
+ /^\.orchestrator\/(current-session\.json|host\.json|session\.lock|state\.lock|STATE\.md)$/,
239
+ // coordinator status files, all four harness state dirs.
240
+ // `wave-scope.json` is deliberately NOT here (#938 vector 1): the guard's
241
+ // own control file must never be exempt from reporting.
242
+ /^\.(claude|codex|cursor|pi)\/(STATE\.md|hooks\.json)$/,
243
+ /^\.(claude|codex|cursor|pi)\/(metrics|worktrees)\//,
244
+ /^\.(claude|codex|cursor|pi)\/[^/]*\.lock$/,
245
+ // package-manager artefacts
246
+ /^node_modules\//,
247
+ /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/,
248
+ // build / coverage output
249
+ /^(coverage|dist|build|\.next|\.turbo|out)\//,
250
+ /(^|\/)junit\.xml$/,
251
+ /\.log$/,
252
+ // tmp+rename residue (writeJsonAtomicSync, atomicMutateJson, …)
253
+ /\.tmp$/,
254
+ /\.tmp[-.][^/]*$/,
255
+ /(^|\/)\.tmp[-.][^/]*$/,
256
+ // OS noise
257
+ /(^|\/)(\.DS_Store|Thumbs\.db)$/,
258
+ // Collapsed-directory records. `--untracked-files=all` should prevent these,
259
+ // but a git version or config that still collapses must not turn a fresh
260
+ // harness state dir into a scope violation. A trailing-slash record means the
261
+ // WHOLE directory is untracked — for these four that is first-run harness
262
+ // scaffolding, never a Bash write into someone else's file scope.
263
+ /^\.(orchestrator|claude|codex|cursor|pi)\/$/,
264
+ ]);
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Pure helpers (exported for tests)
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /**
271
+ * Is this repo-relative path exempt from reporting?
272
+ *
273
+ * @param {string} relPath forward-slash repo-relative path
274
+ * @returns {boolean}
275
+ */
276
+ export function isIgnoredPath(relPath) {
277
+ if (typeof relPath !== 'string' || relPath === '') return true;
278
+ return IGNORED_PATH_PATTERNS.some((re) => re.test(relPath));
279
+ }
280
+
281
+ /**
282
+ * Parse `git status --porcelain -z` output into repo-relative paths.
283
+ *
284
+ * The `-z` form is NUL-separated with no quoting/escaping, so a path containing
285
+ * a newline or a quote survives intact — the reason `-z` is not optional here.
286
+ * Each record is `XY<space><path>`; `--no-renames` is passed so the two-path
287
+ * rename record shape never occurs.
288
+ *
289
+ * @param {string} raw
290
+ * @returns {string[]}
291
+ */
292
+ export function parsePorcelainZ(raw) {
293
+ if (typeof raw !== 'string' || raw === '') return [];
294
+ const out = [];
295
+ for (const record of raw.split('\0')) {
296
+ if (record.length < 4) continue; // "XY p" is the shortest possible record
297
+ out.push(record.slice(3));
298
+ }
299
+ return out;
300
+ }
301
+
302
+ /**
303
+ * Is a repo-relative path covered by the wave's `allowedPaths`?
304
+ *
305
+ * Reuses `pathMatchesPattern` — the same matcher `enforce-scope.mjs` Gate 7
306
+ * applies — so a path this hook reports is exactly a path the Edit/Write gate
307
+ * would have denied. No bespoke matching.
308
+ *
309
+ * @param {string} relPath
310
+ * @param {string[]} allowedPaths
311
+ * @returns {boolean}
312
+ */
313
+ export function isInScope(relPath, allowedPaths) {
314
+ if (!Array.isArray(allowedPaths) || allowedPaths.length === 0) return false;
315
+ return allowedPaths.some((p) => typeof p === 'string' && pathMatchesPattern(relPath, p));
316
+ }
317
+
318
+ /**
319
+ * Stable signature of the wave's scope. A change means a new wave, which must
320
+ * re-baseline silently rather than blame the next Bash call for the previous
321
+ * wave's dirt.
322
+ *
323
+ * @param {string[]} allowedPaths
324
+ * @returns {string}
325
+ */
326
+ export function scopeSignature(allowedPaths) {
327
+ const list = Array.isArray(allowedPaths) ? [...allowedPaths].filter((p) => typeof p === 'string') : [];
328
+ list.sort();
329
+ return createHash('sha1').update(list.join('\n')).digest('hex').slice(0, 16);
330
+ }
331
+
332
+ /**
333
+ * Absolute path of the snapshot sidecar for a given repo root.
334
+ *
335
+ * Deliberately under `os.tmpdir()`, never inside the repo: an in-repo sidecar
336
+ * would itself appear in `git status` and the guard would report its own
337
+ * bookkeeping on the next call.
338
+ *
339
+ * @param {string} repoRoot
340
+ * @returns {string}
341
+ */
342
+ export function snapshotPathFor(repoRoot) {
343
+ const key = createHash('sha1').update(String(repoRoot)).digest('hex').slice(0, 16);
344
+ return path.join(os.tmpdir(), 'so-bash-write-verify', `${key}.json`);
345
+ }
346
+
347
+ /**
348
+ * Core decision: which out-of-scope paths are NEW since the last invocation?
349
+ *
350
+ * On a re-baseline (no snapshot, or a changed scope signature) the pre-#938
351
+ * behaviour was total silence. That let the observed actor buy blindness by
352
+ * deleting the `$TMPDIR` sidecar (vector 2), and made the first call after
353
+ * every wave rollover blind (vector 3). The mtime rule closes both: dirt
354
+ * whose mtime is NEWER than the wave-scope.json write happened during THIS
355
+ * wave and is reported even on a re-baseline; older dirt predates the wave
356
+ * and stays silent (pre-wave dirt is never blamed on the observed call).
357
+ *
358
+ * @param {object} args
359
+ * @param {string[]} args.dirtyPaths repo-relative paths from git status
360
+ * @param {string[]} args.allowedPaths wave allowedPaths
361
+ * @param {object|null} args.snapshot previous `{ signature, paths[] }`, or null
362
+ * @param {string} args.signature current scope signature
363
+ * @param {number|null} [args.scopeMtimeMs] mtime (ms) of wave-scope.json, or null
364
+ * @param {(relPath: string) => (number|null)} [args.mtimeMs] mtime lookup for a
365
+ * dirty path; null = not attributable (deleted path / stat failure)
366
+ * @returns {{ report: string[], nextSnapshot: { signature: string, paths: string[] }, rebaselined: boolean }}
367
+ */
368
+ export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, scopeMtimeMs = null, mtimeMs = null, scopeRelPath = null }) {
369
+ const outOfScope = dirtyPaths
370
+ .filter((p) => !isIgnoredPath(p))
371
+ .filter((p) => !isInScope(p, allowedPaths))
372
+ // The wave-scope.json control file is out-of-scope by construction (it is
373
+ // never under allowedPaths) but its CHANGES are reported via the content-hash
374
+ // control-notice path, not here. Excluding it keeps the mtime re-baseline
375
+ // filter from reporting the scope file against ITSELF — its mtime always
376
+ // equals scopeMtimeMs, so the MED-3 `>=` boundary would otherwise flag it on
377
+ // every first run (#938 MED-3 follow-through).
378
+ .filter((p) => p !== scopeRelPath);
379
+
380
+ const rebaselined = !snapshot || snapshot.signature !== signature;
381
+ const seen = rebaselined || !Array.isArray(snapshot?.paths) ? new Set() : new Set(snapshot.paths);
382
+
383
+ let report;
384
+ if (!rebaselined) {
385
+ // Report-once: only paths absent from the previous snapshot.
386
+ report = outOfScope.filter((p) => !seen.has(p));
387
+ } else if (typeof scopeMtimeMs === 'number' && typeof mtimeMs === 'function') {
388
+ // #938 vectors 2+3: a re-baseline is only silent about PRE-WAVE dirt.
389
+ // MED-3 (W4 panel): `>=`, not `>`. A write whose mtime lands on the SAME
390
+ // coarse-FS tick as the scope file (or the same instant) is dirt from THIS
391
+ // wave, not pre-wave — a strict `>` silently dropped it (equal-mtime
392
+ // false-negative). The deeper evasion (an actor bumping the scope file's
393
+ // mtime ABOVE its own out-of-scope write to force a silent re-baseline)
394
+ // remains a warn-only detection-integrity gap — see follow-up.
395
+ report = outOfScope.filter((p) => {
396
+ const m = mtimeMs(p);
397
+ return typeof m === 'number' && m >= scopeMtimeMs;
398
+ });
399
+ } else {
400
+ // No mtime signal (scope-file stat failed / caller supplied none): degrade
401
+ // to the conservative silent re-baseline rather than blame pre-wave dirt.
402
+ report = [];
403
+ }
404
+
405
+ // Fold everything observed into the next snapshot, so a path reported now is
406
+ // never reported again for this wave.
407
+ const nextPaths = [...new Set([...seen, ...outOfScope])].slice(-MAX_SNAPSHOT_PATHS);
408
+ return { report, nextSnapshot: { signature, paths: nextPaths }, rebaselined };
409
+ }
410
+
411
+ /**
412
+ * Render the operator/Claude-facing message, clamped on BOTH axes (path count
413
+ * and total chars) so the envelope can never approach the 64 KiB pipe buffer.
414
+ *
415
+ * @param {string[]} report
416
+ * @param {number} allowedCount
417
+ * @returns {string}
418
+ */
419
+ export function formatMessage(report, allowedCount) {
420
+ const shown = report.slice(0, MAX_REPORTED_PATHS);
421
+ const more = report.length - shown.length;
422
+ const tail = more > 0 ? ` (+${more} more)` : '';
423
+ const msg =
424
+ `bash-write-verify: ${report.length} file(s) changed by a Bash call OUTSIDE the wave's `
425
+ + `${allowedCount} allowedPaths — ${shown.join(', ')}${tail}. `
426
+ + 'Warn-only (#915): PostToolUse cannot block a command that already ran. '
427
+ + 'If this was unintended, revert it; Bash writes are NOT covered by the PreToolUse path gate.';
428
+ return msg.length > MAX_CONTEXT_CHARS ? `${msg.slice(0, MAX_CONTEXT_CHARS - 1)}…` : msg;
429
+ }
430
+
431
+ /**
432
+ * Render the control-file-change notice (#938 vector 1).
433
+ *
434
+ * Fired whenever the wave-scope.json CONTENT differs from the state recorded
435
+ * in the snapshot. The coordinator's per-wave rollover legitimately triggers
436
+ * this once per wave — that case reads as information, not alarm
437
+ * (`weakened: false`). A change that turns enforcement off, disables the
438
+ * bash-write-verify gate, or makes the file unparseable is a weakening
439
+ * (`weakened: true`): exactly the write an actor silencing the guard must
440
+ * make, so it is the one line the operator must not miss.
441
+ *
442
+ * @param {string} relScopePath repo-relative path of the scope file
443
+ * @param {{ hash: string, enforcement: string, gateOn: boolean }} prev
444
+ * @param {{ hash: string, enforcement: string, gateOn: boolean }} cur
445
+ * @returns {{ text: string, weakened: boolean }}
446
+ */
447
+ export function formatControlNotice(relScopePath, prev, cur) {
448
+ const prevGateOn = prev.gateOn !== false;
449
+ const curGateOn = cur.gateOn !== false;
450
+ const deltas = [];
451
+ if (prev.enforcement !== cur.enforcement) deltas.push(`enforcement ${prev.enforcement}→${cur.enforcement}`);
452
+ if (prevGateOn !== curGateOn) deltas.push(`gates.bash-write-verify ${prevGateOn ? 'on' : 'off'}→${curGateOn ? 'on' : 'off'}`);
453
+ const weakened = (cur.enforcement === 'off' && prev.enforcement !== 'off')
454
+ || cur.enforcement === 'unparseable'
455
+ || (prevGateOn && !curGateOn);
456
+ const what = deltas.length > 0 ? deltas.join(', ') : 'allowedPaths/content change';
457
+ const text = `bash-write-verify: control file ${relScopePath} changed since the last Bash call (${what}). `
458
+ + (weakened
459
+ ? 'This weakens or disables the bash-write guard from the NEXT call on; the current call was still evaluated under the previous scope state (#938).'
460
+ : 'Expected once per wave rollover; scope-control changes are never exempt from reporting (#938).');
461
+ return { text: text.length > MAX_CONTEXT_CHARS ? `${text.slice(0, MAX_CONTEXT_CHARS - 1)}…` : text, weakened };
462
+ }
463
+
464
+ /**
465
+ * Render the lost-snapshot notice (#938 fix 3, extended by #945), or null when
466
+ * neither clock finds the absence suspicious.
467
+ *
468
+ * Both inputs are ages in ms and both are OPTIONAL: an unknown clock
469
+ * contributes nothing rather than a guess. Two independent clocks are ORed
470
+ * because each has a different blind spot — the scope-file mtime is resettable
471
+ * by the observed actor (`touch`), and the session start time is unknown
472
+ * outside an orchestrated session. Either one alone is evadable; both together
473
+ * cost the actor two forgeries instead of one.
474
+ *
475
+ * Fail-safe direction is SILENCE. Neither clock readable ⇒ null. This hook is
476
+ * warn-only, and noise is precisely what gets a guard switched off — the #915
477
+ * bypass pressure it exists to fight.
478
+ *
479
+ * `reason` names WHICH untrusted state was observed. Both are the same finding —
480
+ * "this call had no trustworthy prior state" — and differ only in what the
481
+ * operator will find on disk when they look: nothing (`absent`), or a file whose
482
+ * contents this build did not write (`untrusted`). Keeping the substring "state
483
+ * snapshot" in both keeps one grep across the pair.
484
+ *
485
+ * @param {object} args
486
+ * @param {number|null} [args.sessionAge] ms since session start, or null
487
+ * @param {number|null} [args.scopeAge] ms since the wave-scope.json mtime, or null
488
+ * @param {'absent'|'untrusted'} [args.reason] which untrusted state was observed
489
+ * @returns {string|null}
490
+ */
491
+ export function formatSnapshotMissingNotice({ sessionAge = null, scopeAge = null, reason = 'absent' } = {}) {
492
+ const reasons = [];
493
+ if (typeof sessionAge === 'number' && Number.isFinite(sessionAge) && sessionAge > SESSION_AGE_SUSPICION_MS) {
494
+ reasons.push(`the session started ${Math.round(sessionAge / 60_000)} min ago`);
495
+ }
496
+ if (typeof scopeAge === 'number' && Number.isFinite(scopeAge) && scopeAge > SNAPSHOT_MISSING_SUSPICION_MS) {
497
+ reasons.push(`the wave scope is ${Math.round(scopeAge / 60_000)} min old`);
498
+ }
499
+ if (reasons.length === 0) return null;
500
+ const head = reason === 'untrusted'
501
+ ? 'a state snapshot exists but carries no scope state this build wrote'
502
+ : 'no state snapshot found';
503
+ return `bash-write-verify: ${head} although ${reasons.join(' and ')} — `
504
+ + 'a deleted or overwritten state file ($TMPDIR/so-bash-write-verify) cannot be ruled out; '
505
+ + 're-baselined with mtime attribution (#938/#945).';
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // I/O helpers
510
+ // ---------------------------------------------------------------------------
511
+
512
+ /**
513
+ * Collect the working-tree dirty set. Returns null when git is unavailable, the
514
+ * directory is not a repo, or the call times out — all of which mean "no signal
515
+ * to report", never a warning.
516
+ *
517
+ * @param {string} repoRoot
518
+ * @returns {string[]|null}
519
+ */
520
+ function readDirtyPaths(repoRoot) {
521
+ try {
522
+ const raw = execFileSync(
523
+ 'git',
524
+ // `--untracked-files=all` is load-bearing, not a tuning knob: the default
525
+ // `normal` COLLAPSES a wholly-untracked directory into a single `dir/`
526
+ // record, which defeats every file-level ignore pattern below (a fresh
527
+ // `.orchestrator/` arrives as `.orchestrator/`, not as
528
+ // `.orchestrator/metrics/events.jsonl`). Measured on this repo it is also
529
+ // not slower — 25.0 ms/call vs 28.7 ms for `normal` — because the
530
+ // expensive subtrees (node_modules/, coverage/) are .gitignore'd and git
531
+ // never descends into them.
532
+ ['--no-optional-locks', 'status', '--porcelain', '-z', '--no-renames', '--untracked-files=all'],
533
+ { cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'ignore'] },
534
+ );
535
+ return parsePorcelainZ(raw);
536
+ } catch {
537
+ return null;
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Age in ms of one session clock: a JSON file carrying an ISO start timestamp.
543
+ *
544
+ * Never throws. Absent / unparseable / non-string / non-ISO ⇒ null. A NEGATIVE
545
+ * age (timestamp in the future) is also null rather than a negative number —
546
+ * see `sessionAgeMs` for why that matters once two clocks are combined.
547
+ *
548
+ * @param {string} file absolute path to the JSON file
549
+ * @param {string} field name of the ISO-timestamp property
550
+ * @param {number} now
551
+ * @returns {number|null}
552
+ */
553
+ function clockAgeMs(file, field, now) {
554
+ try {
555
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
556
+ const startedAt = Date.parse(parsed?.[field]);
557
+ if (!Number.isFinite(startedAt)) return null;
558
+ const age = now - startedAt;
559
+ return age >= 0 ? age : null;
560
+ } catch {
561
+ return null;
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Milliseconds since the current session started, or null when unknown — the
567
+ * MINIMUM of two independently-written session clocks (#957 finding 2):
568
+ *
569
+ * - `.orchestrator/current-session.json` `timestamp`, written once per
570
+ * session by `hooks/on-session-start.mjs`;
571
+ * - `.orchestrator/session.lock` `started_at`, written by session-start
572
+ * Phase 1.2 via `scripts/lib/session-lock.mjs` `acquire()`.
573
+ *
574
+ * Both share the two properties that make them the right clocks here: each is
575
+ * `.gitignore`d AND on this hook's own ignore list (see IGNORED_PATH_PATTERNS),
576
+ * so reading one can never turn into reporting it; and neither is touched by
577
+ * any write to wave-scope.json, which is exactly the evasion the scope-mtime
578
+ * clock lost to (#945).
579
+ *
580
+ * ## Why the minimum, and why NOT a staleness cap
581
+ *
582
+ * A single clock reads an age of HOURS whenever its file is a leftover from a
583
+ * previous session on a harness that never rewrote it — firing the lost-snapshot
584
+ * notice on the first Bash call of a session seconds old. The obvious repair, a
585
+ * hard "ignore ages above N hours" cap, is the wrong one: it blinds the guard
586
+ * in precisely the regime it was built for (its own docblock names "a mid-session
587
+ * `rm -f` sits hours after session start"), so a legitimate long deep session
588
+ * ages into the blind spot with no attacker action at all — and it converts a
589
+ * one-sided threshold into a two-sided one, giving a forged timestamp two
590
+ * winning ranges instead of one.
591
+ *
592
+ * `Math.min` fixes the leftover-file case: a session that actually started
593
+ * acquired a FRESH `session.lock`, so the stale clock is outvoted by the young
594
+ * one ⇒ silence. After 14 h of a genuine deep session both clocks read 14 h ⇒
595
+ * the notice fires.
596
+ *
597
+ * ## What min() actually costs an attacker (#957/F2 — corrected)
598
+ *
599
+ * The notice fires only ABOVE the threshold (`sessionAge >
600
+ * SESSION_AGE_SUSPICION_MS`), so silence is bought by a YOUNG value, not an old
601
+ * one. min() therefore narrows the FIRING set (it now takes BOTH clocks reading
602
+ * old) and correspondingly WIDENS the forgeable target set: a young value in
603
+ * EITHER file suffices. The price is still one forgery — but the attacker now
604
+ * chooses which of two files to write, where before there was one. (`max()` is
605
+ * the operator that would cost two forgeries; it was not chosen, because it
606
+ * re-admits the leftover-file false positive this change exists to remove. An
607
+ * earlier draft of this docblock asserted the min() variant cost two — it does
608
+ * not.) Both files are `.gitignore`d and unwritable-by-convention rather than
609
+ * by permission, so this is a real widening, judged an acceptable trade against
610
+ * a guard that otherwise cried wolf on the first Bash call of every session.
611
+ *
612
+ * KNOWN SILENCE RANGE (non-adversarial): `acquire()` writes a fresh `started_at`
613
+ * only when no lock is present — it classifies and returns when one exists
614
+ * (`scripts/lib/session-lock.mjs`), while `forceAcquire()` overwrites
615
+ * unconditionally. A session that released and re-acquired, or took over a stale
616
+ * lock, therefore carries a YOUNG `started_at` against an OLD
617
+ * `current-session.json`; min() picks the young one and the sessionAge clock
618
+ * stays blind for the rest of that session however long it runs. Contrary to an
619
+ * earlier claim here, this change DOES create a new silence range — just not an
620
+ * adversarial one.
621
+ *
622
+ * If that range proves load-bearing, the follow-up worth considering is
623
+ * per-clock evaluation (fire when ANY clock reads old AND is corroborated)
624
+ * rather than a single combined age. That is a behaviour change, not a
625
+ * documentation one, and is deliberately NOT made here.
626
+ *
627
+ * Negative ages are dropped rather than kept (see `clockAgeMs`): without that,
628
+ * a single timestamp in the FUTURE would win the min() and silence the notice
629
+ * unboundedly — a strictly worse range than the two above.
630
+ *
631
+ * Never throws. No clock readable ⇒ null ⇒ silence.
632
+ *
633
+ * @param {string} repoRoot
634
+ * @param {number} [now]
635
+ * @returns {number|null}
636
+ */
637
+ export function sessionAgeMs(repoRoot, now = Date.now()) {
638
+ const dir = path.join(repoRoot, '.orchestrator');
639
+ const ages = [
640
+ clockAgeMs(path.join(dir, 'current-session.json'), 'timestamp', now),
641
+ clockAgeMs(path.join(dir, 'session.lock'), 'started_at', now),
642
+ ].filter((age) => age !== null);
643
+ return ages.length > 0 ? Math.min(...ages) : null;
644
+ }
645
+
646
+ /** @returns {object|null} */
647
+ function readSnapshot(file) {
648
+ try {
649
+ return JSON.parse(readFileSync(file, 'utf8'));
650
+ } catch {
651
+ return null;
652
+ }
653
+ }
654
+
655
+ /**
656
+ * The recorded scope state, but ONLY when the whole record is one this build
657
+ * wrote and can act on. Anything else ⇒ null ⇒ the caller evaluates strict.
658
+ *
659
+ * This is the single trust decision of the hook, and it is deliberately about
660
+ * CONTENT, never about the sidecar's existence. Keying it on `snapshot === null`
661
+ * (the #944 shape) closed one state of four at unchanged attacker cost — `rm -f
662
+ * <path>` became `echo '{}' > <path>`, same computable path, same Bash round —
663
+ * and left `{}` and `{"scopeState":{"hash":"…"}}` completely silent. Each clause
664
+ * below therefore names a forgery it refuses:
665
+ *
666
+ * schema !== SNAPSHOT_SCHEMA `{}` / any older or hand-written shape
667
+ * scopeState not an object `{"schema":2}`
668
+ * enforcement not a string `{"schema":2,"scopeState":{"hash":"deadbeef"}}`
669
+ * hash not a string a half-written record the control-notice path
670
+ * would otherwise compare against
671
+ *
672
+ * Residual, named rather than implied: a forgery that supplies a COMPLETE record
673
+ * (marker, hash, `enforcement: "off"`) is still believed. That costs the actor
674
+ * the one thing the cheap forgeries bought silence to avoid — writing the
675
+ * disabling value where the next call reads it as a prior state and reports the
676
+ * transition. The gap this closes is the free one.
677
+ *
678
+ * @param {object|null} snapshot
679
+ * @returns {{ hash: string, enforcement: string, gateOn?: boolean }|null}
680
+ */
681
+ function trustedScopeState(snapshot) {
682
+ if (!snapshot || typeof snapshot !== 'object') return null;
683
+ if (snapshot.schema !== SNAPSHOT_SCHEMA) return null;
684
+ const state = snapshot.scopeState;
685
+ if (!state || typeof state !== 'object') return null;
686
+ if (typeof state.enforcement !== 'string') return null;
687
+ if (typeof state.hash !== 'string') return null;
688
+ return state;
689
+ }
690
+
691
+ /**
692
+ * Atomic tmp+rename write; failure is non-fatal (worst case: a re-baseline).
693
+ *
694
+ * The schema marker is stamped HERE, not at the call sites: it is the property
695
+ * that makes a record trustworthy, so the one place that produces records is the
696
+ * one place that may claim it.
697
+ */
698
+ function writeSnapshot(file, data) {
699
+ try {
700
+ mkdirSync(path.dirname(file), { recursive: true });
701
+ const tmp = `${file}.${process.pid}.tmp`;
702
+ writeFileSync(tmp, JSON.stringify({ ...data, schema: SNAPSHOT_SCHEMA }), 'utf8');
703
+ renameSync(tmp, file);
704
+ } catch {
705
+ /* best-effort */
706
+ }
707
+ }
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // Main
711
+ // ---------------------------------------------------------------------------
712
+
713
+ /**
714
+ * Emit stderr line(s) for the operator + one PostToolUse envelope for Claude.
715
+ * `warn` selects the stderr glyph: violations and control-file weakenings
716
+ * alarm (⚠); a plain rollover/teardown notice informs (ℹ).
717
+ *
718
+ * @param {string[]} messages
719
+ * @param {boolean} warn
720
+ */
721
+ function emitMessages(messages, warn) {
722
+ const glyph = warn ? '⚠' : 'ℹ';
723
+ try {
724
+ for (const m of messages) process.stderr.write(`${glyph} ${m}\n`);
725
+ } catch {
726
+ /* stderr may be closed */
727
+ }
728
+ writeStdoutLineSync(JSON.stringify({
729
+ hookSpecificOutput: {
730
+ hookEventName: 'PostToolUse',
731
+ additionalContext: messages.join('\n'),
732
+ },
733
+ }));
734
+ }
735
+
736
+ async function main() {
737
+ const input = await readStdin();
738
+ if (!input) return;
739
+
740
+ // G2 — only Bash calls carry the bypass risk this hook watches.
741
+ if (input.tool_name !== 'Bash') return;
742
+
743
+ const repoRootRaw = resolveProjectDir();
744
+ let repoRoot;
745
+ try {
746
+ repoRoot = realpathSync(repoRootRaw);
747
+ } catch {
748
+ repoRoot = repoRootRaw;
749
+ }
750
+
751
+ const snapFile = snapshotPathFor(repoRoot);
752
+ const snapshot = readSnapshot(snapFile);
753
+ // The ONE trust decision (see `trustedScopeState`). Every downstream use of a
754
+ // "previous" value reads this binding, so no code path can accidentally act on
755
+ // a recorded value that failed the check.
756
+ const prevScopeState = trustedScopeState(snapshot);
757
+ // The rest of an untrusted record is untrusted too. `signature` + `paths` drive
758
+ // computeReport's report-once suppression, so a forged
759
+ // `{"signature":"<sig>","paths":["out-of-scope.mjs"]}` would buy silence on the
760
+ // path axis by the same one-line write — the report-once list is a claim about
761
+ // what was ALREADY reported, and this build reported nothing. Untrusted ⇒ the
762
+ // same empty base the absent-sidecar case starts from.
763
+ const prevRecord = prevScopeState === null ? null : snapshot;
764
+ const carriedRecord = prevRecord ?? { signature: null, paths: [] };
765
+
766
+ // G3 — no wave scope → nothing defines "outside". #938: if a previous call
767
+ // RECORDED a scope state, the control file's disappearance is itself a
768
+ // control-file change and gets one visible (non-alarming) notice.
769
+ const scopePath = findScopeFile(repoRoot);
770
+ if (!scopePath) {
771
+ if (prevScopeState && prevScopeState.hash !== 'absent') {
772
+ writeSnapshot(snapFile, {
773
+ ...carriedRecord,
774
+ scopeState: { hash: 'absent', enforcement: 'strict', gateOn: true },
775
+ });
776
+ emitMessages([
777
+ 'bash-write-verify: control file wave-scope.json was REMOVED since the last Bash call — '
778
+ + 'the bash-write guard is inert until a new wave scope is written. '
779
+ + 'Expected at session teardown; scope-control changes are never exempt from reporting (#938).',
780
+ ], false);
781
+ }
782
+ return;
783
+ }
784
+
785
+ let raw = null;
786
+ let scope = null;
787
+ try {
788
+ raw = readFileSync(scopePath, 'utf8');
789
+ scope = JSON.parse(raw);
790
+ } catch {
791
+ /* raw stays null (unreadable) or scope stays null (unparseable) */
792
+ }
793
+ if (raw === null) return; // file vanished between findScopeFile and read
794
+
795
+ const relScopePath = path.relative(repoRoot, scopePath) || scopePath;
796
+ const currentScopeState = {
797
+ hash: createHash('sha1').update(raw).digest('hex').slice(0, 16),
798
+ enforcement: scope
799
+ ? (typeof scope.enforcement === 'string' ? scope.enforcement : 'strict')
800
+ : 'unparseable',
801
+ gateOn: scope ? scope?.gates?.['bash-write-verify'] !== false : true,
802
+ };
803
+
804
+ let scopeMtimeMs = null;
805
+ try {
806
+ scopeMtimeMs = statSync(scopePath).mtimeMs;
807
+ } catch {
808
+ /* stat failure → computeReport degrades to the silent re-baseline */
809
+ }
810
+
811
+ // The lost-snapshot signal is computed HERE — ahead of every early return
812
+ // below (#944/#945). It used to sit at the very end of the reporting path,
813
+ // which made it worthless against the attack it was built for: `enforcement:
814
+ // "off"` returns at G5, so a single Bash call that deleted the sidecar AND
815
+ // disabled the gate skipped the suspicion line as well as the path report.
816
+ // A guard whose "I may have been tampered with" notice is itself behind the
817
+ // gate the tampering opens is not a guard.
818
+ //
819
+ // Every return point reachable from here writes a snapshot, which is what
820
+ // keeps this to ONE line: the next call sees a snapshot and the condition is
821
+ // false. Without that, a genuinely purged $TMPDIR would warn on every Bash
822
+ // call for the rest of the session.
823
+ //
824
+ // The condition is `prevScopeState === null` — no TRUSTWORTHY prior state —
825
+ // not `snapshot === null`. The two effects of that condition (evaluating the
826
+ // call strict, and saying so) must ride the SAME predicate: keyed on file
827
+ // existence, this line stayed silent for `echo '{}' > <sidecar>` while the
828
+ // path report was restored, which drops the tamper trail and leaves a green
829
+ // `toContain('out-of-scope.mjs')` test to say otherwise.
830
+ const missingSnapshotNotice = prevScopeState === null
831
+ ? formatSnapshotMissingNotice({
832
+ sessionAge: sessionAgeMs(repoRoot),
833
+ scopeAge: typeof scopeMtimeMs === 'number' ? Date.now() - scopeMtimeMs : null,
834
+ reason: snapshot === null ? 'absent' : 'untrusted',
835
+ })
836
+ : null;
837
+
838
+ // Unparseable control file: nothing to evaluate paths against, but the
839
+ // CHANGE is still reported (#938 vector 1 variant — `echo garbage >
840
+ // wave-scope.json` must not be a free total silencer).
841
+ if (!scope) {
842
+ writeSnapshot(snapFile, { ...carriedRecord, scopeState: currentScopeState });
843
+ const messages = [];
844
+ if (prevScopeState && prevScopeState.hash !== currentScopeState.hash) {
845
+ messages.push(formatControlNotice(relScopePath, prevScopeState, currentScopeState).text);
846
+ }
847
+ // `echo garbage > wave-scope.json` + `rm -f <sidecar>` in one call left
848
+ // prevScopeState null and produced total silence before #945.
849
+ if (missingSnapshotNotice) messages.push(missingSnapshotNotice);
850
+ if (messages.length > 0) emitMessages(messages, true);
851
+ return;
852
+ }
853
+
854
+ // G4+G5 — evaluated on the PRE-CALL scope state from the snapshot (#938
855
+ // vector 1); the live values only take effect from the NEXT call on. G4 is ON
856
+ // unless explicitly disabled — the contrast with `bash-write-guard`
857
+ // (=== true) stands: this detector observes the filesystem rather than
858
+ // parsing a command. G5: enforcement:off means nothing is enforced, including
859
+ // advisories.
860
+ //
861
+ // #944/#945 — NO TRUSTWORTHY PRIOR STATE is evaluated as `strict`, never from
862
+ // the live file. There is no live-value fallback left on this path at all,
863
+ // which is the whole repair: the #944 shape kept one (`snapshot !== null` but
864
+ // no usable `scopeState` ⇒ read `currentScopeState`) as an anti-noise carve-out
865
+ // for sidecars from an older build, and that carve-out was keyed on the
866
+ // ABSENCE of a field — free to reproduce with `echo '{}' > <sidecar>`. The
867
+ // carve-out's purpose survives, moved onto `SNAPSHOT_SCHEMA`: an old build's
868
+ // record fails the marker check and is judged strict for ONE call, after which
869
+ // this build's write makes it trustworthy again.
870
+ //
871
+ // Cost, unchanged from #944 and now also paid once per deploy: a repo that
872
+ // legitimately runs `enforcement: "off"` may get ONE advisory line on its
873
+ // first Bash call, then silence from call 2 on (the snapshot now records
874
+ // `off`). One line is the cheaper half of the trade against a guard that a
875
+ // single `echo` can switch off in silence.
876
+ const effEnforcement = prevScopeState ? prevScopeState.enforcement : 'strict';
877
+ const effGateOn = prevScopeState ? prevScopeState.gateOn !== false : true;
878
+ if (!effGateOn || effEnforcement === 'off') {
879
+ // Refresh only the recorded scope state so a later re-enable is honored on
880
+ // the next call; signature/paths stay untouched.
881
+ writeSnapshot(snapFile, { ...carriedRecord, scopeState: currentScopeState });
882
+ // Unreachable while the strict branch above holds (a null prevScopeState
883
+ // never returns here). Kept as the structural guarantee rather than a
884
+ // comment: if a future edit reintroduces any live-value path, the tamper
885
+ // notice still escapes ahead of the gate instead of silently going with it.
886
+ if (missingSnapshotNotice) emitMessages([missingSnapshotNotice], false);
887
+ return;
888
+ }
889
+
890
+ const controlNotice = prevScopeState
891
+ && prevScopeState.hash !== 'absent'
892
+ && prevScopeState.hash !== currentScopeState.hash
893
+ ? formatControlNotice(relScopePath, prevScopeState, currentScopeState)
894
+ : null;
895
+
896
+ const allowedPaths = Array.isArray(scope.allowedPaths) ? scope.allowedPaths : [];
897
+
898
+ const dirtyPaths = readDirtyPaths(repoRoot);
899
+ if (dirtyPaths === null) {
900
+ // git unavailable → no path signal; the control-file and lost-snapshot
901
+ // notices do not depend on git and still stand.
902
+ writeSnapshot(snapFile, { ...carriedRecord, scopeState: currentScopeState });
903
+ const messages = [];
904
+ if (controlNotice) messages.push(controlNotice.text);
905
+ if (missingSnapshotNotice) messages.push(missingSnapshotNotice);
906
+ if (messages.length > 0) emitMessages(messages, Boolean(controlNotice?.weakened));
907
+ return;
908
+ }
909
+
910
+ const signature = scopeSignature(allowedPaths);
911
+
912
+ const { report, nextSnapshot } = computeReport({
913
+ dirtyPaths,
914
+ allowedPaths,
915
+ // `prevRecord`, not `snapshot`: an untrusted record's report-once list is a
916
+ // forgeable claim about what was already reported (see the binding above).
917
+ snapshot: prevRecord,
918
+ signature,
919
+ scopeMtimeMs,
920
+ scopeRelPath: relScopePath,
921
+ mtimeMs: (rel) => {
922
+ try {
923
+ return statSync(path.join(repoRoot, rel)).mtimeMs;
924
+ } catch {
925
+ return null; // deleted path → not mtime-attributable on a re-baseline
926
+ }
927
+ },
928
+ });
929
+
930
+ writeSnapshot(snapFile, { ...nextSnapshot, scopeState: currentScopeState });
931
+
932
+ const messages = [];
933
+ let warn = false;
934
+ if (controlNotice) {
935
+ messages.push(controlNotice.text);
936
+ warn = warn || controlNotice.weakened;
937
+ }
938
+ // #938 fix 3 + #945 — a missing sidecar that neither clock can call a clean
939
+ // first run is named, not silently trusted. Computed before the G4/G5 gate
940
+ // above so the gate cannot swallow it.
941
+ if (missingSnapshotNotice) messages.push(missingSnapshotNotice);
942
+ if (report.length > 0) {
943
+ messages.push(formatMessage(report, allowedPaths.length));
944
+ warn = true;
945
+ }
946
+ if (messages.length === 0) return; // silence is the common case (0.91 % fire rate)
947
+
948
+ emitMessages(messages, warn);
949
+ }
950
+
951
+ // Self-execution guard — run only when invoked directly as a hook, never when
952
+ // imported (the test suite imports named exports; a top-level main() then runs
953
+ // on import and its .finally(process.exit(0)) trips vitest's process.exit guard,
954
+ // surfacing as an unhandled rejection that can cause false-positive tests).
955
+ //
956
+ // #938 MED-2 (W4 panel): compare the REAL path of BOTH sides. `process.argv[1]`
957
+ // carries the path as passed (symlink-bearing under a symlinked plugin install),
958
+ // while `import.meta.url` is realpath-resolved by Node's default loader — a bare
959
+ // string compare then reads false and silently no-ops the whole scope-detector
960
+ // under a symlinked `$CLAUDE_PLUGIN_ROOT`. realpath'ing both sides also survives
961
+ // `--preserve-symlinks` (where import.meta.url stays symlinked instead).
962
+ function invokedAsScript() {
963
+ const entry = process.argv[1];
964
+ if (!entry) return false;
965
+ const self = fileURLToPath(import.meta.url);
966
+ try {
967
+ return realpathSync(entry) === realpathSync(self);
968
+ } catch {
969
+ // argv[1] unresolvable (deleted/renamed mid-run) — best-effort raw compare.
970
+ return entry === self;
971
+ }
972
+ }
973
+ const isMain = invokedAsScript();
974
+ if (isMain) {
975
+ // Advisory hook: never block, never surface an error to the tool call.
976
+ main().catch(() => {}).finally(() => process.exit(0));
977
+ }