Repository Public
1# Security23State on 2026-11-10 (phases 0–2). Every control listed here exists in the code and names the test that4exercises it; the "Known gaps" section is the honest remainder.56## Threat model78Agent OS runs model-driven tool calls on a personal Mac that also holds other projects, credentials and9a cluster. One owner (a single administrator account). What we defend against:10111. **The agent itself** — a model that is wrong, manipulated by content it reads (prompt injection in a12 repository, a tool output, later a web page), or simply over-eager: it may try to read secrets, write13 outside its project, exfiltrate data, push code, burn money, or loop forever.142. **The Internet** — anyone reaching `https://agent.spboucher.ai`: credential guessing, session theft,15 CSRF, XSS through model / tool output rendered in the console, WebSocket hijacking.163. **Crashes and restarts** — a worker killed mid-step must not repeat an external effect, lose an17 approval, or leave a mission in a state nobody can explain.1819Out of scope today: a malicious local user on the node, a compromised provider, a compromised20dependency, physical access.2122## Controls implemented — and the test covering each2324### Agent containment2526| Control | Where | Test |27|---|---|---|28| 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") |29| 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 |30| 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 |31| 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` |32| 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") |33| `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") |34| 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` |35| 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") |36| `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") |37| 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 |38| 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) |39| 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`) |40| 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` |41| 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` |4243### Durable execution4445| Control | Where | Test |46|---|---|---|47| 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) |48| 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` |49| 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") |50| 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`) |51| 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…") |5253### Console and API5455| Control | Where | Test |56|---|---|---|57| 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") |58| 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` |59| 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) |60| 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 |61| 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` |62| 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) |63| 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) |64| 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) |65| 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) |66| 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`) |67| 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` |68| 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` |69| Audit of every mutation, without objectives, answers, steering text or credentials | `audit.ts` | `auth.test.ts`, `missions.test.ts` |70| 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 |7172## Known gaps7374- **Seatbelt read exposure.** The sandbox is deny-default for *writes*; reads are allowed except an75 explicit deny list. Anything readable by the worker's OS user that is not on the list (other home76 sub-directories, `/Users/Shared`, mounted volumes, files sitting directly in `~`) can be read by a77 shell command. `AOS_SANDBOX_EXTRA_DENY_READ` must be maintained by hand for each node (production78 sets `~/apps`, `~/backups`, `~/Desktop`, `~/Documents`, `~/Downloads`, `~/dispatch`, `~/srv`). Plain79 files directly inside an ancestor directory of the workspace stay readable. The file tools80 (`read_file`, `search`) are confined to the workspace; this gap is the shell's.81- **`network: open` has no egress proxy.** Outbound traffic is unrestricted (inbound and localhost are82 blocked): a manipulated agent can send workspace content anywhere once the owner approved a network83 command. The mitigation today is the approval (once per command prefix when remembered) and the84 default `none`. No domain allowlist, no traffic log.85- **No dedicated OS user.** The worker runs as the node's main user; containment relies entirely on86 Seatbelt and the tools' path checks. `sandbox-exec` is deprecated by Apple (still functional on87 macOS 27; the self-test fails closed if it stops enforcing).88- **Shell re-execution window.** With `network: none` a `shell` step is classed `idempotent_write`: a89 step interrupted by a crash is executed again from the start. A command that is not idempotent inside90 the workspace (appending to a file, a half-applied migration script) runs twice partially. With91 `network: open` the owner is asked instead.92- **Git tools outside the sandbox.** `git_status` / `git_diff` / `git_commit` / `git_push` run `git`93 directly with a hardened config, not under Seatbelt. A repository's own config cannot add hooks or an94 external protocol, but git is a large surface.95- **No compaction.** At ~70 % of the context window the agent is told to wrap up; at ~90 % the run ends96 `partial`. Long missions must be split.97- **Single agent only.** No sub-agents, so no isolation between a planner and workers handling98 untrusted content; all tool output enters the one context.99- **Prompt injection is mitigated, not solved.** The system prompt tells the agent to treat file and tool content as untrusted data, and the policy100 is outside the model, but a repository file can still talk the agent into a bad *allowed* action101 (e.g. a destructive edit inside the workspace). The mission branch + commits are the undo.102- **`policy.decision` events are not replayable over REST.** They are persisted and pushed live, but a103 page reload does not fetch old ones: after a reload the timeline shows approvals (from the journaled104 `wait` steps) and marks refused tool calls as "not executed" without the reason.105- **Refused tool calls have no journal step** (invalid input, policy deny): they exist only in the106 model's transcript, which is not readable from the console.107- **Mission status during an outage.** With no live worker a mission still reads "running" until a108 worker claims it; only the System page says the worker is down.109- **Single administrator, no roles**; no IP allowlist in front of the login page; passkeys optional.110- **No retention / GC** for `events`, `steps`, artifacts and audit rows; backups contain the journal111 (objectives, tool outputs) encrypted with a passphrase stored on the same node.112- **Not exercised in production yet**: `mld` deployment, the tunnel route, PM2 `kill_timeout`,113 `backup.sh` / `restore.sh` against the real database (`docs/deployment.md`).114