nodwyn.

ADR 0017: Standing command approvals

  • Status: accepted
  • Date: 2026-09-21

Context

shell-exec is irreversible, so every command needs a confirm. The dev tools nodwyn uses to build itself (go-test, git-diff, grep-repo) wrap shell-exec, which would mean a prompt for every test run — confirm fatigue turns "y" into a reflex, which is worse than no prompt.

Decision

The user may approve command lines ahead of time, by argv prefix ("go test", "git status", "rg"). Approvals live in three places: policy.allow_commands in config.toml (global), <repo>/.nodwyn/allow.toml (per repository, tracked), and "a" answers to a confirm prompt (this session only, not persisted). The guard checker CommandAllow turns a match into a standing Allow, which outranks any Confirm from another checker and never outranks a Deny. Every use is logged as an allow with the matching prefix as reason.

The default lists are read-only and test commands. There is no approval for anything that commits, pushes, deletes or reaches the network; those stay per-call confirms.

Amendment 2026-09-21: writes

The same shape applies to writes. writes = ["internal/", "docs/"] in .nodwyn/allow.toml (or policy.allow_writes) and "a" at a write prompt (the file's directory, session only) make fs-write/fs-edit under those prefixes a standing Allow; "t" at a write prompt approves every further write until the turn ends. A standing-approved write is not silent: its diff goes into the guard.decision record (detail) and the front-end shows it dimmed. Paths that change policy or run in CI — .nodwyn/, .github/, .gitea/, scripts/, Makefile — can never be standing-approved and always prompt. git remains the undo: review git diff before committing.

Amended 2026-09-27: .git/ is on that list too (hooks run at your next commit), and a write to any of these paths is a security confirm (guard.ProtectedPaths), judged after resolving absolute paths, .., case and symbolic links like the repository pin. An unattended run (--yolo) answers ordinary confirms but never security ones, so it cannot write them; before this, it could.

Amended 2026-10-02 (ADR 35): [approval] writes = "allow", the default, makes every write the pin lets through a standing approval (guard.FolderWrites), so a write prefix list matters only with writes = "ask". Protected paths and credential-shaped content remain security confirms. git is no longer the only undo: a write keeps the file's previous content unless git has it committed and unchanged. Commands are unchanged here: a standing approval still runs one without a confirm, and what no standing approval covers goes to [approval] commands (the judge, then the person, by default).

Consequences

  • This is the command-line analogue of the binding table (SPEC): the user authorizes ahead of time, the harness applies silently and logs.
  • The model can edit .nodwyn/allow.toml through fs-write/fs-edit; that is a confirmed write whose diff shows the new entry, and it takes effect only at the next session start.
  • "a" persists nothing: standing approvals that should outlive a session are written down by the user, where they can be reviewed.

Amendment 2026-10-02: "don't ask", a switch only a person reaches

The console offers one switch under the composer, Don't ask: approve writes and commands: while it is on, every confirm a standing approval could answer, an ordinary write or command (ApprovalOptions().Session), is answered yes as the person who turned it on, with scope all in the guard decision; the confirm pending when it is turned on is answered too. It installs no prefix. Security confirms (a write that looks like a credential, a command built from tool output), protected paths and every refusal are untouched: it answers questions, it never overrules the guard. MCP tools, uploads and egress keep their own questions.

The point is who can reach it. It is a field of the Go session with one writer, Session.SetApproveAll, called only by the daemon's session.approve_all with the authenticated peer as actor. No primitive reads or writes it, whatever the Scheme's origin: user origin is not enough, since a repository's .nodwyn/init.scm runs as user-origin Scheme. scripts/lint-boundaries.sh fails a build that calls SetApproveAll outside internal/frontend/rpc or touches the field outside internal/core/confirm.go. Turning it on is refused to a paired device (ADR 30), as starting a run that answers itself is, and to a session whose confirms are not asked of a person. Like "a", it persists nothing: it ends with the session, a resume starts it off, and each switch is an approval.change (kind: "all", off when it is turned off) in the log.

Amendment 2026-10-04: the switch's record names the ask it answered

A switch that answered an ask which was waiting records its approval.change with that ask's prompt_id, as every log event that resolves a prompt carries the prompt it resolved. Without it the console's Totals clocks read the switch's record as an event that did not end a wait, and the whole wait — the time the task sat waiting for the answer the switch then gave — was added to the working time, which made the working time match the running time at the answer and lost the split the panel keeps (reported on a session that answered a command's confirm with the switch).

