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 +22 -0
- package/dist/extension.js +6 -5
- package/dist/guard.d.ts +5 -3
- package/dist/guard.js +144 -96
- package/dist/script-bodies.d.ts +8 -0
- package/dist/script-bodies.js +144 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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 (
|
|
4047
|
+
if (!globalResult.file)
|
|
4047
4048
|
allRejections.push(...globalResult.rejections.map(r => `global index: entry ${r}`));
|
|
4048
|
-
if (
|
|
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
|
|
273
|
-
*
|
|
274
|
-
*
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
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.
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
358
|
-
*
|
|
359
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
453
|
+
return undefined;
|
|
433
454
|
if (context === "brace") {
|
|
434
455
|
if (char === "'" || char === "\"" || char === "$")
|
|
435
|
-
return
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
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[
|
|
480
|
+
if (char === "$" && command[next] === "{") {
|
|
459
481
|
emit("${");
|
|
460
|
-
index
|
|
482
|
+
index = next + 1;
|
|
461
483
|
stack.push("brace");
|
|
462
484
|
continue;
|
|
463
485
|
}
|
|
464
|
-
if (char === "$" && command[
|
|
465
|
-
return
|
|
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
|
-
|
|
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
|
|
491
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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
|
|
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
|
|
564
|
+
const at = after(index + 1);
|
|
565
|
+
const next = command[at];
|
|
524
566
|
if (next === "'") {
|
|
525
567
|
emit("$'");
|
|
526
|
-
index
|
|
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
|
|
575
|
+
index = at + 1;
|
|
534
576
|
stack.push("double");
|
|
535
577
|
wordStart = false;
|
|
536
578
|
continue;
|
|
537
579
|
}
|
|
538
|
-
if (next === "(" && command[
|
|
539
|
-
return
|
|
580
|
+
if (next === "(" && command[after(at + 1)] === "(")
|
|
581
|
+
return undefined;
|
|
540
582
|
if (next === "(") {
|
|
541
583
|
emit("$(");
|
|
542
|
-
index
|
|
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
|
|
592
|
+
index = at + 1;
|
|
551
593
|
stack.push("brace");
|
|
552
594
|
wordStart = false;
|
|
553
595
|
continue;
|
|
554
596
|
}
|
|
555
597
|
if (next === "[")
|
|
556
|
-
return
|
|
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
|
|
564
|
-
if (char === "<" && command
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
|
617
|
+
index = tabs ? third + 1 : third;
|
|
574
618
|
const delimiter = delimiterWord();
|
|
575
619
|
if (delimiter === undefined)
|
|
576
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
2941
|
-
//
|
|
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,
|
|
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 });
|
package/dist/script-bodies.d.ts
CHANGED
|
@@ -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.
|
package/dist/script-bodies.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|