pi-warden 1.11.0 → 1.11.2

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/CHANGELOG.md CHANGED
@@ -8,6 +8,28 @@ How to keep this current: add the entry in the same pull request as the change,
8
8
 
9
9
  <!-- Empty. Next release starts here. -->
10
10
 
11
+ ## 1.11.2
12
+
13
+ ### Fixed
14
+
15
+ - The action guard's floor no longer holds a bash call for text the shell never runs. Every pattern check (the built-in rules, the SQL and git state checks, user `commandRules`, and path rules on the command surface) reads a command and a script body without shell comments, the same comment scan the judge request uses. A `#!/bin/sh` first line no longer keeps every quoted `echo`/`printf` message in scope (the data scan read it as a call to `sh`), and a message that holds `;`, `|`, or `&&` inside its quotes is no longer cut into commands. The hit lines of a script body leave out its comment lines.
16
+ - A `grep`, `sed`, or `rtk grep` of a file, with `\|` in its quoted pattern, no longer reads that file as a script that runs. Script detection cut the command at every `|`, also inside quotes, so `down=" scripts/x.py` read as an assignment and the file as the command; it now cuts only where the shell does. A command with a substitution, an unclosed quote, `eval`, or `sh -c` keeps the old cut, so every script read before is still read. ([#190](https://github.com/DevMortimer/pi-warden/issues/190))
17
+ - The floor reads line continuations as bash does. A delete split by a backslash-newline (`r\<newline>m -rf ~`, `rm \<newline>-rf ~`) was not held, and neither was a script run as `sh \<newline>a.sh`. The comment scan now joins each backslash-newline that bash removes: outside quotes, in double quotes and `${...}`, and in a heredoc body with an unquoted delimiter, but not in single quotes, `$'...'`, a quoted heredoc body, or a comment. A backslash-newline before a script run (`cd . && \<newline> sh a.sh`, also `npm run` and `make`) no longer hides the script from the floor or the ask gate.
18
+ - A delete after a here-string or a partly quoted heredoc delimiter is held again. The data scan found heredocs with a line regex that read `<<<` and a `<<` inside quotes as the start of a heredoc, and took `E` as the delimiter of `<<E"O"F`, so every later line was blanked as heredoc data. It now takes heredocs from the comment scan, which skips here-strings, quotes, and comments, removes quotes from the delimiter, and joins a backslash-newline in an unquoted body before it matches the delimiter; a command that scan cannot read keeps every line in scope.
19
+ - The ask gate reads the command the way the floor does, without shell comments and with quoted messages and heredoc data blanked. A push or a delete that appears only in a comment or in a quoted `echo` message no longer sends a judge request.
20
+
21
+ ### Tests
22
+
23
+ - A differential test runs each input through `bash -c` with a `touch "$MARK"` twin in place of the delete: the floor holds an input exactly when bash creates the marker (13 inputs it stops holding, 16 it keeps holding, among them a delete inside `$(...)` on a heredoc line that starts with `#`, and a delete on the line after a comment that holds an apostrophe). Unit tests pin the issue 190 command with its user rule, and every way of running a script (`sh`, `bash`, `./`, `.`, `source`, `npm run`), also next to a read of the same file.
24
+ - The differential test adds 28 inputs with backslash-newlines, here-strings, and quoted or split heredoc delimiters, typed and in script bodies with `#!/bin/sh`, `#!/bin/bash`, `#!/usr/bin/env bash`, or no first line; the comment-scan output test adds 8 inputs whose output bash must print unchanged. Unit tests hold each of them through `matchPatterns`, check that the ask gate still asks for a script named after a backslash-newline, and check that the comment, echo message, and `sed` shapes still run.
25
+ - A judge-call count through `evaluateAction` pins that a push only in a comment sends no request, and that the same push as a second command sends one.
26
+
27
+ ## 1.11.1
28
+
29
+ ### Fixed
30
+
31
+ - `/warden index` now finalizes the entries-only files its own prompt asks the model to write. The entries-only fallback ran only when the strict read had already found a complete file, so an `{ "entries": [...] }` file never got its metadata, its rejections were never reported, and every later session loaded the index as missing and repeated the "run /warden index" nudge. The fallback now runs whenever the strict read finds no file, and rejected entries are reported. ([#185](https://github.com/DevMortimer/pi-warden/issues/185))
32
+
11
33
  ## 1.11.0
12
34
 
13
35
  ### Added
package/dist/extension.js CHANGED
@@ -4013,8 +4013,9 @@ export default function wardenExtension(host) {
4013
4013
  // Accept both the full format and entries-only; warden always writes the final metadata.
4014
4014
  let globalResult = rawGlobal ? validateIndex(rawGlobal) : { file: undefined, rejections: [] };
4015
4015
  let projectResult = rawProject ? validateIndex(rawProject) : { file: undefined, rejections: [] };
4016
- // Entries-only fallback: read raw JSON and extract entries array
4017
- if (!globalResult.file && rawGlobal) {
4016
+ // Entries-only fallback: read raw JSON and extract entries array. It must not depend on rawGlobal/rawProject:
4017
+ // the strict read returns nothing for the entries-only shape the index prompt asks for.
4018
+ if (!globalResult.file) {
4018
4019
  try {
4019
4020
  const parsed = JSON.parse(readFileSync(globalPath, "utf8"));
4020
4021
  if (parsed && typeof parsed === "object" && Array.isArray(parsed.entries)) {
@@ -4027,7 +4028,7 @@ export default function wardenExtension(host) {
4027
4028
  record(ctx, config, "conscience", `global entries-only parse: ${msg}`, ["trigger: warden_index"]);
4028
4029
  }
4029
4030
  }
4030
- if (!projectResult.file && rawProject) {
4031
+ if (!projectResult.file) {
4031
4032
  try {
4032
4033
  const parsed = JSON.parse(readFileSync(projectPath, "utf8"));
4033
4034
  if (parsed && typeof parsed === "object" && Array.isArray(parsed.entries)) {
@@ -4043,9 +4044,9 @@ export default function wardenExtension(host) {
4043
4044
  }
4044
4045
  // Collect rejections from both files
4045
4046
  const allRejections = [];
4046
- if (rawGlobal && !globalResult.file)
4047
+ if (!globalResult.file)
4047
4048
  allRejections.push(...globalResult.rejections.map(r => `global index: entry ${r}`));
4048
- if (rawProject && !projectResult.file)
4049
+ if (!projectResult.file)
4049
4050
  allRejections.push(...projectResult.rejections.map(r => `project index: entry ${r}`));
4050
4051
  if (allRejections.length > 0) {
4051
4052
  report(`Index rejected:\n${allRejections.map(r => ` ${r}`).join("\n")}\nOld files kept.`, "warning");
package/dist/guard.d.ts CHANGED
@@ -269,9 +269,11 @@ export interface ScannedCommand {
269
269
  */
270
270
  export declare function stripDataText(command: string): ScannedCommand;
271
271
  /**
272
- * The command text Jev reads: shell comments removed; quotes, escapes, heredoc bodies, `$#`, `${#…}`, and a `#`
273
- * mid-word kept. Quote state carries across lines. A construct the scan does not model (backticks, arithmetic, `case`
274
- * inside `$(…)`, quotes inside `${…}`), or an unclosed quote, returns the command unchanged.
272
+ * The command text Jev and the pattern floor read: shell comments removed, and each backslash-newline the shell removes
273
+ * joined (outside quotes, in double quotes and `${…}`, and in a heredoc body with an unquoted delimiter); quotes,
274
+ * escapes, heredoc bodies, `$#`, `${#…}`, and a `#` mid-word kept. Quote state carries across lines. A construct the
275
+ * scan does not model (backticks, arithmetic, `case` inside `$(…)`, quotes inside `${…}`), or an unclosed quote,
276
+ * returns the command unchanged.
275
277
  */
276
278
  export declare function stripShellComments(command: string): string;
277
279
  /**
package/dist/guard.js CHANGED
@@ -11,7 +11,7 @@ import { resolveRulesFile } from "./rules-file.js";
11
11
  import { mergeWrites, shellWrites } from "./shell-writes.js";
12
12
  import { COMMAND_TOOLS, commandOf } from "./tools.js";
13
13
  import { actionAskGate } from "./ask-gate.js";
14
- import { scriptSources } from "./script-bodies.js";
14
+ import { scriptSources, shellParts } from "./script-bodies.js";
15
15
  import { actionTokens, DEFAULT_TEMPLATES, renderTemplate } from "./widget.js";
16
16
  export const SLOP_SYMPTOMS = ["stub", "comments", "dead", "hedging"];
17
17
  const LEVEL_RANK = { allow: 0, warn: 1, confirm: 2, deny: 3 };
@@ -88,7 +88,6 @@ const GIT_MESSAGE_SUBCOMMANDS = new Set(["commit", "tag", "notes", "merge", "sta
88
88
  /** Interpreters whose stdin script can still run shell commands; their heredoc bodies stay in scope when they do. */
89
89
  const INTERPRETERS = /^(?:python[\d.]*|node|ruby|perl|php|deno|bun|tsx|Rscript|lua[\d.]*)$/;
90
90
  const EXEC_CALLS = /\b(?:os\.system|os\.popen|os\.exec\w*|subprocess|child_process|execSync|spawnSync|execFileSync|spawn\(|exec\(|system\(|popen\(|shell_exec|passthru|proc_open|Open3|IO\.popen|Deno\.run|Deno\.Command|Bun\.spawn|Bun\.\$|%x[\[{(]|`[^`\n]*\b(?:rm|git|dd|mkfs|kubectl|terraform)\b)/;
91
- const HEREDOC = /<<-?\s*(?:"(\w+)"|'(\w+)'|(\\)?(\w+))/;
92
91
  const SUBSTITUTION = /\$\(|`/;
93
92
  function headOf(segment) {
94
93
  const tokens = segment.trim().split(/\s+/);
@@ -297,40 +296,31 @@ function blankMessageFlags(command) {
297
296
  * (`python3 - <<EOF`) are kept when the script calls out to a shell or process API.
298
297
  */
299
298
  export function stripDataText(command) {
300
- const lines = command.split("\n");
301
- const out = [];
302
299
  let stripped = false;
303
- for (let index = 0; index < lines.length; index++) {
304
- const line = lines[index];
305
- const heredoc = HEREDOC.exec(line);
306
- if (!heredoc) {
307
- out.push(line);
308
- continue;
309
- }
310
- const delimiter = heredoc[1] ?? heredoc[2] ?? heredoc[4];
311
- // 'EOF', "EOF", and \EOF make the body literal; a bare EOF body is expanded, so a substitution inside it runs.
312
- const literal = heredoc[4] === undefined || heredoc[3] !== undefined;
313
- const body = [];
314
- let close = index + 1;
315
- while (close < lines.length && lines[close].replace(/^\t+/, "") !== delimiter)
316
- body.push(lines[close]), close++;
317
- const bodyText = body.join("\n");
300
+ // Only a `<<` the shell scan reads as one starts a body: not a here-string `<<<`, nor `<<` in quotes or a comment. A
301
+ // command the scan cannot read keeps every line in scope.
302
+ const heredocs = scanShell(command)?.heredocs ?? [];
303
+ let scanned = "";
304
+ let cursor = 0;
305
+ for (const heredoc of heredocs) {
306
+ const line = command.slice(heredoc.line, heredoc.start - 1);
307
+ const body = command.slice(heredoc.start, heredoc.end);
308
+ const bodyText = body.replace(/\n$/, "");
318
309
  // The whole pipeline on the heredoc line counts: `cat <<EOF | bash` executes the body as much as `bash <<EOF` does.
319
310
  const heads = splitShell(line).map(segment => headOf(segment) ?? "");
320
- const consumer = headOf(line.slice(0, heredoc.index)) ?? "";
321
- const executed = heads.some(head => SHELL_SINKS.has(head)) || (INTERPRETERS.test(consumer) && EXEC_CALLS.test(bodyText)) || (!literal && SUBSTITUTION.test(bodyText));
322
- out.push(line);
323
- if (executed)
324
- out.push(...body);
325
- else if (body.length) {
326
- out.push(`[heredoc body: ${body.length} lines of data]`);
327
- stripped = true;
328
- }
329
- if (close < lines.length)
330
- out.push(lines[close]);
331
- index = close;
332
- }
333
- const scanned = out.join("\n");
311
+ const consumer = headOf(line.slice(0, heredoc.operator - heredoc.line)) ?? "";
312
+ // 'EOF', "EOF", E"O"F, and \EOF make the body literal; a bare EOF body is expanded, so a substitution inside it runs.
313
+ const executed = heads.some(head => SHELL_SINKS.has(head)) || (INTERPRETERS.test(consumer) && EXEC_CALLS.test(bodyText)) || (!heredoc.literal && SUBSTITUTION.test(bodyText));
314
+ scanned += command.slice(cursor, heredoc.start);
315
+ cursor = heredoc.end;
316
+ if (executed || !body) {
317
+ scanned += body;
318
+ continue;
319
+ }
320
+ scanned += `[heredoc body: ${bodyText.split("\n").length} lines of data]${body.endsWith("\n") ? "\n" : ""}`;
321
+ stripped = true;
322
+ }
323
+ scanned += command.slice(cursor);
334
324
  const joined = blankMessageFlags(scanned);
335
325
  const segments = splitShell(joined);
336
326
  // A shell sink anywhere may run text written earlier in the same command (`cat <<EOF > run.sh` then `bash run.sh`), so nothing is treated as data.
@@ -341,7 +331,12 @@ export function stripDataText(command) {
341
331
  let text = joined;
342
332
  if (joined !== scanned)
343
333
  stripped = true;
344
- for (const segment of segments) {
334
+ // A printed or searched message is cut where the shell cuts, so `echo "a; b"` stays one argument. git and gh keep the
335
+ // wider cut: `blankMessageFlags` already blanked the flags that take a message, and `gh pr view --body` takes none.
336
+ const parts = shellParts(joined);
337
+ const printsData = (segment) => DATA_HEADS.has(headOf(segment) ?? "");
338
+ const dataSegments = parts === undefined ? segments : [...parts.filter(printsData), ...segments.filter(segment => !printsData(segment))];
339
+ for (const segment of dataSegments) {
345
340
  if (!isDataSegment(segment) || !/["']/.test(segment))
346
341
  continue;
347
342
  const blanked = blankQuotes(segment);
@@ -352,29 +347,49 @@ export function stripDataText(command) {
352
347
  }
353
348
  return { text, stripped };
354
349
  }
355
- // A shell comment can carry text the agent wrote to sway Jev; the request state must read only what the shell runs.
350
+ // A shell comment can carry text the agent wrote to sway Jev, or delete text that never runs; Jev and the floor read only
351
+ // what the shell runs.
356
352
  /**
357
- * The command text Jev reads: shell comments removed; quotes, escapes, heredoc bodies, `$#`, `${#…}`, and a `#`
358
- * mid-word kept. Quote state carries across lines. A construct the scan does not model (backticks, arithmetic, `case`
359
- * inside `$(…)`, quotes inside `${…}`), or an unclosed quote, returns the command unchanged.
353
+ * The command text Jev and the pattern floor read: shell comments removed, and each backslash-newline the shell removes
354
+ * joined (outside quotes, in double quotes and `${…}`, and in a heredoc body with an unquoted delimiter); quotes,
355
+ * escapes, heredoc bodies, `$#`, `${#…}`, and a `#` mid-word kept. Quote state carries across lines. A construct the
356
+ * scan does not model (backticks, arithmetic, `case` inside `$(…)`, quotes inside `${…}`), or an unclosed quote,
357
+ * returns the command unchanged.
360
358
  */
361
359
  export function stripShellComments(command) {
360
+ return scanShell(command)?.text ?? command;
361
+ }
362
+ /** Whether a line ends in a backslash that escapes the new line after it, not one that a backslash before it escapes. */
363
+ function continues(line) {
364
+ let count = 0;
365
+ while (count < line.length && line[line.length - 1 - count] === "\\")
366
+ count++;
367
+ return count % 2 === 1;
368
+ }
369
+ /** `stripShellComments` with the heredocs it read; undefined where it returns the command unchanged. */
370
+ function scanShell(command) {
362
371
  const stack = [];
363
372
  const depths = [];
364
373
  const heredocs = [];
374
+ const found = [];
365
375
  let out = "";
366
376
  // Text before this length is not trailing blank space, so a removed comment takes only the blanks before it.
367
377
  let kept = 0;
368
378
  let wordStart = true;
369
379
  let index = 0;
380
+ // Where the current logical line starts: a backslash-newline continues it.
381
+ let lineStart = 0;
370
382
  const emit = (text, blank = false) => { out += text; if (!blank)
371
383
  kept = out.length; };
384
+ // The offset of the next character bash reads at `at`: a backslash-newline before it is gone, so `<\<nl><` is `<<`.
385
+ const after = (at) => { while (command.startsWith("\\\n", at))
386
+ at += 2; return at; };
372
387
  // A heredoc delimiter word, quotes and backslashes removed; undefined when it is not a plain word.
373
388
  const delimiterWord = () => {
374
389
  let word = "";
375
390
  let raw = "";
376
- while (index < command.length && /[ \t]/.test(command[index]))
377
- raw += command[index++];
391
+ for (index = after(index); index < command.length && /[ \t]/.test(command[index]); index = after(index + 1))
392
+ raw += command[index];
378
393
  while (index < command.length && !/[\s;|&<>()]/.test(command[index])) {
379
394
  const char = command[index];
380
395
  if (char === "'" || char === "\"") {
@@ -386,8 +401,12 @@ export function stripShellComments(command) {
386
401
  index = close + 1;
387
402
  continue;
388
403
  }
404
+ if (char === "\\" && command[index + 1] === "\n") {
405
+ index += 2;
406
+ continue;
407
+ }
389
408
  if (char === "\\") {
390
- if (index + 1 >= command.length || command[index + 1] === "\n")
409
+ if (index + 1 >= command.length)
391
410
  return undefined;
392
411
  word += command[index + 1];
393
412
  raw += command.slice(index, index + 2);
@@ -403,7 +422,7 @@ export function stripShellComments(command) {
403
422
  if (!word)
404
423
  return undefined;
405
424
  emit(raw);
406
- return word;
425
+ return { word, literal: /['"\\]/.test(raw) };
407
426
  };
408
427
  while (index < command.length) {
409
428
  const char = command[index];
@@ -417,7 +436,9 @@ export function stripShellComments(command) {
417
436
  }
418
437
  if (context === "ansi" || context === "double" || context === "brace") {
419
438
  if (char === "\\") {
420
- emit(command.slice(index, index + 2));
439
+ // bash keeps a backslash-newline inside `$'…'` (measured, bash 3.2) and removes it in `"…"` and `${…}`.
440
+ if (command[index + 1] !== "\n" || context === "ansi")
441
+ emit(command.slice(index, index + 2));
421
442
  index += 2;
422
443
  continue;
423
444
  }
@@ -429,10 +450,10 @@ export function stripShellComments(command) {
429
450
  continue;
430
451
  }
431
452
  if (char === "`")
432
- return command;
453
+ return undefined;
433
454
  if (context === "brace") {
434
455
  if (char === "'" || char === "\"" || char === "$")
435
- return command;
456
+ return undefined;
436
457
  emit(char);
437
458
  index++;
438
459
  if (char === "}")
@@ -445,33 +466,36 @@ export function stripShellComments(command) {
445
466
  stack.pop();
446
467
  continue;
447
468
  }
448
- if (char === "$" && command[index + 1] === "(") {
449
- if (command[index + 2] === "(")
450
- return command;
469
+ const next = char === "$" ? after(index + 1) : index;
470
+ if (char === "$" && command[next] === "(") {
471
+ if (command[after(next + 1)] === "(")
472
+ return undefined;
451
473
  emit("$(");
452
- index += 2;
474
+ index = next + 1;
453
475
  stack.push("subshell");
454
476
  depths.push(0);
455
477
  wordStart = true;
456
478
  continue;
457
479
  }
458
- if (char === "$" && command[index + 1] === "{") {
480
+ if (char === "$" && command[next] === "{") {
459
481
  emit("${");
460
- index += 2;
482
+ index = next + 1;
461
483
  stack.push("brace");
462
484
  continue;
463
485
  }
464
- if (char === "$" && command[index + 1] === "[")
465
- return command;
486
+ if (char === "$" && command[next] === "[")
487
+ return undefined;
466
488
  emit(char);
467
489
  index++;
468
490
  continue;
469
491
  }
470
492
  // Unquoted: the top level or inside `$(…)`.
471
493
  if (char === "\\") {
472
- emit(command.slice(index, index + 2));
473
- if (command[index + 1] !== "\n")
494
+ // A backslash-newline is gone before bash reads words: `r\<nl>m` runs `rm`, and `true &&\<nl>sh a.sh` runs `sh`.
495
+ if (command[index + 1] !== "\n") {
496
+ emit(command.slice(index, index + 2));
474
497
  wordStart = false;
498
+ }
475
499
  index += 2;
476
500
  continue;
477
501
  }
@@ -485,16 +509,33 @@ export function stripShellComments(command) {
485
509
  emit(char);
486
510
  index++;
487
511
  wordStart = true;
488
- for (const { delimiter, tabs } of heredocs.splice(0)) {
512
+ for (const { delimiter, tabs, literal, line: operatorLine, operator } of heredocs.splice(0)) {
513
+ const start = index;
514
+ let end = command.length;
489
515
  while (index < command.length) {
490
- const lineEnd = command.indexOf("\n", index);
491
- const line = command.slice(index, lineEnd < 0 ? command.length : lineEnd);
516
+ const bodyLine = index;
517
+ let line = "";
518
+ let lineEnd;
519
+ // An unquoted body joins a backslash-newline before bash matches the delimiter (measured, bash 3.2): `EO\<nl>F` ends it.
520
+ for (;;) {
521
+ lineEnd = command.indexOf("\n", index);
522
+ const piece = command.slice(index, lineEnd < 0 ? command.length : lineEnd);
523
+ index = lineEnd < 0 ? command.length : lineEnd + 1;
524
+ if (literal || lineEnd < 0 || !continues(piece)) {
525
+ line += piece;
526
+ break;
527
+ }
528
+ line += piece.slice(0, -1);
529
+ }
492
530
  emit(lineEnd < 0 ? line : `${line}\n`);
493
- index = lineEnd < 0 ? command.length : lineEnd + 1;
494
- if ((tabs ? line.replace(/^\t+/, "") : line) === delimiter)
531
+ if ((tabs ? line.replace(/^\t+/, "") : line) === delimiter) {
532
+ end = bodyLine;
495
533
  break;
534
+ }
496
535
  }
536
+ found.push({ line: operatorLine, operator, start, end, literal });
497
537
  }
538
+ lineStart = index;
498
539
  continue;
499
540
  }
500
541
  if (char === " " || char === "\t") {
@@ -504,7 +545,7 @@ export function stripShellComments(command) {
504
545
  continue;
505
546
  }
506
547
  if (char === "`")
507
- return command;
548
+ return undefined;
508
549
  if (char === "'") {
509
550
  emit(char);
510
551
  index++;
@@ -520,26 +561,27 @@ export function stripShellComments(command) {
520
561
  continue;
521
562
  }
522
563
  if (char === "$") {
523
- const next = command[index + 1];
564
+ const at = after(index + 1);
565
+ const next = command[at];
524
566
  if (next === "'") {
525
567
  emit("$'");
526
- index += 2;
568
+ index = at + 1;
527
569
  stack.push("ansi");
528
570
  wordStart = false;
529
571
  continue;
530
572
  }
531
573
  if (next === "\"") {
532
574
  emit("$\"");
533
- index += 2;
575
+ index = at + 1;
534
576
  stack.push("double");
535
577
  wordStart = false;
536
578
  continue;
537
579
  }
538
- if (next === "(" && command[index + 2] === "(")
539
- return command;
580
+ if (next === "(" && command[after(at + 1)] === "(")
581
+ return undefined;
540
582
  if (next === "(") {
541
583
  emit("$(");
542
- index += 2;
584
+ index = at + 1;
543
585
  stack.push("subshell");
544
586
  depths.push(0);
545
587
  wordStart = true;
@@ -547,40 +589,42 @@ export function stripShellComments(command) {
547
589
  }
548
590
  if (next === "{") {
549
591
  emit("${");
550
- index += 2;
592
+ index = at + 1;
551
593
  stack.push("brace");
552
594
  wordStart = false;
553
595
  continue;
554
596
  }
555
597
  if (next === "[")
556
- return command;
598
+ return undefined;
557
599
  emit(char);
558
600
  index++;
559
601
  wordStart = false;
560
602
  continue;
561
603
  }
562
- if (char === "(" && command[index + 1] === "(")
563
- return command;
564
- if (char === "<" && command.startsWith("<<<", index)) {
565
- emit("<<<");
566
- index += 3;
567
- wordStart = true;
568
- continue;
569
- }
570
- if (char === "<" && command[index + 1] === "<") {
571
- const tabs = command[index + 2] === "-";
604
+ if (char === "(" && command[after(index + 1)] === "(")
605
+ return undefined;
606
+ if (char === "<" && command[after(index + 1)] === "<") {
607
+ const third = after(after(index + 1) + 1);
608
+ if (command[third] === "<") {
609
+ emit("<<<");
610
+ index = third + 1;
611
+ wordStart = true;
612
+ continue;
613
+ }
614
+ const tabs = command[third] === "-";
615
+ const operator = index;
572
616
  emit(tabs ? "<<-" : "<<");
573
- index += tabs ? 3 : 2;
617
+ index = tabs ? third + 1 : third;
574
618
  const delimiter = delimiterWord();
575
619
  if (delimiter === undefined)
576
- return command;
577
- heredocs.push({ delimiter, tabs });
620
+ return undefined;
621
+ heredocs.push({ delimiter: delimiter.word, tabs, literal: delimiter.literal, line: lineStart, operator });
578
622
  wordStart = false;
579
623
  continue;
580
624
  }
581
625
  if (context === "subshell") {
582
626
  if (wordStart && /^case\b/.test(command.slice(index)))
583
- return command;
627
+ return undefined;
584
628
  if (char === "(")
585
629
  depths[depths.length - 1]++;
586
630
  if (char === ")" && depths.at(-1) === 0) {
@@ -599,8 +643,8 @@ export function stripShellComments(command) {
599
643
  wordStart = /[;|&()<>]/.test(char);
600
644
  }
601
645
  if (stack.length || heredocs.length)
602
- return command;
603
- return out;
646
+ return undefined;
647
+ return { text: out, heredocs: found };
604
648
  }
605
649
  /** The shell's own working directory, as a `DirPath`. */
606
650
  const cwdPath = (cwd) => (cwd === undefined ? { kind: "unknown" } : { kind: "path", path: cwd });
@@ -2022,8 +2066,8 @@ function compileUserRule(raw, defaultSeverity) {
2022
2066
  return undefined;
2023
2067
  }
2024
2068
  }
2025
- /** The built-in shell rules and user command rules for one command body. `raw` is the text as written; `stripDataText`
2026
- * blanks the data that is not a command. Deduplicated by id. */
2069
+ /** The built-in shell rules and user command rules for one command body. `raw` is the text without shell comments
2070
+ * (`stripShellComments`); `stripDataText` blanks the data that is not a command. Deduplicated by id. */
2027
2071
  function commandPatternHits(raw, cwd, options, exempt) {
2028
2072
  const hits = new Map();
2029
2073
  const add = (hit) => { if (hit && !hits.has(hit.id))
@@ -2119,17 +2163,19 @@ export function matchPatterns(tool, input, cwd, options) {
2119
2163
  const view = commandOf(tool, input);
2120
2164
  const raw = view?.command;
2121
2165
  if (raw) {
2122
- merge(commandPatternHits(raw, cwd, options, exempt));
2166
+ const code = stripShellComments(raw);
2167
+ merge(commandPatternHits(code, cwd, options, exempt));
2123
2168
  // The bodies of scripts the command runs are read one level deep: a script the body calls is not read again.
2124
2169
  if (view?.shell) {
2125
- const sources = options?.scripts ?? scriptSources(stripDataText(raw).text, cwd);
2170
+ const sources = options?.scripts ?? scriptSources(stripDataText(code).text, cwd);
2126
2171
  for (const source of sources) {
2127
2172
  if (source.kind !== "shell")
2128
2173
  continue;
2129
- const bodyHits = commandPatternHits(source.body, cwd, options, exempt);
2174
+ const body = stripShellComments(source.body);
2175
+ const bodyHits = commandPatternHits(body, cwd, options, exempt);
2130
2176
  if (!bodyHits.length)
2131
2177
  continue;
2132
- const lines = matchingLines(source.body, options, exempt);
2178
+ const lines = matchingLines(body, options, exempt);
2133
2179
  merge(bodyHits.map(hit => ({ ...hit, lines })), source.source);
2134
2180
  }
2135
2181
  }
@@ -2292,7 +2338,7 @@ export function matchPathRules(tool, input, cwd, rules, exempt) {
2292
2338
  };
2293
2339
  const raw = commandOf(tool, input)?.command;
2294
2340
  if (raw) {
2295
- const text = stripDataText(raw).text;
2341
+ const text = stripDataText(stripShellComments(raw)).text;
2296
2342
  const sinks = writeSinkTargets(text);
2297
2343
  const mentioned = (rule) => mentionRegex(rule).some(re => re.test(text));
2298
2344
  const sinkWrite = (rule) => sinks.some(target => pathRuleMatches(rule, target));
@@ -2933,12 +2979,14 @@ async function judgeAction(action, options) {
2933
2979
  const withPlan = (verdict) => (plan ? { ...verdict, plan } : verdict);
2934
2980
  const view = commandOf(action.tool, action.input);
2935
2981
  // Read the script bodies once: the floor labels their hits and the ask gate reads their shapes from the same list.
2936
- const scriptBodies = view?.shell ? scriptSources(stripDataText(view.command).text, action.cwd) : [];
2982
+ // The text the shell runs, as the floor reads it: comments removed and backslash-newlines joined, then data blanked.
2983
+ const shellText = view?.shell ? stripDataText(stripShellComments(view.command)).text : undefined;
2984
+ const scriptBodies = shellText === undefined ? [] : scriptSources(shellText, action.cwd);
2937
2985
  const patterns = matchPatterns(action.tool, action.input, action.cwd, { commandRules: config.commandRules, commandDenyRules: config.commandDenyRules, exemptRules: config.exemptRules, pathRules: config.pathRules, scripts: scriptBodies, scratch: options.scratch, scratchPaths: options.scratchPaths, movedIn: options.movedIn });
2938
2986
  // A script body the command runs that hit the floor: its lines ride the request, and the call is worth a judge even
2939
2987
  // when the gate would have left the outer command offline.
2940
- // The floor's hit lines keep the body's comments; Jev reads lines of the whole body with its comments stripped, so a
2941
- // multi-line string keeps its quote state and a code line that starts with `#` inside it stays.
2988
+ // Lines of the whole body with its comments stripped, as the floor reads them, so a multi-line string keeps its quote
2989
+ // state and a code line that starts with `#` inside it stays.
2942
2990
  const hitSources = new Set(patterns.flatMap(hit => hit.via ?? []));
2943
2991
  const lineOptions = { commandRules: config.commandRules, commandDenyRules: config.commandDenyRules };
2944
2992
  const scriptLines = capScriptLines(scriptBodies
@@ -3044,7 +3092,7 @@ async function judgeAction(action, options) {
3044
3092
  };
3045
3093
  // Ask gate: a call that cannot change anything the agent sees is decided by the pattern pass and the floor alone.
3046
3094
  if (config.ask?.enabled !== false) {
3047
- const decision = actionAskGate(action.tool, action.input, view?.shell ? stripDataText(view.command).text : undefined, action.cwd, scriptBodies);
3095
+ const decision = actionAskGate(action.tool, action.input, shellText, action.cwd, scriptBodies);
3048
3096
  if (!decision.ask && !bodyHit) {
3049
3097
  applyFloor();
3050
3098
  return withPlan({ level, source: "pattern", summary, patterns, reasons, notAsked: decision.why });
@@ -9,6 +9,14 @@ export interface ScriptSource {
9
9
  }
10
10
  /** The most of a body that is read; a larger file is left alone. */
11
11
  export declare const MAX_SCRIPT_BYTES: number;
12
+ /**
13
+ * The parts of a command, cut where the shell cuts them: at a new line, `;`, `&`, and `|` outside quotes, comments, and
14
+ * heredoc bodies, and never at a backslash-escaped character. A heredoc body is cut at every separator, as a body fed to
15
+ * a shell runs. `joined` drops each backslash-newline the shell removes (outside quotes and in double quotes), so a part
16
+ * reads as bash runs it; without it each part is a slice of the command. Undefined when the scan cannot be sure: an
17
+ * unclosed quote or heredoc, a substitution, or arithmetic.
18
+ */
19
+ export declare function shellParts(command: string, joined?: boolean): string[] | undefined;
12
20
  /**
13
21
  * The bodies a shell command runs from disk: shell scripts, `source`/`.`, package managers, make, interpreter files,
14
22
  * and executable paths. One level only: this function is never called on a body it reads.
@@ -41,9 +41,152 @@ function segmentOf(part) {
41
41
  return undefined;
42
42
  return { head: basename(rawHead), rawHead, args: tokens.slice(index + 1) };
43
43
  }
44
- function partsOf(command) {
44
+ function separatorParts(command) {
45
45
  return command.split(/\n|;|&&|\|\||\||&/).map(part => part.trim()).filter(Boolean);
46
46
  }
47
+ /**
48
+ * The parts of a command, cut where the shell cuts them: at a new line, `;`, `&`, and `|` outside quotes, comments, and
49
+ * heredoc bodies, and never at a backslash-escaped character. A heredoc body is cut at every separator, as a body fed to
50
+ * a shell runs. `joined` drops each backslash-newline the shell removes (outside quotes and in double quotes), so a part
51
+ * reads as bash runs it; without it each part is a slice of the command. Undefined when the scan cannot be sure: an
52
+ * unclosed quote or heredoc, a substitution, or arithmetic.
53
+ */
54
+ export function shellParts(command, joined = false) {
55
+ const parts = [];
56
+ const heredocs = [];
57
+ /** Offsets of the backslash of each backslash-newline the shell removes, in order. */
58
+ const joins = [];
59
+ let nextJoin = 0;
60
+ let start = 0;
61
+ let wordStart = true;
62
+ let index = 0;
63
+ const slice = (from, to) => {
64
+ if (!joined)
65
+ return command.slice(from, to);
66
+ let text = "";
67
+ let at = from;
68
+ for (; nextJoin < joins.length && joins[nextJoin] < to; nextJoin++) {
69
+ if (joins[nextJoin] < from)
70
+ continue;
71
+ text += command.slice(at, joins[nextJoin]);
72
+ at = joins[nextJoin] + 2;
73
+ }
74
+ return text + command.slice(at, to);
75
+ };
76
+ const cut = (end, next) => { parts.push(slice(start, end)); start = next; };
77
+ // The end of a quoted run that starts at `open`, or -1 when it does not close; `escapes` honours a backslash inside.
78
+ const closing = (open, quote, escapes) => {
79
+ for (let at = open; at < command.length; at++) {
80
+ const char = command[at];
81
+ if (escapes && char === "\\") {
82
+ if (quote === "\"" && command[at + 1] === "\n")
83
+ joins.push(at);
84
+ at++;
85
+ continue;
86
+ }
87
+ if (char === quote)
88
+ return at;
89
+ if (quote === "\"" && (char === "`" || (char === "$" && command[at + 1] === "(")))
90
+ return -1;
91
+ }
92
+ return -1;
93
+ };
94
+ while (index < command.length) {
95
+ const char = command[index];
96
+ if (char === "\\") {
97
+ if (command[index + 1] === "\n")
98
+ joins.push(index);
99
+ else
100
+ wordStart = false;
101
+ index += 2;
102
+ continue;
103
+ }
104
+ if (char === "`" || (char === "$" && command[index + 1] === "(") || (char === "(" && command[index + 1] === "("))
105
+ return undefined;
106
+ if (char === "'" || char === "\"" || (char === "$" && (command[index + 1] === "'" || command[index + 1] === "\""))) {
107
+ const open = char === "$" ? index + 1 : index;
108
+ const quote = command[open];
109
+ const end = closing(open + 1, quote, quote === "\"" || char === "$");
110
+ if (end < 0)
111
+ return undefined;
112
+ index = end + 1;
113
+ wordStart = false;
114
+ continue;
115
+ }
116
+ if (char === "$" && command[index + 1] === "{") {
117
+ const end = command.indexOf("}", index + 2);
118
+ if (end < 0 || /['"`\n]|\$[({]/.test(command.slice(index + 2, end)))
119
+ return undefined;
120
+ index = end + 1;
121
+ wordStart = false;
122
+ continue;
123
+ }
124
+ if (char === "#" && wordStart) {
125
+ const end = command.indexOf("\n", index);
126
+ cut(index, end < 0 ? command.length : end);
127
+ index = end < 0 ? command.length : end;
128
+ continue;
129
+ }
130
+ if (char === "\n") {
131
+ cut(index, index + 1);
132
+ index++;
133
+ wordStart = true;
134
+ for (const { delimiter, tabs } of heredocs.splice(0)) {
135
+ let closed = false;
136
+ while (index < command.length && !closed) {
137
+ const lineEnd = command.indexOf("\n", index);
138
+ const line = command.slice(index, lineEnd < 0 ? command.length : lineEnd);
139
+ index = lineEnd < 0 ? command.length : lineEnd + 1;
140
+ closed = (tabs ? line.replace(/^\t+/, "") : line) === delimiter;
141
+ if (!closed)
142
+ parts.push(...separatorParts(line));
143
+ }
144
+ if (!closed)
145
+ return undefined;
146
+ }
147
+ start = index;
148
+ continue;
149
+ }
150
+ if (char === ";" || char === "&" || char === "|") {
151
+ cut(index, index + 1);
152
+ index++;
153
+ wordStart = true;
154
+ continue;
155
+ }
156
+ if (char === "<" && command.startsWith("<<<", index)) {
157
+ index += 3;
158
+ wordStart = true;
159
+ continue;
160
+ }
161
+ if (char === "<" && command[index + 1] === "<") {
162
+ const tabs = command[index + 2] === "-";
163
+ index += tabs ? 3 : 2;
164
+ while (command[index] === " " || command[index] === "\t")
165
+ index++;
166
+ const word = /^[^\s;|&<>()]+/.exec(command.slice(index))?.[0];
167
+ if (word === undefined || /[$`]/.test(word))
168
+ return undefined;
169
+ const delimiter = word.replace(/'([^']*)'|"([^"]*)"|\\(.)/g, "$1$2$3");
170
+ if (/['"\\]/.test(delimiter))
171
+ return undefined;
172
+ heredocs.push({ delimiter, tabs });
173
+ index += word.length;
174
+ wordStart = false;
175
+ continue;
176
+ }
177
+ wordStart = char === " " || char === "\t" || /[()<>]/.test(char);
178
+ index++;
179
+ }
180
+ if (heredocs.length)
181
+ return undefined;
182
+ cut(command.length, command.length);
183
+ return parts.map(part => part.trim()).filter(Boolean);
184
+ }
185
+ /** Code a shell runs again from a quoted word: a script named inside it is found by cutting at every separator, as before. */
186
+ const RUNS_QUOTED_CODE = /\beval\b|\b(?:ba|z|da|k)?sh\s+-[a-zA-Z]*c\b/;
187
+ function partsOf(command) {
188
+ return (RUNS_QUOTED_CODE.test(command) ? undefined : shellParts(command, true)) ?? separatorParts(command);
189
+ }
47
190
  /** The literal texts that name a temp root, before any resolution: no file system call is made for a path outside these. */
48
191
  let tempTextCache;
49
192
  function tempTextRoots() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
4
4
  "description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.",
5
5
  "type": "module",
6
6
  "license": "MIT",