@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.
Files changed (61) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +7 -0
  3. package/package.json +1 -1
  4. package/plugins/sp/lib/destructive-policy.ts +521 -0
  5. package/plugins/sp/plugin.json +1 -1
  6. package/plugins/sp/scripts/hooks/careful-guard.ts +5 -0
  7. package/spur.js +2235 -1292
  8. package/web/_astro/BoardApp.BpVPF1yi.js +1 -0
  9. package/web/_astro/{BoardApp.CNBpKuB6.js → BoardApp.H0GHCpML.js} +157 -157
  10. package/web/_astro/TaskDetail.C3fD4iNx.js +1 -0
  11. package/web/_astro/{arc.CLFbvaU4.js → arc.CwmuZ1Pw.js} +1 -1
  12. package/web/_astro/{architectureDiagram-3BPJPVTR.YYrMKtrm.js → architectureDiagram-3BPJPVTR.D2r2pAtD.js} +1 -1
  13. package/web/_astro/{blockDiagram-GPEHLZMM.D-Xzu32A.js → blockDiagram-GPEHLZMM.LRgyDihu.js} +1 -1
  14. package/web/_astro/{c4Diagram-AAUBKEIU.Cv7EV8QD.js → c4Diagram-AAUBKEIU.OscV13G7.js} +1 -1
  15. package/web/_astro/channel.OpZyofHt.js +1 -0
  16. package/web/_astro/{chunk-2J33WTMH.CkAKN-fF.js → chunk-2J33WTMH.BcbENXnC.js} +1 -1
  17. package/web/_astro/{chunk-4BX2VUAB.D-xu2Tgi.js → chunk-4BX2VUAB.CcRidR01.js} +1 -1
  18. package/web/_astro/{chunk-55IACEB6.yNQibVGY.js → chunk-55IACEB6.DxAFaIIp.js} +1 -1
  19. package/web/_astro/{chunk-727SXJPM.DlVIFHHZ.js → chunk-727SXJPM.Cjvuc3en.js} +1 -1
  20. package/web/_astro/{chunk-AQP2D5EJ.C-LGFaeS.js → chunk-AQP2D5EJ.B3GSEPXo.js} +1 -1
  21. package/web/_astro/{chunk-FMBD7UC4.Df2n26zT.js → chunk-FMBD7UC4.DbpwRXpL.js} +1 -1
  22. package/web/_astro/{chunk-ND2GUHAM.CV_k83Gj.js → chunk-ND2GUHAM.CdiLgZ9z.js} +1 -1
  23. package/web/_astro/{chunk-QZHKN3VN.BQQDO3h3.js → chunk-QZHKN3VN.DT_2ovxF.js} +1 -1
  24. package/web/_astro/{classDiagram-4FO5ZUOK.DkSMdA84.js → classDiagram-4FO5ZUOK.B4Y_Byb0.js} +1 -1
  25. package/web/_astro/{classDiagram-v2-Q7XG4LA2.DkSMdA84.js → classDiagram-v2-Q7XG4LA2.B4Y_Byb0.js} +1 -1
  26. package/web/_astro/{cose-bilkent-S5V4N54A.CCN7GwAZ.js → cose-bilkent-S5V4N54A.0UOSbCRW.js} +1 -1
  27. package/web/_astro/{cynefin-OW5HDTMX.CqQCttkM.js → cynefin-OW5HDTMX.iaVw7KUy.js} +1 -1
  28. package/web/_astro/{dagre-BM42HDAG.CCn6iftc.js → dagre-BM42HDAG.DzBLfYbl.js} +1 -1
  29. package/web/_astro/{diagram-2AECGRRQ.DImUVZK-.js → diagram-2AECGRRQ.CtoO_RXO.js} +1 -1
  30. package/web/_astro/{diagram-5GNKFQAL.BYNywZwV.js → diagram-5GNKFQAL.C1ah--0o.js} +1 -1
  31. package/web/_astro/{diagram-KO2AKTUF.LJZ9DjsJ.js → diagram-KO2AKTUF.DAawwAoM.js} +1 -1
  32. package/web/_astro/{diagram-LMA3HP47.CJ3YpVED.js → diagram-LMA3HP47.kC7Zg0ru.js} +1 -1
  33. package/web/_astro/{diagram-OG6HWLK6.CnVtq5_j.js → diagram-OG6HWLK6.B3dSXJ9m.js} +1 -1
  34. package/web/_astro/{erDiagram-TEJ5UH35.SdmuzZwT.js → erDiagram-TEJ5UH35.cVJXYJkv.js} +1 -1
  35. package/web/_astro/{flowDiagram-I6XJVG4X.C9V-B-nO.js → flowDiagram-I6XJVG4X.CwD6KgFD.js} +1 -1
  36. package/web/_astro/{ganttDiagram-6RSMTGT7.DT5v7SL5.js → ganttDiagram-6RSMTGT7.UB88xr_u.js} +1 -1
  37. package/web/_astro/{gitGraphDiagram-PVQCEYII.B3qURYBL.js → gitGraphDiagram-PVQCEYII.C4B3FNOY.js} +1 -1
  38. package/web/_astro/index.CKtaGArK.css +1 -0
  39. package/web/_astro/{infoDiagram-5YYISTIA.CHO0xT8l.js → infoDiagram-5YYISTIA.6QC564C4.js} +1 -1
  40. package/web/_astro/{ishikawaDiagram-YF4QCWOH.buALAOGh.js → ishikawaDiagram-YF4QCWOH.sQBtM0gc.js} +1 -1
  41. package/web/_astro/{journeyDiagram-JHISSGLW.DvKuIdbX.js → journeyDiagram-JHISSGLW.Dslglgu2.js} +1 -1
  42. package/web/_astro/{kanban-definition-UN3LZRKU.Dvoi9jdN.js → kanban-definition-UN3LZRKU.CPKaPyzi.js} +1 -1
  43. package/web/_astro/{linear.EsEfz8Ex.js → linear.DNHY6BCF.js} +1 -1
  44. package/web/_astro/{mermaid.core.Vk3ryVC7.js → mermaid.core.VoW0rvGv.js} +4 -4
  45. package/web/_astro/{mindmap-definition-RKZ34NQL.DG1pkymq.js → mindmap-definition-RKZ34NQL.jYZ-jCh7.js} +1 -1
  46. package/web/_astro/{pieDiagram-4H26LBE5.DAUmIdd6.js → pieDiagram-4H26LBE5.DQ0fapSh.js} +1 -1
  47. package/web/_astro/{quadrantDiagram-W4KKPZXB.Du3QUozl.js → quadrantDiagram-W4KKPZXB.CWF406ut.js} +1 -1
  48. package/web/_astro/{requirementDiagram-4Y6WPE33.neRi5GrQ.js → requirementDiagram-4Y6WPE33.BmUiiY0p.js} +1 -1
  49. package/web/_astro/{sankeyDiagram-5OEKKPKP.CMUtxA70.js → sankeyDiagram-5OEKKPKP.CzUeGGUK.js} +1 -1
  50. package/web/_astro/{sequenceDiagram-3UESZ5HK.CfYFLmYH.js → sequenceDiagram-3UESZ5HK.DKvkUeEk.js} +1 -1
  51. package/web/_astro/{stateDiagram-AJRCARHV.Bn4dmAad.js → stateDiagram-AJRCARHV.ByedXnFB.js} +1 -1
  52. package/web/_astro/{stateDiagram-v2-BHNVJYJU.BMNezvp-.js → stateDiagram-v2-BHNVJYJU.Bx7wyAlv.js} +1 -1
  53. package/web/_astro/{timeline-definition-PNZ67QCA.ByaDwsBX.js → timeline-definition-PNZ67QCA.Chzu_dUX.js} +1 -1
  54. package/web/_astro/{vennDiagram-CIIHVFJN.CUYU1Mc9.js → vennDiagram-CIIHVFJN.PuxG68DI.js} +1 -1
  55. package/web/_astro/{wardleyDiagram-YWT4CUSO.DpZZeJr2.js → wardleyDiagram-YWT4CUSO.C9ZKxfsC.js} +1 -1
  56. package/web/_astro/{xychartDiagram-2RQKCTM6.CWwI33U0.js → xychartDiagram-2RQKCTM6.B7qR-3HU.js} +1 -1
  57. package/web/index.html +2 -2
  58. package/web/_astro/BoardApp.DVWLxHcV.js +0 -1
  59. package/web/_astro/TaskDetail.BY6jO_3P.js +0 -1
  60. package/web/_astro/channel.DzHZuqj0.js +0 -1
  61. package/web/_astro/index.C8LjH5ri.css +0 -1
@@ -7,7 +7,7 @@
7
7
  "plugins": [
8
8
  {
9
9
  "name": "sp",
10
- "version": "0.4.25",
10
+ "version": "0.4.26",
11
11
  "source": "./plugins/sp"
12
12
  }
13
13
  ]
@@ -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.25",
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);
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics."
5
5
  }
@@ -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.