# Plan: Read-Before-Write Guard for write_file and edit_file

## Problem

The agent can overwrite existing files with `write_file` without ever reading them first. This is dangerous — the model may hallucinate file contents, clobber work it never saw, or blindly replace a file based on a stale mental model. `edit_file` has the same risk in theory, though its search-and-replace mechanics make blind edits less likely to succeed silently.

The guard should prevent writes to **existing** files that haven't been read in the current session. Creating new files is fine — there's nothing to read first.

## Design

### New state object: `FileAccessTracker`

A simple class that lives alongside `ThinkingState` as per-session state. It records which files have been read and which files the agent has created, and enforces the read-before-write invariant.

```python
class FileAccessTracker:
    def __init__(self):
        self.read_files: set[str] = set()     # resolved absolute paths
        self.written_files: set[str] = set()   # files created by write_file

    def record_read(self, path: str) -> None:
        self.read_files.add(path)

    def record_write(self, path: str) -> None:
        self.written_files.add(path)

    def check_write_allowed(self, path: str, exists: bool) -> str | None:
        """Return an error string if the write should be blocked, None if OK."""
        if not exists:
            return None
        if path in self.read_files or path in self.written_files:
            return None
        return (
            "error: cannot write to an existing file that hasn't been read first. "
            "Use read_file to inspect the current contents before overwriting or editing."
        )

    def reset(self) -> None:
        """Clear all tracked state (used by /clear)."""
        self.read_files.clear()
        self.written_files.clear()
```

Paths are stored as resolved absolute strings (the output of `safe_resolve()`), so symlinks and relative paths don't cause mismatches.

### Files created in the same session are re-writable

If the agent created a file via `write_file` earlier in the run, it should be able to overwrite it again without a read. The agent knows what it wrote. `record_write()` is called after a successful write in `_write_file`, and `check_write_allowed` checks both `read_files` and `written_files`. This avoids noisy errors during normal iterative workflows (create a file, then fix it).

### When to register a read

Only register after `_read_file` successfully returns file content. Specifically:

- **Directory listing:** Do NOT register. The model saw file names, not file contents.
- **Binary file rejection:** Do NOT register. The model got an error, never saw the contents.
- **Successful text read (full or paginated):** Register. The model saw actual content.

The registration call goes after the binary check and after the content has been successfully read and formatted, just before the return statement that produces the paginated content string.

This is stricter than "register on any read attempt" but more correct — the whole point is that the model should have seen the file contents before it decides to overwrite them.

### Where the checks go

**`_read_file`** — After successfully reading and formatting the file content (past the binary check, past the content read), call `tracker.record_read(str(resolved))`.

**`_write_file`** — After `safe_resolve()`, check `resolved.exists()`. If the file exists, call `tracker.check_write_allowed()`. If blocked, return the error string immediately. On successful write, call `tracker.record_write(str(resolved))`.

**`_edit_file`** — Same check after `safe_resolve()`. `edit_file` already requires the file to exist (line 866), so the existence check is always true. Insert the tracker check right after the existing existence check.

### Passing the tracker through the actual call chain

The real call chain is: `run_agent_loop()` → `handle_tool_call()` → `dispatch()` → tool functions. The tracker must be threaded through each layer.

**`handle_tool_call()` (agent.py:282)** — Add `file_tracker=None` parameter. Forward to `dispatch()` as a kwarg.

```python
def handle_tool_call(
    tool_call,
    base_dir,
    thinking_state,
    verbose,
    resolved_commands=None,
    skills_catalog=None,
    skill_read_roots=None,
    extra_write_roots=None,
    yolo=False,
    file_tracker=None,          # <-- new
):
```

The call to `dispatch()` at line 324 adds `file_tracker=file_tracker` to its kwargs.

**`run_agent_loop()` (agent.py:1013)** — Add `file_tracker: FileAccessTracker | None = None` parameter. Forward to `handle_tool_call()` at line 1256.

**`repl_loop()` (agent.py:1461)** — Add `file_tracker: FileAccessTracker | None = None` parameter. Forward to both `run_agent_loop()` call sites (lines 1540 and 1574).

**`dispatch()` (tools.py)** — Extract `file_tracker` from kwargs, pass to `_read_file`, `_write_file`, and `_edit_file`.

**`main()` (agent.py)** — Create `FileAccessTracker()` (or `None` if `--no-read-guard`). Include in `loop_kwargs` dict that feeds both `run_agent_loop()` (one-shot mode) and `repl_loop()` (REPL mode).

### REPL `/clear` resets the tracker

`_repl_clear()` (agent.py:1374) already resets `ThinkingState`. It must also call `tracker.reset()` to clear the read/write sets. After `/clear`, the conversation is gone — the model no longer has any file contents in context, so the guard must re-require reads.

```python
def _repl_clear(messages: list, thinking_state: ThinkingState,
                file_tracker: FileAccessTracker | None = None) -> None:
    # ... existing clearing logic ...
    if file_tracker is not None:
        file_tracker.reset()
```

The call site at line 1526 passes the tracker in.

### Context compaction does NOT reset the tracker

The agent loop has two compaction stages on context overflow (agent.py:1084, 1124):

1. `compact_messages()` — truncates large tool results to `[compacted — originally N chars]` but preserves all turns. The model still sees the turn where it called `read_file`, just with a truncated result. No tracker impact needed.

2. `drop_middle_turns()` — deletes entire middle turns, keeping only the leading system/user block and the last 3 turns. Read calls in dropped turns disappear from context entirely.

