Skip to content

Commit 15b9154

Browse files
committed
feat(coding-agent): intercept bash execution
1 parent 79268f9 commit 15b9154

File tree

13 files changed

+424
-29
lines changed

13 files changed

+424
-29
lines changed

packages/coding-agent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- Extension package management with `pi install`, `pi remove`, `pi update`, and `pi list` commands ([#645](https://github.com/badlogic/pi-mono/issues/645))
1414
- `/reload` command to reload extensions, skills, prompts, and themes ([#645](https://github.com/badlogic/pi-mono/issues/645))
1515
- CLI flags for `--skill`, `--prompt-template`, `--theme`, `--no-prompt-templates`, and `--no-themes` ([#645](https://github.com/badlogic/pi-mono/issues/645))
16+
- `tool_result` handlers can return `errorMessage` to override tool errors or force a failure
1617

1718
### Changed
1819

packages/coding-agent/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1101,7 +1101,23 @@ export default function (pi: ExtensionAPI) {
11011101
pi.on("tool_result", async (event, ctx) => {
11021102
if (event.toolName === "read") {
11031103
// Redact secrets from file contents
1104-
return { modifiedResult: event.result.replace(/API_KEY=\w+/g, "API_KEY=***") };
1104+
return {
1105+
content: event.content.map((item) =>
1106+
item.type === "text"
1107+
? { ...item, text: item.text.replace(/API_KEY=\w+/g, "API_KEY=***") }
1108+
: item
1109+
),
1110+
};
1111+
}
1112+
1113+
if (event.isError) {
1114+
// Override the thrown error message (optional)
1115+
return { errorMessage: "Custom error message" };
1116+
}
1117+
1118+
if (event.toolName === "bash" && event.content.length > 0) {
1119+
// Force a successful tool to be treated as an error
1120+
return { errorMessage: "Tool output rejected by policy" };
11051121
}
11061122
});
11071123

packages/coding-agent/docs/extensions.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,9 +549,34 @@ pi.on("tool_call", async (event, ctx) => {
549549

550550
**Examples:** [chalk-logger.ts](../examples/extensions/chalk-logger.ts), [permission-gate.ts](../examples/extensions/permission-gate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [protected-paths.ts](../examples/extensions/protected-paths.ts)
551551

552+
#### before_bash_exec
553+
554+
Fired before a bash command executes (tool calls and user `!`/`!!`). Use it to rewrite commands or override execution settings. You can also block execution by returning `{ block: true, reason?: string }`. For follow-up hints based on output, pair this with `tool_result`.
555+
556+
```typescript
557+
pi.on("before_bash_exec", async (event) => {
558+
if (event.command.includes("rm -rf")) {
559+
return { block: true, reason: "Blocked by policy" };
560+
}
561+
562+
if (event.source === "tool") {
563+
return {
564+
cwd: "/tmp",
565+
env: {
566+
...event.env,
567+
MY_VAR: "1",
568+
PATH: undefined, // remove PATH
569+
},
570+
};
571+
}
572+
});
573+
```
574+
575+
Return a `BashExecOverrides` object to override fields, or return `{ block: true, reason?: string }` to reject the command. Any field set to a non-undefined value replaces the original (`command`, `cwd`, `env`, `shell`, `args`, `timeout`). For `env`, set a key to `undefined` to remove it.
576+
552577
#### tool_result
553578

554-
Fired after tool executes. **Can modify result.**
579+
Fired after tool executes. **Can modify result.** Use this to post-process outputs (for example, append hints or redact secrets) before the result is sent to the model.
555580

556581
```typescript
557582
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
@@ -565,10 +590,12 @@ pi.on("tool_result", async (event, ctx) => {
565590
}
566591

567592
// Modify result:
568-
return { content: [...], details: {...}, isError: false };
593+
return { content: [...], details: {...} };
569594
});
570595
```
571596

597+
If `event.isError` is true, return `{ errorMessage: "..." }` to override the thrown error message (optionally alongside `content`). Returning `errorMessage` on a successful tool result forces the tool to be treated as an error.
598+
572599
**Examples:** [git-checkpoint.ts](../examples/extensions/git-checkpoint.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts)
573600

574601
### User Bash Events
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* uv Python Interceptor
3+
*
4+
* Demonstrates before_bash_exec by redirecting python invocations through uv.
5+
* This is a simple example that assumes basic whitespace-separated arguments.
6+
*
7+
* Usage:
8+
* pi -e examples/extensions/uv.ts
9+
*/
10+
11+
import { type ExtensionAPI, isBashToolResult } from "@mariozechner/pi-coding-agent";
12+
13+
const PYTHON_PREFIX = /^python3?(\s+|$)/;
14+
const UV_RUN_PYTHON_PREFIX = /^uv\s+run\s+python3?(\s+|$)/;
15+
const PIP_PREFIX = /^pip3?(\s+|$)/;
16+
const PIP_MODULE_PATTERN = /\s-m\s+pip3?(\s|$)/;
17+
const TRACEBACK_PATTERN = /Traceback \(most recent call last\):/;
18+
const IMPORT_ERROR_PATTERN = /\b(ModuleNotFoundError|ImportError):/;
19+
const MODULE_NOT_FOUND_PATTERN = /No module named ['"]([^'"]+)['"]/;
20+
21+
const PIP_BLOCK_REASON =
22+
"pip is disabled. Use uv run instead, particularly --with and --script for throwaway work. Do not use uv pip!";
23+
24+
export default function (pi: ExtensionAPI) {
25+
pi.on("before_bash_exec", (event) => {
26+
const trimmed = event.originalCommand.trim();
27+
const isPythonCommand = PYTHON_PREFIX.test(trimmed);
28+
const isUvRunPythonCommand = UV_RUN_PYTHON_PREFIX.test(trimmed);
29+
const isPipModule = PIP_MODULE_PATTERN.test(trimmed);
30+
31+
if (PIP_PREFIX.test(trimmed) || (isPipModule && (isPythonCommand || isUvRunPythonCommand))) {
32+
return {
33+
block: true,
34+
reason: PIP_BLOCK_REASON,
35+
};
36+
}
37+
38+
if (!isPythonCommand) {
39+
return;
40+
}
41+
42+
const normalizedCommand = trimmed.replace(PYTHON_PREFIX, "python ").trimEnd();
43+
const uvCommand = `uv run ${normalizedCommand}`;
44+
45+
return {
46+
command: uvCommand,
47+
};
48+
});
49+
50+
pi.on("tool_result", (event) => {
51+
if (!isBashToolResult(event)) return;
52+
53+
const text = event.content
54+
.filter((item) => item.type === "text")
55+
.map((item) => item.text)
56+
.join("");
57+
58+
if (!TRACEBACK_PATTERN.test(text) || !IMPORT_ERROR_PATTERN.test(text)) {
59+
return;
60+
}
61+
62+
const moduleMatch = text.match(MODULE_NOT_FOUND_PATTERN);
63+
const moduleName = moduleMatch?.[1];
64+
const hintTarget = moduleName ? ` --with ${moduleName}` : "";
65+
const hint =
66+
"\n\nHint: Python import failed. Consider running with uv to fetch dependencies, " +
67+
`e.g. \`uv run${hintTarget} python -c '...'\` or \`uv run --script\` for throwaway scripts.`;
68+
69+
const message = text + hint;
70+
71+
return {
72+
content: [...event.content, { type: "text", text: hint }],
73+
errorMessage: message,
74+
};
75+
});
76+
}

packages/coding-agent/src/core/agent-session.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/
2727
import { getAuthPath } from "../config.js";
2828
import { theme } from "../modes/interactive/theme/theme.js";
2929
import { stripFrontmatter } from "../utils/frontmatter.js";
30+
import { getShellConfig, getShellEnv } from "../utils/shell.js";
3031
import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor.js";
3132
import {
3233
type CompactionResult,
@@ -41,6 +42,7 @@ import {
4142
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js";
4243
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
4344
import {
45+
type BeforeBashExecEvent,
4446
type ContextUsage,
4547
type ExtensionCommandContextActions,
4648
type ExtensionErrorListener,
@@ -2018,19 +2020,48 @@ export class AgentSession {
20182020
): Promise<BashResult> {
20192021
this._bashAbortController = new AbortController();
20202022

2021-
// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
2022-
const prefix = this.settingsManager.getShellCommandPrefix();
2023-
const resolvedCommand = prefix ? `${prefix}\n${command}` : command;
2024-
20252023
try {
2024+
// Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support)
2025+
const prefix = this.settingsManager.getShellCommandPrefix();
2026+
const resolvedCommand = prefix ? `${prefix}\n${command}` : command;
2027+
const shellConfig = getShellConfig();
2028+
const baseEvent: BeforeBashExecEvent = {
2029+
type: "before_bash_exec",
2030+
source: "user_bash",
2031+
command: resolvedCommand,
2032+
originalCommand: command,
2033+
cwd: process.cwd(),
2034+
env: { ...getShellEnv() },
2035+
shell: shellConfig.shell,
2036+
args: [...shellConfig.args],
2037+
};
2038+
const execEvent = this._extensionRunner?.hasHandlers("before_bash_exec")
2039+
? await this._extensionRunner.emitBeforeBashExec(baseEvent)
2040+
: baseEvent;
2041+
const execCommand = execEvent.command;
2042+
const execCwd = execEvent.cwd;
2043+
const execEnv = execEvent.env;
2044+
const execShell = execEvent.shell;
2045+
const execArgs = execEvent.args;
2046+
const execTimeout = execEvent.timeout;
2047+
20262048
const result = options?.operations
2027-
? await executeBashWithOperations(resolvedCommand, process.cwd(), options.operations, {
2049+
? await executeBashWithOperations(execCommand, execCwd, options.operations, {
20282050
onChunk,
20292051
signal: this._bashAbortController.signal,
2052+
env: execEnv,
2053+
shell: execShell,
2054+
args: execArgs,
2055+
timeout: execTimeout,
20302056
})
2031-
: await executeBashCommand(resolvedCommand, {
2057+
: await executeBashCommand(execCommand, {
20322058
onChunk,
20332059
signal: this._bashAbortController.signal,
2060+
cwd: execCwd,
2061+
env: execEnv,
2062+
shell: execShell,
2063+
args: execArgs,
2064+
timeout: execTimeout,
20342065
});
20352066

20362067
this.recordBashResult(command, result, options);

packages/coding-agent/src/core/bash-executor.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ export interface BashExecutorOptions {
2525
onChunk?: (chunk: string) => void;
2626
/** AbortSignal for cancellation */
2727
signal?: AbortSignal;
28+
/** Working directory override */
29+
cwd?: string;
30+
/** Environment override */
31+
env?: NodeJS.ProcessEnv;
32+
/** Shell executable override */
33+
shell?: string;
34+
/** Shell argument override */
35+
args?: string[];
36+
/** Timeout in seconds */
37+
timeout?: number;
2838
}
2939

3040
export interface BashResult {
@@ -60,13 +70,29 @@ export interface BashResult {
6070
*/
6171
export function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
6272
return new Promise((resolve, reject) => {
63-
const { shell, args } = getShellConfig();
64-
const child: ChildProcess = spawn(shell, [...args, command], {
73+
const shellConfig = getShellConfig();
74+
const resolvedShell = options?.shell ?? shellConfig.shell;
75+
const resolvedArgs = options?.args ?? shellConfig.args;
76+
const resolvedCwd = options?.cwd ?? process.cwd();
77+
const resolvedEnv = { ...getShellEnv(), ...(options?.env ?? {}) };
78+
const child: ChildProcess = spawn(resolvedShell, [...resolvedArgs, command], {
79+
cwd: resolvedCwd,
80+
env: resolvedEnv,
6581
detached: true,
66-
env: getShellEnv(),
6782
stdio: ["ignore", "pipe", "pipe"],
6883
});
6984

85+
let timedOut = false;
86+
let timeoutHandle: NodeJS.Timeout | undefined;
87+
if (options?.timeout !== undefined && options.timeout > 0) {
88+
timeoutHandle = setTimeout(() => {
89+
timedOut = true;
90+
if (child.pid) {
91+
killProcessTree(child.pid);
92+
}
93+
}, options.timeout * 1000);
94+
}
95+
7096
// Track sanitized output for truncation
7197
const outputChunks: string[] = [];
7298
let outputBytes = 0;
@@ -88,6 +114,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
88114
if (options.signal.aborted) {
89115
// Already aborted, don't even start
90116
child.kill();
117+
if (timeoutHandle) {
118+
clearTimeout(timeoutHandle);
119+
}
91120
resolve({
92121
output: "",
93122
exitCode: undefined,
@@ -144,6 +173,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
144173
if (options?.signal) {
145174
options.signal.removeEventListener("abort", abortHandler);
146175
}
176+
if (timeoutHandle) {
177+
clearTimeout(timeoutHandle);
178+
}
147179

148180
if (tempFileStream) {
149181
tempFileStream.end();
@@ -153,8 +185,7 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
153185
const fullOutput = outputChunks.join("");
154186
const truncationResult = truncateTail(fullOutput);
155187

156-
// code === null means killed (cancelled)
157-
const cancelled = code === null;
188+
const cancelled = code === null || timedOut;
158189

159190
resolve({
160191
output: truncationResult.truncated ? truncationResult.content : fullOutput,
@@ -170,6 +201,9 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
170201
if (options?.signal) {
171202
options.signal.removeEventListener("abort", abortHandler);
172203
}
204+
if (timeoutHandle) {
205+
clearTimeout(timeoutHandle);
206+
}
173207

174208
if (tempFileStream) {
175209
tempFileStream.end();
@@ -238,6 +272,10 @@ export async function executeBashWithOperations(
238272
const result = await operations.exec(command, cwd, {
239273
onData,
240274
signal: options?.signal,
275+
timeout: options?.timeout,
276+
env: options?.env,
277+
shell: options?.shell,
278+
args: options?.args,
241279
});
242280

243281
if (tempFileStream) {

packages/coding-agent/src/core/extensions/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,14 @@ export type {
2525
// App keybindings (for custom editors)
2626
AppAction,
2727
AppendEntryHandler,
28+
BashExecEvent,
29+
BashExecOverrides,
30+
BashExecSource,
2831
BashToolResultEvent,
2932
BeforeAgentStartEvent,
3033
BeforeAgentStartEventResult,
34+
BeforeBashExecEvent,
35+
BeforeBashExecEventResult,
3136
// Context
3237
CompactOptions,
3338
// Events - Agent

0 commit comments

Comments
 (0)