# Shell string commands in yolo mode

Allow `run_command` to accept a plain string that gets executed as a shell command (via `sh -c`) when `--yolo` is active. In normal (sandboxed) mode, the array-of-strings requirement stays — shell execution would bypass the whitelist entirely.

## Motivation

The array syntax (`["ls", "-la"]`) exists to prevent shell injection: each element maps to a single `execvp` argument, so metacharacters like `&&`, `|`, `;`, and `>` are inert. That's the right default for sandboxed mode where only whitelisted commands should run.

In yolo mode the whitelist is already gone — the user has opted into unrestricted execution. Requiring array syntax forces the model to decompose shell pipelines into multiple sequential `run_command` calls, which is awkward and burns turns. Accepting `"ls -la && echo done"` as a string and running it via the shell is the natural interface here.

## Design

### Schema change

The `command` parameter becomes a union type: either an array of strings (current behavior) or a plain string (new, yolo-only).

When `--yolo` is active, `agent.py` builds the tool schema with:

```json
{
  "command": {
    "oneOf": [
      {
        "type": "string",
        "description": "Shell command string (executed via sh -c). Supports pipes, redirects, &&, etc."
      },
      {
        "type": "array",
        "items": {"type": "string"},
        "description": "Command as array of strings. Each argument is a separate element."
      }
    ],
    "description": "Command to run. Can be a shell string (e.g. \"ls -la | head\") or an array of strings (e.g. [\"ls\", \"-la\"])."
  }
}
```

When `--yolo` is not active (sandboxed mode), the schema stays unchanged — `command` is `{"type": "array", "items": {"type": "string"}}` only.

**Why `oneOf` instead of just `"type": "string"`**: Keeping array support means the model can still use the array form when it doesn't need shell features. The array path avoids shell quoting issues for simple commands.

### System prompt update

The yolo command blurb in `agent.py` changes to mention both forms:

```
**Command execution tool:**
- `run_command`: Run any command and return its output.
  Pass a shell string (e.g. `"ls -la | grep foo"`) or an array (e.g. `["ls", "-la"]`).
  Shell strings support pipes, redirects, `&&`, etc.
  Optional `timeout` (1-120s, default 30).
```

The sandboxed blurb stays unchanged (array-only).

### `_run_command()` changes

The function already handles `command: list[str] | str`. Currently, when it receives a string, it tries to parse it as JSON array and failing that returns an error telling the model to use array syntax.

New behavior when `unrestricted=True` and `command` is a string:

1. **Try JSON-array repair first** (same as today). If the string parses as a JSON array of strings (e.g. `'["ls", "-la"]'`), convert it to a list and continue down the normal array path with `was_repaired=True`. This preserves backward compat for models that send stringified arrays.
2. **If JSON repair fails, treat it as a shell command.** Execute via `subprocess.Popen(["/bin/sh", "-c", command], **popen_kwargs)`.
3. Everything else stays: timeout clamping, output capture, large-output spill.

When `unrestricted=False` and `command` is a string, behavior stays the same as today — attempt JSON repair, fail with the instructive error if it's not a JSON array.

Flow summary:

```
command is str + unrestricted → try JSON repair → if array: array path (was_repaired=True)
                                                → else: shell execution (sh -c)
command is str + sandboxed    → try JSON repair → if array: array path (was_repaired=True)
                                                → else: error (unchanged)
command is list + unrestricted → current unrestricted array path (unchanged)
command is list + sandboxed    → current sandboxed array path (unchanged)
```

### Subprocess details

The shell path is only reached after JSON-array repair has already failed (the string is not a valid JSON array). At that point:

```python
# Inside _run_shell_command()
shell_cmd = ["/bin/sh", "-c", command] if sys.platform != "win32" else ["cmd.exe", "/c", command]
proc = subprocess.Popen(
    shell_cmd,
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
    stdin=subprocess.DEVNULL,
    cwd=base_dir,
    start_new_session=True,  # Unix only, for killpg
)
```

No `resolved_path` lookup needed — the shell handles PATH resolution, pipes, redirects, everything.

On timeout, `_kill_process_tree(proc)` handles cleanup. On Unix, it kills the entire process group (via `start_new_session=True` + `os.killpg`), covering child processes spawned by the shell. On Windows, only the direct child (`cmd.exe`) is killed — grandchild processes may orphan. This is a pre-existing limitation of `_kill_process_tree` (see `tools.py:872`), not something introduced by this change. Documenting it here for clarity; fixing Windows process-tree kill is out of scope.

### Windows

`/bin/sh` doesn't exist on Windows. Use `cmd.exe /c` there:

```python
if sys.platform == "win32":
    shell_cmd = ["cmd.exe", "/c", command]
else:
    shell_cmd = ["/bin/sh", "-c", command]
```

This matches Python's own `subprocess.Popen(command, shell=True)` behavior, but explicit is better — we control exactly what shell is invoked.

## Changes by file

### agent.py

1. **Tool schema** (lines 713-722): When `yolo` is True, use the `oneOf` schema for `command` instead of the array-only schema.
2. **System prompt** (lines 746-752): Update the yolo command blurb to mention both string and array forms.

### tools.py

