@mmnto/totem 2.0.0 → 2.2.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 (67) hide show
  1. package/dist/compiler.d.ts +12 -0
  2. package/dist/compiler.d.ts.map +1 -1
  3. package/dist/compiler.js +17 -3
  4. package/dist/compiler.js.map +1 -1
  5. package/dist/config-schema.d.ts +28 -3
  6. package/dist/config-schema.d.ts.map +1 -1
  7. package/dist/config-schema.js +22 -3
  8. package/dist/config-schema.js.map +1 -1
  9. package/dist/config-schema.test.js +33 -0
  10. package/dist/config-schema.test.js.map +1 -1
  11. package/dist/describe.d.ts +19 -0
  12. package/dist/describe.d.ts.map +1 -1
  13. package/dist/describe.js +83 -12
  14. package/dist/describe.js.map +1 -1
  15. package/dist/describe.test.js +69 -2
  16. package/dist/describe.test.js.map +1 -1
  17. package/dist/gate-engine.d.ts +11 -1
  18. package/dist/gate-engine.d.ts.map +1 -1
  19. package/dist/gate-engine.js +27 -5
  20. package/dist/gate-engine.js.map +1 -1
  21. package/dist/gate-types.d.ts +17 -2
  22. package/dist/gate-types.d.ts.map +1 -1
  23. package/dist/index.d.ts +8 -4
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +5 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/parity-detect.d.ts +41 -4
  28. package/dist/parity-detect.d.ts.map +1 -1
  29. package/dist/parity-detect.js +264 -1
  30. package/dist/parity-detect.js.map +1 -1
  31. package/dist/parity-detect.orientation-rows.test.d.ts +17 -0
  32. package/dist/parity-detect.orientation-rows.test.d.ts.map +1 -0
  33. package/dist/parity-detect.orientation-rows.test.js +357 -0
  34. package/dist/parity-detect.orientation-rows.test.js.map +1 -0
  35. package/dist/parity-label-canon.d.ts +160 -0
  36. package/dist/parity-label-canon.d.ts.map +1 -0
  37. package/dist/parity-label-canon.js +220 -0
  38. package/dist/parity-label-canon.js.map +1 -0
  39. package/dist/parity-label-canon.test.d.ts +11 -0
  40. package/dist/parity-label-canon.test.d.ts.map +1 -0
  41. package/dist/parity-label-canon.test.js +296 -0
  42. package/dist/parity-label-canon.test.js.map +1 -0
  43. package/dist/parity-manifest.d.ts +9 -0
  44. package/dist/parity-manifest.d.ts.map +1 -1
  45. package/dist/parity-manifest.js +47 -0
  46. package/dist/parity-manifest.js.map +1 -1
  47. package/dist/parity-manifest.test.js +45 -0
  48. package/dist/parity-manifest.test.js.map +1 -1
  49. package/dist/recurrence-stats.d.ts +10 -10
  50. package/dist/retrospect.d.ts +42 -42
  51. package/dist/selection-manifest.d.ts +4 -4
  52. package/dist/selection-manifest.d.ts.map +1 -1
  53. package/dist/selection-manifest.js +2 -0
  54. package/dist/selection-manifest.js.map +1 -1
  55. package/dist/transport-shield.d.ts +190 -0
  56. package/dist/transport-shield.d.ts.map +1 -0
  57. package/dist/transport-shield.fold.test.d.ts +2 -0
  58. package/dist/transport-shield.fold.test.d.ts.map +1 -0
  59. package/dist/transport-shield.fold.test.js +439 -0
  60. package/dist/transport-shield.fold.test.js.map +1 -0
  61. package/dist/transport-shield.js +1040 -0
  62. package/dist/transport-shield.js.map +1 -0
  63. package/dist/transport-shield.test.d.ts +2 -0
  64. package/dist/transport-shield.test.d.ts.map +1 -0
  65. package/dist/transport-shield.test.js +298 -0
  66. package/dist/transport-shield.test.js.map +1 -0
  67. package/package.json +1 -1
