@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.
- package/dist/compiler.d.ts +12 -0
- package/dist/compiler.d.ts.map +1 -1
- package/dist/compiler.js +17 -3
- package/dist/compiler.js.map +1 -1
- package/dist/config-schema.d.ts +28 -3
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +22 -3
- package/dist/config-schema.js.map +1 -1
- package/dist/config-schema.test.js +33 -0
- package/dist/config-schema.test.js.map +1 -1
- package/dist/describe.d.ts +19 -0
- package/dist/describe.d.ts.map +1 -1
- package/dist/describe.js +83 -12
- package/dist/describe.js.map +1 -1
- package/dist/describe.test.js +69 -2
- package/dist/describe.test.js.map +1 -1
- package/dist/gate-engine.d.ts +11 -1
- package/dist/gate-engine.d.ts.map +1 -1
- package/dist/gate-engine.js +27 -5
- package/dist/gate-engine.js.map +1 -1
- package/dist/gate-types.d.ts +17 -2
- package/dist/gate-types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/parity-detect.d.ts +41 -4
- package/dist/parity-detect.d.ts.map +1 -1
- package/dist/parity-detect.js +264 -1
- package/dist/parity-detect.js.map +1 -1
- package/dist/parity-detect.orientation-rows.test.d.ts +17 -0
- package/dist/parity-detect.orientation-rows.test.d.ts.map +1 -0
- package/dist/parity-detect.orientation-rows.test.js +357 -0
- package/dist/parity-detect.orientation-rows.test.js.map +1 -0
- package/dist/parity-label-canon.d.ts +160 -0
- package/dist/parity-label-canon.d.ts.map +1 -0
- package/dist/parity-label-canon.js +220 -0
- package/dist/parity-label-canon.js.map +1 -0
- package/dist/parity-label-canon.test.d.ts +11 -0
- package/dist/parity-label-canon.test.d.ts.map +1 -0
- package/dist/parity-label-canon.test.js +296 -0
- package/dist/parity-label-canon.test.js.map +1 -0
- package/dist/parity-manifest.d.ts +9 -0
- package/dist/parity-manifest.d.ts.map +1 -1
- package/dist/parity-manifest.js +47 -0
- package/dist/parity-manifest.js.map +1 -1
- package/dist/parity-manifest.test.js +45 -0
- package/dist/parity-manifest.test.js.map +1 -1
- package/dist/recurrence-stats.d.ts +10 -10
- package/dist/retrospect.d.ts +42 -42
- package/dist/selection-manifest.d.ts +4 -4
- package/dist/selection-manifest.d.ts.map +1 -1
- package/dist/selection-manifest.js +2 -0
- package/dist/selection-manifest.js.map +1 -1
- package/dist/transport-shield.d.ts +190 -0
- package/dist/transport-shield.d.ts.map +1 -0
- package/dist/transport-shield.fold.test.d.ts +2 -0
- package/dist/transport-shield.fold.test.d.ts.map +1 -0
- package/dist/transport-shield.fold.test.js +439 -0
- package/dist/transport-shield.fold.test.js.map +1 -0
- package/dist/transport-shield.js +1040 -0
- package/dist/transport-shield.js.map +1 -0
- package/dist/transport-shield.test.d.ts +2 -0
- package/dist/transport-shield.test.d.ts.map +1 -0
- package/dist/transport-shield.test.js +298 -0
- package/dist/transport-shield.test.js.map +1 -0
- 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
|