Security
State on 2026-11-10 (phases 0–2). Every control listed here exists in the code and names the test that exercises it; the "Known gaps" section is the honest remainder.
Threat model
Agent OS runs model-driven tool calls on a personal Mac that also holds other projects, credentials and a cluster. One owner (a single administrator account). What we defend against:
- The agent itself — a model that is wrong, manipulated by content it reads (prompt injection in a repository, a tool output, later a web page), or simply over-eager: it may try to read secrets, write outside its project, exfiltrate data, push code, burn money, or loop forever.
- The Internet — anyone reaching
https://agent.spboucher.ai: credential guessing, session theft, CSRF, XSS through model / tool output rendered in the console, WebSocket hijacking. - Crashes and restarts — a worker killed mid-step must not repeat an external effect, lose an approval, or leave a mission in a state nobody can explain.
Out of scope today: a malicious local user on the node, a compromised provider, a compromised dependency, physical access.
Controls implemented — and the test covering each
Agent containment
| Control | Where | Test |
|---|---|---|
Decisions are taken by the engine, never by the model: hard denies → any deny rule (project or global) → any ask → any allow → defaults by risk (read / workspace write: allow; network / external: ask) |
packages/engine/src/policy.ts |
packages/engine/test/policy.test.ts ("precedence: deny > ask > allow regardless of scope", "defaults by risk", "owner rules: deny beats allow whatever the scope") |
| No write, shell or git-write call before the owner has confirmed done criteria | policy.ts (criteriaConfirmed), control.ts |
policy.test.ts ("no write before the criteria are confirmed"), criteria.test.ts; real run scenario 3 |
| Approvals are durable waits; expiry = deny; "approve and remember" creates a project-scoped allow rule that never covers compound shell commands | approval.ts, policy.ts (ruleMatches) |
policy.test.ts (approve / deny / expire / remember, "allow rules never cover compound commands"); real run scenario 2 |
Path safety for every file tool: inside the workspace by realpath, no .., no NUL, no symlink or hard-link escape |
packages/tools/src/paths.ts |
paths.test.ts (10 tests), files.test.ts |
Protected paths are never writable by any tool, whatever the policy: .git/ internals, .aos/, root AGENTS.md / CLAUDE.md, shell rc files |
paths.ts (protectedReason), Seatbelt profile |
paths.test.ts ("protected paths are refused for writes"), sandbox.test.ts ("protected paths are read-only from the shell", "…refused by the file tools too"), policy.test.ts ("protected paths, lexically") |
shell runs only inside a Seatbelt sandbox (sandbox-exec, deny-default): writes limited to the workspace and a per-run TMPDIR; fail closed when the launcher is missing, rejects the profile or does not enforce it |
packages/tools/src/sandbox.ts, shell.ts |
sandbox.test.ts ("cannot write outside the workspace…", "missing sandbox launcher ⇒ … NOT run", "a launcher that does not enforce the profile is detected…", "non-macOS platforms: disabled") |
Secrets unreadable from the sandbox: ~/.ssh, ~/.aws, ~/.config, ~/.gnupg, ~/.docker, ~/.kube, keychains, ~/.claude*, ~/.codex*, shell startup / history / credential files, the AOS data dir, env files outside the workspace; plus sibling projects under the workspace root, ~/Desktop, ~/Documents, ~/Downloads and AOS_SANDBOX_EXTRA_DENY_READ |
sandbox.ts (secretReadDenies), packages/engine/src/sandbox.ts |
sandbox.test.ts ("cannot read ~/.ssh, ~/.config/agent-os/dev.env, ~/.claude*…", "cannot read the AOS data dir, env files outside the workspace, or extraDenyRead paths"), engine tools.test.ts |
The command's environment is built from scratch (PATH, HOME, LANG, TMPDIR, TERM): no API key, nothing of the worker's env |
sandbox.ts (sandboxEnv) |
sandbox.test.ts ("never contains the worker secrets, only the allowlist", "sandboxEnv is built from scratch") |
network: none (default) blocks every connection including localhost; open allows outbound only and makes shell risk network (ask) and effect non_idempotent |
sandbox.ts, shell.ts |
sandbox.test.ts (network profile tests), policy.test.ts ("defaults by risk") |
| Timeouts kill the whole process group; output capped at 1 MiB; background jobs do not outlive the command; cancel kills the command | sandbox.ts |
sandbox.test.ts ("timeout kills the whole process tree", "a process that ignores SIGTERM is SIGKILLed", "output is capped…", "cancellation through the abort signal…"); real run scenario 5 |
Git tools run with a safe config (no hooks, no pager / editor, protocol.ext off, no credential prompt); git_push is risk external (approval by default), never forces, takes a remote name only |
packages/tools/src/git.ts |
git.test.ts; real run scenario 2 (nothing on the remote before the approval) |
| Budgets: per-mission USD / turns / wall-clock, a kernel hard stop before a model call once the budget is spent, and a daily cap for all missions | kernel context.ts, engine governors.ts, server POST /missions |
kernel budget.test.ts, engine governors.test.ts, server missions.test.ts (daily_cap_reached) |
Stall detection (same call + same result ×3, or 15 turns without progress) → note, then a question to the owner; a turn without tool calls is nudged twice then ends partial |
stall.ts, logic.ts |
governors.test.ts, loop.test.ts |
| Tool output shown to the model is bounded (12 000 chars, rest offloaded as an artifact) | packages/tools/src/output.ts |
files.test.ts, engine tools.test.ts |
Durable execution
| Control | Where | Test |
|---|---|---|
A step is journaled as an intent before it runs; a completed step is never executed again on replay; a diverging replay fails (NonDeterminismError) |
packages/kernel/src/journal.ts, context.ts |
kernel basic.test.ts, chaos.test.ts (child process SIGKILLed at every step boundary) |
| Leases with fencing: two workers never execute the same step; a zombie's writes are refused | lease.ts |
lease.test.ts ("lease expiry: another worker takes over, and the zombie's commit is refused by fencing"), soak.test.ts |
A crashed non-idempotent step is reconciled by a probe (git_push: compare the remote ref) or handed to the owner — never retried blindly |
context-waits.ts (askReconcile), tool-exec.ts |
chaos.test.ts, engine crash.test.ts ("a push killed after it reached the remote… pushed exactly once", "a network-enabled shell command killed mid-flight is never retried blindly") |
| Worker killed with SIGKILL mid-mission: a new worker resumes, no duplicated step or commit | whole stack | real run scenario 4 (apps/web/e2e-real/real.spec.ts) |
| Graceful worker stop leaves runs resumable, never marks them failed | apps/worker/src/worker.ts |
apps/worker/test/worker.test.ts, lease.test.ts ("graceful stop…") |
Console and API
| Control | Where | Test |
|---|---|---|
| Every route needs a full session (password + second factor) except the documented public / pre-auth ones; one global guard, also for WebSocket upgrades | apps/server/src/security/guard.ts |
http.test.ts ("every registered API route refuses unauthenticated callers…"), events-ws.test.ts; real run ("unauthenticated REST and WebSocket access is refused") |
| Password hashing (argon2id, scrypt fallback), login throttling (per user / IP / global, doubling lock), strict rate limit on credential endpoints | security/password.ts, security/throttle.ts |
security/unit.test.ts, auth.test.ts, rate-limit.test.ts, prod.test.ts |
Mandatory second factor: TOTP (secret encrypted with AOS_SECRET_KEY, codes single-use) or passkey (WebAuthn, RP = origin) |
security/totp.ts, security/webauthn.ts, modules/auth |
auth.test.ts, webauthn.test.ts; real run (first-run test) |
| One-time setup token read on the host (0600), never logged, deleted once the administrator exists | security/setup-token.ts |
auth.test.ts; real run |
Session cookie __Host-aos_sid: Secure, HttpOnly, SameSite=Strict; idle 12 h, absolute 30 d; rotated on upgrade; WebSocket re-validated every 60 s |
security/sessions.ts, modules/events-ws.ts |
prod.test.ts, auth.test.ts, events-ws.test.ts |
CSRF token + exact Origin on every mutation; Origin checked at WebSocket upgrade |
guard.ts |
http.test.ts ("every mutating route refuses a missing CSRF token and a foreign Origin"); real run (foreign-Origin upgrade refused) |
CSP on every response without unsafe-inline for scripts (per-document sha256 hashes of the static export); hardening headers; API responses no-store |
static.ts, app.ts |
http.test.ts; mock suite ("every route is free of console errors and CSP violations"); real run (no CSP violation under the real CSP, desktop and phone) |
Model / tool output is rendered as text: no links, no remote images, code escaped; artifacts are always downloads (octet-stream, attachment, nosniff, sandbox CSP) |
apps/web/src/lib/markdown.ts, server modules/missions |
web lib.test.ts ("never produces links or images"), server missions.test.ts (artifact headers) |
| Provider keys never reach the browser or the logs: the server holds booleans and scrubs the values from every response body / header, WebSocket frame and log line | apps/server/src/config.ts (SecretScrubber) |
leak.test.ts (journal polluted with the keys, every GET route); real run (all responses and WS frames scanned for the real key values) |
The provider-native transcript (native: thinking blocks, signatures, encrypted reasoning items) is removed from step results and step.completed events before they are sent; the browser gets only the normalised blocks (text, reasoning summary, tool calls) |
apps/server/src/browser-view.ts |
missions.test.ts, events-ws.test.ts; real run (journal fetched by the browser contains no native) |
Step bodies bounded to 20 000 chars; lease, fencing token, idem_key, workspace_abs never selected for the browser; every read is owner-scoped |
modules/missions |
missions.test.ts |
Workspace paths of projects confined to AOS_WORKSPACE_ROOT (realpath, no .git / .aos segment); clone URL whitelist, argv arrays, credentials stripped from stored URLs |
apps/server/src/paths.ts, modules/projects |
projects.test.ts |
| Audit of every mutation, without objectives, answers, steering text or credentials | audit.ts |
auth.test.ts, missions.test.ts |
Secrets at rest: .env 0600 written at deploy; provider keys only on the node (~/.config/agent-os/keys.env), never in the manifest or the PM2 environment; processes load AOS_ENV_FILE themselves and never override the environment |
deploy/make-manifest.py, env-file.ts |
env-file.test.ts (server and worker); generator run with fake keys in its environment: none in the output |
Known gaps
- Seatbelt read exposure. The sandbox is deny-default for writes; reads are allowed except an
explicit deny list. Anything readable by the worker's OS user that is not on the list (other home
sub-directories,
/Users/Shared, mounted volumes, files sitting directly in~) can be read by a shell command.AOS_SANDBOX_EXTRA_DENY_READmust be maintained by hand for each node (production sets~/apps,~/backups,~/Desktop,~/Documents,~/Downloads,~/dispatch,~/srv). Plain files directly inside an ancestor directory of the workspace stay readable. The file tools (read_file,search) are confined to the workspace; this gap is the shell's. network: openhas no egress proxy. Outbound traffic is unrestricted (inbound and localhost are blocked): a manipulated agent can send workspace content anywhere once the owner approved a network command. The mitigation today is the approval (once per command prefix when remembered) and the defaultnone. No domain allowlist, no traffic log.- No dedicated OS user. The worker runs as the node's main user; containment relies entirely on
Seatbelt and the tools' path checks.
sandbox-execis deprecated by Apple (still functional on macOS 27; the self-test fails closed if it stops enforcing). - Shell re-execution window. With
network: noneashellstep is classedidempotent_write: a step interrupted by a crash is executed again from the start. A command that is not idempotent inside the workspace (appending to a file, a half-applied migration script) runs twice partially. Withnetwork: openthe owner is asked instead. - Git tools outside the sandbox.
git_status/git_diff/git_commit/git_pushrungitdirectly with a hardened config, not under Seatbelt. A repository's own config cannot add hooks or an external protocol, but git is a large surface. - No compaction. At ~70 % of the context window the agent is told to wrap up; at ~90 % the run ends
partial. Long missions must be split. - Single agent only. No sub-agents, so no isolation between a planner and workers handling untrusted content; all tool output enters the one context.
- Prompt injection is mitigated, not solved. The system prompt tells the agent to treat file and tool content as untrusted data, and the policy is outside the model, but a repository file can still talk the agent into a bad allowed action (e.g. a destructive edit inside the workspace). The mission branch + commits are the undo.
policy.decisionevents are not replayable over REST. They are persisted and pushed live, but a page reload does not fetch old ones: after a reload the timeline shows approvals (from the journaledwaitsteps) and marks refused tool calls as "not executed" without the reason.- Refused tool calls have no journal step (invalid input, policy deny): they exist only in the model's transcript, which is not readable from the console.
- Mission status during an outage. With no live worker a mission still reads "running" until a worker claims it; only the System page says the worker is down.
- Single administrator, no roles; no IP allowlist in front of the login page; passkeys optional.
- No retention / GC for
events,steps, artifacts and audit rows; backups contain the journal (objectives, tool outputs) encrypted with a passphrase stored on the same node. - Not exercised in production yet:
mlddeployment, the tunnel route, PM2kill_timeout,backup.sh/restore.shagainst the real database (docs/deployment.md).