This document describes the security properties the public repo implements today. Private overlays can strengthen or weaken them.
- The public flake exports only
eval-*fixtures plus apps, checks, and test helpers. It does not exportdeploy.nodes. scripts/deploy.shrefuses to deploy unless the current flake has atsurf.urlinput, so the public repo is intentionally non-deployable.hosts/dev/default.nixis the sandboxed agent host: it enablesagentCompute,agentLauncher,agentSandbox, andnonoSandbox.hosts/services/default.nixis the service host: it omits the sandbox modules and imports onlyextras/restic.nix, which stays disabled by default.- The public repo does not ship file sync, unattended-agent orchestration, or a default home-manager profile for the agent user.
| Identity | Default groups | Purpose |
|---|---|---|
root |
n/a | operator, deploy, secrets, recovery |
agent |
users |
sandboxed agent execution |
Important properties:
- The
agentuser is not inwheel. - Build-time assertions keep the
agentuser out ofdocker. users.mutableUsers = false.- Sudo access is limited to immutable per-agent launchers such as
tsurf-launch-claude. - Base Nix daemon policy is
allowed-users = [ "root" "<agent>" ]andtrusted-users = [ "root" ], so the agent can use Nix but cannot extend the trust root. tsurf.template.allowUnsafePlaceholdersexists only for eval fixtures. Real host source files do not set it.
On sandboxed hosts, wrappers follow this path:
caller
-> wrapper
-> sudo tsurf-launch-<agent>
-> systemd-run transient unit
-> scripts/agent-wrapper.sh (loads /run/secrets/* into env vars)
-> nono run --credential <service> --profile /etc/nono/profiles/tsurf-<name>.json
-> nono's built-in reverse proxy (reads env:// URIs, issues phantom tokens)
-> setpriv drop to the configured agent user
-> real agent binary
Security properties of that path:
security.sudo.extraRulesexposes only immutable launchers. There is no generic root helper.- The launcher bakes in the real binary path, the nono profile path, and the credential secret pairs.
- The launcher rejects any real binary outside
/nix/store. - Launch events go to journald only (
journalctl -t agent-launch). - The public path has no
--no-sandboxorAGENT_ALLOW_NOSANDBOXescape hatch.
Resource limits:
- Shared slice:
MemoryMax = 8G,CPUQuota = 300%,TasksMax = 1024 - Per transient session:
MemoryMax = 4G,CPUQuota = 200%,TasksMax = 256
The sandbox is implemented with nono and Landlock-backed filesystem rules.
Enforced behavior:
$PWDmust be insideservices.agentLauncher.projectRoot(default/data/projects).- The wrapper derives the sandbox read scope as the first path component beneath
that project root, so
/data/projects/foo/subdiris scoped to/data/projects/foo. - The wrapper refuses to run if
$PWDis exactly the project root. This prevents blanket read access to all workspaces under/data/projects. - The base nono profile denies
/run/secrets,~/.ssh,~/.bash_history,~/.gnupg,~/.aws,~/.kube,~/.docker,~/.npmrc,~/.pypirc,~/.gem,~/.config/gh,~/.git-credentials, and/etc/nono.
Important nuance:
- The current workspace is still writable.
workdir.access = "readwrite"is a deliberate design choice. - What is blocked is broad cross-workspace access, not writes inside the current workspace.
- Avoid pointing agents at infrastructure repos. That is still an operational rule, not a technical control.
Storage:
sops-nixderives its age identity from the host SSH ed25519 key.- Secrets are decrypted to
/run/secrets. - Public defaults keep
anthropic-api-keyandopenai-api-keyroot-owned. github-pat,google-api-key,xai-api-key, andopenrouter-api-keydefault to the agent user.
Injection model:
- Each wrapper carries an
AGENT_CREDENTIAL_SECRETSallowlist ofENV_VAR:secret-file-namepairs. scripts/agent-wrapper.shreads only those named secret files from/run/secretsand exports them as environment variables.- nono's per-agent profile defines
custom_credentialswithenv://URIs. nono reads the real keys from the parent env before applying the sandbox, starts its built-in reverse proxy with 256-bit phantom tokens, and strips real keys from the child environment.
What the child gets:
- a per-session phantom token via
NONO_PROXY_TOKEN - a localhost base URL such as
ANTHROPIC_BASE_URL=http://127.0.0.1:<port>/anthropic
What the child does not get:
- the raw
/run/secrets/*file - the raw provider key in its environment
- nftables is enabled.
- Public ingress is limited to
22, plus80and443only whenservices.nginx.enable = true. - Cloud metadata access to
169.254.169.254is dropped in nftables. - The effective trusted interface set in the public eval fixtures is loopback only.
SSH defaults:
- key-only auth
PermitRootLogin = prohibit-password- ed25519 host key only
PasswordAuthentication = falseKbdInteractiveAuthentication = falseMaxAuthTries = 3fail2bandisabled
These SSH and firewall defaults are set explicitly in modules/networking.nix.
srvos also sets them; the explicit declarations ensure the security model is
self-backing.
Agent egress:
nonois not the network allowlist boundary here;network.block = false.- Agent egress is enforced in nftables by
meta skuid. - Default allowed traffic for the agent UID is:
- loopback
- DNS on TCP/UDP
53 - TCP
22,80, and443
- Default denied traffic for the agent UID includes:
- RFC1918 IPv4 ranges
100.64.0.0/10169.254.0.0/16fc00::/7fe80::/10
- The root filesystem rolls back on boot from BTRFS subvolumes.
- Persistent state is declared explicitly under
/persist. - Persisted security-critical state includes
/var/lib/nixos,/etc/ssh/ssh_host_ed25519_key,/data/projects, selected root state, and selected agent state. modules/impermanence.nixmakessetupSecretsdepend onpersist-files, sosops-nixcan read the persisted SSH host key.- Real deployments are expected to generate a root SSH key with
nix run .#tsurf-init -- --overlay-dir /path/to/private-overlay. - If SSH is lost, recover through console or rescue mode, repair access, and redeploy from the private overlay.
- Nix inputs are pinned by
flake.lock. nonois built from pinned source (rustPlatform.buildRustPackage). Remaining prebuilt binaries are SHA256-pinned.cassis an opt-in extra (extras/cass.nix), not in the default trust path.- Critical kernel and network hardening (kexec, BPF, sysrq, reverse-path
filtering, source routing) is set explicitly in
modules/base.nix.nix-mineralprovides additional depth (~80 settings) but the core claims in this document do not depend on it staying enabled. - Firewall, SSH password auth, keyboard-interactive auth, and X11 forwarding
defaults are set explicitly in
modules/networking.nix.srvosalso sets them; the explicit declarations are the trust anchor. nix-mineraltargets nixpkgs-unstable. A compatibility shim stubsservices.resolved.settingsfor nixos-25.11. This shim is annotated with@decision SEC-160-04inflake.nix.claude-codeandcodexcome from the pinnedllm-agents.nixinput.- The repo does not add signature verification for these remaining prebuilt binaries.
The security claims above are backed by eval checks plus VM and live tests.
Eval-time checks:
tests/eval/config-checks.nixcovers public-output safety, placeholder isolation, firewall exposure, root-key requirements, Nix daemon restrictions, sandbox structure, launcher hardening, and root-side credential broker structure.
Runtime checks:
tests/live/security.batsverifies SSH hardening, metadata blocking, and firewall exposure.tests/live/secrets.batsverifies/run/secretspresence, ownership, and permissions.tests/live/networking.batsverifies DNS reachability, metadata blocking, and the egress allowlist.tests/live/sandbox-behavioral.batsprobes the sandbox from inside the agent context.tests/live/agent-sandbox.batsis structural coverage for wrapper contents, not full behavioral proof.tests/live/service-health.batsverifies persistent unit health when those units exist.tests/live/impermanence.batsverifies/persistand related persistence behavior.tests/vm/sandbox-behavioral.nixis the reproducible VM smoke test.
- The service-host role does not include the agent sandbox.
- The sandbox does not make the current workspace immutable.
- The public repo deliberately avoids a separate unattended-agent supervisor.
- The host-level egress allowlist is coarse by design. It is scoped by UID, not by individual wrapper or destination hostname.