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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +9 -9
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +1 -1
- package/docs/instruction-delivery.md +350 -0
- package/docs/session-config-reference.md +1 -41
- package/docs/session-config-template.md +0 -23
- package/hooks/_lib/guard-source-loader.mjs +304 -91
- package/hooks/enforce-commands.mjs +216 -17
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +7 -4
- package/hooks/pre-bash-destructive-guard.mjs +146 -59
- package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
- package/package.json +2 -2
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/emit-session.mjs +3 -40
- package/scripts/lib/command-blocker.mjs +322 -62
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/reconcile/emitter.mjs +107 -22
- package/scripts/lib/reconcile/engine.mjs +9 -15
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/scope-gate.mjs +194 -72
- package/scripts/lib/session-close-backfill.mjs +2 -2
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +31 -5
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/print-applicable-rules.mjs +170 -7
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/skills/brainstorm/soul.md +47 -1
- package/skills/evolve/SKILL.md +116 -18
- package/skills/gitlab-ops/SKILL.md +5 -0
- package/skills/grill/soul.md +44 -1
- package/skills/plan/soul.md +46 -3
- package/skills/session-end/SKILL.md +1 -24
- package/skills/session-end/phase-3-6-tail.md +30 -1
- package/skills/session-end/plan-verification.md +1 -5
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-start/SKILL.md +2 -0
- package/skills/session-start/soul.md +41 -1
- package/skills/wave-executor/SKILL.md +1 -5
- package/skills/wave-executor/wave-loop.md +36 -71
package/scripts/emit-session.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
814
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
965
|
-
//
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
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
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
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
|
|
1224
|
-
*
|
|
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
|
|
1230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1312
|
-
*
|
|
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
|
|
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-
|
|
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/
|
|
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';
|