From cf396fb9c7da7eaef7b299c1cd45271acb2c155b Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Tue, 16 Jun 2026 12:48:00 +0530 Subject: [PATCH] feat(worker): enforce bounded bash timeouts via pi extension --- .../src/ai/extensions/bash-timeout/index.ts | 47 +++++++++++++++++++ apps/worker/src/ai/pi-executor.ts | 5 +- apps/worker/src/paths.ts | 3 ++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 apps/worker/src/ai/extensions/bash-timeout/index.ts diff --git a/apps/worker/src/ai/extensions/bash-timeout/index.ts b/apps/worker/src/ai/extensions/bash-timeout/index.ts new file mode 100644 index 0000000..138882e --- /dev/null +++ b/apps/worker/src/ai/extensions/bash-timeout/index.ts @@ -0,0 +1,47 @@ +/** + * pi extension: enforce a bounded timeout on every `bash` tool call. + * + * pi's built-in bash tool accepts an optional `timeout` (in seconds) but applies + * NO default and NO upper bound — an unbounded command (e.g. a `playwright-cli` + * browser action that never returns) hangs the agent indefinitely. This extension + * registers a `tool_call` pre-execution handler that blocks any `bash` invocation + * that omits `timeout` or sets it above the maximum, returning a message that tells + * the model how to re-run the command correctly. + */ + +import type { ExtensionAPI, ToolCallEvent, ToolCallEventResult } from '@earendil-works/pi-coding-agent'; +import { isToolCallEventType } from '@earendil-works/pi-coding-agent'; + +/** Recommended timeout (seconds) suggested to the model when it omits one. */ +const DEFAULT_TIMEOUT_SECONDS = 120; + +/** Hard upper bound (seconds) a single bash command may run. */ +const MAX_TIMEOUT_SECONDS = 600; + +function evaluateBashTimeout(timeout: number | undefined): ToolCallEventResult | undefined { + const hasValidTimeout = typeof timeout === 'number' && Number.isFinite(timeout) && timeout > 0; + if (!hasValidTimeout) { + return { + block: true, + reason: `Set bash 'timeout' (seconds). Default ${DEFAULT_TIMEOUT_SECONDS}s, max ${MAX_TIMEOUT_SECONDS}s.`, + }; + } + + if (timeout > MAX_TIMEOUT_SECONDS) { + return { + block: true, + reason: `bash 'timeout' ${timeout}s exceeds max ${MAX_TIMEOUT_SECONDS}s. Default ${DEFAULT_TIMEOUT_SECONDS}s, max ${MAX_TIMEOUT_SECONDS}s.`, + }; + } + + return undefined; +} + +export default function bashTimeoutExtension(pi: ExtensionAPI): void { + pi.on('tool_call', (event: ToolCallEvent): ToolCallEventResult | undefined => { + if (!isToolCallEventType('bash', event)) { + return undefined; + } + return evaluateBashTimeout(event.input.timeout); + }); +} diff --git a/apps/worker/src/ai/pi-executor.ts b/apps/worker/src/ai/pi-executor.ts index b62758b..a3dfa2c 100644 --- a/apps/worker/src/ai/pi-executor.ts +++ b/apps/worker/src/ai/pi-executor.ts @@ -21,7 +21,7 @@ import { } from '@earendil-works/pi-coding-agent'; import { fs, path } from 'zx'; import type { AuditSession } from '../audit/index.js'; -import { deliverablesDir, PLAYWRIGHT_SKILL_DIR } from '../paths.js'; +import { BASH_TIMEOUT_EXTENSION_DIR, deliverablesDir, PLAYWRIGHT_SKILL_DIR } from '../paths.js'; import { isRetryableError, PentestError } from '../services/error-handling.js'; import { AGENT_VALIDATORS } from '../session-manager.js'; import type { ActivityLogger } from '../types/activity-logger.js'; @@ -65,7 +65,8 @@ function permissionExtensionDir(): string | null { } async function buildResourceLoader(cwd: string, logger: ActivityLogger): Promise { - const additionalExtensionPaths: string[] = []; + // Always enforce bounded bash timeouts so an unbounded command cannot hang the agent. + const additionalExtensionPaths: string[] = [BASH_TIMEOUT_EXTENSION_DIR]; if (fs.existsSync(permissionConfigPath())) { const extDir = permissionExtensionDir(); if (extDir) { diff --git a/apps/worker/src/paths.ts b/apps/worker/src/paths.ts index 2bf8a1d..2866f60 100644 --- a/apps/worker/src/paths.ts +++ b/apps/worker/src/paths.ts @@ -12,6 +12,9 @@ export const CONFIGS_DIR = path.join(WORKER_ROOT, 'configs'); export const PLAYWRIGHT_SKILL_DIR = path.join(os.homedir(), '.claude', 'skills', 'playwright-cli'); +/** Compiled pi extension dir that enforces bounded `bash` timeouts (resolved from dist/) */ +export const BASH_TIMEOUT_EXTENSION_DIR = path.join(import.meta.dirname, 'ai', 'extensions', 'bash-timeout'); + /** Default deliverables subdirectory relative to repoPath */ export const DEFAULT_DELIVERABLES_SUBDIR = '.shannon/deliverables';