pi-tool-guard 0.1.0 → 0.2.0
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 +23 -12
- package/bash.test.ts +437 -0
- package/bash.ts +7 -2
- package/execute.ts +136 -0
- package/index.ts +10 -93
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,20 +33,19 @@ When the LLM calls a tool with wrong field names, the extension normalizes them
|
|
|
33
33
|
|
|
34
34
|
### 2. Bash pipeline extractor stripping
|
|
35
35
|
|
|
36
|
-
When the LLM appends truncation commands (`tail`, `head`, `grep`, etc.) to
|
|
36
|
+
When the LLM appends truncation commands (`tail`, `head`, `grep`, etc.) to bash commands, the extension strips them and applies the extractor intelligently.
|
|
37
37
|
|
|
38
38
|
**Three-case strategy:**
|
|
39
39
|
|
|
40
|
-
| Scenario |
|
|
41
|
-
|
|
42
|
-
|
|
|
43
|
-
| Fast command (< 10s),
|
|
44
|
-
| Slow command
|
|
40
|
+
| Scenario | UI Notification | LLM Response |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Fast command (< 10s), truncated | `Filtered via \`head\`` | Filtered result only |
|
|
43
|
+
| Fast command (< 10s), not truncated | `Filtered via \`head\`` | Filtered result only |
|
|
44
|
+
| Slow command (truncated or not) | `The full output is above...` | Result + `This is a slow command. Avoid re-running...` |
|
|
45
45
|
|
|
46
46
|
**Example:** `vitest run | tail -n 10`
|
|
47
|
-
- If vitest
|
|
48
|
-
- If vitest
|
|
49
|
-
- If vitest is slow but not truncated → return full output + notice
|
|
47
|
+
- If vitest finishes in < 10s → run `tail` on result (or full output file if truncated), notify UI
|
|
48
|
+
- If vitest is slow → return result as-is, notify UI, append slow-command hint to LLM
|
|
50
49
|
|
|
51
50
|
**Detected extractors:** `head`, `tail`, `grep`, `egrep`, `fgrep`, `rg`, `sed`, `awk`, `cut`, `sort`, `uniq`, `wc`, `less`, `more`, `column`, `jq`, `yq`, `tr`
|
|
52
51
|
|
|
@@ -62,8 +61,20 @@ Both features use `prepareArguments` on overridden built-in tools — the cleane
|
|
|
62
61
|
- **bash**: `createBashToolDefinition(cwd)` + custom `execute` override:
|
|
63
62
|
1. `prepareArguments` parses the command with [unbash](https://github.com/nicolo-ribaudo/unbash), strips trailing extractors
|
|
64
63
|
2. `execute` runs the stripped command via the original built-in execute
|
|
65
|
-
3. If truncated → runs extractor on the full output file via `pi.exec`
|
|
66
|
-
4. If fast (< 10s) → pipes result through extractor via `pi.exec`
|
|
67
|
-
5. If slow → returns
|
|
64
|
+
3. If fast (< 10s) + truncated → runs extractor on the full output file via `pi.exec`
|
|
65
|
+
4. If fast (< 10s) + not truncated → pipes result through extractor via `pi.exec`
|
|
66
|
+
5. If slow (truncated or not) → returns result as-is with notice
|
|
68
67
|
|
|
69
68
|
No error recovery, no session scanning.
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pnpm install
|
|
74
|
+
pnpm test # Run all tests
|
|
75
|
+
pnpm typecheck # Type check
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/bash.test.ts
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import { buildNotice, FAST_THRESHOLD_MS, runExtractorOnText, runExtractorOnFile } from "./bash.ts";
|
|
5
|
+
import { stripTrailingExtractors } from "./pipeline.ts";
|
|
6
|
+
import { executeBashGuarded, prepareBashArguments, type ExecuteContext, type ToolResult } from "./execute.ts";
|
|
7
|
+
|
|
8
|
+
describe("buildNotice", () => {
|
|
9
|
+
it("returns fast-mode notice when mode is 'fast'", () => {
|
|
10
|
+
const notice = buildNotice(["head"], "fast");
|
|
11
|
+
expect(notice).toContain("Filtered via");
|
|
12
|
+
expect(notice).toContain("small enough to pipe");
|
|
13
|
+
expect(notice).not.toContain("do NOT re-run");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns slow-mode notice when mode is 'slow'", () => {
|
|
17
|
+
const notice = buildNotice(["head"], "slow");
|
|
18
|
+
expect(notice).toContain("The full output is above");
|
|
19
|
+
expect(notice).toContain("do NOT re-run");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("defaults to slow mode when no mode provided", () => {
|
|
23
|
+
const notice = buildNotice(["head"]);
|
|
24
|
+
expect(notice).toContain("The full output is above");
|
|
25
|
+
expect(notice).toContain("do NOT re-run");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("lists multiple removed commands", () => {
|
|
29
|
+
const notice = buildNotice(["grep", "head"], "fast");
|
|
30
|
+
expect(notice).toContain("`grep`, `head`");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("FAST_THRESHOLD_MS", () => {
|
|
35
|
+
it("is 10 seconds", () => {
|
|
36
|
+
expect(FAST_THRESHOLD_MS).toBe(10_000);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("fast command behavior", () => {
|
|
41
|
+
let mockPi: ExtensionAPI;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
mockPi = {
|
|
45
|
+
exec: vi.fn(),
|
|
46
|
+
} as unknown as ExtensionAPI;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("runExtractorOnText pipes text through extractor pipeline", async () => {
|
|
50
|
+
const text = "line1\nline2\nline3";
|
|
51
|
+
const pipeline = "head -2";
|
|
52
|
+
|
|
53
|
+
(mockPi.exec as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
54
|
+
code: 0,
|
|
55
|
+
stdout: "line1\nline2",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = await runExtractorOnText(mockPi, text, pipeline);
|
|
59
|
+
expect(result).toBe("line1\nline2");
|
|
60
|
+
expect(mockPi.exec).toHaveBeenCalledWith("bash", [
|
|
61
|
+
"-c",
|
|
62
|
+
expect.stringContaining("head -2"),
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("runExtractorOnFile pipes file content through extractor pipeline", async () => {
|
|
67
|
+
const filePath = "/tmp/output.txt";
|
|
68
|
+
const pipeline = "tail -1";
|
|
69
|
+
|
|
70
|
+
(mockPi.exec as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
71
|
+
code: 0,
|
|
72
|
+
stdout: "last line",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const result = await runExtractorOnFile(mockPi, filePath, pipeline);
|
|
76
|
+
expect(result).toBe("last line");
|
|
77
|
+
expect(mockPi.exec).toHaveBeenCalledWith("bash", [
|
|
78
|
+
"-c",
|
|
79
|
+
expect.stringContaining("tail -1"),
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("runExtractorOnText returns undefined on failure", async () => {
|
|
84
|
+
(mockPi.exec as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
85
|
+
code: 1,
|
|
86
|
+
stdout: "",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const result = await runExtractorOnText(mockPi, "text", "head -1");
|
|
90
|
+
expect(result).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("runExtractorOnFile returns undefined on failure", async () => {
|
|
94
|
+
(mockPi.exec as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("fail"));
|
|
95
|
+
|
|
96
|
+
const result = await runExtractorOnFile(mockPi, "/tmp/file.txt", "head -1");
|
|
97
|
+
expect(result).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("stripTrailingExtractors integration", () => {
|
|
102
|
+
it("strips head from ls | head", () => {
|
|
103
|
+
const result = stripTrailingExtractors("ls -la | head -5");
|
|
104
|
+
expect(result).toBeDefined();
|
|
105
|
+
expect(result!.cleaned).toBe("ls -la");
|
|
106
|
+
expect(result!.removedNames).toEqual(["head"]);
|
|
107
|
+
expect(result!.removedPipeline).toBe("head -5");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("strips tail from npm test | tail -20", () => {
|
|
111
|
+
const result = stripTrailingExtractors("npm test 2>&1 | tail -20");
|
|
112
|
+
expect(result).toBeDefined();
|
|
113
|
+
expect(result!.cleaned).toBe("npm test 2>&1");
|
|
114
|
+
expect(result!.removedNames).toEqual(["tail"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("strips multiple extractors from grep FAIL | head -5", () => {
|
|
118
|
+
const result = stripTrailingExtractors("npm test | grep FAIL | head -5");
|
|
119
|
+
expect(result).toBeDefined();
|
|
120
|
+
expect(result!.removedNames).toEqual(["grep", "head"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns undefined for commands without extractors", () => {
|
|
124
|
+
const result = stripTrailingExtractors("ls -la");
|
|
125
|
+
expect(result).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("executeBashGuarded", () => {
|
|
130
|
+
let mockPi: ExtensionAPI;
|
|
131
|
+
let mockCtx: { ui: { notify: ReturnType<typeof vi.fn> } };
|
|
132
|
+
let mockOriginalExecute: ReturnType<typeof vi.fn>;
|
|
133
|
+
let ctx: ExecuteContext;
|
|
134
|
+
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
mockPi = {
|
|
137
|
+
exec: vi.fn(),
|
|
138
|
+
} as unknown as ExtensionAPI;
|
|
139
|
+
mockCtx = {
|
|
140
|
+
ui: { notify: vi.fn() },
|
|
141
|
+
};
|
|
142
|
+
mockOriginalExecute = vi.fn().mockResolvedValue({
|
|
143
|
+
content: [{ type: "text", text: "" }],
|
|
144
|
+
details: undefined,
|
|
145
|
+
});
|
|
146
|
+
ctx = {
|
|
147
|
+
pi: mockPi,
|
|
148
|
+
ctx: mockCtx as unknown as ExecuteContext["ctx"],
|
|
149
|
+
originalExecute: mockOriginalExecute as unknown as ExecuteContext["originalExecute"],
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("runs original execute when no extractors removed", async () => {
|
|
154
|
+
const expected: ToolResult = {
|
|
155
|
+
content: [{ type: "text", text: "output" }],
|
|
156
|
+
details: undefined,
|
|
157
|
+
};
|
|
158
|
+
mockOriginalExecute.mockResolvedValue(expected);
|
|
159
|
+
|
|
160
|
+
const result = await executeBashGuarded(
|
|
161
|
+
ctx,
|
|
162
|
+
"call-1",
|
|
163
|
+
{ command: "ls -la" },
|
|
164
|
+
new AbortController().signal,
|
|
165
|
+
() => {},
|
|
166
|
+
{},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(result).toBe(expected);
|
|
170
|
+
expect(mockOriginalExecute).toHaveBeenCalledOnce();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("fast command: pipes result through extractor and returns filtered output", async () => {
|
|
174
|
+
// Simulate fast command (< 10s)
|
|
175
|
+
const originalResult: ToolResult = {
|
|
176
|
+
content: [{ type: "text", text: "line1\nline2\nline3" }],
|
|
177
|
+
details: undefined,
|
|
178
|
+
};
|
|
179
|
+
mockOriginalExecute.mockImplementation(async () => {
|
|
180
|
+
// Simulate fast execution
|
|
181
|
+
return originalResult;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Mock pi.exec to simulate head -2
|
|
185
|
+
(mockPi.exec as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
186
|
+
code: 0,
|
|
187
|
+
stdout: "line1\nline2",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const params = { command: "ls -la" };
|
|
191
|
+
// prepareBashArguments would have set these
|
|
192
|
+
const prepared = prepareBashArguments({ command: "ls -la | head -2" });
|
|
193
|
+
|
|
194
|
+
const result = await executeBashGuarded(
|
|
195
|
+
ctx,
|
|
196
|
+
"call-1",
|
|
197
|
+
prepared,
|
|
198
|
+
new AbortController().signal,
|
|
199
|
+
() => {},
|
|
200
|
+
{},
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Should return filtered output (head -2)
|
|
204
|
+
const textContent = result.content[0];
|
|
205
|
+
if (textContent.type === "text") {
|
|
206
|
+
expect(textContent.text).toBe("line1\nline2");
|
|
207
|
+
}
|
|
208
|
+
// Should notify UI
|
|
209
|
+
expect(mockCtx.ui.notify).toHaveBeenCalledWith(
|
|
210
|
+
expect.stringContaining("Filtered via"),
|
|
211
|
+
"info",
|
|
212
|
+
);
|
|
213
|
+
// Should NOT append notice to LLM response for fast commands
|
|
214
|
+
expect(result.content).toHaveLength(1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("fast command: produces same output as original bash with extractor", async () => {
|
|
218
|
+
// This is the key test: ensure guarded execution == original execution
|
|
219
|
+
const originalOutput = "file1.txt\nfile2.txt\nfile3.txt";
|
|
220
|
+
const headOutput = "file1.txt\nfile2.txt";
|
|
221
|
+
|
|
222
|
+
mockOriginalExecute.mockResolvedValue({
|
|
223
|
+
content: [{ type: "text", text: originalOutput }],
|
|
224
|
+
details: undefined,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
(mockPi.exec as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
228
|
+
code: 0,
|
|
229
|
+
stdout: headOutput,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const prepared = prepareBashArguments({ command: "ls | head -2" });
|
|
233
|
+
|
|
234
|
+
const result = await executeBashGuarded(
|
|
235
|
+
ctx,
|
|
236
|
+
"call-1",
|
|
237
|
+
prepared,
|
|
238
|
+
new AbortController().signal,
|
|
239
|
+
() => {},
|
|
240
|
+
{},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// The filtered output should match what head -2 would produce
|
|
244
|
+
const textContent = result.content[0];
|
|
245
|
+
if (textContent.type === "text") {
|
|
246
|
+
expect(textContent.text).toBe(headOutput);
|
|
247
|
+
}
|
|
248
|
+
// Should notify UI
|
|
249
|
+
expect(mockCtx.ui.notify).toHaveBeenCalled();
|
|
250
|
+
// Should NOT append notice to LLM response for fast commands
|
|
251
|
+
expect(result.content).toHaveLength(1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("slow command: returns full output with LLM notice", async () => {
|
|
255
|
+
// Use fake timers to simulate slow command
|
|
256
|
+
vi.useFakeTimers();
|
|
257
|
+
|
|
258
|
+
mockOriginalExecute.mockImplementation(async () => {
|
|
259
|
+
// Advance time past the threshold
|
|
260
|
+
vi.advanceTimersByTime(FAST_THRESHOLD_MS + 100);
|
|
261
|
+
return {
|
|
262
|
+
content: [{ type: "text", text: "full output" }],
|
|
263
|
+
details: undefined,
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const prepared = prepareBashArguments({ command: "npm test | tail -5" });
|
|
268
|
+
|
|
269
|
+
const result = await executeBashGuarded(
|
|
270
|
+
ctx,
|
|
271
|
+
"call-1",
|
|
272
|
+
prepared,
|
|
273
|
+
new AbortController().signal,
|
|
274
|
+
() => {},
|
|
275
|
+
{},
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// Should return full output with LLM notice
|
|
279
|
+
const textContent = result.content[0];
|
|
280
|
+
const llmNotice = result.content[1];
|
|
281
|
+
if (textContent.type === "text" && llmNotice.type === "text") {
|
|
282
|
+
expect(textContent.text).toBe("full output");
|
|
283
|
+
expect(llmNotice.text).toContain("slow command");
|
|
284
|
+
expect(llmNotice.text).toContain("Avoid re-running");
|
|
285
|
+
}
|
|
286
|
+
// Should notify UI
|
|
287
|
+
expect(mockCtx.ui.notify).toHaveBeenCalledWith(
|
|
288
|
+
expect.stringContaining("The full output is above"),
|
|
289
|
+
"info",
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
vi.useRealTimers();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("slow command + truncated: returns truncated output with LLM notice, no extractor run", async () => {
|
|
296
|
+
// Use fake timers to simulate slow command
|
|
297
|
+
vi.useFakeTimers();
|
|
298
|
+
|
|
299
|
+
mockOriginalExecute.mockImplementation(async () => {
|
|
300
|
+
vi.advanceTimersByTime(FAST_THRESHOLD_MS + 100);
|
|
301
|
+
return {
|
|
302
|
+
content: [{ type: "text", text: "truncated output..." }],
|
|
303
|
+
details: { fullOutputPath: "/tmp/full-output.txt" },
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const prepared = prepareBashArguments({ command: "npm test | tail -5" });
|
|
308
|
+
|
|
309
|
+
const result = await executeBashGuarded(
|
|
310
|
+
ctx,
|
|
311
|
+
"call-1",
|
|
312
|
+
prepared,
|
|
313
|
+
new AbortController().signal,
|
|
314
|
+
() => {},
|
|
315
|
+
{},
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// Should NOT run extractor on file for slow commands
|
|
319
|
+
expect(mockPi.exec).not.toHaveBeenCalled();
|
|
320
|
+
// Should return truncated result as-is with LLM notice
|
|
321
|
+
const textContent = result.content[0];
|
|
322
|
+
const llmNotice = result.content[1];
|
|
323
|
+
if (textContent.type === "text" && llmNotice.type === "text") {
|
|
324
|
+
expect(textContent.text).toBe("truncated output...");
|
|
325
|
+
expect(llmNotice.text).toContain("slow command");
|
|
326
|
+
expect(llmNotice.text).toContain("Avoid re-running");
|
|
327
|
+
}
|
|
328
|
+
// Should notify UI
|
|
329
|
+
expect(mockCtx.ui.notify).toHaveBeenCalledWith(
|
|
330
|
+
expect.stringContaining("The full output is above"),
|
|
331
|
+
"info",
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
vi.useRealTimers();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("truncated output: fast command runs extractor on full output file", async () => {
|
|
338
|
+
mockOriginalExecute.mockResolvedValue({
|
|
339
|
+
content: [{ type: "text", text: "truncated..." }],
|
|
340
|
+
details: { fullOutputPath: "/tmp/full-output.txt" },
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
(mockPi.exec as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
344
|
+
code: 0,
|
|
345
|
+
stdout: "filtered from file",
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const prepared = prepareBashArguments({ command: "npm test | tail -5" });
|
|
349
|
+
|
|
350
|
+
const result = await executeBashGuarded(
|
|
351
|
+
ctx,
|
|
352
|
+
"call-1",
|
|
353
|
+
prepared,
|
|
354
|
+
new AbortController().signal,
|
|
355
|
+
() => {},
|
|
356
|
+
{},
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// Should run extractor on the full output file
|
|
360
|
+
expect(mockPi.exec).toHaveBeenCalledWith("bash", [
|
|
361
|
+
"-c",
|
|
362
|
+
expect.stringContaining("/tmp/full-output.txt"),
|
|
363
|
+
]);
|
|
364
|
+
// Should return filtered output only (no LLM notice for fast commands)
|
|
365
|
+
const textContent = result.content[0];
|
|
366
|
+
if (textContent.type === "text") {
|
|
367
|
+
expect(textContent.text).toBe("filtered from file");
|
|
368
|
+
}
|
|
369
|
+
// Should notify UI
|
|
370
|
+
expect(mockCtx.ui.notify).toHaveBeenCalledWith(
|
|
371
|
+
expect.stringContaining("Filtered via"),
|
|
372
|
+
"info",
|
|
373
|
+
);
|
|
374
|
+
// Should NOT append notice to LLM response for fast commands
|
|
375
|
+
expect(result.content).toHaveLength(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("strips multiple extractors: grep FAIL | head -5", async () => {
|
|
379
|
+
mockOriginalExecute.mockResolvedValue({
|
|
380
|
+
content: [{ type: "text", text: "FAIL test1\nFAIL test2\nPASS test3" }],
|
|
381
|
+
details: undefined,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
(mockPi.exec as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
385
|
+
code: 0,
|
|
386
|
+
stdout: "FAIL test1\nFAIL test2",
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const prepared = prepareBashArguments({ command: "npm test | grep FAIL | head -5" });
|
|
390
|
+
|
|
391
|
+
const result = await executeBashGuarded(
|
|
392
|
+
ctx,
|
|
393
|
+
"call-1",
|
|
394
|
+
prepared,
|
|
395
|
+
new AbortController().signal,
|
|
396
|
+
() => {},
|
|
397
|
+
{},
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// Should pipe through both grep and head
|
|
401
|
+
expect(mockPi.exec).toHaveBeenCalledWith("bash", [
|
|
402
|
+
"-c",
|
|
403
|
+
expect.stringContaining("grep FAIL | head -5"),
|
|
404
|
+
]);
|
|
405
|
+
const textContent = result.content[0];
|
|
406
|
+
if (textContent.type === "text") {
|
|
407
|
+
expect(textContent.text).toBe("FAIL test1\nFAIL test2");
|
|
408
|
+
}
|
|
409
|
+
// Should notify UI
|
|
410
|
+
expect(mockCtx.ui.notify).toHaveBeenCalledWith(
|
|
411
|
+
expect.stringContaining("Filtered via"),
|
|
412
|
+
"info",
|
|
413
|
+
);
|
|
414
|
+
// Should NOT append notice to LLM response for fast commands
|
|
415
|
+
expect(result.content).toHaveLength(1);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("prepareBashArguments", () => {
|
|
420
|
+
it("strips trailing extractor and stores metadata", () => {
|
|
421
|
+
const result = prepareBashArguments({ command: "ls | head -5" });
|
|
422
|
+
expect(result.command).toBe("ls");
|
|
423
|
+
expect(result._piToolGuardRemoved).toEqual(["head"]);
|
|
424
|
+
expect(result._piToolGuardPipeline).toBe("head -5");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("does nothing for commands without extractors", () => {
|
|
428
|
+
const result = prepareBashArguments({ command: "ls -la" });
|
|
429
|
+
expect(result.command).toBe("ls -la");
|
|
430
|
+
expect(result._piToolGuardRemoved).toBeUndefined();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("handles non-string command gracefully", () => {
|
|
434
|
+
const result = prepareBashArguments({ command: 123 } as Record<string, unknown>);
|
|
435
|
+
expect(result.command).toBe(123);
|
|
436
|
+
});
|
|
437
|
+
});
|
package/bash.ts
CHANGED
|
@@ -6,9 +6,14 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
6
6
|
|
|
7
7
|
export const FAST_THRESHOLD_MS = 10_000;
|
|
8
8
|
|
|
9
|
-
export
|
|
9
|
+
export type NoticeMode = "fast" | "slow";
|
|
10
|
+
|
|
11
|
+
export function buildNotice(removedNames: string[], mode: NoticeMode = "slow"): string {
|
|
10
12
|
const list = removedNames.map((r) => `\`${r}\``).join(", ");
|
|
11
|
-
|
|
13
|
+
const suffix = mode === "fast"
|
|
14
|
+
? `Filtered via ${list} — full output was small enough to pipe.`
|
|
15
|
+
: `The full output is above — do NOT re-run with different parameters.`;
|
|
16
|
+
return `[pi-tool-guard] Removed trailing pipeline commands: ${list}. ${suffix}`;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
/**
|
package/execute.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, AgentToolResult, BashToolDetails } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
stripTrailingExtractors,
|
|
4
|
+
extractFullOutputPath,
|
|
5
|
+
} from "./pipeline.ts";
|
|
6
|
+
import {
|
|
7
|
+
FAST_THRESHOLD_MS,
|
|
8
|
+
buildNotice,
|
|
9
|
+
runExtractorOnFile,
|
|
10
|
+
runExtractorOnText,
|
|
11
|
+
} from "./bash.ts";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export type ToolResult = AgentToolResult<BashToolDetails | undefined>;
|
|
18
|
+
|
|
19
|
+
export interface ExecuteContext {
|
|
20
|
+
pi: ExtensionAPI;
|
|
21
|
+
ctx: ExtensionContext;
|
|
22
|
+
originalExecute: (
|
|
23
|
+
toolCallId: string,
|
|
24
|
+
params: unknown,
|
|
25
|
+
signal: AbortSignal | undefined,
|
|
26
|
+
onUpdate: ((update: unknown) => void) | undefined,
|
|
27
|
+
execCtx: unknown,
|
|
28
|
+
) => Promise<ToolResult>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Core execute logic (extracted for testability)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export async function executeBashGuarded(
|
|
36
|
+
ctx: ExecuteContext,
|
|
37
|
+
toolCallId: string,
|
|
38
|
+
params: unknown,
|
|
39
|
+
signal: AbortSignal | undefined,
|
|
40
|
+
onUpdate: ((update: unknown) => void) | undefined,
|
|
41
|
+
execCtx: unknown,
|
|
42
|
+
): Promise<ToolResult> {
|
|
43
|
+
const input = params as Record<string, unknown>;
|
|
44
|
+
const removedNames = input._piToolGuardRemoved as string[] | undefined;
|
|
45
|
+
const removedPipeline = input._piToolGuardPipeline as string | undefined;
|
|
46
|
+
|
|
47
|
+
// No extractors — run normally
|
|
48
|
+
if (!removedNames || removedNames.length === 0 || !removedPipeline) {
|
|
49
|
+
return ctx.originalExecute(toolCallId, params, signal, onUpdate, execCtx);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Clean transient fields before running
|
|
53
|
+
delete input._piToolGuardRemoved;
|
|
54
|
+
delete input._piToolGuardPipeline;
|
|
55
|
+
|
|
56
|
+
// Run the stripped command
|
|
57
|
+
const start = Date.now();
|
|
58
|
+
let result;
|
|
59
|
+
try {
|
|
60
|
+
result = await ctx.originalExecute(toolCallId, params, signal, onUpdate, execCtx);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// On error: check if output was saved to file, run extractor on it
|
|
63
|
+
const errText = err instanceof Error ? err.message : String(err);
|
|
64
|
+
const fullPath = extractFullOutputPath(errText);
|
|
65
|
+
if (fullPath) {
|
|
66
|
+
const extracted = await runExtractorOnFile(ctx.pi, fullPath, removedPipeline);
|
|
67
|
+
if (extracted !== undefined) {
|
|
68
|
+
const uiNotice = buildNotice(removedNames, "fast");
|
|
69
|
+
ctx.ctx.ui.notify(uiNotice, "info");
|
|
70
|
+
throw new Error(extracted);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const elapsed = Date.now() - start;
|
|
77
|
+
const details = result.details as BashToolDetails | undefined;
|
|
78
|
+
const isTruncated = !!details?.fullOutputPath;
|
|
79
|
+
|
|
80
|
+
// Case 1: Fast command, truncated → run extractor on the full output file
|
|
81
|
+
if (elapsed < FAST_THRESHOLD_MS && isTruncated) {
|
|
82
|
+
const extracted = await runExtractorOnFile(ctx.pi, details!.fullOutputPath!, removedPipeline);
|
|
83
|
+
if (extracted !== undefined) {
|
|
84
|
+
const uiNotice = buildNotice(removedNames, "fast");
|
|
85
|
+
ctx.ctx.ui.notify(uiNotice, "info");
|
|
86
|
+
return {
|
|
87
|
+
content: [{ type: "text" as const, text: extracted }],
|
|
88
|
+
details: result.details,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Case 2: Fast command, not truncated → pipe result through extractor
|
|
94
|
+
if (elapsed < FAST_THRESHOLD_MS && !isTruncated) {
|
|
95
|
+
const resultText = result.content
|
|
96
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
97
|
+
.map((c) => c.text)
|
|
98
|
+
.join("\n");
|
|
99
|
+
|
|
100
|
+
const extracted = await runExtractorOnText(ctx.pi, resultText, removedPipeline);
|
|
101
|
+
if (extracted !== undefined) {
|
|
102
|
+
const uiNotice = buildNotice(removedNames, "fast");
|
|
103
|
+
ctx.ctx.ui.notify(uiNotice, "info");
|
|
104
|
+
return {
|
|
105
|
+
content: [{ type: "text" as const, text: extracted }],
|
|
106
|
+
details: result.details,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Case 3: Slow command (truncated or not) → return result with LLM notice
|
|
112
|
+
const uiNotice = buildNotice(removedNames, "slow");
|
|
113
|
+
ctx.ctx.ui.notify(uiNotice, "info");
|
|
114
|
+
const llmNotice = `This is a slow command. Avoid re-running; prefer reading from the full output path if available.`;
|
|
115
|
+
return {
|
|
116
|
+
content: [...result.content, { type: "text" as const, text: `\n\n${llmNotice}` }],
|
|
117
|
+
details: result.details,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// prepareArguments logic (extracted for testability)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
export function prepareBashArguments(input: Record<string, unknown>): Record<string, unknown> {
|
|
126
|
+
if (typeof input.command !== "string") return input;
|
|
127
|
+
|
|
128
|
+
const stripped = stripTrailingExtractors(input.command);
|
|
129
|
+
if (stripped) {
|
|
130
|
+
input.command = stripped.cleaned;
|
|
131
|
+
input._piToolGuardRemoved = stripped.removedNames;
|
|
132
|
+
input._piToolGuardPipeline = stripped.removedPipeline;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return input;
|
|
136
|
+
}
|
package/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
3
|
type BashToolInput,
|
|
4
|
-
type BashToolDetails,
|
|
5
4
|
type EditToolInput,
|
|
6
5
|
type ReadToolInput,
|
|
7
6
|
type WriteToolInput,
|
|
@@ -15,16 +14,7 @@ import {
|
|
|
15
14
|
normalizeWriteArgs,
|
|
16
15
|
normalizeReadArgs,
|
|
17
16
|
} from "./aliases.ts";
|
|
18
|
-
import {
|
|
19
|
-
stripTrailingExtractors,
|
|
20
|
-
extractFullOutputPath,
|
|
21
|
-
} from "./pipeline.ts";
|
|
22
|
-
import {
|
|
23
|
-
FAST_THRESHOLD_MS,
|
|
24
|
-
buildNotice,
|
|
25
|
-
runExtractorOnFile,
|
|
26
|
-
runExtractorOnText,
|
|
27
|
-
} from "./bash.ts";
|
|
17
|
+
import { executeBashGuarded, prepareBashArguments } from "./execute.ts";
|
|
28
18
|
|
|
29
19
|
// ---------------------------------------------------------------------------
|
|
30
20
|
// extension
|
|
@@ -73,91 +63,18 @@ export default function toolGuardExtension(pi: ExtensionAPI) {
|
|
|
73
63
|
...bashDef,
|
|
74
64
|
prepareArguments: (args: unknown): BashToolInput => {
|
|
75
65
|
if (!args || typeof args !== "object") return args as BashToolInput;
|
|
76
|
-
|
|
77
|
-
const input = args as Record<string, unknown>;
|
|
78
|
-
if (typeof input.command !== "string") return args as BashToolInput;
|
|
79
|
-
|
|
80
|
-
const stripped = stripTrailingExtractors(input.command);
|
|
81
|
-
if (stripped) {
|
|
82
|
-
input.command = stripped.cleaned;
|
|
83
|
-
input._piToolGuardRemoved = stripped.removedNames;
|
|
84
|
-
input._piToolGuardPipeline = stripped.removedPipeline;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return input as unknown as BashToolInput;
|
|
66
|
+
return prepareBashArguments(args as Record<string, unknown>) as unknown as BashToolInput;
|
|
88
67
|
},
|
|
89
68
|
|
|
90
69
|
async execute(toolCallId, params, signal, onUpdate, execCtx) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// Clean transient fields before running
|
|
101
|
-
delete input._piToolGuardRemoved;
|
|
102
|
-
delete input._piToolGuardPipeline;
|
|
103
|
-
|
|
104
|
-
// Run the stripped command
|
|
105
|
-
const start = Date.now();
|
|
106
|
-
let result;
|
|
107
|
-
try {
|
|
108
|
-
result = await originalExecute(toolCallId, params, signal, onUpdate, execCtx);
|
|
109
|
-
} catch (err) {
|
|
110
|
-
// On error: check if output was saved to file, run extractor on it
|
|
111
|
-
const errText = err instanceof Error ? err.message : String(err);
|
|
112
|
-
const fullPath = extractFullOutputPath(errText);
|
|
113
|
-
if (fullPath) {
|
|
114
|
-
const extracted = await runExtractorOnFile(pi, fullPath, removedPipeline);
|
|
115
|
-
if (extracted !== undefined) {
|
|
116
|
-
const notice = buildNotice(removedNames);
|
|
117
|
-
throw new Error(`${extracted}\n\n${notice}`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
throw err;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const elapsed = Date.now() - start;
|
|
124
|
-
|
|
125
|
-
// Case 1: Output was truncated → run extractor on the full output file
|
|
126
|
-
const details = result.details as BashToolDetails | undefined;
|
|
127
|
-
if (details?.fullOutputPath) {
|
|
128
|
-
const extracted = await runExtractorOnFile(pi, details.fullOutputPath, removedPipeline);
|
|
129
|
-
if (extracted !== undefined) {
|
|
130
|
-
const notice = buildNotice(removedNames);
|
|
131
|
-
return {
|
|
132
|
-
content: [{ type: "text" as const, text: `${extracted}\n\n${notice}` }],
|
|
133
|
-
details: result.details,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Case 2: Fast command, no truncation → pipe result through extractor
|
|
139
|
-
if (elapsed < FAST_THRESHOLD_MS) {
|
|
140
|
-
const resultText = result.content
|
|
141
|
-
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
142
|
-
.map((c) => c.text)
|
|
143
|
-
.join("\n");
|
|
144
|
-
|
|
145
|
-
const extracted = await runExtractorOnText(pi, resultText, removedPipeline);
|
|
146
|
-
if (extracted !== undefined) {
|
|
147
|
-
const notice = buildNotice(removedNames);
|
|
148
|
-
return {
|
|
149
|
-
content: [{ type: "text" as const, text: `${extracted}\n\n${notice}` }],
|
|
150
|
-
details: result.details,
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Case 3: Slow, no truncation → return full result with notice
|
|
156
|
-
const notice = buildNotice(removedNames);
|
|
157
|
-
return {
|
|
158
|
-
content: [...result.content, { type: "text" as const, text: `\n\n${notice}` }],
|
|
159
|
-
details: result.details,
|
|
160
|
-
};
|
|
70
|
+
return executeBashGuarded(
|
|
71
|
+
{ pi, ctx, originalExecute },
|
|
72
|
+
toolCallId,
|
|
73
|
+
params,
|
|
74
|
+
signal,
|
|
75
|
+
onUpdate,
|
|
76
|
+
execCtx,
|
|
77
|
+
);
|
|
161
78
|
},
|
|
162
79
|
});
|
|
163
80
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tool-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi extension that corrects LLM tool calls: normalizes argument aliases for edit/write/read and strips trailing pipeline extractors from bash commands",
|
|
6
6
|
"main": "index.ts",
|