feat(permission): tiered tool-permission system with approval gate#93
Closed
hakula139 wants to merge 16 commits into
Closed
feat(permission): tiered tool-permission system with approval gate#93hakula139 wants to merge 16 commits into
hakula139 wants to merge 16 commits into
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
35200a2 to
2c71d77
Compare
Specifies the Permission & Approval feature (roadmap current focus): a tiered allow/ask/deny gate where instant static rules settle the common cases and a cheap Haiku classifier judges the ambiguous middle, so the agent stays non-stop and only asks on real risk. Key decisions: default `auto` mode flips today's unchecked behavior; dangerous-pattern defaults seed the deny set (no separate immune tier, `yolo` bypasses); project `ox.toml` may only tighten via `deny`; the approval decision rides the existing user_rx channel and needs a new ModalStack cancel hook so dismissal resolves to deny. Ships in three independent phases (static tiers, classifier, session allow-always).
Introduces the pure core of the permission system (Phase 1, step 1): a `Mode` enum (auto/plan/yolo) shaped like `Effort`, a `tool(specifier)` rule grammar with case-insensitive tool names, bash exact/prefix/wildcard matching, and gitignore-style path globs, plus `Policy::decide` — the sync, side-effect-free pipeline returning allow/ask/deny. Adds a `risk_class` method to the `Tool` trait (no default, so each tool declares its own) classifying the six tools as read-only, edit, or execute. No agent wiring yet; the gate is consulted in a later commit. Bash allow rules refuse compound commands while deny rules match any chained segment, so widening stays conservative and revoking aggressive.
Wires the permission policy through config resolution. Adds a `[permission]` block (mode, allow, deny) to the file config with append-merge across layers, resolves it in `Config::load` behind an `OX_PERMISSION_MODE` env override, and surfaces the mode in `/config`. A project `ox.toml` is untrusted, so `reject_project_permissions` blocks project-set mode and allow (the widening levers) while honoring deny, mirroring how `reject_project_secrets` guards credentials. The shipped dangerous-pattern defaults seed every resolved deny set.
Adds `Tool::gate_target`, which pulls what the gate matches rules against out of a call's input: `bash` yields its command, `edit` / `write` the canonicalized target path, and every other tool the default `None` (only a tool-wide rule matches). Read-only tools need no path extraction in Phase 1. `GateTarget::for_path` resolves a path against cwd, canonicalizing an existing file and lexically normalizing a not-yet-created one so a `../escape` traversal can never read as inside-cwd. Owned target plus a borrowing `as_target` keeps the matcher allocation-free.
Wire the resolved policy into the agent loop so every tool call passes through the tiered gate before running. An `ask` verdict emits `ApprovalRequested` and blocks on the matching `ApprovalDecision`, riding the existing `user_rx` channel so cancel / quit / queue semantics are reused without a second channel. Deny and non-interactive `ask` short-circuit to a synthetic error tool result the model can react to. On the TUI side an `ApprovalModal` joins the `ModalStack`. The stack gains a `Modal::on_cancel` hook so universal-cancel, N, and session-swap `clear` resolve a pending approval to `Deny` rather than stranding the blocked agent. Tools expose `approval_preview` so bash shows its command and edit / write show a diff.
…command Deny matching split a command on chain operators before matching each segment, so a deny pattern whose own text contains an operator could never match: no post-split segment retains the operator. The shipped `bash(* | sh)`, `bash(* | bash)`, and fork-bomb defaults were inert, and `curl ... | sh` fell through to ask in auto mode. Test the whole command first, then fall back to per-segment matching, so an operator-bearing pattern fires on the unsplit command while a danger chained behind a safe head is still caught. Add a data-driven test pinning every dangerous default to a command it must deny.
A new file cannot canonicalize, so the target path fell back to a purely lexical normalization. That kept `cwd/link/new.rs` textually under cwd even when `link` is a symlink to an external directory, letting a new-file write bypass the outside-cwd approval gate. Canonicalize the nearest existing ancestor first (resolving the symlink), then append the missing tail, falling back to lexical normalization only when no ancestor exists. A `..` traversal is still clamped before the inside-cwd test.
A rule with an opening `(` but no closing `)` (or the reverse) parsed as a bare tool name with an empty specifier, producing a tool-wide rule on a tool no real call uses. A typo'd deny silently matched nothing instead of failing at config load. Treat an unbalanced parenthesis as a hard parse error, upholding the contract that malformed rules surface at load time.
`is_compound` gated only chain operators and substitution, so a prefix allow like `bash(echo hi:*)` matched `echo hi > /etc/passwd`, letting a benign-looking allowlist entry clobber an arbitrary file. Add `>` / `<` to the allow-side compound check. Deny-side segment splitting is unchanged, since redirection does not chain a second command.
When `current_dir` fails, the call sites fall back to an empty `PathBuf`.
An empty cwd made `strip_prefix("")` succeed for every target, including
absolute paths outside any project, so the inside-cwd auto-allow fired on
all edits: the one fail-open in the gate.
Compute the relative component only when `cwd` is absolute. A non-absolute
cwd now yields no relative component, so the call falls through to ask.
A full user-action channel routed an approval decision through the generic "prompt dropped (this is a bug)" message. An approval reply is a control-plane message the agent is actively blocked on, so a dropped one strands the turn rather than merely losing a prompt. Surface a message pointing at the Esc cancel path instead.
`Rule::matches` took a bare `bool` to pick the allow vs deny matching
discipline, so the two call sites read `matches(tool, target, true)`,
hiding the most security-critical distinction in the rule layer behind an
opaque flag. Promote it to a `MatchDiscipline::{Allow, Deny}` enum so the
asymmetry is legible at every call site, with no behavior change.
The design doc numbered a 5-step pipeline while the code ships 7 (plan and
read-only split out), and described the classifier, session allow-always,
and headless classifier path as if shipped. Renumber the pipeline, mark
the later-phase steps inline, and rewrite the headless section to the
actual deny-on-ask behavior. Fix the README index ("classifier" → "rule
grammar"), the merge docstrings ("widens" was wrong for deny), the
approval-preview "already truncated" claim, and drop the transitional
"test-only until then" note from Mode::ALL.
The per-tool gate methods (`gate_target`, `approval_preview`, `risk_class`) and the session-swap clear that resolves a pending approval to Deny had no direct coverage, so a regression in any would pass CI. Add tests for each: command / path extraction including the missing-field None branch, the edit and write diff previews, the read-only risk classes, and the `clear_modals` cancel-hook deny reaching the agent channel.
The approval request rode `sink.emit`, which logs and continues if the event channel is full or closed. The gate then unconditionally entered `await_approval`, so a dropped request left the turn blocked on a decision no modal could ever send. Switch to `sink.send` and fail closed with a synthetic denial when delivery fails, turning a hang into a recoverable refusal.
`read` / `grep` / `glob` never overrode `gate_target`, so it returned `None`. A path-scoped deny like `read(**/.env)` could not match, and the read-only auto-allow then let the call through, so a user-configured deny silently did nothing. Extract the file path for `read` and the search root (defaulting to cwd) for `grep` / `glob`, so a deny is consulted before the read-only allow. Add an end-to-end test running the real tool gate target through `Policy::decide`, which the hand-built-target unit test missed.
2c71d77 to
79f3562
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
Adds a tiered tool-permission system so the agent runs non-stop on settled cases and only prompts when a call is genuinely ambiguous. Every tool call passes through a pure, synchronous gate before running: deny rules (including shipped dangerous-pattern defaults) → read-only auto-allow → in-cwd edit auto-allow → allow rules → otherwise ask. Three modes shape the posture:
auto(default),plan(read-only), andyolo(bypasses everything, today's behavior).This PR ships Phase 1: the static pipeline, the approval modal, the modes, and config wiring. The Haiku classifier (Phase 2) and session allow-always (Phase 3) slot in where the pipeline resolves to ask, and are scoped out here. Design and rationale live in
docs/design/tools/permissions.md.The gate is the whole safety boundary, since oxide-code has no sandbox, so the deny path is built to fail closed: an unmatched call asks, a non-interactive surface or an undeliverable approval request denies, a non-absolute working directory never counts as in-cwd, and a malformed rule fails at config load rather than silently dropping. Deny rules are matched against the whole command before splitting on chain operators, so an operator-bearing default like
bash(* | sh)fires, and read-only tools still resolve a path target so a deny such asread(**/.env)is consulted before the read-only auto-allow.Design decisions
bashcall.auto, flipping today's behavior. Running tools unchecked is the larger hazard.yolopreserves the old behavior as an explicit opt-in and bypasses every deny rule, including the dangerous-pattern defaults, so there is no separate immune tier.ox.tomlis untrusted, exactly like the credentialsreject_project_secretsalready blocks. It may appenddenyrules but never setmodeorallow, which would let a teammate's repo widen what the local user permitted.user_rxrather than a second channel the turn loop does not poll. Tool dispatch is sequential, so at most one approval is outstanding andawait_approvalreuses the cancel / quit / queue semantics ofawait_unless_aborted.clearall resolve through a newModal::on_cancelhook, so a dismissed approval always denies (never stranding the blocked agent) and the verdict is constructed in exactly one place.MatchDisciplineenum keeps that asymmetry legible at the call sites.Changes
docs/design/tools/permissions.md,docs/design/README.mdpermission.rsMode(auto / plan / yolo),Policy::decidetiered gate,Target/GateTarget(path resolution that canonicalizes through the nearest existing parent so a symlinked parent or..traversal cannot masquerade as in-cwd), and the dangerous-pattern deny defaults.permission/rule.rstool(specifier)rule grammar: bash exact / prefix (:*) / wildcard (*) and gitignore-style path globs. Allow refuses compound commands while deny matches the whole command or any segment, expressed through aMatchDisciplineenum; unbalanced parentheses are rejected at parse.tool.rsRiskClassand therisk_class,gate_target, andapproval_previewtrait methods.tool/bash.rs,tool/edit.rs,tool/write.rsgate_target/approval_preview(bash shows its command, edit / write show a diff).tool/glob.rs,tool/grep.rs,tool/read.rsread, the search root forgrep/glob) so a path-scoped deny applies before the read-only allow.agent.rsGateContextplus the gate intercept indispatch_tool_call:check_permission,await_approval, and the syntheticdenied_output. An undeliverable approval request fails closed to a denial.agent/event.rsAgentEvent::ApprovalRequested,UserAction::ApprovalDecision, and theApprovalPreview/ApprovalBody/ApprovalDecisiontypes.tui/modal.rsModal::on_cancelhook;ModalStack::clearandhandle_keysurface the outgoing modal's cancel action.tui/modal/approval.rsApprovalModal: approve-or-deny overlay rendering a command or diff preview, resolving every dismissal to a decision.tui/app.rsApprovalRequested, dispatches cleared modals' cancel actions, and gives a dropped approval decision an actionable error.config.rs,config/file.rsPermissionFileConfig(mode / allow / deny) withOX_PERMISSION_MODEoverride and project-tighten-only enforcement.client/anthropic.rsClient::permissionaccessor for the agent loop's gate.main.rsGateContextat the three entry points, withnon_interactive_gatefor the modal-less REPL / headless surfaces.slash/config.rs/configmodal.slash.rs,tui/components/welcome.rs,client/anthropic/testing.rsCLAUDE.mdTest plan
cargo fmt --all --checkcargo buildcargo clippy --all-targets -- -D warnings: zero warningscargo test: 2190 tests passcargo llvm-cov --ignore-filename-regex 'main\.rs': gate, rule, and tool paths covered (permission/rule.rs100%,permission.rs/agent.rs/config/file.rs~99%)pnpm lint: 0 errorspnpm spellcheck: 0 issues