Amendment 2026-10-05: confirmed approvals retain previews

Explicitly approved confirmations retain their existing preview in the final guard.decision.detail, as standing approvals already do. The Changes inspector needs the recorded preview rather than a reconstruction from a later file. Denials remain denials, and old records are not backfilled. This is an approval record before execution, never evidence that the operation succeeded.

Amendment 2026-10-05: "don't ask" is offered only when Settings says so

The switch answered too much too easily. In the 75 local session logs of 2026-09-30 to 10-05 it was on in 40 sessions, often within seconds of the first question, and 1,327 of the 1,329 confirms a person answered were yes. Under it, with every ordinary command answered as the person, models listed ~/.ssh, tested the private key for a passphrase and loaded it into a new ssh-agent to sign their commits (session 20261004T053047Z-1b4c67dc); read, through cat, a session log fs-read had refused two seconds before (20261004T073130Z-40187c24); committed with --no-gpg-sign (20261004T024748Z-c7491879); and ran git pull --rebase on the person's own checkout (20261002T204507Z-19a161b2). The guard's denylist checks the paths a file tool is given, not the words of a command line, so the switch turned the guard's questions off where they mattered most. It is still sometimes the right tool, for a task a person is watching.

So the switch is offered only when the person asks for it in Settings:

  • [approval] dont_ask (Settings › Approvals, "Don't ask in tasks"), off by default. settings.write takes it, and only the computer nodwyn runs on may call that (ADR 30); config.toml is the person's, so no repository and no Scheme turns it on.
  • While it is off, session.approve_all refuses to turn the switch on, and session.info says approve_all_offered: false; the console shows the switch only when it is offered, or while it is still on, so it can be turned off. Turning it off is always allowed.
  • Turning the setting off turns the switch off in every open task as well, each as an ordinary approval.change by the person who saved it: a person who takes the offer away is not left with tasks still approving everything. Turning it on changes no task until the person switches it on there.
  • While Settings offers it, the page shows a red warning in place of the hint: what the switch lets a task do, and what models did here with it, from the sessions above.

The switch itself is unchanged: what it answers, what it never answers, who may reach it and that a resumed task starts with it off.

Amendment 2026-10-05: a standing approval names the program, not the shell

The system prompt sends every pipe, redirect and chain through bash -c "<line>" (prompts.scm), and Prefix offered "Always under bash" for every such call, since -c looks like a flag. One "a" on any of them approved every command the session would ever run, which is "don't ask" by another door: three of the local sessions of 2026-10-02 to 10-04 granted bash within their first two commands (20261002T192800Z-920da7d8 at seq 1503, then git stash && go test; git stash pop and a python rewrite of a file the guard had refused ran unasked).

  • Prefix never suggests an interpreter: a shell, python, node, perl, ruby, env, xargs, sudo, ssh and the like run whatever their arguments say. A shell's -c line is suggested by the command it runs, when it runs one, piped into filters or not (go test ./... 2>&1 | tail -n 40 is "go test"); a line of several commands is suggested nothing, and its prompt offers no standing answer.
  • A standing approval covers a -c line only when every command in it is approved, joined by &&, || or ;, and every command a pipe feeds is one of head, tail, wc, sort, uniq or grep with flags that name no file (internal/guard/shellline.go). A substitution, a variable, a subshell or group, a background job, an assignment, positional parameters or a redirection to anything but /dev/null (or 2>&1) leaves the line to ask. So .nodwyn/allow.toml's "go test" now also covers bash -c "go vet ./... && go test ./x 2>&1 | tail -20", which asked before, and a shell's name in allow_commands covers no -c line.
  • "Don't ask" answers what is ordinary (Prompt.Ordinary: not a security confirm, not a protected path), not what a prefix could cover, so it still answers a line of several commands while it is on.

Amendment 2026-10-06: security review and project identity

