# Plan: `run_command` tool

## Overview

Add a `run_command` tool that lets the agent execute a whitelisted set of commands. Commands are specified via `--allowed-commands=ls,whoami,...` on the CLI. Execution uses `subprocess` directly (no shell) to prevent injection.

## CLI changes (`agent.py`)

Add argument:

```
--allowed-commands   Comma-separated list of allowed command basenames (e.g. "ls,git,python3")
```

Parse with normalization — split on `,`, strip whitespace from each token, drop empty strings:

```python
allowed_names = {c.strip() for c in args.allowed_commands.split(",") if c.strip()} if args.allowed_commands else set()
```

This handles sloppy input like `"ls, git,, "` → `{"ls", "git"}`.

**Resolve to absolute paths at startup.** For each allowed name, call `shutil.which()` once at process start and record the mapping. If a name can't be resolved, exit with an error — the operator asked for a command that doesn't exist on this system.

```python
resolved_commands: dict[str, str] = {}  # bare name -> absolute path
base_resolved = Path(base_dir).resolve()
for name in sorted(allowed_names):
    path = shutil.which(name)
    if path is None:
        print(f"Error: allowed command {name!r} not found on PATH", file=sys.stderr)
        sys.exit(1)
    # shutil.which() can return relative paths if PATH contains relative entries.
    # Resolve to absolute so cwd=base_dir can't redirect execution later.
    abs_path = Path(path).resolve()
    # Reject commands that live inside base_dir — the model can modify files
    # there via write_file/patch_file, so pinning the path wouldn't prevent
    # it from rewriting the executable's contents.
    if abs_path.is_relative_to(base_resolved):
        print(
            f"Error: allowed command {name!r} resolves to {abs_path}, "
            f"which is inside base directory {base_resolved}. "
            f"Commands inside the workspace can be modified by the model.",
            file=sys.stderr,
        )
        sys.exit(1)
    resolved_commands[name] = str(abs_path)
```

This pinning happens once before the agent loop starts. Two properties hold for every entry in `resolved_commands`:
1. The path is absolute and canonical — immune to `cwd` or relative PATH tricks at runtime.
2. The path is outside `base_dir` — the model cannot modify the executable's contents via file tools.

