SPB Git

Repository Public

security.md 14.6 KB

# 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:

  1. 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.
  2. The Internet — anyone reaching https://agent.spboucher.ai: credential guessing, session theft, CSRF, XSS through model / tool output rendered in the console, WebSocket hijacking.
  3. 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_READ must 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: open has 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 default none. 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-exec is deprecated by Apple (still functional on macOS 27; the self-test fails closed if it stops enforcing).
  • Shell re-execution window. With network: none a shell step is classed idempotent_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. With network: open the owner is asked instead.
  • Git tools outside the sandbox. git_status / git_diff / git_commit / git_push run git directly 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.decision events 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 journaled wait steps) 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: mld deployment, the tunnel route, PM2 kill_timeout, backup.sh / restore.sh against the real database (docs/deployment.md).
Files main
Menu

Theme