Skip to content

Intelligent Bash Tool Execution NEW

Clawdbot Contributors

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

  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.

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.

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.

References