Skip to content

Commit bf01b2a

Browse files
Merge pull request #546 from corbitsdev/cl-6925-drop-the-15s-default-run_shell-timeout
Drop the built-in run_shell timeout default
2 parents 0a07298 + 9fbbbed commit bf01b2a

10 files changed

Lines changed: 156 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,22 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### Plugins
17+
18+
- **`run_shell` no longer defaults to a 15s timeout.** Omitted timeout arms no
19+
timer (match Pi). Pass a per-call `timeout`, or set `shell.timeoutMs` in
20+
settings, to bound a command. `shell.maxTimeoutMs` still clamps a resolved
21+
timeout and does not invent one on its own. Abort and the output-byte cap are
22+
unchanged.
23+
1624
### Sub-agents
1725

1826
- **Sub-agent `maxTurns` no longer hard-caps at 100.** Default remains 30 when
1927
unset; values must still be integers ≥1. `task(maxTurns)`, profile
2028
`maxTurns`, and `settings.subagentMaxTurns` may exceed 100 for long jobs.
2129

2230
## [0.2.104] - 2026-08-23
31+
2332
### TUI
2433

2534
- **Taller live chain-of-thought preview.** Parent reasoning still paints

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ tool call
354354
- **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. Shell commands that _reference_ a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here: they require operator approval via the permission gate, and auto mode forces an ask through the auto-shell policy (`sensitive-path` rule). Once the operator approves, the command runs. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (variable indirection, `printf` assembly). Tool-result secret scrub still redacts credential-shaped output.
355355
- **Authorization** (`run-shell-authz.ts`, wired by `authz-plugin.ts`) — Denies catastrophic shell command patterns by regex, and hard-blocks shell `find`, head-position `rg`, and recursive `grep -r` (they can walk huge trees and OOM the host). Bounded `grep`/`search_files` tools remain practical alternatives (timeout + output caps); the patterns match those three command shapes only — an `ls -R`, `fd`, or scripted `os.walk` is just as unbounded and is not caught, so the block message tells the model not to substitute one. The permission gate’s shell auto-allow path consults the same policy so it never pre-approves a command authz would reject.
356356
- **Permission** (`permission-plugin.ts`) — Delegates consequential calls to the permission gate.
357-
- **Shell Guard** (`shell-guard-plugin.ts`) — Corbits Code-only replacement for stock `run_shell` (interchange stays unpatched): 15s default timeout, 512KB display cap with head+tail retention (the process keeps running when the cap is hit), process-group kill on timeout/abort only. Also applies a 10s wall-clock budget to `grep`/`search_files`.
357+
- **Shell Guard** (`shell-guard-plugin.ts`) — Corbits Code-only replacement for stock `run_shell` (interchange stays unpatched): no built-in default timeout (optional per-call or `settings.shell.timeoutMs`; `maxTimeoutMs` clamps only a resolved timeout), 512KB display cap with head+tail retention (the process keeps running when the cap is hit), process-group kill on timeout/abort only. Also applies a 10s wall-clock budget to `grep`/`search_files`.
358358
- **Read File Guard** (`read-file-guard-plugin.ts`) — Corbits Code-only short-circuit for `read_file` on real filesystem paths and configured `tool-output://` URIs (interchange stays unpatched): streaming reads that never decode the whole file in one pass, caps model-facing output at 50KB, defaults to 2000 lines, truncates long lines with recovery hints, samples the first chunk to reject binary, and stops at an 8MB scan ceiling. Emits `offset` continuation notices so the model can page without losing file or spill content on disk.
359359
- **Verify** (`verify-plugin.ts`) — Re-reads after `write_file` / `edit_file` and errors on mismatch. Per-path serialization (`file-mutation-lock.ts`) prevents parallel edits on one file from tripping verification.
360360
- **Edit file line range** (`edit-file-line-range-plugin.ts`) — Corbits Code-only short-circuit for `edit_file` mode B (`start_line`/`end_line`/`new_string`), same pattern as shell-guard; schema advertised via `advertiseEditFileLineRange`. Modes are mutually exclusive: a call supplying both `old_string` and `start_line`/`end_line` is rejected with a recoverable error naming which fields to omit (no file-content disambiguation).

