session-orchestrator 3.19.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 (66) 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/CHANGELOG.md +80 -0
  5. package/README.md +9 -9
  6. package/commands/session.md +6 -2
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/instruction-delivery.md +350 -0
  9. package/docs/session-config-reference.md +1 -41
  10. package/docs/session-config-template.md +0 -23
  11. package/hooks/_lib/guard-source-loader.mjs +304 -91
  12. package/hooks/enforce-commands.mjs +216 -17
  13. package/hooks/enforce-scope.mjs +133 -9
  14. package/hooks/hooks-codex.json +1 -1
  15. package/hooks/hooks.json +1 -1
  16. package/hooks/on-session-start.mjs +7 -4
  17. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  18. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  19. package/package.json +2 -2
  20. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  21. package/scripts/emit-session.mjs +3 -40
  22. package/scripts/lib/command-blocker.mjs +322 -62
  23. package/scripts/lib/hardening.mjs +9 -9
  24. package/scripts/lib/learnings/affinity.mjs +434 -0
  25. package/scripts/lib/learnings/candidates.mjs +736 -0
  26. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  27. package/scripts/lib/learnings/judgment.mjs +782 -0
  28. package/scripts/lib/learnings/kebab.mjs +128 -0
  29. package/scripts/lib/learnings/select.mjs +550 -0
  30. package/scripts/lib/reconcile/emitter.mjs +107 -22
  31. package/scripts/lib/reconcile/engine.mjs +9 -15
  32. package/scripts/lib/reconcile/renderer.mjs +141 -25
  33. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  34. package/scripts/lib/reconcile/writer.mjs +95 -1
  35. package/scripts/lib/scope-gate.mjs +194 -72
  36. package/scripts/lib/session-close-backfill.mjs +2 -2
  37. package/scripts/lib/session-record-repair.mjs +551 -0
  38. package/scripts/lib/session-schema/serializer.mjs +54 -0
  39. package/scripts/lib/session-schema.mjs +1 -0
  40. package/scripts/lib/session-token-rollup.mjs +68 -6
  41. package/scripts/lib/soul-resolve.mjs +12 -0
  42. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  43. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  44. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  45. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  46. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  47. package/scripts/lib/validate/check-rules.mjs +31 -5
  48. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  49. package/scripts/print-applicable-rules.mjs +170 -7
  50. package/scripts/print-learnings-index.mjs +474 -0
  51. package/scripts/repair-invalid-sessions.mjs +209 -0
  52. package/scripts/sweep-expired-learnings.mjs +192 -32
  53. package/scripts/validate-plugin.mjs +21 -0
  54. package/skills/brainstorm/soul.md +47 -1
  55. package/skills/evolve/SKILL.md +116 -18
  56. package/skills/gitlab-ops/SKILL.md +5 -0
  57. package/skills/grill/soul.md +44 -1
  58. package/skills/plan/soul.md +46 -3
  59. package/skills/session-end/SKILL.md +1 -24
  60. package/skills/session-end/phase-3-6-tail.md +30 -1
  61. package/skills/session-end/plan-verification.md +1 -5
  62. package/skills/session-end/session-metrics-write.md +2 -0
  63. package/skills/session-start/SKILL.md +2 -0
  64. package/skills/session-start/soul.md +41 -1
  65. package/skills/wave-executor/SKILL.md +1 -5
  66. package/skills/wave-executor/wave-loop.md +36 -71
@@ -27,6 +27,7 @@
27
27
  import { readFileSync } from 'node:fs';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import { appendJsonl } from './lib/common.mjs';
