A grant never reaches its own key
Nothing the agent runs can read the provider keys exarch uses to call its model. A profile decides what the agent may touch on the user’s machine, but no profile has to remember to exclude these keys; the guards below sit beneath every profile. The few places they stop are listed at the end.
A key can be reached in two places: on disk, and inside the process holding it. Each place has its own guard. Neither guard depends on a profile remembering it.
On disk: denied by composition
Most profiles (reasonable, read-only, edit-only) read `xdg-config . and
`xdg-state . whole, on purpose. Tools break without their configuration, and the
profiles accept that risk by name, denying gh, op and gcloud by hand. Our
own credentials sit inside that region:
$XDG_CONFIG_HOME/<app>/keys.json, the keychain fallback, which is the normal path on any host with no Secret Service (provider/keychain.rs);$XDG_STATE_HOME/exarch/oauth.json, a ChatGPT login’s tokens (provider/oauth/).
Unguarded, a grant that leaves the network open could exfiltrate them with cat. So
provider::credential_files names every such file for every product on the
engine (bootstrap::APPS). policy::for_invocation then denies them after
composition, as its own layer, on the same footing as a --restrict file’s own
path (policy). No profile can forget the deny, no
--extend can widen past it, and every spawned child’s layer can only
narrow beneath it (a-spawn-is-one-layer).
Synod’s files are named by the engine (bootstrap::SYNOD), not by synod. The
deny and the directory therefore cannot drift apart. Synod’s own agent runs in a
guest that mounts no host directory, but exarch started directly on the same
machine would otherwise read synod’s keys.
The deny names files, not directories. $XDG_CONFIG_HOME/exarch/ also
holds the operator’s AGENTS.md and the global skill roots, which the agent is
meant to read (exarch-config-dir).
In the process: blanked, then sealed
A key handed over in the launch environment is read once, at single-threaded
startup, by CredentialStore::resolve_and_scrub (exarch
§Credentials). Removing the variable from the environment is not enough, for
two reasons:
- The bytes outlive the variable.
unsetenvdrops libc’s pointer, but/proc/<pid>/environreads the memory the kernel laid down atexecve. exarch evaluates a tool call in its own process, so< /proc/self/environnames exarch itself, and one redirect used to print every key. Each value is now overwritten in place before it is unset. - The store holds the key. The resolved keys live in exarch’s heap. Under
ptrace_scope=0, any same-uid child could read/proc/<exarch-pid>/mem. So the process makes itself non-dumpable first (PR_SET_DUMPABLE, Linux). This hands/proc/<pid>/{environ,mem,maps}to root and refuses the session, its children and every other same-uid process alike.execverestores dumpability, so children are otherwise unaffected.
On macOS there is no /proc, but sysctl(KERN_PROCARGS2) (what ps eww
reads) shows a same-uid process the same launch bytes, so blanking closes it
too; the heap is already guarded, since task_for_pid is refused to ordinary
processes.
This guard holds under every grant, dangerous included, because it does not
depend on a policy.
Where it stops
dangeroushas nofspolicy to carry the file deny. It attenuates nothing by contract, and installing a policy only to hold the deny would confine a session that asked not to be. The credential layer is pushed wherever some fs restricts: name a--restrictfile, or extenddangerouswith an fs exclusion, and the deny comes with it; a positive-only fs restricts nothing and gets none.- The vault is reachable from an unconfined session. The macOS profile
admits no
securityd, and the Linux envelope binds no session bus unless a grant reads/run/user. Outside those, the credential manager answers any process of the user’s (provider). - Root inside a user namespace defeats the seal. A non-dumpable process’s
/procfiles belong to its namespace’s root, which is exarch itself if it runs as that root. The bytes are still blanked, but the heap is open. - Session logs are readable on purpose. Asking an agent to read logs is
common, so
`xdg-state .stays open. A key reaches a log only if the agent could read it first, and the two guards above close that; anything a user pastes into a session can be read by later sessions. - Windows has no seal. Any process of the same user can open exarch for
PROCESS_VM_READand read its environment block and heap; a process DACL cannot stop its own owner. A confined child cannot: it runs in an AppContainer, which a process’s default DACL does not admit. An unconfined session can.