Despite this, the tracker is intentionally **not** reset on compaction. Reasons:

- **Compaction happens mid-loop, not at a user boundary.** The model is in the middle of a task. Suddenly blocking writes to files it read 10 turns ago (but that got dropped) would cause confusing failures with no recovery path — the model can't re-read files it doesn't remember reading.
- **The guard's purpose is preventing blind writes, not enforcing perfect context alignment.** A model that read a file 20 turns ago and now writes to it is still better than one that never read it at all. The content may be stale, but the model at least had a chance to see it.
- **`/clear` is different** — it's an explicit user action that wipes the conversation. The user expects a fresh start. Compaction is automatic recovery from context pressure; the user didn't ask for a reset.
- **`edit_file` naturally self-corrects** — if the model's stale mental model produces a bad `old_string`, the edit will fail with "not found" anyway. `write_file` is the riskier case, but resetting the tracker on compaction would make it worse (model can't write) rather than better (model writes with stale knowledge, which is the lesser evil).

If this proves too permissive in practice, a middle ground would be to only invalidate paths whose `read_file` results were in the dropped turns. That would require correlating tracker entries with message indices, which adds complexity for unclear benefit. Defer until there's evidence of real harm.

### `list_files` and `grep` do NOT count as reads

Only `read_file` registers a path. Seeing a file name in a directory listing or a grep match is not the same as reading its contents. The model needs to have seen the actual content before overwriting it.

### `edit_file` reads the file internally

`_edit_file` already reads the file to perform the replacement (line 873). However, that internal read should **not** bypass the guard. The point is that the model should have seen the contents before deciding what edit to make. The internal read is mechanical, not informed.

### Unrestricted / yolo mode

The guard applies regardless of `--yolo`. Yolo mode disables the sandbox (path restrictions, command whitelisting), but read-before-write is about correctness, not security. Even an unrestricted agent shouldn't blindly overwrite files it hasn't seen.

### Opt-out

Add a `--no-read-guard` flag. When set, `file_tracker` is `None` everywhere, and all checks are no-ops. Default is guard on.

## Implementation plan

### 1. New file: `swival/tracker.py`

Minimal module with the `FileAccessTracker` class. ~30 lines. Contains `record_read`, `record_write`, `check_write_allowed`, and `reset`.

### 2. Changes to `tools.py`

- `_read_file`: Accept `tracker: FileAccessTracker | None` param. After successful content read (past binary check, past content formatting), call `tracker.record_read(str(resolved))`.
- `_write_file`: Accept `tracker: FileAccessTracker | None` param. After `safe_resolve()`, if `resolved.exists()`, call `tracker.check_write_allowed(str(resolved), exists=True)`. Return error if blocked. After successful write, call `tracker.record_write(str(resolved))`.
- `_edit_file`: Same guard as `_write_file` — accept tracker, check before proceeding. No `record_write` needed (edit doesn't create files, and a successful edit implies a prior read was required).
- `dispatch()`: Extract tracker from kwargs, pass to the three functions above.

### 3. Changes to `agent.py`

- Import `FileAccessTracker` from `swival.tracker`.
- In `main()`, create `tracker = FileAccessTracker()` (or `None` if `--no-read-guard`). Add to `loop_kwargs`.
- Add `--no-read-guard` to argparse.
- `handle_tool_call()`: Add `file_tracker=None` parameter. Pass to `dispatch()`.
- `run_agent_loop()`: Add `file_tracker` parameter. Pass to `handle_tool_call()` at line 1256.
- `repl_loop()`: Add `file_tracker` parameter. Pass to both `run_agent_loop()` calls (lines 1540, 1574).
- `_repl_clear()`: Add `file_tracker` parameter. Call `file_tracker.reset()` if not None. Update call site at line 1526.

### 4. Tests: `tests/test_read_guard.py`

**Tool-level tests (tracker + tool functions directly):**

- **Write to new file (no prior read):** Should succeed — file doesn't exist yet.
- **Write to existing file without read:** Should return error string.
- **Write to existing file after read:** Should succeed.
- **Re-write file created in same session (no read):** Should succeed — `written_files` allows it.
- **Edit existing file without read:** Should return error string.
- **Edit existing file after read:** Should succeed.
- **Read registers resolved path:** Read via relative path, write via absolute path (or vice versa) — should still match.
- **Tracker is None (guard disabled):** All writes succeed unconditionally.
- **Directory read_file doesn't register:** Reading a directory, then writing a file inside it, should still be blocked.
- **Binary file read doesn't register:** Attempting to read a binary file, then writing to it, should be blocked.

**Integration tests (CLI args, REPL flow):**

- **`--no-read-guard` flag:** Verify it sets `file_tracker=None` and writes proceed unconditionally.
- **REPL `/clear` resets tracker:** Read a file, `/clear`, try to write — should be blocked.
- **REPL `/continue` preserves tracker:** Read a file, `/continue`, write — should succeed (tracker state persists across continuations).

**Fixture updates:**

- Existing test fixtures that construct argparse namespaces (`test_guardrails.py:26`, `test_logging.py:32`, `test_repl.py:55`) need the new `no_read_guard` attribute added to avoid `AttributeError`. (`file_tracker` is runtime state created in `main()`, not a CLI arg — it doesn't belong on the namespace.)

## Non-goals

- Don't guard `run_command` output that gets written to `.swival/` scratch files — those are internal plumbing, not routed through `_write_file`.
- Don't persist the read set across sessions. Each run starts fresh.
- Don't guard skill file reads — skills are loaded by the system, not the model.
