Intelligent Bash Tool Execution NEW
Problem
Secure, reliable command execution from agents is complex and error-prone:
- PTY requirements: TTY-required CLIs (coding agents, terminal UIs) fail with direct exec
- Platform differences: Linux and macOS behave differently for detached processes, signal handling
- Security concerns: Arbitrary command execution needs approval workflows, elevated mode detection
- Background management: Long-running processes need tracking, output aggregation, and cleanup
Agents need a multi-mode execution strategy that adapts to the command's requirements while maintaining security and reliability.
Solution
Multi-mode execution with adaptive fallback: direct exec → PTY, with automatic selection based on command requirements and runtime capabilities. The system handles PTY spawn failures gracefully, manages background processes, and provides security-aware approval workflows.
Core concepts:
- PTY-first for TTY-required commands: Detects when commands need a pseudo-terminal (coding agents, interactive CLIs) and spawns via
node-pty. - Graceful PTY fallback: If PTY spawn fails (module missing, platform unsupported), falls back to direct exec with a warning.
- Platform-specific handling: macOS requires detached processes for proper signal propagation; Linux handles both modes.
- Security-aware modes: Elevated mode detection with approval workflows (deny, allowlist, full).
- Background process registry: Long-running processes are tracked with session IDs, output tailing, and exit notifications.
- Proper signal propagation: SIGTERM/SIGKILL are delivered correctly to child processes on timeout or abort.
Implementation sketch:
async function runExecProcess(opts: {
command: string;
workdir: string;
env: Record<string, string>;
usePty: boolean;
timeoutSec: number;
}): Promise<ExecProcessHandle> {
let child: ChildProcess | null = null;
let pty: PtyHandle | null = null;
const warnings: string[] = [];
if (opts.usePty) {
try {
const { spawn } = await import("@lydell/node-pty");
pty = spawn(shell, [opts.command], {
cwd: opts.workdir,
env: opts.env,
cols: 120,
rows: 30,
});
} catch (err) {
// PTY unavailable; fallback to direct exec
warnings.push(`PTY spawn failed (${err}); retrying without PTY.`);
const { child: spawned } = await spawnWithFallback({
argv: [shell, opts.command],
options: { cwd: opts.workdir, env: opts.env },
fallbacks: [{ label: "no-detach", options: { detached: false } }],
});
child = spawned;
}
} else {
// Direct exec without PTY
const { child: spawned } = await spawnWithFallback({
argv: [shell, opts.command],
options: { cwd: opts.workdir, env: opts.env, detached: platform !== "win32" },
fallbacks: [{ label: "no-detach", options: { detached: false } }],
});
child = spawned;
}
// Register session for tracking
const session = {
id: createSessionSlug(),
command: opts.command,
pid: child?.pid ?? pty?.pid,
aggregated: "",
tail: "",
exited: false,
};
addSession(session);
// Handle timeout with SIGKILL
if (opts.timeoutSec > 0) {
setTimeout(() => {
if (!session.exited) {
killSession(session); // SIGTERM then SIGKILL
}
}, opts.timeoutSec * 1000);
}
return { session, promise /* resolves on exit */ };
}
Spawn fallback for platform differences:
async function spawnWithFallback(params: {
argv: string[];
options: ChildProcess.SpawnOptions;
fallbacks: Array<{ label: string; options: Partial<ChildProcess.SpawnOptions> }>;
}): Promise<{ child: ChildProcess }> {
try {
return { child: spawn(...params.argv, params.options) };
} catch (err) {
for (const fallback of params.fallbacks) {
try {
const mergedOptions = { ...params.options, ...fallback.options };
const child = spawn(...params.argv, mergedOptions);
// Warn about fallback
logWarn(`spawn failed (${err}); retrying with ${fallback.label}.`);
return { child };
} catch {
continue;
}
}
throw err;
}
}
Security-aware execution with approval:
async function executeWithApproval(params: {
command: string;
security: "deny" | "allowlist" | "full";
ask: "off" | "on-miss" | "always";
agentId: string;
}): Promise<ExecResult> {
const approvals = resolveExecApprovals(params.agentId, {
security: params.security,
ask: params.ask,
});
const allowlistEval = evaluateShellAllowlist({
command: params.command,
allowlist: approvals.allowlist,
safeBins: approvals.safeBins,
});
const requiresAsk = requiresExecApproval({
ask: params.ask,
security: params.security,
analysisOk: allowlistEval.analysisOk,
allowlistSatisfied: allowlistEval.allowlistSatisfied,
});
if (requiresAsk) {
const approvalId = crypto.randomUUID();
// Request approval via gateway; wait for decision
const decision = await requestApproval(approvalId, params.command);
if (decision === "deny") {
throw new Error("exec denied: user rejected");
}
}
// Execute command
return runExecProcess(params);
}
Background process management:
type ProcessSession = {
id: string;
command: string;
pid?: number;
aggregated: string;
tail: string;
exited: boolean;
exitCode?: number | null;
exitSignal?: NodeJS.Signals | null;
backgrounded: boolean;
};
const sessions = new Map<string, ProcessSession>();
function addSession(session: ProcessSession) {
sessions.set(session.id, session);
}
function markBackgrounded(session: ProcessSession) {
session.backgrounded = true;
}
function killSession(session: ProcessSession) {
if (session.child) {
session.child.kill("SIGTERM");
// Fallback to SIGKILL after grace period
setTimeout(() => {
if (!session.exited) {
session.child?.kill("SIGKILL");
markExited(session, null, "SIGKILL", "failed");
}
}, 1000);
}
}
How to use it
- Detect TTY requirements: Check if the command is a TTY-required CLI (coding agent, interactive tool) and set
usePty: true. - Handle PTY failures: Wrap PTY spawn in try-catch and fall back to direct exec with appropriate warnings.
- Configure security modes: Set default security level (
deny,allowlist,full) and approval behavior (off,on-miss,always). - Register background processes: Add sessions to a registry for tracking, polling, and cleanup.
- Propagate signals correctly: Use SIGTERM then SIGKILL for graceful shutdown, and handle platform-specific detached process behavior.
- Aggregate output: Collect stdout/stderr into
aggregatedand maintain atailfor user notifications.
Pitfalls to avoid:
- Missing PTY module:
node-ptymay not be available in all environments; always provide fallback. - Signal handling differences: macOS detached processes don't receive signals; use process groups or alternative signaling.
- Zombie processes: Always handle the
"close"event and clean up session registry entries. - Output truncation: Large outputs can overwhelm memory; enforce
maxOutputlimits and truncate middle sections.
Trade-offs
Pros:
- TTY support: PTY mode enables TTY-required tools that would otherwise fail.
- Graceful degradation: Falls back to direct exec when PTY is unavailable.
- Security layers: Multiple modes (deny, allowlist, full) provide flexible security policies.
- Background tracking: Process registry enables long-running task management and cleanup.
- Platform awareness: Handles macOS/Linux differences for signal propagation.
Cons/Considerations:
- PTY dependency: Requires
node-ptynative module, which may fail to compile in some environments. - Complexity: Multi-mode execution increases code complexity and testing surface.
- Output buffering: Aggregating all output in memory can exhaust RAM for long-running, verbose processes.
- Signal limitations: Detached processes on macOS don't receive signals; requires workarounds.
References
- Clawdbot bash-tools.exec.ts - Execution modes
- Clawdbot bash-tools.process.ts - Process management
- Clawdbot bash-process-registry.ts - Background registry
- Related: Virtual Machine Operator Agent for remote execution patterns