session-orchestrator 3.17.0 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,1054 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pre-bash-sessions-ledger-guard.mjs — PreToolUse Bash hook: blocks a DIRECT
4
+ * shell write into the sessions ledger (`.orchestrator/metrics/sessions.jsonl`).
5
+ *
6
+ * ## Why this exists (GitLab #958, finding 3)
7
+ *
8
+ * W1/D4 proved the root cause of the malformed ledger record: it was composed
9
+ * by the coordinator from a Markdown template and appended straight to
10
+ * `sessions.jsonl`, bypassing `scripts/emit-session.mjs` — the validating
11
+ * writer that would have refused it. Piping that record into emit-session.mjs
12
+ * exits 1 and leaves the file untouched, so it cannot have taken the sanctioned
13
+ * path.
14
+ *
15
+ * `skills/session-end/session-metrics-write.md` already says, in prose:
16
+ * "Hand-composing JSON and appending it directly to `sessions.jsonl` is
17
+ * forbidden." A prose prohibition did not stop it. This hook is the mechanism.
18
+ *
19
+ * ## What is ALLOWED, and why it needs no name allowlist
20
+ *
21
+ * The guard fires on WRITE INTENT (`>`/`>>`, `tee`, `dd of=`, `cp`/`mv`
22
+ * destination), not on command names. The three sanctioned writers —
23
+ * `scripts/emit-session.mjs`, `scripts/backfill-sessions.mjs`,
24
+ * `scripts/migrate-sessions-jsonl.mjs`, `scripts/backfill-abandoned-sessions.mjs`
25
+ * — all open the file from inside Node (append / atomic tmp+rename); none of
26
+ * them is invoked through a shell redirect into the ledger. They are therefore
27
+ * allowed STRUCTURALLY: their command strings contain no ledger write-intent
28
+ * for the matcher to see.
29
+ *
30
+ * That is a deliberate design choice over a script-name allowlist. A name
31
+ * allowlist in a shell string is trivially spoofable — the name can sit in a
32
+ * comment (`echo '…' >> ledger # emit-session.mjs`), in an unrelated argument
33
+ * (`echo emit-session.mjs >> ledger`), or inside the quoted payload being
34
+ * appended. "Does this command redirect bytes into the ledger" is a property of
35
+ * the command's STRUCTURE that no amount of name-dropping can fake, and that
36
+ * the sanctioned writers structurally never have. There is nothing to spoof
37
+ * because there is no name being matched.
38
+ *
39
+ * The one true positive this costs is `node scripts/emit-session.mjs … >> …
40
+ * sessions.jsonl` — piping the writer's `{"action":"appended"}` receipt INTO
41
+ * the ledger. That is a bug, and denying it is correct.
42
+ *
43
+ * ## Path scope
44
+ *
45
+ * A write target counts as the ledger when its BASENAME is `sessions.jsonl`,
46
+ * in any directory — including a tmp-repo fixture. Not "every .jsonl": exactly
47
+ * the ledger's own filename. Matching the basename rather than the full
48
+ * `.orchestrator/metrics/sessions.jsonl` suffix closes the
49
+ * `cd .orchestrator/metrics && echo … >> sessions.jsonl` hole. Tmp ledgers are
50
+ * guarded too, at no cost: test fixtures write them from Node (`writeFileSync`),
51
+ * which this Bash-only hook never sees.
52
+ *
53
+ * ## How the command is read (GitLab #958 follow-up, security review 2×MED)
54
+ *
55
+ * The first cut used a redirect REGEX over the raw string plus a per-character
56
+ * "is this index quoted?" mask. Both halves leaked, and both leaks were
57
+ * demonstrated to write the ledger:
58
+ *
59
+ * - MED-1 — the mask modelled only `'…'` and `"…"`. One apostrophe from a
60
+ * shell COMMENT (`# don't forget the record`), a HERE-DOC body (`it's
61
+ * fine`) or an ANSI-C literal (`$'a\'b'`) left it stuck in "single" for the
62
+ * rest of the command, and the real `>>` after it was skipped. English
63
+ * prose is full of apostrophes, so `# don't …` is the EXPECTED shape of the
64
+ * accident this guard exists to catch, not an adversarial construction.
65
+ * - MED-2 — the regex captured ONE quoting run, so a target assembled from
66
+ * several (`"$PWD"/…/sessions.jsonl`, `.orchestrator/metrics/"sessions.jsonl"`,
67
+ * `My\ Repo/…/sessions.jsonl`, `"."/…/sessions.jsonl`) was captured wrong
68
+ * and its tail never examined. Two of those four contain no variable at all.
69
+ *
70
+ * Both classes are STATICALLY resolvable, so "a PreToolUse hook cannot know the
71
+ * target" never covered them. The regex + mask are gone. {@link scanCommand} is
72
+ * a single quote-aware pass that understands comments, here-doc bodies,
73
+ * `$'…'` / `$"…"`, backslash escapes, fd duplication (`2>&1` contributes no
74
+ * target) and multi-run words — it returns the RESOLVED redirect targets, so
75
+ * all four MED-2 forms reduce to the same basename. The same pass emits a
76
+ * sanitized command (comments and here-doc bodies removed, `$'…'` folded to a
77
+ * plain literal, redirect operators AND their targets elided) for the
78
+ * `tee`/`dd`/`cp`/`mv` matcher.
79
+ *
80
+ * ## Why this scanner is NOT redundant with `tokenizeCommand` (#965 follow-up)
81
+ *
82
+ * #965 taught the shared lexer comments, here-doc bodies and `$'…'`, which
83
+ * removes the ORIGINAL reason this pass existed. It is kept anyway, because two
84
+ * things it produces are structurally absent from `tokenizeCommand` — measured,
85
+ * not assumed:
86
+ *
87
+ * 1. **The `balanced` flag.** `tokenizeCommand('echo "x')` returns tokens
88
+ * shaped `{text, quoted}` — there is no third field, and its unterminated-
89
+ * quote path deliberately fails OPEN (flush + mark quoted) so a wedged
90
+ * lexer cannot block every Bash call. That is right for a lexer serving
91
+ * nine rules; it is wrong here, where `findLedgerWrite` must fail CLOSED.
92
+ * The signal is destroyed inside the lexer, so no consumer can recover it.
93
+ * 2. **Redirect-target elision.** `tokenizeCommand` emits the redirect TARGET
94
+ * as an ordinary token by design (its docblock: dropping it "would have
95
+ * silently changed rm-allowlist verdicts"). Feeding the raw command to the
96
+ * `cp`/`mv` branch therefore picks the wrong destination:
97
+ * `cp foo …/sessions.jsonl > /dev/null` resolves dest=`/dev/null` and
98
+ * ALLOWS, where the sanitized form resolves dest=`…/sessions.jsonl` and
99
+ * denies. Deleting the sanitizer re-opens a proven ledger write path.
100
+ *
101
+ * ## Wrapper + segment parsing is the LIB's, not a local copy (#991)
102
+ *
103
+ * This module used to carry three near-twins of shared machinery: a local
104
+ * `splitSegments`, a local `resolveVerb`, and a FLAGBLIND `VERB_PREFIXES` set.
105
+ * The copies drifted, and the drift was fail-OPEN: `VERB_PREFIXES` skipped only
106
+ * the wrapper WORD and never its arguments, so `sudo -u root tee -a <ledger>`
107
+ * resolved to the verb `-u`, `tee` was never in verb position, and the write
108
+ * was ALLOWED. All fourteen wrapper spellings in the test table were measured
109
+ * ALLOW before this change — the guard reported a safety it did not provide.
110
+ *
111
+ * They are gone. `splitChainSegments` and `resolveSegmentVerb` now come from
112
+ * `scripts/lib/command-blocker.mjs` — the same primitives
113
+ * hooks/pre-bash-destructive-guard.mjs consumes, imported DIRECTLY from the
114
+ * source module for the reason stated there: the `hardening.mjs` barrel
115
+ * deliberately does not re-export the #982/#983 primitives, and one import edge
116
+ * keeps this hook's dependency graph unambiguous. The deleted `VERB_PREFIXES`
117
+ * was a strict SUBSET of the lib's `WRAPPER_UNWRAP` table (6 ⊂ 9), so the
118
+ * switch ADDS `doas`/`timeout`/`stdbuf` plus flag-awareness and removes nothing.
119
+ *
120
+ * `resolveSegmentVerb` additionally reports `wrapperArgs` — the value-taking
121
+ * wrapper flags consumed on the way to the verb. One of those operands is
122
+ * itself a WRITE: `/usr/bin/time -o <file>` TRUNCATES `<file>` (BSD time(1):
123
+ * "If file exists and the -a flag is not specified, the file will be
124
+ * overwritten"), while the verb is whatever `time` goes on to run. The target
125
+ * is therefore invisible in both `verb` and `args`, which is why
126
+ * `/usr/bin/time -o <ledger> npm test` was allowed and now denies.
127
+ * `resolveSegmentVerb` itself supplies the file-vs-option semantics: each
128
+ * write-destination operand is flagged `writesFile: true` from its per-wrapper
129
+ * `fileArgFlags` table (#996.1), so this guard keys on that flag directly and no
130
+ * longer keeps a local `<wrapper>:<flag>` pair list that had to track the lexer's
131
+ * grammar by hand. The bash
132
+ * KEYWORD `time` is unaffected: it rejects the flag outright
133
+ * (`bash -c 'time -o x echo hi'` → "-o: command not found"), so only
134
+ * `/usr/bin/time`, `command time` and `env time` can carry it at all.
135
+ *
136
+ * Fail-CLOSED on an unbalanced quote: if the scan ends mid-quote the command is
137
+ * one bash itself would reject with a syntax error, so denying it when it
138
+ * mentions the ledger costs ~0 in false positives and removes "leave the lexer
139
+ * confused" as a bypass strategy. This is the one place the guard is closed
140
+ * rather than open, and it is closed because the alternative is a silent skip.
141
+ *
142
+ * ## What this does NOT catch (stated plainly — the bounds are the contract)
143
+ *
144
+ * - Indirection through a variable: `>> "$LEDGER"`, `tee "$LEDGER"`,
145
+ * `exec 3>"$LEDGER"; echo x >&3`. A PreToolUse hook sees the unexpanded
146
+ * string; the target is genuinely unknowable here.
147
+ * - A target whose BASENAME only exists after expansion:
148
+ * `>> $(echo sessions.jsonl)`, `>> "$dir/$name"`, a glob or brace form.
149
+ * What matters is whether the literal `sessions.jsonl` survives in the
150
+ * command string — the DIRECTORY half may expand freely and is still
151
+ * denied (`"$PWD"/…/sessions.jsonl`, `$HOME/…/sessions.jsonl`,
152
+ * `"$(pwd)/…/sessions.jsonl"` and `exec 3> …/sessions.jsonl` all deny;
153
+ * verified, not assumed).
154
+ * - A redirect nested inside a DOUBLE-QUOTED command substitution or a
155
+ * backtick run: `echo "$(echo x >> …/sessions.jsonl)"` allows, while the
156
+ * unquoted `echo $(echo x >> …/sessions.jsonl)` denies. The scanner
157
+ * consumes a quoted run whole and does not recurse into it. Verified, and
158
+ * left uncaught deliberately: recursing costs lexer surface, and a redirect
159
+ * buried in a quoted substitution is not the accident shape this guard is
160
+ * for — it is the "determined circumvention" line below.
161
+ * - In-place editors: `sed -i`, `perl -i`, `ed`, an interactive editor.
162
+ * - A write performed inside an interpreter: `node -e`, `python -c`,
163
+ * `bash -c '… >> …/sessions.jsonl'`, or any script the command invokes that
164
+ * appends the ledger itself. The one exception is a WRAPPER payload —
165
+ * `env -S 'tee -a …/sessions.jsonl'` — which `resolveSegmentVerb` reports
166
+ * as a payload and which this matcher re-enters (to MAX_PAYLOAD_DEPTH).
167
+ * Interpreter `-c` payloads are a larger surface and stay out of scope.
168
+ * - Obfuscation: `eval`, `base64 -d | sh`, a here-doc-fed shell. Here-doc
169
+ * BODIES are skipped as the data they are — `bash <<EOF … EOF` therefore
170
+ * hides its payload from this matcher by construction.
171
+ * - `sponge`, `install`, `rsync`, `awk > file` and other less common write
172
+ * verbs (a redirect inside an `awk` program string is quoted data here).
173
+ * - The Write/Edit tools — a different PreToolUse matcher entirely
174
+ * (`hooks/enforce-scope.mjs` territory), not this hook's surface.
175
+ *
176
+ * This is a guard against the accident that actually happened, not a
177
+ * containment boundary. Determined circumvention is out of scope by design.
178
+ *
179
+ * ## Fail-open on internal error
180
+ *
181
+ * A crash allows the command (exit 0, empty stdout) and warns on stderr,
182
+ * matching every sibling Bash guard in this repo. This is a nudge on a
183
+ * developer machine, not a security boundary: a wedged guard that blocks every
184
+ * Bash call is strictly worse than a missed enforcement, and the visibility
185
+ * half of #958 (the session-start ledger-integrity banner) catches what slips
186
+ * through. Under the #906 exit-0 protocol, "fail-open" is literally exit 0 with
187
+ * no stdout envelope — the harness reads no decision and proceeds.
188
+ *
189
+ * A LOAD failure is a distinct class since #993: the repo dependencies (`io.mjs`,
190
+ * `command-blocker.mjs`) are bound LATE (dynamic `import()` in `bootstrap()`), so
191
+ * a link-time SyntaxError in either becomes a catchable runtime error that
192
+ * banners GUARD INACTIVE on stderr instead of the pre-#993 exit-1 / 0-byte crash
193
+ * that disarmed the guard invisibly under the exit-0 protocol. The
194
+ * `command-blocker` half additionally recovers its COMMITTED source via
195
+ * `git show HEAD:` (banner: DEGRADED, guard still armed against HEAD) — the whole
196
+ * mechanism lives in `_lib/guard-source-loader.mjs` (`armGuard`).
197
+ *
198
+ * ## Override
199
+ *
200
+ * SO_DISABLED_HOOKS=pre-bash-sessions-ledger-guard (session-level)
201
+ * SO_HOOK_PROFILE=minimal|off
202
+ *
203
+ * Deliberately session-level and not in-command: an in-command escape hatch
204
+ * would be one more string for a hurried agent to paste, i.e. the discipline
205
+ * failure this hook exists to replace.
206
+ */
207
+
208
+ import { spawnSync } from 'node:child_process';
209
+ import path from 'node:path';
210
+ import { pathToFileURL } from 'node:url';
211
+
212
+ import { shouldRunHook } from './_lib/profile-gate.mjs';
213
+ // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
214
+ if (!shouldRunHook('pre-bash-sessions-ledger-guard')) process.exit(0);
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // #993 — late-bound repo dependencies
218
+ //
219
+ // `io.mjs` (readStdin/emitAllow/emitDeny) and `command-blocker.mjs`
220
+ // (tokenizeCommand/resolveSegmentVerb/splitChainSegments) used to be STATIC
221
+ // imports. A SyntaxError in either failed at ESM LINK time, before the first
222
+ // statement here ran: node exited 1 with 0 bytes on stdout, and the
223
+ // `main().catch(...)` handler at the bottom of this file was structurally
224
+ // unreachable. Under the exit-0 PreToolUse protocol (#906) that crash is, on the
225
+ // only decision-bearing channel, INDISTINGUISHABLE from an explicit
226
+ // `emitAllow()` — the guard failed open and silently.
227
+ //
228
+ // This hook is fail-open BY DESIGN (a nudge, not a boundary — see the module
229
+ // docblock), so a silent disarm is a smaller loss here than in the
230
+ // destructive-guard. It is still a loss: the #958 corruption class stops being
231
+ // caught with no sign it stopped. Binding these late (dynamic `import()` inside
232
+ // `bootstrap()`) turns the link-time crash into a catchable runtime error, which
233
+ // is what makes the GUARD INACTIVE banner in `_lib/guard-source-loader.mjs`
234
+ // reachable at all.
235
+ //
236
+ // `command-blocker.mjs` is held as a NAMESPACE object (`blocker.*`) rather than
237
+ // three destructured bindings on purpose: the required-export list then exists in
238
+ // exactly one place — the `requires` array on the `blocker` spec passed to
239
+ // `armGuard` — which validates BOTH the working-tree copy and the HEAD copy
240
+ // against it, so a partial namespace banners GUARD INACTIVE instead of arming a
241
+ // guard that fails open per command.
242
+ //
243
+ // `profile-gate.mjs` stays static on purpose — it has ZERO imports of its own and
244
+ // gates whether this hook runs at all.
245
+ // ---------------------------------------------------------------------------
246
+ /** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin;
247
+ /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
248
+ /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
249
+ /** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
250
+ /**
251
+ * The whole `command-blocker.mjs` namespace (#991: one direct import path, not
252
+ * via the hardening.mjs barrel, which deliberately does NOT re-export the
253
+ * #982/#983 primitives). Held as ONE object rather than destructured so the
254
+ * required-export set lives only on the `blocker` spec's `requires` array (#993).
255
+ *
256
+ * @type {Record<string, Function>|null}
257
+ */
258
+ let blocker = null;
259
+
260
+ /**
261
+ * Module labels `armGuard` recovered from HEAD because the working-tree copy
262
+ * failed (parse error OR shape check). Non-empty ⇒ this hook is analysing
263
+ * commands with the COMMITTED lexer. Surfaced on the visible stdout channel by
264
+ * {@link flushNotices} (#1001) — the stderr DEGRADED banner alone is discarded
265
+ * under the exit-0 protocol, which made a degraded ALLOW indistinguishable from
266
+ * a healthy one.
267
+ *
268
+ * @type {string[]}
269
+ */
270
+ let degradedLabels = [];
271
+
272
+ const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
273
+
274
+ /** This hook's name — threaded into the guard banners (#993: no hard-wired literal). */
275
+ const HOOK_NAME = 'pre-bash-sessions-ledger-guard';
276
+
277
+ /**
278
+ * Consequence prose spliced VERBATIM into the DEGRADED and GUARD INACTIVE banners
279
+ * (#993). This hook is fail-open by design, so the `inactive` text says so plainly
280
+ * rather than borrowing the destructive-guard's "do not route around it" framing.
281
+ */
282
+ const GUARD_CONSEQUENCE = {
283
+ degraded: [
284
+ ' Consequence: ledger-write enforcement IS still armed, but it is evaluating the',
285
+ ' COMMITTED (HEAD) command lexer — any uncommitted change to that file is NOT in effect.',
286
+ ],
287
+ inactive: [
288
+ ' Consequence: a direct shell write into .orchestrator/metrics/sessions.jsonl',
289
+ ' (>, >>, tee, dd of=, cp/mv destination) is NOT being blocked. This hook is a',
290
+ ' fail-open nudge, not a security boundary — but repair it so the #958 corruption',
291
+ ' class stays caught.',
292
+ ],
293
+ };
294
+
295
+ /**
296
+ * Project dir for banner keying, resolved WITHOUT any repo module — those are the
297
+ * ones that may have failed to load.
298
+ *
299
+ * @returns {string}
300
+ */
301
+ function bannerProjectDir() {
302
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
303
+ }
304
+
305
+ /**
306
+ * Bind every repo dependency. Throws on any load failure; the caller banners.
307
+ *
308
+ * `io` gets NO HEAD fallback (a missing export surfaces as a plain TypeError at
309
+ * its single call site — no half-armed guard to protect against). `blocker` opts
310
+ * into the `git show HEAD:` recovery (it is dependency-free — its only import is
311
+ * `node:path`) and carries the COMPLETE required-export set, so a partial
312
+ * namespace banners GUARD INACTIVE rather than arming a guard that fails open per
313
+ * command.
314
+ *
315
+ * @returns {Promise<void>}
316
+ */
317
+ async function bootstrap() {
318
+ const lib = (...seg) => pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', ...seg)).href;
319
+
320
+ const { armGuard } = await import('./_lib/guard-source-loader.mjs');
321
+ const { modules, degraded } = await armGuard(
322
+ {
323
+ io: { specifier: lib('io.mjs') },
324
+ blocker: {
325
+ specifier: lib('command-blocker.mjs'),
326
+ headFallback: true,
327
+ requires: ['tokenizeCommand', 'resolveSegmentVerb', 'splitChainSegments'],
328
+ },
329
+ },
330
+ {
331
+ hookName: HOOK_NAME,
332
+ repoRoot: PLUGIN_ROOT,
333
+ projectDir: bannerProjectDir(),
334
+ consequence: GUARD_CONSEQUENCE,
335
+ }
336
+ );
337
+
338
+ ({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
339
+ blocker = modules.blocker;
340
+ degradedLabels = degraded;
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Constants
345
+ // ---------------------------------------------------------------------------
346
+
347
+ /** The ledger's filename. A write target matches on basename equality. */
348
+ const LEDGER_BASENAME = 'sessions.jsonl';
349
+
350
+ /** The repair CLI whose effective apply mode mutates the sessions ledger internally. */
351
+ const REPAIR_SCRIPT_BASENAME = 'repair-invalid-sessions.mjs';
352
+ const REPAIR_APPLY_MARKER = `${REPAIR_SCRIPT_BASENAME} --apply`;
353
+
354
+ /**
355
+ * How much of the offending target may appear in the deny reason.
356
+ *
357
+ * Load-bearing, not cosmetic. `emitDeny` clamps the whole reason to
358
+ * DENY_REASON_MAX (#906) — so an unbounded target on the FIRST line pushes the
359
+ * `emit-session.mjs` route, the override and the issue reference off the end,
360
+ * and the operator gets a wall of characters with no instruction. Bounding the
361
+ * target keeps the reason short enough that the actionable half always survives.
362
+ */
363
+ const TARGET_ECHO_MAX = 200;
364
+
365
+ /** Verbs whose LAST non-flag argument is a write destination. */
366
+ const DEST_LAST_VERBS = new Set(['cp', 'mv']);
367
+
368
+ /**
369
+ * How deep to follow a wrapper's command-string payload (`env -S '…'`).
370
+ *
371
+ * Payloads are the only recursion source here, and two levels covers every
372
+ * shape a hurried agent produces while keeping the work bounded — a cap that
373
+ * cannot be exhausted into a bypass because the OUTER command is still matched
374
+ * on its own terms.
375
+ */
376
+ const MAX_PAYLOAD_DEPTH = 2;
377
+
378
+ /** Unquoted characters that end a shell WORD. */
379
+ const WORD_END = new Set([';', '|', '&', '<', '>', '(', ')', '\n']);
380
+
381
+ /** `>&1`, `>&2`, `>&-`, `>&3-` duplicate a descriptor — no file target. */
382
+ const FD_DUP_RE = /^(?:\d+-?|-)$/;
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Helpers
386
+ // ---------------------------------------------------------------------------
387
+
388
+ /**
389
+ * Does this path string name the sessions ledger?
390
+ *
391
+ * Basename equality, so `.orchestrator/metrics/sessions.jsonl`,
392
+ * `/tmp/x/.orchestrator/metrics/sessions.jsonl` and a bare `sessions.jsonl`
393
+ * (after a `cd`) all match, while `sessions.jsonl.bak`, `learnings.jsonl` and
394
+ * `events.jsonl` do not.
395
+ *
396
+ * @param {string} target
397
+ * @returns {boolean}
398
+ */
399
+ function refersToLedger(target) {
400
+ if (typeof target !== 'string' || target.length === 0) return false;
401
+ // Normalise Windows-style separators before taking the basename so a
402
+ // backslash-spelled path is not read as one long filename.
403
+ const normalized = target.replace(/\\/g, '/');
404
+ return path.posix.basename(normalized) === LEDGER_BASENAME;
405
+ }
406
+
407
+ /**
408
+ * Does this token name the repair CLI by its immediate script operand basename?
409
+ *
410
+ * @param {string} target
411
+ * @returns {boolean}
412
+ */
413
+ function refersToRepairScript(target) {
414
+ if (typeof target !== 'string' || target.length === 0) return false;
415
+ const normalized = target.replace(/\\/g, '/');
416
+ return path.posix.basename(normalized) === REPAIR_SCRIPT_BASENAME;
417
+ }
418
+
419
+ /**
420
+ * Node's own `--help` output is the source of truth for the runtime-option
421
+ * grammar. A hand-maintained option set is necessarily stale as Node adds
422
+ * runtime flags (for example, Node 24's `--experimental-worker-inspection`).
423
+ *
424
+ * The parser only records option names and whether the help specification says
425
+ * the option takes a value. Optional inline values such as `--inspect[=...]`
426
+ * deliberately do not consume the next token: Node treats a separated token as
427
+ * the script in that form. Aliases inherit the value-taking shape of the whole
428
+ * help row (`--loader, --experimental-loader=...`).
429
+ *
430
+ * @returns {{ available: true, grammar: Map<string, { takesNextValue: boolean }> } |
431
+ * { available: false, reason: string }}
432
+ */
433
+ function loadNodeOptionGrammar() {
434
+ const result = spawnSync(process.execPath, ['--help'], {
435
+ encoding: 'utf8',
436
+ maxBuffer: 2 * 1024 * 1024,
437
+ });
438
+ if (result.error || result.status !== 0 || typeof result.stdout !== 'string') {
439
+ return { available: false, reason: 'spawn-failed' };
440
+ }
441
+ if (!/^Usage: node \[options\]/m.test(result.stdout)) {
442
+ return { available: false, reason: 'malformed-help' };
443
+ }
444
+
445
+ const grammar = new Map();
446
+ for (const line of result.stdout.split('\n')) {
447
+ const trimmed = line.trim();
448
+ if (!trimmed.startsWith('-')) continue;
449
+
450
+ const specification = trimmed.split(/\s{2,}/, 1)[0];
451
+ const aliases = specification.split(',').map((alias) => alias.trim());
452
+ const names = aliases
453
+ .map((alias) => /^(-{1,2}[A-Za-z0-9][A-Za-z0-9-]*)/.exec(alias)?.[1])
454
+ .filter((name) => typeof name === 'string');
455
+ if (names.length === 0) continue;
456
+
457
+ const takesNextValue = aliases.some((alias) => {
458
+ const equals = alias.indexOf('=');
459
+ const optionalValue = alias.indexOf('[');
460
+ return equals >= 0 && (optionalValue < 0 || equals < optionalValue);
461
+ });
462
+ for (const name of names) grammar.set(name, { takesNextValue });
463
+ }
464
+
465
+ // These stable Node help entries make a truncated or otherwise incomplete
466
+ // output unavailable rather than an empty grammar that silently allows.
467
+ if (!grammar.has('--version') || !grammar.has('--eval')) {
468
+ return { available: false, reason: 'malformed-help' };
469
+ }
470
+ return { available: true, grammar };
471
+ }
472
+
473
+ /** Parsed lazily so ordinary non-Node Bash commands do not spawn a child process. */
474
+ let nodeOptionGrammar;
475
+
476
+ /**
477
+ * Return Node's runtime-option grammar, loading it only when a Node command
478
+ * actually presents an option token for resolution.
479
+ *
480
+ * @returns {{ available: true, grammar: Map<string, { takesNextValue: boolean }> } |
481
+ * { available: false, reason: string }}
482
+ */
483
+ function getNodeOptionGrammar() {
484
+ if (!nodeOptionGrammar) nodeOptionGrammar = loadNodeOptionGrammar();
485
+ return nodeOptionGrammar;
486
+ }
487
+
488
+ /** Node modes in which a later token is not an executing script operand. */
489
+ const NODE_EVAL_OPTIONS = new Set(['-e', '--eval', '-p', '--print']);
490
+ const NODE_CHECK_OPTIONS = new Set(['-c', '--check']);
491
+
492
+ /**
493
+ * Return the option name without an inline `=value` suffix.
494
+ *
495
+ * @param {string} text
496
+ * @returns {string}
497
+ */
498
+ function nodeOptionName(text) {
499
+ const equals = text.indexOf('=');
500
+ return equals > 0 ? text.slice(0, equals) : text;
501
+ }
502
+
503
+ /**
504
+ * Resolve Node's actual script operand from tokens immediately following `node`.
505
+ *
506
+ * Values consumed by the runtime's own help-derived options are never candidates
507
+ * for the script. Eval/print/check modes have no executing script operand for
508
+ * this guard's contract, so their following tokens cannot activate the repair
509
+ * mutation rule. Unknown options stop resolution rather than causing a later
510
+ * argument to be searched.
511
+ *
512
+ * @param {Array<{ text: string, quoted: boolean }>} args
513
+ * @returns {{ scriptIndex: number }|{ unavailable: true, reason: string }|null}
514
+ */
515
+ function resolveNodeScriptOperand(args) {
516
+ for (let i = 0; i < args.length;) {
517
+ const text = args[i].text;
518
+ const name = nodeOptionName(text);
519
+
520
+ if (text === '--') return i + 1 < args.length ? { scriptIndex: i + 1 } : null;
521
+
522
+ if (NODE_EVAL_OPTIONS.has(name)) return null;
523
+ if ((text.startsWith('-e') || text.startsWith('-p')) && !text.startsWith('--') && text.length > 2) {
524
+ return null;
525
+ }
526
+ if (NODE_CHECK_OPTIONS.has(name)) return null;
527
+
528
+ if (!text.startsWith('-')) return { scriptIndex: i };
529
+
530
+ const grammarState = getNodeOptionGrammar();
531
+ if (!grammarState.available) return { unavailable: true, reason: grammarState.reason };
532
+
533
+ const option = grammarState.grammar.get(name);
534
+ if (option?.takesNextValue) {
535
+ i += text.includes('=') ? 1 : 2;
536
+ continue;
537
+ }
538
+ if (option) {
539
+ i++;
540
+ continue;
541
+ }
542
+
543
+ return null;
544
+ }
545
+ return null;
546
+ }
547
+
548
+ /**
549
+ * Skip a quoted run that OPENS at `open`.
550
+ *
551
+ * @param {string} command
552
+ * @param {number} open - index of the opening quote character
553
+ * @param {boolean} escapes - true when `\` escapes the next char (double / ANSI-C)
554
+ * @returns {number} index just past the closing quote, or -1 when never closed
555
+ */
556
+ function skipQuotedRun(command, open, escapes) {
557
+ const quote = command[open];
558
+ for (let i = open + 1; i < command.length; i++) {
559
+ const ch = command[i];
560
+ if (escapes && ch === '\\' && i + 1 < command.length) { i++; continue; }
561
+ if (ch === quote) return i + 1;
562
+ }
563
+ return -1;
564
+ }
565
+
566
+ /**
567
+ * Read ONE shell word starting at `i`, resolving quoting and escapes to the
568
+ * logical value bash would pass as an argument.
569
+ *
570
+ * This is the MED-2 fix: a word is a sequence of runs, not a single one, so
571
+ * `"$PWD"/.orchestrator/metrics/sessions.jsonl`,
572
+ * `.orchestrator/metrics/"sessions.jsonl"`, `My\ Repo/…/sessions.jsonl` and
573
+ * `"."/…/sessions.jsonl` all resolve to a value whose basename is the ledger.
574
+ *
575
+ * @param {string} command
576
+ * @param {number} i
577
+ * @returns {{ value: string, end: number, balanced: boolean }}
578
+ */
579
+ function readWord(command, i) {
580
+ let value = '';
581
+ let state = 'normal';
582
+ while (i < command.length) {
583
+ const ch = command[i];
584
+
585
+ if (state === 'single') {
586
+ if (ch === "'") { state = 'normal'; i++; continue; }
587
+ value += ch; i++; continue;
588
+ }
589
+ if (state === 'double' || state === 'ansi') {
590
+ if (ch === (state === 'double' ? '"' : "'")) { state = 'normal'; i++; continue; }
591
+ if (ch === '\\' && i + 1 < command.length) { value += command[i + 1]; i += 2; continue; }
592
+ value += ch; i++; continue;
593
+ }
594
+
595
+ if (/\s/.test(ch) || WORD_END.has(ch)) break;
596
+ if (ch === '\\' && i + 1 < command.length) { value += command[i + 1]; i += 2; continue; }
597
+ if (ch === "'") { state = 'single'; i++; continue; }
598
+ if (ch === '"') { state = 'double'; i++; continue; }
599
+ if (ch === '$' && command[i + 1] === "'") { state = 'ansi'; i += 2; continue; }
600
+ if (ch === '$' && command[i + 1] === '"') { state = 'double'; i += 2; continue; }
601
+ value += ch; i++;
602
+ }
603
+ return { value, end: i, balanced: state === 'normal' };
604
+ }
605
+
606
+ /**
607
+ * Skip a here-doc body starting at `from`, returning the index just past the
608
+ * terminator line (or the end of input when the terminator never arrives).
609
+ *
610
+ * @param {string} command
611
+ * @param {number} from
612
+ * @param {string} delim
613
+ * @param {boolean} stripTabs - `<<-` form: leading tabs on the terminator ignored
614
+ * @returns {number}
615
+ */
616
+ function skipHeredocBody(command, from, delim, stripTabs) {
617
+ let i = from;
618
+ while (i < command.length) {
619
+ let lineEnd = command.indexOf('\n', i);
620
+ if (lineEnd === -1) lineEnd = command.length;
621
+ const raw = command.slice(i, lineEnd);
622
+ const line = stripTabs ? raw.replace(/^\t+/, '') : raw;
623
+ i = lineEnd + 1;
624
+ if (line === delim) return Math.min(i, command.length);
625
+ }
626
+ return command.length;
627
+ }
628
+
629
+ /**
630
+ * One quote-aware pass over the command.
631
+ *
632
+ * Returns every RESOLVED redirect target, plus a sanitized command for the
633
+ * write-verb matcher: comments and here-doc bodies removed, `$'…'` folded to a
634
+ * plain single-quoted literal, redirect operators and their targets elided.
635
+ * `balanced` is false when the scan ended inside an unterminated quote — a
636
+ * command bash would reject, and the fail-closed trigger (see docblock).
637
+ *
638
+ * @param {string} command
639
+ * @returns {{ targets: string[], sanitized: string, balanced: boolean }}
640
+ */
641
+ function scanCommand(command) {
642
+ const targets = [];
643
+ const heredocs = [];
644
+ let out = '';
645
+ let i = 0;
646
+
647
+ const atWordStart = (idx) => idx === 0 || /[\s;|&()<>]/.test(command[idx - 1]);
648
+
649
+ while (i < command.length) {
650
+ const ch = command[i];
651
+
652
+ // A `#` in word position comments out the rest of the LINE. Without this,
653
+ // one apostrophe in English prose desynced the whole scan (MED-1).
654
+ if (ch === '#' && atWordStart(i)) {
655
+ while (i < command.length && command[i] !== '\n') i++;
656
+ continue;
657
+ }
658
+
659
+ if (ch === '\n') {
660
+ out += '\n';
661
+ i++;
662
+ while (heredocs.length) {
663
+ const { delim, stripTabs } = heredocs.shift();
664
+ i = skipHeredocBody(command, i, delim, stripTabs);
665
+ }
666
+ continue;
667
+ }
668
+
669
+ if (ch === '\\' && i + 1 < command.length) { out += ch + command[i + 1]; i += 2; continue; }
670
+
671
+ // ANSI-C `$'…'`: a backslash escapes the closing quote, which the old mask
672
+ // did not know (MED-1). Folded to a plain literal for the write-verb lexer.
673
+ if (ch === '$' && command[i + 1] === "'") {
674
+ const end = skipQuotedRun(command, i + 1, true);
675
+ if (end === -1) return { targets, sanitized: out, balanced: false };
676
+ const literal = command.slice(i + 2, end - 1).replace(/\\(.)/g, '$1').replace(/'/g, '');
677
+ out += `'${literal}'`;
678
+ i = end;
679
+ continue;
680
+ }
681
+ if (ch === '$' && command[i + 1] === '"') {
682
+ const end = skipQuotedRun(command, i + 1, true);
683
+ if (end === -1) return { targets, sanitized: out, balanced: false };
684
+ out += command.slice(i + 1, end);
685
+ i = end;
686
+ continue;
687
+ }
688
+ if (ch === "'" || ch === '"') {
689
+ const end = skipQuotedRun(command, i, ch === '"');
690
+ if (end === -1) return { targets, sanitized: out, balanced: false };
691
+ out += command.slice(i, end);
692
+ i = end;
693
+ continue;
694
+ }
695
+
696
+ // Here-doc: remember the delimiter now, skip the body at the next newline.
697
+ // `<<<` is a here-STRING, not a here-doc, and needs no body skipping.
698
+ if (ch === '<' && command[i + 1] === '<' && command[i + 2] !== '<') {
699
+ let j = i + 2;
700
+ let stripTabs = false;
701
+ if (command[j] === '-') { stripTabs = true; j++; }
702
+ while (command[j] === ' ' || command[j] === '\t') j++;
703
+ const w = readWord(command, j);
704
+ if (!w.balanced) return { targets, sanitized: out, balanced: false };
705
+ if (w.value) heredocs.push({ delim: w.value, stripTabs });
706
+ out += ' ';
707
+ i = Math.max(w.end, i + 2);
708
+ continue;
709
+ }
710
+
711
+ // Redirection: `>` `>>` `>|` `N>` `&>` `&>>`. The optional leading fd digits
712
+ // are ordinary characters we already copied; only the operator matters.
713
+ if (ch === '>' || (ch === '&' && command[i + 1] === '>')) {
714
+ let j = ch === '&' ? i + 2 : i + 1;
715
+ if (command[j] === '>') j++;
716
+ if (command[j] === '|') j++;
717
+ if (command[j] === '&') {
718
+ const dup = readWord(command, j + 1);
719
+ if (FD_DUP_RE.test(dup.value)) { out += ' '; i = Math.max(dup.end, j + 1); continue; }
720
+ j += 1;
721
+ }
722
+ while (command[j] === ' ' || command[j] === '\t') j++;
723
+ const w = readWord(command, j);
724
+ if (!w.balanced) return { targets, sanitized: out, balanced: false };
725
+ if (w.value) targets.push(w.value);
726
+ out += ' ';
727
+ i = Math.max(w.end, j, i + 1);
728
+ continue;
729
+ }
730
+
731
+ out += ch;
732
+ i++;
733
+ }
734
+
735
+ return { targets, sanitized: out, balanced: true };
736
+ }
737
+
738
+ /**
739
+ * Find a non-redirect write verb (`tee`, `dd of=`, `cp`/`mv` destination)
740
+ * whose target is the ledger.
741
+ *
742
+ * Runs on the TOKENIZED command, so quoting is handled by the lexer: a
743
+ * `tee '.orchestrator/metrics/sessions.jsonl'` target is still seen, while a
744
+ * `tee` appearing only inside a quoted payload is not in verb position.
745
+ *
746
+ * Takes the SANITIZED command from {@link scanCommand}, not the raw one. Since
747
+ * #965 the reason is no longer comments/here-docs/`$'…'` — the shared lexer
748
+ * handles those now — but REDIRECT ELISION: `tokenizeCommand` emits a redirect
749
+ * target as an ordinary token, so on the raw string
750
+ * `cp foo …/sessions.jsonl > /dev/null` resolves its destination to
751
+ * `/dev/null` and allows. Measured; see the module docblock.
752
+ *
753
+ * Verb resolution is `resolveSegmentVerb`'s (#991) — flag-aware, so a wrapper's
754
+ * OPTIONS are consumed with it and `sudo -u root tee -a <ledger>` reaches the
755
+ * real verb instead of stopping at `-u`.
756
+ *
757
+ * DUAL PARSE (#1000). `resolveSegmentVerb` returns an optional `alt` reading —
758
+ * the value-taking interpretation of an unknown dash-flag — whenever the two
759
+ * readings disagree about the verb. Both are judged here and the FIRST hit wins,
760
+ * which is the safe direction: guessing wrong in the boolean direction hid the
761
+ * write verb behind an unrecognised flag, and two commands were MEASURED to slip
762
+ * through that way (`nice --unknown 5 tee -a <ledger>` read the verb as `5`;
763
+ * `env -Q x tee -a <ledger>` read it as `x`). Judging both cannot lose a deny
764
+ * parse A already found — parse A is still evaluated first and unchanged.
765
+ *
766
+ * @param {string} command - sanitized command
767
+ * @param {number} [depth] - payload recursion level (see MAX_PAYLOAD_DEPTH)
768
+ * @param {string[]} [marks] - OUT-param accumulator for fail-visible markers
769
+ * (#998): analysis this pass could not complete, e.g. a payload dropped at the
770
+ * MAX_PAYLOAD_DEPTH cut. Threaded through the recursion so a mark raised in a
771
+ * nested payload reaches `main()`. Never affects the return value — the
772
+ * `string|null` contract is unchanged; marks add VISIBILITY, never a deny.
773
+ * @returns {string|null} the offending target, or null
774
+ */
775
+ function findWriteVerbTarget(command, depth = 0, marks = []) {
776
+ for (const segment of blocker.splitChainSegments(blocker.tokenizeCommand(command))) {
777
+ const resolved = blocker.resolveSegmentVerb(segment);
778
+ const readings = [resolved, resolved.alt].filter(Boolean);
779
+ // Payloads are deduped ACROSS readings: both readings usually report the same
780
+ // `env -S '…'` operand, and recursing twice on one string only doubles work.
781
+ const payloadSet = new Set();
782
+
783
+ for (const { payloads, wrapperArgs } of readings) {
784
+ for (const p of payloads) payloadSet.add(p);
785
+
786
+ // A wrapper can write a file WITHOUT being the verb: `/usr/bin/time -o F`
787
+ // truncates F while the verb is whatever time runs. Checked before the verb
788
+ // dispatch because `time -o <ledger>` alone resolves to verb null. The
789
+ // file-vs-option distinction is the LEXER's now (#996.1): resolveSegmentVerb
790
+ // marks a write-destination operand `writesFile: true` from its per-wrapper
791
+ // `fileArgFlags` table (command-blocker.mjs — the writesFile contract, the
792
+ // `resolveSegmentVerb` return docblock), so a local `<wrapper>:<flag>` pair
793
+ // list here is gone. The rationale it encoded — `stdbuf -o` is a BUFFERING
794
+ // MODE, not a file, and `time -o` is the only wrapper flag that opens one —
795
+ // lives beside that table (command-blocker.mjs, the WRAPPER_UNWRAP docblock).
796
+ for (const wa of wrapperArgs) {
797
+ if (wa.writesFile !== true) continue;
798
+ if (typeof wa.value === 'string' && refersToLedger(wa.value)) return wa.value;
799
+ }
800
+ }
801
+
802
+ // `env -S 'tee -a <ledger>'` hides a whole command line in one operand.
803
+ // Recurse on the payload with the SAME matcher rather than a second,
804
+ // weaker one — bounded by MAX_PAYLOAD_DEPTH. Kept BEFORE the verb dispatch
805
+ // (pre-#1000 order), so which target a mixed command reports is unchanged.
806
+ if (payloadSet.size > 0) {
807
+ if (depth < MAX_PAYLOAD_DEPTH) {
808
+ for (const payload of payloadSet) {
809
+ const hit = findLedgerWrite(payload, depth + 1, marks);
810
+ if (hit) return hit;
811
+ }
812
+ } else if ([...payloadSet].some((p) => typeof p === 'string' && p.includes(LEDGER_BASENAME))) {
813
+ // The cut USED to be silent (#998 item 2): a payload nested past the cap
814
+ // was dropped with no trace, so an operator could not tell "analysed and
815
+ // clean" from "never looked at". Marked — but ONLY when the dropped
816
+ // payload actually mentions the ledger. An unfiltered mark would turn
817
+ // every ordinary deep-but-benign command into a warn envelope, which is
818
+ // noise this accident-guard must not generate. Still no deny: raising the
819
+ // cap into a deny is a policy change, not a visibility fix.
820
+ marks.push('depth-exceeded');
821
+ }
822
+ }
823
+
824
+ for (const { verb, index } of readings) {
825
+ if (!verb) continue;
826
+ // `index` is the verb position OF THIS READING — never mix it with the
827
+ // other's (the lib docblock's positional-walk caveat).
828
+ const args = segment.slice(index + 1);
829
+
830
+ // The repair CLI mutates the ledger from inside Node, so there is no
831
+ // redirect target for the structural matcher to see. Match only its
832
+ // effective apply contract at the resolved token seam: `node`, the
833
+ // actual script operand after Node runtime options, a later exact
834
+ // `--apply`, and no later exact `--dry-run`. The CLI remains the owner of
835
+ // target semantics.
836
+ if (verb === 'node') {
837
+ const script = resolveNodeScriptOperand(args);
838
+ const hasRepairMention = args.some((arg) => refersToRepairScript(arg.text));
839
+ const hasApply = args.some((arg) => arg.text === '--apply');
840
+ const hasDryRun = args.some((arg) => arg.text === '--dry-run');
841
+
842
+ if (script?.unavailable) {
843
+ // The runtime-derived grammar is an enforcement dependency. If it is
844
+ // unavailable, an ambiguous repair invocation must be visible and
845
+ // denied rather than falling through to the hook's normal allow path.
846
+ if (hasRepairMention && hasApply && !hasDryRun) {
847
+ return `${REPAIR_APPLY_MARKER} (Node option grammar unavailable: ${script.reason}; denied fail-closed)`;
848
+ }
849
+ } else {
850
+ const scriptToken = script && args[script.scriptIndex];
851
+ const laterArgs = script ? args.slice(script.scriptIndex + 1) : [];
852
+ if (
853
+ scriptToken &&
854
+ refersToRepairScript(scriptToken.text) &&
855
+ laterArgs.some((arg) => arg.text === '--apply') &&
856
+ !laterArgs.some((arg) => arg.text === '--dry-run')
857
+ ) {
858
+ return REPAIR_APPLY_MARKER;
859
+ }
860
+ }
861
+ }
862
+
863
+ if (verb === 'tee') {
864
+ for (const arg of args) {
865
+ if (!arg.quoted && arg.text.startsWith('-')) continue;
866
+ if (refersToLedger(arg.text)) return arg.text;
867
+ }
868
+ continue;
869
+ }
870
+
871
+ if (verb === 'dd') {
872
+ for (const arg of args) {
873
+ const m = /^of=(.*)$/.exec(arg.text);
874
+ if (m && refersToLedger(m[1])) return m[1];
875
+ }
876
+ continue;
877
+ }
878
+
879
+ if (DEST_LAST_VERBS.has(verb)) {
880
+ const operands = args.filter((a) => a.quoted || !a.text.startsWith('-'));
881
+ // `cp a b` writes b; `cp ledger backup` READS the ledger and must pass.
882
+ const dest = operands.length >= 2 ? operands[operands.length - 1] : null;
883
+ if (dest && refersToLedger(dest.text)) return dest.text;
884
+ }
885
+ }
886
+ }
887
+ return null;
888
+ }
889
+
890
+ /**
891
+ * The single matcher seam. Returns the offending write target, or null when the
892
+ * command carries no direct ledger write.
893
+ *
894
+ * Deliberately NOT exported: this module runs `main()` on import, so a test that
895
+ * imported the matcher would block on stdin. The fake-regression proof
896
+ * neutralises this function in place and re-runs the spawned-hook tests.
897
+ *
898
+ * @param {string} command
899
+ * @param {number} [depth] - payload recursion level (see MAX_PAYLOAD_DEPTH)
900
+ * @param {string[]} [marks] - OUT-param accumulator for fail-visible markers
901
+ * (#998); see {@link findWriteVerbTarget}. Purely additive — the `string|null`
902
+ * return contract is unchanged and a mark never becomes a deny.
903
+ * @returns {string|null}
904
+ */
905
+ function findLedgerWrite(command, depth = 0, marks = []) {
906
+ if (typeof command !== 'string' || command.length === 0) return null;
907
+ // Cheap pre-filter: commands naming neither the ledger nor the internal repair
908
+ // CLI cannot reach either deny path.
909
+ if (!command.includes(LEDGER_BASENAME) && !command.includes(REPAIR_SCRIPT_BASENAME)) return null;
910
+
911
+ const scan = scanCommand(command);
912
+ for (const target of scan.targets) {
913
+ if (refersToLedger(target)) return target;
914
+ }
915
+ // Fail-CLOSED on an unbalanced quote (see docblock): the scan could not be
916
+ // trusted past the break, and the command mentions the ledger. Bash would
917
+ // reject it with a syntax error anyway, so the false-positive cost is ~0
918
+ // while "confuse the lexer" stops being a bypass.
919
+ if (!scan.balanced) {
920
+ return `${LEDGER_BASENAME} (unbalanced quote — command not parseable, denied fail-closed)`;
921
+ }
922
+ return findWriteVerbTarget(scan.sanitized, depth, marks);
923
+ }
924
+
925
+ /**
926
+ * Flush the aggregated allow-with-notice channel (#1001).
927
+ *
928
+ * Notices accumulate during matching and are emitted ONCE, here, on the VISIBLE
929
+ * stdout channel via `emitWarn` (allow-with-notice) — else a plain `emitAllow`.
930
+ * Both exit 0 and never return, so this is always the LAST statement on an allow
931
+ * path.
932
+ *
933
+ * Why aggregate instead of `emitWarn`-ing inline: `emitWarn` is `@returns never`
934
+ * (scripts/lib/io.mjs), so an inline call before `findLedgerWrite` would exit
935
+ * BEFORE the matcher ran — turning every would-be DENY into an ALLOW-with-notice.
936
+ * The deny path never reaches here and drops the notices by design: DENY wins,
937
+ * and the stderr copies remain for CI/debug.
938
+ *
939
+ * @param {string[]} notices
940
+ * @returns {never}
941
+ */
942
+ function flushNotices(notices) {
943
+ return notices.length > 0 ? emitWarn(notices.join('\n')) : emitAllow();
944
+ }
945
+
946
+ // ---------------------------------------------------------------------------
947
+ // Main
948
+ // ---------------------------------------------------------------------------
949
+
950
+ async function main() {
951
+ const input = await readStdin();
952
+ if (!input) return emitAllow();
953
+
954
+ // G1 — only Bash is gated.
955
+ if (input.tool_name !== 'Bash') return emitAllow();
956
+
957
+ // G2 — command must be a non-empty string.
958
+ const command = input?.tool_input?.command;
959
+ if (typeof command !== 'string' || command.length === 0) return emitAllow();
960
+
961
+ // #1001 — aggregated allow-with-notice channel, opened only AFTER G1/G2 (a
962
+ // non-Bash call or an empty command is not this hook's business and stays a
963
+ // bare allow). Flushed ONCE on the allow path; see flushNotices for why an
964
+ // inline emitWarn here would disarm the G4 deny below.
965
+ const notices = [];
966
+ // A guard armed from HEAD is a visible-channel concern: the DEGRADED banner
967
+ // rides stderr only, which exit 0 discards. armGuard already fired it once per
968
+ // session; surface it on stdout so a degraded ALLOW is not silently
969
+ // indistinguishable from a healthy one.
970
+ if (degradedLabels.length > 0) {
971
+ notices.push(
972
+ `${HOOK_NAME}: DEGRADED — guard module(s) loaded from HEAD, not your working tree ` +
973
+ `(${degradedLabels.join(', ')}); uncommitted changes to them are NOT in effect. See #992.`
974
+ );
975
+ }
976
+
977
+ // G3 — matcher. No direct ledger write → allow.
978
+ const marks = [];
979
+ const target = findLedgerWrite(command, 0, marks);
980
+ if (!target) {
981
+ // Fail-VISIBLE (#998 item 2): the matcher completed without a hit, but part
982
+ // of the command was never analysed. Say so rather than reporting a clean
983
+ // allow. Still an ALLOW — this hook is an accident-guard, fail-open by
984
+ // design, and a depth cut is not evidence of a write.
985
+ if (marks.length > 0) {
986
+ const msg =
987
+ `${HOOK_NAME}: unresolved payload (${[...new Set(marks)].join(', ')}) — ` +
988
+ `not analysed (fail-visible)`;
989
+ process.stderr.write(`⚠ ${msg}\n`);
990
+ notices.push(msg);
991
+ }
992
+ return flushNotices(notices);
993
+ }
994
+
995
+ // G4 — deny. emitDeny writes the envelope with fs.writeSync (#906/#914): a
996
+ // console.log here would be dropped above the 64 KiB pipe buffer, and a
997
+ // dropped envelope on this protocol reads as NO decision, i.e. fail-OPEN.
998
+ const shown = target.length > TARGET_ECHO_MAX
999
+ ? `${target.slice(0, TARGET_ECHO_MAX)}… (${target.length} chars)`
1000
+ : target;
1001
+
1002
+ emitDeny(
1003
+ [
1004
+ `Direct write to the sessions ledger blocked: '${shown}'`,
1005
+ `The ledger is append-only through its validating writer:`,
1006
+ ` node scripts/emit-session.mjs --entry '<json>' (or pipe the JSON on stdin)`,
1007
+ `Hand-composing a record and appending it with a shell redirect skips schema`,
1008
+ `validation — that is exactly how the malformed record in GitLab #958 landed.`,
1009
+ `Override (intentional maintenance only): run the session with`,
1010
+ `SO_DISABLED_HOOKS=pre-bash-sessions-ledger-guard`,
1011
+ `See: GitLab #958, skills/session-end/session-metrics-write.md`,
1012
+ ].join('\n'),
1013
+ );
1014
+ }
1015
+
1016
+ // ---------------------------------------------------------------------------
1017
+ // Entry point (#993)
1018
+ //
1019
+ // TWO distinct failure classes, two distinct handlers — do not merge them:
1020
+ // 1. LOAD failure (`bootstrap()` throws): the guard never armed, nothing was
1021
+ // evaluated. This used to be a bare exit-1 crash with 0 bytes of stdout —
1022
+ // indistinguishable from an allow, and therefore invisible. Now it exits 0
1023
+ // (still fail-open, so a broken module cannot brick the session) but SAYS SO
1024
+ // via the GUARD INACTIVE banner, once loud on stderr.
1025
+ // 2. RUNTIME failure inside `main()`: pre-existing fail-open behaviour,
1026
+ // unchanged (this hook is fail-open by design — see the module docblock).
1027
+ // ---------------------------------------------------------------------------
1028
+ try {
1029
+ await bootstrap();
1030
+ } catch (loadError) {
1031
+ try {
1032
+ const { emitGuardInactiveBanner } = await import('./_lib/guard-source-loader.mjs');
1033
+ // hookName is threaded explicitly (#993 — no hard-wired literal in the loader).
1034
+ emitGuardInactiveBanner({ hookName: HOOK_NAME, error: loadError, consequence: GUARD_CONSEQUENCE });
1035
+ } catch {
1036
+ // Last resort: even the banner helper failed to load. Emit unconditionally —
1037
+ // repeated noise beats a silent disarm.
1038
+ process.stderr.write(
1039
+ '🚨 pre-bash-sessions-ledger-guard: GUARD INACTIVE — module load failed ' +
1040
+ `(${String(loadError?.message || loadError).split('\n')[0]}). ` +
1041
+ 'Direct shell writes to the sessions ledger are NOT being blocked. See issue #992.\n'
1042
+ );
1043
+ }
1044
+ process.exit(0); // fail-open, but no longer fail-silent
1045
+ }
1046
+
1047
+ // Top-level error handler — fail-OPEN (see the module docblock). Never let a
1048
+ // non-zero exit leak: on this protocol exit 0 + empty stdout is "no decision".
1049
+ main().catch((e) => {
1050
+ process.stderr.write(
1051
+ `⚠ pre-bash-sessions-ledger-guard: internal error — ${e?.message || e}\n`,
1052
+ );
1053
+ process.exit(0);
1054
+ });