ADR 45 supersedes the earlier behaviour where it differs: repository startup and approval files need an external user trust record; executable identity is resolved, credential transport is a user setting with secure defaults, keychain decoding and persistence are bounded, and project paths resolve symlinks consistently. Its compatibility rules apply to existing sessions, workspaces, notes and worktrees.

Amendment 2026-10-06: standing approvals review command arguments

At fcc5c42, an approved prefix covered flags that gave the command new authority. A guarded git diff --output=... wrote outside the repository with no prompt, and git diff --no-index ... /dev/null returned an outside file's contents. The reproduced guard cases also covered git's pager, external diff and text conversion, Go's execution and output flags, and make overrides.

We choose security confirmation by a person, rather than the ordinary judge-then-person path, when arguments would extend a standing prefix's authority. Selecting executable code or crossing a filesystem boundary should not depend on a model's judgement. The command is still available after the person approves that call; neither a standing approval nor an unattended approval setting supplies that answer.

  • Prefix and resolved executable identity remain necessary. A per-tool flag allowlist in internal/guard/command_arguments.go is also required. Flags selecting programs, output files, makefiles or working directories are absent; unknown flags ask. Go arguments forwarded to test binaries and git's --no-index, --ext-diff and --textconv cannot inherit approval.
  • A make variable assignment or an additional make target cannot inherit a prefix. Approving make test does not approve make test deploy.
  • Every argv word is checked for absolute and repository-escaping paths, including --flag=VALUE, nested values, paths with spaces and symlinks. Parent-directory components (..) always ask: cleaning them before resolving a preceding symlink could otherwise conceal an escape. Absolute executable paths also cannot inherit approval. Use the approved program name or an explicitly approved repository-relative executable.
  • Static shell wrappers and pipeline filters get the same path checks. Dynamic scripts, substitutions and variables retain the earlier ordinary confirmation and hard triggers; they never inherit a standing prefix. ArgvTrigger independently makes explicit outside paths, including flag values, a security confirm. Its exception for the session's scratch directory remains; scratch paths still cannot inherit a standing command approval.
  • A standing approval outranks only an ordinary confirm. Denies and security confirms continue to win. These rules apply equally to user configuration, trusted repository approvals and session-only prefixes.

The allowlist is deliberately conservative: a harmless but unrecognised flag asks until reviewed. It checks the command line, not everything the process will do. Builds and tests execute project code, write outputs and caches, and may use the network with the user's operating-system privileges. The original description of the defaults as read-only was incorrect. The file-tool denylist does not sandbox a child process.

Evidence: TestStandingApprovalsRespectArgumentBoundaries uses model origin, prim.Checkers and NewCommandAllowIn for direct and shell-wrapped commands. TestArgvTriggerSeesPathsInsideFlagValues exercises the independent checker. TestStandingGitCannotReadOrWriteOutsideTheRepository runs real git behind the guard with a non-credential-shaped canary, checking the outside output file and the Scheme result, output and log. All three tests compile against fcc5c42 and fail there. Existing scratch and judge tests preserve their confirmation behaviour; executable identity tests still cover a retargeted relative symlink.

Amendment 2026-10-10: the argv trigger reads a script word by word

On the long-context SWE-bench cohort (ADR 32, amended 2026-10-10), 223 of nodwyn's shell commands were refused in 94 of its 107 attempts, all as security confirms for a path outside the repository, which an unattended run cannot answer. 98 named the project environment's interpreter (/opt/miniconda3/envs/testbed/bin/python), which the system prompt tells the model to find and use; 48 were -c lines that shellLine would not split (a variable, an interpreter, a continued line) and that the trigger therefore read as one word, so PYTHONPATH=/testbed python \… was flagged as the path /testbed python \ though /testbed is the repository; a few were sed addresses (/^def polyval/,/^def /p) and a git log -L range. Each was answered by a way round the guard (conda run, a script, Python walking /opt), which the guard does not see at all. The same one-word reading hid paths: a redirection to a file outside, cat $(ls /outside), or cat "$X" /etc/hostname, asked nobody.

  • ArgvTrigger splits a shell's -c line with scriptWords (internal/guard/scriptwords.go) whatever it holds: variables, substitutions, subshells, background jobs, assignments and redirections. Each command's words and each redirection's target are judged on their own; a backslash before a newline joins lines, as in the shell; a here-document's body is data, as it was. A line it cannot split (an unclosed quote), or one with positional parameters, is read as one word, as before. shellLine still alone decides what a standing approval covers.
  • A command's program, or the program a wrapper runs (env, timeout, nice, nohup, time, exec, command), needs no question when it is in the bin folder of a Python environment, conda (conda-meta) or a virtualenv (pyvenv.cfg), and the program, its real folder, that bin folder and the environment's folder belong to root or the person and nobody else may write them (envExecutable). That is the authority a program on PATH has: its arguments are still read, the same path given as an argument is still asked about, and an environment another user could have put there is not let through.
  • sed's and awk's script, grep's, rg's and git grep's pattern, the values of their -e, and git log's -L range are not paths; the files they read (-f, the operands) still are. /dev/null and the other standard devices are no outside path.
  • The file tools' refusal of a path outside the repository and the scratch directory now says where a throwaway file goes, written as $TMPDIR/<name>: 68 writes were refused so in 56 attempts.

