@gobing-ai/spur 0.4.25 → 0.4.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/config.example.yaml +7 -0
- package/package.json +1 -1
- package/plugins/sp/lib/destructive-policy.ts +521 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/hooks/careful-guard.ts +5 -0
- package/spur.js +2235 -1292
- package/web/_astro/BoardApp.BpVPF1yi.js +1 -0
- package/web/_astro/{BoardApp.CNBpKuB6.js → BoardApp.H0GHCpML.js} +157 -157
- package/web/_astro/TaskDetail.C3fD4iNx.js +1 -0
- package/web/_astro/{arc.CLFbvaU4.js → arc.CwmuZ1Pw.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.YYrMKtrm.js → architectureDiagram-3BPJPVTR.D2r2pAtD.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.D-Xzu32A.js → blockDiagram-GPEHLZMM.LRgyDihu.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.Cv7EV8QD.js → c4Diagram-AAUBKEIU.OscV13G7.js} +1 -1
- package/web/_astro/channel.OpZyofHt.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.CkAKN-fF.js → chunk-2J33WTMH.BcbENXnC.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.D-xu2Tgi.js → chunk-4BX2VUAB.CcRidR01.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.yNQibVGY.js → chunk-55IACEB6.DxAFaIIp.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.DlVIFHHZ.js → chunk-727SXJPM.Cjvuc3en.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.C-LGFaeS.js → chunk-AQP2D5EJ.B3GSEPXo.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.Df2n26zT.js → chunk-FMBD7UC4.DbpwRXpL.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.CV_k83Gj.js → chunk-ND2GUHAM.CdiLgZ9z.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.BQQDO3h3.js → chunk-QZHKN3VN.DT_2ovxF.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.DkSMdA84.js → classDiagram-4FO5ZUOK.B4Y_Byb0.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.DkSMdA84.js → classDiagram-v2-Q7XG4LA2.B4Y_Byb0.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.CCN7GwAZ.js → cose-bilkent-S5V4N54A.0UOSbCRW.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.CqQCttkM.js → cynefin-OW5HDTMX.iaVw7KUy.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.CCn6iftc.js → dagre-BM42HDAG.DzBLfYbl.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.DImUVZK-.js → diagram-2AECGRRQ.CtoO_RXO.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.BYNywZwV.js → diagram-5GNKFQAL.C1ah--0o.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.LJZ9DjsJ.js → diagram-KO2AKTUF.DAawwAoM.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.CJ3YpVED.js → diagram-LMA3HP47.kC7Zg0ru.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.CnVtq5_j.js → diagram-OG6HWLK6.B3dSXJ9m.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.SdmuzZwT.js → erDiagram-TEJ5UH35.cVJXYJkv.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.C9V-B-nO.js → flowDiagram-I6XJVG4X.CwD6KgFD.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.DT5v7SL5.js → ganttDiagram-6RSMTGT7.UB88xr_u.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.B3qURYBL.js → gitGraphDiagram-PVQCEYII.C4B3FNOY.js} +1 -1
- package/web/_astro/index.CKtaGArK.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.CHO0xT8l.js → infoDiagram-5YYISTIA.6QC564C4.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.buALAOGh.js → ishikawaDiagram-YF4QCWOH.sQBtM0gc.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.DvKuIdbX.js → journeyDiagram-JHISSGLW.Dslglgu2.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.Dvoi9jdN.js → kanban-definition-UN3LZRKU.CPKaPyzi.js} +1 -1
- package/web/_astro/{linear.EsEfz8Ex.js → linear.DNHY6BCF.js} +1 -1
- package/web/_astro/{mermaid.core.Vk3ryVC7.js → mermaid.core.VoW0rvGv.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.DG1pkymq.js → mindmap-definition-RKZ34NQL.jYZ-jCh7.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.DAUmIdd6.js → pieDiagram-4H26LBE5.DQ0fapSh.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.Du3QUozl.js → quadrantDiagram-W4KKPZXB.CWF406ut.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.neRi5GrQ.js → requirementDiagram-4Y6WPE33.BmUiiY0p.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.CMUtxA70.js → sankeyDiagram-5OEKKPKP.CzUeGGUK.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.CfYFLmYH.js → sequenceDiagram-3UESZ5HK.DKvkUeEk.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.Bn4dmAad.js → stateDiagram-AJRCARHV.ByedXnFB.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.BMNezvp-.js → stateDiagram-v2-BHNVJYJU.Bx7wyAlv.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.ByaDwsBX.js → timeline-definition-PNZ67QCA.Chzu_dUX.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.CUYU1Mc9.js → vennDiagram-CIIHVFJN.PuxG68DI.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.DpZZeJr2.js → wardleyDiagram-YWT4CUSO.C9ZKxfsC.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.CWwI33U0.js → xychartDiagram-2RQKCTM6.B7qR-3HU.js} +1 -1
- package/web/index.html +2 -2
- package/web/_astro/BoardApp.DVWLxHcV.js +0 -1
- package/web/_astro/TaskDetail.BY6jO_3P.js +0 -1
- package/web/_astro/channel.DzHZuqj0.js +0 -1
- package/web/_astro/index.C8LjH5ri.css +0 -1
|
@@ -240,6 +240,13 @@ redaction:
|
|
|
240
240
|
# # none — only the stack-neutral `spur rule run` presets are bound; declare the
|
|
241
241
|
# # rest in `actions` (see below) or they stay unbound.
|
|
242
242
|
# preset: bun
|
|
243
|
+
# # Fresh `spur self init` detects root Bun first, then matching Make targets, exact declared
|
|
244
|
+
# # JavaScript scripts and Cargo/Go defaults under preset: none. Python needs explicit bindings.
|
|
245
|
+
# # A root manifest wins; otherwise one immediate child may be selected. Multiple roots stay
|
|
246
|
+
# # unbound with paths listed. Child commands prefix a shell-quoted cd; no cwd field is added.
|
|
247
|
+
# # Cargo/Go defaults require the tool on PATH; declared Make/JS proposals warn when unavailable.
|
|
248
|
+
# # Comments explain provenance, selected directory and unbound actions; re-init preserves config
|
|
249
|
+
# # unless --force is explicitly selected. Inspect with `spur self exec --list --json`.
|
|
243
250
|
# # action name -> one shell command, or a list of action names (composite, run in order,
|
|
244
251
|
# # fail-fast). A declared entry replaces the preset binding whole. Reserved names: build, dev,
|
|
245
252
|
# # clean, format, autofix, lint, typecheck, test, test-full, test-heavy, rules-pre, rules-post,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/spur",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.26",
|
|
4
4
|
"description": "Spur CLI — local-first harness for mainstream coding agents: constraint checking, workflow orchestration, agent health, and history analytics. Bun-native; exposes the `spur` command.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"spur",
|
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Adapters own **I/O only** (payload shape, how a prompt is raised). They must not
|
|
16
16
|
* re-derive classification.
|
|
17
|
+
*
|
|
18
|
+
* **Executable positions (task 1313).** `classifyCommand` lexes quotes, escapes,
|
|
19
|
+
* command separators (including a redirection's `&`, which is not a separator),
|
|
20
|
+
* `$(...)` / backticks, the `SHELL_WRAPPERS` that forward a shell, and a `-c` script from
|
|
21
|
+
* any `SHELL_BINS` interpreter — matched on the basename, so `/bin/bash` counts, and
|
|
22
|
+
* `SHELL_OPTIONS_WITH_VALUE` options (`-O extglob`, `--rcfile x`) do not end the scan.
|
|
23
|
+
* Proven standalone `rg` / `grep` operands are data: single quotes never recurse, and
|
|
24
|
+
* only substitutions inside double quotes
|
|
25
|
+
* or unquoted text are classified again. `--pre` is not data, and neither is a search
|
|
26
|
+
* whose pipeline feeds anything but a known text consumer (`DATA_CONSUMERS`) — matched on
|
|
27
|
+
* the argv0 basename, so `/bin/sh` and `nice sh` count as execution. Malformed or
|
|
28
|
+
* unsupported syntax keeps the whole-string scan below — it never becomes an allow.
|
|
17
29
|
*/
|
|
18
30
|
|
|
19
31
|
/**
|
|
@@ -186,8 +198,517 @@ export function stripGitGlobalOptions(command: string): string {
|
|
|
186
198
|
return command.replace(GIT_WITH_GLOBAL_OPTIONS, 'git');
|
|
187
199
|
}
|
|
188
200
|
|
|
201
|
+
/** How many nested scripts or substitutions to follow before the whole-string scan. */
|
|
202
|
+
const MAX_LEX_DEPTH = 8;
|
|
203
|
+
|
|
204
|
+
interface ShellWord {
|
|
205
|
+
text: string;
|
|
206
|
+
substitutions: string[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface ShellSegment {
|
|
210
|
+
start: number;
|
|
211
|
+
end: number;
|
|
212
|
+
words: ShellWord[];
|
|
213
|
+
substitutionSpans: Array<[number, number]>;
|
|
214
|
+
/** Segments joined by `|` / `|&` share an id; `;`, newline, `&`, `&&`, `||` start a new one. */
|
|
215
|
+
pipeline: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Index of the `)` that closes a `$(` whose body starts at `start`, or -1.
|
|
222
|
+
* Quotes hide parens; a backslash hides the next character.
|
|
223
|
+
*/
|
|
224
|
+
function scanBalancedParen(source: string, start: number): number {
|
|
225
|
+
let depth = 1;
|
|
226
|
+
let i = start;
|
|
227
|
+
while (i < source.length) {
|
|
228
|
+
const ch = source[i];
|
|
229
|
+
if (ch === '\\') {
|
|
230
|
+
i += 2;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (ch === "'") {
|
|
234
|
+
i++;
|
|
235
|
+
while (i < source.length && source[i] !== "'") i++;
|
|
236
|
+
if (i >= source.length) return -1;
|
|
237
|
+
i++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (ch === '"') {
|
|
241
|
+
i++;
|
|
242
|
+
while (i < source.length) {
|
|
243
|
+
if (source[i] === '\\') {
|
|
244
|
+
i += 2;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (source[i] === '"') break;
|
|
248
|
+
i++;
|
|
249
|
+
}
|
|
250
|
+
if (i >= source.length) return -1;
|
|
251
|
+
i++;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (ch === '(') {
|
|
255
|
+
depth++;
|
|
256
|
+
i++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (ch === ')') {
|
|
260
|
+
depth--;
|
|
261
|
+
if (depth === 0) return i;
|
|
262
|
+
i++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
i++;
|
|
266
|
+
}
|
|
267
|
+
return -1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Index of the closing backtick, or -1. A backslash can escape a backtick. */
|
|
271
|
+
function scanBacktick(source: string, start: number): number {
|
|
272
|
+
let i = start + 1;
|
|
273
|
+
while (i < source.length) {
|
|
274
|
+
if (source[i] === '\\' && i + 1 < source.length) {
|
|
275
|
+
const next = source[i + 1];
|
|
276
|
+
if (next === '`' || next === '\\' || next === '$') {
|
|
277
|
+
i += 2;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (source[i] === '`') return i;
|
|
282
|
+
i++;
|
|
283
|
+
}
|
|
284
|
+
return -1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function readSubstitution(
|
|
288
|
+
source: string,
|
|
289
|
+
dollar: number,
|
|
290
|
+
word: ShellWord,
|
|
291
|
+
spans: Array<[number, number]>,
|
|
292
|
+
): number | null {
|
|
293
|
+
if (source[dollar + 2] === '(') return null;
|
|
294
|
+
const close = scanBalancedParen(source, dollar + 2);
|
|
295
|
+
if (close < 0) return null;
|
|
296
|
+
word.substitutions.push(source.slice(dollar + 2, close));
|
|
297
|
+
spans.push([dollar, close + 1]);
|
|
298
|
+
return close + 1;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function readBacktick(source: string, open: number, word: ShellWord, spans: Array<[number, number]>): number | null {
|
|
302
|
+
const close = scanBacktick(source, open);
|
|
303
|
+
if (close < 0) return null;
|
|
304
|
+
word.substitutions.push(source.slice(open + 1, close));
|
|
305
|
+
spans.push([open, close + 1]);
|
|
306
|
+
return close + 1;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Split a command into simple commands and words.
|
|
311
|
+
* Returns null for unclosed quotes, unclosed substitutions, or syntax this pass
|
|
312
|
+
* does not claim to understand (ANSI-C quotes, arithmetic, heredocs, process
|
|
313
|
+
* substitutions, subshells). Null means "use the whole-string scan".
|
|
314
|
+
*/
|
|
315
|
+
function lexShell(command: string): ShellSegment[] | null {
|
|
316
|
+
const segments: ShellSegment[] = [];
|
|
317
|
+
const n = command.length;
|
|
318
|
+
let i = 0;
|
|
319
|
+
let segStart = 0;
|
|
320
|
+
let pipeline = 0;
|
|
321
|
+
let words: ShellWord[] = [];
|
|
322
|
+
let spans: Array<[number, number]> = [];
|
|
323
|
+
let current: ShellWord | null = null;
|
|
324
|
+
|
|
325
|
+
const flushWord = (): void => {
|
|
326
|
+
if (current === null) return;
|
|
327
|
+
words.push(current);
|
|
328
|
+
current = null;
|
|
329
|
+
};
|
|
330
|
+
const flushSegment = (end: number): void => {
|
|
331
|
+
flushWord();
|
|
332
|
+
if (command.slice(segStart, end).trim().length > 0) {
|
|
333
|
+
segments.push({ start: segStart, end, words, substitutionSpans: spans, pipeline });
|
|
334
|
+
}
|
|
335
|
+
words = [];
|
|
336
|
+
spans = [];
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
while (i < n) {
|
|
340
|
+
const ch = command[i] ?? '';
|
|
341
|
+
if (current === null) {
|
|
342
|
+
if (ch === ' ' || ch === '\t' || ch === '\r') {
|
|
343
|
+
i++;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (ch === '\n' || ch === ';') {
|
|
347
|
+
flushSegment(i);
|
|
348
|
+
pipeline++;
|
|
349
|
+
i++;
|
|
350
|
+
segStart = i;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (ch === '|') {
|
|
354
|
+
flushSegment(i);
|
|
355
|
+
if (command[i + 1] === '|') pipeline++;
|
|
356
|
+
i += command[i + 1] === '|' || command[i + 1] === '&' ? 2 : 1;
|
|
357
|
+
segStart = i;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
// `&` only separates commands on its own: inside a redirection (`2>&1`, `&>log`,
|
|
361
|
+
// `<&3`) it is part of the operand. Treating it as a separator split `a 2>&1 | sh`
|
|
362
|
+
// into two pipelines, which silently disabled the "search piped into an executor"
|
|
363
|
+
// rule and let `rg 'rm -rf /' x 2>&1 | bash` through.
|
|
364
|
+
if (ch === '&') {
|
|
365
|
+
const redirectAmp = command[i - 1] === '>' || command[i - 1] === '<' || command[i + 1] === '>';
|
|
366
|
+
if (redirectAmp) {
|
|
367
|
+
// Redirect operand (`2>&1`, `&>log`): not a separator, but the index MUST
|
|
368
|
+
// advance or the segment scanner re-reads the same char forever.
|
|
369
|
+
i++;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
flushSegment(i);
|
|
373
|
+
pipeline++;
|
|
374
|
+
i += command[i + 1] === '&' ? 2 : 1;
|
|
375
|
+
segStart = i;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (ch === '#') {
|
|
379
|
+
while (i < n && command[i] !== '\n') i++;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (ch === '<' || ch === '>') {
|
|
383
|
+
if (command[i + 1] === '(' || (ch === '<' && command[i + 1] === '<')) return null;
|
|
384
|
+
i += command[i + 1] === '>' ? 2 : 1;
|
|
385
|
+
if (command[i] === '&') i++;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (ch === '(' || ch === ')') return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
current ??= { text: '', substitutions: [] };
|
|
392
|
+
if (ch === "'") {
|
|
393
|
+
i++;
|
|
394
|
+
while (i < n && command[i] !== "'") {
|
|
395
|
+
current.text += command[i] ?? '';
|
|
396
|
+
i++;
|
|
397
|
+
}
|
|
398
|
+
if (i >= n) return null;
|
|
399
|
+
i++;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (ch === '"') {
|
|
403
|
+
i++;
|
|
404
|
+
let closed = false;
|
|
405
|
+
while (i < n) {
|
|
406
|
+
const quoted = command[i] ?? '';
|
|
407
|
+
if (quoted === '\\') {
|
|
408
|
+
const next = command[i + 1];
|
|
409
|
+
if (next === undefined) return null;
|
|
410
|
+
if (next === '\n') {
|
|
411
|
+
i += 2;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
current.text += next === '$' || next === '`' || next === '"' || next === '\\' ? next : `\\${next}`;
|
|
415
|
+
i += 2;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (quoted === '"') {
|
|
419
|
+
i++;
|
|
420
|
+
closed = true;
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
if (quoted === '$' && (command[i + 1] === "'" || command[i + 1] === '"')) return null;
|
|
424
|
+
if (quoted === '$' && command[i + 1] === '(') {
|
|
425
|
+
const next = readSubstitution(command, i, current, spans);
|
|
426
|
+
if (next === null) return null;
|
|
427
|
+
i = next;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (quoted === '`') {
|
|
431
|
+
const next = readBacktick(command, i, current, spans);
|
|
432
|
+
if (next === null) return null;
|
|
433
|
+
i = next;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
current.text += quoted;
|
|
437
|
+
i++;
|
|
438
|
+
}
|
|
439
|
+
if (!closed) return null;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (ch === '\\') {
|
|
443
|
+
const next = command[i + 1];
|
|
444
|
+
if (next === undefined) return null;
|
|
445
|
+
if (next !== '\n') current.text += next;
|
|
446
|
+
i += 2;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (ch === '$' && (command[i + 1] === "'" || command[i + 1] === '"')) return null;
|
|
450
|
+
if (ch === '$' && command[i + 1] === '(') {
|
|
451
|
+
const next = readSubstitution(command, i, current, spans);
|
|
452
|
+
if (next === null) return null;
|
|
453
|
+
i = next;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (ch === '`') {
|
|
457
|
+
const next = readBacktick(command, i, current, spans);
|
|
458
|
+
if (next === null) return null;
|
|
459
|
+
i = next;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
|
|
463
|
+
flushWord();
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (ch === '(' || ch === ')') return null;
|
|
467
|
+
if ((ch === '<' || ch === '>') && current.text === '' && current.substitutions.length === 0) {
|
|
468
|
+
current = null;
|
|
469
|
+
if (command[i + 1] === '(' || (ch === '<' && command[i + 1] === '<')) return null;
|
|
470
|
+
i += command[i + 1] === '>' ? 2 : 1;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
current.text += ch;
|
|
474
|
+
i++;
|
|
475
|
+
}
|
|
476
|
+
flushSegment(n);
|
|
477
|
+
return segments;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Drop leading `command` / `env` wrappers this pass understands. Unknown flags stay. */
|
|
481
|
+
function stripWrappers(words: readonly ShellWord[]): ShellWord[] {
|
|
482
|
+
let i = 0;
|
|
483
|
+
while (i < words.length) {
|
|
484
|
+
const name = words[i]?.text ?? '';
|
|
485
|
+
const substituted = (words[i]?.substitutions.length ?? 0) > 0;
|
|
486
|
+
if (substituted) break;
|
|
487
|
+
if (ENV_ASSIGNMENT.test(name)) {
|
|
488
|
+
i++;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (name === 'command') {
|
|
492
|
+
i++;
|
|
493
|
+
if (words[i]?.text === '-p' || words[i]?.text === '--') i++;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (argvName(words[i]) === 'env') {
|
|
497
|
+
const envAt = i;
|
|
498
|
+
let j = i + 1;
|
|
499
|
+
let known = true;
|
|
500
|
+
while (j < words.length && (words[j]?.text ?? '').startsWith('-')) {
|
|
501
|
+
const flag = words[j]?.text ?? '';
|
|
502
|
+
if (flag === '--') {
|
|
503
|
+
j++;
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
if (flag === '-i' || flag === '-0' || flag === '--ignore-environment') {
|
|
507
|
+
j++;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (flag === '-u' || flag === '--unset') {
|
|
511
|
+
j += 2;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (flag.startsWith('--unset=')) {
|
|
515
|
+
j++;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
known = false;
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
// An unrecognized flag is not a wrapper. Leave argv0 at `env` so the
|
|
522
|
+
// whole-string scan still sees the original command.
|
|
523
|
+
if (!known) {
|
|
524
|
+
i = envAt;
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
while (j < words.length && ENV_ASSIGNMENT.test(words[j]?.text ?? '')) j++;
|
|
528
|
+
i = j;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
return words.slice(i);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Shell interpreters whose `-c` operand is code, matched on the basename so `/bin/bash` counts. */
|
|
537
|
+
const SHELL_BINS = new Set(['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh', 'mksh', 'busybox-sh']);
|
|
538
|
+
|
|
539
|
+
/** True for a bare or path-qualified shell interpreter (`bash`, `/bin/bash`, `/usr/bin/env`-less). */
|
|
540
|
+
function isShellBin(name: string): boolean {
|
|
541
|
+
const base = name.slice(name.lastIndexOf('/') + 1);
|
|
542
|
+
return SHELL_BINS.has(base);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* These wrappers can forward a shell invocation. Their option grammar is deliberately
|
|
547
|
+
* not used to grant exemptions: inspect possible shell operands for destructive code,
|
|
548
|
+
* then retain the conservative scan even when every inspected script appears safe.
|
|
549
|
+
*/
|
|
550
|
+
const SHELL_WRAPPERS = new Set(['env', 'sudo', 'exec', 'nice', 'xargs']);
|
|
551
|
+
|
|
552
|
+
function classifyWrappedShell(words: readonly ShellWord[], depth: number): string | null {
|
|
553
|
+
const name = argvName(words[0]);
|
|
554
|
+
if (name === null || !SHELL_WRAPPERS.has(name)) return null;
|
|
555
|
+
for (let i = 1; i < words.length; i++) {
|
|
556
|
+
const word = words[i];
|
|
557
|
+
if (word === undefined || word.substitutions.length > 0 || !isShellBin(word.text)) continue;
|
|
558
|
+
const script = extractDashC(words.slice(i));
|
|
559
|
+
if (script === null || script === 'missing') continue;
|
|
560
|
+
const hit = classifyLexical(script, depth + 1);
|
|
561
|
+
if (hit !== null) return hit;
|
|
562
|
+
}
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** The `-c` script, `missing` when `-c` has no operand, or null when this is not `-c`. */
|
|
567
|
+
/** Shell options whose operand is a separate word, so `-c` detection must step over it. */
|
|
568
|
+
const SHELL_OPTIONS_WITH_VALUE = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file', '--rc-file']);
|
|
569
|
+
|
|
570
|
+
function extractDashC(words: readonly ShellWord[]): string | null | 'missing' {
|
|
571
|
+
let i = 1;
|
|
572
|
+
while (i < words.length) {
|
|
573
|
+
const token = words[i]?.text ?? '';
|
|
574
|
+
if (token === '--') return null;
|
|
575
|
+
// A bare word (`bash script.sh -c x`) ends the option list: `-c` after it is the script's
|
|
576
|
+
// own argument, not the shell's.
|
|
577
|
+
if ((!token.startsWith('-') && !token.startsWith('+')) || token === '-') break;
|
|
578
|
+
if (token.startsWith('--')) {
|
|
579
|
+
i += SHELL_OPTIONS_WITH_VALUE.has(token) ? 2 : 1;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (token.includes('c')) {
|
|
583
|
+
const script = words[i + 1];
|
|
584
|
+
if (script === undefined) return 'missing';
|
|
585
|
+
return script.text;
|
|
586
|
+
}
|
|
587
|
+
// `-O extglob -c …` / `-o noglob -c …`: the operand is a value, not the command.
|
|
588
|
+
i += SHELL_OPTIONS_WITH_VALUE.has(token) ? 2 : 1;
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** True when every operand is data for a standalone rg/grep, with no `--pre` command. */
|
|
594
|
+
function isLiteralSearch(words: readonly ShellWord[]): boolean {
|
|
595
|
+
const bin = words[0]?.text;
|
|
596
|
+
if ((bin !== 'rg' && bin !== 'grep') || (words[0]?.substitutions.length ?? 0) > 0) return false;
|
|
597
|
+
let options = true;
|
|
598
|
+
for (let i = 1; i < words.length; i++) {
|
|
599
|
+
const token = words[i]?.text ?? '';
|
|
600
|
+
if (options && token === '--') {
|
|
601
|
+
options = false;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (options && (token === '--pre' || token.startsWith('--pre='))) return false;
|
|
605
|
+
}
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function maskedRaw(command: string, segment: ShellSegment): string {
|
|
610
|
+
const chars = [...command.slice(segment.start, segment.end)];
|
|
611
|
+
for (const [start, end] of segment.substitutionSpans) {
|
|
612
|
+
for (let i = start; i < end; i++) {
|
|
613
|
+
const local = i - segment.start;
|
|
614
|
+
if (local >= 0 && local < chars.length) chars[local] = ' ';
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return chars.join('');
|
|
618
|
+
}
|
|
619
|
+
|
|
189
620
|
/** Return a human label for the destructive command, or null when the command is safe. */
|
|
190
621
|
export function classifyCommand(command: string): string | null {
|
|
622
|
+
return classifyLexical(command, 0);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function classifyLexical(command: string, depth: number): string | null {
|
|
626
|
+
if (depth > MAX_LEX_DEPTH) return classifyConservatively(command);
|
|
627
|
+
const segments = lexShell(command);
|
|
628
|
+
if (segments === null) return classifyConservatively(command);
|
|
629
|
+
const codePipelines = new Set(
|
|
630
|
+
segments.filter((segment) => !isDataConsumer(segment)).map((segment) => segment.pipeline),
|
|
631
|
+
);
|
|
632
|
+
for (const segment of segments) {
|
|
633
|
+
const hit = classifySegment(command, segment, depth, !codePipelines.has(segment.pipeline));
|
|
634
|
+
if (hit !== null) return hit;
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Commands that read a pipeline's flow as text: filters, viewers and plain formatters.
|
|
641
|
+
* A strict allowlist, so an unnamed consumer folds toward `ask` — the same search piped
|
|
642
|
+
* into a shell, an interpreter or an unknown wrapper emits code, not data.
|
|
643
|
+
*/
|
|
644
|
+
const DATA_CONSUMERS = new Set([
|
|
645
|
+
'cat',
|
|
646
|
+
'cut',
|
|
647
|
+
'env',
|
|
648
|
+
'grep',
|
|
649
|
+
'head',
|
|
650
|
+
'less',
|
|
651
|
+
'more',
|
|
652
|
+
'nl',
|
|
653
|
+
'rg',
|
|
654
|
+
'sort',
|
|
655
|
+
'tac',
|
|
656
|
+
'tail',
|
|
657
|
+
'tee',
|
|
658
|
+
'tr',
|
|
659
|
+
'uniq',
|
|
660
|
+
'wc',
|
|
661
|
+
]);
|
|
662
|
+
|
|
663
|
+
/** Basename of an argv0 word, or null when a substitution leaves the head unresolved. */
|
|
664
|
+
function argvName(word: ShellWord | undefined): string | null {
|
|
665
|
+
if (word === undefined || word.substitutions.length > 0) return null;
|
|
666
|
+
const slash = word.text.lastIndexOf('/');
|
|
667
|
+
return slash < 0 ? word.text : word.text.slice(slash + 1);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* True when this segment treats what flows into it as text. The argv0 basename is used so a
|
|
672
|
+
* path-qualified shell (`/bin/sh`) or a wrapper (`nice sh`) is not mistaken for a consumer;
|
|
673
|
+
* stripping the whole head away (`| env`) falls back to the original word.
|
|
674
|
+
*/
|
|
675
|
+
function isDataConsumer(segment: ShellSegment): boolean {
|
|
676
|
+
const stripped = stripWrappers(segment.words);
|
|
677
|
+
const name = argvName(stripped[0] ?? segment.words[0]);
|
|
678
|
+
return name !== null && DATA_CONSUMERS.has(name);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function classifySegment(
|
|
682
|
+
command: string,
|
|
683
|
+
segment: ShellSegment,
|
|
684
|
+
depth: number,
|
|
685
|
+
searchExemptable: boolean,
|
|
686
|
+
): string | null {
|
|
687
|
+
for (const word of segment.words) {
|
|
688
|
+
for (const body of word.substitutions) {
|
|
689
|
+
const hit = classifyLexical(body, depth + 1);
|
|
690
|
+
if (hit !== null) return hit;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const words = stripWrappers(segment.words);
|
|
694
|
+
if (words.length === 0) return null;
|
|
695
|
+
const head = words[0];
|
|
696
|
+
if (head !== undefined && head.substitutions.length === 0 && isShellBin(head.text)) {
|
|
697
|
+
const script = extractDashC(words);
|
|
698
|
+
if (script === 'missing') return classifyConservatively(maskedRaw(command, segment));
|
|
699
|
+
if (script !== null) return classifyLexical(script, depth + 1);
|
|
700
|
+
}
|
|
701
|
+
// A search piped into an executor (`rg 'rm -rf /' x | sh`) emits code, not data:
|
|
702
|
+
// drop the exemption and keep the whole-string scan.
|
|
703
|
+
if (searchExemptable && isLiteralSearch(words)) return null;
|
|
704
|
+
const wrappedHit = classifyWrappedShell(words, depth);
|
|
705
|
+
if (wrappedHit !== null) return wrappedHit;
|
|
706
|
+
const rawHit = classifyConservatively(command.slice(segment.start, segment.end));
|
|
707
|
+
if (rawHit !== null) return rawHit;
|
|
708
|
+
return classifyConservatively(words.map((word) => word.text).join(' '));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function classifyConservatively(command: string): string | null {
|
|
191
712
|
for (const rmMatch of command.matchAll(/\brm\b([^\n&|;]*)/g)) {
|
|
192
713
|
const args = rmMatch[1] ?? '';
|
|
193
714
|
const flags = parseFlags(args);
|
package/plugins/sp/plugin.json
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* `git push --force`; the lexical pass only removes that option span and never executes git or
|
|
18
18
|
* expands a substitution. The run cannot cross a command boundary (`;`, `&`, `|`, newline).
|
|
19
19
|
*
|
|
20
|
+
* Quoted operands of a standalone `rg` or `grep` are literal data, so a search
|
|
21
|
+
* pattern that only names a destructive command does not prompt. Substitutions,
|
|
22
|
+
* backticks, and a `-c` script from any shell interpreter (bare or path-qualified)
|
|
23
|
+
* are still classified. The decision stays in `classifyCommand`.
|
|
24
|
+
*
|
|
20
25
|
* **Safe exceptions:** `rm -rf` of well-known build/dependency caches (`node_modules`, `dist`,
|
|
21
26
|
* `.next`, `coverage`, `build`, `.turbo`, `.cache`) passes without a warning — deleting a rebuildable
|
|
22
27
|
* cache is routine, not dangerous.
|