SECURITY¶
agent6 treats the LLM as untrusted. Concrete claims below, layer by layer, each with what it means and where it stops.
Reporting¶
Open a GitHub issue prefixed [security]. 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/).
Threat model¶
Adversary: a fully malicious worker model, or an honest model that has been prompt-injected by a file in the workspace.
We assume the adversary controls:
- the text of every LLM response
- the choice of tool calls and their arguments (within the published JSON schema)
- the content of any file the agent reads during the run
We do NOT assume the adversary controls:
- the kernel
- the agent6 binary
- the provider endpoints
Under that adversary, agent6 aims to hold:
- No writes outside the workspace.
- No reads outside the workspace and a read-only system set.
- The system set (
/usr /bin /sbin /lib /lib64 /etc /dev /proc /tmp) exists so installed toolchains resolve;sandbox.extra_read_pathsadds more.
- The system set (
-
The agent process's own egress is NOT bounded. agent6 talks to the configured providers; nothing stops the PROCESS reaching elsewhere.
- A network-only block on a process with unconfined filesystem access is a
partial mitigation that reads as a guarantee: code executing there could
write
~/.ssh/authorized_keysor a cron entry and exfiltrate on its own schedule regardless. agent6 does not ship that non-claim (§1). - What IS bounded is what a COMMAND reaches:
sandbox.network(defaultauto; §8). That boundary is per-jailed-child and unchanged.
- A network-only block on a process with unconfined filesystem access is a
partial mitigation that reads as a guarantee: code executing there could
write
-
agent6's own git never pushes,
--forces, rewrites history, orreset --hards (§5).- This does NOT bind a
gitthe model runs viarun_command; that path is bounded by the sandbox (protect_gitkeeps.gitunwritable understrict; push needs egress).
- This does NOT bind a
- No persistence after the run: no daemon, cron,
.bashrcwrite, or setuid binary.- No setuid/setgid bit can be set: every chmod-family syscall
(
fchmodat2included) is denied when the mode carriesS_ISUID/S_ISGID; ordinary chmod is untouched. The bit would land on the HOST inode and outlive the jail. Every mount also carriesnosuidandnodev;/tmpis notnoexec(toolchains run helpers from it, and a child that can execute from the workspace gains nothing there). - Children write only inside the jail's mount namespace (strict) or the Landlock write grants (hardened).
- Nothing a command starts outlives it. strict's PID namespace takes
the tree down; hardened has none, so the agent is a child subreaper and
kills every escapee (a
setsiddaemon included) when the command returns. - The bound is the command's own descendants. On hardened a command can
still hand work to a user daemon ALREADY running (tmux,
systemd --user): unix sockets have no Landlock hook and stay nameable without a mount namespace. strict does not expose them.
- No setuid/setgid bit can be set: every chmod-family syscall
(
Defense layers¶
1. The agent process is trusted, and no level confines it¶
The agent's own Python process runs unconfined at every isolation level: it holds the provider keys, writes the per-repo state dir, and spawns the jail. Every security boundary is the jail's, applied per command; isolation levels differ only in which jail features the launcher enables (§2). A partial confinement of a trusted process (a port filter, a bind ban) reads as a guarantee it cannot keep -- one HTTPS endpoint is enough to exfiltrate from a process with filesystem access -- so agent6 states the boundary honestly instead of shipping one.
The PROCESS is trusted; the TOOL SURFACE it exposes to the model is not. A model is untrusted input at all times -- its behaviour is probabilistic and cannot be tested exhaustively -- so the file axis is enforced in both places it can be reached: the tools are the front door, the jail is the fence.
- The in-process tools carry the run's file boundary (
Workspace).read_file,list_dir,outline,find_*,apply_editandapply_patchrun in this process and ask no approval, so they resolve every path through one value: the workspace root plus the operator'sextra_read_paths/extra_write_paths, minushide_pathsand agent6's own private dirs. The symbol index skips a hidden file too -- one that reached the index would leak its symbol names and line numbers throughfind_definitioneven though nothing could read it. - It is derived from config VALUES, never from the isolation level. A
degradation must never widen access:
autofalling back tohardenedornone, or macOS having no jail at all, leaves this boundary exactly where it was -- andnoneis precisely where it is the only one left. - A denied path is refused, with the reason. The jail masks (empty dir,
empty file) because a command cannot be handed an error; a tool result can,
and answering "no such file" for a path that is plainly there would be the
surface lying.
list_dirdrops the entry but reportshidden: N: true that something is hidden, without naming it. - What it does not cover. Where the operator removes both gates --
isolation = "none"withrun_commands = "yes"-- a command can read a denied path directly. Screeningrun_command's argv would be enumerating badness and a script the model writes bypasses it, so the unsandboxed warning names the cost instead. Underrun_commands = "ask"the attempt surfaces as an approval prompt showing the argv.
2. agent6-jail (Rust), for every child command¶
apply_edit is in-process; every run_verify_command/run_command runs in
agent6-jail, as does every backgrounded command. Under strict a RUN's commands
are served by ONE launcher process, so they share its netns, PID namespace and
private /tmp: a server a background command starts is reachable by the next
command on loopback, and closing the run's request channel takes the PID
namespace and everything in it down. Its confinement is fixed when it opens, so
the policy is the run's rather than the first command's, and it grants the
background log root (<session>/shells/logs) up front -- a run's background
commands can therefore write each other's logs, but not their own exit code or
name, which live outside that root. Anywhere else (hardened, a dispatcher with
no run) each command gets its own launcher. Under strict it:
- Forks a new user/mount/PID/IPC/UTS/net namespace.
pivot_roots into a minimal bind-mount rootfs on a fresh tmpfs: cwd + private/tmpwritable; system paths read-only;extra_read_pathsandextra_write_pathsat their real paths; operator-tool dirs as read+exec mounts. EVERY mount is at the path it has outside, the cwd included, so a path means the same thing on both sides of the boundary: an absolute path the model produced, an MCP server's own argv, and an editable install's.pthall resolve. Tool mounts never include agent6's own private dirs (config + state, either direction of containment) or$HOMEand its ancestors. A tool dir whose read-only remount fails is detached; a failed detach refuses the run. Command jails and machine tool jails share this one computation.- A bin symlink resolving OUT of its bin dir mounts the target's whole
directory read-only (
~/bin/x -> ~/.ssh/helperexposes~/.ssh). That is allowed -- the operator placed the symlink, and guessing at which directories hold keys would be enumerating badness.agent6 checklists them; it is not a per-run warning, because on a normal machine every uv-installed tool resolves this way. A tool the jail cannot reach AT ALL is different -- rare, and that tool silently will not run -- so that one is named at run start.
- A bin symlink resolving OUT of its bin dir mounts the target's whole
directory read-only (
- Hidden paths are masked last, after every bind, so no grant exposes
them from above: an empty tmpfs over a dir,
/dev/nullover a file. One mask per path, because every mount is at its real location -- there is no alias to close a second door on.- Always hidden: the config dir (provider keys) and the state base
(transcripts, notes, memories, run history), so they stay out of the
jail even under an
extra_read_pathsgrant of$HOME.[sandbox].hide_pathsadds operator entries. NOT the data dir or the cache: installed skills are content the model is meant to use (a skill's bundled script has to be runnable), and the cache is regenerable -- both are grantable. - A policy grant BENEATH a hidden root (a machine's data dir under the state dir) is re-bound through the mask at its real path.
- An extra grant AT or INSIDE a private dir is refused at config load.
hardenedhas no mount namespace and Landlock has no deny rules, so it cannot mask. A granted region containing a private dir gets a LOUD WARNING naming what becomes readable, not a refusal: the grant is a choice the operator may mean, and real protection remains (writes stay confined to the workspace,/rootand the rest of the host stay unreadable, seccomp applies). An explicithide_pathsentry it cannot mask does refuse -- the same rulenetworkfollows, where a default degrades and a written-down value enforces.
- Always hidden: the config dir (provider keys) and the state base
(transcripts, notes, memories, run history), so they stay out of the
jail even under an
- Exposes curated
/dev(null zero urandom random full, plus a privateshmtmpfs — POSIX shared memory is ordinary for real toolchains and a headless browser aborts without one. Strict only: there it is the jail's own, while on hardened it would be the host's, shared with every other process of this user); omits/dev/tty(it would let a child write escape sequences to the parent's terminal). - Mounts a fresh private
/proc; if that fails, leaves/procempty (never the host's, which would leak process info).- The launcher is PID 1 of that namespace, so
/proc/1/environis readable by the jailed command. It is spawned with an EMPTY environment for that reason: inheriting agent6's put a provider key supplied viaapi_key_envone file read away from the model. The launcher needs none -- its policy arrives on stdin and the child's env is set explicitly in it.
- The launcher is PID 1 of that namespace, so
- Applies Landlock FS rules (net confinement is the namespace); best-effort: a kernel without Landlock skips this layer, warned loudly at run entry.
- Installs a seccomp deny-list: dangerous syscalls (ptrace and its pidfd-era
fd-theft sibling pidfd_getfd, process_vm_readv/writev, kcmp, io_uring_setup
(whose ring bypasses this filter), userfaultfd, mount, setns, unshare, kexec,
bpf, perf, keyctl, module loading, reboot, clock-set, …) return
EPERM, the rest allowed. This is a default-allow denylist; podman/docker default-DENY and allowlist, reaching the same result for these by omission. - Sets
NO_NEW_PRIVS, so the kernel ignores setuid bits (sudo/setuid can't escalate). execves the binary and SIGKILLs the group at the wall-clock timeout.
Notes:
- The memory cap is operational, not a threat-model control, and is OFF by
default. A memory bomb is a denial of service against your own machine and
the kernel already handles it, while a cap costs real builds. Set
[sandbox].memory_limit_mbto bound a specific task: a per-processRLIMIT_DATA(notRLIMIT_AS, so V8/JVM/ASAN keep working) that stops one runaway allocation, nothing more. - The seccomp layer is a deny-list (defense-in-depth), not a boundary.
Known-dangerous syscalls only; nested-namespace creation via
clone/clone3stays allowed (accepted: the whole mount-syscall family is denied and Landlock is inherited and irrevocable, so a nested namespace grants nothing against the host; denyingclone3would break glibc/Go spawning). The real boundaries are the namespaces, Landlock, and the mount denials. - No
capset.strictmaps namespaced-root to your uid;hardenedkeeps the caller's caps (none for a normal user). hardeneddrops the namespaces + rootfs; Landlock, seccomp,NO_NEW_PRIVS, and the timeout remain.- The policy arrives as JSON on stdin from
run_in_jail; the Rust side validates it against a strict schema and refuses unknown fields.
2a. Environment: sudo, packages, provisioning¶
The jail is one-way: the agent works within the environment you give it and can't expand it.
sudocan't escalate, even passwordless.NO_NEW_PRIVSvoids setuid, so jailedsudofails regardless of anyNOPASSWDrule.- Package installs are impossible.
apt/dnf/apkneed all three of root (blocked), mirror network (provider-only egress), and/usr//varwrites (denied). - Compiling and running host-installed toolchains works.
- Every command tool --
run_command,run_verify_command,stop_background-- answers torun_commandsand runs jailed. They just can't install new tools, and a networked build step needsnetworkloosened. - The verify gate is a command like any other:
run_commands = "no"withholds it too, and such a run starts gateless rather than chasing a green it can never reach.
- Every command tool --
- Provisioning is operator-first. Install toolchains, venvs, and deps
yourself before/outside agent6; widen access via config, never sudo
(
extra_read_paths,network,[providers.*].base_url, all inconfig show). - Running agent6 as root (
--allow-root/AGENT6_ALLOW_ROOT=1) weakens the boundary.strictmaps inside-root to real root, so jailed children run as real root under only Landlock + seccomp +NO_NEW_PRIVS.- Still no writes outside the workspace and no egress beyond providers, but
the allowed reads now include root-only files (
/etc/shadowunderhardened;strict's rootfs hides them). Run as your normal user.
2b. Host-side subprocess allowlist¶
Everything the model can influence runs through run_in_jail (§2). A fixed set
of modules also shells out directly with subprocess.run/Popen; each has
fixed argv depending only on operator input, never LLM output.
tests/security/test_subprocess_allowlist.py pins the file list; audit with
rg 'subprocess\.|os\.(system|exec|posix_spawn)' src/agent6/.
git_ops.py: agent6's own git operations (§5).sandbox/detect.py: probes the host's sandboxing capabilities.sandbox/exec_confined.py: confines itself, thenexecvps the argv after--— the restrict-self-then-exec shim for a long-lived child agent6 spawns but does not drive (a configured MCP server; the jail launcher owns stdio, which cannot host a live MCP pipe). Both mechanisms are inherited by everything the server spawns: the Landlock domain is irrevocable acrossexecve, andnetwork = "none"unshares a user + network namespace that the stdio pipes survive. Each stands alone; asked for neither, the shim refuses rather than exec unconfined while looking confined. An applied domain grants the five inert/devnodes ahead of the operator's paths. A kernel forbidding unprivileged userns makes anetwork = "none"server refuse rather than start connected. Argv and paths are the operator's, from config.-
sandbox/jail.py: the jail launcher itself. -
tools/mcp_client.py: operator-configured[mcp.servers.*]server commands. providers/token_command.py: the operator-configured[providers.*].token_commandthat mints a provider bearer; argv from config.sessions/ipc.py:ps -p <pid> -o lstart=on hosts without /proc (macOS) for the worker.pid start-time identity; fixed argv over a pid agent6 itself recorded.ui/cli/_btw.py: spawnsagent6 askdetached for/btw, so the side question keeps provider egress when the run itself is confined. argv is the agent6 exe plus the question the OPERATOR typed at the pause menu -- never LLM output -- with--before it so a question starting with a dash cannot read as a flag.ui/spawn.py: the shared front-end spawn helper; spawns the agent6 CLI detached for run/machine launches and capturessessions merge/prune/config set; argv is the agent6 exe plus operator-chosen args.ui/notify.py: firesnotify-sendwith fixed argv (exe,--end-of-options, two positional data args, no shell) for the device-present machine notification; the message is inert data, never a command or an option.ui/cli/helpers:$EDITORfor plan, notes and steer editing.git diff/logfor the review subcommand and thesessions/askdiff views; argv from the run manifest the CLI wrote outside the jail.rgfor history search.- The fixed-argv
python -m agent6.ui.tuico-process behindrun --tui. ui/cli/system_cmds.py:cp/rm/apparmor_parservia sudo with fixed argv foragent6 system apparmor(operator host setup).
app/helpers:app/finalize.py: the operator[notify].on_completehook fired at run end; argv from config, env fromhook_env(a minimal base plusAGENT6_SESSION_*, never the provider keys in the operator environment).app/machine/_scriptcheck.py: ruff/ty with fixed argv to statically read generated scripts, which only ever execute viarun_in_jail.- The
machine runsupervisor (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, never on argv; its operator[machine.notify].on_eventhook (argv from config, fired fromapp/machine/_preflight.py) runs on the host with the same minimalhook_envbase plusAGENT6_MACHINE_*, mirroring[notify].on_complete. ui/cli/skills_cmds.py:git clone --depth 1 -- <url>with fixed argv foragent6 skills install; the URL is operator-supplied on the CLI and nothing fetched is ever executed.
ui/tui/clipboard.py: fixed-argvtmux set-buffer -wwith the copied transcript text as one inert data argument.ui/tui/conversation.py: the operator's$PAGER, argv from the environment, transcript text on stdin.
3. Isolation-level selection¶
You set sandbox.isolation; it resolves against the host to the effective
isolation level. No silent downgrade: a request the host can't meet is refused, and
auto reaches none only when the host offers no confinement mechanism at
all (non-Linux, or a Linux kernel with neither userns nor Landlock) -- always
loudly. Capabilities are probed (unshare for userns, the Landlock ABI
syscall for hardened), never guessed from the kernel version.
sandbox.isolation |
Host | Effective |
|---|---|---|
auto (default) |
Linux + user namespaces | strict |
auto |
Linux, no userns, Landlock | hardened |
auto |
Linux, no userns, no Landlock | none (loud warning) |
auto |
non-Linux | none |
strict |
Linux + user namespaces | strict |
strict |
else | ⛔ refuse |
hardened |
Linux + Landlock | hardened |
hardened |
else | ⛔ refuse (Landlock is hardened's only FS boundary) |
none (opt-out) |
any | none (the environment is the boundary) |
- strict: full namespaces +
pivot_root+ Landlock + seccomp +NO_NEW_PRIVS.- On a kernel without Landlock the jail's in-rootfs Landlock layer is skipped (namespaces, read-only binds, and seccomp still confine) with a loud once-per-run warning; hardened has no mounts to fall back on, so it refuses instead.
- hardened: Landlock + seccomp +
NO_NEW_PRIVS, no namespaces.- Works in default-seccomp Docker (the container blocks the inner
clone(CLONE_NEW*)); the container is the blast radius. - With no PID namespace, teardown is the agent's job (§5): it holds
PR_SET_CHILD_SUBREAPER, and each command kills every process that appeared during it from outside the agent's session. A survivor the sweep cannot kill fails the command rather than passing silently.
- Works in default-seccomp Docker (the container blocks the inner
-
none: unsandboxed, always with a loud warning.
-
Unsandboxing is explicit and self-authorizing.
isolation = "none",--dangerously-disable-sandbox, orAGENT6_DANGEROUSLY_DISABLE_SANDBOX=1. The LLM can't reach argv/env, so setting one is the consent. - Sandbox-off + auto-approved
run_commandadds a one-time gate. For that combination only:Continue? [y/N]interactively, a warning in CI/machine run. - CI should set
strictto fail loud if the sandbox is weaker than expected.
4. Fixed tool surface¶
fetchis the model's only egress, and it is narrow. One https URL, GET, no redirects followed, no credential, text only, 1 MiB. Hosts onsandbox.fetch_hostsare read without asking; any other host prompts, and an absent operator is a no. It exists because a jailed command has no route off the box, so it is hidden entirely whennetwork = "host". A GET can still carry data out in its path -- the allow-list is empty by default for that reason. Nothing resolves before that gate either: a DNS query delivers the hostname to whoever runs its authoritative server, so an unapproved URL never reaches a resolver.- An MCP server's tools are approved per call, on their own scope. A
server does fixed things, but the model chooses the arguments, so each
mcp__<server>__<tool>call prompts with those arguments ([mcp.servers.<name>].approve, defaultask). An "allow all" answer covers that server for the run: never the command tools, never a sibling server. The prompts that offer no standing answer at all (fetch's off-list host, the sandbox-off gate) say so, and no front-end shows the button. A tool name that names no configured server is refused rather than prompted about: the scope becomes a filename, and the model picks the name. - The LLM only sees the fixed set in
src/agent6/tools/schema.py.- Structured edits, read-only navigation, fixed-argv verify/metric commands,
finish_session,ask_user, a curator task notepad, a cross-run memory notepad, and capability-gatedrun_command. - No
shell, nowrite_file(writes go throughapply_edit, which refuses paths outside cwd), noweb_fetch, noeval. - Adding a tool needs a security review note (AGENTS.md).
- Structured edits, read-only navigation, fixed-argv verify/metric commands,
- The memory notepad and notes scratchpad are prompt-injection persistence
channels.
add_memory/invalidate_memory(run mode) write fixed markdown under<state-dir>/<repo-id>/memories/(code picks the path; the model supplies only a schema-validated scope + text); active notes join later runs' system prompt on the same repo.write_notesreplaces one fixed file,<state-dir>/<repo-id>/notes.md, on the same terms: code owns the path, the model supplies only a length-bounded string.- Mitigated: both are inert data (never executed), the injected blocks are
size-capped and framed as untrusted, and both stores are operator-auditable
(
agent6 memory list --all,agent6 memory invalidatekeeps the trail; notes are one readable markdown file). Neither is ever mounted into the jail, so a jailed command cannot reach or rewrite them. - Notes are whole-file replace, so a hostile write can erase earlier notes. That is the same authority the agent already has over its own context and costs nothing outside it; memories, which carry the audit trail, stay append-only precisely because they must survive this.
- Neither weakens a boundary here: sandbox/egress/git policy come from config, not prompt content.
5. Git invariants¶
- agent6's own git refuses the destructive ops, by construction.
git_ops.pyis the only module through which agent6 invokes git; it wraps the safe ops (status, add, commit, diff, branch, checkout) and refusespush,reset --hard,commit --amend,rebase,filter-branch/filter-repo,branch -D/--force, and any--force/-fon a destructive verb.
- A
gitthe model runs viarun_commandis bounded by the sandbox, not this list, and its argv is NOT screened.protect_git(default on) keeps.gitunwritable understrict, which re-binds it read-only, recursively: a mount nested under it (e.g..git/objectson its own bind) stays visible and read-only rather than shadowed. A rewrite fails andpushhas no egress. It is STRICT-ONLY: see below. Onhardenedthe default degrades with a warning and an explicitly-settruerefuses to run.- The protected scope is the project's own
.git: agent6's operational state, the repository it commits to each turn. A nested.git(a vendored repo's, a submodule's) is content, like any other file in the workspace -- tracked by the root repo or untracked, with no guarantee offered either way. Naming it would close nothing: a planted git config is one host-execution vector among many in repo content (.envrc, aMakefile,conftest.py). run_commandargv carries no git-verb screen: a blocklist enumerates badness, and a script the model writes walks past it. The jail owns that boundary.
- git_ops neutralizes repo-controlled host code in a poisoned
.git/config.core.fsmonitoranddiff.externalare always off;.git/hooks/*run only undergit.run_repo_hooks = true(default false;core.hooksPathpoints away so a hook can't fire on agent6's auto-commit).- Content drivers —
filter.<n>.clean/smudge/processandmerge.<n>.driver— are the same RCE class (the clean filter runs on the auto-commit'sgit add, the merge driver on the chain merge'smerge-tree; a cloned poisoned repo fires either with no model action). Off by default (git.run_repo_filters), neutralized per name since git has no blanket switch.truehonors them — the Git-LFS opt-in. - Defense in depth on top of
protect_git: those settings bound what a poisoned.git/configcould do, andprotect_gitstops the model writing one in the first place. - git's environment is stripped of provider keys. agent6's git runs on
the host and inherits the environment; the configured
api_key_envnames are removed from it, so a credential helper or content driver git invokes never inherits a provider key. git keeps everything else (PATH, SSH, proxy, credential-helper vars). protect_gitis strict-only, and hardened leaves.gitwritable. The threat is real there: a jailed command can plant afilter.<n>.cleanplus a.gitattributes, and agent6's own auto-commit (a temp-indexgit add -Aon the HOST, outside the jail) then runs it, reaching$HOMEand the network. Hardened has no mount namespace, so the only tool is Landlock, and Landlock cannot express this. Two of its properties close the door together: a grant on a directory is RECURSIVE (no "this directory only"), and stacked rulesets INTERSECT (an access needs every layer to allow it). To deny.gitsome layer must not grant it, which by recursion means not granting the workspace root either. Measured, both shapes: granting the root allows.gittoo; a children-only carve denies every NEW top-level entry (touch,mkdir,mkfifofail at the root) -- too much to pay for a protectionstrictprovides properly. The in-process edit tools (apply_edit/apply_patch) refuse, on both isolation levels, a write into the project's own.git, raw or symlink-resolved. That guard covers only in-process edits, not jailed commands. The name is matched case-folded, on every platform: macOS and Windows open.GIT/configas.git/config, and macOS runs unsandboxed, where this refusal is the only thing protecting.git. Same forsite-packagesand an operator protect path.
- The edit tools refuse writes into an in-repo venv or
site-packages.- A
pyvenv.cfgdir orsite-packagesancestor: a run rewriting an editable-install.pthwould silently corrupt the venv, invisible inruns diff/merge since venvs are gitignored. Reads stay allowed. - An editable install imports itself inside the jail: the repo is bound
at its real path, so the host path its
.pthrecords is the same path inside.
- A
5b. Secrets, connect, root¶
- Provider keys are
0600, owner-only, and never leave agent6's process.- In
$XDG_CONFIG_HOME/agent6/secrets.toml(refused if group/other-readable or foreign-owned, like an SSH key), or from[providers.<name>].api_key_env(env wins). Never in transcripts, never inconfig show(redacted), never in the jail: the config dir is masked there even when an explicit grant covers it, and a grant naming it directly is refused at config load.
- In
agent6 connectnever executes remote input.- It only prompts locally (
getpass) and writes config/secrets. It makes one read-onlyGETto the provider's key endpoint to confirm auth (status only;--no-verifyto skip). - During a run agent6 opens no listening socket (an MCP server is spawned on
stdio or DIALLED at an operator-set
url-- outbound either way, never a listener; the web UI is private unix socket); the only accept-side socket is opt-inagent6 web(§7).
- It only prompts locally (
- Running as root is refused without an explicit opt-in.
--allow-root/AGENT6_ALLOW_ROOT=1(+ a banner). Undersudo, agent6 reads the real user's config/secrets (fromSUDO_UID/SUDO_USER), not root's, and chowns state-dir writes back. It doesn't drop privileges in-process: the jail, not the uid, is the boundary.
6. Curator + state location¶
- An in-process
GraphCuratorowns the task graph.- It validates every mutation against a pydantic schema before writing, and holds a per-mutation flock on the session dir. A write-path fault after the in-memory update reloads from disk before surfacing, so a later read never observes a node that was never persisted.
- The session directory is safe because of its location, not any single writer.
- Per-repo state lives at
$XDG_STATE_HOME/agent6/<repo-id>/(override with[agent6].state_dir), outside the cwd jailed commands run on.
- Per-repo state lives at
- The config write lock is a concurrency optimization, not a boundary.
- Publishes are atomic, so a torn config is impossible with or without it;
the lock only serializes read-modify-write cycles. It FAILS OPEN by
design (a planted symlink is refused
O_NOFOLLOW; a stale root-owned lock is ignored), so it is never a way to block or redirect a write. Without the lock a rollback could erase a concurrent writer's update, so the write is kept and the error says "kept as written" (docs/config.md).
- Publishes are atomic, so a torn config is impossible with or without it;
the lock only serializes read-modify-write cycles. It FAILS OPEN by
design (a planted symlink is refused
6b. Parallel lanes (fan-out / coordinator dispatch)¶
agent6 run --parallel, agent6 sessions compare, and a live run's /parallel
steer directive (§ architecture.md)
each spawn subordinate work. Nothing here loosens the sandbox:
- Every lane is an ordinary run. A lane is a plain detached
agent6 runon its own clone: its own jail persandbox.isolation, its ownrun_commandspolicy. Nothing shares a sandbox socket across lanes or with the parent run. - Recursion is blocked by an env guard, not policy. Every spawned lane
carries
AGENT6_SUBRUN=1; both the--parallelflag and the coordinator'slane_spawnerwiring refuse when it is set, so a lane can never itself fan out or dispatch (depth 1 by construction). - A lane's config carries key references, never secret values. The
orchestrator writes each lane a
--configfile viamaterialize(), a dump of the resolvedConfigmodel (providerbase_url,api_key_envnames, etc.) --Confignever holds a raw API key. The lane's own process reads the samesecrets.toml/ provider env var as any other run, same user, same host. - No new subprocess call site.
workflows/subrun.py,app/parallel.py, andui/cli/parallel.pyadd no directsubprocessuse; lane git plumbing (clone/fetch/merge) goes throughgit_ops.pyand lane spawning goes throughui/spawn.py, both already on the §2b allowlist. Thetests/security/test_subprocess_allowlist.pypin needed no new entry. - Dirty-tree refusal, not auto-stash. 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 any changes), so
--parallelrefuses a dirty origin undergit.require_clean_worktreerather than carrying uncommitted work into a lane it cannot see.
7. No agent-owned network surface (except opt-in agent6 web)¶
- The loop opens no accept-side socket.
- Only outbound HTTPS to the provider; the task graph is an in-process curator, no socket.
agent6 webis the one accept-side socket, and only when you start it.- Loopback (
127.0.0.1) by default, no app auth (run behindtailscale serve; the tailnet identity is the access control, see the web UI). - A non-loopback bind is refused unless opted in:
[web].hostneeds[web].allow_non_loopback = true,--hostneeds--allow-non-loopback.
- Loopback (
- The server renders folded state and drives typed contracts only; it executes
nothing.
- New-work spawns fixed argv with the task behind
--; machine-run is allow-listed to authored files; answers write only the addressed run's answer files (session id, answer id, machine target state dir each validated to one path component); merge/prune/config-set are fixed agent6 subcommands.
- New-work spawns fixed argv with the task behind
- State-changing POSTs carry a CSRF guard.
- Body must be
Content-Type: application/json(a cross-sitefetchwith it triggers a preflight the server never answers) and anyOriginmust matchHost. Holds on loopback and behindtailscale serve. - It does NOT cover DNS rebinding (that needs a Host allow-list incompatible with the tailnet name).
- Body must be
- Request framing is bounded. 1 MiB body cap (413), chunked refused (411), any unread-body refusal closes the connection.
- The machine write surface (
POST /api/machine/<name>/{poke,answer,approve,steer}) uses the same guards.pokewrites only the instance signal file (inert JSON the nexttoolreads); the others write only the current agent state's per-state dir. PWA assets are static; the service worker is a no-op passthrough (no Web Push/VAPID). - No telemetry, no auto-update, no remote control plane.
8. State-machine egress + script bundles¶
machine runis a supervisor that makes no network calls.- Each
toolstate is jailed, so a per-toolnetworksets its netns independently. - This lets a machine keep agents on the provider API while one reviewed,
fixed-argv
toolreaches the network: unlikerun_command(LLM-chosen argv), atoolisn't a free exfil channel.
- Each
Egress = network × per-tool network; the effective isolation
level decides what's enforceable. Neither session nor none reaches off the
box; they differ only in who else is in there.
Agent egress is unconfined at every isolation level (claim 3). Only jailed commands have a network boundary.
Which network a jailed child joins. Three answers, one vocabulary: host
(the machine's), session (the run's own — its members reach each other and
nothing off the box), none (its own, alone). auto is the default everywhere
and picks the safest that works.
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.
By network (cells = strict, the only level with namespaces to give):
| jailed command | auto (def) |
session |
only_explicit_states |
host |
|---|---|---|---|---|
run_command |
the run's session network | same | same | host network |
tool, network auto(def)/block |
own, alone | own, alone | own, alone | own, alone |
tool, network = host |
⛔ refuse | ⛔ refuse | host network | host network |
An MCP server takes the same vocabulary per server, defaulting to none; a
server set to session joins the run's network, which is how a browser server
reaches the dev server a background command started.
auto is the secure default that runs everywhere (see AGENTS.md "Secure by
default, degrade or refuse"): on strict it is the session network above; on
hardened (no netns) there is none to give, so a jailed child inherits the
agent process's network and a once-per-run warning says so. session and
none are the ENFORCE forms — they refuse on hardened rather than run
under-confined. (On none isolation nothing is enforced or refused: it is the
explicit unsandboxed opt-out with its own loud warning, below.)
Refusals (fail-closed):
| Configuration | When |
|---|---|
a tool sets network = host under network auto/session |
machine start |
network = only_explicit_states, or explicit network = private |
run start, hardened ¹ |
a machine with tool states, or a tool with network = none, under network auto/session |
machine start, hardened ¹ |
-
⚠
none(non-Linux, or explicit opt-out) is unsandboxed: nothing enforced, nothing refused, loud warning. -
¹ a network namespace is
strict-only. Onhardeneda jailed child shares the host network. The secure defaultautodegrades there with a warning; an EXPLICIT enforce (session,none,only_explicit_states) refuses rather than run silently under-confined.
More fail-closed properties:
- Operator-gated policy.
networkis read only from the operator's config; a machine's[config]overlay is rejected at load if it declares[providers.*],[sandbox.*],[presets.*],git.run_repo_hooks, orgit.run_repo_filters.- Otherwise a strategy preset or a host
[machine.notify]argv could splice into the resolved config, andrun_repo_hooks/run_repo_filterswould run repo.git/hooksor a content driver on the host on amode="run"commit. Atoolonly declaresnetwork; honoringallowis the operator's call, and every conflict is refused at startup naming the state.
- Otherwise a strategy preset or a host
- Bundle confinement. Scripts live in a reviewed
scripts/beside the.asm.toml;machine checkverifies every entry and static reference resolves inside the bundle (escaping symlinks rejected).- Scripts are operator-authored and committed, never fetched/generated at run
time, and the
.asm.toml+scripts/are RO in every jail during a run, so a state can't rewrite its own logic or add annetworkflag.
- Scripts are operator-authored and committed, never fetched/generated at run
time, and the
- Notifications don't widen the agent's surface. Front-ends render
machine.notifyas an overlay, andattach/TUI callnotify-sendwith a FIXED argv (no shell), so a model message is inert data.- The out-of-band hook
[machine.notify].on_eventruns an operator argv on the host with a minimal env -- PATH/HOME/locale/desktop-bus plusAGENT6_MACHINE_*(hook_envinapp/finalize.py), never the full environment with its provider keys (mirrors[notify].on_complete); a[config]overlay setting[machine.notify]is rejected at load. No Web Push/VAPID.
- The out-of-band hook
Skills trust model¶
- A skill is config, not repo content: install only from trusted sources.
agent6 skills install <url>is an operator-initiated CLI fetch (same trust class asconnect); what it installs enters the system prompt/tool results verbatim.
- Nothing in a skill runs at install or load.
- Its scripts run only if the model runs them through the jailed command path,
subject to
run_commands.
- Its scripts run only if the model runs them through the jailed command path,
subject to
use_skillis read-only and path-contained. Serves the skill's own dir only, through a component-walked descriptor (any symlink hop or..refused), never the repo or network. Skill dirs aren't mounted into the jail; content reaches the model engine-side.- Repo-local
.claude/skills/are deliberately NOT discovered. Third-party repo content must not enter the prompt; only the installed dir +[skills].extra_dirsare scanned.
Prompt-injection resilience¶
tests/security/test_prompt_injection.py
runs an adversarial corpus through the planner/worker/reviewer prompts and
asserts no exfiltration, no out-of-policy tool calls, and no following embedded
instructions to weaken constraints. It's a smoke test, not a proof: the
structural defenses above are the real mitigation; the corpus catches prompt
regressions.
Known limitations¶
- User namespaces must be enabled; some distros disable them, and agent6
refuses
strictthere. -
AppArmor userns (Ubuntu 24.04+) blocks unprivileged userns without a profile.
- agent6 ships one scoped to the launcher (
agent6 system apparmor install); with it, per-command jailing isstrict, without ithardened.
- agent6 ships one scoped to the launcher (
-
seccomp is required; kernels that block it from unprivileged callers make the jail fail closed.
- Devcontainers get
hardened; the container is the FS blast radius, network still Landlock-confined when supported. The XDG state base is ephemeral (lost on rebuild), so mount a volume at the state dir or set[agent6].state_dirto persist runs. - 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, and the next tool call runs the rewrite as
you, outside the jail. Install agent6 outside the tree (pipx /
uv tool); agent6 warns at run entry when it detects this shape. - Side channels: no claim about timing/cache/speculative side channels; don't co-locate agent6 with secrets if Spectre-class attacks are in your model.
- Supply chain: pin your install. Runtime deps
pydantic,httpx2,argcomplete, thetree-sitterpair,textual,ruff,ty; build-dephatchling; the jail's Rust cratesnix,libc,landlock,seccompiler,serde,serde_json.