Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

oppx permissions: what runs without asking, saved and session rules, the question

Part of the developer notes; the index is in CLAUDE.md.

  • Permissions (oppx_chat/permissions.py, no UI code, modelled on Claude Code; scripts/test_permissions.py, 39 tests):
    • The line is read the way sh reads it (tokenize, segments; CHO-25): quotes decide what is a pattern and what is shell syntax, so grep 'a|b$' f is one command with one pattern and ls x#; rm y is two commands (# starts a comment only at the start of a word). Loop and condition keywords are structure, not commands: for f in a; do rm $f; done is judged as rm $f. $HOME, $PWD and the like are replaced by their value; a ~ is expanded as sh would; any other variable (it may hold a secret, or anything), ${x:-y}, {a,b} and ~user make the word unknown, and a line with an unknown word is asked every time. A loop’s own variable is fine when the loop’s words stay in the project. cd is followed: later names are relative to where it went (a bare cd goes home). Checked against the real sh on 3,000 random quoted lines: the same words every time.
    • Never asked: a command whose every part (split on &&, ||, ;, |) only reads, inside the project:
      • ls, cat, rg, grep, head, wc, find (also with -exec of something that only reads), cd, test/[, ps, pgrep, lsof, md5sum, tac, nproc, nvidia-smi …; sed that only prints or substitutes to its output (sed -n 1,5p, sed 's/a/b/'), awk without system(), pipes or output files; < file from the project.
      • git: status/log/diff/show/blame/ls-files/grep, and the listing forms of branch (--contains, --merged), tag, remote, stash list|show, reflog, worktree list, submodule status, config (--get, --list, or one name); git -C <folder in the project>.
      • A version check of a known tool (node --version, cargo --version, python3 -V), command -v, cargo tree|metadata, npm ls, pip list, docker ps, go env.
      • curl that only fetches from this machine (curl -s localhost:5173, also with -o /dev/null -w …): the dev server just started.
      • Not when an argument points outside the project (below), nor with an argument that makes it write or run something (find -delete, sort -o also written -oFILE, tree -o, rg --pre, git --output, git grep -O<pager>, git -c, uniq in out, jq … env, tail -f, ps e, --files0-from, file -f), nor behind an environment assignment, an earlier export, or a wrapper.
    • One yes is enough for a session (Permissions.answered, answered_tool, session_yes): y allows what was asked about until the chat ends, and saves nothing. For a command that is the rule “always” would have saved (cargo build, the folder outside the project, the > rule); for a connector tool it is that tool, of whatever connector, also one added later. o is “only this once”. Commands that are asked every time (rm, sudo, git push, git checkout -- .) have no such yes. Why: in a real session the same playwright.browser_evaluate call was asked about three times running, and the rule had to hold for any tool, not for that one.
    • Asked once: y (for this session; a typed “ok”, “sure”, “yes please”, “go ahead” counts as yes), o (only this once), N, a (always), or any other text, which declines and is passed to the model as what to do instead. a saves an allow rule for the project in ~/.config/oppx/permissions.json (OPPX_PERMISSIONS). A rule names what is run, and is matched word by word after stripping assignments and wrappers (env -u X, timeout 60, nice):
      • cargo test, npm run, git commit, python -m pytest, python scripts/x.py, bash scripts/test.sh, ./scripts/x.sh with its path; never a bare interpreter or shell, and a tool’s name alone covers only the bare call (git * by hand for all of it).
      • A tool’s own options before the subcommand are left out (GLOBAL_FLAGS): make -j8 test is make test, npm --prefix app run x is npm run, cargo +nightly fmt is cargo fmt, kubectl -n dev get is kubectl get. gh and docker compose keep a third word (gh pr view).
      • A subcommand that runs another program names that program (RUNNERS): uv run pytest, npx vite, go run ./cmd/x; what it runs is judged like any command, so uv run rm … is asked every time. xargs cmd is xargs cmd: what it runs is known, the names it is handed are not, so it never counts as only reading.
      • That exact command only (Key.exact) when it uses a tool’s dangerous side or an option this doesn’t know: find . -name '*.pyc' -delete, sed -i …, awk with system(), curl -d/-o, git -c …/--git-dir, docker exec …, cargo --config x test. A rule find or sed saved by an older version doesn’t cover these either.
    • Outside the project (_where, grant_dir; the dirs list): a rule says what a command does, not where. An argument outside the project is asked about whatever the rules say, also for an allowed tool (cp x ~/.bashrc with cp allowed). a then allows that folder (“files in /srv/logs”, shown in the question and in /permissions; removed with its path), for every command. For a file directly in a folder every program shares (/tmp, /var/tmp, /dev/shm) it allows that one file only, and the folder itself (ls /tmp) is never offered: in the first live run “always” on cat /tmp/x.log had opened all of /tmp. Never offered for a folder that holds credentials or is too wide: /, /home, the home folder itself, /etc, /proc, ~/.ssh, ~/.aws, ~/.gnupg, ~/.config (the rules file is there), ~/.docker, ~/.kube … (BROAD_DIRS, SECRET_HOME). The pattern of a grep/rg/sed/awk/jq, the value of -e, --glob, -name, --format and what echo prints are text, not places; /api/v1 is a pattern because no /api exists.
    • Output redirected into a file of the project (cargo test > out.txt): asked, and a saves the rule >, after which redirects into project files aren’t asked about (the command itself still needs its rule). Outside the project, into .git, or after a cd out of the project: asked every time. 2>/dev/null, >/dev/null and 2>&1 are nothing.
    • Asked every time, never remembered:
      • sudo, su, rm, dd, mkfs*, shutdown, reboot, chmod, chown, truncate, shred, kill, pkill, crontab;
      • git that loses work: push, reset, clean, rebase, rm, checkout -- … / checkout <an existing file> / -f, restore (unless --staged), branch -D, tag -d, stash drop|clear, commit --amend (_destructive; git checkout main, git stash, git branch x stay rememberable);
      • publishing and deleting elsewhere: npm|cargo|uv|poetry publish, docker push/prune, kubectl delete, gh … merge|delete|close, gh release create;
      • whatever runs text as code or hides what runs: bash/sh -c, eval, ssh, python -c, node -e, env -S, and the wrappers setsid, pkexec, busybox, strace, flock, script, runuser, chroot, systemd-run (OPAQUE);
      • anything that can’t be taken apart safely: command substitution, a background job, a here-document, case, an unclosed quote, an unknown variable.
      • A rule for one of these written into the file by hand is ignored.
    • For this session (s; Permissions.offer_session, never written to disk): offered when a line can’t get a saved rule only because a part is an interpreter given its code inline (python3 -c, node -e). It allows that interpreter’s one-liners, and the line’s other parts, until the chat ends. On such a line a saves what a rule can name (curl) and allows the inline code for the session. Not offered for the rest of the never-remembered list (sudo, rm, git push, a shell running text, anything that can’t be taken apart); deny rules and plan mode still refuse. Why: asked to sort an API’s results, Qwen3.6 read the JSON with six curl … | python3 -c '…' variants and the user was asked about every one, with curl already allowed. The run line of the system prompt now also says which commands run without a question and to read JSON with jq or python3 -m json.tool.
    • The question (OppxIO.ask_options; commands, connector tools, the browser): the answers are listed one per line, always in the same order (yes, always, session, no, free text), each key in its own colour (green yes, teal for the lasting ones, red no), under a bold yellow heading. They used to sit in one grey parenthesis after “Allow?” and were easy to miss. Seen in a pty; not yet in a live chat.
    • A command that just failed isn’t run again unchanged (failed_runs): the same line after a non-zero exit, with no edit and no other command that worked in between, is a repeat. In that session the model asked for a failing one-liner twice more.
    • Deny rules win over everything, read-only commands included, and refuse without asking (/permissions deny <rule>); the model is told not to work around it. They are looked for in every command a line may run (commands_in): behind wrappers (setsid make, sudo make, xargs make), in loops and conditions, in the text given to bash -c, eval and env -S, in find -exec, behind runners (uv run make), and in a line that can’t be taken apart (echo `make x`, X=$(date) make x, make x &). deny linear refuses every tool of that connector, like linear.*.
    • Plan mode and /ask keep run, but only for commands that read; anything else is refused without a question (allowed or not).
    • Storage: each change is read-modify-write on the file under a lock (permissions.json.lock), and rules are re-read when the file changes, so two sessions on one project share rules and neither loses the other’s (four threads saving 15 rules each: all 60 kept). The file is written 0600. Lists per project: run, deny, tools, dirs. A "global" section written by hand applies to every project.
    • /permissions lists allow rules, allowed folders and deny rules; allow <rule>, deny <rule>, remove <rule>, clear. allow linear.save_issue (a connector that is connected, or name.*) is saved as a tool rule.
    • Connector tools: y (this tool, until the chat ends), a (this tool, saved), c (every tool of the connector, saved), o (only this once), N, or text. A tool marked asked-every-time (Permissions.every_time: playwright.browser_evaluate, JavaScript the model wrote) is never saved and no rule covers it, written by hand or for its whole connector; a yes covers it for the session like any other tool, so a page that tells the model to run something is then no longer stopped by a question in that session. That is the price of not asking again, taken on purpose. A connector that came with the repository (.mcp.json) isn’t taken at its word about readOnlyHint: its “read-only” tools are asked (judge_tool(…, trusted=False)), and can be remembered.
    • Type-ahead: terminal input still pending when a question appears is discarded (OppxIO._ask_line), so a line typed as the next message can’t answer it.
    • Found in the audit and fixed: a rule saved for ./scripts/x.sh never matched again (and would have matched any x.sh); git -C . push slipped past the never-remember list and was offered a bare git rule; cargo "test x" matched the rule cargo test; > 1 (a file named 1) was treated like >&1; cat ~/.ssh/id_rsa and cat /etc/passwd ran unasked; uniq a b, tree -o, rg --pre, git grep -O, git reflog expire, hostname x, date -s counted as read-only; printenv (it prints secrets) was on the read-only list; two sessions overwrote each other’s rules; typing a where “always” wasn’t on offer declined the command; plan mode couldn’t run even git log. Found by the live run: saying “always” to cargo --version saved the rule cargo, which covered every cargo command.
    • The CHO-25 audit (22 defects, each reproduced first, each now a test named test_dNN_…): the line was split with shlex, which drops everything after a # inside a word (ls src#x; touch P ran unasked, and walked past deny rules); do/then/!/{ were taken for the program, so for …; do rm -rf x; done lost “asked every time” and “always” saved the rules for, do, done; env -u DEBUG cargo test saved the rule DEBUG; setsid, pkexec, busybox … got a blanket rule; a flag was taken for the subcommand (npx -y, kubectl -n, make -j8); uv run, npm exec, docker exec were rules for anything; one harmless find/sed/awk/curl gave a rule for find -delete, sed -i, system(), curl -d @.env; “always” on cat /tmp/x.log saved cat, which then read ~/.ssh/id_rsa; git -c core.sshCommand=… fetch rode on the rule git fetch; git checkout -- ., git branch -D, git stash clear, npm publish, gh pr merge, kill could be remembered; sort -oFILE, git grep -Osh, jq -n env, date -f/etc/x, cat {/etc/passwd,x} counted as read-only; deny rules were walked around by a wrapper, a loop, bash -c, a redirect; quoting was thrown away, so grep '${name}', grep '|' f, rg '()', grep 'foo$', awk '{print $1}' were asked (some every time, some with a rule named after the file); node --version, python -u x.py, bash scripts/test.sh could never be remembered; a typed “ok” declined the command; playwright.browser_evaluate could be allowed forever; the file was 0664 and unlocked. Found on a second pass over the new code and fixed before it shipped: cd && cat .ssh/id_rsa (a bare cd goes home, and later names were taken as the project’s), cd /tmp && echo x > f, cat list.txt | xargs cat (a list the model wrote can name any file), echo $SECRET_TOKEN, ps e, curl file:///etc/passwd, git branch --sort=x newbranch, xxd in out.
    • Differences from Claude Code that remain: file edits are applied without asking (as in its accept-edits mode; /undo reverts the last turn’s), so an allowed test runner can run what the model just wrote; rules are per project path (a worktree or a moved repository asks again) plus a hand-written global section, with no shared repository file; curl to this machine is never asked, though a local service may act on a GET.
    • Tested: the rules and every answer to the question are unit-tested (39 tests). The CHO-25 rewrite in a real chat (tmux, the dev build against the 0.7.1 server, Qwen3.6-27B, a scratch repository and its own rules file): grep -rn 'fn main() {$' src, node --version and for f in src/*.rs; do wc -l $f; done ran unasked; cargo --version > out.txt 2>&1 was asked with “always: > (output into this project’s files)”, and after a git log --oneline > log.txt ran unasked; rm -f out.txt log.txt was asked with no “always” and a typed ok ran it; cat /tmp/x.log offered “always: files in /tmp”, after which head -1 /tmp/x.log ran unasked and ls ~/.ssh was asked with no “always” (declined with a note, which the model followed); after /permissions deny cargo build, for i in 1; do cargo build; done was refused without a question; /permissions listed the > rule, the folder and the deny rule; the rules file was 0600; linear.save_comment was asked with y/a/c/s/n (declined). Not seen live: a repository connector’s read-only tool, the browser’s asked-every-time tool, and type-ahead being discarded. The sessions below were on the old gate; “Do you understand the current changes?” was run earlier on Qwen3.8-27B in -p mode (its git commands ran unasked; a correct summary in 42 s). A real terminal session on Qwen3.8-27B (driven through a pty): a to cargo --version saved a rule and the next cargo --version ran unasked; rm -f … was asked, answered with “do not delete anything; run ls scripts instead”, and the model skipped the rm and ran ls scripts; /permissions listed the rule. A second session covered the rest: after /permissions deny make, make --version was refused without a question and the model said the deny rule forbade it; under /ask, git log -1 --oneline ran unasked and cargo --version was refused without a question; with a test MCP server, notes.add_note was asked once, a saved it, and the next call ran unasked (both notes were written). That session also showed deny make didn’t cover make --version (a tool’s name alone matched only the bare call, which is right for allow rules and wrong for deny rules); fixed, and re-run.