Skip to content

feat(permission): tiered tool-permission system with approval gate#93

Closed
hakula139 wants to merge 16 commits into
mainfrom
docs/permissions-design
Closed

feat(permission): tiered tool-permission system with approval gate#93
hakula139 wants to merge 16 commits into
mainfrom
docs/permissions-design

Conversation

@hakula139

@hakula139 hakula139 commented Jun 25, 2026

Copy link
Copy Markdown
Owner

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), and yolo (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 as read(**/.env) is consulted before the read-only auto-allow.

Design decisions

  • Classifier runs last (deferred to Phase 2). Static checks are instant and free, so the model round-trip only fires when neither an allow nor a deny rule settles the call. A pure rule engine would prompt on everything unmatched, while a pure classifier would add a round-trip to every bash call.
  • Default auto, flipping today's behavior. Running tools unchecked is the larger hazard. yolo preserves 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.
  • Project files tighten only. A checked-in ox.toml is untrusted, exactly like the credentials reject_project_secrets already blocks. It may append deny rules but never set mode or allow, which would let a teammate's repo widen what the local user permitted.
  • Approval rides the existing channel. The decision flows back on user_rx rather than a second channel the turn loop does not poll. Tool dispatch is sequential, so at most one approval is outstanding and await_approval reuses the cancel / quit / queue semantics of await_unless_aborted.
  • One source of the deny verdict. N, Esc, Ctrl+C, and session-swap clear all resolve through a new Modal::on_cancel hook, so a dismissed approval always denies (never stranding the blocked agent) and the verdict is constructed in exactly one place.
  • Bash matching is best-effort UX, not a boundary. The command string is unparsed, so an allow rule refuses to match a compound command (chained, substituted, or redirected) while a deny rule matches the whole command or any chained segment. The deny list is the dependable lever, and a MatchDiscipline enum keeps that asymmetry legible at the call sites.

Changes

File Description
docs/design/tools/permissions.md, docs/design/README.md New design spec for the tiered gate, modes, rule grammar, approval round-trip, and phasing, indexed in the design README.
permission.rs Module root: Mode (auto / plan / yolo), Policy::decide tiered 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.rs tool(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 a MatchDiscipline enum; unbalanced parentheses are rejected at parse.
tool.rs Adds RiskClass and the risk_class, gate_target, and approval_preview trait methods.
tool/bash.rs, tool/edit.rs, tool/write.rs Declare risk classes and override gate_target / approval_preview (bash shows its command, edit / write show a diff).
tool/glob.rs, tool/grep.rs, tool/read.rs Declare the read-only risk class and extract a gate target (the file for read, the search root for grep / glob) so a path-scoped deny applies before the read-only allow.
agent.rs GateContext plus the gate intercept in dispatch_tool_call: check_permission, await_approval, and the synthetic denied_output. An undeliverable approval request fails closed to a denial.
agent/event.rs AgentEvent::ApprovalRequested, UserAction::ApprovalDecision, and the ApprovalPreview / ApprovalBody / ApprovalDecision types.
tui/modal.rs Modal::on_cancel hook; ModalStack::clear and handle_key surface the outgoing modal's cancel action.
tui/modal/approval.rs ApprovalModal: approve-or-deny overlay rendering a command or diff preview, resolving every dismissal to a decision.
tui/app.rs Pushes the approval modal on ApprovalRequested, dispatches cleared modals' cancel actions, and gives a dropped approval decision an actionable error.
config.rs, config/file.rs Layers PermissionFileConfig (mode / allow / deny) with OX_PERMISSION_MODE override and project-tighten-only enforcement.
client/anthropic.rs Client::permission accessor for the agent loop's gate.
main.rs Builds the GateContext at the three entry points, with non_interactive_gate for the modal-less REPL / headless surfaces.
slash/config.rs Surfaces the permission mode in the /config modal.
slash.rs, tui/components/welcome.rs, client/anthropic/testing.rs Wire the new config fields through test fixtures.
CLAUDE.md Adds the new modules to the crate tree.

Test plan

  • cargo fmt --all --check
  • cargo build
  • cargo clippy --all-targets -- -D warnings: zero warnings
  • cargo test: 2190 tests pass
  • cargo llvm-cov --ignore-filename-regex 'main\.rs': gate, rule, and tool paths covered (permission/rule.rs 100%, permission.rs / agent.rs / config/file.rs ~99%)
  • pnpm lint: 0 errors
  • pnpm spellcheck: 0 issues

@hakula139 hakula139 added the enhancement New feature or request label Jun 25, 2026
@hakula139 hakula139 self-assigned this Jun 25, 2026
@codecov

codecov Bot commented Jun 25, 2026

Copy link
Copy Markdown

@hakula139 hakula139 force-pushed the docs/permissions-design branch from 35200a2 to 2c71d77 Compare June 26, 2026 07:40
hakula139 added 16 commits June 26, 2026 17:17
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.
@hakula139 hakula139 force-pushed the docs/permissions-design branch from 2c71d77 to 79f3562 Compare June 26, 2026 09:20
@hakula139 hakula139 closed this Jun 26, 2026
@hakula139 hakula139 deleted the docs/permissions-design branch June 26, 2026 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant