pi-tool-discipline 0.1.14 → 0.1.16
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/README.md +11 -4
- package/extensions/guard.ts +1040 -0
- package/extensions/index.ts +19 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ tool, not bash").
|
|
|
19
19
|
|
|
20
20
|
## How it works
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Three mechanisms, applied automatically in every session:
|
|
23
23
|
|
|
24
24
|
1. **Activate search tools (root fix).** pi 0.84+ ships real `grep` / `find` /
|
|
25
25
|
`ls` built-in tool definitions but only activates `read`/`bash`/`edit`/
|
|
@@ -34,10 +34,17 @@ Two mechanisms, applied automatically in every session:
|
|
|
34
34
|
2. **System-prompt injection (rules).** On every agent start, appends an
|
|
35
35
|
idempotent "Tool Discipline" section to the system prompt: content search
|
|
36
36
|
with `ffgrep`, path search with `fffind`, file reads with `read`
|
|
37
|
-
(offset/limit), no bash `grep`/`
|
|
38
|
-
`which` for searching, bash reserved for pipelines/git/npm/network, `rg`
|
|
39
|
-
(never `grep`) as the last resort. Also strips the bash guideline text as
|
|
37
|
+
(offset/limit), no bash `grep`/`find`/`ls`/`cat`/`sed`/`head`/`tail`/
|
|
38
|
+
`which` for searching or reading, bash reserved for pipelines/git/npm/network, `rg`
|
|
39
|
+
(never `grep`) as the last resort in pipelines. Also strips the bash guideline text as
|
|
40
40
|
a belt-and-suspenders fallback.
|
|
41
|
+
3. **Runtime Guardrail (interception).** Intercepts `tool_call` events for
|
|
42
|
+
`bash` and `powershell`. If the model attempts to invoke prohibited file
|
|
43
|
+
operations (`ls`, `cat`, `grep`, `find`, `sed`, `which`, or unpiped
|
|
44
|
+
`head`/`tail` directly on files), execution is blocked at runtime with
|
|
45
|
+
actionable feedback guiding the model to use the proper tool (`read`,
|
|
46
|
+
`ffgrep`, `fffind`, `ls`, etc.), while safely preserving legitimate builds,
|
|
47
|
+
tests, git operations, and pipelines.
|
|
41
48
|
|
|
42
49
|
## Install
|
|
43
50
|
|
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash & PowerShell command analyzer for tool discipline enforcement.
|
|
3
|
+
* Blocks prohibited file operations (ls, cat, grep, find, sed, which, head/tail on files)
|
|
4
|
+
* including when nested in subshells $(...), backticks, script blocks {...}, wrappers, or redirects,
|
|
5
|
+
* while allowing legitimate pipelines, builds, test runs, here-doc writes, and scripts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface BlockResult {
|
|
9
|
+
block: boolean;
|
|
10
|
+
reason?: string;
|
|
11
|
+
prohibitedCommand?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CommandSegment {
|
|
15
|
+
command: string;
|
|
16
|
+
args: string[];
|
|
17
|
+
isPipeTarget: boolean;
|
|
18
|
+
hasInputRedirect: boolean;
|
|
19
|
+
hasOutputRedirect: boolean;
|
|
20
|
+
hasHereDoc: boolean;
|
|
21
|
+
raw: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Remove shell quotes and backslash escapes from a command token.
|
|
26
|
+
* e.g. '"/bin/ls"' -> '/bin/ls', '\ls' -> 'ls', "'Get-ChildItem'" -> 'Get-ChildItem'
|
|
27
|
+
*/
|
|
28
|
+
export function unquoteWord(word: string): string {
|
|
29
|
+
if (!word) return "";
|
|
30
|
+
let result = "";
|
|
31
|
+
let i = 0;
|
|
32
|
+
const len = word.length;
|
|
33
|
+
|
|
34
|
+
while (i < len) {
|
|
35
|
+
const c = word[i];
|
|
36
|
+
if (c === "'") {
|
|
37
|
+
i++;
|
|
38
|
+
while (i < len && word[i] !== "'") {
|
|
39
|
+
result += word[i];
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
if (i < len) i++;
|
|
43
|
+
} else if (c === '"') {
|
|
44
|
+
i++;
|
|
45
|
+
while (i < len && word[i] !== '"') {
|
|
46
|
+
if (word[i] === "\\" && i + 1 < len) {
|
|
47
|
+
result += word[i + 1];
|
|
48
|
+
i += 2;
|
|
49
|
+
} else {
|
|
50
|
+
result += word[i];
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (i < len) i++;
|
|
55
|
+
} else if (c === "\\") {
|
|
56
|
+
if (i + 1 < len) {
|
|
57
|
+
result += word[i + 1];
|
|
58
|
+
i += 2;
|
|
59
|
+
} else {
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
result += c;
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Check if a word token has shell quotes around or inside it */
|
|
71
|
+
function isQuoted(word: string): boolean {
|
|
72
|
+
return word.includes("'") || word.includes('"') || word.includes("\\");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract embedded command substitutions $(...) and `...` from unquoted here-doc body.
|
|
77
|
+
* Respects backslash escapes (\$, \`, \\, \)) so `\$(ls)` is treated as literal and `\)` does not close subshell.
|
|
78
|
+
*/
|
|
79
|
+
function extractSubshellsFromHereDocBody(body: string, subshellSnippets: string[]) {
|
|
80
|
+
let i = 0;
|
|
81
|
+
const len = body.length;
|
|
82
|
+
while (i < len) {
|
|
83
|
+
const ch = body[i];
|
|
84
|
+
|
|
85
|
+
if (ch === "\\") {
|
|
86
|
+
// Escape in unquoted here-doc: \$ -> literal $, \` -> literal `
|
|
87
|
+
i += 2;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (ch === "`") {
|
|
92
|
+
i++;
|
|
93
|
+
let code = "";
|
|
94
|
+
while (i < len && body[i] !== "`") {
|
|
95
|
+
if (body[i] === "\\" && i + 1 < len) {
|
|
96
|
+
code += body[i + 1];
|
|
97
|
+
i += 2;
|
|
98
|
+
} else {
|
|
99
|
+
code += body[i];
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (i < len) i++;
|
|
104
|
+
if (code) subshellSnippets.push(code);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (ch === "$" && body[i + 1] === "(") {
|
|
109
|
+
i += 2;
|
|
110
|
+
let depth = 1;
|
|
111
|
+
let code = "";
|
|
112
|
+
while (i < len && depth > 0) {
|
|
113
|
+
if (body[i] === "\\" && i + 1 < len) {
|
|
114
|
+
// Escape inside substitution (e.g. \) is literal, not subshell close)
|
|
115
|
+
code += body.slice(i, i + 2);
|
|
116
|
+
i += 2;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (body[i] === "(") {
|
|
120
|
+
depth++;
|
|
121
|
+
code += body[i];
|
|
122
|
+
i++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (body[i] === ")") {
|
|
126
|
+
depth--;
|
|
127
|
+
if (depth === 0) {
|
|
128
|
+
i++;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
code += body[i];
|
|
132
|
+
i++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (body[i] === "'" || body[i] === '"') {
|
|
136
|
+
const q = body[i];
|
|
137
|
+
code += q;
|
|
138
|
+
i++;
|
|
139
|
+
while (i < len && body[i] !== q) {
|
|
140
|
+
if (q === '"' && body[i] === "\\" && i + 1 < len) {
|
|
141
|
+
code += body.slice(i, i + 2);
|
|
142
|
+
i += 2;
|
|
143
|
+
} else {
|
|
144
|
+
code += body[i];
|
|
145
|
+
i++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (i < len) {
|
|
149
|
+
code += body[i];
|
|
150
|
+
i++;
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
code += body[i];
|
|
155
|
+
i++;
|
|
156
|
+
}
|
|
157
|
+
if (code) subshellSnippets.push(code);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
i++;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Tokenize a shell command input into individual pipeline segments.
|
|
167
|
+
*/
|
|
168
|
+
export function parseCommandSegments(
|
|
169
|
+
input: string,
|
|
170
|
+
dialect: "bash" | "powershell" = "bash",
|
|
171
|
+
): CommandSegment[] {
|
|
172
|
+
if (!input || typeof input !== "string") return [];
|
|
173
|
+
|
|
174
|
+
const segments: CommandSegment[] = [];
|
|
175
|
+
const subshellSnippets: { code: string; dialect: "bash" | "powershell" }[] = [];
|
|
176
|
+
|
|
177
|
+
const rawLines = input.split(/\r?\n/);
|
|
178
|
+
let lineIdx = 0;
|
|
179
|
+
|
|
180
|
+
let currentTokens: string[] = [];
|
|
181
|
+
let currentRaw = "";
|
|
182
|
+
let isPipeTarget = false;
|
|
183
|
+
let hasInputRedirect = false;
|
|
184
|
+
let hasOutputRedirect = false;
|
|
185
|
+
let hasHereDoc = false;
|
|
186
|
+
|
|
187
|
+
const flushSegment = () => {
|
|
188
|
+
if (currentTokens.length > 0 || hasInputRedirect || hasOutputRedirect || hasHereDoc) {
|
|
189
|
+
segments.push(
|
|
190
|
+
createSegment(
|
|
191
|
+
currentTokens,
|
|
192
|
+
currentRaw,
|
|
193
|
+
isPipeTarget,
|
|
194
|
+
hasInputRedirect,
|
|
195
|
+
hasOutputRedirect,
|
|
196
|
+
hasHereDoc,
|
|
197
|
+
dialect,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
currentTokens = [];
|
|
201
|
+
currentRaw = "";
|
|
202
|
+
hasInputRedirect = false;
|
|
203
|
+
hasOutputRedirect = false;
|
|
204
|
+
hasHereDoc = false;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
while (lineIdx < rawLines.length) {
|
|
209
|
+
const line = rawLines[lineIdx];
|
|
210
|
+
let i = 0;
|
|
211
|
+
const len = line.length;
|
|
212
|
+
const activeHereDocs: { delimiter: string; isQuoted: boolean; stripTabs: boolean }[] = [];
|
|
213
|
+
|
|
214
|
+
while (i < len) {
|
|
215
|
+
const ch = line[i];
|
|
216
|
+
|
|
217
|
+
if (/\s/.test(ch)) {
|
|
218
|
+
currentRaw += ch;
|
|
219
|
+
i++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Subshell / command substitution inside backticks `...` (in bash)
|
|
224
|
+
if (dialect === "bash" && ch === "`") {
|
|
225
|
+
const btStart = i;
|
|
226
|
+
i++;
|
|
227
|
+
let code = "";
|
|
228
|
+
while (i < len && line[i] !== "`") {
|
|
229
|
+
if (line[i] === "\\" && i + 1 < len) {
|
|
230
|
+
code += line[i + 1];
|
|
231
|
+
i += 2;
|
|
232
|
+
} else {
|
|
233
|
+
code += line[i];
|
|
234
|
+
i++;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (i < len) i++;
|
|
238
|
+
if (code) subshellSnippets.push({ code, dialect: "bash" });
|
|
239
|
+
currentRaw += line.slice(btStart, i);
|
|
240
|
+
currentTokens.push(`\`${code}\``);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Subshell $(...)
|
|
245
|
+
if (ch === "$" && line[i + 1] === "(") {
|
|
246
|
+
const ssStart = i;
|
|
247
|
+
i += 2;
|
|
248
|
+
let depth = 1;
|
|
249
|
+
let code = "";
|
|
250
|
+
while (i < len && depth > 0) {
|
|
251
|
+
if (line[i] === "\\" && i + 1 < len) {
|
|
252
|
+
code += line.slice(i, i + 2);
|
|
253
|
+
i += 2;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (line[i] === "(") {
|
|
257
|
+
depth++;
|
|
258
|
+
code += line[i];
|
|
259
|
+
i++;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (line[i] === ")") {
|
|
263
|
+
depth--;
|
|
264
|
+
if (depth === 0) {
|
|
265
|
+
i++;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
code += line[i];
|
|
269
|
+
i++;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (line[i] === "'" || line[i] === '"') {
|
|
273
|
+
const q = line[i];
|
|
274
|
+
code += q;
|
|
275
|
+
i++;
|
|
276
|
+
while (i < len && line[i] !== q) {
|
|
277
|
+
if (q === '"' && line[i] === "\\" && i + 1 < len) {
|
|
278
|
+
code += line.slice(i, i + 2);
|
|
279
|
+
i += 2;
|
|
280
|
+
} else {
|
|
281
|
+
code += line[i];
|
|
282
|
+
i++;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (i < len) {
|
|
286
|
+
code += line[i];
|
|
287
|
+
i++;
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
code += line[i];
|
|
292
|
+
i++;
|
|
293
|
+
}
|
|
294
|
+
if (code) subshellSnippets.push({ code, dialect });
|
|
295
|
+
currentRaw += line.slice(ssStart, i);
|
|
296
|
+
currentTokens.push(`$(${code})`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Parenthesized group ( ... )
|
|
301
|
+
if (ch === "(") {
|
|
302
|
+
const pStart = i;
|
|
303
|
+
i++;
|
|
304
|
+
let depth = 1;
|
|
305
|
+
let code = "";
|
|
306
|
+
while (i < len && depth > 0) {
|
|
307
|
+
if (line[i] === "\\" && i + 1 < len) {
|
|
308
|
+
code += line.slice(i, i + 2);
|
|
309
|
+
i += 2;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (line[i] === "(") {
|
|
313
|
+
depth++;
|
|
314
|
+
code += line[i];
|
|
315
|
+
i++;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (line[i] === ")") {
|
|
319
|
+
depth--;
|
|
320
|
+
if (depth === 0) {
|
|
321
|
+
i++;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
code += line[i];
|
|
325
|
+
i++;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (line[i] === "'" || line[i] === '"') {
|
|
329
|
+
const q = line[i];
|
|
330
|
+
code += q;
|
|
331
|
+
i++;
|
|
332
|
+
while (i < len && line[i] !== q) {
|
|
333
|
+
if (q === '"' && line[i] === "\\" && i + 1 < len) {
|
|
334
|
+
code += line.slice(i, i + 2);
|
|
335
|
+
i += 2;
|
|
336
|
+
} else {
|
|
337
|
+
code += line[i];
|
|
338
|
+
i++;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (i < len) {
|
|
342
|
+
code += line[i];
|
|
343
|
+
i++;
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
code += line[i];
|
|
348
|
+
i++;
|
|
349
|
+
}
|
|
350
|
+
if (code) subshellSnippets.push({ code, dialect });
|
|
351
|
+
currentRaw += line.slice(pStart, i);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// PowerShell Script Block { ... }
|
|
356
|
+
if (ch === "{") {
|
|
357
|
+
const bStart = i;
|
|
358
|
+
i++;
|
|
359
|
+
let depth = 1;
|
|
360
|
+
let code = "";
|
|
361
|
+
while (i < len && depth > 0) {
|
|
362
|
+
const cur = line[i];
|
|
363
|
+
if (cur === "{") {
|
|
364
|
+
depth++;
|
|
365
|
+
code += cur;
|
|
366
|
+
i++;
|
|
367
|
+
} else if (cur === "}") {
|
|
368
|
+
depth--;
|
|
369
|
+
if (depth === 0) {
|
|
370
|
+
i++;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
code += cur;
|
|
374
|
+
i++;
|
|
375
|
+
} else if (cur === "'") {
|
|
376
|
+
code += cur;
|
|
377
|
+
i++;
|
|
378
|
+
while (i < len) {
|
|
379
|
+
if (line[i] === "'") {
|
|
380
|
+
if (line[i + 1] === "'") {
|
|
381
|
+
code += "''";
|
|
382
|
+
i += 2;
|
|
383
|
+
} else {
|
|
384
|
+
code += "'";
|
|
385
|
+
i++;
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
code += line[i];
|
|
390
|
+
i++;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} else if (cur === '"') {
|
|
394
|
+
code += cur;
|
|
395
|
+
i++;
|
|
396
|
+
while (i < len) {
|
|
397
|
+
if (line[i] === "`" && i + 1 < len) {
|
|
398
|
+
code += line.slice(i, i + 2);
|
|
399
|
+
i += 2;
|
|
400
|
+
} else if (line[i] === '"') {
|
|
401
|
+
if (line[i + 1] === '"') {
|
|
402
|
+
code += '""';
|
|
403
|
+
i += 2;
|
|
404
|
+
} else {
|
|
405
|
+
code += '"';
|
|
406
|
+
i++;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
code += line[i];
|
|
411
|
+
i++;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
code += cur;
|
|
416
|
+
i++;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (code) subshellSnippets.push({ code, dialect: "powershell" });
|
|
420
|
+
currentRaw += line.slice(bStart, i);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Control operators: ;, &&, ||, |, &
|
|
425
|
+
if (ch === ";") {
|
|
426
|
+
flushSegment();
|
|
427
|
+
isPipeTarget = false;
|
|
428
|
+
currentRaw += ";";
|
|
429
|
+
i++;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (ch === "&") {
|
|
434
|
+
if (line[i + 1] === "&") {
|
|
435
|
+
flushSegment();
|
|
436
|
+
isPipeTarget = false;
|
|
437
|
+
currentRaw += "&&";
|
|
438
|
+
i += 2;
|
|
439
|
+
} else {
|
|
440
|
+
flushSegment();
|
|
441
|
+
isPipeTarget = false;
|
|
442
|
+
currentRaw += "&";
|
|
443
|
+
i++;
|
|
444
|
+
}
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (ch === "|") {
|
|
449
|
+
if (line[i + 1] === "|") {
|
|
450
|
+
flushSegment();
|
|
451
|
+
isPipeTarget = false;
|
|
452
|
+
currentRaw += "||";
|
|
453
|
+
i += 2;
|
|
454
|
+
} else {
|
|
455
|
+
flushSegment();
|
|
456
|
+
isPipeTarget = true;
|
|
457
|
+
if (line[i + 1] === "&") {
|
|
458
|
+
currentRaw += "|&";
|
|
459
|
+
i += 2;
|
|
460
|
+
} else {
|
|
461
|
+
currentRaw += "|";
|
|
462
|
+
i++;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Here-doc (Bash only): << or <<-
|
|
469
|
+
if (dialect === "bash" && ch === "<" && line[i + 1] === "<" && line[i + 2] !== "<") {
|
|
470
|
+
let hdIdx = i + 2;
|
|
471
|
+
let stripTabs = false;
|
|
472
|
+
if (line[hdIdx] === "-") {
|
|
473
|
+
stripTabs = true;
|
|
474
|
+
hdIdx++;
|
|
475
|
+
}
|
|
476
|
+
while (hdIdx < len && /\s/.test(line[hdIdx])) hdIdx++;
|
|
477
|
+
let delimRaw = "";
|
|
478
|
+
while (hdIdx < len && !/[\s;&|<>()]/.test(line[hdIdx])) {
|
|
479
|
+
const c = line[hdIdx];
|
|
480
|
+
if (c === "'" || c === '"') {
|
|
481
|
+
const q = c;
|
|
482
|
+
delimRaw += q;
|
|
483
|
+
hdIdx++;
|
|
484
|
+
while (hdIdx < len && line[hdIdx] !== q) {
|
|
485
|
+
delimRaw += line[hdIdx];
|
|
486
|
+
hdIdx++;
|
|
487
|
+
}
|
|
488
|
+
if (hdIdx < len) {
|
|
489
|
+
delimRaw += line[hdIdx];
|
|
490
|
+
hdIdx++;
|
|
491
|
+
}
|
|
492
|
+
} else if (c === "\\" && hdIdx + 1 < len) {
|
|
493
|
+
delimRaw += line.slice(hdIdx, hdIdx + 2);
|
|
494
|
+
hdIdx += 2;
|
|
495
|
+
} else {
|
|
496
|
+
delimRaw += c;
|
|
497
|
+
hdIdx++;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const cleanDelim = unquoteWord(delimRaw);
|
|
501
|
+
const isDelimQuoted = isQuoted(delimRaw);
|
|
502
|
+
if (cleanDelim) {
|
|
503
|
+
activeHereDocs.push({ delimiter: cleanDelim, isQuoted: isDelimQuoted, stripTabs });
|
|
504
|
+
}
|
|
505
|
+
currentRaw += line.slice(i, hdIdx);
|
|
506
|
+
hasHereDoc = true;
|
|
507
|
+
i = hdIdx;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Here-string: <<<
|
|
512
|
+
if (dialect === "bash" && ch === "<" && line[i + 1] === "<" && line[i + 2] === "<") {
|
|
513
|
+
hasInputRedirect = true;
|
|
514
|
+
currentRaw += "<<<";
|
|
515
|
+
i += 3;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Redirects: <, >, >>, >&, 2>&1, etc.
|
|
520
|
+
if (ch === "<") {
|
|
521
|
+
hasInputRedirect = true;
|
|
522
|
+
currentRaw += "<";
|
|
523
|
+
i++;
|
|
524
|
+
while (i < len && /\s/.test(line[i])) {
|
|
525
|
+
currentRaw += line[i];
|
|
526
|
+
i++;
|
|
527
|
+
}
|
|
528
|
+
let target = "";
|
|
529
|
+
while (i < len && !/[\s;&|<>()]/.test(line[i])) {
|
|
530
|
+
target += line[i];
|
|
531
|
+
i++;
|
|
532
|
+
}
|
|
533
|
+
currentRaw += target;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (ch === ">") {
|
|
538
|
+
hasOutputRedirect = true;
|
|
539
|
+
currentRaw += ">";
|
|
540
|
+
i++;
|
|
541
|
+
if (i < len && (line[i] === ">" || line[i] === "&")) {
|
|
542
|
+
currentRaw += line[i];
|
|
543
|
+
i++;
|
|
544
|
+
}
|
|
545
|
+
while (i < len && /\s/.test(line[i])) {
|
|
546
|
+
currentRaw += line[i];
|
|
547
|
+
i++;
|
|
548
|
+
}
|
|
549
|
+
let target = "";
|
|
550
|
+
while (i < len && !/[\s;&|<>()]/.test(line[i])) {
|
|
551
|
+
target += line[i];
|
|
552
|
+
i++;
|
|
553
|
+
}
|
|
554
|
+
currentRaw += target;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Numbered redirect like 2>&1, 1>out, 0<in
|
|
559
|
+
if (/\d/.test(ch) && (line[i + 1] === ">" || line[i + 1] === "<")) {
|
|
560
|
+
const isInput = line[i + 1] === "<";
|
|
561
|
+
if (isInput) hasInputRedirect = true;
|
|
562
|
+
else hasOutputRedirect = true;
|
|
563
|
+
|
|
564
|
+
currentRaw += line.slice(i, i + 2);
|
|
565
|
+
i += 2;
|
|
566
|
+
if (i < len && (line[i] === ">" || line[i] === "&")) {
|
|
567
|
+
currentRaw += line[i];
|
|
568
|
+
i++;
|
|
569
|
+
}
|
|
570
|
+
while (i < len && /\s/.test(line[i])) {
|
|
571
|
+
currentRaw += line[i];
|
|
572
|
+
i++;
|
|
573
|
+
}
|
|
574
|
+
let target = "";
|
|
575
|
+
while (i < len && !/[\s;&|<>()]/.test(line[i])) {
|
|
576
|
+
target += line[i];
|
|
577
|
+
i++;
|
|
578
|
+
}
|
|
579
|
+
currentRaw += target;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Regular command / argument token
|
|
584
|
+
const tokenStart = i;
|
|
585
|
+
let token = "";
|
|
586
|
+
|
|
587
|
+
while (i < len && !/\s/.test(line[i]) && !/[;&|<>()]/.test(line[i])) {
|
|
588
|
+
const c = line[i];
|
|
589
|
+
if (c === "'") {
|
|
590
|
+
const qStart = i;
|
|
591
|
+
i++;
|
|
592
|
+
if (dialect === "powershell") {
|
|
593
|
+
while (i < len) {
|
|
594
|
+
if (line[i] === "'") {
|
|
595
|
+
if (line[i + 1] === "'") {
|
|
596
|
+
i += 2;
|
|
597
|
+
} else {
|
|
598
|
+
i++;
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
i++;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
while (i < len && line[i] !== "'") i++;
|
|
607
|
+
if (i < len) i++;
|
|
608
|
+
}
|
|
609
|
+
token += line.slice(qStart, i);
|
|
610
|
+
} else if (c === '"') {
|
|
611
|
+
const qStart = i;
|
|
612
|
+
i++;
|
|
613
|
+
if (dialect === "powershell") {
|
|
614
|
+
while (i < len) {
|
|
615
|
+
if (line[i] === "`" && i + 1 < len) {
|
|
616
|
+
i += 2;
|
|
617
|
+
} else if (line[i] === '"') {
|
|
618
|
+
if (line[i + 1] === '"') {
|
|
619
|
+
i += 2;
|
|
620
|
+
} else {
|
|
621
|
+
i++;
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
} else if (line[i] === "$" && line[i + 1] === "(") {
|
|
625
|
+
const sStart = i;
|
|
626
|
+
i += 2;
|
|
627
|
+
let d = 1;
|
|
628
|
+
let innerCode = "";
|
|
629
|
+
while (i < len && d > 0) {
|
|
630
|
+
if (line[i] === "(") d++;
|
|
631
|
+
else if (line[i] === ")") {
|
|
632
|
+
d--;
|
|
633
|
+
if (d === 0) {
|
|
634
|
+
i++;
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
innerCode += line[i];
|
|
639
|
+
i++;
|
|
640
|
+
}
|
|
641
|
+
if (innerCode) subshellSnippets.push({ code: innerCode, dialect: "powershell" });
|
|
642
|
+
} else {
|
|
643
|
+
i++;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
} else {
|
|
647
|
+
while (i < len && line[i] !== '"') {
|
|
648
|
+
if (line[i] === "\\" && i + 1 < len) {
|
|
649
|
+
i += 2;
|
|
650
|
+
} else if (line[i] === "$" && line[i + 1] === "(") {
|
|
651
|
+
const sStart = i;
|
|
652
|
+
i += 2;
|
|
653
|
+
let d = 1;
|
|
654
|
+
let innerCode = "";
|
|
655
|
+
while (i < len && d > 0) {
|
|
656
|
+
if (line[i] === "(") d++;
|
|
657
|
+
else if (line[i] === ")") {
|
|
658
|
+
d--;
|
|
659
|
+
if (d === 0) {
|
|
660
|
+
i++;
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
innerCode += line[i];
|
|
665
|
+
i++;
|
|
666
|
+
}
|
|
667
|
+
if (innerCode) subshellSnippets.push({ code: innerCode, dialect: "bash" });
|
|
668
|
+
continue;
|
|
669
|
+
} else if (line[i] === "`") {
|
|
670
|
+
i++;
|
|
671
|
+
let innerCode = "";
|
|
672
|
+
while (i < len && line[i] !== "`") {
|
|
673
|
+
if (line[i] === "\\" && i + 1 < len) {
|
|
674
|
+
innerCode += line[i + 1];
|
|
675
|
+
i += 2;
|
|
676
|
+
} else {
|
|
677
|
+
innerCode += line[i];
|
|
678
|
+
i++;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (i < len) i++;
|
|
682
|
+
if (innerCode) subshellSnippets.push({ code: innerCode, dialect: "bash" });
|
|
683
|
+
continue;
|
|
684
|
+
} else {
|
|
685
|
+
i++;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (i < len) i++;
|
|
689
|
+
}
|
|
690
|
+
token += line.slice(qStart, i);
|
|
691
|
+
} else if (dialect === "bash" && c === "\\" && i + 1 < len) {
|
|
692
|
+
token += line.slice(i, i + 2);
|
|
693
|
+
i += 2;
|
|
694
|
+
} else {
|
|
695
|
+
token += c;
|
|
696
|
+
i++;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
currentRaw += line.slice(tokenStart, i);
|
|
701
|
+
if (token.length > 0) {
|
|
702
|
+
currentTokens.push(token);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
lineIdx++;
|
|
707
|
+
|
|
708
|
+
// If this line initiated here-doc(s), consume the full body
|
|
709
|
+
if (activeHereDocs.length > 0) {
|
|
710
|
+
for (const hd of activeHereDocs) {
|
|
711
|
+
const bodyLines: string[] = [];
|
|
712
|
+
while (lineIdx < rawLines.length) {
|
|
713
|
+
const bodyLine = rawLines[lineIdx];
|
|
714
|
+
const matchLine = hd.stripTabs ? bodyLine.replace(/^\t+/, "") : bodyLine;
|
|
715
|
+
lineIdx++;
|
|
716
|
+
if (matchLine === hd.delimiter) {
|
|
717
|
+
// Here-doc delimiter reached
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
bodyLines.push(bodyLine);
|
|
721
|
+
}
|
|
722
|
+
// If delimiter was NOT quoted, perform multiline command substitution extraction
|
|
723
|
+
if (!hd.isQuoted && bodyLines.length > 0) {
|
|
724
|
+
const fullBody = bodyLines.join("\n");
|
|
725
|
+
const snippets: string[] = [];
|
|
726
|
+
extractSubshellsFromHereDocBody(fullBody, snippets);
|
|
727
|
+
for (const s of snippets) {
|
|
728
|
+
subshellSnippets.push({ code: s, dialect: "bash" });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
flushSegment();
|
|
733
|
+
isPipeTarget = false;
|
|
734
|
+
} else {
|
|
735
|
+
flushSegment();
|
|
736
|
+
isPipeTarget = false;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
flushSegment();
|
|
741
|
+
|
|
742
|
+
// Recursively parse any collected subshell, backtick, or script block snippets
|
|
743
|
+
for (const sub of subshellSnippets) {
|
|
744
|
+
const subSegments = parseCommandSegments(sub.code, sub.dialect);
|
|
745
|
+
segments.push(...subSegments);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return segments;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Extract the base executable name from a command token.
|
|
753
|
+
*/
|
|
754
|
+
function extractBaseCommand(cmdToken: string): string {
|
|
755
|
+
if (!cmdToken) return "";
|
|
756
|
+
const rawParts = cmdToken.split(/[/\\]/);
|
|
757
|
+
const lastRaw = rawParts.pop() || cmdToken;
|
|
758
|
+
const unquoted = unquoteWord(lastRaw);
|
|
759
|
+
if (unquoted) return unquoted;
|
|
760
|
+
const clean = unquoteWord(cmdToken);
|
|
761
|
+
const cleanParts = clean.split(/[/\\]/);
|
|
762
|
+
return cleanParts.pop() || clean;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Known wrapper options that take an argument.
|
|
767
|
+
*/
|
|
768
|
+
const WRAPPER_OPTION_ARITY: Record<string, Set<string>> = {
|
|
769
|
+
sudo: new Set([
|
|
770
|
+
"-u",
|
|
771
|
+
"--user",
|
|
772
|
+
"-g",
|
|
773
|
+
"--group",
|
|
774
|
+
"-p",
|
|
775
|
+
"--prompt",
|
|
776
|
+
"-C",
|
|
777
|
+
"--close-from",
|
|
778
|
+
"-r",
|
|
779
|
+
"--role",
|
|
780
|
+
"-t",
|
|
781
|
+
"--type",
|
|
782
|
+
"-T",
|
|
783
|
+
"--command-timeout",
|
|
784
|
+
"-U",
|
|
785
|
+
"--other-user",
|
|
786
|
+
"-h",
|
|
787
|
+
"--host",
|
|
788
|
+
]),
|
|
789
|
+
env: new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string", "-a", "--argv0"]),
|
|
790
|
+
xargs: new Set([
|
|
791
|
+
"-n",
|
|
792
|
+
"--max-args",
|
|
793
|
+
"-I",
|
|
794
|
+
"-i",
|
|
795
|
+
"-s",
|
|
796
|
+
"--max-chars",
|
|
797
|
+
"-a",
|
|
798
|
+
"--arg-file",
|
|
799
|
+
"-d",
|
|
800
|
+
"--delimiter",
|
|
801
|
+
"-E",
|
|
802
|
+
"-e",
|
|
803
|
+
"-L",
|
|
804
|
+
"-l",
|
|
805
|
+
"--max-lines",
|
|
806
|
+
"-P",
|
|
807
|
+
"--max-procs",
|
|
808
|
+
"--process-slot-var",
|
|
809
|
+
]),
|
|
810
|
+
nohup: new Set(),
|
|
811
|
+
time: new Set(["-o", "--output", "-f", "--format"]),
|
|
812
|
+
command: new Set([]), // -p, -v, -V take no option arguments
|
|
813
|
+
builtin: new Set(),
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Extract clean base command and arguments from a segment's tokens,
|
|
818
|
+
* recursively unwrapping nested wrappers (e.g. sudo env cat).
|
|
819
|
+
*/
|
|
820
|
+
function createSegment(
|
|
821
|
+
tokens: string[],
|
|
822
|
+
raw: string,
|
|
823
|
+
isPipeTarget: boolean,
|
|
824
|
+
hasInputRedirect: boolean,
|
|
825
|
+
hasOutputRedirect: boolean,
|
|
826
|
+
hasHereDoc: boolean,
|
|
827
|
+
dialect: "bash" | "powershell" = "bash",
|
|
828
|
+
): CommandSegment {
|
|
829
|
+
let cmdIndex = 0;
|
|
830
|
+
|
|
831
|
+
// 1. Skip environment variable assignments (Bash)
|
|
832
|
+
// and handle PowerShell variable assignments ($var = val, $var=val, $var =val, $var= val)
|
|
833
|
+
while (cmdIndex < tokens.length) {
|
|
834
|
+
const t = tokens[cmdIndex];
|
|
835
|
+
if (dialect === "bash" && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
|
|
836
|
+
cmdIndex++;
|
|
837
|
+
} else if (dialect === "powershell" && t.startsWith("$")) {
|
|
838
|
+
if (t.includes("=")) {
|
|
839
|
+
const eqIdx = t.indexOf("=");
|
|
840
|
+
const rhs = t.slice(eqIdx + 1);
|
|
841
|
+
if (rhs) {
|
|
842
|
+
// e.g. $x=Get-Content -> replace token with RHS command
|
|
843
|
+
tokens[cmdIndex] = rhs;
|
|
844
|
+
break;
|
|
845
|
+
} else {
|
|
846
|
+
// e.g. $x= Get-Content -> skip $x= and proceed to next token
|
|
847
|
+
cmdIndex++;
|
|
848
|
+
}
|
|
849
|
+
} else if (cmdIndex + 1 < tokens.length) {
|
|
850
|
+
const next = tokens[cmdIndex + 1];
|
|
851
|
+
if (next === "=") {
|
|
852
|
+
// e.g. $x = Get-Content -> skip $x and =
|
|
853
|
+
cmdIndex += 2;
|
|
854
|
+
} else if (next.startsWith("=")) {
|
|
855
|
+
// e.g. $x =Get-Content -> replace next token with RHS command and skip $x
|
|
856
|
+
const rhs = next.slice(1);
|
|
857
|
+
if (rhs) {
|
|
858
|
+
tokens[cmdIndex + 1] = rhs;
|
|
859
|
+
cmdIndex++;
|
|
860
|
+
break;
|
|
861
|
+
} else {
|
|
862
|
+
cmdIndex += 2;
|
|
863
|
+
}
|
|
864
|
+
} else {
|
|
865
|
+
break;
|
|
866
|
+
}
|
|
867
|
+
} else {
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
} else {
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// 2. Handle PowerShell invocation operator '&' or '.'
|
|
876
|
+
if (cmdIndex < tokens.length && (tokens[cmdIndex] === "&" || tokens[cmdIndex] === ".")) {
|
|
877
|
+
cmdIndex++;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const cmdToken = tokens[cmdIndex] || "";
|
|
881
|
+
let baseCmd = extractBaseCommand(cmdToken);
|
|
882
|
+
|
|
883
|
+
let currentArgs = tokens.slice(cmdIndex + 1);
|
|
884
|
+
|
|
885
|
+
// 3. Recursively unwrap wrappers: sudo, env, xargs, nohup, time, command, builtin
|
|
886
|
+
while (true) {
|
|
887
|
+
const lowerBase = baseCmd.toLowerCase();
|
|
888
|
+
if (WRAPPER_OPTION_ARITY[lowerBase] === undefined || currentArgs.length === 0) {
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const aritySet = WRAPPER_OPTION_ARITY[lowerBase];
|
|
893
|
+
let subIdx = 0;
|
|
894
|
+
|
|
895
|
+
while (subIdx < currentArgs.length) {
|
|
896
|
+
const arg = currentArgs[subIdx];
|
|
897
|
+
const unquotedArg = unquoteWord(arg);
|
|
898
|
+
|
|
899
|
+
if (unquotedArg === "--") {
|
|
900
|
+
subIdx++;
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Check if arg is --option=value
|
|
905
|
+
if (unquotedArg.startsWith("--") && unquotedArg.includes("=")) {
|
|
906
|
+
subIdx++;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Check if option takes an argument
|
|
911
|
+
if (aritySet.has(unquotedArg)) {
|
|
912
|
+
subIdx += 2;
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Generic single-dash options or env assignments (e.g. -p, FOO=BAR)
|
|
917
|
+
if (unquotedArg.startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(unquotedArg)) {
|
|
918
|
+
subIdx++;
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Found target command
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (subIdx < currentArgs.length) {
|
|
927
|
+
baseCmd = extractBaseCommand(currentArgs[subIdx]);
|
|
928
|
+
currentArgs = currentArgs.slice(subIdx + 1);
|
|
929
|
+
} else {
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
return {
|
|
935
|
+
command: baseCmd,
|
|
936
|
+
args: currentArgs,
|
|
937
|
+
isPipeTarget,
|
|
938
|
+
hasInputRedirect,
|
|
939
|
+
hasOutputRedirect,
|
|
940
|
+
hasHereDoc,
|
|
941
|
+
raw,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Check if a bash/powershell command violates the tool-discipline policy.
|
|
947
|
+
*/
|
|
948
|
+
export function checkDisciplineViolation(
|
|
949
|
+
command: string,
|
|
950
|
+
dialect: "bash" | "powershell" = "bash",
|
|
951
|
+
): BlockResult {
|
|
952
|
+
if (!command || typeof command !== "string") {
|
|
953
|
+
return { block: false };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const segments = parseCommandSegments(command, dialect);
|
|
957
|
+
|
|
958
|
+
for (const seg of segments) {
|
|
959
|
+
const rawCmd = seg.command;
|
|
960
|
+
const cmd = rawCmd.toLowerCase();
|
|
961
|
+
|
|
962
|
+
// 1. Prohibited: ls / dir / Get-ChildItem / gci
|
|
963
|
+
if (cmd === "ls" || cmd === "dir" || cmd === "get-childitem" || cmd === "gci") {
|
|
964
|
+
return {
|
|
965
|
+
block: true,
|
|
966
|
+
prohibitedCommand: rawCmd,
|
|
967
|
+
reason: `Tool discipline violation: Prohibited bash command '${rawCmd}'. Please use the 'ls' tool (or 'fffind') to inspect directories instead of running ls in bash.`,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// 2. Prohibited: find (file finding)
|
|
972
|
+
if (cmd === "find") {
|
|
973
|
+
return {
|
|
974
|
+
block: true,
|
|
975
|
+
prohibitedCommand: rawCmd,
|
|
976
|
+
reason: `Tool discipline violation: Prohibited bash command 'find'. Please use 'fffind' (or the 'find' tool) to search for files instead of running find in bash.`,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// 3. Prohibited: grep / egrep / fgrep / Select-String / sls
|
|
981
|
+
if (["grep", "egrep", "fgrep", "select-string", "sls"].includes(cmd)) {
|
|
982
|
+
return {
|
|
983
|
+
block: true,
|
|
984
|
+
prohibitedCommand: rawCmd,
|
|
985
|
+
reason: `Tool discipline violation: Prohibited bash command '${rawCmd}'. Please use 'ffgrep' (or the 'grep' tool) for searching file contents. If searching command output in bash pipelines is unavoidable, use 'rg' instead of grep.`,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// 4. Prohibited: cat / type / Get-Content / gc
|
|
990
|
+
if (["cat", "type", "get-content", "gc"].includes(cmd)) {
|
|
991
|
+
// A pure here-doc write has hasHereDoc AND hasOutputRedirect, and NO file input redirects, and NO file positional args.
|
|
992
|
+
const positionalArgs = seg.args.filter((a) => {
|
|
993
|
+
const unq = unquoteWord(a);
|
|
994
|
+
return !unq.startsWith("-") && !unq.startsWith("<") && !unq.startsWith(">");
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
const isPureHereDocWrite =
|
|
998
|
+
seg.hasHereDoc && !seg.hasInputRedirect && seg.hasOutputRedirect && positionalArgs.length === 0;
|
|
999
|
+
|
|
1000
|
+
if (!isPureHereDocWrite) {
|
|
1001
|
+
return {
|
|
1002
|
+
block: true,
|
|
1003
|
+
prohibitedCommand: rawCmd,
|
|
1004
|
+
reason: `Tool discipline violation: Prohibited bash command '${rawCmd}'. Please use the 'read' tool to inspect file contents instead of running cat in bash.`,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// 5. Prohibited: sed
|
|
1010
|
+
if (cmd === "sed") {
|
|
1011
|
+
return {
|
|
1012
|
+
block: true,
|
|
1013
|
+
prohibitedCommand: rawCmd,
|
|
1014
|
+
reason: `Tool discipline violation: Prohibited bash command 'sed'. Please use the 'edit' tool for precise file edits or the 'read' tool to inspect files instead of running sed in bash.`,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// 6. Prohibited: which / where / where.exe
|
|
1019
|
+
if (cmd === "which" || cmd === "where" || cmd === "where.exe") {
|
|
1020
|
+
return {
|
|
1021
|
+
block: true,
|
|
1022
|
+
prohibitedCommand: rawCmd,
|
|
1023
|
+
reason: `Tool discipline violation: Prohibited bash command '${rawCmd}'. Please check tool availability using standard Node/API methods rather than running which in bash.`,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// 7. Prohibited: head / tail when directly reading files (not in a pipeline target)
|
|
1028
|
+
if (["head", "tail"].includes(cmd)) {
|
|
1029
|
+
if (!seg.isPipeTarget) {
|
|
1030
|
+
return {
|
|
1031
|
+
block: true,
|
|
1032
|
+
prohibitedCommand: rawCmd,
|
|
1033
|
+
reason: `Tool discipline violation: Prohibited bash command '${rawCmd}' directly on files. Please use the 'read' tool with offset and limit parameters instead.`,
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return { block: false };
|
|
1040
|
+
}
|
package/extensions/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
29
29
|
import { Type } from "typebox";
|
|
30
30
|
import { grepFiles, findFiles, listDir, grepSchema, findSchema, lsSchema } from "./search.js";
|
|
31
31
|
import { stripBashGuidelines } from "./strip.js";
|
|
32
|
+
import { checkDisciplineViolation } from "./guard.js";
|
|
32
33
|
|
|
33
34
|
const MARK = "<!-- pi-tool-discipline:v1 -->";
|
|
34
35
|
|
|
@@ -151,6 +152,24 @@ export default function toolDiscipline(pi: ExtensionAPI) {
|
|
|
151
152
|
return { systemPrompt: `${prompt}\n${MARK}\n${DISCIPLINE}` };
|
|
152
153
|
});
|
|
153
154
|
|
|
155
|
+
// C. Runtime Guardrail: intercept and block prohibited bash file operations.
|
|
156
|
+
pi.on("tool_call", async (event) => {
|
|
157
|
+
if (event.toolName === "bash" || event.toolName === "powershell") {
|
|
158
|
+
const command = (event.input as { command?: string })?.command;
|
|
159
|
+
if (typeof command === "string") {
|
|
160
|
+
const dialect = event.toolName === "powershell" ? "powershell" : "bash";
|
|
161
|
+
const check = checkDisciplineViolation(command, dialect);
|
|
162
|
+
if (check.block) {
|
|
163
|
+
return {
|
|
164
|
+
block: true,
|
|
165
|
+
reason: check.reason,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
});
|
|
172
|
+
|
|
154
173
|
// Status command: /tool-discipline — verify tool activation and injection.
|
|
155
174
|
pi.registerCommand("tool-discipline", {
|
|
156
175
|
description: "Show pi-tool-discipline status (tools + injected guideline)",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tool-discipline",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "pi extension: enforce ffgrep/fffind-first search discipline and neutralize the default bash file-operation guideline",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"typecheck": "tsc --noEmit",
|
|
41
|
-
"test": "node --experimental-strip-types test/fallback.test.mjs"
|
|
41
|
+
"test": "node --experimental-strip-types test/fallback.test.mjs && node --experimental-strip-types test/guard.test.mjs"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@earendil-works/pi-coding-agent": "*",
|