30
+ import { serializeSessionLineChecked } from './lib/session-schema/serializer.mjs';
30
31
  import {
31
32
  validateSession,
32
33
  ValidationError,
@@ -35,45 +36,7 @@ import {
35
36
  aliasLegacyEndedAt,
36
37
  } from './lib/session-schema.mjs';
37
38
 
38
- /**
39
- * Serialize `validated` to a single JSONL line and prove it round-trips:
40
- * the line MUST be JSON-parseable AND the parsed-back object MUST still pass
41
- * `validateSession`. This is the pre-write self-validation seam (#662) that
42
- * mirrors the `appendLearning` guard — it catches non-serializable values
43
- * (`undefined`, `NaN`, `Infinity`, `BigInt`, circular refs) that
44
- * `JSON.stringify` silently drops or that produce a line which won't parse
45
- * back to the same schema-valid shape.
46
- *
47
- * Throws ValidationError (consumed by the caller as a validation failure,
48
- * exit 1). Does not write — the caller appends only after this returns.
49
- *
50
- * @param {object} validated — already validated session entry
51
- * @returns {string} the verified JSONL line (newline-terminated)
52
- * @throws {ValidationError} when the serialized line does not round-trip
53
- */
54
- export function serializeSessionLineChecked(validated) {
55
- let line;
56
- try {
57
- line = JSON.stringify(validated);
58
- } catch (err) {
59
- throw new ValidationError(`session is not JSON-serializable: ${err.message}`);
60
- }
61
- if (typeof line !== 'string' || line.length === 0) {
62
- throw new ValidationError('session serialized to an empty line');
63
- }
64
- let reparsed;
65
- try {
66
- reparsed = JSON.parse(line);
67
- } catch (err) {
68
- throw new ValidationError(
69
- `serialized session line does not parse back as JSON: ${err.message}`
70
- );
71
- }
72
- // Re-validate the round-tripped shape — catches required fields that were
73
- // present as `undefined`/`NaN` before stringify but vanished after.
74
- validateSession(reparsed);
75
- return line + '\n';
76
- }
39
+ export { serializeSessionLineChecked };
77
40
 
78
41
  function parseArgs(argv) {
79
42
  const args = { file: '.orchestrator/metrics/sessions.jsonl', entry: null };
@@ -167,7 +130,7 @@ async function main() {
167
130
  // sessions.jsonl and only surface on the NEXT session's read. Treated as a
168
131
  // validation failure (exit 1); file is left untouched.
169
132
  try {
170
- serializeSessionLineChecked(validated);
133
+ serializeSessionLineChecked(repaired);
171
134
  } catch (err) {
172
135
  if (err instanceof ValidationError) {
173
136
  process.stderr.write(
@@ -450,13 +450,27 @@ export function tokenizeCommand(command) {
450
450
  if (ch === '\n' && pendingHeredocs.length > 0) {
451
451
  flush();
452
452
  let j = i + 1;
453
+ let lastTerm = false;
453
454
  while (pendingHeredocs.length > 0) {
454
455
  const { delim, stripTabs } = pendingHeredocs.shift();
455
456
  const { body, end, terminated } = readHeredocBody(command, j, delim, stripTabs);
456
- if (!terminated) { pendingHeredocs.length = 0; break; }
457
+ // The reset is load-bearing: a first here-doc may terminate (lastTerm
458
+ // true) while a SECOND one on the same line does not — leaving lastTerm
459
+ // set would rewind onto the unterminated body's first char instead of a
460
+ // real newline (#999).
461
+ if (!terminated) { lastTerm = false; pendingHeredocs.length = 0; break; }
462
+ lastTerm = true;
457
463
  if (body.length > 0) tokens.push({ text: body, quoted: true });
458
464
  j = end;
459
465
  }
466
+ // `readHeredocBody` returns `end` pointing PAST the newline that closes the
467
+ // terminator line, so `j` sits at the first char of the NEXT command and
468
+ // the separating newline is swallowed. Rewind onto it (when there is one —
469
+ // a terminator at input-end has none) so the newline reaches the separator
470
+ // branch below and `cat <<EOF\nbody\nEOF\nrm -rf /tmp/ok` splits into two
471
+ // segments. Without this the trailing command glued into the here-doc
472
+ // verb's segment and an allowlisted rm target failed closed (#999 FP).
473
+ if (lastTerm && command[j - 1] === '\n') j -= 1;
460
474
  i = j - 1;
461
475
  continue;
462
476
  }
@@ -790,10 +804,25 @@ export const WRAPPER_UNWRAP = new Map([
790
804
  * Return contract (ADDITIVE — `wrapperArgs` was appended in #988 T3; existing
791
805
  * consumers destructuring `{ verb, index, payloads }` are unaffected):
792
806
  *
807
+ * DUAL PARSE (#1000). An unknown dash-flag is ambiguous: this table cannot know
808
+ * whether `env -Q x bash -c '…'` means "`-Q` is a boolean, `x` is the verb" or
809
+ * "`-Q` takes `x`, `bash` is the verb". The resolver therefore reads the
810
+ * segment TWICE — parse A treats unknown flags as booleans (byte-identical to
811
+ * the pre-#1000 behaviour, and still the primary result), parse B treats them
812
+ * as value-taking — and reports the second reading as `alt` when it disagrees.
813
+ * Both readings are then judged, so a deny in EITHER is a deny: the safe
814
+ * direction, since guessing wrong in the boolean direction silently hid an
815
+ * interpreter behind an unrecognised flag (the measured #1000 bypass).
816
+ * `alt` is present ONLY when an unknown flag was skipped AND the two readings
817
+ * resolve a different verb or index; it is never nested (no `alt.alt`).
818
+ *
793
819
  * @param {Array<{ text: string, quoted: boolean }>} segment
794
820
  * @returns {{ verb: string|null, index: number, payloads: string[],
795
821
  * wrapperArgs: Array<{ wrapper: string, flag: string, value: string|null,
796
- * writesFile?: true }> }}
822
+ * writesFile?: true }>,
823
+ * alt?: { verb: string|null, index: number, payloads: string[],
824
+ * wrapperArgs: Array<{ wrapper: string, flag: string,
825
+ * value: string|null, writesFile?: true }> } }}
797
826
  * verb — bare program basename (or synthetic `sh` for `sudo -i`/`-s`), null
798
827
  * when the segment exhausts in wrappers; index — token index of the resolved
799
828
  * verb (-1 when null); payloads — command strings a wrapper will execute;
@@ -810,15 +839,23 @@ export const WRAPPER_UNWRAP = new Map([
810
839
  * the answer to the question the caller actually has ("is this operand a
811
840
  * write target?"), which used to be re-derived from a second table
812
841
  * (`WRAPPER_FILE_FLAGS` in hooks/pre-bash-sessions-ledger-guard.mjs). That
813
- * copy is now REDUNDANT and can be replaced by `wa.writesFile` (#991
814
- * follow-up) — the knowledge lives here, next to the grammar it belongs to.
842
+ * copy was replaced by `wa.writesFile` in #996.1 — the knowledge lives here,
843
+ * next to the grammar it belongs to.
815
844
  * `/usr/bin/time -o <ledger> npm test` truncates `<ledger>` while the verb is
816
845
  * `npm`; extractRedirectTargets surfaces exactly these entries so the
817
846
  * redirect denylist sees them too.
847
+ *
848
+ * `alt` (#1000) — the value-taking reading of the same segment, present only
849
+ * under the conditions above. Consumers that judge (matchSegments,
850
+ * collectRedirectTargets) must consider BOTH readings; consumers that need a
851
+ * single token position (`parseRmTargets`, the scope gate) keep reading the
852
+ * primary `verb`/`index` only — `alt.index` addresses a DIFFERENT reading of
853
+ * the token stream and must never be fed to a positional walk.
818
854
  */
819
- export function resolveSegmentVerb(segment) {
855
+ function resolveCore(segment, unknownFlagsTakeValue) {
820
856
  const payloads = [];
821
857
  const wrapperArgs = [];
858
+ let sawUnknownFlag = false;
822
859
  let i = 0;
823
860
  // Skip leading FOO=bar env assignments (unquoted).
824
861
  while (i < segment.length && !segment[i].quoted && ENV_ASSIGN_RE.test(segment[i].text)) {
@@ -875,13 +912,48 @@ export function resolveSegmentVerb(segment) {
875
912
  wrapperArgs.push(entry);
876
913
  }
877
914
  }
878
- i++; // unknown / boolean / attached-value flag one token
915
+ // Unknown / boolean / attached-value flag. A dash token that survived
916
+ // envAssignments, `--`, the non-dash break, splitString, shellFlags,
917
+ // argFlags AND the attached-`=` form is one this table does not know —
918
+ // the ONLY place the two readings differ (#1000). No wrapperArgs entry is
919
+ // recorded in the value-taking reading: an unknown flag is by
920
+ // construction absent from fileArgFlags, so `writesFile` can never be
921
+ // invented for it.
922
+ sawUnknownFlag = true;
923
+ i += (unknownFlagsTakeValue && i + 1 < segment.length) ? 2 : 1;
879
924
  }
880
925
  for (let p = spec.positionals ?? 0; p > 0 && i < segment.length; p--) i++;
881
- if (sawShellFlag) return { verb: 'sh', index: i, payloads, wrapperArgs };
926
+ if (sawShellFlag) return { verb: 'sh', index: i, payloads, wrapperArgs, sawUnknownFlag };
882
927
  }
883
- if (i >= segment.length) return { verb: null, index: -1, payloads, wrapperArgs };
884
- return { verb: segment[i].text.replace(/^.*\//, ''), index: i, payloads, wrapperArgs };
928
+ if (i >= segment.length) return { verb: null, index: -1, payloads, wrapperArgs, sawUnknownFlag };
929
+ return {
930
+ verb: segment[i].text.replace(/^.*\//, ''),
931
+ index: i,
932
+ payloads,
933
+ wrapperArgs,
934
+ sawUnknownFlag,
935
+ };
936
+ }
937
+
938
+ /**
939
+ * Strip the internal `sawUnknownFlag` marker from a resolveCore result, leaving
940
+ * the public shape. The key must be ABSENT (not undefined-valued) so a strict
941
+ * `toEqual` on an unambiguous resolution keeps passing.
942
+ *
943
+ * @param {{ verb: string|null, index: number, payloads: string[],
944
+ * wrapperArgs: object[], sawUnknownFlag: boolean }} r
945
+ * @returns {{ verb: string|null, index: number, payloads: string[], wrapperArgs: object[] }}
946
+ */
947
+ function stripCore(r) {
948
+ return { verb: r.verb, index: r.index, payloads: r.payloads, wrapperArgs: r.wrapperArgs };
949
+ }
950
+
951
+ export function resolveSegmentVerb(segment) {
952
+ const a = resolveCore(segment, false); // parse A — byte-identical to pre-#1000
953
+ if (!a.sawUnknownFlag) return stripCore(a); // unambiguous → no `alt` key at all
954
+ const b = resolveCore(segment, true); // parse B — unknown flags take a value
955
+ if (b.verb === a.verb && b.index === a.index) return stripCore(a);
956
+ return { ...stripCore(a), alt: stripCore(b) };
885
957
  }
886
958
 
887
959
  /**
@@ -938,6 +1010,45 @@ function quotedTokensMatch(segment, re) {
938
1010
  return false;
939
1011
  }
940
1012
 
1013
+ /**
1014
+ * How far a redirect token's syntax reaches: the index of the LAST token this
1015
+ * redirect owns, starting at the redirect token itself (#1002).
1016
+ *
1017
+ * The grammar rule, stated ONCE (it was coded three times independently before
1018
+ * this export existed):
1019
+ * - `dup` (`2>&1`) carries its target INLINE in the operator token — it owns
1020
+ * no following word.
1021
+ * - `heredoc` (`<<EOF`) consumes its delimiter as SYNTAX inside the lexer. A
1022
+ * terminated body arrives as a QUOTED token; an UNTERMINATED here-doc
1023
+ * leaves real command tokens behind, which must stay visible to the caller
1024
+ * — skipping a word here would eat the next real command (#970).
1025
+ * - EVERY other mode — `truncate` (`>`), `append` (`>>`), `read` (`<`) and
1026
+ * `herestring` (`<<<`) — owns the next word. `herestring` deliberately so:
1027
+ * in `rm -rf /tmp/x <<< /etc/passwd` the word after `<<<` is inline data
1028
+ * for the redirect, not an `rm` operand, and reading it as one would
1029
+ * invent a deletion target the command never had.
1030
+ * - A next token that is ITSELF a redirect is never an operand (`> >> x`):
1031
+ * the dangling redirect owns nothing.
1032
+ *
1033
+ * This does NOT answer "does this redirect name a filesystem target" — that is
1034
+ * a SEPARATE rule, owned by collectRedirectTargets, which additionally excludes
1035
+ * `herestring` (inline data names no file). Operand OWNERSHIP and target
1036
+ * REPORTABILITY are different questions with different answers for `<<<`.
1037
+ *
1038
+ * @param {Array<{ text: string, quoted: boolean, redirect?: { mode: string } }>} segment
1039
+ * @param {number} i — index of the redirect token
1040
+ * @returns {number} `i` when the redirect owns no operand word, else `i + 1`
1041
+ */
1042
+ export function redirectSpanEnd(segment, i) {
1043
+ const tok = segment[i];
1044
+ if (!tok || !tok.redirect) return i;
1045
+ const hasOperandWord = tok.redirect.mode !== 'dup' && tok.redirect.mode !== 'heredoc';
1046
+ if (hasOperandWord && i + 1 < segment.length && !segment[i + 1].redirect) {
1047
+ return i + 1;
1048
+ }
1049
+ return i;
1050
+ }
1051
+
941
1052
  /**
942
1053
  * Test whether a blocked pattern occurs OUTSIDE quoted tokens within a segment.
943
1054
  * Reconstructs the unquoted skeleton (quoted tokens replaced by a single space
@@ -961,14 +1072,10 @@ function unquotedSegmentMatch(segment, re) {
961
1072
  const tok = segment[i];
962
1073
  if (tok.redirect) {
963
1074
  parts.push(' ');
964
- // `dup` (2>&1) has its target inline; `heredoc` consumes its delimiter in
965
- // the lexer and its body arrives as a separate QUOTED token — neither has
966
- // an operand word to skip, and skipping would eat the next real command.
967
- const hasOperandWord = tok.redirect.mode !== 'dup' && tok.redirect.mode !== 'heredoc';
968
- if (hasOperandWord && i + 1 < segment.length && !segment[i + 1].redirect) {
969
- parts.push(' ');
970
- i++; // operand word belongs to the redirect — skip it too
971
- }
1075
+ // Operand-span rule lives in redirectSpanEnd (#1002) one grammar, one
1076
+ // place. `end > i` is exactly the old inline predicate.
1077
+ const end = redirectSpanEnd(segment, i);
1078
+ if (end > i) { parts.push(' '); i = end; } // operand word belongs to the redirect
972
1079
  continue;
973
1080
  }
974
1081
  parts.push(tok.quoted ? ' ' : tok.text);
@@ -985,6 +1092,42 @@ function unquotedSegmentMatch(segment, re) {
985
1092
  const MAX_PAYLOAD_DEPTH = 3;
986
1093
  const MAX_PAYLOAD_EVALUATIONS = 32;
987
1094
 
1095
+ /**
1096
+ * Build the DE-DUPLICATED payload set for a segment: the UNION of the primary
1097
+ * reading's payloads and the ambiguous alt reading's payloads (#1000).
1098
+ *
1099
+ * The dual-parse union MUST be de-duped BEFORE budget accounting so an ambiguous
1100
+ * segment is charged against the shared MAX_PAYLOAD_EVALUATIONS budget ONCE per
1101
+ * DISTINCT payload — never the (primary + alt) sum (HIGH-1 remediation (a)). The
1102
+ * pre-#1000 zero-charge for a non-interpreter primary verb is preserved
1103
+ * naturally: when the alt reading adds no new distinct payload the set stays
1104
+ * empty and no budget is consumed.
1105
+ *
1106
+ * Both matchSegments AND collectRedirectTargets consume this single helper so the
1107
+ * union is applied IDENTICALLY on the match surface and the redirect surface. A
1108
+ * redirect-recursion that walked parse A only was a denylist bypass
1109
+ * (`env -Q x bash -c 'echo pwned > CLAUDE.md'` resolved to non-interpreter `x` in
1110
+ * parse A, so its redirect target was never collected — HIGH-2).
1111
+ *
1112
+ * @param {Array<{ text: string, quoted: boolean }>} segment
1113
+ * @param {{ verb: string|null, index: number, payloads: string[],
1114
+ * alt?: { verb: string|null, index: number, payloads: string[] } }} resolved
1115
+ * @returns {string[]} distinct payload strings (insertion-ordered)
1116
+ */
1117
+ function dedupedSegmentPayloads(segment, resolved) {
1118
+ const payloadSet = new Set(resolved.payloads);
1119
+ if (resolved.verb && DASH_C_SHELLS.has(resolved.verb)) {
1120
+ for (const p of dashCPayloads(segment, resolved.index)) payloadSet.add(p);
1121
+ }
1122
+ if (resolved.alt) {
1123
+ for (const p of resolved.alt.payloads) payloadSet.add(p);
1124
+ if (resolved.alt.verb && DASH_C_SHELLS.has(resolved.alt.verb)) {
1125
+ for (const p of dashCPayloads(segment, resolved.alt.index)) payloadSet.add(p);
1126
+ }
1127
+ }
1128
+ return [...payloadSet];
1129
+ }
1130
+
988
1131
  /**
989
1132
  * Match a blocked-pattern regex against tokenized chain segments — the shared
990
1133
  * core of commandMatchesBlocked, recursion-capable for `-c` payloads (#982).
@@ -1012,21 +1155,35 @@ function matchSegments(segments, re, depth, budget) {
1012
1155
  const resolved = resolveSegmentVerb(segment);
1013
1156
 
1014
1157
  // 2) Quoted occurrence → only a match when the segment verb is an interpreter
1015
- // that executes its quoted payload.
1158
+ // that executes its quoted payload. EITHER reading of an ambiguous
1159
+ // unknown flag counts (#1000): `env -Q x bash -c 'rm -rf /etc'` resolves
1160
+ // to the non-interpreter `x` in parse A and to `bash` in parse B.
1016
1161
  if (quotedTokensMatch(segment, re)) {
1017
- if (resolved.verb && SHELL_EXEC_INTERPRETERS.has(resolved.verb)) return true;
1162
+ const isInterp = (v) => Boolean(v) && SHELL_EXEC_INTERPRETERS.has(v);
1163
+ if (isInterp(resolved.verb) || isInterp(resolved.alt?.verb)) return true;
1018
1164
  // else: inert literal inside quotes for a non-interpreter verb → no match
1019
1165
  // for THIS segment; keep scanning other segments.
1020
1166
  }
1021
1167
 
1022
- // 3) `-c`/`env -S` payload recursion (depth-capped, budgeted).
1168
+ // 3) `-c`/`env -S` payload recursion (depth-capped, budgeted). The payload
1169
+ // set is the de-duplicated UNION over both readings (dedupedSegmentPayloads)
1170
+ // so an ambiguous segment cannot double-charge the shared evaluation budget.
1023
1171
  if (depth < MAX_PAYLOAD_DEPTH) {
1024
- const payloads = resolved.payloads;
1025
- if (resolved.verb && DASH_C_SHELLS.has(resolved.verb)) {
1026
- payloads.push(...dashCPayloads(segment, resolved.index));
1027
- }
1028
- for (const payload of payloads) {
1029
- if (budget.remaining <= 0) break;
1172
+ for (const payload of dedupedSegmentPayloads(segment, resolved)) {
1173
+ // FAIL-VISIBLE cut-off (HIGH-1 remediation (b)). A silent `break` here
1174
+ // was fail-OPEN: an attacker prepends 32 inert `env -Q x sh -c 'echo N'`
1175
+ // filler segments — each charging one alt-reading payload against the
1176
+ // shared 32-eval budget (#1000) — then the real deny-capable payload
1177
+ // (`env -S 'rm -rf /'`) arrives with budget exhausted and the old `break`
1178
+ // returned false → ALLOW (coordinator-measured deny-loss vs 1be450a).
1179
+ // This matcher returns a boolean with no unresolved channel, so an
1180
+ // UNJUDGED deny-capable payload is treated as a MATCH (fail-closed),
1181
+ // mirroring collectRedirectTargets' budget-exhausted marker (#988 T2).
1182
+ // A realistic benign command never approaches the budget (depth-capped
1183
+ // at 3); only pathological width reaches exhaustion, and denying a
1184
+ // command that also carries a blocked pattern in the raw string is the
1185
+ // safe direction.
1186
+ if (budget.remaining <= 0) return true;
1030
1187
  budget.remaining -= 1;
1031
1188
  const subTokens = tokenizeCommand(
1032
1189
  normalizeShellWhitespaceExpansions(payload, { expandSingleQuoted: true }),
@@ -1057,11 +1214,15 @@ function collectRedirectTargets(segments, out, depth, budget) {
1057
1214
  // Deliberate boundary: only file-operand modes are reported. `dup`
1058
1215
  // (`2>&1`) targets a file descriptor, `heredoc`/`herestring` operands
1059
1216
  // are inline data/delimiters — none names a filesystem target.
1217
+ // NOTE: this mode filter is a SEPARATE rule from operand ownership
1218
+ // (redirectSpanEnd) — `herestring` OWNS its next word but names no file,
1219
+ // so it is excluded here and included there. Do not merge the two.
1060
1220
  if (mode === 'dup' || mode === 'heredoc' || mode === 'herestring') continue;
1061
- const operand = (i + 1 < segment.length && !segment[i + 1].redirect)
1062
- ? segment[i + 1]
1063
- : null;
1064
- if (operand) i++; // operand word belongs to this redirect
1221
+ // Past the filter `hasOperandWord` is unconditionally true, so the shared
1222
+ // span rule reduces to the old inline predicate (#1002).
1223
+ const end = redirectSpanEnd(segment, i);
1224
+ const operand = end > i ? segment[end] : null;
1225
+ if (operand) i = end; // operand word belongs to this redirect
1065
1226
  if (!operand || /[$`]/.test(operand.text)) {
1066
1227
  // Variable indirection (`> "$X"`), command substitution (`> $(cmd)` /
1067
1228
  // backticks), or a missing operand: fail-visible, never guess (#983).
@@ -1088,9 +1249,25 @@ function collectRedirectTargets(segments, out, depth, budget) {
1088
1249
  // consumes — to buy back a false-positive class that is empty in practice
1089
1250
  // (nobody appends a timing report to a policy-protected file). An
1090
1251
  // under-report here is a bypass; this over-report is a nuisance at worst.
1091
- for (const wa of resolved.wrapperArgs) {
1252
+ //
1253
+ // Both readings of an ambiguous unknown flag contribute (#1000), UNIONED
1254
+ // and never replaced: `env -Q x /usr/bin/time -o CLAUDE.md npm test` hides
1255
+ // the operand from parse A (which reads `x` as the verb) and surfaces it in
1256
+ // parse B, while `sudo -n /usr/bin/time -o report.txt npm test` is the
1257
+ // mirror case — parse B swallows `/usr/bin/time` as `-n`'s operand and
1258
+ // reports nothing. Replacement would lose one of the two.
1259
+ const seenWrapperTargets = new Set();
1260
+ const altWrapperArgs = resolved.alt ? resolved.alt.wrapperArgs : [];
1261
+ for (const wa of [...resolved.wrapperArgs, ...altWrapperArgs]) {
1092
1262
  if (wa.writesFile !== true) continue;
1093
1263
  if (typeof wa.value !== 'string') continue;
1264
+ // De-dup key is the OPERAND plus the mode, which for a resolved entry is
1265
+ // exactly `target + ':' + mode`; using the operand keeps two distinct
1266
+ // unresolved spellings (`"$OUT"` vs `"$X"`) distinct rather than
1267
+ // collapsing them onto a shared `null` target.
1268
+ const key = `${wa.value}:truncate`;
1269
+ if (seenWrapperTargets.has(key)) continue;
1270
+ seenWrapperTargets.add(key);
1094
1271
  if (/[$`]/.test(wa.value)) {
1095
1272
  // Same fail-visible rule as a redirect operand (#983): never guess at a
1096
1273
  // variable or a command substitution, but never silently drop it either.
@@ -1100,10 +1277,15 @@ function collectRedirectTargets(segments, out, depth, budget) {
1100
1277
  out.push({ target: wa.value, mode: 'truncate', fd: null });
1101
1278
  }
1102
1279
 
1103
- const payloads = [...resolved.payloads];
1104
- if (resolved.verb && DASH_C_SHELLS.has(resolved.verb)) {
1105
- payloads.push(...dashCPayloads(segment, resolved.index));
1106
- }
1280
+ // Both readings of an ambiguous unknown flag contribute payloads (#1000),
1281
+ // UNIONED and de-duped (HIGH-2). Walking parse A only let
1282
+ // `env -Q x bash -c 'echo pwned > CLAUDE.md'` — which resolves to the
1283
+ // non-interpreter `x` in parse A — bypass the redirect denylist entirely
1284
+ // (coordinator-measured: `bash -c '… > CLAUDE.md'` DENY, `env -Q x bash -c
1285
+ // '… > CLAUDE.md'` ALLOW). The same helper matchSegments uses guarantees the
1286
+ // deduped-before-charging rule holds on this surface too, so the redirect
1287
+ // recursion cannot be starved any differently than the match recursion.
1288
+ const payloads = dedupedSegmentPayloads(segment, resolved);
1107
1289
  if (payloads.length === 0) continue;
1108
1290
 
1109
1291
  // A cap that drops payloads SILENTLY is a bypass, not a cap: 33 filler
@@ -1181,6 +1363,16 @@ export function extractRedirectTargets(command) {
1181
1363
  * duplicated locally because this module is hook-hot-path pure (no imports
1182
1364
  * beyond node built-ins, no I/O at import time; see header invariant).
1183
1365
  *
1366
+ * CASE-INSENSITIVE (#994 R2): the returned RegExp carries the `i` flag
1367
+ * UNCONDITIONALLY — no platform detection. Detection is unsafe in both
1368
+ * directions (macOS can run case-sensitive APFS; Linux can mount
1369
+ * case-insensitive volumes via ext4 casefold / ciopfs / CIFS), and a correct
1370
+ * per-path answer would need filesystem I/O this module forbids (header
1371
+ * invariant). The cost is asymmetric: a MISS truncates CLAUDE.md
1372
+ * unrecoverably, whereas an over-block is a visible deny with two escapes
1373
+ * (`>>`, the Write tool). So `> claude.md` / `> Claude.md` — the same inode as
1374
+ * `CLAUDE.md` on a case-insensitive volume — deny like the canonical spelling.
1375
+ *
1184
1376
  * @param {string} pattern
1185
1377
  * @returns {RegExp}
1186
1378
  */
@@ -1208,26 +1400,40 @@ function redirectGlobToRegExp(pattern) {
1208
1400
  i++;
1209
1401
  }
1210
1402
  }
1211
- return new RegExp(`^${re}$`);
1403
+ return new RegExp(`^${re}$`, 'i');
1212
1404
  }
1213
1405
 
1214
1406
  /**
1215
- * Collapse the macOS `/private` alias prefix: `/private/tmp` and `/private/var`
1216
- * name the SAME directories as `/tmp` and `/var` (the short forms are symlinks
1217
- * into `/private`). Without this, one location has two spellings that compare
1218
- * unequala repo checked out under `/tmp/...` (CI runners, worktrees) would
1219
- * not recognise its own root in a command that spells it `/private/tmp/...`.
1407
+ * Collapse the macOS same-inode path aliases so one location has one spelling.
1408
+ *
1409
+ * Two independent alias classes, applied in order:
1410
+ * 1. `/System/Volumes/Data/...`the firmlink onto the Data volume: on APFS
1411
+ * `/System/Volumes/Data/repo` and `/repo` are the SAME inode. Stripped
1412
+ * FIRST so a `/System/Volumes/Data/private/tmp/...` spelling then also runs
1413
+ * through the `/private` strip below.
1414
+ * 2. `/private/{tmp,var,etc}/...` — the short forms `/tmp`, `/var`, `/etc` are
1415
+ * symlinks into `/private`. Without this, a repo checked out under
1416
+ * `/tmp/...` (CI runners, worktrees) would not recognise its own root in a
1417
+ * command that spells it `/private/tmp/...`.
1418
+ *
1419
+ * A strip that would empty the path (`/System/Volumes/Data` itself) is guarded
1420
+ * with `|| '/'` so the result stays an absolute path.
1220
1421
  *
1221
1422
  * Deliberately STATIC: no `realpathSync` on user input. Resolving an
1222
1423
  * attacker-supplied path at guard time is its own risk class, and this module
1223
- * is I/O-free by header invariant. Only the two known macOS aliases collapse;
1224
- * every other path is returned byte-identical.
1424
+ * is I/O-free by header invariant. Only the known aliases collapse; every other
1425
+ * path is returned byte-identical.
1225
1426
  *
1226
1427
  * @param {string} p — an absolute, already-normalized path
1227
1428
  * @returns {string}
1228
1429
  */
1229
- function stripPrivateAlias(p) {
1230
- return /^\/private\/(?:tmp|var)(?:\/|$)/.test(p) ? p.slice('/private'.length) : p;
1430
+ function stripPathAliases(p) {
1431
+ const dataStripped =
1432
+ (/^\/System\/Volumes\/Data(?:\/|$)/.test(p) ? p.slice('/System/Volumes/Data'.length) : p) ||
1433
+ '/';
1434
+ return /^\/private\/(?:tmp|var|etc)(?:\/|$)/.test(dataStripped)
1435
+ ? dataStripped.slice('/private'.length) || '/'
1436
+ : dataStripped;
1231
1437
  }
1232
1438
 
1233
1439
  /**
@@ -1258,10 +1464,55 @@ function expandLeadingHome(target, home) {
1258
1464
  return home + target.slice(1);
1259
1465
  }
1260
1466
 
1467
+ /**
1468
+ * Relativize an ABSOLUTE target against an absolute repoRoot to the repo-relative
1469
+ * POSIX form the denylist globs use, or `null` when the target names the root
1470
+ * itself or lies outside it.
1471
+ *
1472
+ * Both paths are alias-collapsed (`stripPathAliases`) and case-folded before the
1473
+ * containment comparison (#994 R3): a case-insensitive volume makes `/REPO` and
1474
+ * `/repo` the same directory, so the comparison that decides in-vs-out must fold
1475
+ * too — otherwise an absolute spelling in the wrong case escapes the root and is
1476
+ * silently allowed. Folding uses `toLocaleLowerCase('en-US')` explicitly (same
1477
+ * rationale as scripts/lib/path-utils.mjs:46 — avoids the Turkish-İ divergence a
1478
+ * locale-default `toLowerCase` carries). The returned path is therefore
1479
+ * lowercased; it is matched against the denylist regexes, which carry the `i`
1480
+ * flag (#994 R2), so the fold and the match agree.
1481
+ *
1482
+ * Shared by BOTH branches of `repoRelativeRedirectTarget` (absolute + relative)
1483
+ * so the two can never drift again (#994 R1 structural fix).
1484
+ *
1485
+ * @param {string} absTarget — absolute, resolved target path
1486
+ * @param {string} repoRoot — absolute repo root
1487
+ * @returns {string|null}
1488
+ */
1489
+ function relativizeAgainstRoot(absTarget, repoRoot) {
1490
+ const fold = (p) => p.toLocaleLowerCase('en-US');
1491
+ const rel = path.relative(
1492
+ fold(stripPathAliases(path.normalize(repoRoot))),
1493
+ fold(stripPathAliases(path.normalize(absTarget))),
1494
+ );
1495
+ // '' = the root itself (a directory, not a file target); '..'-prefixed or
1496
+ // absolute = outside the repo, which the repo-relative denylist never covers.
1497
+ if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
1498
+ return null;
1499
+ }
1500
+ return path.posix.normalize(rel).replace(/^(\.\/)+/, '');
1501
+ }
1502
+
1261
1503
  /**
1262
1504
  * Reduce a raw redirect target to the repo-relative POSIX form the denylist
1263
1505
  * globs are written in, or `null` when it cannot name a file inside the repo.
1264
1506
  *
1507
+ * A RELATIVE target (#994 R1) is interpreted as REPO-ROOT-relative, not
1508
+ * shell-cwd-relative: after lexical normalization, a target that stays inside
1509
+ * the root (no leading `..`) is already the repo-relative form and returned
1510
+ * as-is; a target that climbs out (`../repo/CLAUDE.md`, `./a/../../repo/…`) is
1511
+ * resolved against `repoRoot` and re-relativized through the SAME
1512
+ * `relativizeAgainstRoot` the absolute branch uses. Without a usable `repoRoot`
1513
+ * the pre-#994 lexical form is returned verbatim — byte-identical for
1514
+ * optionless callers.
1515
+ *
1265
1516
  * @param {string} raw — resolved target text (quotes already stripped)
1266
1517
  * @param {string|null} repoRoot — absolute repo root, or null (no resolution)
1267
1518
  * @param {string|undefined} home
@@ -1271,21 +1522,20 @@ function repoRelativeRedirectTarget(raw, repoRoot, home) {
1271
1522
  const expanded = expandLeadingHome(raw, home);
1272
1523
 
1273
1524
  if (!path.isAbsolute(expanded)) {
1274
- return path.posix.normalize(expanded).replace(/^(\.\/)+/, '');
1525
+ const lexical = path.posix.normalize(expanded).replace(/^(\.\/)+/, '');
1526
+ // Post-normalization, a leading `..` is the ONLY way a relative target
1527
+ // leaves the root; anything else is already the repo-relative form (this
1528
+ // also preserves the `~other/...` another-account spelling untouched).
1529
+ if (!lexical.startsWith('../')) return lexical;
1530
+ // A relative target that climbs out — resolve it against repoRoot. Without a
1531
+ // usable root, return the pre-#994 lexical form (byte-identical contract).
1532
+ if (!repoRoot || !path.isAbsolute(repoRoot)) return lexical;
1533
+ return relativizeAgainstRoot(path.resolve(repoRoot, expanded), repoRoot);
1275
1534
  }
1276
1535
  // Absolute target: only judgeable against a known repo root. Without one the
1277
1536
  // pre-#988 behaviour stands (no match) rather than a guess.
1278
1537
  if (!repoRoot || !path.isAbsolute(repoRoot)) return null;
1279
- const rel = path.relative(
1280
- stripPrivateAlias(path.normalize(repoRoot)),
1281
- stripPrivateAlias(path.normalize(expanded)),
1282
- );
1283
- // '' = the root itself (a directory, not a file target); '..'-prefixed or
1284
- // absolute = outside the repo, which the repo-relative denylist never covers.
1285
- if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
1286
- return null;
1287
- }
1288
- return path.posix.normalize(rel).replace(/^(\.\/)+/, '');
1538
+ return relativizeAgainstRoot(expanded, repoRoot);
1289
1539
  }
1290
1540
 
1291
1541
  /**
@@ -1304,12 +1554,22 @@ function repoRelativeRedirectTarget(raw, repoRoot, home) {
1304
1554
  * so `> /abs/path/to/repo/CLAUDE.md` and `> ~/repo/CLAUDE.md` matched NOTHING
1305
1555
  * and were silently allowed (probe-measured `rule abs: false`, `rule tilde:
1306
1556
  * false` against `rule rel: true`). Pass `{ repoRoot }` and such a target is
1307
- * tilde-expanded, `/private`-alias-collapsed and made repo-relative before the
1308
- * globs run; a target outside the repo yields no match. WITHOUT `repoRoot`
1309
- * (the default) an absolute target still never matches — identical to the
1310
- * pre-#988 contract, so existing callers keep their exact behaviour. This
1311
- * function stays I/O-free: `~` resolves from `process.env.HOME` (overridable
1312
- * via `home`), never via a filesystem lookup.
1557
+ * tilde-expanded, alias-collapsed and made repo-relative before the globs run;
1558
+ * a target outside the repo yields no match. WITHOUT `repoRoot` (the default)
1559
+ * an absolute target still never matches — identical to the pre-#988 contract,
1560
+ * so existing callers keep their exact behaviour. This function stays I/O-free:
1561
+ * `~` resolves from `process.env.HOME` (overridable via `home`), never via a
1562
+ * filesystem lookup.
1563
+ *
1564
+ * Relative spellings and case (#994). A RELATIVE target is interpreted as
1565
+ * REPO-ROOT-relative, NOT shell-cwd-relative: one that climbs out with `..`
1566
+ * (`> ../repo/CLAUDE.md`) is resolved against `repoRoot` and re-relativized, so
1567
+ * it is caught when it lands back inside the root and allowed when it does not
1568
+ * (without `repoRoot`, such a target keeps its pre-#994 lexical form). The
1569
+ * denylist globs and the absolute-containment comparison are BOTH
1570
+ * case-insensitive, because a case-insensitive volume makes `> claude.md` the
1571
+ * same inode as `CLAUDE.md`; the alias-collapse also covers `/System/Volumes/Data`
1572
+ * and `/private/etc` alongside `/private/{tmp,var}`.
1313
1573
  *
1314
1574
  * Deliberate boundary (#641 FP class): `unresolved: true` entries (variable
1315
1575
  * indirection, command substitution) are NEVER matched — blocking on a guess
@@ -3,18 +3,21 @@
3
3
  *
4
4
  * The original 691-line module bundled THREE structurally-independent concerns.
5
5
  * They now live in dedicated modules (Epic A4 split); this file is a pure
6
- * re-export barrel that preserves the original import surface (all 11 symbols)
6
+ * re-export barrel that preserves the surviving import surface (9 symbols)
7
7
  * so existing importers keep working UNCHANGED:
8
- * - hooks/enforce-commands.mjs, hooks/enforce-scope.mjs,
9
- * hooks/post-edit-validate.mjs, hooks/pre-bash-destructive-guard.mjs,
8
+ * - hooks/enforce-commands.mjs, hooks/enforce-scope.mjs (both bind the barrel
9
+ * via armGuard), hooks/post-bash-write-verify.mjs, hooks/post-edit-validate.mjs,
10
10
  * hooks/pre-bash-memory-propose-audit.mjs, hooks/wave-scope-commit-guard.mjs
11
- * - scripts/lib/io.mjs, scripts/lib/pre-dispatch-check.mjs,
12
- * scripts/lib/worktree-freshness.mjs
11
+ * - scripts/lib/pre-dispatch-check.mjs, scripts/lib/worktree-freshness.mjs
13
12
  *
14
13
  * Concern split:
15
14
  * A) Env / runtime checks → ./env-check.mjs
16
15
  * B) Scope / pattern primitives → ./scope-gate.mjs
17
- * C) Command-blocking tokenizer + matcher → ./command-blocker.mjs
16
+ * C) Command-blocking tokenizer + matcher → ./command-blocker.mjs. The
17
+ * command-blocker primitives (tokenizeCommand, commandMatchesBlocked,
18
+ * suggestForCommandBlock) were re-exported through this barrel until #996.3;
19
+ * consumers now import them DIRECTLY from ./command-blocker.mjs, so this
20
+ * barrel no longer touches concern C.
18
21
  *
19
22
  * The new modules MUST NOT import from this barrel (would cycle).
20
23
  *
@@ -38,6 +41,3 @@ export {
38
41
  extractBashWriteTargets,
39
42
  suggestForScopeViolation,
40
43
  } from './scope-gate.mjs';
41
-
42
- // C) Command-blocking tokenizer + matcher
43
- export { tokenizeCommand, commandMatchesBlocked, suggestForCommandBlock } from './command-blocker.mjs';