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
shreads it (tokenize,segments; CHO-25): quotes decide what is a pattern and what is shell syntax, sogrep 'a|b$' fis one command with one pattern andls x#; rm yis 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; doneis judged asrm $f.$HOME,$PWDand 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~usermake 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.cdis followed: later names are relative to where it went (a barecdgoes home). Checked against the realshon 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-execof something that only reads),cd,test/[,ps,pgrep,lsof,md5sum,tac,nproc,nvidia-smi…;sedthat only prints or substitutes to its output (sed -n 1,5p,sed 's/a/b/'),awkwithoutsystem(), pipes or output files;< filefrom the project.- git:
status/log/diff/show/blame/ls-files/grep, and the listing forms ofbranch(--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. curlthat 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 -oalso 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 earlierexport, 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 sameplaywright.browser_evaluatecall 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.shwith 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 testismake test,npm --prefix app run xisnpm run,cargo +nightly fmtiscargo fmt,kubectl -n dev getiskubectl get.ghanddocker composekeep 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, souv run rm …is asked every time.xargs cmdisxargs 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 …,awkwithsystem(),curl -d/-o,git -c …/--git-dir,docker exec …,cargo --config x test. A rulefindorsedsaved by an older version doesn’t cover these either.
- Outside the project (
_where,grant_dir; thedirslist): 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 ~/.bashrcwithcpallowed). 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” oncat /tmp/x.loghad 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 agrep/rg/sed/awk/jq, the value of-e,--glob,-name,--formatand whatechoprints are text, not places;/api/v1is a pattern because no/apiexists. - 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 acdout of the project: asked every time.2>/dev/null,>/dev/nulland2>&1are 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 xstay 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 wrapperssetsid,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 lineasaves 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 sixcurl … | python3 -c '…'variants and the user was asked about every one, withcurlalready allowed. Therunline of the system prompt now also says which commands run without a question and to read JSON withjqorpython3 -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 tobash -c,evalandenv -S, infind -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 linearrefuses every tool of that connector, likelinear.*. - Plan mode and
/askkeeprun, 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. /permissionslists allow rules, allowed folders and deny rules;allow <rule>,deny <rule>,remove <rule>,clear.allow linear.save_issue(a connector that is connected, orname.*) 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 aboutreadOnlyHint: 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.shnever matched again (and would have matched anyx.sh);git -C . pushslipped past the never-remember list and was offered a baregitrule;cargo "test x"matched the rulecargo test;> 1(a file named 1) was treated like>&1;cat ~/.ssh/id_rsaandcat /etc/passwdran unasked;uniq a b,tree -o,rg --pre,git grep -O,git reflog expire,hostname x,date -scounted as read-only;printenv(it prints secrets) was on the read-only list; two sessions overwrote each other’s rules; typingawhere “always” wasn’t on offer declined the command; plan mode couldn’t run evengit log. Found by the live run: saying “always” tocargo --versionsaved the rulecargo, 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 withshlex, which drops everything after a#inside a word (ls src#x; touch Pran unasked, and walked past deny rules);do/then/!/{were taken for the program, sofor …; do rm -rf x; donelost “asked every time” and “always” saved the rulesfor,do,done;env -u DEBUG cargo testsaved the ruleDEBUG;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 execwere rules for anything; one harmlessfind/sed/awk/curlgave a rule forfind -delete,sed -i,system(),curl -d @.env; “always” oncat /tmp/x.logsavedcat, which then read~/.ssh/id_rsa;git -c core.sshCommand=… fetchrode on the rulegit fetch;git checkout -- .,git branch -D,git stash clear,npm publish,gh pr merge,killcould 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, sogrep '${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.shcould never be remembered; a typed “ok” declined the command;playwright.browser_evaluatecould 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 barecdgoes 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;
/undoreverts 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;curlto 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 --versionandfor f in src/*.rs; do wc -l $f; doneran unasked;cargo --version > out.txt 2>&1was asked with “always: > (output into this project’s files)”, and after agit log --oneline > log.txtran unasked;rm -f out.txt log.txtwas asked with no “always” and a typedokran it;cat /tmp/x.logoffered “always: files in /tmp”, after whichhead -1 /tmp/x.logran unasked andls ~/.sshwas asked with no “always” (declined with a note, which the model followed); after/permissions deny cargo build,for i in 1; do cargo build; donewas refused without a question;/permissionslisted the>rule, the folder and the deny rule; the rules file was 0600;linear.save_commentwas 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-pmode (its git commands ran unasked; a correct summary in 42 s). A real terminal session on Qwen3.8-27B (driven through a pty): a tocargo --versionsaved a rule and the nextcargo --versionran unasked;rm -f …was asked, answered with “do not delete anything; runls scriptsinstead”, and the model skipped the rm and ranls scripts;/permissionslisted the rule. A second session covered the rest: after/permissions deny make,make --versionwas refused without a question and the model said the deny rule forbade it; under/ask,git log -1 --onelineran unasked andcargo --versionwas refused without a question; with a test MCP server,notes.add_notewas asked once, a saved it, and the next call ran unasked (both notes were written). That session also showeddeny makedidn’t covermake --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.
- The line is read the way