Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -3134,10 +3134,21 @@ The dedicated `read_file`/`search_text` tools enforce the workspace boundary and
- **Reader executables now honour the boundary.** A reader executable (`cat`/`head`/`tail`/`grep`/`rg`/`wc`/`file`/`stat`/`du`) whose path argument resolves **outside the workspace**, or names a secret marker, is escalated to `RiskLevel.HIGH` (always-ask) instead of returning LOW/auto-run. Because reader commands carry `SideEffect.VARIABLE` they can never auto-run silently, so the deterministic over-ask (HIGH → ASK) is the conservative match for the file tools' boundary, rather than threading the `allow_sensitive_reads` setting through the command path.
- **`git` global options feed classification.** A `git` invocation carrying a non-benign global option before the verb (`--git-dir`/`--work-tree`/`--exec-path`/`-C`/`-c` and the like) is treated as at least MEDIUM (ASK), closing an out-of-workspace read primitive that the previous first-non-dash-token verb derivation skipped past. Only `--no-pager`/`--literal-pathspecs` remain benign.
- **Mutating verbs and `--output` under the read-only allowlist no longer auto-run.** The `GIT_READONLY_VERBS` LOW return is read-*only*, but two forms slipped through it under `balanced`. First, `git branch <name>` / `git remote add|set-url …` (a non-flag positional names a branch/remote to create, rename, add, or repoint — a state mutation) now classify at least MEDIUM (ASK), while bare `git branch`/`git remote`/`git remote -v` and listing forms (`git branch --list <pattern>`, `-a`/`-r`) stay LOW. Second, `--output=<file>` is a **global diff-formatting option honoured by every diff-emitting verb** (`diff`/`show`/**`log`**/**`stash show`**/…), so `git log --output=/etc/x` was an arbitrary out-of-workspace file-write/truncate primitive at LOW→AUTO just like `git diff --output=`. A single hoisted check now scans the whole invocation for an `--output`/`-O` path value (the `=`, space-separated, and glued forms) and routes it through the same workspace-boundary check as the write/reader paths: outside → HIGH (always-ask), inside → MEDIUM. The check is placed **after** every HIGH determination (`reset`/`clean`, `branch -d/-D`, force `push`) and **before** any read-only LOW return, so it only escalates a would-be-LOW command and never downgrades an already-HIGH one (`git branch -D x --output=in_ws.txt` stays HIGH). `-O` is git's order-file option, not an `--output` short form; it is covered conservatively (an out-of-workspace order-file read is still a boundary crossing worth escalating). The git path previously never consulted the boundary check at all.
- **Option-encoded paths are checked, not skipped.** `_path_arg_outside_workspace` skipped every `-`-prefixed token, so a path hidden in an option value (`grep --file=/etc/passwd .`, `patch --output=/etc/x`) bypassed the boundary entirely and fell through to the LOW reader/write allowlist. For a `-`-token containing `=`, the substring after the first `=` is now run through the same resolve-and-compare check when it looks path-like (contains `/` or starts with `..`); a non-path option value (`--color=auto`, `--include=*.py`) is still ignored, so an in-workspace option path keeps its LOW/MEDIUM classification and only out-of-workspace targets escalate. **Accepted residual:** only `--opt=PATH` and space-separated (`--opt PATH`) forms are boundary-checked; a path glued to a short flag (`grep -f/etc/passwd`, `cp -t/outside`) is still skipped, so the glued short form remains a known gap — a glued out-of-workspace reader path (`grep -f/etc/passwd .`) still classifies LOW and can auto-run under `balanced`. The high-value `--opt=PATH` primitive (the common, model-natural form) is closed; the glued short form is the lower-value remainder and is left as a documented residual rather than parsing every command's short-flag grammar.
- **Option-encoded paths are checked, not skipped.** `_path_arg_outside_workspace` skipped every `-`-prefixed token, so a path hidden in an option value (`grep --file=/etc/passwd .`, `patch --output=/etc/x`) bypassed the boundary entirely and fell through to the LOW reader/write allowlist. For a `-`-token containing `=`, the substring after the first `=` is now run through the same resolve-and-compare check when it looks path-like (contains `/` or starts with `..`); a non-path option value (`--color=auto`, `--include=*.py`) is still ignored, so an in-workspace option path keeps its LOW/MEDIUM classification and only out-of-workspace targets escalate. This generic check does not decode a path glued to a short flag; the per-tool LOW-invariant verification below closes that form for the auto-running tools (`date`/`tree`/`ps`) and narrows the remaining gap to the single searcher pattern-file form documented in the residual note at the end of this section.
- **`pytest` requires approval.** `pytest` and `python -m pytest` execute arbitrary project Python (conftest/collected modules) at collection time, so the previous LOW carve-out is removed: they now classify MEDIUM (ASK in `balanced`), symmetric with `python script.py`.
- **Path-qualified basenames are distrusted.** When `argv[0]` contains a path separator (`./grep`, `/abs/grep`), the bare-name LOW allowlist no longer applies; a path-qualified executable is classified at least MEDIUM, so a workspace-staged file sharing a trusted command's name cannot auto-run.

