Skip to content

Security: AdamFrisby/Agnes

Security

docs/security.md

Operating Agnes securely (shared / multi-tenant hosts)

Agnes runs coding agents with real filesystem, process, and (optionally) network and git-credential access. On a shared host — one daemon that several developers pair to and launch agents on — that access is the whole attack surface. This document is the operator hardening guide: the config knobs that exist today, recommended settings, and the residual risks you should know about.

The sandbox is the primary isolation boundary (per-session Incus VM). Everything below is defence-in-depth around it, plus guidance for hosts that can't sandbox.

All keys live under the Agnes: configuration root (appsettings.json, environment variables, or any ASP.NET configuration source). Every guardrail is opt-in and defaults to the historical behaviour, so upgrading changes nothing until you turn it on.

Session guardrails (Agnes:Security:*)

Key Type Default Effect
AllowedSessionRoots string[] [] (unrestricted) Every session working directory must canonicalise to a location inside one of these roots. Boundary-aware (/srv/work admits /srv/work/a, not /srv/work-evil), collapses .., resolves symlinks on the existing prefix. Enforced on new / fork / handoff / resume, before any filesystem side effect.
RequireSandbox bool false The host refuses any session that would run outside a sandbox (sandbox opted out, or no provider configured). Fails loud instead of silently running the agent on the host.
RequirePermissionPrompts bool false The host forbids autonomous / --dangerously-skip-permissions sessions entirely — every tool call must be prompted. The strongest autonomy control.
AllowUnsandboxedSkipPermissions bool false Whether autonomous mode may run outside a sandbox. Default false: dangerous autonomous mode is confined to a sandbox unless you explicitly opt in.
AllowGraphicalSandboxes bool false Whether a session may ask for a graphical sandbox — a VM with a real display the agent can see and drive over computer_*, and a person can watch over the display channel. Default false, and deliberately so: a screen is a second, much wider interface into the guest than a shell; capture means the host is continuously holding pixels of whatever the guest is showing (a logged-in browser session, a password manager); and an agent that can move a mouse can click through confirmations no permission prompt ever sees. Implies a sandbox — the display exists at the VM boundary, so "a screen but on the host" is refused rather than silently downgraded. See display-channel.md for the channel's own auth, the control arbiter and the input budget.
AllowUsbPassthrough bool false Whether a project may pass host USB devices through to its sandbox VMs (a test phone on adb, a board on a serial port). Default false: a passed-through device is the host's hardware handed raw to a VM an agent controls — everything adb can do to that phone, the agent can do — and the host loses the device for as long as the sandbox holds it. A project on a host that has not opted in launches without its devices and says so in the log. See USB passthrough.
AllowedHostMcpServers string[] [] (unrestricted) Allowlist (by MCP server name, case-insensitive) of the only servers permitted to run with RunAt=Host — i.e. execute a command on the host, outside the sandbox. A non-allowlisted host server is dropped from the session's MCP set (with a visible notice) on both the direct and the sandbox-forward paths. Sandbox-run servers are unaffected.
SessionIsolation Shared | PerUser | PerGroup Shared How sessions are scoped to callers. Shared = today's behaviour (host owner sees all; others need an explicit share). PerUser also lets a caller reach the sessions they own (matched across their devices). PerGroup also lets group members reach a session (read/drive, not manage) via an IGroupProvider. The host owner stays an admin super-user in every mode; these are additive grants on top of shares.
RestrictConfigToOwner bool false Restricts host-wide config mutations — sandbox image manifest, project config, MCP registry, sandbox delete/reap — to the host owner rather than any paired device.
MaxConcurrentSandboxes int 0 (unlimited) Caps the number of concurrently-running sandboxes host-wide; a new sandboxed session is refused once the ceiling is reached.
TranscriptRetentionDays int 0 (keep forever) A daily sweep prunes event-log entries older than this many days. Session catalogue rows are kept (sessions still restore); only aged transcript content is removed.