src/agent/prompts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function buildHarnessFacts(
6969
]),
7070
"- Use the provided tools for file reads/searches instead of shelling out as a substitute.",
7171
"- read_file accepts a filesystem path or a tool-output:///{callId} URI from a prior tool result when the harness exposes one; prefer the URI over re-reading huge blobs.",
72-
"- run_shell defaults to a 15s timeout; pass timeout for builds, tests, and other long commands.",
72+
"- run_shell has no default timeout; pass timeout for builds, tests, and other long commands.",
7373
"- Shell find, rg, and grep -r are blocked — they can walk huge trees and OOM the host. Prefer the bounded grep/search_files tools, and do not substitute another unbounded walk (fd, ls -R, scripted os.walk).",
7474
...(subAgent
7575
? [
@@ -207,7 +207,7 @@ const TOOL_SUMMARIES: Record<string, string> = {
207207
"make a surgical edit (exact old_string match, or start_line/end_line line-range mode; never include read_file's NNNNNN\\t line prefix; substring failures include nearby file text; prefer over sed/awk in the shell)",
208208
delete_file: "delete one file with an explicit outcome (never shell rm)",
209209
run_shell:
210-
"run a shell command (builds, tests, git; 15s default timeout — pass timeout ms to override; never to read/write/delete files, search trees, or talk to the user)",
210+
"run a shell command (builds, tests, git; pass timeout ms to bound long commands; never to read/write/delete files, search trees, or talk to the user)",
211211
search_files:
212212
"find files by name or pattern (bounded; timeout + output caps — safer than open-ended shell find)",
213213
grep: "search file contents (bounded; timeout + output caps — safer than open-ended shell grep -r/rg)",

src/agent/tools.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,9 @@ export interface AgentToolsetArgs {
9191
// Skill directories (from enabled plugins) the use_skill tool resolves bodies
9292
// from, in addition to the project-local and bundled defaults.
9393
skillDirs?: string[];
94-
// Shell command timeout defaults/cap, resolved from settings. When omitted the
95-
// shell-guard plugin applies its built-in defaults.
94+
// Shell command timeout default/cap, resolved from settings. When omitted the
95+
// shell-guard plugin arms no default timeout (per-call timeout or settings
96+
// shell.timeoutMs required to bound a command).
9697
shellTimeout?: ShellTimeoutConfig;
9798
// Outer per-invocation tool run budget (dynamic runner). When omitted built-in
9899
// defaults apply.
@@ -246,7 +247,8 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
246247
return { content: "Tasks updated." };
247248
};
248249

249-
// Align the advertised run_shell timeout with shell-guard's resolved default.
250+
// Align the advertised run_shell timeout with shell-guard (no built-in default;
251+
// advertise settings.shell.timeoutMs when set).
250252
const baseTools: AgentTool[] = [
251253
...fromToolRunner(posixTools).map((tool) => ({
252254
...tool,

src/config/settings.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,11 @@ export interface Settings {
131131
// back to whatever the user's main session is currently using so the agent
132132
// still runs; "none" treats it as a hard error and the profile fails to load.
133133
agentModelFallback?: "active" | "none";
134-
// Shell command timeouts. `timeoutMs` is the default applied when the model
135-
// does not pass a per-command timeout; `maxTimeoutMs` caps any per-command
136-
// override so a single command cannot wait effectively unbounded.
134+
// Shell command timeouts. `timeoutMs` is the optional default applied when the
135+
// model does not pass a per-command timeout (unset = no default timeout, match
136+
// Pi). `maxTimeoutMs` clamps a resolved timeout only — it alone does not invent
137+
// one. A single command with neither settings default nor a per-call timeout
138+
// runs until exit, abort, or the outer tool watchdog (when configured).
137139
shell?: { timeoutMs?: number; maxTimeoutMs?: number };
138140
// Outer wall-clock budget for each tool `run()` (dynamic runner / agent dispatch).
139141
//
@@ -240,7 +242,7 @@ export function listFavoriteModels(settings: Settings): ModelRef[] {
240242
}
241243

242244
// Maps the settings shell block to the shape the shell-guard plugin expects.
243-
// Returns undefined when unset so the plugin applies its own defaults.
245+
// Returns undefined when unset so the plugin arms no default timeout.
244246
export function shellTimeoutFromSettings(
245247
settings?: Settings | null,
246248
): { defaultMs?: number; maxMs?: number } | undefined {

src/plugins/shell-guard-plugin.test.ts

Lines changed: 89 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { randomUUID } from "node:crypto";
1010

1111
import {
1212
BoundedShellOutput,
13-
DEFAULT_SHELL_TIMEOUT_MS,
1413
MAX_SHELL_OUTPUT_BYTES,
1514
advertiseShellGuardTimeout,
1615
resolveShellTimeoutMs,
@@ -32,8 +31,18 @@ describe("runGuardedShell", () => {
3231
expect(output).toContain("hello");
3332
});
3433

35-
test("defaults to a 15s timeout", () => {
36-
expect(DEFAULT_SHELL_TIMEOUT_MS).toBe(15_000);
34+
test("omitted timeout does not arm a timer", async () => {
35+
const start = Date.now();
36+
const { exitCode, timedOut, output } = await runGuardedShell(
37+
{ command: "sleep 0.25; echo done" },
38+
neverAbort(),
39+
);
40+
expect(timedOut).toBe(false);
41+
expect(exitCode).toBe(0);
42+
expect(output).toContain("done");
43+
// Completes without a timeout flag; under a 15s default this would also
44+
// pass for a short sleep — pair with resolveShellTimeoutMs coverage.
45+
expect(Date.now() - start).toBeLessThan(5_000);
3746
});
3847

3948
test("merges settings.env into the spawn environment on top of process.env", async () => {
@@ -146,37 +155,75 @@ describe("runGuardedShell", () => {
146155
});
147156

148157
describe("resolveShellTimeoutMs", () => {
149-
test("omitted timeout uses the 15s default", () => {
150-
expect(resolveShellTimeoutMs(undefined, DEFAULT_SHELL_TIMEOUT_MS)).toBe(15_000);
151-
expect(resolveShellTimeoutMs(undefined, DEFAULT_SHELL_TIMEOUT_MS, undefined)).toBe(
152-
DEFAULT_SHELL_TIMEOUT_MS,
153-
);
158+
test("omitted timeout with no default is undefined (no timer)", () => {
159+
expect(resolveShellTimeoutMs(undefined, undefined)).toBeUndefined();
160+
expect(resolveShellTimeoutMs(undefined, undefined, undefined)).toBeUndefined();
161+
});
162+
163+
test("maxMs alone does not invent a timeout", () => {
164+
expect(resolveShellTimeoutMs(undefined, undefined, 100)).toBeUndefined();
165+
expect(resolveShellTimeoutMs(undefined, undefined, 600_000)).toBeUndefined();
154166
});
155167

156-
test("non-positive requested timeout falls back to default", () => {
157-
expect(resolveShellTimeoutMs(0, DEFAULT_SHELL_TIMEOUT_MS)).toBe(DEFAULT_SHELL_TIMEOUT_MS);
158-
expect(resolveShellTimeoutMs(-1, DEFAULT_SHELL_TIMEOUT_MS)).toBe(DEFAULT_SHELL_TIMEOUT_MS);
168+
test("non-positive requested timeout falls back to default when set", () => {
169+
expect(resolveShellTimeoutMs(0, 15_000)).toBe(15_000);
170+
expect(resolveShellTimeoutMs(-1, 15_000)).toBe(15_000);
171+
});
172+
173+
test("non-positive requested with no default is undefined", () => {
174+
expect(resolveShellTimeoutMs(0, undefined)).toBeUndefined();
175+
expect(resolveShellTimeoutMs(-1, undefined)).toBeUndefined();
159176
});
160177

161178
test("requested timeout well above 10 minutes is not clamped when maxMs is omitted", () => {
162-
expect(resolveShellTimeoutMs(5_400_000, DEFAULT_SHELL_TIMEOUT_MS)).toBe(5_400_000);
163-
expect(resolveShellTimeoutMs(5_400_000, DEFAULT_SHELL_TIMEOUT_MS, undefined)).toBe(5_400_000);
164-
expect(resolveShellTimeoutMs(900_000, DEFAULT_SHELL_TIMEOUT_MS)).toBe(900_000);
179+
expect(resolveShellTimeoutMs(5_400_000, undefined)).toBe(5_400_000);
180+
expect(resolveShellTimeoutMs(5_400_000, undefined, undefined)).toBe(5_400_000);
181+
expect(resolveShellTimeoutMs(900_000, 15_000)).toBe(900_000);
165182
});
166183

167-
test("configured maxMs still clamps", () => {
168-
expect(resolveShellTimeoutMs(900_000, DEFAULT_SHELL_TIMEOUT_MS, 100)).toBe(100);
169-
expect(resolveShellTimeoutMs(5_400_000, DEFAULT_SHELL_TIMEOUT_MS, 600_000)).toBe(600_000);
170-
expect(resolveShellTimeoutMs(undefined, DEFAULT_SHELL_TIMEOUT_MS, 100)).toBe(100);
184+
test("configured maxMs still clamps a resolved timeout", () => {
185+
expect(resolveShellTimeoutMs(900_000, undefined, 100)).toBe(100);
186+
expect(resolveShellTimeoutMs(5_400_000, 15_000, 600_000)).toBe(600_000);
187+
expect(resolveShellTimeoutMs(undefined, 15_000, 100)).toBe(100);
171188
});
172189

173190
test("requested below maxMs is unchanged", () => {
174-
expect(resolveShellTimeoutMs(1_000, DEFAULT_SHELL_TIMEOUT_MS, 600_000)).toBe(1_000);
191+
expect(resolveShellTimeoutMs(1_000, undefined, 600_000)).toBe(1_000);
192+
});
193+
194+
test("settings defaultMs applies when request is omitted", () => {
195+
expect(resolveShellTimeoutMs(undefined, 90)).toBe(90);
175196
});
176197
});
177198

178199
describe("advertiseShellGuardTimeout", () => {
179-
test("rewrites run_shell timeout default to match the guard", () => {
200+
test("rewrites run_shell timeout description when a settings default is set", () => {
201+
const rewritten = advertiseShellGuardTimeout(
202+
{
203+
name: "run_shell",
204+
description: "Execute a shell command",
205+
inputSchema: {
206+
type: "object",
207+
properties: {
208+
command: { type: "string" },
209+
timeout: {
210+
type: "number",
211+
description: "Timeout in milliseconds (default: 30000)",
212+
},
213+
},
214+
required: ["command"],
215+
},
216+
},
217+
120_000,
218+
);
219+
const timeout = (
220+
rewritten.inputSchema["properties"] as Record<string, { description: string }>
221+
)["timeout"];
222+
expect(timeout?.description).toContain("120000");
223+
expect(timeout?.description).not.toContain("30000");
224+
});
225+
226+
test("advertises no default when settings default is unset", () => {
180227
const rewritten = advertiseShellGuardTimeout({
181228
name: "run_shell",
182229
description: "Execute a shell command",
@@ -195,8 +242,9 @@ describe("advertiseShellGuardTimeout", () => {
195242
const timeout = (
196243
rewritten.inputSchema["properties"] as Record<string, { description: string }>
197244
)["timeout"];
198-
expect(timeout?.description).toContain(String(DEFAULT_SHELL_TIMEOUT_MS));
245+
expect(timeout?.description).toMatch(/no default|omit/i);
199246
expect(timeout?.description).not.toContain("30000");
247+
expect(timeout?.description).not.toContain("15000");
200248
});
201249

202250
test("leaves other tools unchanged", () => {
@@ -270,6 +318,26 @@ describe("shellGuardPlugin", () => {
270318
expect(result.content).toMatch(/timed out after 90ms/);
271319
});
272320

321+
test("omitted timeout with no settings default does not time out", async () => {
322+
const handler = shellGuardPlugin(process.cwd()).middleware!(fallback);
323+
const result = await handler(
324+
{ id: "c2d", name: "run_shell", arguments: { command: "sleep 0.2; echo ok" } },
325+
neverAbort(),
326+
);
327+
expect(result.content).toContain("ok");
328+
expect(String(result.content)).not.toMatch(/timed out/);
329+
});
330+
331+
test("maxMs alone does not invent a timeout when the model omits timeout", async () => {
332+
const handler = shellGuardPlugin(process.cwd(), { maxMs: 50 }).middleware!(fallback);
333+
const result = await handler(
334+
{ id: "c2e", name: "run_shell", arguments: { command: "sleep 0.2; echo ok" } },
335+
neverAbort(),
336+
);
337+
expect(result.content).toContain("ok");
338+
expect(String(result.content)).not.toMatch(/timed out/);
339+
});
340+
273341
test("passes non-shell tools through", async () => {
274342
const result = await run({
275343
id: "c3",

0 commit comments

Comments
 (0)