A follow-up review found the read-path work above still let a whole allowlist auto-run on the executable **basename** alone: any name in `LOW_EXECUTABLES` returned LOW regardless of what its options did. That is tightened to a per-tool **LOW invariant** — a command may classify LOW only when its argv form provably cannot execute code or a configured helper, write or truncate a file, perform network I/O, or read content outside the workspace. Capability-bearing LOW tools now prove that invariant explicitly from their own argv; the argv-inert names (`INERT_LOW_EXECUTABLES` = `pwd`/`true`/`false`/`uname`/`whoami`/`which`/`echo`/`df`) carry no filesystem or process payload and keep LOW unconditionally.

- **Searchers (`grep`/`egrep`/`fgrep`/`rg`).** `--pre`/`--pre-glob` run a preprocessor over matched files → HIGH. An unrecognized long option (anything outside the `SEARCHER_SAFE_LONG_OPTIONS` allowlist) fails the proof → MEDIUM; LOW is earned from a known-safe option set, not assumed from the `grep` basename. A path operand resolving outside the workspace → HIGH.
- **Read-only git verbs.** `--ext-diff`/`--textconv` can execute configured external diff/textconv programs → MEDIUM. The `GIT_READONLY_VERBS` LOW return additionally proves the boundary: an out-of-workspace path operand (`git diff --no-index /etc/a /etc/b`) → HIGH.
- **`tree` output-writing forms.** `-o`/`--output` (and `-R`, which writes `00Tree.html` at each visited level) write a file → MEDIUM, or HIGH when the resolved output path is outside the workspace. An unrecognized long option → MEDIUM; an out-of-workspace read operand → HIGH.
- **`ps` environment-display forms.** Selectors that expose process environments — BSD `ps e`/`ps auxe`, macOS `-E`, and `-o environ`/`--format=…env…` in space-separated, `=`, glued, and clustered forms — → MEDIUM. An unrecognized long option → MEDIUM.
- **`ls` boundary.** An unrecognized long option → MEDIUM; a path operand outside the workspace → HIGH.
- **`date` file-backed reads.** `-r`/`-f`/`--file`/`--reference` make `date` read a file; the value is decoded in split (`-r PATH`), `=` (`--file=PATH`), glued (`-rPATH`), and clustered (`-ur PATH`) forms. An out-of-workspace file → HIGH; an in-workspace file → MEDIUM (a filesystem payload, not a pure clock read).

**Accepted residual (LOW invariant).** The `date`/`tree`/`ps` glued and clustered forms are now decoded and closed. One searcher form remains: a pattern file glued to its short flag — `grep -f/etc/passwd .` — is read as a *pattern list* (its contents are never printed) and the boundary check does not decode the glued short flag, so it still classifies LOW and can auto-run under `balanced`. The long `--file=/etc/passwd` and space-separated `grep -f /etc/passwd` forms already escalate to HIGH; the glued pattern-file remainder is left as a documented residual rather than parsing every tool's short-flag grammar.

### 36.2 Terminal Output Sanitization

The active-cloud indicator (section 15.2) is only trustworthy if the model — or untrusted data it surfaces — cannot repaint the terminal over it. Control characters and ANSI escape sequences are now stripped at **every** output sink via the shared `_sanitize_line`/`_CONTROL_CHARS` helper (`cli/render.py`), not only in the diff panel: streamed model text (`cli/streaming.py`), raw command output (`show_command_output`), tool-call/tool-result summaries (`cli/render.py`), and status/error lines (`cli/terminal.py`). Tab and newline are preserved; ESC and C0/DEL bytes are removed before anything reaches the screen, so neither model output nor auto-run command output can forge a status region or spoof an approval prompt.
Expand Down
Loading