When `resolved_commands` is non-empty, include the `run_command` tool schema in the `TOOLS` list passed to the LLM (so it's not even advertised when no commands are allowed). Pass `resolved_commands` into `dispatch()` via kwargs (same pattern as `thinking_state`).

## Tool schema (`tools.py`)

Add a new entry to `TOOLS` — but since it's conditional, define it as a separate dict constant (`RUN_COMMAND_TOOL`) that `agent.py` appends to the tools list only when `--allowed-commands` is non-empty.

```python
RUN_COMMAND_TOOL = {
    "type": "function",
    "function": {
        "name": "run_command",
        "description": "Run a command and return its output. Only whitelisted commands are allowed.",
        "parameters": {
            "type": "object",
            "properties": {
                "command": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "Command and arguments as a list, e.g. [\"ls\", \"-la\", \"src/\"].",
                },
                "timeout": {
                    "type": "integer",
                    "description": "Timeout in seconds (1-120). Defaults to 30.",
                    "default": 30,
                },
            },
            "required": ["command"],
        },
    },
}
```

The command is an **array of strings**, not a single string. This maps directly to `subprocess.run(argv, ...)` with no ambiguity and no need to parse/split a shell command line.

Build a per-invocation copy of the schema at startup with the allowed list baked into the description. Use `copy.deepcopy(RUN_COMMAND_TOOL)` so the module-level constant is never mutated (safe across tests and repeated calls):

```python
import copy
tool = copy.deepcopy(RUN_COMMAND_TOOL)
tool["function"]["description"] = (
    f"Run a command and return its output. Allowed commands: {', '.join(sorted(allowed))}."
)
```

## Implementation (`tools.py`)

New function `_run_command(command, base_dir, resolved_commands, timeout=30)`:

- `resolved_commands` is a `dict[str, str]` mapping bare names to absolute paths, built once at startup.

1. **Validate the command list** — must be non-empty.
2. **Reject paths in `command[0]`** — if `command[0]` contains a `/` (or `\` on Windows), return an error. The model must pass bare command names like `ls`, never paths like `/tmp/ls` or `./my-ls`.
3. **Look up the pinned path** — `resolved_path = resolved_commands.get(command[0])`. If not found, return an error string listing what's allowed. No `shutil.which()` call at runtime — the path was resolved and frozen at startup.
5. **Run with streaming output cap** — instead of `capture_output=True` (which buffers everything in memory), use `subprocess.Popen` with piped stdout/stderr and read in a bounded loop.

Helper to kill the process and all its descendants:

```python
import os
import sys

_KILL_WAIT_TIMEOUT = 5  # seconds to wait for process to die after kill signals

def _kill_process_tree(proc: subprocess.Popen) -> None:
    """Kill a process and its descendants, then wait for exit.

    On Unix, uses process groups (via start_new_session=True) to kill the
    entire tree. On Windows, only the direct child is killed — grandchildren
    may survive (Python's subprocess has no built-in Job Object support).
    """
    if sys.platform != "win32":
        import signal
        try:
            os.killpg(proc.pid, signal.SIGKILL)
        except OSError:
            pass  # already exited
    try:
        proc.kill()
    except OSError:
        pass  # already dead
    try:
        proc.wait(timeout=_KILL_WAIT_TIMEOUT)
    except subprocess.TimeoutExpired:
        pass  # give up — process is unkillable (zombie, D-state, etc.)
```

**Platform note:** On Windows, `proc.kill()` calls `TerminateProcess` which only kills the direct child. There is no built-in way to kill a process tree without `Job Objects` or third-party libraries, which are out of scope. The plan does not claim tree-kill on Windows. The process-tree test (test 15) is Unix-only.

Popen setup and reader:

```python
MAX_CMD_OUTPUT = 50 * 1024  # 50 KB, same as other tools

popen_kwargs = dict(
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,   # merge stderr into stdout
    stdin=subprocess.DEVNULL,
    cwd=base_dir,
)
if sys.platform != "win32":
    popen_kwargs["start_new_session"] = True  # new process group for killpg

proc = subprocess.Popen([resolved_path] + command[1:], **popen_kwargs)
```

Use a background thread to read output, with the main thread enforcing a strict wall-clock deadline via `proc.wait(timeout=...)`. This way a silent long-running process that produces no output (blocking `read()`) still gets killed when the timeout expires:

```python
import threading

deadline = timeout  # wall-clock seconds from now
output_chunks: list[bytes] = []
output_total = 0
output_truncated = False

def _reader():
    nonlocal output_total, output_truncated
    try:
        while True:
            chunk = proc.stdout.read(4096)
            if not chunk:
                break
            if output_truncated:
                continue  # keep draining to prevent pipe backpressure, but discard
            remaining = MAX_CMD_OUTPUT - output_total
            output_chunks.append(chunk[:remaining])
            output_total += len(output_chunks[-1])
            if output_total >= MAX_CMD_OUTPUT:
                output_truncated = True
                # don't break — keep reading to drain the pipe
    except (OSError, ValueError):
        pass  # pipe closed/broken after kill — expected, exit silently

reader_thread = threading.Thread(target=_reader, daemon=True)
reader_thread.start()

timed_out = False
try:
    proc.wait(timeout=deadline)
except subprocess.TimeoutExpired:
    timed_out = True
    _kill_process_tree(proc)

reader_thread.join(timeout=2)  # give reader a moment to drain after kill
proc.stdout.close()
```

Three key properties:

1. **Timeout is wall-clock strict.** `proc.wait(timeout=deadline)` runs on the main thread and is not blocked by the `read()` call happening on the reader thread. When the deadline expires, the process group is killed, which causes the reader's `read()` to return empty and exit.
2. **Truncation doesn't cause false timeouts.** After hitting the 50KB cap, the reader keeps calling `read()` and discarding the data. This drains the pipe so the child process never blocks on a full pipe buffer. A finite command that produces gigabytes of output will still exit normally — it just gets its output truncated, not killed.
3. **Kill covers the whole process tree on Unix.** `start_new_session=True` puts the child in its own process group and `os.killpg()` sends `SIGKILL` to the entire group. On Windows, only the direct child is killed — grandchildren may survive (no Job Object support). `_kill_process_tree` uses a bounded `proc.wait(timeout=5)` after kill signals; if the process is unkillable (zombie, D-state), it gives up rather than blocking forever.

`reader_thread.join(timeout=2)` is a courtesy wait — if the thread is stuck, it's a daemon thread and won't block shutdown. The `try/except (OSError, ValueError)` in the reader handles the race where `proc.stdout.close()` on the main thread interrupts a blocking `read()` on the reader thread.

6. **Build result string** — decode the collected bytes as UTF-8 with `errors="replace"`. Then:
   - If **timed out**: prepend `"error: command timed out after {timeout}s\n"` but still include whatever partial output was collected before the kill.
   - If **non-zero exit code**: prepend `"Exit code: {code}\n"`.
   - If **output truncated**: append `"\n[output truncated at 50KB]"`.
   - All three conditions can combine (e.g. timed out with partial output that hit the 50KB cap).
7. **Handle exceptions** — `FileNotFoundError`, `PermissionError`, `OSError` return descriptive error strings. All exceptions are caught (never crash the loop).

## Timeout enforcement

Clamp the model-supplied timeout to `[1, 120]`:

```python
MAX_TIMEOUT = 120
timeout = max(1, min(args.get("timeout", 30), MAX_TIMEOUT))
```

This is enforced in `_run_command`, not just documented. The schema `description` states the range so the model knows.

## Security constraints

- **No shell** — `shell=False` means no pipes, redirects, backticks, `$()`, semicolons, or any other shell metacharacters are interpreted. The model gets exactly one process per call.
- **Bare names only** — `command[0]` is rejected if it contains `/` or `\`. No path-based bypass is possible: the model cannot point at `/tmp/evil` even if its basename matches an allowed command.
- **Startup-pinned paths** — `shutil.which()` is called once at process start for each allowed command. The resulting paths are resolved to absolute and verified to be outside `base_dir`. At runtime, `_run_command` does a dict lookup — no PATH resolution happens during the agent loop. This prevents two attacks: (a) planting a shadowing binary on PATH via `write_file`, and (b) modifying the contents of an allowed executable that lives inside the workspace.
- **Arguments are not validated** — the allowlist controls *which program* runs, not what arguments it receives. This is intentional: validating arguments for arbitrary commands is intractable and fragile. The operator chooses which commands to expose; argument-level safety is the command's own responsibility.
- **Timeout** — default 30s, clamped to [1, 120]. Strict wall-clock enforcement: output is read on a daemon background thread while the main thread calls `proc.wait(timeout=deadline)`. A silent process that blocks the reader still gets killed when the deadline expires. On Unix, `start_new_session=True` + `os.killpg()` kills the entire process tree. On Windows, only the direct child is terminated (no Job Object support); grandchildren may survive.
- **No stdin** — `stdin=subprocess.DEVNULL`. Commands cannot prompt for input.
- **Bounded memory** — output is read incrementally and capped at 50KB. A command producing gigabytes of output won't exhaust memory.
- **`cwd=base_dir` is not a sandbox** — commands run with `cwd` set to `base_dir` for convenience, but they can still access absolute paths, network, and other system resources. The security boundary is the allowlist (which commands can run), not filesystem containment. The operator is responsible for only allowing commands that are safe in their context.

## Dispatch wiring (`tools.py`)

Extend `dispatch()`:

```python
elif name == "run_command":
    resolved = kwargs.get("resolved_commands", {})
    return _run_command(
        command=args["command"],
        base_dir=base_dir,
        resolved_commands=resolved,
        timeout=args.get("timeout", 30),
    )
```

## Agent loop wiring (`agent.py`)

- Parse `--allowed-commands` into a `set`, then resolve each name to an absolute path via `shutil.which()` at startup (exit with error if any name is not found).
- Build the tools list: `tools = TOOLS + ([run_command_tool] if resolved_commands else [])`, where `run_command_tool` is the deep-copied and description-patched schema.
- Pass `resolved_commands=resolved_commands` to `dispatch()` via kwargs (same pattern as `thinking_state`).

## Tests (`tmp/test_run_command.py`)

All tests pass `resolved_commands` as a `dict[str, str]` (e.g. `{"ls": "/bin/ls"}`) rather than a bare set.

1. **Allowed command runs** — `["ls"]` with `resolved={"ls": "/bin/ls"}` returns directory listing.
2. **Blocked command** — `["rm", "-rf", "/"]` with `resolved={"ls": "/bin/ls"}` returns error with allowlist.
3. **Path in command[0] rejected** — `["/usr/bin/ls"]` returns error even when `ls` is allowed. Same for `["./ls"]`, `["../bin/ls"]`.
4. **Path-based bypass blocked** — `["/tmp/ls"]` (attacker-planted binary with matching basename) is rejected because it contains `/`.
5. **Empty command list** — `[]` returns error.
6. **Timeout enforcement** — `["sleep", "10"]` with `timeout=1` returns timeout error.
7. **Timeout clamping** — requested timeout of 9999 is clamped to 120. Requested timeout of -5 is clamped to 1.
8. **Non-zero exit code** — exit code is reported in output.
9. **No shell injection** — `["ls", "; rm -rf /"]` treats the semicolon as a literal filename argument, not a shell separator.
10. **Command not in resolved_commands** — `["nonexistent_command_xyz"]` returns error listing allowed commands.
11. **stderr capture** — stderr output is included in result (merged into stdout).
12. **Large output truncation without false timeout** — command producing well beyond 50KB (e.g. 1MB via `python3 -c "print('x'*1_000_000)"`) is truncated, result includes truncation notice, but the command exits normally (no timeout error). This verifies the reader keeps draining the pipe after the cap so the child doesn't block on backpressure.
13. **Tool absent when no allowed commands** — when `--allowed-commands` is not passed, the tools list should not contain `run_command`.
14. **Timeout returns partial output** — a command that produces some output then hangs should return the partial output along with the timeout error.
15. **Process tree killed on timeout** (Unix-only, skip on Windows) — run `["bash", "-c", "sleep 999 & echo child_started; sleep 999"]` with `resolved={"bash": "/bin/bash"}` and `timeout=2`. After `_run_command` returns, verify no process with the child's PID or its group is still alive (use `os.killpg(pgid, 0)` which raises `OSError` if the group is gone). Partial output should contain `"child_started"`.
16. **PATH shadowing blocked** — create a temp directory with a fake `ls` script, prepend it to `PATH`. Call `_run_command(["ls"], ...)` with `resolved={"ls": "/bin/ls"}` (pinned at startup to the real binary). Verify the real `/bin/ls` runs, not the shadowing script. This confirms runtime PATH changes have no effect.
17. **Startup resolution failure** — test the CLI startup logic: if `--allowed-commands=nonexistent_xyz` is passed, the agent should exit with an error before the loop starts.
18. **Startup pinning produces absolute paths** — set up a PATH with a relative entry (e.g. `PATH=reldir:$PATH`) where `reldir/mycommand` exists. Run the startup resolution logic. Assert that `resolved_commands["mycommand"]` is an absolute path (`Path(...).is_absolute()`), not the relative path that `shutil.which()` would have returned raw.
19. **Startup rejects commands inside base_dir** — create an executable script inside `base_dir`, put its directory on PATH, and run the startup resolution logic. Assert it exits with an error indicating the command is inside the workspace.
20. **`--allowed-commands` CLI parsing** — `"ls,git,python3"` parses and resolves correctly. Also test normalization: `"ls, git,, "` parses to `{"ls", "git"}` (whitespace stripped, empty tokens dropped).

## Files changed

| File | Change |
|------|--------|
| `tools.py` | Add `RUN_COMMAND_TOOL` schema, `_run_command()` function, extend `dispatch()` |
| `agent.py` | Add `--allowed-commands` argument, conditional tool inclusion, pass to dispatch |
| `tmp/test_run_command.py` | New test file |
| `CLAUDE.md` | Update tool count and architecture description |