The checks a person answers are otherwise unchanged: a path outside is still a security confirm, never covered by a standing approval, the judge or an unattended setting. Evidence: TestArgvTriggerReadsAScriptWordByWord, TestArgvTriggerLetsAnEnvironmentsInterpreterRun, TestArgvTriggerReadsPatternsAsPatterns and TestRepoPinRefusalNamesTheScratchDirectory fail on d208b2c and pass with the change. TestTheJudgeIsToldOfAScratchScript named /etc/hostname in a line with a variable and expected the judge to answer; it now names a repository file, since the outside path is a question for a person.

Amendment 2026-10-10: what a command does with a path

Reading a -c line word by word (above) saw every path in it, and asked about more than before. Replayed through the trigger, the 2,874 shell commands of nodwyn's long-context benchmark attempts and the first canary of 85695c5 gave 442 questions, against 226 when they ran: 115 of those were gone, and 331 commands that had run unasked now asked. The 331 were the trigger seeing what the one-word reading had hidden, and nearly all of them found or used the project's environment: source /opt/miniconda3/etc/profile.d/conda.sh (89), ls /opt/*/envs and find / -maxdepth 6 -name python3.6, PY=$(ls -d /opt/*/envs/*/bin/python), reads of its installed packages, and for p in /usr/bin/python3 …; do $p -V; done. The canary of 85695c5 asked once in 36 commands, for P=/opt/miniconda3/envs/testbed/bin/python.

A path outside the repository asks a person because the command may read what is there, change it or run it. So the trigger now asks what the command does with its paths (access, internal/guard/scriptwords.go):

  • A program that shows only names and metadata (ls, stat, which, readlink, realpath, du, test, and find without -exec, -ok, -delete or -fprint) may name any path. Inside a substitution, whose output may become another command's arguments (cat $(ls -d /etc/*)), it may name only a Python environment's paths.
  • A program that only reads (cat, head, tail, grep, rg without --pre, wc, diff, sort without -o, …) may read in a Python environment, a conda or virtualenv folder only root or the person can change (inEnvironment, globs matched). sed and awk are not readers: their scripts can write and run commands.
  • source and . may run a script in such an environment, the script and its folder owned as the environment must be: conda's activation scripts.
  • A for loop's list and what echo and printf print are not file accesses.
  • An assignment, before a command or after export and its kin, names its paths: each element of a : list is judged on its own, and an environment's may be named (PATH=…/envs/testbed/bin:$PATH, PYTHONPATH=/testbed:/testbed/tests).
  • A program is always judged as a program: /elsewhere/ls is no names-only ls.
  • The scratch directory written with a trailing slash is the scratch directory. A refused /tmp path says where a throwaway file goes.

Everything else asks as before: a command that writes or runs what it names outside (cp, python3 /tmp/x.py, find -exec, sort -o), a read outside an environment (cat /etc/…), cd outside, and a redirection to a file outside. Replayed again, the 2,874 commands ask 39 times: /tmp paths, mistyped scratch paths, the repository's parent, /dev0 typos, a read of /etc/ld.so.preload, cd /, and three environment globs that matched nothing in the replay's stand-in tree. The model may now list any folder without the question, ~/.ssh included: names, never contents, which the person chose over a question on every search for the environment.

TestArgvTriggerAsksWhatACommandDoesWithAPath fails on 85695c5 and passes with the change. TestArgvTriggerLetsAnEnvironmentsInterpreterRun asked about cat on the environment's interpreter, a read now let through; it asks about cp over it instead.

Amendment 2026-10-16: the argv trigger reads the home directory as the shell does

The argv trigger joined every word that was not absolute to the repository, so cat ~/.aws/credentials, cat $HOME/.netrc and cd ~ read as paths inside it and asked no person, while the same path written in full got the security confirm. The standing-approval reader already refused these spellings, so the two disagreed. The trigger now spells ~, ~/…, $HOME and ${HOME} as the home directory and ~name as outside, before judging a word, and $TMPDIR and ${TMPDIR} as the session's scratch directory, which shell-exec gives as TMPDIR, so that spelling stays unasked. Other variables are read as written. The regression cases are in TestArgvTriggerReadsAScriptWordByWord.

Amendment 2026-10-16: the refusal of the repository's own absolute path says what to write

The rule is unchanged: no standing approval covers an absolute path, the repository's own included. The system prompt gives the model the repository's absolute path, and on commit-bench (dsl20r, 7513f7eb) an unattended run's cd <repository> && go test ... was refused for good with a reason that said only that the path was absolute. When the path resolves inside the repository (and has no ..), the reason now adds that the command already runs in the repository root, so the path is written relative to it (pathRisk, TestAbsoluteRepositoryPathRefusalSaysToWriteItRelative). Nothing else about the decision changes; a path outside the repository gets the reason it had.

Amendment 2026-10-19: what a command prints or lists is a path once another command takes it

The amendment of 2026-10-10 (what a command does with a path) let a for list and what echo and printf print be no file access, and let a program that lists names (ls, find) name any path outside a substitution. That holds while nothing takes the words for arguments, and a security audit of 2026-10-19 found six lines that make something do so, each reproduced with argvVerdict on 038d1d1 where OUT is outside the repository: cat $(echo OUT), cat $(printf %s OUT), echo OUT | xargs cat, for f in OUT/*; do cat $f; done and echo hi >&OUT all got no question, against the security confirm that cat OUT and cat $(ls OUT) got. A path outside is a question that neither a standing approval, the judge, "Don't ask" nor --yolo answers; through these forms it was none, and the model could read ~/.ssh or nodwyn's keychain and config through the shell. The same audit's cat "$(echo OUT)" was open for the same reason: a substitution inside double quotes was text, not a command.

  • What echo and printf print needs no question only when nothing takes it: in a substitution (quoted or not, or backquoted), before a | (|& included; || feeds nothing) or in a line where a command reads its input for arguments (xargs, parallel, read, mapfile, readarray; the line, not the pipe, since { echo OUT; } | xargs cat and echo OUT > f; xargs cat < f are as much a list of paths). Otherwise its words are judged as a read's are: a Python environment's may be named.
  • A names-only program stays unasked outside a substitution, but not in a line with such a reader (find OUT | xargs cat lists full paths), where it is judged as in a substitution. find / -name python3.6 | head is unchanged.
  • A for list is judged: its body uses each word as a path or a program. A Python environment's paths, matched as globs, and the binaries isSystemBinary knows (/usr/bin/python3) may be listed, which is what the benchmark's loops over interpreters did. A glob that matches nothing is no longer let through.
  • >&FILE and >& followed by a word are a redirection to that file, as &>FILE; only >&2, 2>&1 and >&- are descriptors.
  • A substitution inside double quotes is a command of its own, $(…) or backquoted, and is read as one outside quotes is.
  • The network tools and the hard-trigger patterns are looked for in each command of a -c line, the programs wrappers run included, as well as in the argv as before: bash -c "curl …" named no tool, and git push matched no pattern spelled with one space. This only adds questions (plain confirms, answered as any is).

Not read, and still open: the trigger reads words, not code. An interpreter's program (python3 -c "open('/etc/hosts')"), a nested sh -c or eval string, a here-document or here-string handed to xargs, a path written to a file one command and read by another, and find OUT | python3 reader.py name a path the words do not spell. The guard is not a sandbox; an operating-system one is the answer there.

Evidence: TestArgvTriggerReadsWhatFeedsAnotherCommand and TestArgvTriggerFindsNetworkToolsAndPatternsInAScript fail on 038d1d1 (every bypass above, and the refusal where a lean session nobody approves may use /tmp) and pass with the change; the first also pins what stays unasked: echo OUT, for over repository files and interpreters, ls OUT | head, >&2. The existing argv trigger tests pass unchanged.

Amendment 2026-10-18: in sandbox mode only the denylist's directories are outside (ADR 55)

A run started with --sandbox container declares the machine its disposable sandbox. There the argv trigger judges a command line's paths against the session's credential denylist alone: a path in one of its directories (nodwyn's configuration and data, ~/.ssh, ~/.aws and the rest), as the system reaches it, is refused outright, and every other path is inside. Programs, flag values and glob places are judged as everywhere else, and so are the words the amendment above makes paths, what echo, printf, a for list or a names-only program hands to another command: cat $(echo ~/.ssh/id_rsa) is refused as cat ~/.ssh/id_rsa is (TestSandboxModeJudgesWhatFeedsAnotherCommandByTheDenylist, which fails on sandbox mode's branch before it met that amendment). The network tools and hard-trigger patterns still ask, and the run answers them. Every other session is judged as above.

Amendment 2026-10-19: a nested shell's script and eval are command lines of their own

The amendment of 2026-10-19 above (what a command prints or lists is a path once another command takes it) left one route open that the same audit's probing found: the string after a nested shell's -c is one word to the argv trigger, and a word that begins with neither / nor ~ reads as a path relative to the repository. bash -c "sh -c 'cat /etc/hosts'", env sh -c 'cat /etc/hosts', sudo sh -c 'cat ~/.ssh/config', find . -exec sh -c 'cat /etc/hosts' \; and eval "cat /etc/hosts" all got no security confirm, against the one cat /etc/hosts gets; sudo only asked its own plain question, which "Don't ask" and --yolo answer. The same word-as-path reading flagged a script that began with a path, as sh -c '/usr/bin/python3 -V; echo hi', for the path /usr/bin/python3 -V; echo hi.

  • withNested (internal/guard/scriptwords.go) reads the string a command hands to a shell as a command line of its own, with scriptWords, and puts its commands after the command that runs it. The shell is any word of the command that names one (sh, bash, zsh, dash, ksh), so a wrapper (env, timeout), sudo, xargs and find -exec reach it as the top-level shell does; the script is the first operand after the shell's options when one of them holds -c (-c, -lc, -ec; -o, +o, -O, --rcfile take a value). A shell with a file before its -c runs the file and hands over nothing.
  • eval among a command's programs joins its words with spaces and reads them again, as the shell does.
  • The words that make such a string are not paths of the command that holds them: its commands stand for them, so the script is judged by what it does with its paths, an environment's interpreter or a system binary at its head included. A string scriptWords cannot split is still a word.
  • An inner command is in a substitution, or feeds a pipe, when the command that runs it does (cat $(sh -c 'echo /etc/hosts'), sh -c 'echo /etc/hosts' | xargs cat), and the network tools and hard-trigger patterns are looked for in it too.
  • Every path of such a script goes through the same judgement as any other, so in sandbox mode (the amendment of 2026-10-18 above) sh -c 'cat ~/.ssh/id_rsa' is refused as cat ~/.ssh/id_rsa is, and sh -c 'cat /etc/hosts' goes ahead there.
  • The same applies to a shell that is an argument of a program that is not a shell: docker exec c bash -c 'cat /etc/hosts' names /etc/hosts as docker exec c cat /etc/hosts always did.

Still not read: an interpreter's program (python3 -c), a here-document or here-string handed to xargs, a path one command writes for another to read. TestArgvTriggerReadsANestedShellsScript and TestSandboxModeJudgesANestedShellsScriptByTheDenylist fail on 5a33587 (32 of their lines) and pass with the change; the other argv trigger tests are unchanged.

Amendment 2026-10-23: "don't ask" answers git-land

Prompt.Ordinary also holds for a git-land confirm that is not a security confirm, though it names no path or command line, so "Don't ask" lands a worktree task's work unasked (ADR 34, amended the same day). No other call without a path or command line is answered by it.