1. **`RUN_COMMAND_TOOL`**: No change to the constant — it keeps the array-only schema. The yolo schema override happens in `agent.py`.
2. **`_run_command()`** (lines 974-994): The existing `isinstance(command, str)` block stays as the entry point. The JSON-repair attempt runs first regardless of mode. What changes is the fallback when repair fails:
   ```python
   if isinstance(command, str):
       # Try JSON-array repair (same as today, both modes)
       repaired_command = None
       try:
           parsed = json.loads(command)
           if isinstance(parsed, list) and all(isinstance(x, str) for x in parsed):
               repaired_command = parsed
       except (json.JSONDecodeError, TypeError):
           pass

       if repaired_command is not None:
           command = repaired_command
           was_repaired = True
           # Fall through to normal array path
       elif unrestricted:
           # Not a JSON array → treat as shell command
           return _run_shell_command(command, base_dir, timeout)
       else:
           # Sandboxed: error (unchanged)
           return _finalize('error: "command" must be a JSON array...', was_repaired)
   ```

   Extract shell execution into a helper `_run_shell_command()`. The helper shares the same output-capture and timeout machinery.

   Cleanest approach: refactor the output-capture + timeout + result-formatting block (lines 1046-1132) into a shared helper `_capture_process(proc, timeout, base_dir)` that both paths call after creating their `Popen`. This avoids duplicating ~90 lines of process management.

### Refactoring: `_capture_process()`

Extract from `_run_command()`:

```python
def _capture_process(proc, timeout: int, base_dir: str) -> str:
    """Capture output from a running subprocess with timeout enforcement."""
    # Lines 1046-1132 of current _run_command(), returning the result string.
```

Then `_run_command()` becomes:

```python
def _run_command(...):
    # ... validation, JSON repair, path resolution → gets proc via Popen
    proc = subprocess.Popen(...)
    return _finalize(_capture_process(proc, timeout, base_dir), was_repaired)
```

And the shell path:

```python
def _run_shell_command(command: str, base_dir: str, timeout: int) -> str:
    # Validate base_dir exists (same check as array path)
    ...
    shell_cmd = ["/bin/sh", "-c", command] if sys.platform != "win32" else ["cmd.exe", "/c", command]
    proc = subprocess.Popen(shell_cmd, **popen_kwargs)
    return _capture_process(proc, timeout, base_dir)
    # No _finalize — no "was_repaired" annotation for genuine shell strings
```

## Tests

### Existing test to update

**`tests/test_run_command.py:121` — `test_plain_string_errors_even_in_yolo`**: This test asserts that a plain string like `"echo yolo-repaired"` returns the "must be a JSON array" error even in unrestricted mode. That assertion is now wrong — in yolo mode, a non-JSON string should be executed as a shell command. Update this test to assert that the command runs successfully and the output contains `"yolo-repaired"`. Gate it with `skipif(win32)` since it relies on shell execution.

### New tests

Add to `tests/test_yolo.py`. All tests use `tmp_path` (pytest fixture) instead of hardcoded `/tmp` paths, and avoid Unix-specific commands where possible. Tests that require a Unix shell are gated with `@pytest.mark.skipif(sys.platform == "win32", reason="requires /bin/sh")`.

### Shell execution (Unix-gated)

- **`test_shell_string_command`** — `_run_command("echo hello && echo world", ..., unrestricted=True)` succeeds and output contains both "hello" and "world".
- **`test_shell_pipe`** — `_run_command("echo abc | tr a-z A-Z", ..., unrestricted=True)` returns "ABC".
- **`test_shell_redirect`** — Uses `tmp_path` for the output file: `_run_command(f"echo test > {tmp_path}/out && cat {tmp_path}/out", ..., unrestricted=True)` returns "test".
- **`test_shell_string_timeout`** — `_run_command("sleep 60", ..., unrestricted=True, timeout=1)` returns timeout error.
- **`test_shell_string_nonzero_exit`** — `_run_command("exit 42", ..., unrestricted=True)` returns "Exit code: 42".

### JSON-repair backward compat

- **`test_json_array_string_repaired_in_yolo`** — `_run_command('["echo", "hello"]', ..., unrestricted=True)` parses as array, runs via the array path, output contains "hello" and the `was_repaired` annotation. Confirms stringified arrays don't get sent to `sh -c`.

### Cross-mode behavior

- **`test_shell_string_sandboxed_rejected`** — `_run_command("ls -la", ..., unrestricted=False)` still returns the "must be a JSON array" error (existing behavior preserved). Platform-agnostic (only checks the error message, doesn't run the command).
- **`test_array_still_works_in_yolo`** — `_run_command(["echo", "hello"], ..., unrestricted=True)` still works (array path unchanged). Unix-gated since the array path uses `shutil.which` + direct exec, not a shell, and `echo` as a standalone executable isn't reliably on PATH on Windows.

### Agent-level integration

- **`test_yolo_schema_has_oneof`** — With `yolo=True`, the tool schema's `command` property uses `oneOf` with both string and array types.
- **`test_yolo_system_prompt_mentions_shell`** — System prompt contains "shell string" or "pipes" text.

## What stays the same

- Sandboxed mode: array-only, no shell, whitelist enforced.
- Output caps, binary detection, timeout clamping, large-output spill.
- The array path in yolo mode — string is an additional option, not a replacement.
- `was_repaired` annotation only applies to the array path (when a string is auto-parsed as JSON array).
- All other tools and CLI flags.