All of these are enforced server-side in SessionManager, at the single shared open/resume path — so they hold regardless of what a client (desktop, web, the MCP/voice backends) sends. RequireSandbox and RequirePermissionPrompts are also advertised in HostInfo so a cooperating client locks the corresponding toggles, but that is UX only; the host is the enforcement point.

Recommended shared-host baseline

"Agnes": {
  "Security": {
    "AllowedSessionRoots": ["/srv/agnes/work"],   // ideally a per-user subtree, e.g. /srv/agnes/work/<user>
    "RequireSandbox": true,                         // needs Agnes:Sandbox:Provider configured (see deployment.md)
    "RequirePermissionPrompts": false,              // set true to ban autonomous mode outright
    "AllowUnsandboxedSkipPermissions": false,       // keep false: autonomy only inside a sandbox
    "AllowedHostMcpServers": [],                    // list names ONLY if you truly need host-run MCP servers
    "SessionIsolation": "PerGroup",                 // Shared | PerUser | PerGroup (see below)
    "RestrictConfigToOwner": true,                  // only the operator edits image/project/MCP config
    "MaxConcurrentSandboxes": 32,                   // 0 = unlimited
    "TranscriptRetentionDays": 90                   // 0 = keep forever
  }
}

Session isolation & groups

SessionIsolation controls who can reach whose sessions. Groups are a plugin point (IGroupProvider): the shipped backend treats a session's repo as its group and GitHub write access as membership (via the linked GitHub App's collaborator-permission API). So under PerGroup, everyone who can push to repo X can collaborate on X's sessions, and no one else can. Other membership backends (LDAP, SSO teams, a static roster) can ship as additional IGroupProvider plugins without touching core. A session's owner is the caller's GitHub login (falling back to device id), recorded at open time; a host Owner remains an admin who can reach every session.

The session-owner match applies in every mode, including the default Shared. Whoever started a session reaches it — matched across their identities, so a phone reaches what a laptop began when both resolve to the same GitHub login. This is not an isolation feature; it is the baseline. Making it conditional on isolation being on is what left a non-Owner device unable to subscribe to the session it had just opened. What Shared still adds nothing for is somebody else's session: that needs an explicit share, or host ownership.

Opening a session requires a paired device of any role, and stamps it as the owner. A public-link viewer cannot open one at all.

Egress control

