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 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 slow commands, the extension strips them so the full output is available. This prevents the LLM from repeatedly re-running slow commands with different truncation parameters.
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 | Behavior |
41
- |---|---|
42
- | Output truncated (has `Full output: <file>`) | Run extractor on the full output file, return filtered result |
43
- | Fast command (< 10s), no truncation | Pipe result through extractor, return filtered result |
44
- | Slow command, no truncation | Return full result with notice |
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 output is truncated → run `tail` on the saved full output file
48
- - If vitest finishes in < 10s pipe result through `tail`
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 slowreturn 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 full result with notice
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 function buildNotice(removedNames: string[]): string {
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
- return `[pi-tool-guard] Removed trailing pipeline commands: ${list}. The full output is above — do NOT re-run with different parameters.`;
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
- const input = params as Record<string, unknown>;
92
- const removedNames = input._piToolGuardRemoved as string[] | undefined;
93
- const removedPipeline = input._piToolGuardPipeline as string | undefined;
94
-
95
- // No extractors — run normally
96
- if (!removedNames || removedNames.length === 0 || !removedPipeline) {
97
- return originalExecute(toolCallId, params, signal, onUpdate, execCtx);
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.1.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",