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,627 @@
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. The bash
127
+ * KEYWORD `time` is unaffected: it rejects the flag outright
128
+ * (`bash -c 'time -o x echo hi'` → "-o: command not found"), so only
129
+ * `/usr/bin/time`, `command time` and `env time` can carry it at all.
130
+ *
131
+ * Fail-CLOSED on an unbalanced quote: if the scan ends mid-quote the command is
132
+ * one bash itself would reject with a syntax error, so denying it when it
133
+ * mentions the ledger costs ~0 in false positives and removes "leave the lexer
134
+ * confused" as a bypass strategy. This is the one place the guard is closed
135
+ * rather than open, and it is closed because the alternative is a silent skip.
136
+ *
137
+ * ## What this does NOT catch (stated plainly — the bounds are the contract)
138
+ *
139
+ * - Indirection through a variable: `>> "$LEDGER"`, `tee "$LEDGER"`,
140
+ * `exec 3>"$LEDGER"; echo x >&3`. A PreToolUse hook sees the unexpanded
141
+ * string; the target is genuinely unknowable here.
142
+ * - A target whose BASENAME only exists after expansion:
143
+ * `>> $(echo sessions.jsonl)`, `>> "$dir/$name"`, a glob or brace form.
144
+ * What matters is whether the literal `sessions.jsonl` survives in the
145
+ * command string — the DIRECTORY half may expand freely and is still
146
+ * denied (`"$PWD"/…/sessions.jsonl`, `$HOME/…/sessions.jsonl`,
147
+ * `"$(pwd)/…/sessions.jsonl"` and `exec 3> …/sessions.jsonl` all deny;
148
+ * verified, not assumed).
149
+ * - A redirect nested inside a DOUBLE-QUOTED command substitution or a
150
+ * backtick run: `echo "$(echo x >> …/sessions.jsonl)"` allows, while the
151
+ * unquoted `echo $(echo x >> …/sessions.jsonl)` denies. The scanner
152
+ * consumes a quoted run whole and does not recurse into it. Verified, and
153
+ * left uncaught deliberately: recursing costs lexer surface, and a redirect
154
+ * buried in a quoted substitution is not the accident shape this guard is
155
+ * for — it is the "determined circumvention" line below.
156
+ * - In-place editors: `sed -i`, `perl -i`, `ed`, an interactive editor.
157
+ * - A write performed inside an interpreter: `node -e`, `python -c`,
158
+ * `bash -c '… >> …/sessions.jsonl'`, or any script the command invokes that
159
+ * appends the ledger itself. The one exception is a WRAPPER payload —
160
+ * `env -S 'tee -a …/sessions.jsonl'` — which `resolveSegmentVerb` reports
161
+ * as a payload and which this matcher re-enters (to MAX_PAYLOAD_DEPTH).
162
+ * Interpreter `-c` payloads are a larger surface and stay out of scope.
163
+ * - Obfuscation: `eval`, `base64 -d | sh`, a here-doc-fed shell. Here-doc
164
+ * BODIES are skipped as the data they are — `bash <<EOF … EOF` therefore
165
+ * hides its payload from this matcher by construction.
166
+ * - `sponge`, `install`, `rsync`, `awk > file` and other less common write
167
+ * verbs (a redirect inside an `awk` program string is quoted data here).
168
+ * - The Write/Edit tools — a different PreToolUse matcher entirely
169
+ * (`hooks/enforce-scope.mjs` territory), not this hook's surface.
170
+ *
171
+ * This is a guard against the accident that actually happened, not a
172
+ * containment boundary. Determined circumvention is out of scope by design.
173
+ *
174
+ * ## Fail-open on internal error
175
+ *
176
+ * A crash allows the command (exit 0, empty stdout) and warns on stderr,
177
+ * matching every sibling Bash guard in this repo. This is a nudge on a
178
+ * developer machine, not a security boundary: a wedged guard that blocks every
179
+ * Bash call is strictly worse than a missed enforcement, and the visibility
180
+ * half of #958 (the session-start ledger-integrity banner) catches what slips
181
+ * through. Under the #906 exit-0 protocol, "fail-open" is literally exit 0 with
182
+ * no stdout envelope — the harness reads no decision and proceeds.
183
+ *
184
+ * ## Override
185
+ *
186
+ * SO_DISABLED_HOOKS=pre-bash-sessions-ledger-guard (session-level)
187
+ * SO_HOOK_PROFILE=minimal|off
188
+ *
189
+ * Deliberately session-level and not in-command: an in-command escape hatch
190
+ * would be one more string for a hurried agent to paste, i.e. the discipline
191
+ * failure this hook exists to replace.
192
+ */
193
+
194
+ import { readStdin, emitAllow, emitDeny } from '../scripts/lib/io.mjs';
195
+ // Single direct import from the source module (#991), matching the precedent in
196
+ // hooks/pre-bash-destructive-guard.mjs: the hardening.mjs barrel re-exports
197
+ // tokenizeCommand from this very module (same instance either way) but
198
+ // deliberately does NOT re-export the #982/#983 primitives, so importing the
199
+ // lexer from the barrel and the wrapper resolver from the source would give
200
+ // this hook two dependency edges to one module. The barrel is unchanged.
201
+ import {
202
+ tokenizeCommand,
203
+ resolveSegmentVerb,
204
+ splitChainSegments,
205
+ } from '../scripts/lib/command-blocker.mjs';
206
+ import path from 'node:path';
207
+
208
+ import { shouldRunHook } from './_lib/profile-gate.mjs';
209
+ // #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
210
+ if (!shouldRunHook('pre-bash-sessions-ledger-guard')) process.exit(0);
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Constants
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /** The ledger's filename. A write target matches on basename equality. */
217
+ const LEDGER_BASENAME = 'sessions.jsonl';
218
+
219
+ /**
220
+ * How much of the offending target may appear in the deny reason.
221
+ *
222
+ * Load-bearing, not cosmetic. `emitDeny` clamps the whole reason to
223
+ * DENY_REASON_MAX (#906) — so an unbounded target on the FIRST line pushes the
224
+ * `emit-session.mjs` route, the override and the issue reference off the end,
225
+ * and the operator gets a wall of characters with no instruction. Bounding the
226
+ * target keeps the reason short enough that the actionable half always survives.
227
+ */
228
+ const TARGET_ECHO_MAX = 200;
229
+
230
+ /** Verbs whose LAST non-flag argument is a write destination. */
231
+ const DEST_LAST_VERBS = new Set(['cp', 'mv']);
232
+
233
+ /**
234
+ * Wrapper flags from {@link resolveSegmentVerb}'s `wrapperArgs` whose operand is
235
+ * a FILE the wrapper itself writes, keyed `<wrapper>:<flag>`.
236
+ *
237
+ * Deliberately an explicit pair list rather than "any argFlag operand": most
238
+ * value-taking wrapper flags name something that is NOT a path — `sudo -u` a
239
+ * user, `nice -n` a niceness, `timeout -k` a duration, `env -u` a variable, and
240
+ * `stdbuf -o` a BUFFERING MODE (`0`, `L`, `4096`), which shares its spelling
241
+ * with `time -o` and would be the obvious false positive of a blanket rule.
242
+ * Only `time -o`/`--output` opens a file for writing.
243
+ */
244
+ const WRAPPER_FILE_FLAGS = new Set(['time:-o', 'time:--output']);
245
+
246
+ /**
247
+ * How deep to follow a wrapper's command-string payload (`env -S '…'`).
248
+ *
249
+ * Payloads are the only recursion source here, and two levels covers every
250
+ * shape a hurried agent produces while keeping the work bounded — a cap that
251
+ * cannot be exhausted into a bypass because the OUTER command is still matched
252
+ * on its own terms.
253
+ */
254
+ const MAX_PAYLOAD_DEPTH = 2;
255
+
256
+ /** Unquoted characters that end a shell WORD. */
257
+ const WORD_END = new Set([';', '|', '&', '<', '>', '(', ')', '\n']);
258
+
259
+ /** `>&1`, `>&2`, `>&-`, `>&3-` duplicate a descriptor — no file target. */
260
+ const FD_DUP_RE = /^(?:\d+-?|-)$/;
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Helpers
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Does this path string name the sessions ledger?
268
+ *
269
+ * Basename equality, so `.orchestrator/metrics/sessions.jsonl`,
270
+ * `/tmp/x/.orchestrator/metrics/sessions.jsonl` and a bare `sessions.jsonl`
271
+ * (after a `cd`) all match, while `sessions.jsonl.bak`, `learnings.jsonl` and
272
+ * `events.jsonl` do not.
273
+ *
274
+ * @param {string} target
275
+ * @returns {boolean}
276
+ */
277
+ function refersToLedger(target) {
278
+ if (typeof target !== 'string' || target.length === 0) return false;
279
+ // Normalise Windows-style separators before taking the basename so a
280
+ // backslash-spelled path is not read as one long filename.
281
+ const normalized = target.replace(/\\/g, '/');
282
+ return path.posix.basename(normalized) === LEDGER_BASENAME;
283
+ }
284
+
285
+ /**
286
+ * Skip a quoted run that OPENS at `open`.
287
+ *
288
+ * @param {string} command
289
+ * @param {number} open - index of the opening quote character
290
+ * @param {boolean} escapes - true when `\` escapes the next char (double / ANSI-C)
291
+ * @returns {number} index just past the closing quote, or -1 when never closed
292
+ */
293
+ function skipQuotedRun(command, open, escapes) {
294
+ const quote = command[open];
295
+ for (let i = open + 1; i < command.length; i++) {
296
+ const ch = command[i];
297
+ if (escapes && ch === '\\' && i + 1 < command.length) { i++; continue; }
298
+ if (ch === quote) return i + 1;
299
+ }
300
+ return -1;
301
+ }
302
+
303
+ /**
304
+ * Read ONE shell word starting at `i`, resolving quoting and escapes to the
305
+ * logical value bash would pass as an argument.
306
+ *
307
+ * This is the MED-2 fix: a word is a sequence of runs, not a single one, so
308
+ * `"$PWD"/.orchestrator/metrics/sessions.jsonl`,
309
+ * `.orchestrator/metrics/"sessions.jsonl"`, `My\ Repo/…/sessions.jsonl` and
310
+ * `"."/…/sessions.jsonl` all resolve to a value whose basename is the ledger.
311
+ *
312
+ * @param {string} command
313
+ * @param {number} i
314
+ * @returns {{ value: string, end: number, balanced: boolean }}
315
+ */
316
+ function readWord(command, i) {
317
+ let value = '';
318
+ let state = 'normal';
319
+ while (i < command.length) {
320
+ const ch = command[i];
321
+
322
+ if (state === 'single') {
323
+ if (ch === "'") { state = 'normal'; i++; continue; }
324
+ value += ch; i++; continue;
325
+ }
326
+ if (state === 'double' || state === 'ansi') {
327
+ if (ch === (state === 'double' ? '"' : "'")) { state = 'normal'; i++; continue; }
328
+ if (ch === '\\' && i + 1 < command.length) { value += command[i + 1]; i += 2; continue; }
329
+ value += ch; i++; continue;
330
+ }
331
+
332
+ if (/\s/.test(ch) || WORD_END.has(ch)) break;
333
+ if (ch === '\\' && i + 1 < command.length) { value += command[i + 1]; i += 2; continue; }
334
+ if (ch === "'") { state = 'single'; i++; continue; }
335
+ if (ch === '"') { state = 'double'; i++; continue; }
336
+ if (ch === '$' && command[i + 1] === "'") { state = 'ansi'; i += 2; continue; }
337
+ if (ch === '$' && command[i + 1] === '"') { state = 'double'; i += 2; continue; }
338
+ value += ch; i++;
339
+ }
340
+ return { value, end: i, balanced: state === 'normal' };
341
+ }
342
+
343
+ /**
344
+ * Skip a here-doc body starting at `from`, returning the index just past the
345
+ * terminator line (or the end of input when the terminator never arrives).
346
+ *
347
+ * @param {string} command
348
+ * @param {number} from
349
+ * @param {string} delim
350
+ * @param {boolean} stripTabs - `<<-` form: leading tabs on the terminator ignored
351
+ * @returns {number}
352
+ */
353
+ function skipHeredocBody(command, from, delim, stripTabs) {
354
+ let i = from;
355
+ while (i < command.length) {
356
+ let lineEnd = command.indexOf('\n', i);
357
+ if (lineEnd === -1) lineEnd = command.length;
358
+ const raw = command.slice(i, lineEnd);
359
+ const line = stripTabs ? raw.replace(/^\t+/, '') : raw;
360
+ i = lineEnd + 1;
361
+ if (line === delim) return Math.min(i, command.length);
362
+ }
363
+ return command.length;
364
+ }
365
+
366
+ /**
367
+ * One quote-aware pass over the command.
368
+ *
369
+ * Returns every RESOLVED redirect target, plus a sanitized command for the
370
+ * write-verb matcher: comments and here-doc bodies removed, `$'…'` folded to a
371
+ * plain single-quoted literal, redirect operators and their targets elided.
372
+ * `balanced` is false when the scan ended inside an unterminated quote — a
373
+ * command bash would reject, and the fail-closed trigger (see docblock).
374
+ *
375
+ * @param {string} command
376
+ * @returns {{ targets: string[], sanitized: string, balanced: boolean }}
377
+ */
378
+ function scanCommand(command) {
379
+ const targets = [];
380
+ const heredocs = [];
381
+ let out = '';
382
+ let i = 0;
383
+
384
+ const atWordStart = (idx) => idx === 0 || /[\s;|&()<>]/.test(command[idx - 1]);
385
+
386
+ while (i < command.length) {
387
+ const ch = command[i];
388
+
389
+ // A `#` in word position comments out the rest of the LINE. Without this,
390
+ // one apostrophe in English prose desynced the whole scan (MED-1).
391
+ if (ch === '#' && atWordStart(i)) {
392
+ while (i < command.length && command[i] !== '\n') i++;
393
+ continue;
394
+ }
395
+
396
+ if (ch === '\n') {
397
+ out += '\n';
398
+ i++;
399
+ while (heredocs.length) {
400
+ const { delim, stripTabs } = heredocs.shift();
401
+ i = skipHeredocBody(command, i, delim, stripTabs);
402
+ }
403
+ continue;
404
+ }
405
+
406
+ if (ch === '\\' && i + 1 < command.length) { out += ch + command[i + 1]; i += 2; continue; }
407
+
408
+ // ANSI-C `$'…'`: a backslash escapes the closing quote, which the old mask
409
+ // did not know (MED-1). Folded to a plain literal for the write-verb lexer.
410
+ if (ch === '$' && command[i + 1] === "'") {
411
+ const end = skipQuotedRun(command, i + 1, true);
412
+ if (end === -1) return { targets, sanitized: out, balanced: false };
413
+ const literal = command.slice(i + 2, end - 1).replace(/\\(.)/g, '$1').replace(/'/g, '');
414
+ out += `'${literal}'`;
415
+ i = end;
416
+ continue;
417
+ }
418
+ if (ch === '$' && command[i + 1] === '"') {
419
+ const end = skipQuotedRun(command, i + 1, true);
420
+ if (end === -1) return { targets, sanitized: out, balanced: false };
421
+ out += command.slice(i + 1, end);
422
+ i = end;
423
+ continue;
424
+ }
425
+ if (ch === "'" || ch === '"') {
426
+ const end = skipQuotedRun(command, i, ch === '"');
427
+ if (end === -1) return { targets, sanitized: out, balanced: false };
428
+ out += command.slice(i, end);
429
+ i = end;
430
+ continue;
431
+ }
432
+
433
+ // Here-doc: remember the delimiter now, skip the body at the next newline.
434
+ // `<<<` is a here-STRING, not a here-doc, and needs no body skipping.
435
+ if (ch === '<' && command[i + 1] === '<' && command[i + 2] !== '<') {
436
+ let j = i + 2;
437
+ let stripTabs = false;
438
+ if (command[j] === '-') { stripTabs = true; j++; }
439
+ while (command[j] === ' ' || command[j] === '\t') j++;
440
+ const w = readWord(command, j);
441
+ if (!w.balanced) return { targets, sanitized: out, balanced: false };
442
+ if (w.value) heredocs.push({ delim: w.value, stripTabs });
443
+ out += ' ';
444
+ i = Math.max(w.end, i + 2);
445
+ continue;
446
+ }
447
+
448
+ // Redirection: `>` `>>` `>|` `N>` `&>` `&>>`. The optional leading fd digits
449
+ // are ordinary characters we already copied; only the operator matters.
450
+ if (ch === '>' || (ch === '&' && command[i + 1] === '>')) {
451
+ let j = ch === '&' ? i + 2 : i + 1;
452
+ if (command[j] === '>') j++;
453
+ if (command[j] === '|') j++;
454
+ if (command[j] === '&') {
455
+ const dup = readWord(command, j + 1);
456
+ if (FD_DUP_RE.test(dup.value)) { out += ' '; i = Math.max(dup.end, j + 1); continue; }
457
+ j += 1;
458
+ }
459
+ while (command[j] === ' ' || command[j] === '\t') j++;
460
+ const w = readWord(command, j);
461
+ if (!w.balanced) return { targets, sanitized: out, balanced: false };
462
+ if (w.value) targets.push(w.value);
463
+ out += ' ';
464
+ i = Math.max(w.end, j, i + 1);
465
+ continue;
466
+ }
467
+
468
+ out += ch;
469
+ i++;
470
+ }
471
+
472
+ return { targets, sanitized: out, balanced: true };
473
+ }
474
+
475
+ /**
476
+ * Find a non-redirect write verb (`tee`, `dd of=`, `cp`/`mv` destination)
477
+ * whose target is the ledger.
478
+ *
479
+ * Runs on the TOKENIZED command, so quoting is handled by the lexer: a
480
+ * `tee '.orchestrator/metrics/sessions.jsonl'` target is still seen, while a
481
+ * `tee` appearing only inside a quoted payload is not in verb position.
482
+ *
483
+ * Takes the SANITIZED command from {@link scanCommand}, not the raw one. Since
484
+ * #965 the reason is no longer comments/here-docs/`$'…'` — the shared lexer
485
+ * handles those now — but REDIRECT ELISION: `tokenizeCommand` emits a redirect
486
+ * target as an ordinary token, so on the raw string
487
+ * `cp foo …/sessions.jsonl > /dev/null` resolves its destination to
488
+ * `/dev/null` and allows. Measured; see the module docblock.
489
+ *
490
+ * Verb resolution is `resolveSegmentVerb`'s (#991) — flag-aware, so a wrapper's
491
+ * OPTIONS are consumed with it and `sudo -u root tee -a <ledger>` reaches the
492
+ * real verb instead of stopping at `-u`.
493
+ *
494
+ * @param {string} command - sanitized command
495
+ * @param {number} [depth] - payload recursion level (see MAX_PAYLOAD_DEPTH)
496
+ * @returns {string|null} the offending target, or null
497
+ */
498
+ function findWriteVerbTarget(command, depth = 0) {
499
+ for (const segment of splitChainSegments(tokenizeCommand(command))) {
500
+ const { verb, index, payloads, wrapperArgs } = resolveSegmentVerb(segment);
501
+
502
+ // A wrapper can write a file WITHOUT being the verb: `/usr/bin/time -o F`
503
+ // truncates F while the verb is whatever time runs. Checked before the verb
504
+ // dispatch because `time -o <ledger>` alone resolves to verb null.
505
+ for (const wa of wrapperArgs) {
506
+ if (!WRAPPER_FILE_FLAGS.has(`${wa.wrapper}:${wa.flag}`)) continue;
507
+ if (typeof wa.value === 'string' && refersToLedger(wa.value)) return wa.value;
508
+ }
509
+
510
+ // `env -S 'tee -a <ledger>'` hides a whole command line in one operand.
511
+ // Recurse on the payload with the SAME matcher rather than a second,
512
+ // weaker one — bounded by MAX_PAYLOAD_DEPTH.
513
+ if (depth < MAX_PAYLOAD_DEPTH) {
514
+ for (const payload of payloads) {
515
+ const hit = findLedgerWrite(payload, depth + 1);
516
+ if (hit) return hit;
517
+ }
518
+ }
519
+
520
+ if (!verb) continue;
521
+ const args = segment.slice(index + 1);
522
+
523
+ if (verb === 'tee') {
524
+ for (const arg of args) {
525
+ if (!arg.quoted && arg.text.startsWith('-')) continue;
526
+ if (refersToLedger(arg.text)) return arg.text;
527
+ }
528
+ continue;
529
+ }
530
+
531
+ if (verb === 'dd') {
532
+ for (const arg of args) {
533
+ const m = /^of=(.*)$/.exec(arg.text);
534
+ if (m && refersToLedger(m[1])) return m[1];
535
+ }
536
+ continue;
537
+ }
538
+
539
+ if (DEST_LAST_VERBS.has(verb)) {
540
+ const operands = args.filter((a) => a.quoted || !a.text.startsWith('-'));
541
+ // `cp a b` writes b; `cp ledger backup` READS the ledger and must pass.
542
+ const dest = operands.length >= 2 ? operands[operands.length - 1] : null;
543
+ if (dest && refersToLedger(dest.text)) return dest.text;
544
+ }
545
+ }
546
+ return null;
547
+ }
548
+
549
+ /**
550
+ * The single matcher seam. Returns the offending write target, or null when the
551
+ * command carries no direct ledger write.
552
+ *
553
+ * Deliberately NOT exported: this module runs `main()` on import, so a test that
554
+ * imported the matcher would block on stdin. The fake-regression proof
555
+ * neutralises this function in place and re-runs the spawned-hook tests.
556
+ *
557
+ * @param {string} command
558
+ * @param {number} [depth] - payload recursion level (see MAX_PAYLOAD_DEPTH)
559
+ * @returns {string|null}
560
+ */
561
+ function findLedgerWrite(command, depth = 0) {
562
+ if (typeof command !== 'string' || command.length === 0) return null;
563
+ // Cheap pre-filter: no mention of the filename at all → nothing to analyse.
564
+ if (!command.includes(LEDGER_BASENAME)) return null;
565
+
566
+ const scan = scanCommand(command);
567
+ for (const target of scan.targets) {
568
+ if (refersToLedger(target)) return target;
569
+ }
570
+ // Fail-CLOSED on an unbalanced quote (see docblock): the scan could not be
571
+ // trusted past the break, and the command mentions the ledger. Bash would
572
+ // reject it with a syntax error anyway, so the false-positive cost is ~0
573
+ // while "confuse the lexer" stops being a bypass.
574
+ if (!scan.balanced) {
575
+ return `${LEDGER_BASENAME} (unbalanced quote — command not parseable, denied fail-closed)`;
576
+ }
577
+ return findWriteVerbTarget(scan.sanitized, depth);
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // Main
582
+ // ---------------------------------------------------------------------------
583
+
584
+ async function main() {
585
+ const input = await readStdin();
586
+ if (!input) return emitAllow();
587
+
588
+ // G1 — only Bash is gated.
589
+ if (input.tool_name !== 'Bash') return emitAllow();
590
+
591
+ // G2 — command must be a non-empty string.
592
+ const command = input?.tool_input?.command;
593
+ if (typeof command !== 'string' || command.length === 0) return emitAllow();
594
+
595
+ // G3 — matcher. No direct ledger write → allow.
596
+ const target = findLedgerWrite(command);
597
+ if (!target) return emitAllow();
598
+
599
+ // G4 — deny. emitDeny writes the envelope with fs.writeSync (#906/#914): a
600
+ // console.log here would be dropped above the 64 KiB pipe buffer, and a
601
+ // dropped envelope on this protocol reads as NO decision, i.e. fail-OPEN.
602
+ const shown = target.length > TARGET_ECHO_MAX
603
+ ? `${target.slice(0, TARGET_ECHO_MAX)}… (${target.length} chars)`
604
+ : target;
605
+
606
+ emitDeny(
607
+ [
608
+ `Direct write to the sessions ledger blocked: '${shown}'`,
609
+ `The ledger is append-only through its validating writer:`,
610
+ ` node scripts/emit-session.mjs --entry '<json>' (or pipe the JSON on stdin)`,
611
+ `Hand-composing a record and appending it with a shell redirect skips schema`,
612
+ `validation — that is exactly how the malformed record in GitLab #958 landed.`,
613
+ `Override (intentional maintenance only): run the session with`,
614
+ `SO_DISABLED_HOOKS=pre-bash-sessions-ledger-guard`,
615
+ `See: GitLab #958, skills/session-end/session-metrics-write.md`,
616
+ ].join('\n'),
617
+ );
618
+ }
619
+
620
+ // Top-level error handler — fail-OPEN (see the module docblock). Never let a
621
+ // non-zero exit leak: on this protocol exit 0 + empty stdout is "no decision".
622
+ main().catch((e) => {
623
+ process.stderr.write(
624
+ `⚠ pre-bash-sessions-ledger-guard: internal error — ${e?.message || e}\n`,
625
+ );
626
+ process.exit(0);
627
+ });