RequireSandbox isolates the agent, but a sandbox still has open network by default. Two options, both host-enforced (a sudo agent inside the VM can't disable them):

Per-profile bridges (CodeyBox-compatible, recommended). Egress policy lives in the host kernel (nftables) on one filtered bridge per profile, and the bridge choice is the policy. This reuses CodeyBox's scripts/setup-host-networks.sh verbatim: run it once to create the cb-* bridges (each with its own allowlist — - = no egress, internet = no-LAN, or a hostname allowlist), then map profile names to those bridges and pick a default:

"Agnes": { "Sandbox": { "Incus": {
  "NetworkProfiles": { "locked": "cb-locked", "internet": "cb-internet" },
  "NetworkProfile": "locked"          // default for every sandbox; a spec may request another
} } }

Agnes creates each VM with --no-profiles and a single bridged NIC on the profile's bridge, so that bridge is the VM's only path out — nothing in the guest to flush. See CodeyBox's docs/host-firewall.md.

Incus network ACLs (Incus-only alternative). Define a default-deny + allowlist ACL in Incus and attach it to every sandbox NIC:

"Agnes": { "Sandbox": { "Incus": { "NetworkAcls": ["agnes-egress"] } } }

Either way, the trusted image-bake VM is left open so it can install tooling.

Usage attribution

GET /admin/usage (owner-gated when RestrictConfigToOwner is on) returns live per-owner session / sandbox counts, and the same summary is logged every 15 minutes — so you can see who is consuming the host. Combine with MaxConcurrentSandboxes to bound total load.

RunAt=Host MCP servers are arbitrary host code execution — that is by design (the host-forward shim runs the real command on the host when a sandboxed agent calls the tool). On a shared host, either leave AllowedHostMcpServers empty and configure every MCP server as RunAt=Sandbox, or allowlist a small, audited set of host servers by name. Treat adding a host MCP server as a privileged operation.

Vendor-pin the agent CLIs

By default the Claude adapter launches npx -y @zed-industries/claude-code-acp, which fetches from npm at session start — a runtime supply-chain dependency. Pin it to a vendored binary you control via the existing launch-command config:

"Agnes": {
  "ClaudeCode": { "Command": "/opt/agnes/bin/claude-code-acp", "Args": [] }
  // likewise Agnes:OpenCode:*, Agnes:ClaudeCodeNative:*, Agnes:Codex:*
}

Bake the agent CLIs into the sandbox image (see docs/sandbox-live-testing.md) rather than resolving them at runtime, and lock down who can edit the sandbox image manifest — a poisoned manifest pre-compromises every subsequent sandbox. Plugin installation is admin-gated and refuses unsigned packages by default; keep that on. Agnes:CustomBackends lets config point the launcher at an arbitrary command — treat it as admin-only.

Production plugin provenance (Agnes:Plugins:*)

Plugins execute code in the host process. In Production, Agnes therefore accepts only an operator-approved, exact package artifact: the source URL, package id, normalized version, and SHA-512 of the complete .nupkg must all match. Search results are limited to approved packages; installs cannot use latest; and an enabled plugin is rebuilt from a verified local archive before the host accepts clients. Legacy enabled-plugin records without provenance fail closed until they are reinstalled.

Leave both arrays empty when third-party plugins are not needed. To approve one, configure the specific HTTPS source and its immutable artifact together (the source list and approvals must agree):

"Agnes": {
  "Plugins": {
    "Sources": ["https://api.nuget.org/v3/index.json"],
    "ApprovedPackages": [
      {
        "Source": "https://api.nuget.org/v3/index.json",
        "PackageId": "Example.Agnes.Plugin",
        "Version": "1.2.3",
        "Sha512": "<base64 SHA-512 of the downloaded .nupkg>"
      }
    ],
    "AllowUnsignedPackages": false
  }
}

Calculate the digest over the downloaded package, not over an extracted directory:

openssl dgst -sha512 -binary Example.Agnes.Plugin.1.2.3.nupkg | base64 --wrap=0

Treat adding or upgrading an approval as a security-sensitive production change. It remains subject to normal NuGet signature verification; AllowUnsignedPackages is rejected outside the Development environment.

Authentication

  • Bootstrap methods are opt-in. Prefer GitHub SSO restricted to your org/users or OIDC over static pairing codes for a shared host. GitHub SSO already fails safe: it will not enable unless Agnes:Auth:GitHub:AllowedUsers or AllowedOrgs is non-empty.
  • Device tokens are per-device, individually revocable, and stored hashed. Revoke on offboarding.
  • CORS: never set Agnes:AllowAllOrigins=true on a shared/public host (it defaults to false). Set an explicit Agnes:AllowedOrigins for the web client.
  • Auth endpoints are rate-limited (Agnes:Auth:RateLimit:*).

Device roles: Owner and Member

A paired device is either an Owner or a Member, and which one it is depends on how it was admitted — never on when it arrived.

The rule this replaces was "the earliest-paired device owns the host". It reads as a sensible first-run heuristic and it is a booby trap: whatever wrote the first record into devices.json owned the machine, whether or not that was ever a real device. On the machine this was found on, the earliest record was a months-old test fixture — so no real device was the owner, and under the default SessionIsolation=Shared the operator was refused every session on their own host, including ones they had just opened. Ownership is now a fact on the record, written when the device is admitted.

Admitted by Role Why
Typed pairing code Owner The code is printed on the host's own console. Presenting it is the operator acting.
QR grant (POST /pair/grant → POST /pair) the minter's own role A grant hands over the standing of the device that showed it, and no more: an Owner's QR admits an Owner, a Member's admits a Member. Otherwise "show a QR" would be a promotion path for anyone already inside.
Configured bootstrap token (Agnes:PairingToken) Owner It is the operator's own secret, in the host's own configuration. It has no device record.
Authorized key (authorized_keys) Owner by default Somebody with access to the host's filesystem put it there. A host that hands keys to a team can set Agnes:Auth:Keypair:Role=Member and promote individually.
GitHub SSO Owner if the login is in Agnes:Auth:GitHub:Owners, else Member A GitHub login proves who somebody is, not that they administer this host.
OIDC / Cloudflare Access / mTLS Owner if the subject (or, for Cloudflare, the email) is in the matching …:Owners list, else Member Same reasoning.
Approval (POST /pair/approve/{id}) the role in the request body, capped at Member unless the approver is an Owner Vouching is not promotion. A Member's approval admits a Member however the body is crafted.

A Member is not a guest. It opens sessions like anybody else and always reaches the sessions it started, plus anything explicitly shared with it. What it cannot do is see other people's sessions or change host-wide configuration.

Owner-only, and enforced host-side: PUT /devices/{id}/role, POST /devices/prune, and (when Agnes:Security:RestrictConfigToOwner is set) the host-wide config mutations. The host refuses to be left with no Owner: the last one cannot be demoted or pruned, because a host with no Owner can never promote anybody back.

Upgrading an existing host changes nothing. A devices.json written before roles existed has none, so on first load Agnes marks the earliest-paired device an Owner — exactly what the old rule computed on every call — records that decision, logs it once, and never re-derives it again. An explicit Member on disk is somebody's decision and is never migrated over.

One device, one row. A sign-in that presents the same credential — the same key fingerprint, or the same GitHub/OIDC identity from the same device name — rotates that device's token instead of minting a second identity: same id, same role, same paired-at date, and the old token stops working. Two devices behind one account stay two rows, because the device name is part of the identity. A pairing code stands for nothing durable (it is single-use), so those always mint fresh.

The typed pairing code is a bootstrap, not a way in

The short code the host logs at startup (ABCD-EFGH) is sized to be read off a screen and typed, which caps it at about 40 bits. That is defensible exactly once — on a host nobody has connected to yet, behind rate limiting and a rotate-on-failure counter. It is not defensible as a standing way to join a host that is already in use.

So the code stops working the moment the first device pairs. After that, a new device joins one of two ways, both of which require an already-trusted device to participate:

What it is Entropy
QR grant An already-paired device calls POST /pair/grant; the host mints a 256-bit single-use secret with a 5-minute lifetime and returns it inside an agnes://pair?host=…&grant=… deep link to encode as a QR. The scanning device redeems it at POST /pair. 256 bits, never typed
Approval The new device posts its public key to POST /pair/request; an already-paired device sees it at GET /pair/pending and calls POST /pair/approve/{id}. n/a — nothing is guessed

Both are single-use. A grant is destroyed on redemption (so a photographed QR can't be replayed) and can be revoked early — which is what "hide the QR" does, so a code that was on a screen stops working when it stops being visible.

The approval flow shows six digits on both screens. Those digits are a comparison value, not a credential: knowing them grants nothing. They are derived as SHA-256(publicKey ‖ requestId), so the requesting device computes them independently rather than being told them — substituting a different key changes the number, which is what makes the comparison meaningful. This is why six digits is enough here when forty bits was not enough for the bootstrap code. The issued token is handed to the requesting device on its next poll and never shown to the approver.

An operator who genuinely needs the old behaviour (a lab host that is re-paired constantly) can set Agnes:Auth:Pairing:AllowCodeAfterFirstDevice=true. Don't do this on a shared host.

A QR carrying a grant is a credential. Treat a screen showing one the way you'd treat a password on a whiteboard — that is why the clients that display one can hide it again, and why the grant is revoked when they do.

In the desktop client this lives on a session's context menu, beside Restart agent: "Show QR to connect a device to this session…". Scanning it with a phone's camera pairs the device and opens that session, because the grant carries the session id alongside the host address. Hide revokes the grant server-side, so the code stops working the moment it leaves the screen rather than lingering for the rest of its five minutes.

Transport trust is bootstrapped from pairing. A self-hosted host has no CA, which used to leave two bad options: cleartext HTTP — which Android forbids outright (cleartextTrafficPermitted="false"), so the phone simply cannot reach the host — or self-signed HTTPS, which fails trust until someone hand-installs a certificate. Neither is acceptable for a single developer on a LAN.

So the host always serves its IHostCertificateProvider certificate on the HTTPS listener (previously relay-only), and the pairing material carries that certificate's SHA-256 as fp=. A client that paired from a QR pins it: the TLS handshake is validated against the pin instead of the OS trust store, in .NET's managed stack, which is also what puts it outside Android's network_security_config. Run the host, scan the QR, done — encrypted, with the host authenticated by the pinned certificate and the device authenticated by its per-device token, and nothing installed anywhere.

The pin is stronger than plain trust-on-first-use: the QR is displayed on the host's own screen, so the fingerprint reaches the phone over a channel no network attacker sits on, and even the first connection is verified rather than assumed. A certificate that doesn't match the pin is a hard failure with no prompt-and-continue, the way a changed SSH host key is. Re-pair to accept a new one.

The enterprise path is untouched: when a real-CA certificate is configured (Agnes:Transport:Relay:Cert -> ACME/DNS-01), that is what gets served, the host advertises no fingerprint, and clients validate the chain and host name normally with no pinning involved.

Operator note. Because the HTTPS listener now always serves this certificate, anything that reaches the host without a pin — a browser, curl, a client that was added by typing an address rather than scanning — sees an untrusted self-signed certificate, where it previously saw whatever Kestrel's default was (locally, the ASP.NET dev certificate). For a host that browsers must trust, configure a real certificate; the self-signed default is for the pair-and-pin path.

The address in a QR is a choice, not a fact. A host advertises one address (Agnes:PublicUrl, else whatever the transport resolved), and for a QR that is frequently the wrong one: a host bound to loopback is reachable from the desktop client beside it and unreachable from the phone holding the camera. So a grant response also carries every address the host believes it answers on — the operator override, the transport's, each non-loopback interface (which is where a Tailscale 100.64/10 address turns up), the hostname, and loopback last — and the client offers them, plus free text for anything the host can't see for itself such as a port-forward or a MagicDNS name.

Switching address does not re-mint. The secret is bound to the host, not to a route: it is minted by the host and redeemed at whichever address the scanning device reaches it on, so the client re-encodes the same grant locally. A code already on screen stays the code that works, and no extra credential is issued to try a second address. If the chosen address is wrong the phone simply fails to connect — redeeming at a different host fails, because that host never minted the secret.

The approval flow is reachable from both ends in both clients:

Asking to join Deciding
Desktop — Settings → Devices, above the paired list: each waiting device with its six digits, Approve / Decline.
Android Connect a host → "Ask for approval", which then shows the digits and polls. Inbox → "Devices asking to join", above the agent approvals.

Both approver surfaces show the digits next to the buttons, because approving without comparing them is the one way to use this mechanism and get nothing from it. Declining is the same size and distance as approving: "I wasn't expecting this" should be the cheap answer.

USB passthrough (Agnes:Security:AllowUsbPassthrough)

A project can list host USB devices its sandboxes get (Settings › Projects › USB devices, or UsbDevices on the project record). Each becomes an Incus usb device on the session's VM before it starts — for a VM, a QEMU usb-host on the instance's xHCI controller — and follows the physical device across a replug. What that means for the operator:

  • The device is the guest's, raw. Passthrough is the whole device, not a filtered protocol: an agent with a phone on adb can install, sideload, wipe app data and read whatever the phone's debugging authorisation allows. Attach only devices you would hand the agent in person. The switch is off by default so a project file synced from somewhere else cannot claim hardware on its own.
  • The host loses it while a session holds it. QEMU claims the device, so the host's own adb or serial tools do not see it until the sandbox is stopped or deleted. A host adb server that already has the device open must be stopped first (adb kill-server), or the attach fails with a busy device.
  • One device, one VM. A fork of a session (a copy-on-write clone of its VM) never inherits a USB device: the provider strips every inherited agnes-usb-* device from the clone before it starts, the same way it re-points the clone's display bus.
  • Owner-gated, like the rest of the project. Editing a project's device list is a config change (RestrictConfigToOwner applies), and the picker's host inventory (GET /sandbox/usb-devices, read from Incus's /1.0/resources) is behind the same gate. Members see the list; only an Owner changes it.
  • Applied at create, not on resume. A device added to a project reaches sessions opened after the save. A running or paused session keeps the devices its VM was created with; Incus persists them on the instance.

The selector is a vendor and product id (0e8d:201c, as lsusb prints them) plus an optional serial for telling identical units apart. Both ids are validated to four lowercase hex digits before they touch argv; the label a person saw in the picker is stored for display and never sent to Incus.

The fleet proxy (/fleet)

A phone cannot reach a CodeyBox orchestrator that listens on loopback beside the host, so the host forwards for it — but not as a reverse proxy. Agnes.Host/Fleet/FleetRoutes.cs is a route table: every orchestrator route the Agnes apps have a control for is mapped by hand, and nothing else under /fleet exists (a 404 with a sentence, before the orchestrator hears of it). The bound holds against the orchestrator growing: a new endpoint with security characteristics nobody has assessed is not reachable through a paired device until someone adds it to the table, on purpose, with a control that uses it.

What the table enforces, per route:

  • Path. Assembled by the host from vetted parts; an item id is [A-Za-z0-9._-]{1,80}, never a path.
  • Query. Forwarded only where the route names the keys it takes (quota/history: agent, from, limit); everything else is dropped, including the hub's token parameter.
  • Body. Read into a typed record and re-serialised. The ceiling patch carries one integer field; {"auditMaxIterations":30,"prompt":"…"} reaches the orchestrator as {"auditMaxIterations":30}, and a body without the field is refused rather than forwarded as an empty patch.
  • Who. A read takes any paired device. A write — retry, replay, promote, cancel, answer, dismiss, raise a ceiling — takes an Owner, the same line the host draws for its own configuration.
  • Credentials. The device token never leaves the host; the orchestrator sees the host's own key, from Agnes:Fleet:* or the orchestrator's config file. Nothing about the orchestrator's key reaches a phone.

Streams are not proxied: the phone polls. The desktop plugin's wider surface (suggestions, releases, test cases, prompt edits, supervision) is not proxied either; the desktop runs beside the orchestrator and reads it directly. tests/Agnes.Integration.Tests/FleetProxyTests.cs pins each of these properties.

The plaintext MCP listeners (/mcp-agnes)

Agnes offers its own MCP tools (send_user_file, arm_goal, …) back to the agents it runs. Two extra listeners carry that, both plain HTTP, both serving /mcp-agnes and nothing else:

Listener Bound to Reached by Key
Sandbox bridge the bridge gateway, e.g. 10.99.5.1:5099 sandboxed sessions Agnes:Sandbox:GuestMcpBindUrl / …:GuestMcpUrl (off unless set)
Loopback 127.0.0.1:5117 agents running on the host itself Agnes:Mcp:LocalUrl, Agnes:Mcp:LocalEnabled (on by default)

Why not the main TLS listener. Two reasons, and either alone is decisive. It is commonly self-signed or pinned — the deployment Agnes is built for — and an agent CLI has no way to be handed that trust anchor; and it is authenticated by device tokens, which carry the authority of a paired human across every session on the host. Handing one to an agent would be strictly worse than plaintext on loopback.

Why plaintext is acceptable here. Three properties, enforced in code rather than assumed:

  • Path allowlist, first in the pipeline. GuestMcpEndpoint.IsAllowedPath refuses everything but /mcp-agnes on these ports, and the middleware runs before authentication — registered later, the auth layer would answer /agnes with a 401, which both admits the hub is there and would serve it outright to anyone holding a device token. The hub, the REST API, the web head and the display channel are unreachable on both ports — a plaintext, unauthenticated socket onto a session's screen is exactly what that allowlist exists to prevent, so do not widen it.
  • No device authority crosses them. The only credential that works is a per-session token (SessionMcpTokens) which is that session's identity to the tool layer: an agent presenting one can act only on its own session, cannot name another, and is explicitly refused by the tools that need a paired device. Tokens are in-memory, are revoked when the session closes, and do not survive a host restart.
  • A device token buys no more here than at the hub. Every session-scoped tool asks the same SessionAccessDecider the SignalR hub and the display channel ask, for the same verb the equivalent hub method requires: read_session_transcript and get_session_status need Subscribe, send_prompt, set_mode, the goal tools and computer_* need Prompt, respond_permission needs Approve. The catalogue tools (list_sessions, list_open_approvals, list_goals all) filter rather than refuse, exactly as AgnesHub.ListSessions does, so they can never advertise a session the caller would then be denied. Before this, a device token the hub refused could list and drive every session on the host by arriving over /mcp-agnes instead.
  • Neither address is routable off-box. The bridge gateway is reachable only from that bridge's sandboxes; loopback only from this machine.

They are added to your listener, never in place of it. Kestrel takes endpoints from two channels that do not merge symmetrically, so Agnes picks the one that composes: an explicit Listen when you configure Kestrel:Endpoints, an appended address when you set ASPNETCORE_URLS (as the Docker image does). If you have configured neither — Kestrel is on its own default endpoint, which applies only while both channels are empty — Agnes logs a line and binds no MCP listener at all, because adding one would take the main listener away with it. Configure a listener and they come up alongside it.

What loopback does mean. Any local process running as any user on the host can reach 127.0.0.1:5117 — but it gains nothing without a session token, and the tokens live only in files written for one session (0600 for the host-session config; a sandbox's own home otherwise). On a shared machine where you do not trust local users, set Agnes:Mcp:LocalEnabled=false; unsandboxed sessions then simply get no agnes server. If the configured port is already taken (a second Agnes on the same box), the host logs it and starts without the local endpoint rather than failing to start.

Data at rest

The event store holds full session transcripts — which routinely contain secrets that flowed through a session — plus the device registry (~/.agnes/devices.json, tokens hashed) and MCP config. Agnes does not encrypt these at rest; that is a deployment responsibility:

  • Run the daemon as a dedicated, unprivileged user and restrict the data directory (~/.agnes/, the SQLite DB file, devices.json) to that user (chmod 700).
  • Put the data directory on an encrypted volume (LUKS / cloud disk encryption).
  • If transcript confidentiality is critical, consider the Postgres event store on a hardened DB, or a custom IEventStore — the store is a plugin point.
  • There is no built-in retention/pruning of the event log yet; scrub or rotate the store out of band if you need bounded retention.

Known residual risks

Mitigated by the options above (turn them on) but worth stating plainly:

  • Session ownership is by GitHub login / device id, not a full tenant model. PerUser/PerGroup scope access, but the host owner remains an admin who can read every session. For hard tenant separation (operator can't read tenant transcripts), give each tenant their own host.
  • Group membership is only as fresh as its cache. Repo write-access results are cached ~5 min, so a revoked collaborator may retain access briefly. Membership checks also require the linked GitHub App; with none linked, PerGroup grants no group access (owner-only).
  • Credential scoping needs Project.CredentialAccount set. Minting is pinned to a project's linked account only when configured; otherwise it routes by repo owner. Keep GitCredentialMode=Off by default and pin accounts per project.
  • Egress ACLs are enforced by Incus, not Agnes. Correct policy definition (default-deny + a tight allowlist) is on you; a permissive ACL is no protection. Disk usage (forks copy whole workspaces) is still unbounded — cap it at the OS / storage-pool level.
  • Retention/attribution are host-side, not tamper-proof. TranscriptRetentionDays deletes old content but isn't a legal-hold/audit system; the usage report reflects live state only.

See docs/architecture.md for the design and docs/deployment.md for the full auth/config reference.

There aren't any published security advisories