# `--add-dir-ro`: Read-only directory access

## Goal

Add `--add-dir-ro <path>` that grants the agent read-only access to a directory (read_file, list_files, grep — no write_file, edit_file, delete_file). This mirrors how skill directories already work via `extra_read_roots`, but user-controlled.

## Current state

- `--add-dir` resolves paths at startup and passes them as `extra_write_roots` through the entire call chain. These roots grant full read+write.
- Skill directories use a separate `extra_read_roots` / `skill_read_roots` parameter for read-only access. Write operations (`_write_file`, `_edit_file`, `_delete_file`) deliberately omit `extra_read_roots`, so skill dirs can't be written to.
- `safe_resolve()` accepts both `extra_read_roots` and `extra_write_roots` and checks them independently.
- `skill_read_roots` starts as `[]` at loop start and gets populated dynamically when `activate_skill()` mutates the list by reference.

## Approach

Piggyback on the existing `extra_read_roots` mechanism. The `--add-dir-ro` paths get seeded into `skill_read_roots` so they flow through the same code paths — no new permission concept needed.

`--add-dir-ro` works independently of `--no-skills`. The read-only dir list is always maintained; skill discovery only appends to it.

## Changes by file

### 1. config.py

Add `allowed_dirs_ro` to all the config plumbing:

- **`CONFIG_KEYS`** — add `"allowed_dirs_ro": list`.
- **`_LIST_OF_STR_KEYS`** — add `"allowed_dirs_ro"`.
- **`_CONFIG_TO_ARGPARSE`** — add `"allowed_dirs_ro": "add_dir_ro"`.
- **`_ARGPARSE_DEFAULTS`** — add `"add_dir_ro": []`.
- **`_NONE_SENTINEL_DESTS`** (inside `apply_config_to_args`) — add `"add_dir_ro"` so `None` sentinel from argparse append is handled and config values apply correctly.
- **`_resolve_paths()`** — add `"allowed_dirs_ro"` to the loop that resolves relative paths (alongside `"allowed_dirs"` and `"skills_dir"`).
- **`generate_config()`** — add a commented example:

```toml
# allowed_dirs_ro = ["/reference/docs", "~/datasets"]
```

### 2. agent.py

**CLI argument** — add `--add-dir-ro` next to `--add-dir`:

```python
parser.add_argument(
    "--add-dir-ro",
    type=str,
    action="append",
    default=None,
    help="Grant read-only access to an extra directory (repeatable).",
)
```

Uses `default=None` (not `_UNSET`) since append actions can't use the custom sentinel. Included in `_NONE_SENTINEL_DESTS` in config.py so config values apply when CLI doesn't set it.

**Validation in `_run_main()`** — same validation block as `--add-dir` (resolve, reject non-dirs, reject root), producing `allowed_dirs_ro: list[Path]`.

**Seed skill_read_roots** — after skill discovery but before building loop kwargs, seed the read roots list with the user's RO dirs:

```python
skill_read_roots: list[Path] = list(allowed_dirs_ro)  # seed with --add-dir-ro paths
```

Skill activation via `use_skill` will append to this same list at runtime. The RO dirs come first, which is fine — order doesn't matter for `is_relative_to` checks.

**REPL** — add `/add-dir-ro` slash command. Same shape as `_repl_add_dir` but mutates `skill_read_roots` instead of `extra_write_roots`. Add a helper `_repl_add_dir_ro(path_str, skill_read_roots)`. Add to `/help` output.

### 3. session.py

**Constructor** — add `allowed_dirs_ro: list[str] | None = None` parameter. Store as `self.allowed_dirs_ro = allowed_dirs_ro or []`.

**`_setup()`** — resolve and validate into `self._allowed_dir_ro_paths` with the same logic as `allowed_dirs` (expanduser, resolve, reject non-dirs, reject root).

**`_make_per_run_state()`** — seed `skill_read_roots` with a copy of the resolved RO paths instead of starting empty:

```python
"skill_read_roots": list(self._allowed_dir_ro_paths),
```

Using `list()` copy so each run gets its own list that skill activations can safely append to without cross-run contamination.

**`_build_loop_kwargs()`** — no change needed; it already passes `state["skill_read_roots"]` through.

### 4. tools.py — no changes

The permission logic already works correctly:
- `_write_file`, `_edit_file`, `_delete_file` don't receive `extra_read_roots`, so writes to RO dirs fail.
- `_read_file`, `_list_files`, `_grep` accept `extra_read_roots` and will see the new paths.

### 5. Error messages — deferred

When writing to a read-only dir, `safe_resolve()` will report "outside base directory." This is slightly misleading but functional — the model gets a clear rejection. Adding a nicer "directory is read-only" message would require threading `extra_read_roots` into write/edit/delete just for diagnostics. Not worth the plumbing for v1. Can revisit if models get confused by the error.

### 6. Tests

**`tests/test_add_dir.py`** — add parallel test classes mirroring the existing `--add-dir` tests:

- `TestSafeResolveExtraReadRoots` — `safe_resolve` accepts paths under read roots.
- `TestFileOpsExtraReadRoots` — read_file succeeds, write_file/edit_file/delete_file rejected for read-only dirs.
- `TestListGrepExtraReadRoots` — list_files and grep work in read-only dirs.
- `TestAddDirRoCLIValidation` — startup validation: non-existent path, file path, root rejection, `~` expansion, works with and without `--yolo`, coexistence with `--add-dir` (both flags together — RW dir is writable, RO dir is read-only).
- `TestDispatchExtraReadRoots` — dispatch correctly forwards read-only paths only to read-capable tools.

**`tests/test_repl.py`** — add tests for `/add-dir-ro`:

- Adds path to `skill_read_roots` (not `extra_write_roots`).
- Explicitly assert `extra_write_roots` is unmodified after `/add-dir-ro`.
- Duplicate detection.
- Invalid path handling.

**`tests/test_config.py`** — add tests for `allowed_dirs_ro`:

- Loaded from TOML config.
- Relative path resolution against config dir.
- `~` expansion.
- Precedence: CLI > project config > global config.

**`tests/test_session.py`** — add tests for `Session(allowed_dirs_ro=...)`:

- RO paths appear in `skill_read_roots` for each run.
- Each `.run()` gets its own copy (skill activations don't leak across runs).

## What doesn't change

- `safe_resolve()` — already handles the two root lists correctly.
- `_is_within_base()` — already checks both root lists.
- `dispatch()` — already passes `skill_read_roots` as `extra_read_roots` to read tools.
- `FileAccessTracker` — reads from RO dirs count toward the read-before-write guard; writes are blocked at the path level before the tracker is consulted.

## Migration

None. New flag, no breaking changes.