@@ -0,0 +1,1040 @@
1
+ import { TotemError } from './errors.js';
2
+ /**
3
+ * transport-shield (mmnto-ai/totem#2799): a PreToolUse gate over the Bash and
4
+ * PowerShell tools that refuses the KNOWN payload-mangling command shapes before
5
+ * the shell sees them, naming the cure in every refusal.
6
+ *
7
+ * The pattern table lives HERE, once, beside the evaluator (the charter's
8
+ * errata: provenance is the table in core; the CLI only installs). Every row is
9
+ * a pure function of the payload — no filesystem, no environment, no clock
10
+ * beyond the verdict's `checkedAt` — so the same command yields the same
11
+ * verdict on every machine given the same `platform` and `tool` fields, and a
12
+ * test can pin win32 on any runner.
13
+ *
14
+ * Evaluation order is the table order: the first DENY row that fires wins; a
15
+ * WARN row is consulted only when no deny fired. A shape the table lacks is
16
+ * allowed — the gate enumerates known shapes (the charter's positive corpus),
17
+ * and a miss is a corpus gap counted by the charter's metric, never a fail-open
18
+ * of an applicable gate.
19
+ *
20
+ * False-positive budget (ADR-109: a non-exact-match gate ships a stated budget
21
+ * and the fixture that measures it): ZERO denies over the benign corpus in
22
+ * `transport-shield.fold.test.ts` (everyday commands that share a token with a
23
+ * row — `diff -b`, `curl -b`, `grep gh -b`, a Windows path as a sed operand,
24
+ * `<<` inside a quoted argument, a here-string). A deny on a benign command in
25
+ * the field is a corpus row plus a fix, never a hand-carved exemption; the
26
+ * `--pilot` tier exists for a measurement week.
27
+ *
28
+ * Out of scope by design, disclosed: a bare backslash outside the four named
29
+ * contexts (a heredoc body, a sed -i expression, an inline node/python body, a
30
+ * gh --body) is not a shape this gate reads; a heredoc inside a `bash -c "…"`
31
+ * or `sh -c '…'` operand is quoted text to this scanner and is not recursed
32
+ * into (a corpus gap, not a fail-open of an applicable row); a wrapper that
33
+ * takes operands of its own before the program (`sudo -u me gh …`, `timeout
34
+ * 30 gh …`, `npx …`) hides the program from the position anchor — the same
35
+ * class. The scanners read the shell's own grammar where a mis-read would
36
+ * desynchronize them: a `#` that begins a word — after an unquoted blank,
37
+ * newline, `;`, `|`, `&`, an opening `(` or an OPERATOR `)` (a subshell's, a
38
+ * case pattern's) — is a comment to the end of the line, discarded WITHOUT
39
+ * quote processing (POSIX 2.3 rule 9; bash §3.1.3), while a `#` that continues
40
+ * a word is not one: `a#b`, and `$(x)#1` / `<(x)#1`, where the `)` closes a
41
+ * substitution that is part of the word (rules 5 and 8 for `$( … )`; process
42
+ * substitution is a bash extension, bash §3.5.6, that behaves the same way) —
43
+ * the scanners track which `(` each `)` closes. `$(( … ))` / `(( … ))` arithmetic is skipped. So
44
+ * neither an apostrophe in a comment nor a `<<` shift can hide a later heredoc
45
+ * or expose comment text as arguments. For the PowerShell tool the SAME walk
46
+ * reads PowerShell's grammar where it differs from bash's, so there is one
47
+ * model of which text is code and no pre-pass to disagree with it: a `<# … #>`
48
+ * block comment outside quotes is skipped whole (a `<#` inside a string is
49
+ * text; a quote inside a block opens nothing), the backtick is the escape
50
+ * inside a double-quoted string, and a `#` is read by the same word-boundary
51
+ * rule as bash's. Not read, disclosed: PowerShell also begins a comment after a
52
+ * token-ending string, an assignment operator or a `)` (`'a'#b`, `$x=#c`,
53
+ * `$(1)#c`), which the scanners read as word text — the over-scan direction,
54
+ * except when such a comment carries text the scanners read as shell syntax
55
+ * (an odd `'` or `"`, a `<#`, a trailing backslash, an unterminated `$(`) on
56
+ * the line before a later positive: those five carriers, each confirmed by
57
+ * execution, are the miss direction. PowerShell's token boundaries are not
58
+ * derivable from a character walk (`$x=#c` is an assignment and a comment at
59
+ * statement position but one argument after a command; `'a'#b` and `x='a'#c`
60
+ * differ only by where the token began), and five successive folds of the
61
+ * PowerShell reading — a pre-pass, its string tracking, its comment rule, then
62
+ * two token-boundary models inside the scanners — each opened a sibling shape
63
+ * (the bot-round record on mmnto-ai/totem#2804), so they stay disclosed rather
64
+ * than modelled. PowerShell
65
+ * here-strings (`@" … "@`, `@' … '@`) are not parsed either — a quote inside
66
+ * one can desynchronize the quote scan for that tool, the miss direction. The
67
+ * MSYS opt-out is honoured through the shell forms that export
68
+ * the variable to the judged program (MSYS reads its presence, any value): the
69
+ * segment's own `VAR=… prog` prefix, an earlier `export` / `declare -x` /
70
+ * `typeset -x`, a bare assignment under `set -a` or followed by `export NAME`,
71
+ * until an `unset` — never as a substring: a comment, a heredoc body or a
72
+ * quoted string that names the cure opts nothing out. Disclosed over-allow: an
73
+ * export inside a subshell `( … )` or a pipeline element is taken as reaching
74
+ * later segments though the shell would not apply it.
75
+ */
76
+ export const TRANSPORT_SHIELD_EVENT = 'transport-shield';
77
+ /** The module label every verdict cites as its `provenance.source` (never a path). */
78
+ export const TRANSPORT_SHIELD_SOURCE = 'transport-shield pattern table';
79
+ /** A heredoc body at or above this many bytes WARNs (the banked ~4 KB harness trap). */
80
+ export const HEREDOC_OVERSIZE_BYTES = 4096;
81
+ /** `provenance.matched` is bounded to this many characters. */
82
+ export const MATCHED_FRAGMENT_MAX = 80;
83
+ const TOOLS = new Set(['Bash', 'PowerShell']);
84
+ /**
85
+ * Parse an unknown payload into the gate's shape. Throws `GATE_INVALID` on a
86
+ * missing or non-string `command`, a `tool` outside the pair, or a missing
87
+ * `platform` — never default-allows (ADR-109: an unparseable payload is a
88
+ * broken source). The distributed wrapper never sends such a payload: it exits
89
+ * 0 as not-applicable first, so this branch is reachable only by hand.
90
+ */
91
+ export function parseTransportShieldPayload(payload) {
92
+ const rec = payload && typeof payload === 'object' ? payload : undefined;
93
+ const tool = rec && typeof rec.tool === 'string' ? rec.tool : '';
94
+ const command = rec && typeof rec.command === 'string' ? rec.command : undefined;
95
+ const platform = rec && typeof rec.platform === 'string' ? rec.platform.trim() : '';
96
+ const hint = 'Pass --payload \'{"tool":"Bash","command":"<the command>","platform":"win32"}\'.';
97
+ if (!TOOLS.has(tool)) {
98
+ throw new TotemError('GATE_INVALID', 'transport-shield payload requires "tool" to be "Bash" or "PowerShell".', hint);
99
+ }
100
+ if (command === undefined || command.trim() === '') {
101
+ throw new TotemError('GATE_INVALID', 'transport-shield payload requires a non-empty "command" string.', hint);
102
+ }
103
+ if (platform === '') {
104
+ throw new TotemError('GATE_INVALID', 'transport-shield payload requires a non-empty "platform" string (the host\'s process.platform).', hint);
105
+ }
106
+ return { tool: tool, command, platform };
107
+ }
108
+ /** Inside double quotes a backslash escapes only these (POSIX); elsewhere it is kept. */
109
+ const DQ_ESCAPABLE = new Set(['$', '`', '"', '\\', '\n']);
110
+ /**
111
+ * `<<` or `<<-`, optional blanks, then the delimiter WORD as bash delimits it:
112
+ * single-quoted, double-quoted, backslash-quoted (`\EOF` — any quoted character
113
+ * in the word quotes the whole delimiter, POSIX 2.7.4), or bare — a bare word
114
+ * running to the next blank, quote, backslash or operator character, so
115
+ * `EOF.TXT`, `EOF-1`, `1EOF` and `$X` are whole delimiter words (reading only a
116
+ * prefix of one left the body unterminated and over-scanned everything after
117
+ * it; not reading `<<1EOF` at all left its body to be scanned as shell text, the
118
+ * miss direction). A `$X` delimiter is read literally, as bash reads it — a
119
+ * heredoc delimiter word is never expanded, so bash and the scanner terminate
120
+ * at the same literal `$X` line. Groups: 1 the dash, 2 a single-quoted word,
121
+ * 3 a double-quoted word, 4 a backslash-quoted word, 5 a bare word. A partly
122
+ * quoted word (`E'O'F`) is read to its first quote — over-scan direction.
123
+ */
124
+ const HEREDOC_AT = /^<<(-?)[ \t]*(?:'([^'\n]+)'|"([^"\n]+)"|\\([^\s'"\\<>()|&;]+)|([^\s'"\\<>()|&;]+))/;
125
+ /**
126
+ * Characters after which the next character begins a word — where a `#` starts
127
+ * a comment (POSIX 2.3 rule 9, the comment rule; rule 8 appends to a word that
128
+ * is still open). Parentheses are not here: an opening `(` and an OPERATOR `)`
129
+ * begin a word, but the `)` that closes a `$( … )` or `<( … )` continues one
130
+ * (rule 5 makes a `$( … )` part of the word; `<( … )` is bash's own extension,
131
+ * §3.5.6, and bash treats it the same way) — `findHeredocs` tracks which `(`
132
+ * each `)` closes and sets the boundary from that.
133
+ */
134
+ const WORD_BOUNDARY = new Set([' ', '\t', '\r', '\n', ';', '|', '&']);
135
+ /**
136
+ * The index just past the `))` that closes an arithmetic expansion or command
137
+ * whose opening `$((` / `((` ends at `from`; the end of the command when it is
138
+ * unterminated. A `<<` inside is a shift, never a heredoc operator.
139
+ */
140
+ function skipArithmetic(command, from) {
141
+ let depth = 2;
142
+ let i = from;
143
+ while (i < command.length) {
144
+ const ch = command[i];
145
+ if (ch === '(')
146
+ depth += 1;
147
+ else if (ch === ')') {
148
+ depth -= 1;
149
+ if (depth === 0)
150
+ return i + 1;
151
+ }
152
+ i += 1;
153
+ }
154
+ return command.length;
155
+ }
156
+ /**
157
+ * Locate every heredoc in a command in ONE pass that tracks shell quoting and
158
+ * SKIPS heredoc bodies: an operator inside a quoted argument is text, `<<<` is
159
+ * a here-string, and an apostrophe inside a body never opens a quote (the
160
+ * fold's own regression, mmnto-ai/totem#2799 pass 2). A body starts after the
161
+ * newline that ends the operator's line and runs to the first line that IS the
162
+ * delimiter — an exact line match, as bash reads it (a `EOF ` with trailing
163
+ * space or a CRLF `EOF\r` does not terminate); for `<<-` leading tabs are
164
+ * stripped first — or to the end of the command when no such line exists. An
165
+ * operator with no newline after it yields a body of the remaining text: the
166
+ * conservative reading, so a truncated command still refuses on its escapes.
167
+ * A `#` that begins a word discards the rest of its line without quote
168
+ * processing, as bash does, and `$(( … ))` / `(( … ))` arithmetic is skipped.
169
+ * `parens` records what each open `(` is — a substitution (`$(`, `<(`, `>(`),
170
+ * which is part of a word, or a grouping operator — so the `)` that closes it
171
+ * can say whether the next character begins a word. With `powershell` set the
172
+ * same walk reads PowerShell's grammar where it differs (see `ScanOptions`).
173
+ */
174
+ export function findHeredocs(command, opts = {}) {
175
+ const ps = opts.powershell === true;
176
+ const spans = [];
177
+ const pending = [];
178
+ const parens = [];
179
+ let inSingle = false;
180
+ let inDouble = false;
181
+ let boundary = true;
182
+ let i = 0;
183
+ const consumeBodies = (from) => {
184
+ let cursor = from;
185
+ for (const h of pending) {
186
+ const bodyStart = cursor;
187
+ let bodyEnd = command.length;
188
+ let unterminated = true;
189
+ let resume = command.length;
190
+ let at = bodyStart;
191
+ while (at <= command.length) {
192
+ const nl = command.indexOf('\n', at);
193
+ const stop = nl === -1 ? command.length : nl;
194
+ let line = command.slice(at, stop);
195
+ if (h.stripTabs)
196
+ line = line.replace(/^\t+/, '');
197
+ if (line === h.delimiter) {
198
+ bodyEnd = at;
199
+ unterminated = false;
200
+ resume = nl === -1 ? command.length : nl + 1;
201
+ break;
202
+ }
203
+ if (nl === -1)
204
+ break;
205
+ at = nl + 1;
206
+ }
207
+ spans.push({
208
+ delimiter: h.delimiter,
209
+ quoted: h.quoted,
210
+ stripTabs: h.stripTabs,
211
+ body: command.slice(bodyStart, bodyEnd),
212
+ unterminated,
213
+ bodyStart,
214
+ bodyEnd,
215
+ });
216
+ cursor = resume;
217
+ if (unterminated)
218
+ break;
219
+ }
220
+ pending.length = 0;
221
+ return cursor;
222
+ };
223
+ while (i < command.length) {
224
+ const ch = command[i];
225
+ if (inSingle) {
226
+ if (ch === "'")
227
+ inSingle = false;
228
+ i += 1;
229
+ boundary = false;
230
+ continue;
231
+ }
232
+ if (inDouble) {
233
+ if (ps && ch === '`' && i + 1 < command.length) {
234
+ // PowerShell's escape inside a double-quoted string is the backtick.
235
+ i += 2;
236
+ boundary = false;
237
+ continue;
238
+ }
239
+ if (ch === '\\' && i + 1 < command.length && DQ_ESCAPABLE.has(command[i + 1])) {
240
+ i += 2;
241
+ boundary = false;
242
+ continue;
243
+ }
244
+ if (ch === '"')
245
+ inDouble = false;
246
+ i += 1;
247
+ boundary = false;
248
+ continue;
249
+ }
250
+ if (ps && command.startsWith('<#', i)) {
251
+ // PowerShell's block comment, discarded without quote processing; an
252
+ // unterminated one runs to the end. Skipped in the same walk that tracks
253
+ // quotes, so a `<#` inside a string is text and a quote inside a block
254
+ // opens nothing.
255
+ const close = command.indexOf('#>', i + 2);
256
+ i = close === -1 ? command.length : close + 2;
257
+ boundary = true;
258
+ continue;
259
+ }
260
+ if (ch === '#' && boundary) {
261
+ // A comment: discarded to the end of the line without quote processing;
262
+ // the newline itself stays (it may end an operator line).
263
+ const nl = command.indexOf('\n', i);
264
+ i = nl === -1 ? command.length : nl;
265
+ continue;
266
+ }
267
+ if (ch === '$' && command.startsWith('$((', i)) {
268
+ i = skipArithmetic(command, i + 3);
269
+ boundary = false;
270
+ continue;
271
+ }
272
+ if (ch === '(' && command[i + 1] === '(' && boundary) {
273
+ i = skipArithmetic(command, i + 2);
274
+ boundary = false;
275
+ continue;
276
+ }
277
+ if ((ch === '$' || ch === '<' || ch === '>') && command[i + 1] === '(') {
278
+ // A command or process substitution: part of the word that carries it. Its
279
+ // first character begins a word (a `#` right after `$(` is a comment).
280
+ parens.push('subst');
281
+ i += 2;
282
+ boundary = true;
283
+ continue;
284
+ }
285
+ if (ch === '(') {
286
+ parens.push('group');
287
+ i += 1;
288
+ boundary = true;
289
+ continue;
290
+ }
291
+ if (ch === ')') {
292
+ // The `)` of a substitution continues the word; an operator `)` ends one.
293
+ boundary = parens.pop() !== 'subst';
294
+ i += 1;
295
+ continue;
296
+ }
297
+ if (ch === '\\') {
298
+ i += 2;
299
+ boundary = false;
300
+ continue;
301
+ }
302
+ if (ch === "'") {
303
+ inSingle = true;
304
+ i += 1;
305
+ boundary = false;
306
+ continue;
307
+ }
308
+ if (ch === '"') {
309
+ inDouble = true;
310
+ i += 1;
311
+ boundary = false;
312
+ continue;
313
+ }
314
+ if (ch === '\n') {
315
+ i = pending.length > 0 ? consumeBodies(i + 1) : i + 1;
316
+ boundary = true;
317
+ continue;
318
+ }
319
+ if (ch === '<' && command[i + 1] === '<' && command[i - 1] !== '<' && command[i + 2] !== '<') {
320
+ const m = HEREDOC_AT.exec(command.slice(i));
321
+ if (m !== null) {
322
+ pending.push({
323
+ stripTabs: (m[1] ?? '') === '-',
324
+ quoted: m[2] !== undefined || m[3] !== undefined || m[4] !== undefined,
325
+ delimiter: m[2] ?? m[3] ?? m[4] ?? m[5] ?? '',
326
+ });
327
+ i += m[0].length;
328
+ boundary = false;
329
+ continue;
330
+ }
331
+ }
332
+ boundary = WORD_BOUNDARY.has(ch);
333
+ i += 1;
334
+ }
335
+ if (pending.length > 0)
336
+ consumeBodies(command.length);
337
+ return spans;
338
+ }
339
+ /**
340
+ * The index just past the `)` that closes the substitution whose `(` sits at
341
+ * `open`, counting parentheses only OUTSIDE quotes — a `)` inside `'…'` or
342
+ * `"…"`, or after a backslash, is text (POSIX 2.2) and never closes it; the end
343
+ * of the text when the substitution is unterminated. For PowerShell a
344
+ * `<# … #>` block inside the substitution is skipped whole and the backtick is
345
+ * the escape inside double quotes — the same rules the scanners' main walks
346
+ * apply, so a `)` inside a comment closes nothing.
347
+ */
348
+ function substitutionEnd(text, open, powershell = false) {
349
+ let depth = 0;
350
+ let inSingle = false;
351
+ let inDouble = false;
352
+ let j = open;
353
+ while (j < text.length) {
354
+ const c = text[j];
355
+ if (inSingle) {
356
+ if (c === "'")
357
+ inSingle = false;
358
+ }
359
+ else if (inDouble) {
360
+ if (powershell && c === '`' && j + 1 < text.length)
361
+ j += 1;
362
+ else if (c === '\\' && j + 1 < text.length && DQ_ESCAPABLE.has(text[j + 1]))
363
+ j += 1;
364
+ else if (c === '"')
365
+ inDouble = false;
366
+ }
367
+ else if (powershell && text.startsWith('<#', j)) {
368
+ const close = text.indexOf('#>', j + 2);
369
+ j = close === -1 ? text.length : close + 2;
370
+ continue;
371
+ }
372
+ else if (c === '\\' && j + 1 < text.length) {
373
+ j += 1;
374
+ }
375
+ else if (c === "'") {
376
+ inSingle = true;
377
+ }
378
+ else if (c === '"') {
379
+ inDouble = true;
380
+ }
381
+ else if (c === '(') {
382
+ depth += 1;
383
+ }
384
+ else if (c === ')') {
385
+ depth -= 1;
386
+ if (depth === 0)
387
+ return j + 1;
388
+ }
389
+ j += 1;
390
+ }
391
+ return text.length;
392
+ }
393
+ /** The command with every heredoc body replaced by spaces (length preserved), so tokenizing never reads a body. */
394
+ function blankHeredocBodies(command, spans) {
395
+ let out = command;
396
+ for (const s of spans) {
397
+ out = out.slice(0, s.bodyStart) + ' '.repeat(s.bodyEnd - s.bodyStart) + out.slice(s.bodyEnd);
398
+ }
399
+ return out;
400
+ }
401
+ const CONTROL_OPERATORS = ['&&', '||', '|', ';', '\n'];
402
+ /**
403
+ * A minimal POSIX-shell tokenizer: splits a command into segments at control
404
+ * operators (`&&`, `||`, `|`, `;`, a background `&`, newline) and each segment
405
+ * into words with quotes resolved. Good enough to find an option's value and a
406
+ * program name; it does not expand anything. Command substitutions `$( … )`
407
+ * and process substitutions `<( … )` / `>( … )` stay inside the word that
408
+ * carries them; grouping `(` and `)` are operators that delimit words. A `#`
409
+ * that begins a word discards the rest of its line, as bash does — comment
410
+ * text is never an argument. With `powershell` set the same walk reads
411
+ * PowerShell's grammar where it differs (see `ScanOptions`).
412
+ */
413
+ export function tokenizeShell(command, opts = {}) {
414
+ const ps = opts.powershell === true;
415
+ const segments = [];
416
+ let tokens = [];
417
+ let word = '';
418
+ let inWord = false;
419
+ let segStart = 0;
420
+ let i = 0;
421
+ const flushWord = () => {
422
+ if (inWord)
423
+ tokens.push(word);
424
+ word = '';
425
+ inWord = false;
426
+ };
427
+ const flushSegment = (end) => {
428
+ flushWord();
429
+ segments.push({ tokens, raw: command.slice(segStart, end) });
430
+ tokens = [];
431
+ };
432
+ while (i < command.length) {
433
+ const ch = command[i];
434
+ // A line continuation is whitespace, never an operator or a word character.
435
+ if (ch === '\\' && command[i + 1] === '\n') {
436
+ flushWord();
437
+ i += 2;
438
+ continue;
439
+ }
440
+ if (ch === '\\' && command[i + 1] === '\r' && command[i + 2] === '\n') {
441
+ flushWord();
442
+ i += 3;
443
+ continue;
444
+ }
445
+ if (ps && command.startsWith('<#', i)) {
446
+ // PowerShell's block comment: skipped whole, no token, in the same walk
447
+ // that tracks quotes (a `<#` inside a string is text; a quote inside a
448
+ // block opens nothing).
449
+ flushWord();
450
+ const close = command.indexOf('#>', i + 2);
451
+ i = close === -1 ? command.length : close + 2;
452
+ continue;
453
+ }
454
+ if (ch === '#' && !inWord) {
455
+ // A comment runs to the end of the line and is discarded without quote
456
+ // processing; the newline stays, a separator like any other. Read by the
457
+ // same word-boundary rule for both tools (see the header for what
458
+ // PowerShell reads differently and why that stays disclosed).
459
+ flushWord();
460
+ const nl = command.indexOf('\n', i);
461
+ i = nl === -1 ? command.length : nl;
462
+ continue;
463
+ }
464
+ if (ch === '&' &&
465
+ command[i + 1] !== '&' &&
466
+ command[i + 1] !== '>' &&
467
+ command[i - 1] !== '>' &&
468
+ command[i - 1] !== '<') {
469
+ // A background `&` ends the segment like `;`; `&&`, `&>`, `>&`, `<&` are not it.
470
+ flushSegment(i);
471
+ i += 1;
472
+ segStart = i;
473
+ continue;
474
+ }
475
+ const op = CONTROL_OPERATORS.find((o) => command.startsWith(o, i));
476
+ if (op !== undefined) {
477
+ flushSegment(i);
478
+ i += op.length;
479
+ segStart = i;
480
+ continue;
481
+ }
482
+ if (ch === "'") {
483
+ const close = command.indexOf("'", i + 1);
484
+ const end = close === -1 ? command.length : close;
485
+ word += command.slice(i + 1, end);
486
+ inWord = true;
487
+ i = end + 1;
488
+ continue;
489
+ }
490
+ if (ch === '"') {
491
+ i += 1;
492
+ inWord = true;
493
+ while (i < command.length && command[i] !== '"') {
494
+ if (ps && command[i] === '`' && i + 1 < command.length) {
495
+ // PowerShell's escape inside a double-quoted string is the backtick.
496
+ word += command[i + 1];
497
+ i += 2;
498
+ }
499
+ else if (command[i] === '\\' &&
500
+ i + 1 < command.length &&
501
+ DQ_ESCAPABLE.has(command[i + 1])) {
502
+ if (command[i + 1] !== '\n')
503
+ word += command[i + 1];
504
+ i += 2;
505
+ }
506
+ else {
507
+ word += command[i];
508
+ i += 1;
509
+ }
510
+ }
511
+ i += 1;
512
+ continue;
513
+ }
514
+ if (ch === '\\' && i + 1 < command.length) {
515
+ word += command[i + 1];
516
+ inWord = true;
517
+ i += 2;
518
+ continue;
519
+ }
520
+ if (ch === ' ' || ch === '\t' || ch === '\r') {
521
+ flushWord();
522
+ i += 1;
523
+ continue;
524
+ }
525
+ if ((ch === '$' || ch === '<' || ch === '>') && command[i + 1] === '(') {
526
+ // Keep a command or process substitution whole inside the word, its
527
+ // parentheses balanced OUTSIDE quotes (a `)` inside `"…"` is text): it is
528
+ // part of the word that carries it, so a `#` right after its `)` continues
529
+ // that word.
530
+ const end = substitutionEnd(command, i + 1, ps);
531
+ word += command.slice(i, end);
532
+ inWord = true;
533
+ i = end;
534
+ continue;
535
+ }
536
+ if (ch === '(' || ch === ')') {
537
+ // Grouping parentheses are operators: they delimit words and carry none,
538
+ // so a `#` after a subshell's or a case pattern's `)` begins a comment.
539
+ flushWord();
540
+ i += 1;
541
+ continue;
542
+ }
543
+ word += ch;
544
+ inWord = true;
545
+ i += 1;
546
+ }
547
+ flushSegment(command.length);
548
+ return segments.filter((s) => s.tokens.length > 0 || s.raw.trim() !== '');
549
+ }
550
+ // ─── Helpers the rows share ────────────────────────────────────────────────
551
+ const hasEscape = (text) => text.includes('\\') || text.includes('`');
552
+ /** The first line of `text` carrying a backslash or a backtick, for the fragment. */
553
+ function firstEscapedLine(text) {
554
+ for (const line of text.split('\n')) {
555
+ if (hasEscape(line))
556
+ return line;
557
+ }
558
+ return text;
559
+ }
560
+ /** Prefix words a shell runs THROUGH: the real program follows them. */
561
+ const WRAPPERS = new Set([
562
+ 'env',
563
+ 'time',
564
+ 'sudo',
565
+ 'nice',
566
+ 'nohup',
567
+ 'command',
568
+ 'exec',
569
+ 'builtin',
570
+ 'xargs',
571
+ ]);
572
+ /**
573
+ * The index of the segment's PROGRAM when it is `program` (a bare name or a
574
+ * path ending in it), after any `VAR=VALUE` assignments and known wrappers;
575
+ * -1 when the program is something else — a later mention of the name (`grep
576
+ * gh …`, `git log --grep sed …`) is an argument, never an invocation.
577
+ */
578
+ function programIndex(tokens, program) {
579
+ let i = 0;
580
+ // Assignments and wrappers interleave: `env VAR=x prog`, `VAR=x sudo prog`.
581
+ while (i < tokens.length) {
582
+ const t = tokens[i];
583
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t) || WRAPPERS.has(t))
584
+ i += 1;
585
+ else
586
+ break;
587
+ }
588
+ const t = tokens[i];
589
+ if (t === undefined)
590
+ return -1;
591
+ return t === program || t.endsWith('/' + program) ? i : -1;
592
+ }
593
+ /**
594
+ * Every occurrence of an option with its value and the flag AS WRITTEN:
595
+ * `--long X`, `--long=X`, `-s X`. Every occurrence, not the first — a `-b`
596
+ * that is some other option's value would otherwise hide a later, real
597
+ * `--body`. Empty when the option is absent. The short flag is required: the
598
+ * one caller always has one (the github-code-quality inline on
599
+ * mmnto-ai/totem#2804 at ac867140).
600
+ */
601
+ function optionsAsWritten(tokens, long, short) {
602
+ const out = [];
603
+ for (let i = 0; i < tokens.length; i += 1) {
604
+ const t = tokens[i];
605
+ if (t === long || t === short) {
606
+ const value = tokens[i + 1];
607
+ if (value !== undefined)
608
+ out.push({ flag: t, value });
609
+ }
610
+ else if (t.startsWith(long + '=')) {
611
+ out.push({ flag: long + '=', value: t.slice(long.length + 1) });
612
+ }
613
+ }
614
+ return out;
615
+ }
616
+ /**
617
+ * The text with every single-quoted region (outside double quotes) replaced by
618
+ * spaces, length preserved: a `$(` inside one is a literal, not a subshell. An
619
+ * unquoted backslash keeps the next character literal (POSIX 2.2.1), so `\'`
620
+ * opens no region and `\"` closes none.
621
+ */
622
+ function blankSingleQuoted(text) {
623
+ let out = '';
624
+ let inSingle = false;
625
+ let inDouble = false;
626
+ for (let i = 0; i < text.length; i += 1) {
627
+ const ch = text[i];
628
+ if (inSingle) {
629
+ if (ch === "'")
630
+ inSingle = false;
631
+ out += ch === "'" ? ch : ' ';
632
+ continue;
633
+ }
634
+ if (ch === '\\' && i + 1 < text.length) {
635
+ out += ch + text[i + 1];
636
+ i += 1;
637
+ continue;
638
+ }
639
+ if (inDouble) {
640
+ if (ch === '"')
641
+ inDouble = false;
642
+ out += ch;
643
+ continue;
644
+ }
645
+ if (ch === "'")
646
+ inSingle = true;
647
+ else if (ch === '"')
648
+ inDouble = true;
649
+ out += ch;
650
+ }
651
+ return out;
652
+ }
653
+ /** The values of every `-e` / `--expression` / `--expression=` operand in a sed argv. */
654
+ function sedExpressions(args) {
655
+ const out = [];
656
+ for (let i = 0; i < args.length; i += 1) {
657
+ const a = args[i];
658
+ if (a === '-e' || a === '--expression') {
659
+ if (args[i + 1] !== undefined)
660
+ out.push(args[i + 1]);
661
+ }
662
+ else if (a.startsWith('--expression=')) {
663
+ out.push(a.slice('--expression='.length));
664
+ }
665
+ }
666
+ return out;
667
+ }
668
+ /**
669
+ * True when sed takes its script from a FILE — `-f X`, GNU's attached `-fX` and
670
+ * `-f-`, `-f` clustered after argument-less short options (`-nf X`, `-Enf X`),
671
+ * `--file X`, `--file=X`: then no positional operand is an expression. Option
672
+ * parsing stops at `--`: an operand after it that begins with `-f` is a FILE
673
+ * sed edits in place (GNU 4.9 accepts `sed -i -- 's/X/Q/' -file`), never a
674
+ * script.
675
+ */
676
+ function sedHasScriptFile(args) {
677
+ for (const a of args) {
678
+ if (a === '--')
679
+ return false;
680
+ if (/^-[nsErzub]*f/.test(a) || a === '--file' || a.startsWith('--file='))
681
+ return true;
682
+ }
683
+ return false;
684
+ }
685
+ /**
686
+ * The expression operands of a sed argv: every `-e` value, or — when there is
687
+ * no `-e` and no script file — the first positional operand (a flag's own value
688
+ * skipped). Never a filename: with `-f` every positional operand is an input
689
+ * file, and a Windows path there carries backslashes that are not an escape.
690
+ */
691
+ function sedExpressionOperands(args) {
692
+ const explicit = sedExpressions(args);
693
+ if (explicit.length > 0 || sedHasScriptFile(args))
694
+ return explicit;
695
+ for (let i = 0; i < args.length; i += 1) {
696
+ const a = args[i];
697
+ if (a === '-l' || a === '--line-length') {
698
+ i += 1;
699
+ continue;
700
+ }
701
+ if (a === '' || a.startsWith('-'))
702
+ continue;
703
+ return [a];
704
+ }
705
+ return [];
706
+ }
707
+ const INLINE_BODIES = [
708
+ { program: 'node', flags: ['-e', '--eval', '-p', '--print'] },
709
+ { program: 'python', flags: ['-c'] },
710
+ { program: 'python3', flags: ['-c'] },
711
+ ];
712
+ /** The inline body of `node -e X`, `node --eval=X`, `python -c X`, … as written, or null. */
713
+ function inlineBody(args, flags) {
714
+ for (let i = 0; i < args.length; i += 1) {
715
+ const a = args[i];
716
+ if (flags.includes(a)) {
717
+ const body = args[i + 1];
718
+ return body === undefined ? null : { flag: a, body };
719
+ }
720
+ for (const f of flags) {
721
+ if (f.startsWith('--') && a.startsWith(f + '='))
722
+ return { flag: f + '=', body: a.slice(f.length + 1) };
723
+ }
724
+ }
725
+ return null;
726
+ }
727
+ const REV_PATH_TOKEN = /(^|[\s(])([A-Za-z0-9_./~^-]+:[A-Za-z0-9_./-]+)(?=$|[\s)])/;
728
+ /** Bash + win32: the platform precondition both MSYS rows share. */
729
+ const msysApplies = (p) => p.platform === 'win32' && p.tool === 'Bash';
730
+ /** The scanners read PowerShell's grammar for the PowerShell tool, bash's otherwise. */
731
+ const scanOpts = (p) => ({
732
+ powershell: p.tool === 'PowerShell',
733
+ });
734
+ /** The variable MSYS reads for its opt-out — its PRESENCE, any value (`=1`, `=0`, empty all switch conversion off). */
735
+ const MSYS_OPT_OUT_NAME = 'MSYS_NO_PATHCONV';
736
+ const isOptOutAssignment = (t) => t.startsWith(MSYS_OPT_OUT_NAME + '=');
737
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
738
+ /** True when the segment's own assignment/wrapper prefix carries the opt-out (`MSYS_NO_PATHCONV=1 gh …`, `env MSYS_NO_PATHCONV=1 gh …`). */
739
+ function segmentOptsOut(tokens) {
740
+ for (const t of tokens) {
741
+ if (isOptOutAssignment(t))
742
+ return true;
743
+ if (!ASSIGNMENT.test(t) && !WRAPPERS.has(t))
744
+ return false;
745
+ }
746
+ return false;
747
+ }
748
+ /**
749
+ * True when the segment exports the opt-out to every later segment:
750
+ * `export MSYS_NO_PATHCONV=…`, `declare -x MSYS_NO_PATHCONV=…`,
751
+ * `typeset -x MSYS_NO_PATHCONV=…`. A bare assignment sets a shell variable a
752
+ * later program never sees, so on its own it exports nothing.
753
+ */
754
+ function segmentExportsOptOut(tokens) {
755
+ const [head, ...rest] = tokens;
756
+ if (head === 'export')
757
+ return rest.some(isOptOutAssignment);
758
+ if (head === 'declare' || head === 'typeset') {
759
+ return rest.includes('-x') && rest.some(isOptOutAssignment);
760
+ }
761
+ return false;
762
+ }
763
+ /**
764
+ * The segments an MSYS row may judge: each with its tokens and raw text, minus
765
+ * those the opt-out reaches through the shell forms that export it — the
766
+ * segment's own `VAR=… prog` prefix; an earlier `export` / `declare -x` /
767
+ * `typeset -x`; a bare assignment under `set -a`, or one followed by
768
+ * `export MSYS_NO_PATHCONV` — until an `unset MSYS_NO_PATHCONV`. Never a
769
+ * substring: a comment, a heredoc body, a quoted string or an unrelated segment
770
+ * naming the cure opts nothing out. Disclosed over-allow: an export inside a
771
+ * subshell `( … )` or a pipeline element is taken as reaching later segments
772
+ * though the shell would not apply it (the tokenizer carries no subshell scope).
773
+ */
774
+ function msysSegments(text) {
775
+ const out = [];
776
+ let exported = false;
777
+ let allExport = false;
778
+ let assigned = false;
779
+ for (const seg of tokenizeShell(text)) {
780
+ const t = seg.tokens;
781
+ if (t[0] === 'set' && (t[1] === '-a' || t[1] === '+a')) {
782
+ allExport = t[1] === '-a';
783
+ continue;
784
+ }
785
+ if (t[0] === 'unset' && t.includes(MSYS_OPT_OUT_NAME)) {
786
+ exported = false;
787
+ assigned = false;
788
+ continue;
789
+ }
790
+ if (t.length > 0 && t.every((x) => ASSIGNMENT.test(x)) && t.some(isOptOutAssignment)) {
791
+ assigned = true;
792
+ if (allExport)
793
+ exported = true;
794
+ continue;
795
+ }
796
+ if (t[0] === 'export' && t.includes(MSYS_OPT_OUT_NAME) && assigned) {
797
+ exported = true;
798
+ continue;
799
+ }
800
+ if (segmentExportsOptOut(t)) {
801
+ exported = true;
802
+ continue;
803
+ }
804
+ if (exported || segmentOptsOut(t))
805
+ continue;
806
+ out.push(seg);
807
+ }
808
+ return out;
809
+ }
810
+ // ─── The table ─────────────────────────────────────────────────────────────
811
+ /**
812
+ * The pattern table, in evaluation order. Deny rows first, warn rows after.
813
+ * Each `find` reads only the payload.
814
+ */
815
+ export const TRANSPORT_PATTERNS = Object.freeze([
816
+ {
817
+ id: 'heredoc-escape',
818
+ disposition: 'deny',
819
+ cure: 'author the file with the Write tool and reference it by path',
820
+ find(p) {
821
+ for (const h of findHeredocs(p.command, scanOpts(p))) {
822
+ if (hasEscape(h.body)) {
823
+ return {
824
+ fragment: firstEscapedLine(h.body),
825
+ detail: `a heredoc body (delimiter ${h.delimiter}${h.unterminated ? ', unterminated' : ''}) carries a backslash or a backtick, ` +
826
+ 'which this transport rewrites before the shell sees it',
827
+ };
828
+ }
829
+ }
830
+ return null;
831
+ },
832
+ },
833
+ {
834
+ id: 'msys-body-slash',
835
+ disposition: 'deny',
836
+ cure: 'use --body-file <path>, or the PowerShell tool, or prefix MSYS_NO_PATHCONV=1',
837
+ find(p) {
838
+ // Scoped to segments whose PROGRAM is `gh`, the one program whose --body / -b
839
+ // takes a free-text body that MSYS path-converts (`-b` means something else on
840
+ // diff, curl, cp, sort, du, grep); the cure's MSYS_NO_PATHCONV=1 is honoured
841
+ // per segment, as the assignment the shell would apply.
842
+ if (!msysApplies(p))
843
+ return null;
844
+ const blanked = blankHeredocBodies(p.command, findHeredocs(p.command));
845
+ for (const seg of msysSegments(blanked)) {
846
+ const at = programIndex(seg.tokens, 'gh');
847
+ if (at === -1)
848
+ continue;
849
+ const hit = optionsAsWritten(seg.tokens.slice(at + 1), '--body', '-b').find((h) => h.value.startsWith('/'));
850
+ if (hit !== undefined) {
851
+ const written = hit.flag.endsWith('=')
852
+ ? `${hit.flag}${hit.value}`
853
+ : `${hit.flag} ${hit.value}`;
854
+ return {
855
+ fragment: written,
856
+ detail: 'a gh --body value beginning with / is path-converted by MSYS on win32 (the /gemini review that posted as a Program Files path)',
857
+ };
858
+ }
859
+ }
860
+ return null;
861
+ },
862
+ },
863
+ {
864
+ id: 'sed-i-escape',
865
+ disposition: 'deny',
866
+ cure: 'use the Edit tool',
867
+ find(p) {
868
+ const opts = scanOpts(p);
869
+ const blanked = blankHeredocBodies(p.command, findHeredocs(p.command, opts));
870
+ for (const seg of tokenizeShell(blanked, opts)) {
871
+ const at = programIndex(seg.tokens, 'sed');
872
+ if (at === -1)
873
+ continue;
874
+ const args = seg.tokens.slice(at + 1);
875
+ const inPlace = args.some((a) => a === '-i' || /^-i\S*$/.test(a) || a === '--in-place' || a.startsWith('--in-place='));
876
+ if (!inPlace)
877
+ continue;
878
+ // The EXPRESSION operands only — never a filename (a Windows path carries
879
+ // backslashes), never a `-f` script path, never BSD sed's empty
880
+ // backup-suffix operand (`-i ''`).
881
+ const expressions = sedExpressionOperands(args);
882
+ const escaped = expressions.find((e) => e.includes('\\'));
883
+ if (escaped !== undefined) {
884
+ return { fragment: escaped, detail: 'a sed -i expression carries a backslash' };
885
+ }
886
+ if (expressions.length > 1) {
887
+ return {
888
+ fragment: seg.raw.trim(),
889
+ detail: `a sed -i invocation carries ${expressions.length} -e expressions`,
890
+ };
891
+ }
892
+ // A newline INSIDE an expression operand only: a bare newline ends the
893
+ // segment and an unquoted backslash-newline is a continuation the tokenizer
894
+ // drops, so a newline reaches the operand only inside quotes (the shape that
895
+ // mangles) or inside a `$( … )` the tokenizer copies whole (over-scan,
896
+ // disclosed). A newline in a file operand is not this row's shape.
897
+ const multiline = expressions.find((e) => e.includes('\n'));
898
+ if (multiline !== undefined) {
899
+ return {
900
+ fragment: multiline,
901
+ detail: 'a sed -i expression carries a newline inside quotes or a substitution',
902
+ };
903
+ }
904
+ }
905
+ return null;
906
+ },
907
+ },
908
+ {
909
+ id: 'inline-body-escape',
910
+ disposition: 'deny',
911
+ cure: 'author the script with the Write tool and run it by path',
912
+ find(p) {
913
+ if (p.tool !== 'Bash')
914
+ return null;
915
+ const blanked = blankHeredocBodies(p.command, findHeredocs(p.command));
916
+ for (const seg of tokenizeShell(blanked)) {
917
+ for (const { program, flags } of INLINE_BODIES) {
918
+ const at = programIndex(seg.tokens, program);
919
+ if (at === -1)
920
+ continue;
921
+ const hit = inlineBody(seg.tokens.slice(at + 1), flags);
922
+ if (hit !== null && hasEscape(hit.body)) {
923
+ return {
924
+ fragment: hit.body,
925
+ detail: `an inline ${program} ${hit.flag} body carries a backslash or a backtick`,
926
+ };
927
+ }
928
+ }
929
+ }
930
+ return null;
931
+ },
932
+ },
933
+ {
934
+ id: 'heredoc-oversize',
935
+ disposition: 'warn',
936
+ cure: 'author the file with the Write tool and reference it by path',
937
+ find(p) {
938
+ for (const h of findHeredocs(p.command, scanOpts(p))) {
939
+ const bytes = Buffer.byteLength(h.body, 'utf8');
940
+ if (bytes >= HEREDOC_OVERSIZE_BYTES) {
941
+ return {
942
+ fragment: h.body.split('\n')[0] ?? '',
943
+ detail: `a heredoc body (delimiter ${h.delimiter}) is ${bytes} bytes; this transport has dropped bodies past roughly ${HEREDOC_OVERSIZE_BYTES} bytes (a heuristic threshold)`,
944
+ };
945
+ }
946
+ }
947
+ return null;
948
+ },
949
+ },
950
+ {
951
+ id: 'msys-rev-path-subshell',
952
+ disposition: 'warn',
953
+ cure: 'prefix the command with MSYS_NO_PATHCONV=1',
954
+ find(p) {
955
+ if (!msysApplies(p))
956
+ return null;
957
+ // Bodies and single-quoted text blanked first: a `$(` inside either is
958
+ // literal. Judged per segment, so the opt-out applies where the shell applies it.
959
+ const blanked = blankSingleQuoted(blankHeredocBodies(p.command, findHeredocs(p.command)));
960
+ for (const seg of msysSegments(blanked)) {
961
+ const text = seg.raw;
962
+ let cursor = 0;
963
+ while (cursor < text.length) {
964
+ const open = text.indexOf('$(', cursor);
965
+ if (open === -1)
966
+ break;
967
+ // Balanced outside quotes: a `)` inside `"…"` never closes the subshell.
968
+ const end = substitutionEnd(text, open + 1);
969
+ const inner = text.slice(open + 2, text[end - 1] === ')' ? end - 1 : end);
970
+ const hit = REV_PATH_TOKEN.exec(inner);
971
+ if (hit !== null && !hit[2].includes('//')) {
972
+ return {
973
+ fragment: hit[2],
974
+ detail: 'a <rev>:<path> argument inside $( … ) is path-converted by MSYS on win32',
975
+ };
976
+ }
977
+ cursor = end;
978
+ }
979
+ }
980
+ return null;
981
+ },
982
+ },
983
+ ]);
984
+ // ─── The evaluator ─────────────────────────────────────────────────────────
985
+ /** Bound and sanitize a fragment for provenance: no control characters, at most MATCHED_FRAGMENT_MAX chars. */
986
+ function boundedFragment(fragment) {
987
+ let out = '';
988
+ for (const ch of fragment) {
989
+ const code = ch.charCodeAt(0);
990
+ out += code < 0x20 || code === 0x7f ? ' ' : ch;
991
+ }
992
+ out = out.trim();
993
+ return out.length > MATCHED_FRAGMENT_MAX ? out.slice(0, MATCHED_FRAGMENT_MAX - 1) + '…' : out;
994
+ }
995
+ /**
996
+ * The gate: parse the payload (throws on an invalid one), walk the table, and
997
+ * return the first deny, else the first warn, else allow. Pure over the payload.
998
+ */
999
+ export const transportShieldEvaluator = (payload) => {
1000
+ const p = parseTransportShieldPayload(payload);
1001
+ const checkedAt = new Date().toISOString();
1002
+ let warn = null;
1003
+ for (const pattern of TRANSPORT_PATTERNS) {
1004
+ const match = pattern.find(p);
1005
+ if (match === null)
1006
+ continue;
1007
+ if (pattern.disposition === 'deny') {
1008
+ return {
1009
+ disposition: 'deny',
1010
+ reason: `${match.detail} — ${pattern.cure}.`,
1011
+ provenance: {
1012
+ source: TRANSPORT_SHIELD_SOURCE,
1013
+ ref: pattern.id,
1014
+ matched: boundedFragment(match.fragment),
1015
+ checkedAt,
1016
+ },
1017
+ };
1018
+ }
1019
+ if (warn === null)
1020
+ warn = { pattern, match };
1021
+ }
1022
+ if (warn !== null) {
1023
+ return {
1024
+ disposition: 'warn',
1025
+ reason: `${warn.match.detail} — ${warn.pattern.cure}.`,
1026
+ provenance: {
1027
+ source: TRANSPORT_SHIELD_SOURCE,
1028
+ ref: warn.pattern.id,
1029
+ matched: boundedFragment(warn.match.fragment),
1030
+ checkedAt,
1031
+ },
1032
+ };
1033
+ }
1034
+ return {
1035
+ disposition: 'allow',
1036
+ reason: `No known payload-mangling shape in the ${p.tool} command.`,
1037
+ provenance: { source: TRANSPORT_SHIELD_SOURCE, ref: 'no-match', matched: null, checkedAt },
1038
+ };
1039
+ };
1040
+ //# sourceMappingURL=transport-shield.js.map