# `--allow-dir` — grant access to extra directories

Adds a repeatable CLI flag `--allow-dir <path>` that grants the agent full read/write access to additional directories beyond `base_dir`, without enabling the full `--yolo` mode.

## Motivation

Today you either work inside `base_dir` (the sandbox) or turn on `--yolo` which removes all restrictions. There's no middle ground. Common scenarios that need targeted access to an extra directory:

- A monorepo where the agent works in `./frontend` but needs to read/write shared types in `./common`.
- Config or data files that live outside the project tree (e.g. `~/.config/myapp/`).
- A reference codebase the agent should be able to read and grep through.

`--allow-dir` fills this gap: each directory is resolved to an absolute path at startup and added to an allow-list that `safe_resolve()` checks alongside `base_dir`.

## CLI interface

```
uv run agent "task" --base-dir ./project --allow-dir ../common --allow-dir ~/.config/myapp
```

- Repeatable (`action="append"`), each value is a directory path.
- Resolved to an absolute path at startup via `Path(d).expanduser().resolve()`.
- Validated at startup: must exist, must be a directory, and must not be the filesystem root (`/`). Exits with `sys.exit(1)` on failure.
- Grants **read and write** access (unlike `skill_read_roots` which is read-only).
- Works independently of `--yolo`. When `--yolo` is active, `--allow-dir` is still validated at startup (catches typos early) but has no runtime effect since `unrestricted=True` bypasses all containment checks.

## Changes by file

### agent.py

**`build_parser()`**

Add the argument:

```python
parser.add_argument(
    "--allow-dir",
    type=str,
    action="append",
    default=[],
    help="Grant read/write access to an extra directory (repeatable).",
)
```

**`main()`**

After parsing args, resolve and validate each `--allow-dir` entry:

```python
allowed_dirs: list[Path] = []
for d in args.allow_dir:
    p = Path(d).expanduser().resolve()
    if not p.is_dir():
        fmt.error(f"--allow-dir path is not a directory: {d}")
        sys.exit(1)
    if p == Path(p.anchor):
        fmt.error(f"--allow-dir cannot be the filesystem root: {d}")
        sys.exit(1)
    allowed_dirs.append(p)
```

This uses `sys.exit(1)` to match how other startup errors are handled in `main()` (the entrypoint calls `main()` directly, so a bare `return` would exit with code 0). `expanduser()` is called before `resolve()` so that `~` in quoted arguments or non-shell callers expands correctly.

Pass `allowed_dirs` through to `handle_tool_call()` and into `dispatch()` as a new kwarg `extra_write_roots`.

**`handle_tool_call()` / `run_agent_loop()`**

Thread `extra_write_roots` (the resolved `allowed_dirs` list) through the same kwargs path used for `skill_read_roots` and `yolo`. No logic changes here, just plumbing.

### tools.py

**`safe_resolve()`**

Add a new parameter `extra_write_roots`:

```python
def safe_resolve(
    file_path: str,
    base_dir: str,
    extra_read_roots: list[Path] = (),
    extra_write_roots: list[Path] = (),
    unrestricted: bool = False,
) -> Path:
```

After the `extra_read_roots` loop, add a check against `extra_write_roots`:

```python
for root in extra_write_roots:
    if resolved.is_relative_to(root):
        return resolved
```

The containment check order becomes: `base_dir` → `extra_read_roots` → `extra_write_roots` → reject.

Note: the existing "block filesystem root" check (`resolved == Path(resolved.anchor)`) fires before the `extra_write_roots` loop since it's in the `unrestricted` branch. But `extra_write_roots` entries are also validated at startup to reject the filesystem root, so even if someone managed to pass `/` into `extra_write_roots` programmatically, the `is_relative_to(/)` check would match everything — which is why the startup rejection is essential.

Since `extra_write_roots` paths should also be valid for reads (read is a subset of write), both `_read_file` and write operations check the same list. Technically `extra_read_roots` is only passed to `_read_file` today and `extra_write_roots` goes everywhere, so skills get read-only access and `--allow-dir` gets read+write access — the correct semantics fall out naturally.

**`dispatch()`**

Read the new kwarg and pass it through to every tool function that calls `safe_resolve()`:

```python
extra_write_roots = kwargs.get("extra_write_roots", ())
```

Pass `extra_write_roots=extra_write_roots` to:
- `_read_file()` (via `safe_resolve`)
- `_write_file()` (via `safe_resolve`)
- `_edit_file()` (via `safe_resolve`)
- `_list_files()` (via `safe_resolve` and `_is_within_base`)
- `_grep()` (via `safe_resolve` and `_is_within_base`)

**`_read_file`, `_write_file`, `_edit_file`**

Add `extra_write_roots` parameter, forward to `safe_resolve()`.

**`_list_files` and `_grep`**

Add `extra_write_roots` parameter, forward to `safe_resolve()` for the root path.

Also update `_is_within_base()` to accept and check `extra_write_roots`. The existing function resolves both `path` and `base` before the containment check (line 404: `path.resolve().is_relative_to(base.resolve())`), which prevents symlink escapes during `os.walk` in `_list_files`/`_grep`. The updated version must preserve this:

```python
def _is_within_base(path: Path, base: Path, unrestricted: bool = False, extra_write_roots: list[Path] = ()) -> bool:
    if unrestricted:
        return True
    try:
        resolved = path.resolve()
    except (OSError, ValueError):
        return False
    if resolved.is_relative_to(base.resolve()):
        return True
    # extra_write_roots are already resolved at startup, no need to resolve again
    for root in extra_write_roots:
        if resolved.is_relative_to(root):
            return True
    return False
```

Note: `extra_write_roots` entries are resolved once at startup via `Path(d).expanduser().resolve()`, so they don't need `resolve()` again here. But `path` comes from `os.walk` and could be a symlink, so it must be resolved before the containment check.

Update the relative path formatting in `_list_files` and `_grep`: when a file is inside an `extra_write_roots` entry but not inside `base_dir`, `relative_to(base)` would raise. Use the same fallback as yolo mode — show the absolute path.

**`_run_command()`**

No changes. `--allow-dir` controls filesystem access, not command execution. Command execution is orthogonal and controlled by `--allowed-commands` / `--yolo`.

### system_prompt.txt

No changes. The system prompt doesn't mention path restrictions. The agent will discover allowed paths through tool errors or success.

### Tests

Add tests to `tests/test_tools.py` (or a new `tests/test_allow_dir.py`):

**safe_resolve:**
- `test_safe_resolve_extra_write_root` — path inside an `extra_write_roots` entry resolves successfully.
- `test_safe_resolve_outside_all_roots` — path outside `base_dir`, `extra_read_roots`, and `extra_write_roots` raises `ValueError`.

**File operations:**
- `test_read_file_extra_write_root` — reading a file inside an allowed dir succeeds.
- `test_write_file_extra_write_root` — writing a file inside an allowed dir succeeds.
- `test_edit_file_extra_write_root` — editing a file inside an allowed dir succeeds.
- `test_write_file_skill_read_root_rejected` — writing to a path that's only in `extra_read_roots` (a skill path) is rejected. Confirms read-only semantics are preserved for skills.

**list_files / grep:**
- `test_list_files_extra_write_root` — listing an allowed dir returns results.
- `test_grep_extra_write_root` — grepping in an allowed dir returns matches.
- Both verify that output paths are absolute (not relative to base_dir).
- `test_list_files_symlink_escape_blocked` — a symlink inside an allowed dir pointing outside all allowed roots is excluded from results (verifies `_is_within_base` resolves symlinks before containment check).

**CLI validation:**
- `test_allow_dir_nonexistent` — passing a non-existent path to `--allow-dir` exits with `sys.exit(1)`.
- `test_allow_dir_is_file` — passing a file (not directory) exits with `sys.exit(1)`.
- `test_allow_dir_root_rejected` — passing `/` (filesystem root) exits with `sys.exit(1)`.
- `test_allow_dir_tilde_expansion` — passing `~/somedir` resolves correctly via `expanduser()`.
- `test_allow_dir_with_yolo_valid` — `--yolo --allow-dir <valid>` validates the dir at startup but doesn't error; runtime access uses `unrestricted=True` instead.
- `test_allow_dir_with_yolo_invalid` — `--yolo --allow-dir <nonexistent>` still exits with `sys.exit(1)`, proving startup validation fires even under yolo.

**dispatch integration:**
- `test_dispatch_passes_extra_write_roots` — verify `dispatch()` correctly forwards the kwarg to tool functions.

## What stays the same

- `--yolo` behavior is unchanged (it already bypasses everything).
- `skill_read_roots` stays read-only; `--allow-dir` is a separate mechanism.
- Output caps, binary detection, timeout clamping, SSRF checks — all unchanged.
- `run_command` is not affected by `--allow-dir`.
- `fetch_url` is not affected.

## Design decisions

- **`extra_write_roots`, not extending `extra_read_roots`**: Skills use `extra_read_roots` for read-only access. `--allow-dir` grants read+write, so it uses a separate list to avoid accidentally granting write access to skill directories.
- **No `--allow-dir-ro` variant**: YAGNI. There's no existing mechanism for generic read-only directory access (`--skills-dir` only discovers directories containing `SKILL.md` files, and read roots are granted at skill activation time — it's not a general-purpose allowlist). If a read-only variant is needed later, add `--allow-dir-ro` as a separate flag that populates `extra_read_roots`.
- **Filesystem root rejected**: `--allow-dir /` would make `is_relative_to(/)` true for every path, effectively becoming `--yolo` for filesystem access. Rejected at startup with a clear error.
- **`expanduser()` before `resolve()`**: shells expand `~` in unquoted arguments, but quoted arguments (`'~/foo'`) and non-shell callers (subprocess, scripts) don't. Calling `expanduser()` makes the behavior consistent.
- **`sys.exit(1)` not `return`**: the entrypoint is `main()` called directly (`if __name__ == "__main__": main()`), so `return 1` would exit with code 0. All other startup errors in `main()` use `sys.exit(1)`.
- **Validated at startup even under `--yolo`**: catches typos and bad paths early. The validation is cheap and avoids confusing "it worked but did nothing" scenarios. At runtime, `--yolo` sets `unrestricted=True` which bypasses containment checks entirely, so `extra_write_roots` has no effect.
- **Absolute path resolution**: paths are resolved once at startup. No symlink-escape risk from lazy resolution.
- **Name `--allow-dir` not `--extra-dir`**: "allow" communicates intent (granting access) better than "extra" (which sounds like an additional workspace).
