Security boundaries and user choices
The implemented boundaries are the Go guard, user-owned configuration, opaque credential sinks and redacted event ingress. Scheme chooses the agent's behaviour; it does not decide whether a refused host operation becomes allowed. This is a coding agent running with its user's operating-system account. Approved commands can execute project code with that account's privileges. The guard and secret scanner are not an operating-system sandbox, and scanning does not detect every unknown, encoded or transformed secret.
Repository startup is a trust decision
A repository's author supplies .nodwyn/init.scm and .nodwyn/allow.toml. A clone
cannot authorise either merely by containing those files. Ordinary startup skips
both and reports how to review them. To enable a project's extensions:
nodwyn trust --repo /path/to/project
nodwyn trust --repo /path/to/project --accept REVISION_FROM_THE_REVIEW
The first command only displays the files and their combined revision. The second refuses if either file changed. Review files and scripts the init loads or invokes too: the revision covers the two authority files, not all transitive project code. Trust allows init to run with user origin. It still crosses the guard; startup confirms are denied, but an applicable standing approval may permit an action.
trusted-projects.json lives beside config.toml, outside the repository and
protected from the guarded file tools. There is no Scheme primitive to grant
trust. Exact checked bytes are used for startup; adding, editing or removing an
authority file requires a new review. The same check applies on resume and reload.
The optimiser retains an edited init as a proposal until it is reviewed. A managed
worktree inherits its main project's trust only when both files match. Embedders
that explicitly set Go's Options.InitFile authorise that file themselves.
nodwyn trust --repo /path/to/project --revoke
Revocation affects future startup, resume and reload. Close and restart existing tasks to discard code and grants they already loaded. Trust does not revoke an already-running process or make arbitrary approved shell commands safe.
Executable approvals
git status approves the resolved executable selected for git and that argv
prefix, subject to argument checks. tools/git status is a separate command
even when its basename is git; an explicit repository-relative executable can
be approved separately. Resolved identities are captured when approvals are
installed, so retargeting a symlink does not transfer an existing approval.
Shell wrappers and pipeline filters must also resolve to host tools. A bare PATH
name inside the repository cannot inherit a system-tool approval.
A standing approval covers only flags in the tool's allowlist in
internal/guard/command_arguments.go. Unknown flags need review. Flags that
select another program, an output file, a makefile or a working directory are
excluded, as are git's --no-index, --ext-diff and --textconv, make variable
assignments and additional make targets. Every argument is checked for absolute
and repository-escaping paths, including values inside --flag=VALUE, nested
flag values and symlinks. Parent-directory components (..) conservatively need
review because resolving a symlink before .. can escape a cleaned path.
This also excludes an absolute path to the approved
executable: use its approved name or a repository-relative path.
When a prefix would otherwise cover unsafe arguments, the command needs a
security confirm from a person. Neither a standing approval, the judge nor
an unattended approval setting answers it. A statically parsed shell command
gets the same checks. Shell scripts with variables or substitutions cannot
inherit standing approval and retain their ordinary confirmation and hard
triggers. ArgvTrigger independently asks a person about explicit outside paths,
including flag values; the session's scratch directory retains its exception.
The home directory as the shell spells it (~, ~/…, $HOME, ${HOME},
~name) counts as outside, and $TMPDIR as the scratch directory; any other
variable is read as written.
It reads a shell's -c line word by word, variables, substitutions, subshells
and redirection targets included, so a path anywhere in the line is seen; a
line it cannot split is read as one word. A program in a Python environment's
bin folder (conda or virtualenv) that only root or the person can change
runs without the question, as a program on PATH does, and sed, awk, grep and
rg scripts or patterns and git log -L ranges are not read as paths (ADR 17,
amended 2026-10-10). What the command does with a path decides the rest: a
program that only lists names and metadata (ls, find without actions,
stat, which) may name any path, except inside a substitution or in a line
that reads its input for arguments (xargs, parallel, read); one that only
reads (cat, grep, head) and source may use paths in such an
environment; what echo and printf print is no file access, unless it is in
a substitution (quoted or not), goes into a pipe or the line has such a reader;
a for list may name only a Python environment's paths and the system's
binaries, since its body takes each word as a path; and an assignment's
:-separated paths are judged one by one. >&FILE is a redirection to FILE,
as &>FILE is; >&2 and 2>&1 are descriptors. A command that writes or
runs what it names outside, a read outside an environment and cd outside still
ask. The model may therefore list any folder unasked, names but not contents
(ADR 17, amended 2026-10-19). The network tools and the hard-trigger patterns
are looked for in each command of a -c line as well as in the argv, so
bash -c "curl …" and git push ask as curl and git push do. The string
a shell is given with -c, wherever the shell is in a command (behind env,
sudo, xargs or find -exec too), and what eval joins are command lines of
their own, read as the top-level one is, nested ones included
(ADR 17, amended 2026-10-19, nested shells).
The trigger reads words, not code. A path inside an interpreter's program
(python3 -c), in a here-document or here-string handed to xargs, or in a
file a command wrote earlier and another reads, is not seen.
These checks inspect argv and executable paths, not binary signatures or the behaviour of an approved process. Builds and tests execute project code with the user's operating-system privileges, can write files and can use the network. The file-tool denylist does not mediate those processes' file access.
The denylist judges the file a path reaches, not only the name a call uses: a
link is matched by its own name and by the name of the file it points at, so
notes.png linked to .env is refused as .env is, in every file tool and in
the images doc-export embeds. Names are matched ignoring case, since macOS's
file system opens .env for .ENV.
Sandbox mode
nodwyn run --sandbox container (ADR 55) is the person's declaration that
the machine is a disposable container whose every file is the run's. Only
that flag turns it on: no configuration key, repository file, Scheme or
model call can. It changes four checkers and nothing else. The repository
pin allows every path. ArgvTrigger judges a command line's paths only
against the denylist's directories (nodwyn's configuration and data, the
credentials directory a service manager gives the daemon, ~/.ssh,
~/.aws, ~/.gnupg, ~/.config/gcloud, ~/.azure, ~/.kube), each as
the system reaches it, links followed before .., and refuses a line that
names one outright; network tools and the hard-trigger patterns still raise
their ordinary confirms, which the run answers yes. Of the protected paths
only .nodwyn/ and .nibb/ still need a person, so a write there is
refused. The denylist, its credential-file names, L1, taint, egress
bindings, ingress redaction and the scrubbed child environment are what
they are in every other mode, and security confirms are declined. What a
command starts in the background is not stopped, so it outlives the call,
the session and, if it does not write to the daemon's pipe after that, the
daemon. None of this is a sandbox nodwyn provides: the container is.
Credentials and HTTP
Credential injection into a header, query or body requires HTTPS by default.
A key injected into a query is part of the request's address, and a send that
fails (a refused connection, a timeout) is reported with the address stripped
of its query, so the key never comes back in the call's error, the log or the
model's context. A server that repeats the query in its reply is not covered:
prefer a header position when the service offers one.
HTTP is accepted for localhost and literal loopback addresses. For a deliberately
insecure remote service, set this in the user's config.toml:
[policy]
allow_insecure_http = true
The same switch is in Settings › Keys & devices › Credentials over remote HTTP. False is the default. New and resumed tasks take the saved value; existing tasks keep theirs. Provider setup, tests and model-list requests use the current user setting. On HTTP, anyone able to intercept the route can read or change credentials and content. A binding is still required, redirect responses remain refused, TLS certificate checks remain enabled, and ChatGPT plan credentials retain their special fixed sinks. This switch does not relax separate MCP, OAuth endpoint or Google upload URL validation. Requests without credentials are governed by their own existing endpoint rules.
Workflows: what the daemon asks, and what it sends
A workflow (ADR 53) makes the daemon send requests nobody typed at the moment they go out, so the boundaries are stated here.
- Who makes one. A workflow, its source and its answer are made, changed
and removed on this computer, never from a paired device (the daemon refuses
it, whatever the console's tier allows), which may only see it, pause it and
ask it to check now. Its
definition is a record in nodwyn's data directory (
workflows/<id>.jsonl), written by the daemon that holds the scheduler lock; nothing in a repository writes one, so a stranger's clone cannot add a workflow or change a source. - How a source is asked. A poll of a forge or Gmail goes through
core.Keyed.Authorize, the check a task's own requests meet: the key goes only where the binding table allows that credential at that host for that method, within the scopes it was authorized for, over HTTPS unless Settings allows plain HTTP, with an OAuth2 token refreshed first and no redirect followed. A poll lists pull requests or the newest messages and nothing else (GET); a host without a binding is refused and said on the workflow. - What an event is. What a pull request or an email says is attached to the run as a file and framed as data. The instruction is the workflow's own words, so a description that says "ignore your instructions" is a quoted description. The event passes the workflow's filters, which read its fields, before any model is asked.
- What can be sent out. Only a pull-request review comment and a saved Gmail draft, and never from the run: the model has no way to send, and the run's answer is held in the outbox until a person posts it (or the person turned on Without asking for that workflow, on this computer). The host posts it with a key bound for that one call, which needs its own POST binding. A review is never an approval or a request for changes; a draft is never sent; header values are stripped of line breaks.
- A command source runs a program the person named, with no shell, as an
absolute path or a name on
PATH(a relative path into the folder is refused, since a repository would choose what runs), in the workflow's folder, for at most a minute, with its output capped. It inherits the daemon's environment. It is a program of this person's, run on their word when the workflow is saved here, not something an event can choose; the guard's approvals do not apply to it, which is why only this computer may save one. - Limits. At most 20 events at one check (more pauses the workflow), a number of runs at once, and a spend limit per run and per day, so a hostile source cannot start unbounded work.
These paths have been exercised against fakes of Gitea, GitHub and Gmail, not
the real services. The exfiltration suite (make exfil) covers the guard and
the keychain; it does not drive a workflow.
Keychain input and persistence
The existing XChaCha20-Poly1305 file format and default Argon2id parameters remain. Before deriving a key, readers require a regular, non-symlink file of at most 16 MiB, a complete header, nonce and authentication tag, and bounded parameters: time 1–10, threads 1–16, memory 8 KiB per thread through 256 MiB, and time times memory at most 512 MiB. Invalid parameters return errors; they cannot request unlimited memory or zero-round Argon2 work. A previously custom keychain outside these supported bounds is refused, not silently rewritten.
Writers use a unique exclusive 0600 sibling, sync it, rename it, then sync the
parent directory. A planted keychain.tmp is unused. Cooperative changes retain
the sibling lock and reread-before-change rules. For local entry changes and
passphrase changes, a failure before publication restores the previous in-memory
state. OAuth refresh retains a received token in memory when saving fails, because
the provider may already have invalidated the previous token, and keeps it across
rereads of the file until a save publishes it; the refresh reports the save error. A directory-sync failure after publication
is reported as saved with uncertain durability; memory keeps the newly published
key. This protocol reduces crash-loss windows; tests do not simulate power loss.
A keychain without a passphrase remains only as private as the file and its backups.
Existing project data
Folder identities resolve symbolic links, including macOS /tmp and /var
aliases. Registrations, session summaries, MCP workspace selection, kept file
versions and notes use that identity. Existing logs and version indexes are read
compatibly. On startup, old notes named by the requested path or recorded sessions
are migrated, leaving notes.md.before-path-migration beside the old file.
Different note contents require manual reconciliation. Duplicate registered
aliases require choosing one entry and its intended mounts; permissions are never
silently combined. Symlinks should continue to point to the same project during
this migration; unknown historical aliases cannot be recovered from a path hash.
The detailed decision and validation cases are in ADR 45.