GitHub
Tool Use & Environment validated in production

Intelligent Bash Tool Execution

By Clawdbot Contributors
Add to Pack
or

Saved locally in this browser for now.

Cite This Pattern
APA
Clawdbot Contributors (2026). Intelligent Bash Tool Execution. In *Awesome Agentic Patterns*. Retrieved March 11, 2026, from https://agentic-patterns.com/patterns/intelligent-bash-tool-execution
BibTeX
@misc{agentic_patterns_intelligent-bash-tool-execution,
  title = {Intelligent Bash Tool Execution},
  author = {Clawdbot Contributors},
  year = {2026},
  howpublished = {\url{https://agentic-patterns.com/patterns/intelligent-bash-tool-execution}},
  note = {Awesome Agentic Patterns}
}
01

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.

02

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:

  • Tool schema definition: Bash commands invoked through structured tool interfaces (MCP, OpenAI Function Calling) with validated parameters.
  • 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 tracked with session IDs, output tailing, and exit notifications.
  • Output truncation: Enforce maxOutput limits to prevent memory exhaustion for verbose processes.
  • Proper signal propagation: SIGTERM/SIGKILL 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;
  runInBackground?: boolean;
  maxOutput?: 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);
  }
}
03

How to use it

  1. Detect TTY requirements: Check if the command is a TTY-required CLI (coding agent, interactive tool) and set usePty: true.
  2. Handle PTY failures: Wrap PTY spawn in try-catch and fall back to direct exec with appropriate warnings.
  3. Configure security modes: Set default security level (deny, allowlist, full) and approval behavior (off, on-miss, always).
  4. Register background processes: Add sessions to a registry for tracking, polling, and cleanup.
  5. Propagate signals correctly: Use SIGTERM then SIGKILL for graceful shutdown, and handle platform-specific detached process behavior.
  6. Aggregate output: Collect stdout/stderr into aggregated and maintain a tail for user notifications.
  7. Enforce output limits: Set maxOutput thresholds to prevent memory exhaustion on verbose processes.

Pitfalls to avoid:

  • Missing PTY module: node-pty may 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 maxOutput limits and truncate middle sections.
04

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-pty native 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.
06

References