agent6 treats the LLM as untrusted.
Report privately through GitHub's Security tab.
Include: agent6 version (agent6 --version), kernel + distro (uname -a, /etc/os-release), agent6 check sandbox output, and a minimal repro (ideally a failing test under tests/security/).
Adversary: a fully malicious worker model, or an honest model prompt-injected by a file in the workspace.
The adversary controls the text of every LLM response and the content of any file the agent reads. It also chooses the tool calls and their arguments, within the published JSON schema. Outside its control: the kernel, the agent6 binary, the provider endpoints.
Holds
- No writes outside the workspace
sandbox.extra_write_pathswidens it, visible inagent6 config show- the per-repo memory dir (file tools only) and the jail's persistent
HOME(below) widen it, visible inagent6 check sandbox - the run's background-command log dir, in its session dir under the per-repo state dir, widens it for jailed commands
- where
sandbox.isolation = "auto"resolves topartialon Landlock ABI 1-2, truncation outside write grants is unconfined (warned)
- No reads outside the workspace and a read-only system set for installed toolchains
fullsandbox.isolation:/usr,/bin,/sbin,/lib,/lib64and/etc/alternativesbound read-only into an otherwise empty rootfs, plus a curated/dev: the nodesnull,zero,urandom,randomandfull, a private/dev/shm, and a private devpts (/dev/pts,/dev/ptmx) so a command can allocate a pseudo-terminal that reaches no host terminalpartialsandbox.isolation: Landlock grants/usr,/bin,/sbin,/lib,/lib64,/etcand the/devnodesnull,zero,urandom,randomandfull/proc, the host's/dev/shmand the host's/dev/pts(the operator's own terminals) are not granted:ps, Pythonmultiprocessingand pseudo-terminal allocation fail there, and work underfullsandbox.isolation
- the operator's tool dirs (Sandbox) and
sandbox.extra_read_pathsadd more;agent6 check sandboxprints the resolved set - a child on the host network (
network = "host", or anysandbox.isolationbutfull) also reads what name resolution and TLS verification need, where the host has them:/etc/resolv.confand the file it points to,/etc/hosts,/etc/nsswitch.conf, and the CA store (/etc/ssl,/etc/pki,/etc/ca-certificates). File permissions still apply: the root-only private keys beside the certificates stay unreadable to a run that is not root
/tmpis writable at every levelfullsandbox.isolation: a private tmpfs discarded with the runpartialsandbox.isolation: the host's/tmp
HOMEis a directory of agent6's own, never the operator'sfullsandbox.isolation:/tmp/agent6-home, created inside the private tmpfs and gone with the run;[sandbox].home = "cache"swaps in the persistent dir below, bind-mounted read-write at its real pathpartialandnonesandbox.isolation:$XDG_CACHE_HOME/agent6/homes/<repo-id>(default~/.cache/agent6/homes/<repo-id>), one directory per repository, created0700by agent6 and, underpartial, Landlock-granted read-write like the workspace; every other repository's directory stays ungranted- a linked worktree (
run --worktree, a fork) gets its repository's directory, named by the git dir the session recorded and never by the worktree's own.gitpointer; a lane's clone and a machine state's clone get it too, named by the first directory below the workspace base directory, the owning repository's id - a symlink, another user's directory, a mode open to group or others, or a path inside agent6's private dirs at that location refuses the run.
The mode is checked on every run (a jailed command can
chmodits own HOME), and the refusal nameschmod 700; nothing restores it silently
- agent6's own git never pushes, force-pushes, rewrites history, or
reset --hard(Git)- a
gitthe model runs throughrun_commandis bounded by the sandbox instead:protect_gitkeeps.gitunwritable underfullsandbox.isolation, and push needs egress
- a
- No persistence after the run: no daemon, cron,
.bashrcwrite, or setuid binary (one create path stays open, below)- the exception is the jail's persistent
HOME(partial,nonesandbox.isolation, or[sandbox].home = "cache"): a file or binary written there reaches the next jailed run in that repository, and stays executable. The operator's own shell, tools, and dotfiles never read it - chmod-family syscalls (
fchmodat2included) deny modes carryingS_ISUID/S_ISGID; ordinary chmod passes - so do the create paths that take a mode:
creat,mknod/mknodat, andopen/openatwithO_CREATorO_TMPFILE.openat2carries its mode behind a struct pointer, out of seccomp's reach, so a command can plant the bit through it. Every jail mount isnosuid, and the file is owned by the operator's own uid, so outside the jail it grants no more than that uid (--allow-rootbelow is the exception) - every mount carries
nosuidandnodev, except the bound/devnodes (the builtin five:null,zero,urandom,random,full, plus anysandbox.extra_device_pathsgrant), whichnodevwould make unusable, and the private devpts, which carriesnosuidandnoexec /tmpallows exec (toolchain helpers)- children write inside the jail's mount namespace (
fullsandbox.isolation) or the Landlock write grants (partial) - a process a command leaves behind dies with the run at the latest
fullsandbox.isolation: the PID namespace takes the tree down at close. Its launcher, the namespace's init, sweeps what a stopped background command left outside its process group (asetsiddaemon reparented onto it). The sweep covers what appeared after the command started and before the next still-running one didpartialsandbox.isolation: the agent process holdsPR_SET_CHILD_SUBREAPERand kills every process that appeared during the command
- a survivor the sweep cannot kill fails the command, and a background
stopanswers with its pids - one still running when the run's jail session or a spawned MCP server closes is recorded as a
jail.degradedevent
- the exception is the jail's persistent
Does not hold
- Under
nonesandbox.isolation, a command runs with the operator's own access: the filesystem and persistence bounds above do not apply, though agent6 still setsHOMEto its own directory (Sandbox) - The agent process's own egress is unbounded
- agent6 reaches the configured providers (each
[providers.*].base_urlhost, plus the fixed ChatGPT OAuth authority and each[providers.*].oauthissuer for token grants); nothing stops the process reaching elsewhere - a jailed command's egress is bounded under
fullsandbox.isolationunlesssandbox.network = "host"(Network)
- agent6 reaches the configured providers (each
- Under
partialsandbox.isolation, a command can hand work to a user daemon already running (tmux,systemd --user): pathname unix sockets have no Landlock hook and stay nameable without a mount namespace. Abstract unix sockets are scoped to the jail on Landlock ABI 6 (Linux 6.12); an older kernel leaves them reachable, and the run warns- the environment carries no address for one.
A jailed command gets
LANG,LC_ALL,TERMandCIfrom the host, plusHOME,PATH,PYTHONDONTWRITEBYTECODEandUV_NO_SYNCset by agent6 (a machine tool alsoAGENT6_MACHINE_DATA_DIR), and the[git.commit]name and email asGIT_AUTHOR_*andGIT_COMMITTER_*when the config sets them. A jailed MCP server's base dropsDISPLAY,WAYLAND_DISPLAY,DBUS_SESSION_BUS_ADDRESS, andXDG_RUNTIME_DIR(curated_env(desktop=False)) fullsandbox.isolationdoes not expose them; undersandbox.network = "host"a daemon on an abstract unix socket (an X server) is scoped out on Landlock ABI 6 and reachable below it, warned
- the environment carries no address for one.
A jailed command gets
The agent's Python process is trusted and runs unconfined at every isolation level; the model is untrusted.
- the process holds the provider keys, writes the per-repo state dir, spawns the jail
- isolation levels differ only in which jail features the launcher enables
The model reaches the machine through two surfaces:
- The in-process tools, whose paths resolve against the file boundary (File access).
- Anything that executes, which runs in the jail (Sandbox).
The model sees the fixed tool set in src/agent6/tools/schema.py, plus the tools of operator-configured MCP servers when [mcp].enabled is set.
- structured edits, read-only navigation,
read_session,list_findings(the run's own review findings), fixed-argv verify and metric commands,finish_session(finish_planningin plan mode),ask_user, a curator task notepad,use_skill(Skills), approval-gatedfetch(Network), capability-gatedrun_commandwithread_background/stop_background, andagent6_docsunderagent6 askonly - no
shell, nowrite_file, noeval - adding a tool needs a security review note (AGENTS.md)
Under backend = "claude_code" the model runs inside the operator's installed Claude Code binary, unjailed, as the operator, with every built-in tool off (--tools "").
Its only reach into the machine is agent6's tools served over the sdk MCP tunnel, so the dispatcher, the jail, and the approval gates are unchanged.
The binary holds the operator's Claude login exactly as an interactive claude does, and agent6 never reads it.
Every line the CLI prints to the operator's terminal, on stdout, stderr or /dev/tty, passes one scrubber (terminal.scrub_terminal_output).
It drops a control sequence inside text the model influenced (a file name, a commit subject, a summary, a task a plan wrote), so the model cannot write the clipboard (OSC 52), retitle the window or forge a line.
- SGR styling is the allowlist, conceal excepted; an approval prompt drops every sequence from the text under judgment
- the spinners and the interactive composer write their own erase and cursor sequences under the wrapper, the composer with its rows scrubbed
- the ACP and MCP stdio transports write protocol bytes to their peer, not a terminal; the TUI and the web render text as text
agent6 execis the exception: the operator's own command writes the terminal unscrubbed, soagent6 exec cat FILEprints whatever sequences a model-written file holds- with stdin and stdout on a terminal the command runs on a pty of its own and agent6 relays the bytes, so it never holds the operator's terminal device. The pty is the controlling terminal of the command's session, and the launcher puts the command's process group in its foreground before the command starts. A command the pty stops is resumed by the launcher, which first puts its group back in the foreground
- otherwise it inherits agent6's stdin, stdout and stderr as they are, a terminal among them included: it can change that terminal's modes, which agent6 puts back when the command ends
- it runs in a session of its own, so an inherited terminal is never its controlling terminal: the kernel refuses it
TIOCSCTTYon that terminal, and it takes no foreground there
Every tool call that allows the model to run arbitrary commands (run_command, run_verify_command, run_metric_command, backgrounded commands, and spawned MCP servers without unconfined = true) runs in a jail at the effective isolation level.
agent6 check mcp and agent6 mcp add start a spawned MCP server under that jail with the repository bound read-only.
check mcp applies the run's network and config refusals first and leaves a server it cannot hold that way unstarted: unconfined = true, a write grant (the server's extra_write_paths, sandbox.extra_write_paths, sandbox.extra_device_paths), or no jail at all.
mcp add probes under the same jail, and skips the probe only when there is no jail at all or a run would refuse the config, saying so.
It is the operator's own invocation naming the server they are adding.
Modes ([sandbox].isolation)
| Mode | Applies |
|---|---|
full |
user namespace (one uid, the operator's own) + mount + PID + IPC + UTS namespaces (hostname agent6), a network namespace unless sandbox.network = "host", pivot_root rootfs, private /proc and /tmp, curated /dev, Landlock where the kernel has it, seccomp, NO_NEW_PRIVS, capability drop, timeout |
partial |
Landlock, seccomp, NO_NEW_PRIVS, capability drop, timeout. No namespaces, no rootfs |
none |
No confinement: no namespaces, Landlock or seccomp. The timeout and memory_limit_mib still apply |
auto (default) |
The strongest of the three this host supports |
Under full sandbox.isolation a kernel without Landlock skips that layer, warned once per run; under partial Landlock is the only filesystem boundary.
Resolution
Probed per host: one full jail launch, the Landlock ABI syscall, the seccomp filter's architecture (x86_64 and aarch64).
A failed trial launch makes full unavailable; the refusal and automatic downgrade reason quote its failure text.
That failure does not necessarily mean user namespaces are blocked.
| Setting | Host | Effective |
|---|---|---|
auto |
namespaces | full |
auto |
no namespaces, Landlock ABI >= 3 | partial, warned |
auto |
no namespaces, Landlock ABI 1-2 | partial, warned: truncation outside write grants is unconfined |
auto |
no namespaces, no Landlock | none, warned |
auto |
non-Linux, or no seccomp filter for the arch | none, warned |
auto |
no agent6-jail launcher installed |
none, warned |
full |
no namespaces | refused |
partial |
Landlock ABI < 3 | refused |
full, partial |
non-Linux, or no seccomp filter for the arch | refused |
full, partial |
no agent6-jail launcher installed |
refused |
none |
any | none, warned |
--disable-sandbox and AGENT6_DISABLE_SANDBOX=1 force none sandbox.isolation for one invocation.
Config, flag, and env var are operator-only; the model reaches neither argv nor env.
Inside a container the same probes decide the level.
Measured 2026-10-27 on an Ubuntu 26.04 host in mcr.microsoft.com/devcontainers/base:ubuntu, sandbox.isolation = "auto" resolves to:
partialunder Docker 29.1.3, andfullwith both--security-opt seccomp=unconfinedand--security-opt apparmor=unconfinedfullunder rootless Podman 5.7.0partialunder rootful Podman 5.7.0, andfullwith--security-opt apparmor=unconfined
On Ubuntu (kernel.apparmor_restrict_unprivileged_userns = 1), full under Docker or rootful Podman also needs agent6 system apparmor install on the host or that sysctl at 0; rootless Podman does not.
Each --security-opt flag on this page turns off a protection of the container itself (its seccomp filter, AppArmor profile or /proc masks) for every process in it.
agent6 check sandbox names the level and what blocks a stronger one.
Inside the jail
- Mounts (
fullsandbox.isolation): cwd and a private/tmpwritable, system paths read-only,extra_read_paths/extra_write_paths, a persistentHOMEunder[sandbox].home = "cache", and operator tool dirs at their real paths- every mount keeps the path it has outside
- the workspace is mounted at its resolved path and the launcher starts the command there: under a workspace named through a symlink,
pwdprints the resolved path - a grant is resolved once, when the run starts, and every jail launch of the run binds that resolved directory at the grant's configured path:
/opt/tool -> /opt/tool-1.2shows the content of/opt/tool-1.2at/opt/tool, and a link re-pointed during the run changes nothing. Underpartialsandbox.isolationthe Landlock rule goes on the resolved directory - a grant that contains the workspace is mounted under it: the workspace stays writable under a read grant
- a grant the launcher cannot bind fails the launch, and the error names the grant, the step and the OS error (
write grant /srv: remount of submount cache: EPERM); a read-only mount inside a write grant is such a case - a grant on
/or on/devfails the launch by name:/would replace the jail's own root, and/devwould cover the jail's device nodes (name a device inextra_device_paths) - a grant whose path an earlier grant already shows (a file listed twice, a file inside a granted directory) is bound over it. Where the earlier grant shows a symlink at that path, nothing is mounted through the link: a read grant leaves the link as it is, and a write grant fails the launch by name
- a grant that is the home directory or a directory above it lets the run's commands and file tools reach the credentials other tools keep there (
~/.ssh,~/.aws, another agent's login). agent6 warns about such a grant at run start and inagent6 check sandbox. Underfullsandbox.isolation, agent6 hides only its own config dir and state base, plus the paths insandbox.hide_paths - the paths kept read-only inside the workspace (
.gitunderprotect_git, a machine bundle) are bound after every grant: a grant that contains the workspace does not make them writable - a tool shim whose target passes through a directory symlink grants the parent of the first such link read-only, so the link stays visible.
agent6 check sandboxnames the link in the grant's row. If that parent is refused, or the target has no directory link, a symlink into<root>/bingrants<root>read-only; other targets grant their directory. A refused root keeps thebingrant. Refused widening: a dir holding a jail PATH directory, a dir at, inside, or containing an agent6 private dir, and$HOMEor an ancestor of it. The row names each refusal - a fork's execution or a
run --worktreerun (cwd is its linked worktree) also grants the repository's.git: a read-only bind underfullsandbox.isolation, a Landlock read+exec rule underpartial, in both cases whateverprotect_gitsays. The main checkout's.gitis read-only only underfullsandbox.isolationwithprotect_git, so a worktree's is the more confined of the two. - the granted dir is the one agent6 recorded when it added the worktree (the manifest's
worktree_git_dir, taken from the repository it ran in). The worktree's own.gitpointer is never the source: a jailed command can rewrite it underpartialsandbox.isolation. The policy builder refuses when the pointer no longer resolves to the record. A linked worktree agent6 did not record gets no grant - under
partialsandbox.isolation, ahide_pathsentry inside the recorded dir refuses like any other unmaskable entry
- Masked last, after every bind (
fullsandbox.isolation): the config dir, the state base, the workspace and project roots'.agent6/pack directories,[sandbox].hide_paths- each is masked under its given name and under the path it resolves to, so an entry spelled through a symlink covers the real directory
- a hidden path inside the tree a symlinked grant resolves to is masked under the grant's spelled name too, where the jail shows that tree
- a hidden directory reads as an empty directory and a hidden file as an empty file; a write to either fails with
Read-only file system - a grant agent6 makes beneath a masked dir (the run's background-command log dir) is bound again through the mask and keeps its access
- a grant at or inside a private dir is refused at config load
partialsandbox.isolationcannot mask: a grant containing a private dir warns, an unmaskablehide_pathsentry refuses- a
sandbox.extra_read_pathsorextra_write_pathsentry inside ahide_pathsentry refuses at every jailed level: the launcher binds a grant through the mask above it - every run, resume, machine and exec holds the checkout's workspace lease (shared) for its lifetime, and
packtakes it alone before it writes, so neither starts under the other; a nested checkout's pack also needs the leases of the checkouts above it - a present
.agent6/pack.jsonrefuses startup at every isolation level, includingpartial. Runagent6 unpackin that workspace or delete its.agent6/directory - run preflight scans the project tree once for nested packs, without following symlinks or entering
.git, and refuses with every pack's path. Trees reached throughextra_read_pathsgrants are not scanned: unpack or delete a pack before granting the tree that holds it - a
hide_pathsentry that is the workspace or a directory above it refuses at every level, before anything runs: drop the entry, or work in a directory outside it - a
hide_pathsentry hides a path, not content git tracks: a tracked file still reads through git's objects (git show HEAD:<file>) and in every clone or worktree a lane, a fork, arun --worktreerun or a machine state works in - a
hide_pathsentry inside the workspace stays out of every tree agent6 writes or compares (chain commits, review diffs, dirty checks, undo, resume) and out of the run's pre-run stash, whose change stays in place: no blob of it enters the object store through agent6 XDG_CONFIG_HOMEandXDG_STATE_HOMErelocate those two dirs,XDG_CACHE_HOMEthe persistentHOME; the mask, the grant validator, and the tool-mount scan all read the relocated path
/dev(fullsandbox.isolation):/dev/null,/dev/zero,/dev/urandom,/dev/random,/dev/full, a private/dev/shm, a private devpts at/dev/ptswith/dev/ptmxpointing into it; no/dev/ttysandbox.extra_device_pathsbinds named/devnodes read-write (GPU compute). Each must be a char/block device on the host, or the launch refuses. Onpartialsandbox.isolationthe same grant is a Landlock read+write rule on the node- creating one is denied at both levels: Landlock handles
MakeChar/MakeBlockand grants them nowhere, and seccompEPERMs themknodpair by device type (below).fullsandbox.isolationalso has the user namespace (noCAP_MKNODin the initial one) - under
partialsandbox.isolationwith--allow-root, Landlock, seccomp and the capability drop deny it
/proc(fullsandbox.isolation): fresh and private, empty if that fails; Landlock grants it read without execute- the launcher, PID 1 there, hides its
/procentry from the command (dumpable 0, capabilities dropped) and runs with an empty environment
- the launcher, PID 1 there, hides its
- seccomp: a 36-syscall deny-list returning
EPERM. It covers process inspection (ptrace,pidfd_getfd,process_vm_readv/writev,kcmp),io_uring_setup,userfaultfd, the whole mount family (mount,umount2,pivot_root,mount_setattr,open_tree,move_mount,fsopen,fsconfig,fsmount,fspick),setns,unshare,kexec,bpf,perf_event_open, the keyring calls (keyctl,add_key,request_key), module loading,reboot, swap, and the clock-setting family- the list denies unconditionally.
Argument-conditional rules
EPERMtwo more cases on syscalls that stay allowed: a mode carryingS_ISUID/S_ISGIDonchmodand the create family (above), and amknod/mknodatnaming a character or block device (/devabove) - anything else is allowed; the list itself is the source (
jail/src/main.rs)
- the list denies unconditionally.
Argument-conditional rules
- Capabilities: cleared between fork and exec.
- Timeout: the policy's
timeout_s(verify and metric gates set it from[harness].verify_timeout_s, default 600), then SIGKILL of the process group, rc=124 andtimed_out=true. A command's own exit 124 is a nonzero failure, not a timeout.- a model's
run_commandin a run is not wall-clock killed: at[harness].command_checkin_sit is handed back as a background job (config). Underagent6 planandagent6 ask, in a machine state, without a jail session, or withcommand_checkin_s = 0, it is killed after 600 s
- a model's
- One launcher per run at every isolation level; under
fullsandbox.isolationits commands share that netns, PID namespace, and private/tmp, and underpartialthe host's network and/tmp- closing the run's channel takes the PID namespace down
- a launcher that cannot start leaves each command its own
- The policy arrives as JSON on a pipe from agent6, validated against a strict schema; unknown fields are refused.
Verify
agent6 check sandbox prints the sandbox.isolation this host resolves to and the reason when it is not full.
It then runs live probes at that isolation.
It exits 1 when a check fails, 0 otherwise.
| Probe | Passes when |
|---|---|
landlock_abi |
the kernel reports Landlock ABI >= 1 |
jail_true |
/usr/bin/true runs in the jail, rc=0 |
jail_blocks_network |
a jailed getent hosts example.com fails. n/a under partial sandbox.isolation, which has no network namespace |
jail_blocks_etc_write |
a jailed write to /etc/agent6-escape leaves no file on the host |
read_file, list_dir, outline, find_definition, find_references, apply_edit, and apply_patch run in the agent process and ask no approval.
Every path they take resolves through Workspace:
workspace root
+ [sandbox].extra_read_paths (read) / extra_write_paths (write)
- [sandbox].hide_paths
- agent6's own config dir and state base
- any path with a .agent6 component
- The boundary comes from config values
- a degradation (
autofalling back topartialornone, a host with no jail) never widens it; undernonesandbox.isolationit is the only boundary
- a degradation (
- A grant is resolved once per run, and the file tools and every jail launch hold that resolved path
- a grant spelled inside the workspace root or a write grant, or resolving into one, is dropped: a jailed command can re-point it there. The run names each dropped grant in one line at its start, and the region still covers what the grant held
- a grant reached through a link that sits in one of those regions is dropped too, though it ends outside: a command of one run could point the link elsewhere for the next.
Its line says to grant the target by its own path.
/tmpcounts as one of those regions under anysandbox.isolationbutfull, and the file tools then lose what a grant there held - an MCP server's own
extra_read_pathsandextra_write_paths([mcp.servers.<name>.sandbox]) follow the same rule, resolved once when the server is spawned; a dropped one is named at run start under its own key agent6 check sandboxlists each grant by its resolved path, with the configured spelling when the two differ, and each dropped grant with the region it sits in
- A denied path is refused with the reason
list_dirdrops the entry and reportshidden: N- under
fullsandbox.isolation, the jail masks the path instead (empty dir, empty file; Sandbox)
- The symbol index skips a hidden file (an indexed one leaks symbol names and line numbers through
find_definition) - While
sandbox.protect_gitis on (auto, the default, is on except wheregit.control = "model"needs a writable.git) the edit tools refuse a write into the project's own.git, raw or symlink-resolved, at every isolation level- the name matches case-folded on every platform (macOS and Windows open
.GIT/configas.git/config; macOS runs unsandboxed) - a
pyvenv.cfgdir, asite-packagesancestor, and an operator protect path (a machine bundle's.asm.tomlandscripts/) refuse the same way, whateverprotect_gitsays - reads stay allowed; an editable install still imports itself inside the jail (the repo is bound at its real path)
- the name matches case-folded on every platform (macOS and Windows open
- Inside the per-repo state dir (
$XDG_STATE_HOME/agent6/<repo-id>/), the edit tools may writememory/and nothing else. - Uncovered: with
sandbox.isolation = "none"andrun_commands = "allow", a command reads a denied path directly (run_commandargv is not screened)
Prompt reads. The agent process itself reads AGENTS.md and the manifests the verify inference uses (pyproject.toml, package.json, a Makefile and the like), and their text reaches the provider.
- A file is read only when its real path stays inside the workspace root (the git root for
AGENTS.md), outside.gitand outside the hidden set; anything else reads as absent. - A symlink that stays inside the root is followed (
AGENTS.md -> docs/AGENTS.md). - The existence probes (a root
verify.sh, a manifest, loosetest_*.pyfiles) and the model call's top-level listing leave out a file under the hidden set, by its name or its link target. A link out of the root still counts as present.
Code-review rules. The reviewer reads historical AGENTS.md blobs from the base tree, not the checkout.
The workspace hidden-path policy applies before reading a rule blob.
A checkout symlink cannot redirect a configured hidden path away from its historical name.
Historical symlinks resolve within the same base tree, through at most eight links, to a regular file outside the hidden set.
Current checkout links do not redirect a historical read.
A present file that cannot be read safely makes the review incomplete; missing optional files do not.
AGENTS.md cannot grant tools, network access or operator permissions, and cannot alter the aggregator's severity restrictions.
Model obedience is not a security boundary.
Review text notices escape control characters in rule paths and refusal reasons. See Code-review rules for discovery, limits and recorded sources.
Repo memory is a prompt-injection persistence channel: the rendered index joins the system prompt of later runs on the same repo.
Memory (<state_dir>/memory/) holds facts the model may correct; rules belong in the repo's committed AGENTS.md or the operator rulings file <state_dir>/decisions.md, which the harness appends to, the operator edits with any editor, and the model sees only as read-only text injected into its prompt.
- Entries are inert data.
- The injected index is size-capped and framed as untrusted.
- The store is auditable (
agent6 memory list/show). - The jail never mounts it.
- Files are freely editable, so a hostile write can erase earlier memories.
- Sandbox, egress, and git policy come from config, so memory content cannot move them.
Every command tool (run_command, run_verify_command, run_metric_command, stop_background) answers to [sandbox].run_commands; the first three run jailed, and stop_background signals a jailed child agent6 already started.
run_commands = "deny" withholds the verify gate too.
Under ask, a denied gate is withheld the same way for the rest of the run (no retry loop can discharge a refusal), and the run ends unverified.
The agent works within the environment it is given:
- Under
partialorfullsandbox.isolation,sudocannot escalate, even passwordless:NO_NEW_PRIVSvoids setuid, so jailedsudofails under anyNOPASSWDrule. - Under
partialorfullsandbox.isolation, package installs fail:apt/dnf/apkneed root (blocked), a route to a mirror (the defaultnetworkhas none off the box underfullsandbox.isolation), and/usror/varwrites (denied). - Host-installed toolchains compile and run; a networked build step needs
networkloosened underfullsandbox.isolation.- a toolchain that keeps its state under your home needs that directory granted and named, because the jail's
HOMEis its own; see Rust toolchains for rustup'scargo
- a toolchain that keeps its state under your home needs that directory granted and named, because the jail's
- Toolchains, venvs, and deps are installed outside agent6.
Access widens through config (
extra_read_paths,network), all visible inconfig show. - The setup command of an isolated workspace (
[workspace].setup_command, or an AGENTS.md## Setup commandblock) is a jailed command like any other- Machine clones use the operator's configured command, else the AGENTS.md block at the starting commit recorded in
machine.begin, never a block committed by a run state; read-only agent states run this setup too, under their command policy and jail - A machine clone needing setup refuses
sandbox.run_commands = "ask"at preflight rather than asking; use--auto-approveor set it to"allow"to allow setup - it runs through the run's own jail: the isolation, network and grants every command of that run gets, the approval
run_commands = "ask"asks, and nothing under"deny" - it has no network of its own and no read of the checkout the workspace was cut from: a setup step with a wider network would let a dependency it fetches run with it
- an AGENTS.md block is repository content, so a repository can state the command; it reaches what a command the model chose would reach
- under a sandbox with no route out (the default network under
fullsandbox.isolation), the command has to work offline - the recipe measured under
fullsandbox.isolation, for a uv project:agent6 config set --repo sandbox.home cache, so jailed commands in this repository share one persistentHOMEand its tool cache- warm that cache once with your own networked command, from the checkout:
agent6 --config net.toml exec --new -- uv sync --reinstall --link-mode=copy, wherenet.tomlsets[sandbox] home = "cache"andnetwork = "host"(the host network brings the resolver and CA files with it). The reinstall fetches every locked package; a plainuv syncover a checkout whose.venvis complete fetches nothing, and step 3 then fails offline. agent6 config set --repo workspace.setup_command '["uv", "sync", "--offline", "--link-mode=copy"]': a new worktree's.venvis then built from the cache with no route out (1.2 s for a two-package project), importing the worktree's own source.--link-mode=copyon both steps: the jail binds the cacheHOMEand the working tree as separate mounts, so uv cannot hardlink between them and warns on every install without it
- the warmed cache serves every working tree agent6 makes for the repository: a
run --worktreeworktree, a fork's, a--parallellane's clone and a machine state's clone all get the repository'sHOME
- Machine clones use the operator's configured command, else the AGENTS.md block at the starting commit recorded in
- Running agent6 as root needs
--allow-root/AGENT6_ALLOW_ROOT=1(plus a banner) and weakens the boundary- jailed children run as real root (uid 0), even under
fullsandbox.isolation: the uid is no boundary, and the level's layers (the table in Sandbox) are the whole confinement - a setuid bit planted through
openat2(the one create path seccomp cannot filter) then lands on a root-owned file in the workspace. That is local root for whoever runs it outside the jail - under
fullorpartialsandbox.isolation, running as root adds no write grants or network routes - readable files include root-only ones (
/etc/shadowunderpartialsandbox.isolation;full's rootfs hides it)
- jailed children run as real root (uid 0), even under
- Under
sudo, agent6 reads the real user's config and secrets (SUDO_UID/SUDO_USER) and chowns config and secrets writes back, plus the state dir at run teardown, not workspace files.- it does not drop privileges in-process; confinement comes from the jail
Finding cargo on the jail's PATH does not mean rustup can find its toolchain.
The jail exposes ~/.cargo/bin read-only, but its own HOME has neither your rustup settings nor the toolchains under ~/.rustup.
The proxy can fail with rustup could not choose a version of cargo to run even when agent6 check verify finds it.
That check only infers a command or checks its first executable; it does not execute the gate.
For an installed rustup toolchain, grant sandbox.extra_read_paths = ["/home/you/.rustup"] and name that directory in the gate: env RUSTUP_HOME=/home/you/.rustup cargo test.
Use your actual absolute path in both places.
Test the command with agent6 exec --new -- env RUSTUP_HOME=/home/you/.rustup cargo test before a run.
A system-installed Rust toolchain does not need this rustup grant.
Cargo dependencies are separate from the toolchain: an offline build also needs vendored dependencies or a populated Cargo cache in the jail's HOME.
Use sandbox.home = "cache" to retain that private cache across runs, and populate it with an operator command whose network policy permits the fetch.
Do not grant all of your host ~/.cargo just for its cache: it can contain registry credentials and configuration that jailed commands would then read.
The agent process's own egress is unbounded.
Its own provider and credential requests follow no redirect, so a bearer goes only where the configured URL says; a request to a loopback destination ignores the proxy variables, so a plaintext loopback bearer stays on the host; a base_url or OAuth endpoint carries no userinfo and no fragment.
Model listings, key probes and ChatGPT plan readings use the capped provider request path.
agent6 telemetry export is host egress too: it dials [telemetry].endpoint from the agent6 process with the header values secrets.toml holds, outside sandbox.network, and only when the operator selected a signal.
The default capture carries outcomes, counts, timings and the journal's ids; capture = "content" adds the capped excerpts telemetry.capture lists (config), which can hold anything a run saw.
The export dials the endpoint directly: no proxy is taken from the environment, so a plaintext loopback endpoint stays on loopback.
A plaintext endpoint is accepted on loopback only; the Prometheus textfile is written atomically to the operator's path.
A claude_code provider's child dials api.anthropic.com and claude.ai (login refresh) from the agent process; telemetry, crash reporting, and update hosts are disabled by its environment.
While it runs it listens on a same-uid session socket under /tmp/cc-socks/, reachable from a jailed command under partial and none sandbox.isolation, whose /tmp is the host's.
fetch is the model's only direct egress.
- One https URL, GET, no redirects followed, no credential, text only, 1 MiB, 20 s.
- Hosts on
sandbox.fetch_approved_hostsare read without asking (empty by default); any other host prompts. - Nothing resolves before that gate: a DNS query delivers the hostname to whoever runs its authoritative server, so an unapproved URL never reaches a resolver.
- Behind the gate the name resolves, and every address it yields must be global: loopback, the LAN, and
169.254.169.254are refused, as is a URL naming one literally. The request dials the resolved address with the name in SNI andHost, so no second lookup can rebind it. - A
content-encodingother than identity is refused: the cap counts bytes as they arrive, and a decoded stream would expand past it. - Hidden when a jailed command already has the host network:
network = "host", or anysandbox.isolationbutfull.
Which network a jailed child joins. sandbox.network is one of:
auto(default): the run's own network where the host can give one, the host's with a warning where it cannot.session: the run's own, enforced; refuses where the host cannot give one. Its members reach each other and nothing off the box.host: the machine's network, with the host's resolver settings and CA store readable (Threat model).
A run's commands share one launcher, so there is no per-command sandbox.network = "none".
A machine state sets its own value in its config table, never wider than this one (State machines).
The run owns one session network.
A holder process creates it, the run keeps it alive with an open descriptor on /proc/<holder>/ns/{user,net}, and every child that asks joins those.
Entering a network namespace needs CAP_SYS_ADMIN in the user namespace that owns it, so a joiner enters that user namespace too.
It still gets its own mount, PID, IPC, and UTS namespaces, so two members cannot see or signal each other.
Under full sandbox.isolation, the only level with namespaces:
| jailed command | auto (def) |
session |
host |
|---|---|---|---|
run_command |
the run's session network | same | host network |
a machine tool state |
its own, alone | its own, alone | host network |
The column is the value the command's config resolves to: your sandbox.network, narrowed by the machine's [config] or set by the state's own config.
Below full sandbox.isolation there is no network namespace: auto degrades to the agent process's network with a warning (once per run), while session refuses.
Refusals (fail-closed)
| Configuration | When |
|---|---|
a machine overlay sets network = host under your auto or session at full sandbox.isolation |
machine check, machine start |
explicit network = session, yours or a machine state's |
run or machine start under partial or none sandbox.isolation |
MCP servers take their own value per server ([mcp.servers.<name>.sandbox].network, default auto):
auto: a network of its own where the host can give one, degrading to the host's with a warning.mcp.servers.<name>.sandbox.network = "none": refuses instead of degrading.session: joins the run's network, so a browser server reaches the dev server a background command started.host: the machine's network.- A server is spawned on stdio or dialled at an operator-set
url, outbound either way.
Ingress. The loop opens no accept-side socket.
agent6 web opens one, and only when started.
- It binds loopback (
127.0.0.1) by default and speaks plain HTTP (for remote access run it behindtailscale serve; see the web UI). A non-loopback bind needs[web].allow_non_loopback = truefor[web].host, or--allow-non-loopbackfor--host. - Every
/apiroute requires the sign-in tokenPOST /api/loginanswers for the server's secret, sent asAuthorization: Bearer(sign in). Without it the answer is 401; the page shell, its static assets and the login are the only public routes.- The secret is minted per launch (six words of the session-id wordlist, about 44 bits) and printed on the terminal, or it is the operator's own (
--secret,AGENT6_WEB_SECRET), refused under 12 characters. - It keeps out a local process, and a jailed command where the jail shares the host network: without it either could write the config (
sandbox.isolation = none) or read transcripts the jail masks. - It does not protect the transport: there is no TLS, so on a non-loopback bind the secret and the token are readable on the network path.
- It does not keep out whoever can read the terminal
agent6 webprints to, the operator's environment, or the process list when--secretis used. - The login has no lockout and no rate limit; the secret's length carries it.
- The token is an HMAC-SHA256 keyed by the secret, never the secret.
The page keeps it in storage scoped to its origin (host and port) and sends it in a header, never a cookie: a browser sends a cookie to every port of the host, so a page a jailed command serves on a forwarded port would receive it on the first visit.
The page shell, assets and JSON responses carry
Content-Security-Policy: frame-ancestors 'none', so such a page cannot frame the signed-in UI either.
- The secret is minted per launch (six words of the session-id wordlist, about 44 bits) and printed on the terminal, or it is the operator's own (
- With
telemetry.metrics = "prometheus"andweb.metrics_route = "on"(default:off),/metricsuses the existing listener and requiresAuthorization: Bearer <web-secret>, never a cookie or browser sign-in token. - The server renders folded state and drives typed contracts.
New-work spawns fixed argv with the task behind
--, and machine-run is allow-listed to authored files. Answers write only the addressed run's answer files (session id, answer id, and machine target state dir each validated to one path component). Merge, prune, and config-set are fixed agent6 subcommands. - No request carries an ambient credential, so no POST needs a cross-site guard: a cross-origin page cannot add the header without a preflight the server never answers. DNS rebinding is not covered (that needs a Host allow-list incompatible with the tailnet name): a rebound page holds no token (storage belongs to one origin), so it reaches the public routes and can try secrets at the login; keeping it out is left to the network layer.
- Request framing is bounded: 1 MiB body cap (413), chunked refused (411), and any unread-body refusal closes the connection.
- The machine write surface (
POST /api/machine/<name>/{poke,stop,steer,approve,answer}) uses the same guards.pokewrites the instance's signal file (inert JSON the nexttoolreads) andstopits stop marker;steer,approveandanswerwrite only the current agent state's per-state dir. - Finding actions use the same authenticated session lookup and body limits. Fix constructs steering text from stored evidence, not a request-supplied instruction. Resolve and Reopen validate reasons and duplicate references before writing.
- A live worker is the only journal writer for finding actions.
sessions.ipcpublishes whole request and reply files under the private session'sreview-actions/; request IDs cannot escape that directory. Without a live worker, the action path acquires theworker.lockand rechecks liveness and the review version before appending. - All agent6 merge paths enforce open grounded block findings in
app.merge.execute_merge(), independent of browser controls andreview.block_on. The session writer lock excludes dispositions and concurrent session writers until the pinned source commit lands and its merge stamp is recorded. Git's conditional target-ref update still refuses a concurrently moved target. Resolve-with-reason is the only finding bypass; unreadable evidence refuses with its reason. - Review comparison and file routes read Git objects by recorded tree ID.
Paths and sides pass
ReviewLocationvalidation; regular-file reads do not follow stored symlinks. Finding text and resolution reasons enter the browser as text, not HTML. - PWA assets are static and the service worker is a no-op passthrough (no Web Push, no VAPID). Telemetry requires an operator-started export or the opt-in scrape route (Telemetry export); no auto-update, no remote control plane.
[sandbox].run_commands(askdefault,allow,deny) gates every command tool; anaskprompt shows the argv- Each
mcp__<server>__<tool>call prompts with its arguments ([mcp.servers.<name>].approve, defaultask). - An "allow all" answer covers that server for the run, never the command tools or another server
- A tool name matching no configured server is refused, not prompted.
- The
fetchoff-list host prompt and the sandbox-off gate take no standing answer; both say so, and no front-end shows the button sandbox.isolation = "none"with auto-approvedrun_commandadds a one-time gate:Continue? [y/N]interactively, a warning in CI andmachine run.- A prompt with no operator to answer it: a headless run refuses to start under
askunlessAGENT6_DETACHED_AWAYisdeny(auto-deny),wait(park it for a front-end) orapprove(grant every scope). An unattended machine auto-denies. agent6 mcp servehas no operator at all, so the tools that would prompt are not published. Underrun_commands = "ask"or"deny"its command tools are absent fromtools/list, and a client that names one is told which setting withheld it (the tools).
agent6's own git
- agent6's own commits, refs and merges go through
vcs/git_ops.py, its queries throughvcs/git_read.py, and the web Git and Files tabs' reads throughvcs/git_history.pyandvcs/git_files.py. Only thevcspackage's modules may import the private runner,git_command.py, enforced bytest_only_the_vcs_package_imports_the_runner.- the package spells no destructive verb at all:
push,reset --hard,commit --amend,rebase,filter-branch/filter-repoand any--force/-fappear nowhere in it, so there is nothing to enable.test_the_vcs_package_never_spells_a_destructive_verbpins every verb butfilter-repoin every module of the package, exempting two argv:pushinside agit stash push, and the onebranch -Dbelow - the collectors on the subprocess allowlist carry the same hardening flags. By default, these flags switch off the repository's hooks, fsmonitor, external diff, textconv, commit signing, and filter and merge drivers.
sessions diffandaskread only.skills installclones with fixed argv.
- the package spells no destructive verb at all:
- One operator-only exception:
sessions prune --delete-squashedforce-deletes a run branch or chain ref the manifest confirms was squash-merged. A branch's or chain ref's line carries the sha to undelete it. - Host-side git calls use
git_policy.git_environmentto remove configuredapi_key_envnames, so credential helpers and content drivers never inherit them.- Of the shell's
GIT_*variables, onlyGIT_ASKPASS,GIT_AUTHOR_*,GIT_COMMITTER_*,GIT_EDITOR,GIT_SSH,GIT_SSH_COMMAND,GIT_SSH_VARIANTandGIT_TRACE*pass through; agent6 can still supply explicit settings such asGIT_INDEX_FILE. - Other PATH, SSH, proxy and credential-helper variables stay.
- Of the shell's
Receipt evidence
- Receipts read the journal and manifest, with no provider call or test execution. Reversal guidance uses recorded object IDs and checks commit relationships through read-only git operations. A missing stamp or object cannot authorize a guessed reversal. See Session receipts for evidence limits.
A git the model runs through run_command is bounded by the sandbox, and its argv is not screened.
protect_git = "on"requiresfullsandbox.isolationto bind-remount.gitas read-only; unlessgit.control = "model",auto(the default) binds underfulland keeps the edit tools' refusal at every level- a mount nested under
.gitstays visible and read-only - a rewrite fails;
pushhas no egress unlesssandbox.network = "host" - a grant that contains the workspace does not lift it (Sandbox)
- a mount nested under
- Under
partialsandbox.isolation,protect_git = "auto"degrades with a warning and"on"refuses to run- a jailed command there can plant a
filter.<n>.cleanplus a.gitattributes; agent6's own auto-commit (a temp-indexgit add -Aon the host) blanks every driver the repo defines unlessgit.run_repo_driversis set - Landlock cannot express the exclusion: a directory grant is recursive and stacked rulesets intersect, so denying
.gitmeans not granting the workspace root either
- a jailed command there can plant a
git.control = "agent6"is enforced where.gitis read-only to jailed commands; undersandbox.isolation = "none"the run warns that a commit the model runs lands on the checkout's branch.- The protected scope is the project's own
.gitat every isolation level (the in-process edit tools refuse writes under it; File access)- a nested
.git(a vendored repo's, a submodule's) is workspace content, writable like any other file
- a nested
Repo-controlled host code in a poisoned .git/config
core.fsmonitor,diff.externalanddiff.<d>.textconvare always off.core.pagerandpager.<cmd>never run: every agent6 git call carriesGIT_PAGER, set to yourGIT_PAGER, else yourPAGER, elseless.blame.ignoreRevsFileis honoured for a repository-relative regular file, which blame reads as a private copy in a private work tree, since git opens the configured path itself after any check; the Files view's blame refuses any other value, since git would open the file on the host and quote its first unparsable line.- Finding line-origin classification uses the same bounded blame reader and records an unknown reason on refusal.
- Review refuses a
blame.ignoreRevsFilehidden bysandbox.hide_pathsbefore opening it. - Blame refusals name the setting and failed constraint, not the configured value.
- Line origins remain unknown when the citation or any changed path is hidden, so rename detection cannot read hidden candidates.
- Line-origin and rule-loading records omit Git subprocess output from failure reasons.
.git/hooks/*run only undergit.run_repo_hooks = true(default false);core.hooksPathpoints away, so a hook cannot fire on agent6's auto-commit.- Content drivers (
filter.<n>.clean/smudge/process,merge.<n>.driver) are off by default (git.run_repo_drivers), neutralized per name- the clean filter runs on the auto-commit's
git add, the merge driver on the chain merge'smerge-tree: a cloned poisoned repo fires one without any model action truehonors them (the Git-LFS opt-in)
- the clean filter runs on the auto-commit's
agent6 pack copies credentials into the workspace at .agent6/config/secrets.toml.
A present pack stops every run until it is unpacked or deleted, even on its origin host and home.
pack verifies git's ignore decision and refuses tracked .agent6 paths before writing the snapshot.
File tools, symbol indexing and host-side tree reads deny .agent6 path components; the full jail also masks the workspace and project roots' .agent6/ directories.
The secrets copy is created through a 0600 descriptor in a 0700 directory.
A workspace sync can expose it to the sync service and the destination host; use only trusted destinations.
A devcontainer that maps the user to another uid can leave a synced secrets file foreign-owned, which the secrets loader refuses.
agent6 unpack creates a fresh 0600 secrets file owned by the importing user.
The workspace transfer flow explains import conflicts and startup refusals.
- Provider keys live in
$XDG_CONFIG_HOME/agent6/secrets.toml,0600and owner-only (refused if group- or other-accessible, or foreign-owned; opened without following a link and judged by the descriptor it opened; rewritten only under its sibling lock), or come from[providers.<name>].api_key_env(env wins). config set --secret telemetry.headers.<name> <value>andconfig unset --secret telemetry.headers.<name>use the same locked, atomic secrets writer for[telemetry.headers]. They accept no other keys or write targets and never print header values;config showlists only the configured names. Command-line exposure: a value passed toconfig set --secretcan remain in shell history and the process list.- Bound
StoredGrantrecords may carry anaccount_id; records without it remain readable. Raw id tokens and plan labels are not stored. - ChatGPT grants are bound to their fixed sign-in profile and provider transport settings like configured grants.
Only the current grant format is accepted.
agent6 connect <name>replaces an unusable entry after a successful sign-in. Logout sends no credentials for records outside the current format, rebound grants or contradictory-account grants and removes them locally only. - A configured issuer (
[providers.<name>].oauth) names the only other hosts a sign-in dials: its endpoints are https, or plain http on a literal loopback address, carry no userinfo or fragment, and are never discovered; the stored grant is bound to those settings and to the entry's transport settings (api_format,base_url,deployment,auth_style,extra_query,extra_headers).- the grant is sent only while its binding matches the entry's transport and issuer settings; a mismatch refuses before any request, naming
agent6 connect <name> - a refresh or a second exchange runs under the secrets file's lock, follows no redirect, and scrubs the tokens it sent from any error
- a 401 retries once per call, arming rotation only if the rejected bearer is still current
- a 403 never rotates
agent6 connect <name>signs in only at those endpoints: the browser opensauthorization_endpoint, the code returns on the configured loopback redirect (or is pasted, state-checked either way), tokens come fromtoken_endpoint;--logoutrevokes atrevocation_endpointwhen set and removes the grant regardless
- the grant is sent only while its binding matches the entry's transport and issuer settings; a mismatch refuses before any request, naming
[providers.<name>].extra_headersis the one header surface: a literal string rides every API request; a template table renders from a fixed fact vocabulary (the list) for the request classes it names, and only a template addressed tooauthorexchangereaches a credential endpoint.- no fact carries a token, a path, a host, a user, an environment value or prompt text;
conversation_idis an opaque id minted per role and provider in the session'sconversations.json, kept across a resume and new on a fork; sending it orrequest_idis a correlation the operator opts into - request-header restrictions apply at config load, and a rendered value is checked before it is sent
provider.requestevents carry an attempt's request id and class, never a header value
- no fact carries a token, a path, a host, a user, an environment value or prompt text;
- Secrets are absent from transcripts, redacted in
config show, and masked from the jail underfullsandbox.isolation(Sandbox). config showandconfig getmask mapping values unless the config model marks the field display-safe (skills.state,review.reviewers); other mappings expose only their keys, including in JSON output.- Child processes inherit no provider keys.
- a jailed command's environment is built from its policy
- unconfined paths (
sandbox.isolation = "none", git subprocesses, MCP servers, token commands) drop every configuredapi_key_envname - an
[mcp.servers.*].pass_envnaming a provider'sapi_key_envrefuses at config load, so the name cannot be handed over deliberately either
agent6 connectprompts locally (each keystroke masked on a terminal) and writes config and secrets- one read-only
GETof the provider's/modelsendpoint (OpenRouter's auth-gated/key) confirms auth (status only;--no-verifyskips it) - it executes nothing a remote returns
- one read-only
agent6 connect chatgptis a PKCE OAuth sign-in- a browser hits the authorize page of OpenAI's fixed OAuth authority; the code returns on
localhost:1455(or is pasted), state-checked either way - token exchange and refreshes
POSTonly to the authority's/oauth/token - the device flow posts JSON to
/api/accounts/deviceauth/usercodeand/api/accounts/deviceauth/token, then exchanges the returned code and verifier as a form with/deviceauth/callbackas its redirect. - device requests refuse redirects, validate displayed codes as visible ASCII of at most 64 characters, and scrub both submitted codes from errors.
- tokens live in
secrets.tomlunder the same0600and executes-nothing rules - agent6 never sends a rating or feedback on a response and has no rating surface. The backend may use rated turns for training, and that choice is never made on the operator's behalf
- a browser hits the authorize page of OpenAI's fixed OAuth authority; the code returns on
agent6 connect claudestores nothing: agent6 never reads or copies Claude Code's own login (~/.claude/.credentials.json,~/.claude.json)- no default mount or grant holds it.
A jailed command reads it when the workspace or a path grant (
sandbox.extra_read_paths,sandbox.extra_write_paths) is the home directory or a directory above it, and undersandbox.isolation = "none" - a
sandbox.hide_pathsentry hides it from commands underfullsandbox.isolation - the child's environment carries no
ANTHROPIC_*orCLAUDE*variable from the operator shell, so a shell API key cannot override the subscription login;CLAUDE_CONFIG_DIRis the one passthrough claude auth status --jsonis parsed forloggedInonly; its body (email, org) is never printed or journaled- the binary's initialize handshake carries the account block.
agent6 keeps the email in memory only, to replace it with
<operator-email>in the model's returned text, and records none of it
- no default mount or grant holds it.
A jailed command reads it when the workspace or a path grant (
- An in-process
GraphCuratorowns the task graph (the curator and its locks)- every mutation validates against a pydantic schema before writing
- Per-repo state lives at
$XDG_STATE_HOME/agent6/<repo-id>/, outside the working directory jailed commands run in.- every directory under the base is created
0700whatever the umask (mkdir_for_real_user, the one creator), keeping transcripts, memory and run history from other local users. A base an older release created at755keeps that mode
- every directory under the base is created
- The config write lock serializes read-modify-write cycles and enforces nothing
- publishes are atomic: a torn config is impossible with or without it
- it fails open (
O_NOFOLLOWrefuses a planted symlink; a stale root-owned lock is ignored): see config write behavior
agent6 run --parallel and a live run's /parallel steer directive (see architecture.md, Parallel runs) each spawn subordinate work.
- Every lane is an ordinary run: a detached
agent6 runon its own clone, its own jail persandbox.isolation, its ownrun_commandspolicy - Every spawned lane carries
AGENT6_SUBRUN=1; both the--parallelflag and the coordinator'slane_spawnerwiring refuse when it is set, so a lane cannot fan out or dispatch again. - A lane's config carries key references, never secret values
- the orchestrator writes each lane a
--configviamaterialize()(the resolvedConfig: providerbase_url,api_key_envnames) - the lane reads the same
secrets.tomlor provider env var as any other run
- the orchestrator writes each lane a
- Lane git plumbing (clone, fetch, merge) goes through
git_ops.pyand its private runner; lane spawning goes throughui/spawn.py(see the subprocess allowlist). - A lane starts from committed state only
- the fan-out clones HEAD; a coordinator dispatch cuts lanes at the run's chain tip after chain-committing changes
--parallelrefuses an origin with uncommitted tracked changes undergit.dirty_tree = "ask"
machine run is a supervisor that makes no model calls.
Each tool state is jailed with a network of its own, so one state's network is set independently (Network).
A machine can keep agents on the provider API while one reviewed, fixed-argv tool reaches the network.
An overlay never widens the sandbox
- The machine's
[config]and each state'sconfigmay setsandboxkeys, each bounded by your own layers alone (the rule per key)- the ceiling is read from one load of your layers (defaults, global, repo, preset,
--config FILE), never from a config the machine file has merged into - a path grant is at or below one of yours, compared by real path, so a symlink out of your tree is refused
- a state's grants are judged against the workspace, the union of the machine's and states' resolved write grants, and host
/tmpif any runs offfullsandbox.isolation, not the operator ceiling. A grant below such a region, or reached through a link inside one, refusesmachine checkand machine start, since a state writing the region can point it anywhere; the region itself may be granted. - a wider value refuses at
machine checkand at machine start, naming the state and the key - the other operator-only tables and leaves are rejected at load (the list)
- the ceiling is read from one load of your layers (defaults, global, repo, preset,
- One resolved config per state feeds both enforcement sites: a
toolstate's jail, and anagentstate's subprocess (its in-process file tools and its jailed commands)- every startup check a plain run makes (the
hide_pathscheck underpartialsandbox.isolation, network support) runs per state and names the state - a state the supervisor resolved no config for runs nothing
- every startup check a plain run makes (the
- A
--config FILEinside the workspace is read-only in every jail, like the bundle: a state cannot raise its own ceiling - A grant bounds what a state touches directly: a granted state can copy what it reads to the blackboard, the data dir, the jail's
HOMEor a commit, where a later state reads it - A
toolonly declarespass_env; whether a variable is passed is your[machine].pass_env- its setup command gets none of them: setup runs the tree's code at the chain tip, which a run state can edit, so a passed secret would reach model-written code
Bundle confinement
- Scripts live in a reviewed
scripts/beside the.asm.tomlmachine checkverifies every entry and static reference resolves inside the bundle (escaping symlinks rejected)
- Scripts are committed, never fetched or generated at run time
- the
.asm.tomlandscripts/are read-only in every jail during a run
- the
- Front-ends render
machine.notifyas an overlay, andattach/ TUI callnotify-sendwith fixed argv, so a model message is inert data. - The out-of-band hook
[machine.notify].on_eventruns an operator argv on the host with the minimalhook_envenv plusAGENT6_MACHINE_*.
Every child process whose argv depends on model output runs through run_in_jail (Sandbox).
The exception is notify-send: it has no stdin mode, so a machine notify message is one data argument after --.
A fixed set of modules also shells out directly with subprocess.run / Popen, each with fixed argv.
tests/security/test_subprocess_allowlist.py pins the file list; audit with rg 'subprocess\.|os\.(system|exec|posix_spawn)' src/agent6/.
Allowed host-side subprocesses by module
vcs/git_command.pyandvcs/git_ops.py: agent6's own git operations (Git); the othervcsmodules run git throughgit_command, except the config query the next line names.vcs/git_policy.py: shared hardening flags and hook/driver policy; its fixed git config query enumerates repo content drivers.sandbox/detect.py: probes the host's sandboxing capabilities.sandbox/jail.py: the jail launcher, and the plain spawn undersandbox.isolation = "none". An MCP server spawns through the same launcher andJailPolicya jailed command gets (spawn_in_jail); one the operator opted out takes thenonesandbox.isolationlevel.sandbox/jail_jobs.py: detached jail commands and the plain background spawn undersandbox.isolation = "none".sandbox/jail_session.py: persistent jail launcher sessions and their teardown.providers/token_command.py: the[providers.*].token_commandthat mints a provider bearer.providers/claude_code.pyandproviders/claude_code_session.py: thebackend = "claude_code"provider and its session lifecycle helpers run the Claude Code binary the operator connected with fixed argv from operator configuration, never model output (binary, model, effort, literal flags, the path of a 0600 system-prompt file in a private empty directory). Prompts, tool results, and notices travel on stdin, so model- or repo-derived text never becomes an argv element. Curated environment,--tools "",--allowedTools mcp__agent6,--setting-sources "",--strict-mcp-config,--disable-slash-commands,--no-session-persistence; CLAUDE.md, auto-memory, and auto-compaction off by environment;system/initis audited so a tool outsidemcp__agent6__*or an API-key source refuses the run. Unjailed: it needs the operator's login under$HOMEand its own egress, at the agent process's trust tier. Tool-result text and notices stay under Claude Code's 50,000-byte persistence threshold. The loop clips tool-result text at 34,000 bytes for this provider, keeping 16,000 for the turn's notices. A tool result with its notices above 50,000 bytes is refused.claude auth status --jsonruns the same way for the sign-in preflight.sessions/process.py:ps -p <pid> -o lstart=andps -p <pid> -o stat=on hosts without/proc(macOS), for process start-time identity and zombie checks, over a pid agent6 recorded.ui/btw.py: spawnsagent6 askdetached for/btw(every composer). Argv is the agent6 exe plus the question the operator typed, with--before it.ui/spawn.py: the shared front-end spawn helper. Spawns the agent6 CLI detached for run and machine launches (machine createandmachine run), and capturessessions merge/prune/remove/reviewandconfig set/unset.ui/notify.py:notify-sendwith fixed argv (exe,--, two positional data args, no shell) for the device-present machine notification.ui/cli/helpers:connect_oauth.py: the platform's URL opener (xdg-open,open) for a sign-in URL built from the operator's configuration, with the curated environment; never$BROWSER's command templateplan_watch.pyandsteer_tty.py:$EDITORfor plan and steer editingsessions_cmds.pyandask.py:git diff/logfor thesessions/askdiff views, with argv from the run manifest the CLI wrote outside the jailhistory_cmds.py:rgfor history searchlive.py: the fixed-argvpython -m agent6.ui.tuico-process behindrun --tuisystem_cmds.py:cp/rm/apparmor_parservia sudo with fixed argv foragent6 system apparmorcompletions_cmd.py: runs nothing itself; its onesubprocess.runis text inside the xonsh completer script it emits, which the operator's xonsh runs at tab time to askagent6for candidatesskills_cmds.py:git clone --depth 1 -- <url>with fixed argv foragent6 skills installandskills update. The URL is operator-supplied and nothing fetched is executed
app/finalize.py: both operator notify hooks (run_notify_hook):[notify].on_completeat run end and[machine.notify].on_eventfrommachine run(built byapp/machine/host_preflight.build_machine_notify_hook). Argv from config, env fromhook_env(a minimal base plusAGENT6_SESSION_*orAGENT6_MACHINE_*, never the provider keys in the operator environment).app/machine/scriptcheck.py: ruff and ty with fixed argv, reading generated scripts statically. Those scripts execute only viarun_in_jail.app/machine/agent.py: spawns each agent state as a fixed-argvpython -m agent6.ui.cli.machine_agentsubprocess whose request travels in a temp file.ui/tui/clipboard.py:tmux load-buffer -w -with fixed argv and the copied transcript text on stdin.ui/tui/conversation.py: the operator's$PAGER, argv from the environment, transcript text on stdin.
- A skill is operator-installed config: install only from trusted sources
skills install <url>is an operator-initiated CLI fetch; what it installs enters the system prompt and tool results verbatim
- Nothing in a skill runs at install or load
- its scripts run only through the jailed command path, subject to
run_commands
- its scripts run only through the jailed command path, subject to
use_skillis read-only and path-contained: the skill's own dir through a component-walked descriptor (any symlink hop or..inside it refused)- a skill directory or
SKILL.mdthat is itself a link is followed, and a read that lands undersandbox.hide_pathsor an agent6 private directory is refused, as the workspace file tools refuse it - a skill whose
SKILL.mdresolves under one of those paths is not loaded for a run at all: its name, description and text stay out of the prompt's skill index, thealwaysinjection and/name, and the run logs a warning naming it;agent6 skills liststill shows it - skill dirs are not mounted into the jail
- a skill directory or
- Repo-local
.claude/skills/are not discovered; only the installed dir and[skills].extra_dirsare scanned
tests/security/test_prompt_injection.py drives the dispatcher with the calls a compromised model would make.
Those are path traversal, a swapped symlink between check and open, an unknown tool name, extra fields, and a write outside the workspace.
It asserts the tool surface refuses them whatever the model says; it does not test the model's judgement, and calls no model.
The structural defenses above confine a model that follows an injection.
- seccomp is required; kernels that block it from unprivileged callers make the jail fail closed.
- Under
fullsandbox.isolationin a Docker or Podman container, the jail's/procis empty andpsfails. Docker's--security-opt systempaths=unconfinedor Podman's--security-opt unmask=ALLlets the jail mount its own/proc. Under Podman's default seccomp profile, jailed commands also see the container's hostname. - In a devcontainer the XDG state base is ephemeral (lost on rebuild): mount a volume at the state dir or point
XDG_STATE_HOMEat one - agent6 installed inside the project it works on (pip into the project's own venv) puts the running agent's code in the jail's writable workspace
- a jailed command can rewrite it; agent6 runs the rewrite as you, outside the jail
- install agent6 outside the tree (pipx,
uv tool); agent6 warns at run entry on this shape
- Claude Code (
backend = "claude_code") appends the account email to every system prompt it sends and has no switch for it. The model can echo it, and agent6 scrubs it only from returned text, never from tool inputs (an edit carrying it lands as written)- managed settings (
/etc/claude-code/managed-settings.json) still apply inside the child. Thesystem/initaudit refuses the run when the tool list differs from what agent6 offered orapiKeySourceis notnone, and checks nothing else. A managed hook, or a managed MCP server exposing no tool, passes it
- managed settings (
- Side channels: no claim about timing, cache, or speculative side channels.
- Supply chain: pin your install
- runtime deps
pydantic,httpx2,argcomplete, thetree-sitterpair,textual,ruff,ty; build dephatchling; jail cratesnix,libc,landlock,seccompiler,serde,serde_json
- runtime deps
read_file sends supported static images and PDFs from the workspace or an explicit read grant to the calling provider.
The read uses Workspace.resolve_read and open_contained, retaining hidden-path, regular-file and symlink-race checks.
It reads media under the 10 MiB document cap plus one byte, checks image containers without decoding pixels, and recognizes PDFs by their header.
Transcripts, snapshots, checkpoints and shared message objects omit recognized payloads and retain metadata (path, media type, SHA-256 and byte size).
A resume or fork needs a fresh read_file; it never reopens a saved path automatically.
Streaming error excerpts that reach 8 KiB on a media request are omitted because clipping can split an echoed payload.
Pixels and PDF contents are sent upstream unscrubbed. A screenshot or PDF can contain credentials even when configuration directories are hidden. Metadata-only retention and credential scrubbing of headers and text do not redact those contents.
The Claude Code binary runs outside the jail and receives the original attachment bytes through its stream-json pipes. The adapter sends images as MCP image items and PDFs as embedded resource items, not base64 text tool results. Its 50,000-byte text guard and 8 MiB line cap remain in force; the line cap includes base64, envelopes and notices on both continuation and replay. The installed binary's forwarding and storage behavior require a live check; agent6's retention policy covers its own records, not the child or upstream provider.