Repository navigation
feat: approvals show how risky a shell command is, and ask about the risky ones - #446
Merged
Merged
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
This was referenced Oct 30, 2026
rsharath
approved these changes
Oct 31, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The test named ~/.codeoid outright; CI runners set XDG_CONFIG_HOME, which moves codeoid's config dir, so the path wasn't a credential file there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly when it is exactly codeoid's rewrite, judged in its --cwd An agent could write the wrapper's prefix itself and append anything after it (or name another --cwd); the gate judged only the decoded payload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s the command it runs tree-sitter-bash leaves backquotes in a here-document body as text, so `cat <<EOF` with `rm -rf ~` in its body was rated low. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re judged, so a credential file named that way is refused
- `$HOME/…`, `${HOME}/…` and `"$HOME/…"` resolve like `~/…`, for reads,
writes, deletes, uploads and the credential refusal.
- A reader's argument only known at run time (`$X`, `$(…)`, xargs input)
is medium, not low; find -exec's `{}` stays low under in-workspace roots.
- A cd outside the workspace is tracked to where it goes, so a bare name
after it (`cd ~/.codeoid && cat config.json`) is judged there; a cd to
somewhere unknown (`cd -`, `popd`, `cd "$D"`) asks about a relative
name that would be a credential file in any credential directory.
- A reader run in a directory outside the workspace is medium, args or not.
- find's roots skip -H/-L/-P options before them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d their values `bash -o pipefail -c '…'`, `bash -c -e '…'` and `sh -c -- '…'` took an option's value, or the option, as the script; what -c runs was never seen. fish's -C/--init-command line is not understood. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t feeds it `echo '…' | sh`, `printf '…' | bash` and `cat <<EOF | sh` are judged as the script the line holds; a shell running any other pipe's output, a `< <(…)`, or `source <(…)` is not understood rather than medium. A substitution in a statement-level redirect (`bash < <(curl …)`) now feeds the command, so the download is seen. Rewind lists a shell running its input as not undone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- xargs -i/-l/-e take only an attached value, and -I/-i's replace string
makes an argument a run-time value (no input is appended then).
- env - is env -i; a clustered -S (env -iS'…') or -C/--chdir is not understood.
- command looks only at its own leading options for -v/-V.
- busybox and toybox run their applet; coproc, trap '…' and script -c run
theirs; strace, ltrace, parallel and unshare are not understood.
- run0, runuser, sudoedit, machinectl and systemd-run run as another user,
chroot and nsenter as root: high.
- A program name that is a pattern (/bin/r?, r{m,}) is not understood.
- find -exec replaces {} anywhere in an argument ({}/, sh -c 'rm {}').
- ssh running a command (remote, ProxyCommand-like options, -F) and tar told
to run one (--to-command, --checkpoint-action, -I, -F) are not understood.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iables that steer what runs, are no longer low - fd -x/-X is looked through like find -exec. - rg --pre, sort --compress-program, git --upload-pack/--receive-pack/ --ext-diff/grep -O, jq's env/$ENV and --files0-from are medium; so is git ls-remote (it asks a remote), and git --git-dir=/--work-tree= with =. - awk skips -v/-F values before the program; -f/-e/-i/-l and gawk's file-writing options are medium. sed reads its options as sed does: -i.bak edits in place, --expression=/-eS/-l N are read. - Every variable the line sets is recorded (prefix, env NAME=, export, declare, plain): LD_*, PATH, GIT_*, *PAGER, *EDITOR, BASH_ENV, HOME, CDPATH, OLDPWD and similar are not understood, wherever they're set; any other one in a program's environment makes it medium. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t understood
bash evaluates a variable's value as an expression in $((…)), ((…)), let,
[[ … -eq … ]], array indexes and ${x:off:len}, so a value like
'a[$(cmd)]' runs cmd; ${x@P} runs the $(…) a value holds. Both were low.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… instead of being rated high Only -s/--set, or an operand of digits (MMDDhhmm…), sets the clock; one only known at run time that isn't a +FORMAT still counts as a set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pace are high, however they're written - git push origin :branch deletes a remote branch: high. - truncate/touch/mkdir targets are writes; rmdir/unlink are deletes. - rm run by find -exec, or by xargs fed by a search outside the workspace, is high; rsync --delete to a target outside it is high. - curl -o/-D/-c and wget -O/-o/-P, and cp/mv/install/ln -t, are judged as writes to the file or directory they name. - More start-up and agent files are sensitive (.bash_aliases, .bash_logout, .zlogout, .xprofile, .pam_environment, .vimrc, .cargo/env, ~/.claude, ~/.codex, ~/.gemini, ~/.qwen, ~/.pi, codeoid's config dir). - git config --global/--system/--file writes are high; xxd's output file and tree -o are writes. - crontab (but -l), at, systemctl enable/link/edit, launchctl load: high. - alias values are parsed as the line they run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…up files and system paths its own where() returned "workspace" before it looked at sensitive or system paths, so in a session on ~ a write to ~/.bashrc or rm -rf ~/.ssh was medium. A workspace inside such an area (~/.config/nvim, /opt/app) is still its own ground. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A shell tool call with no readable command was treated as no shell call at all (auto-approved in autonomous mode); it is now not understood. And a line the parser couldn't read at all (not loaded, crashed, over 100k characters), or a call with no command, asks even with approvals.askOnUnknown off: that switch relaxes what the parser can't be sure of, not having no parser. The Claude hook hands a Bash call with no readable command to the gate too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… sed -i, pip through python -m, and verbs after options find -delete under roots outside the workspace, sed -i on a file outside it, python3 -m pip install, and npm --global install are listed; git config --get/--list (reads) no longer are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nputs, variables and unreadable commands are judged Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…riables and arithmetic are medium
Two audit rounds showed a rule-based reading of bash can't be made
complete, so low no longer means "the rules found nothing": a line is low
only when every simple command in it runs a vetted read-only program, by
name and with no wrapper, every word is known before it runs, every path
is inside the workspace, no redirect writes (but to /dev/null), and the
line holds only commands, pipelines, lists, subshells, braces and if.
No cd, assignment, loop, declare or [[ ]]. Options that make a reader run
or write (find -exec, fd -x/-l, rg --pre/-z, sed -i, sort -o, tree -o/-R,
awk system/ENVIRON, git -c/--output/--ext-diff, ...) aren't low, and less,
more, bat and git help left the set.
The round-1 noise is undone: IFS, PATH, PYTHONPATH, NODE_OPTIONS, HOME,
EDITOR, VISUAL and pagers are medium wherever set, and arithmetic on a
variable is medium. It is not understood only where the line sets a
variable that arithmetic reads (directly or through names) to text with
$(, a backtick or [, and for ${x@P}. Variables that load or run code
(LD_*, GIT_*, BASH_ENV, ENV, PROMPT_COMMAND, PS4, CDPATH, ...) stay
unknown.
Small, safe fixes from round 2: time/coproc before a compound command
is not understood and what's inside is judged; bash /dev/stdin and
< /dev/stdin run their input; a wrapper's own words (xargs -a, time -o)
reach the credential check; ~me and ~+ resolve; find -- ROOT is read; a
variable name with a code-holding subscript (printf -v, test -v, declare,
unset, read) is not understood; printf -v, read and mapfile record what
they set.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nts say so docs/approvals.md now says plainly that the levels are read from the command line, not enforced: high and not-understood commands ask, and a credential file is refused when the reader recognises it, as a best effort. It spells out the low allow-list, says that medium runs unasked in autonomous mode and on a Claude allow rule, and lists the known ways a command gets past "high" or "refused" at medium (a cd that doesn't happen, globs over credential files, path spellings, writers and command runners the reader doesn't model). Real protection for credentials and files outside the workspace is a sandbox, a separate issue. The CHANGELOG entry, the approvals.* setting comments, CLAUDE.md and the secret-paths comment match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saucam
force-pushed
the
feat/risk-approvals-431
branch
from
October 31, 2026 22:40
9c67ca9 to
83ec2c4
Compare
saucam
added a commit
that referenced
this pull request
Nov 1, 2026
…, a directory it can't resolve for certain included #446 rates every shell call that reaches the gate. opencode's reaches it as `Bash` with its full command line and, when it names a `workdir`, that directory as `cwd`, resolved by the path rule (a link judged where it leads); the card and the pending approval show the same level and reasons as Claude's for the same call (opencode-risk.test.ts, over the fake server). A shell call whose `workdir` can't be resolved for certain is an `opencode_unresolved_path` card, asked in every mode, but its command was never rated: a credential read with such a workdir was asked instead of refused. Its command is now judged in each place the card says it may run, the worst verdict standing: the card shows its level, and a credential read is refused as anywhere. A line opencode 2.2.5 runs unasked (no command in it) rates medium at most, never one #446 would ask about in every mode or refuse, and is still judged after the fact as before: one that acts stops the server. On the real binary (I49, through a Session, scripted model): `rm -rf ~` asks in autonomous mode, `git status` runs unasked in guarded mode with `autoAllowLowRisk`, a credential read is refused. The case's gate wrapper answers opencode `once` only for `git status`, whatever the Session decides, and the session's shell has a throwaway HOME. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saucam
added a commit
that referenced
this pull request
Nov 1, 2026
…s let go, so one test daemon's /tmp paths don't make later sessions ask The default registry registers opencode's credential dir and database with the gate and never undid it. With #446 a command that reaches a directory holding credential files asks in every mode, so after any test built a SessionManager over a database under /tmp, `ls /tmp` asked in autonomous mode, and the full suite failed (T2 single-decrement, on the rebased branch as committed). `ProviderRegistry.dispose()` undoes what building it registered, and a SessionManager disposes a registry it built itself once it has drained (the daemon's only drain is at shutdown, after its sessions stopped); a registry handed to it stays the caller's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saucam
added a commit
that referenced
this pull request
Nov 2, 2026
… codeoid (#450) * feat: opencode is a codeoid backend, with every tool call approved by codeoid (#447) * fix: #447 an opencode turn's cost is opencode's own figure, and unknown where part of its spend went unreported Since #445 Session prices any turn result without a cost source from its tokens, records a turn that ended with no result itself, and lets a routine cap stop on an unknown cost. opencode's results carried no source, so its own figure was priced again at list price (or unknown), and a failed turn's spend, which the provider carried into the next turn, was counted twice. - A turn's result says `backend` when every step with tokens had a price (or nothing was spent); `unknown` when a request's spend went unreported: a step stopped while the model answered (opencode bills it nothing, as real 2.2.5 does), a step with no usage, one still open when its turn ended or its server or session went, or a feed gap whose read-back failed; and no source for a turn of only $0-with-tokens steps (a subscription sign-in, a model priced at $0), which Session prices under `pricing.models`, else unknown, as on Claude. Mixed with priced steps it is unknown. - A failed turn's result rides its `error` event (`error.result`, new), which Session records instead of an estimate; nothing is carried. - The fake server bills a bare reject's step and fails an interrupted step with no usage, as real 2.2.5 does. - Routine caps on opencode count real cost and stop on an unknown turn (opencode-routine.test.ts); the real-binary suite checks the cost source of a text turn, a stop mid-reply, a refused request, the title, and a model priced at $0 (I48). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: #447 opencode's shell calls are judged by risk as Claude's Bash, a directory it can't resolve for certain included #446 rates every shell call that reaches the gate. opencode's reaches it as `Bash` with its full command line and, when it names a `workdir`, that directory as `cwd`, resolved by the path rule (a link judged where it leads); the card and the pending approval show the same level and reasons as Claude's for the same call (opencode-risk.test.ts, over the fake server). A shell call whose `workdir` can't be resolved for certain is an `opencode_unresolved_path` card, asked in every mode, but its command was never rated: a credential read with such a workdir was asked instead of refused. Its command is now judged in each place the card says it may run, the worst verdict standing: the card shows its level, and a credential read is refused as anywhere. A line opencode 2.2.5 runs unasked (no command in it) rates medium at most, never one #446 would ask about in every mode or refuse, and is still judged after the fact as before: one that acts stops the server. On the real binary (I49, through a Session, scripted model): `rm -rf ~` asks in autonomous mode, `git status` runs unasked in guarded mode with `autoAllowLowRisk`, a credential read is refused. The case's gate wrapper answers opencode `once` only for `git status`, whatever the Session decides, and the session's shell has a throwaway HOME. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 a registry's opencode credential paths are dropped when it's let go, so one test daemon's /tmp paths don't make later sessions ask The default registry registers opencode's credential dir and database with the gate and never undid it. With #446 a command that reaches a directory holding credential files asks in every mode, so after any test built a SessionManager over a database under /tmp, `ls /tmp` asked in autonomous mode, and the full suite failed (T2 single-decrement, on the rebased branch as committed). `ProviderRegistry.dispose()` undoes what building it registered, and a SessionManager disposes a registry it built itself once it has drained (the daemon's only drain is at shutdown, after its sessions stopped); a registry handed to it stays the caller's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 CHANGELOG lists opencode as the newest addition, with what #430 and #431 mean for it Rebased past #431, the opencode entry sat below it; it now leads the Added list, and says that its shell commands are judged by risk and when a turn's cost is unknown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-16 audit: a low-risk shell call whose directory can't be resolved is asked under autoAllowLowRisk too #riskAllows short-circuited the isUnresolvedPathTool hard gate once 19f6fdb gave an opencode_unresolved_path card its command's risk, so guarded mode with approvals.autoAllowLowRisk ran it unasked. It now refuses a hard-gated tool, at the gate and in the card peek. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-16 audit: rewind's "not undone" list reads a shell call in the directory it ran in irreversibleEffects judged every shell command from the workdir, while the gate judges it in the call's cwd/workdir. An opencode (or Codex, Gemini) call that wrote outside the workspace by naming its directory was missed. It now reads the call with shellCallOf and passes its directory to outsideEffects (a new `cwd` in EffectContext); an opencode_unresolved_path card is judged in each of its candidate places. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-16 audit: opencode runs shell lines with bash, not the daemon user's login shell opencode 2.2.5 runs a line with the server's $SHELL for any shell but fish and nu (pwsh with -Command), while codeoid rates every line as bash reads it. A login shell that reads lines otherwise could run what the card and the risk level don't show. The server's SHELL is now the bash on its PATH, else /bin/sh (opencodeServerShell); the session's shell environment keeps the user's $SHELL for the commands themselves. I50 shows it on the real binary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-16 audit: `export -p` and other declaration listings opencode runs unasked keep the server opencode 2.2.5 finds no command in a declaration builtin with options or none (export -p, declare -p, readonly -p, unset -v X, local) and runs it unasked; codeoid took that for a settled request and stopped the shared server, ending every opencode session's turn. In the session's own shell, whose environment is codeoid's bare one, such a listing now counts as opencode's own rule (commandlessShellLine's `listing`). A subagent's still stops the server: one whose start fell in a feed gap may have the server's environment, and the stop rotates its password. I51 shows the lines on the real binary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-16 audit: an opencode compaction interrupted mid-request makes the turn's cost unknown, not a known $0 opencode reports a compaction request's spend only as the request finishes; one stopped while its summary streams (an interrupt, a budget stop, a loop cancel) is never reported, though it was sent with the whole conversation. The turn came out `backend` $0. `session.compaction.failed` with `compaction.interrupted` now counts as unreported spend, for the session and its subagents. Real 2.2.5 announces no compaction start, so the fake no longer emits `compaction.started` and gains a `compact-interrupt` scenario; the scripted model gains `[[usage:N]]` and `[[compact-slow:ms]]` so I52 shows it on the real binary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-16 audit: an opencode turn failed on a model endpoint it couldn't reach is $0, not of unknown cost opencode fails a step it couldn't connect for (ECONNREFUSED, DNS) with a `provider.transport` error and no status, which codeoid counted as spend nobody reported: the failed turn was unknown, and a --daily-cost routine started no more runs that day. A step whose connection was never made (neverConnected) and that carried nothing past its start on the feed, or holds nothing read back, now bills nothing, as a refused request does. A reset or a timeout, or a failure after part of an answer, stays unknown: the request may have been served. The fake gains `transport:<kind>`; I53 shows it on the real binary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-16 audit: a capped opencode run counts steps opencode billed $0 as they land, and stops when nothing prices them opencode's native ceiling added only opencode's own cost, so on a model it reports at $0 with tokens (a subscription sign-in, a custom provider with no cost) --max-cost never fired mid-turn, though docs/routines.md says the agent stops at the ceiling. Under a cap, such a step now counts at the price Session puts on it (pricing.models, else the snapshot; the registry hands the provider `modelPrices`); with no price the turn stops at once (UNPRICED_STOP_MESSAGE), as Session would stop the run at the turn's end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-17 audit: an opencode attempt that broke off after part of an answer, then retried, makes the turn's cost unknown Real opencode 2.2.5 fails an attempt whose response broke off mid-reply (a stream that ended without finish_reason, a connection reset mid-body) with the response's own status, 200, bills it nothing, and retries in a new message. The request was served, but #stepSettled took any numeric status as the provider refusing it, so the turn was a known figure covering only the retry, or a known $0 when the retry couldn't connect. Only a status of 400 or more with nothing of an answer streamed is a refusal now. A retry scheduled straight after part of an answer, with no failed step first, also counts that attempt as unreported. The fake gains the real shape (`midstream:<error>:<then>`), the scripted model `[[midfail:N]]`, and I54 covers both breaks on the real binary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-17 audit: a daemon shutdown during an opencode server's start stops what it launched and removes its key files stopOpencodeServers only reached servers that had finished starting, and stop() did nothing to a start in flight, so a SIGTERM during a session's first turn (or a restart, or the relaunch for agent overrides) let the start adopt its server after the shutdown step ran: a live connection in the grace period, and 0600 key files left on disk until a later daemon cleared them as stale. A server joins the shutdown set as its start begins. stop() counts as a stop epoch the start checks after each await and before adopting; one it overtook shuts down what it launched, clears the key files and fails. stop() closes the launching process's stdin and waits the start out. A later start goes ahead. The fake gains FAKE_OPENCODE_SLOW_START_MS to stop a start midway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-17 audit: a continued opencode subagent read back after a feed gap bills only its steps since codeoid attached #readBackChild billed every ended step of a running child not in #countedSteps, a per-provider set that is empty after a rebuild (a model or backend switch, a session rebuild). A subagent continued by its id carries its whole history, so its earlier turns' steps were billed into the turn and the spend cap again, with extra llm_calls. A step that ended before codeoid attached is now skipped there, as #settleReadBack already skips a call made before it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-17 audit: an opencode execution interrupted from outside codeoid while a pushed message waits ends the turn and withdraws the message #executionEnded waited for a next execution whenever a steer was owed, with no timer, whatever the outcome. opencode runs no input after an interrupted execution, so one stopped by another holder of the server's password, or by opencode itself, before the steer was delivered left the turn hanging until Session's stall watchdog, and the steer rode the next prompt. A `later` message ended the turn but also stayed in opencode's inbox. Now such an execution's waiting inputs are withdrawn, each steer's boundary is paid at the turn's end (Session still counts it: it didn't interrupt), the turn says the message wasn't run, and it ends interrupted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-17 audit: the opencode server's pinned SHELL is never a relative path opencodeServerShell used Bun.which("bash") on the server's PATH, which honours relative entries against the daemon's cwd: with `.` or `bin` on PATH and a `bash` there, SHELL became `./bash` or `bin/bash`, a different file in each directory opencode runs a line in (a repository's own, for the commandless lines opencode runs unasked). Only absolute PATH entries are searched now, and anything but an absolute result falls back to /bin/sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-17 audit: `ps` showing processes' environments is refused as a read of /proc/<pid>/environ With BSD `e` (`ps eww`, `ps auxe`), macOS's `-E` or an `env`/`environ` field, procps prints the environment of every process the same user owns, reading /proc/<pid>/environ itself, so the line named no path the secret check sees and rated a plain medium "runs ps". On opencode that hands the agent the private server's password (and the daemon's own keys on any backend), so in autonomous mode it could reach the server's API past the gate. Such a line is now high with a `deny` secret, as `strings /proc/<pid>/environ` is; an option word only known at run time counts as one. Option arguments (`-C opencode`, `o user`) and Unix `-e` (every process) aren't read as the modifier, so a plain ps stays medium. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-17 audit: under a cap, a subagent's step opencode billed $0 is priced at the subagent's own model #recordStep priced an unpriced step at the turn's model (or the session's last), but a subagent can run on another model (the `subagent` tool takes `model`), so a capped run counted its $0-billed steps at the parent's price instead of stopping when nothing prices them. Each step's model is now recorded from its `session.step.started` (the session's and its subagents'), or read from the stored message on a read-back, and the step is priced at that model. The fake stores each assistant message's model, as real 2.2.5 does, and gains a `free-child` subagent on a $0 model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-17 audit: the docs say a $0-billed opencode model is priced only by a pricing.models entry under its provider/model id docs/routines.md and docs/opencode.md said a step opencode bills $0 counts toward a spend ceiling at the pricing.models price, "else the list price". The list-price snapshot is keyed by bare model ids (`claude-sonnet-4-5`) and opencode's ids are `provider/model`, so the snapshot never priced one, and the capped run stopped at the first such step, as Session's turn-end pricing (documented in the same Cost row as overrides-only) also treats them. The docs now say so, and how to price one. A test pins that the snapshot doesn't price an opencode id and that a pricing.models entry does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-18 audit: a path through a process's /proc/<pid>/root link to an environment or a credential file is refused The secret-path check matched /proc/<x>/environ (and mem, fd, fdinfo, map_files) only right under the pid, and looked through a root link only when realpath could resolve it, so /proc/4242/root/proc/4242/environ, or a credential file under /proc/<pid>/root/ for a pid that isn't running on this host, was a plain path. Any such component below /proc is now a process secret, and a /proc/<pid>[/task/<tid>]/root prefix is also judged as the path after it, for the file tools and the shell check alike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-18 audit: the shell reader sees every variable a line sets and each word's possible values, and a word the grammar splits is read whole A `for X in` loop's variable, `getopts`' name and a quoted `export "X=…"` weren't recorded as set, so `export "LD_PRELOAD=./x.so"` was a plain line and a ps reading `$PS_FORMAT` couldn't tell it was set. They are now; a variable whose name is only known at run time (`declare -x "$n=…"`, `printf -v "$n"`, `read "$n"`) isn't understood. Every word carries a shape, the pattern of the values it could take (run-time parts, globs, brace expansions, xargs, find and fd arguments), for the risk check's rule on process environments. tree-sitter-bash splits `/proc/$A/task/$B/environ` into two adjacent arguments, dropping a `$`: adjacent arguments are joined back into the one word bash reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-18 audit: a shell command that could read another process's environment is refused, and ps unless it provably shows none Round 17 refused `ps eww` and a literal /proc/<pid>/environ, but `PS_FORMAT=pid,environ ps -C opencode` and `cat /proc/$PPID/environ` (an opencode shell's parent is its server, OPENCODE_PASSWORD included) rated medium and ran unasked in autonomous mode, on opencode and on Claude's Bash alike. One fail-closed rule in the shell risk check now refuses, as a credential read on every backend, a command that could read a process environment: a word whose possible values include a /proc/<pid>/environ (or mem, fd) however the pid or the name is written (`$PPID`, `$$`, `$(…)`, globs, a value set on the line, relative to /proc, through a root link); what a search of /proc hands on (find -exec, xargs, a loop); a recursive read of / or /proc; a debugger attached to a process; top; inline code naming /proc/…/environ. ps is an environment reader unless every option is one documented as showing none, every format and any PS_FORMAT set on the line names no environ field, every word is known (a single quoted value counts), and the line sets no other variable ps reads (PS_PERSONALITY, UNIX95, …). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-19 audit: the opencode server runs in an empty, read-only directory of its own, so /proc/$PPID/cwd leads to nothing of codeoid's The server was spawned in the database's folder, beside codeoid-credentials/ and its config dir: a session's shell reached the injected model keys through /proc/$PPID/cwd, which the gate can't follow when the pid is only known at run time. Each start now gets a fresh mkdtemp dir (0500) in the daemon's temp dir, removed when the server exits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-19 audit: an interrupt while the turn's prompt is on its way stops the execution that prompt starts, and the turn ends with it The interrupt found nothing running and was a no-op; the prompt was stored after it and its execution ran on unobserved, its reply dropped and its spend carried into the next turn. #post now stops what a prompt (or command, or skill) starts when the turn was interrupted meanwhile, and #settleInterrupt waits (bounded) for the prompt and for that execution's end. The fake now stops an execution interrupted between admission and its start, as real 2.2.5 does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-19 audit: a teardown while a turn is still attaching leaves no MCP mount on the shared server and no live token The attach went on after the teardown (waiting on a fork's move or a leftover's stop) and mounted the session's servers with a freshly minted memory or proxy token nobody revoked. A teardown now bumps an epoch the attach checks after opening the session and after each mount, and waits (bounded) for an attach already mounting, so what it mounted is unmounted and revoked with the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-19 audit: $(< FILE) is judged as the read of FILE it is, on every backend A substitution of only a redirect made no command, so its target reached neither the credential-file check nor the environment rule: echo $(< FILE) read codeoid's config or /proc/$PPID/environ at medium. The parser now records it as the cat < FILE bash runs for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-19 audit: a debugger attaching with a pid only known at run time is refused, and one whose words could attach is not understood attaches() matched each word's value, which is null for any word holding a variable, so gdb --pid=$PPID, gdb -p$PPID, lldb -p$PPID and gdb -ex "attach $PPID" were medium. It now matches the word's shape (its fixed text with run-time marks). A gdb, lldb, strace or ltrace word that starts with a run-time part, or gdb's pid after the program only known at run time, is not understood (asked in every mode). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-19 audit: .. after a link is taken as the kernel takes it, so /dev/fd/../.. is /proc and /proc/self/cwd/.. is the workdir's parent Both the file-tool check and the shell reader collapsed .. lexically before following links, so cat /dev/fd/../../<pid>/environ was judged as /<pid>/environ and /proc/self/cwd/../.codeoid/config.json as a path under /proc/self: both medium, and null for a Read. kernelPath (secret-paths.ts) resolves .. after the physical prefix, the round-15 rule, with a process's own cwd link taken as the directory it opens the path from; a path that names the same file either way keeps its written form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-19 audit: a listing of /proc (ls, stat, readlink, realpath) isn't refused as an environment read: it reads no file The round-18 rule refused ls -l /proc/$$/fd, ls /proc/$PPID and ls /proc/self/fd as reads of a process's environment, and nothing can approve a refusal. A program that only lists or stats its operands, not recursing, now skips that rule for them (a credential file is still refused, and so are its redirects). A line that lists a /proc dir only known at run time and then reads a run-time path (ls /proc/$PPID | xargs cat) is still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-19 audit: ps fed a plain $(pgrep …) or $(pidof …) isn't refused: it prints pids only ps -p $(pgrep node) -o rss=, one of the commonest ps idioms, was refused as an unquoted value that could split into options. Plain pgrep and pidof print only pids and whitespace; such a substitution in fixed text, with options that keep it so, now counts as known. -l, -a, -d, pidof's -S, or anything run time inside it are still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-19 audit: docs/approvals.md lists the shell classifier gaps left to the sandbox (#448) The /proc/<pid>/cwd and fd links with a run-time pid, a credential file through a root link, a literal /proc/<pid>/environ inside a larger word, another user's ~name, programs that print their server's environment, and why top stays refused rather than asked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-20 audit: the opencode server's cwd is alone in a fresh root made where no directory up to / holds anything of codeoid's Its parent was the daemon's temp dir, where codeoid makes isolated sign-in homes, so /proc/$PPID/cwd/../codeoid-login-…/.claude/.credentials.json was a medium read. The cwd is now <base>/codeoid-ocserver-…/cwd, with the base the first of /var/tmp, /dev/shm, /tmp from which no directory up to / is the daemon's temp dir, is in or holds one of codeoid's places (database, transcripts, config, opencode data and credentials), or holds an entry named codeoid. None such: the server isn't started. Root and cwd are removed when the server exits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-20 audit: a key written literally in the user's own opencode config reaches opencode from a 0600 file, never the server's environment A provider carried from ~/.config/opencode was copied verbatim into OPENCODE_CONFIG_CONTENT, so a literal apiKey or header sat in the server's environment, which every process opencode starts (a stdio MCP server, a feed-gap child's shell) inherits. Each literal string of a carried provider (not its package, nor one already naming a {file:}) is now written, with codeoid's own keys, to carried-<n>.value in this daemon's credentials dir and named by {file:}. An entry with a whitespace-padded value, which opencode would trim reading it back, is left out with a line in the log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-20 audit: an execution interrupted before it announced a step leaves the turn's cost unknown, not a known $0 An interrupt that lands within milliseconds of opencode storing a prompt (or a command) stops an execution that showed only its start and its interruption, though real 2.2.5 had already sent the step's request: the turn ended as a priced $0 (costSource "backend"). Such an execution now counts as unreported spend (Usage.lost), so the turn is "unknown". The fake does as real 2.2.5 does there: no step, no stored reply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-20 audit: a teardown while the turn's prompt is still being stored stops what that prompt starts, before the teardown returns Session.destroy, a hard stop and a backend or model switch tear the backend down without an interrupt first. With nothing running yet the teardown found nothing to stop and returned; the prompt POST still in flight then landed and opencode ran the whole execution on the shared server with nobody listening: its reply missing from codeoid's history, its spend in no turn, a tool request waiting forever. #post now stops what a prompt, skill or command starts whenever its turn was closed before its execution was seen, not only when it was interrupted, and after a teardown waits (bounded) until it has stopped; #teardown waits (bounded) for a prompt still being posted, so a turn after the teardown never joins the stopped execution. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-20 audit: an integration case pins, on the real binary, that a turn interrupted as its prompt is stored is of unknown cost I57 interrupts a few milliseconds after the prompt's request, on real 2.2.5 with a scripted model that logs its requests: opencode sends the step's request and shows only the execution's start and interruption. The turn must be "unknown", never a priced $0. It fails without e3a540e. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-21 audit: the opencode server's cwd is never made in a base that holds the daemon's temp dir at any depth Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-21 audit: the git and hg opencode runs unasked in a workspace run none of the repository's code opencode 2.2.5 reads a workspace's git repo (rev-parse, remote get-url, rev-list, check-ignore, ...) and snapshots it with no permission request, and in a Mercurial checkout runs `hg log -r roots(all())` (project id) and `hg branch` (its hg plugin). hg reads the repository's .hg/hgrc, whose hooks and extensions run code. The server's environment now gives every git GIT_CONFIG_COUNT overrides (hooksPath /dev/null, fsmonitor off, no external diff, no signing, no submodule recursion, safe.bareRepository=explicit) and no transport (GIT_ALLOW_PROTOCOL=none, GIT_NO_LAZY_FETCH=1); every hg gets HGRCSKIPREPO=1, HGRCPATH= and HGPLAIN=1; opencode.vcs.hg is disabled. HGRCSKIPREPO needs Mercurial 5.4+, so the server's hg is asked its version at each start and a workdir under a .hg is refused when it is older or won't say. The server's cwd is never made below a .git or .hg. A session's shell keeps its own env. The fake now runs the same unasked VCS commands as the real binary when it loads a location under a repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-21 audit: a message still in opencode's inbox when the backend is torn down is withdrawn, so the session's next turn doesn't answer it Real 2.2.5 keeps a queued or untaken input across an interrupt, and the teardown keeps the opencode session for the next turn, which got that input delivered with its own prompt (answered there, and billed to it). #teardown now withdraws the turn's steers and queued inputs as #interrupt does, and waits (bounded) for a push still being posted; #send withdraws one that lands after its turn was closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-21 audit: a step whose start fell in a feed gap is no longer taken for an execution interrupted before any step, so its turn's cost stays known #stepSeen became true only on a live session.step.started, so a feed gap over it left an interrupted execution counted as having sent an unreported request (#noStepInterrupted), and a turn whose every token was reported came out `unknown`. Any live event only a started step emits (its blocks, calls, end) now marks the step seen, as does an assistant message of the running execution found by the gap's read-back (#reconcile). The fake gains "startgap-ask" and "startgap-idle", and fails a step whose response had ended with its tokens on an interrupt, as real 2.2.5 does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-21 audit: a relative entry of the daemon's PATH no longer reaches opencode's server, which ran a repository's own ./git there opencode starts git, hg and ripgrep by name in the workspace, unasked; with `.` on its PATH real 2.2.5 ran the workspace's own executable `git` for its repo discovery. The server's PATH now keeps only the daemon's absolute entries (absolutePath, as opencodeServerShell already did for bash). A session's shell keeps the daemon's PATH as it is. The fake resolves the programs it runs in a location as the real binary does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-22 audit: a message still in opencode's inbox when its server dies is withdrawn before the next prompt, so no later turn answers it Real 2.2.5 keeps a session's inbox in its database: an input pushed into a turn whose server died (killed, or stopped by codeoid) survived, and the session's next turn, on the new server or after a daemon restart, answered it and was billed for it. Fixed as a class: every way a turn ends now withdraws what it left undelivered (#closeTurn), and before every prompt (and as a session is resumed, before a leftover's stop or a move) every user input still in the session's inbox is withdrawn (#sweepInbox), after pushes still in flight; a sweep that fails fails the turn. The fake keeps its sessions and inbox across a restart when asked (FAKE_OPENCODE_PERSIST) and lists the inbox, as the real server does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-22 audit: the opencode server's cwd is never made in a base that holds codeoid's database, keys or data at any depth leadsToCodeoid refused a base holding one of codeoid's paths only directly, so with dbPath at /var/tmp/<user>/data/codeoid.db the server ran under /var/tmp, and /proc/$PPID/cwd/../../<user>/data/codeoid-credentials reached the key files in two `..` steps. A base (but `/`) is now refused when it holds one of them at any depth, as it already was for the temp dir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-22 audit: a workdir in a git repository is refused on opencode when the server's git is older than 2.31, which ignores codeoid's git overrides Every override codeoid gives the git opencode runs unasked (hooks, fsmonitor, external diff, signing, submodules, bare repositories) arrives through GIT_CONFIG_COUNT, which git added in 2.31; an older git (Debian 11's 2.30, Ubuntu 20.04's 2.25) drops them all without a word. As for hg, codeoid now asks the git on the server's PATH its version at each start (gitProblem), and with one older than 2.31, or one that doesn't say, a workdir in a git repository (a .git in it or above, or a bare repository: gitCheckout) is refused before any request names it, the turn saying why and what to install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-22 audit: no formatter runs after an approved edit on opencode: they're pinned off, and a server whose system config turns them on is refused opencode registers its built-in formatters (prettier, gofmt, mix format, …) when any config document sets `formatter`, and runs one after every edit without asking; several run the workspace's own code. On real 2.2.5 a `formatter: true` in a document it reads ran the gofmt on its PATH after an approved write. codeoid's injected config now sets `formatter: false`, which resets every document below it, and since opencode's system config dir (/etc/opencode) outranks the injected config (2.2.5's Config.load puts its documents last), a server whose documents, resolved as opencode does (formatterProblem over GET /api/config), turn formatters back on is refused at start and before every turn. The fake lists its config documents in the same order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-22 audit: a message pushed before its turn's prompt goes out no longer holds the turn up for the inbox sweep, and a fork's inbox is swept too The sweep before a prompt waited for every mid-turn push still on its way, including the turn's own, which waits for that prompt: the turn stalled for the sweep's whole bound (3s). It now waits only for earlier turns' pushes. A session forked natively is swept before its move, as a resumed one is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 two interrupt tests wait for opencode to have the prompt, not a fixed delay On a slower machine (CI) starting the fake server took longer than the tests' 100 ms and 150 ms waits, so the interrupt landed before the prompt was sent: a different, already-handled case (the prompt is never sent), and the tests failed asserting the in-flight one. They now wait until the fake has received the prompt. With the server start slowed by 400 ms the old version fails as CI did; the new one passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-23 audit: the opencode server's config dir and its data, state and cache dirs are refused to every tool call, directory card and shell path, in every mode A write into codeoid's own opencode config dir (config/ beside the database) was an ordinary write outside the workdir, and opencode loads a plugin written there at once, inside the server, before the next turn's plugin check. The config dir beside every database location, and the data/, state/ and cache/ of the configured and default dataDir, are now registered with the secret-path gate as codeoid-credentials/ is. The two dirs opencode names to the model (a truncated tool's and a shell's full output) stay readable, judged as written so a link in them is judged where it leads; its tmp/ stays the model's scratch space. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-23 audit: a later message opencode doesn't start running within 3s of the turn's last reply is withdrawn saying so, not silently Since round 22 every turn's end withdraws its undelivered inputs, and the QUEUED_START_MS timeout ended the turn without a word: the user saw the message merged into the turn and no answer. The timeout now emits `opencode.queued_message_withdrawn` before the turn ends, as an interrupt or a budget stop already does. The fake can hold an input after the last step in its inbox for a while (FAKE_OPENCODE_QUEUED_START_MS), as a loaded server does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: opencode's hosted models (opencode.ai) are off unless the owner opts in (#447) With no model set and no credential, opencode 2.2.5 runs a session on one of its free hosted models on opencode.ai: the user's code went to a vendor they never configured. It also fetched its model catalog from models.opencode.ai at every server start. - providers.opencode.hostedModels (CODEOID_OPENCODE_HOSTED_MODELS, Settings > opencode), default false. - Off: the injected config's disabled_providers names `opencode` (a provider.use deny: gone from opencode's provider and model listings and its default); the server's env sets OPENCODE_DISABLE_FREE_MODELS and OPENCODE_DISABLE_MODELS_FETCH; the user's carried config can't bring it back (its provider entries, an opencode/ model or small_model, its enabled_providers entry are dropped with a daemon-log line); a server that still offers it is refused; the picker never lists it, a persisted catalog included. - A turn with no usable model (none at all, a hosted one, one opencode has no credential for) fails before anything is posted, saying what to set. - Fake server models 2.2.5's hosted provider; unit tests, and integration case I65 on the real binary behind a recording, refusing proxy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: a second codeoid daemon on the same data is refused instead of running alongside it (#447) Two daemons on one SQLite store, transcripts, checkpoints and opencode database corrupt each other's state, and opencode's start-up clears the credentials of a daemon it takes for gone. Only a second daemon on the same port was caught (EADDRINUSE). `codeoid start` now locks each directory the daemon owns (the database's, transcripts, opencode's database and data dirs, memory's) before anything is opened or cleaned up: a codeoid-daemon.lock created atomically (hard link of a fully written file) with pid, host, boot, pid namespace and the process start time process-tree.ts reads. A live holder, or one on another host or pid namespace, refuses the start, naming it and how to clear a stale lock; a gone one (pid not running, pid reused, rebooted) is taken over under a guard file with a log line, never removing a live holder's. Released by a ShutdownManager step that runs last and by an exit handler. Only the daemon takes it: CLI commands, tests and DaemonServer embedders don't. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: an opencode session resumed on a hosted model is switched to the default, not run on opencode.ai (#447) A session whose opencode model was one of opencode's hosted ones (from before they were turned off) is moved to the server's default before its prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: autonomous mode asks before a write outside the session's working directory (#447) Autonomous mode ran a write whose target resolved outside the session's workdir without asking, on every backend. Now a write, edit, delete or move whose target provably resolves outside it (the temp dir counts as inside), or can't be resolved for certain, asks at codeoid's own gate: - outside-writes.ts names each backend's write tools and path keys (Claude Write/Edit/MultiEdit/NotebookEdit, opencode write/edit/patch files, Codex file changes and move destinations, pi write/edit, Qwen Code write_file/edit/notebook_edit, Gemini CLI acp_edit/delete/move) and resolves each path as the secret-path gate does (kernelPath), then through the links of its longest existing prefix. - Shell: shellWriteTargets (risk/effects.ts) lists the targets #431's parser can name (redirects, rm/mv/cp/tee/ln/touch/…, dd of=, sed -i, find -delete); a target it can't place keeps today's behaviour. - The card's risk line says why ("writes outside the working directory: <resolved path>"); no budget is spent; an unattended run parks on it as on a high-risk command; on Claude the PreToolUse hook routes it to the gate past allow rules (ProviderSessionInit.sessionMode). Reads and other modes are unchanged. Codex autonomous asks nothing (#442): documented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the autonomous-budget test runs a command with no path in it, so it doesn't depend on where other tests left the config dir (#447) `ls /tmp` asks wherever /tmp holds credential files: in a full run an earlier file leaves codeoid's config dir under /tmp, and the test timed out parked on an approval (before and after #447's changes alike). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-24 audit: an outside write is judged where the kernel writes it, through dangling links, hard links and the shell's own links and cds Autonomous mode's outside-write check resolved a target by its longest existing prefix with existsSync, so a dangling symlink (in the workdir or /tmp) was judged by its parent and a write through it landed outside unasked; a hard link to an outside file was judged by its name; the shell reader collapsed `..` lexically, dropped /proc and ~name targets, followed a cd in a subshell, pipeline, condition or into a missing directory, and never saw a link the same line made. - One resolver: opencode's component-by-component `physical` moves to physical-path.ts (`physicalPath`), used by `realTarget` and by outside-writes. A dangling link leads to its target; a loop or too many links, `/proc`, and an existing file with more than one hard link are unresolved (ask). Both readings of a `..` path (kernel, collapsed) count. - Shell targets stay raw (`..` kept); `~name`, `/proc` and a relative target where the cwd is unknown ask. `ln` (and `cp -l/-s`) counts its sources / symlink targets; every other target of a line that makes a link asks. A cd is followed only when it surely moves the shell (parse.ts marks `subshell` and `conditional` commands) into a directory that exists. - pi / Qwen / NotebookEdit: every path key a call carries is judged. - The temp-dir exemption leaves out codeoid's own `codeoid*` entries. - `..foo` inside the workdir is inside. - `acpTargets` reads `{path}` locations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-24 audit: a Gemini CLI edit inside the workdir no longer asks in autonomous mode gemini-cli sends no rawInput with a permission request, only `locations` ({path}) and diff content naming the file, and the ACP provider handed the gate `rawInput ?? {}`, so every edit was "names no path" and asked. The gate and the card now get those paths as `locations`; a request naming no file still asks. The fake ACP agent sends the real CLI's shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-24 audit: the data-dir lock is judged by machine id and a fixed-zone start time, recovers a crashed container's lock and a dead start's guard, and reads its file safely - macOS's start time is read with `ps` under TZ=UTC LC_ALL=C, so a start from a shell in another zone no longer takes a live daemon's lock. - The machine is told by /etc/machine-id (IOPlatformUUID on macOS) where both sides have one, hostname otherwise: a same-named other machine is never taken over; a renamed machine knows its own lock. - The holder refreshes its lock's mtime every minute; a lock from another pid namespace on this machine and boot (a container restarted after a crash) is taken over once 10 minutes stale, instead of refusing forever. - A takeover guard left by a start that died is judged as a lock is and cleared. A stale lock or guard is moved aside and checked before it goes. - Directories the lock makes are 0700; existing ones are left alone. - The lock is read with O_NOFOLLOW|O_NONBLOCK, as a regular file of at most 4 KB; a symlink, FIFO or big file refuses the start, saying so. - The temp file is fsynced before it's linked; a filesystem without hard links creates the lock in place; other link errors are DataDirLockError. - A signal before the daemon's own handlers are installed releases the lock. - Old temp files of dead starts are swept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-24 audit: everything that reaches opencode.ai is a hosted model while they're off, and a carried enabled_providers keeps its meaning With hostedModels off only the provider id `opencode` was treated as hosted. opencode 2.2.5 has a second, `opencode-go` (its free-model filter and Console integration name both), and a carried provider can reach opencode.ai under any id: `canonical` copies a hosted provider's URL and catalog (overlayProvider), or its base URL is on opencode.ai. - HOSTED_PROVIDER_IDS = opencode, opencode-go; `hostedIdsOf` adds every carried or codeoid-own entry whose canonical names one (transitively) or that has a URL on opencode.ai. Their entries, models and small_model are dropped with a log line, all are denied, the picker, the persisted catalog filter, #offered and hostedProviderProblem (also by canonical) use the set. - A carried enabled_providers stays a restriction: hosted ids are removed but an emptied list stays (it denies every provider, as opencode reads it), and codeoid's own provider ids are added to any carried list instead of being silently shut out. - The fake models enabled_providers, canonical copies and opencode-go. - Docs: titles run on a carried small_model when there is one; a session whose own model is hosted keeps failing until it's changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-24 audit: the data-lock test reads no start time itself, so the file typechecks The time-zone case reads the start time in child processes; its own import of processStartTime was left unused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-24 audit: real opencode 2.2.5 offers no opencode-go, canonical copy or opencode.ai alias from the user's config while hosted models are off (I66) The integration case the fake can't stand in for: real 2.2.5's own provider listing, default and outbound requests, behind a refusing proxy. Without the hosted-id fix it lists opencode-go, z (canonical: opencode) and zen (baseURL on opencode.ai). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-24 audit: a data dir the lock can't be written in refuses the start with a named reason, not a raw stack trace An error writing the lock's temp file (EACCES, ENOSPC, EROFS) escaped createExclusive as a raw error; it is now a DataDirLockError naming the directory, which `codeoid start` prints and exits on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-25 audit: a shell write target asks unless the reader is sure of it, and deleting or renaming a link judges the link Autonomous mode's outside-write check now fails closed on shell lines (risk/effects.ts shellWriteTargets): - A target is placed only when it is a plain literal word: a brace, a pattern, an extglob, a variable or substitution, ~name or a ~ mid-word is unplaced and asks (C1, F1, F2). - Each writing program is read with an allow-list of the options the reader is sure of (OPTIONS); anything else asks: unknown or abbreviated options, an option after the operands, -T, backups, cp forms that may copy a link (-P/-d/-a/-r/-R/-l/-s), find -L -delete, a sed script that may write or run something (sedScriptWrites), a sed -i suffix naming another place. -t DIR is read as getopt reads it, bundled or attached (F3, S2). - A cp that may copy a link, or an mv of anything but an existing plain file, leaves the line's other targets unplaced, as ln does. - A cd by a bare name isn't followed: CDPATH may send it elsewhere (C3). - find -fprint/-fls/-fprintf files are judged. - rm, rmdir, unlink and mv's sources are judged by the name itself: a link in the workdir that leads out, or a hard-linked file, no longer asks to be deleted or renamed; rm -r link/ still does (F5). - outsideWriteOf takes the shell's cwd as the backend reports it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-25 audit: a Claude shell call's relative writes are judged from where its Bash really is, at the hook and at codeoid's gate Claude's Bash keeps a `cd` between calls, and codeoid allows its cwd into ~/.claude/skills, so `cd ~/.claude/skills` then `echo x > my-skill/NOTE.md` was judged inside the workdir and ran unasked in autonomous mode (S1, F4). The PreToolUse hook's `cwd` is where the shell is now: the hook judges the call's risk and outside writes from it, keeps it by tool_use_id, and canUseTool hands it to Session's gate (ToolApprovalFn's and tool_start's `shellCwd`), so the gate, the card and pendingToolApprovals agree. A Claude shell call the hook reported no cwd for has an unknown one: a relative target asks. Other backends start their shell in the workdir each call and report none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-25 audit: while hosted models are off, a provider whose URL comes through {file:} isn't carried, and a server listing one by an opencode.ai URL is refused A carried provider whose base URL (or canonical) came through a {file:} reference reached opencode.ai on real 2.2.5: the filter read the text, opencode the substituted URL (S3, F6). While hosted models are off, an entry with a {file:} anywhere but its apiKey or a header's value is dropped with a log line (uncheckedReference). hostedProviderProblem refused a listed provider only by id or canonical (C2). It now also refuses one that a config document (GET /api/config, /etc/opencode included) points at opencode.ai, and one whose models GET /api/model lists with an opencode.ai URL as opencode resolved it; #offered applies the same test to each model per turn (hostedModelInfo). The fake lists a configured provider's models with their substituted settings, and merges a system document's providers and default, as 2.2.5 does. I67 checks it on the real binary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-25 audit: the data-dir lock is taken over only from a provably gone holder on this machine and boot, and a daemon that loses its lock stops - A holder is local only when hostname, machine id and boot id all match, each where either side has one: a machine id alone is shared by cloned VMs and baked-in images, and took a live lock on another host (S4, F7). An earlier boot can't be told from such a twin, so it refuses too, naming the lock file. - A lock from another pid namespace is never taken over (no mtime age, no heartbeat): refused, naming the file to remove (S5, F8). - The holder checks every minute that its locks are still its own; one removed or replaced shuts the daemon down (onLost, wired to the ShutdownManager), rather than keep writing beside another. - On a filesystem without hard links, another start's lock moved aside by mistake is written back in place, exclusively; one that can't be put back stays aside and is named, never deleted (F9). - Every failure to create a data dir or lock it is a DataDirLockError with a named reason (S6, F10). - A refusal names what the settings put in the dir and suggests XDG_CONFIG_HOME, which separates every owned dir (S7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-25 audit: I37 checks a user's {file:} base URL is carried only with hosted models on, and dropped with them off Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-26 audit: a shell target the reader can't place asks only when its text could leave the workdir, and a bare-name cd is followed where CDPATH is known unset The owner's split guarantee: file tools keep the hard rule; shell is best-effort guidance (#448 is the boundary). An unplaced shell target asks only when it is absolute, starts with ~, has a `..` (or a pattern that may match one), a variable or substitution, comes from input of unknown origin, or the line sets HOME, or its cwd may be anywhere (a cd to such a word, cd -, popd, CDPATH set in the line, an unreported cwd). rm -f *.log, cp -r src dist, cd sub && touch x and find src -exec sed -i {} no longer ask. find -L/-H after -D, and -files0-from, now count as leaving. Claude's CLI gets CDPATH= in its env and opencode's shells have none, so both backends declare shellCdpathUnset and a bare cd is followed to where it really leads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-26 audit: a Claude Grep or Glob's relative path is judged from the cwd the CLI resolves it from, at the hook and at codeoid's gate The CLI resolves a Grep/Glob relative `path` (and a missing one, the search root) from its persisted Bash cwd, so after `cd sub` a `../../.codeoid/...` path read codeoid's own key while the secret-path gate judged it from the workdir. The hook's cwd is now kept for every tool call, handed to canUseTool and Session's gate, and secretPathVerdict resolves relative paths from it; a relative path with no cwd reported asks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-26 audit: Claude's Monitor command goes through the same shell judgement as Bash, and a PowerShell call always asks Monitor's `command` is a shell command or script, but no shell judgement saw it: in autonomous mode it ran unasked whatever it wrote or read. It is now a shell tool (risk level, credential paths, the outside-write check); a Monitor watching a WebSocket runs no command and isn't. PowerShell's language isn't one the reader reads, so its calls are never understood and ask. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-26 audit: while hosted models are off, a provider whose host-building setting isn't a plain name, or whose settings name opencode.ai, isn't carried or used; a credential kept in {file:} is carried again opencode's bundled SDKs build hosts from plain settings (Bedrock and Alibaba `region`, Azure `resourceName`, Vertex `location`/`project`, Alibaba `workspaceID`, Snowflake `account`, Cloudflare `accountId`/ `gateway`, Google `universeDomain`), so `x@opencode.ai/zen/v1?` in a region sent the prompt to opencode.ai. Those settings must now have their plain form, and any setting naming opencode.ai after percent-decoding and compatibility normalisation counts as hosted: at carry time, in the config documents and listed settings at server start, and per turn. A `{file:}` is now dropped only where it builds a URL or host (or names a header), so Bedrock keys, a Vertex private key or an OpenAI organization kept in files are carried, and the log names the setting to write itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-26 audit: the docs say plainly that file tools are a hard rule, shell is best-effort guidance, and the OS sandbox (#448) is the boundary for shell docs/approvals.md: the shell side of the outside-write check (what asks, what doesn't, CDPATH, Monitor and PowerShell) and the shell reader's known limits from round 26 (function bodies, traps and loops, find -execdir, a reopened /dev/stdout, a cd into a removed dir, cp into a directory through a link, a statement after a heredoc, writes through a link the line copies); Claude's Grep/Glob relative paths. docs/opencode.md: host settings, carried file credentials, the shell's CDPATH. docs/routines.md, CLAUDE.md and the CHANGELOG to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-26 audit: `rm -f *.log` in the workspace is medium, not "deletes the whole workspace", so common in-workdir lines run unasked through the whole gate A pattern judged by its fixed directory made `rm -f *.log` at the workspace's top rate high, so it asked in autonomous mode though the owner's list says it never should. A pattern with a name in its last component deletes what matches; `rm -rf *`, `./*` and `.*` stay high, and anything under `.git` too. A Session-gate test runs the owner's in-workdir lines through the risk level and the outside-write check together. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-26 audit: a shell call's own cwd key no longer overrides the cwd the backend reports; both are judged On Claude the hook's `cwd` is where the CLI's Bash really runs; a `cwd` key the model adds to the input (which Bash doesn't take) made a relative write judged from the workdir instead. With a reported cwd, the targets are now judged from both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-26 audit: a listed model's variants, headers and body are checked for opencode.ai as its settings are hostedModelInfo read only a model's `settings`; a variant's settings or a `Host` header naming opencode.ai passed the start-up and per-turn checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 round-26 audit: a lock that couldn't be put back is never swept, and a lock lost during a shutdown doesn't cut that shutdown short sweepTemps deleted `.gone` files by mtime, and a rename keeps the lock's old mtime, so another start's lock that removeIfStill couldn't put back (which the refusal says is kept) went at the next start; one holding a holder not judged gone now stays. onLost called process.exit(1) when a shutdown was already draining, cutting it short; it now leaves that shutdown to finish. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: #447 a delete pattern counts as matching only some files when it's anchored on a literal name, so `rm -rf *a*` and `[a-z]*` stay high Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: #447 the Stop-cost test stops once opencode has the prompt, not after a fixed delay Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: #447 the two-daemon test reads each daemon's output from the start, so a full pipe can't block its shutdown, and shows that output when a daemon exits badly Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: #447 the real-daemon lock tests show a daemon's output whenever it exits or stalls unexpectedly Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.
Closes #431.
Summary
Approvals now show how risky a shell command looks, judged from what it does rather than the tool's name. Every command an agent wants to run is read (pipelines, chains, subshells and wrappers like
sudo,xargsandbash -cincluded) and given a level: low, medium, high, or not understood. High and not-understood commands ask for approval even in autonomous mode, and the level and reason are shown on every approval surface.The levels are guidance, not a security boundary. Real protection for credentials and files outside the workspace is running the agent's commands in a sandbox, tracked in #448.
What we want
Today guarded mode asks about every shell command and autonomous mode runs all of them, with nothing telling
git statusapart fromrm -rf ~, and an approval prompt that gives no hint which one it is.How it behaves
cd, environment prefixes or writes. Anything unusual simply isn't low. Low can be auto-approved in guarded mode with a new opt-in setting.rm -rf ~,git push --force,curl … | sh, writes to start-up files,sudo) and not understood ask in every mode, as a best effort. A command recognised as reading codeoid's or another agent's credential files is refused, as reading them with a file tool already is.The docs list the known ways a dangerous command can still be rated medium (and so run in autonomous mode). They are why the levels are guidance and #448 exists.
Verification
mainon this machine (a NodelocalStorageissue).Provenance: this branch was lost locally before it was pushed and rebuilt from the session record; its 4 original commits were reproduced with identical commit IDs before rebasing.
🤖 Implementation context (for agents / maintainers)
Files
src/daemon/shell/parse.ts— command-line parser on tree-sitter-bash (vendored WASM,web-tree-sitter@0.25), loaded at daemon start; records commands, wrappers (via,wrapped), substitutions, constructs, assignments and arithmetic.src/daemon/risk/shell-risk.ts—assessShellCommandSync; the existing rules, thennotLow/notLowCommand/lowOptions: low only when every check passes (LOW_PROGRAMS, static words, nocd, no assignments or constructs, workspace paths, non-writing redirects).TOOL_VARS(medium) vsCODE_VARS(unknown). Arithmetic is unknown only when the line taints the variable with code (codeInArithmetic).src/daemon/shell/tool-call.ts—shellCallOf; the output-compression wrapper is seen through only when the line is exactly codeoid's own rewrite, judged in its--cwd.src/daemon/session.ts— the gate (#shellRisk,#riskAsks); an unreadable shell call always asks.src/daemon/providers/claude/index.ts—#shellRiskDecision, fail-closed beside fix: a fork no longer takes over the background agents its parent started #428's#secretPathDecision.src/daemon/risk/effects.ts— rewind's outside-effects list.src/config.ts—approvals.autoAllowLowRisk(default false),approvals.askOnUnknown(default true).docs/approvals.md(levels as guidance, the allow-list, Limits), CHANGELOG, CLAUDE.md.Tests
src/daemon/risk/shell-risk.test.ts(rule table,EVERYDAY_LOW/EVERYDAY_MEDIUM, round-2 bypasses not low),src/daemon/shell/parse.test.ts,src/tests/session-risk-gate.test.ts,src/tests/rewind.test.ts,src/tests/provider-claude.test.ts,src/tests/compress-prehook.test.ts,e2e/specs/risk.spec.ts.Known gaps (docs "Limits")
cdthat fails or sits in a subshell is still followed; credential spellings viadd if=,/proc/$$/environ,file://URLs, globs over key directories; writes through programs not modelled as writers (sort -o,tar -C,patch, …); unmodelled command runners. All are medium, never low.Follow-ups
🤖 Generated with Claude Code