This document describes the security properties the public repo implements
today. The public target is one-owner, self-sovereign agent infrastructure;
private overlays can strengthen or weaken these properties. See
docs/base-contract.md for scope boundaries.
Please use GitHub Security Advisories for private vulnerability reports when available. If that is unavailable, open a minimal public issue asking for a private reporting channel and do not include exploit details, secrets, or host identifiers in the issue.
- 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,agentEgressProxy,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
-> setpriv drop to the configured agent user
-> nono run --profile /etc/nono/profiles/tsurf-<name>.json
-> real agent binary
-> iron-proxy on loopback (credential replacement and egress policy)
Iron-backed mediation is the intended public credential path. The legacy nono
credential path is not part of the public base.
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 Iron credential token metadata.
- 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.
Verification status:
- Eval checks verify the wrapper, profile, and Iron credential wiring.
- The repo includes a VM fake-provider test that exercises the brokered request path and proves the child does not receive the real provider key.
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 brokered provider keys root-owned:
anthropic-api-key,openai-api-key,xai-api-key, andopenrouter-api-key. github-patandgoogle-api-keydefault to the agent user.- Optional Restic/B2 backup secrets are declared by
extras/restic.nixonly whenservices.resticStarter.enable = true; they are not part of the core secrets module.
Injection model:
- Credentialed wrappers carry
AGENT_IRON_CREDENTIAL_TOKENS, which maps child provider environment variables to generated Iron placeholder token names. - The wrapper reads only the Iron token file, not raw provider secrets. It exports provider-shaped placeholder credentials and explicit proxy/CA environment variables.
iron-proxyreads real provider keys from a sops-rendered environment file and replaces placeholders at egress.
For credentialed wrapper paths, the child should get:
- provider-shaped placeholder variables such as
ANTHROPIC_API_KEY HTTP_PROXY/HTTPS_PROXYpointing at the loopback Iron tunnel listener- CA trust variables scoped to the child environment
For supported wrapper paths, the child should 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.254andfd00:ec2::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:
- When
services.agentEgressProxy.enable = true, direct agent-UID egress is mediated-only: nftables allows loopback proxy ports and drops direct DNS and public22/80/443from the agent UID.iron-proxyenforces host allowlists, credential replacement, upstream deny CIDRs, and structured per-request logs. - The dev fixture enables this Iron-backed mode by default.
- The base
nonoprofile setsnetwork.block = true. Iron-backed generated profiles disable nono network blocking so the child can reach only the host-allowed Iron loopback proxy ports. - Host egress for direct agent-UID traffic is enforced in nftables by
meta skuid. Drops are logged by default withtsurf-agent-egress-*prefixes before being dropped. - Default allowed traffic for the agent UID in legacy direct-egress mode is:
- DNS on TCP/UDP
53 - TCP
22,80, and443
- DNS on TCP/UDP
- When Iron-backed mediated-only mode is enabled, default direct agent-UID traffic is limited to the fixed Iron loopback listener ports.
- Default denied traffic for the agent UID includes:
- other loopback TCP ports unless explicitly added to
tsurf.agentEgress.allowedLoopbackTCPPorts - RFC1918 IPv4 ranges
100.64.0.0/10169.254.0.0/16fc00::/7fe80::/10
- other loopback TCP ports unless explicitly added to
Iron-backed mode is the public destination-level mediation path. Private
overlays that disable services.agentEgressProxy, enable
services.nonoSandbox.allowDirectNetwork, or add direct egress paths must treat
that as an explicit risk.
- 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,/persist/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. The helper prompts for a passphrase on a TTY; noninteractive generation must use--passphrase-fileor explicitly accept the unencrypted-key risk with--no-passphrase. - If SSH is lost, recover through console or rescue mode, repair access, and redeploy from the private overlay.
- Deploys run deploy-rs checks by default. The
--skip-checksflag is an explicit unsafe fast path for emergencies or known-good out-of-band checks.
- Nix inputs are pinned by
flake.lock. - A scheduled GitHub Actions workflow opens lock-update pull requests so input bumps are reviewed explicitly instead of accumulating silently.
- The public base includes a self-hosted Harmonia cache module as the intended real-deployment cache path. Public fixtures leave it disabled until a private overlay supplies the host, trust key, signing key, and client allowlist.
- The public base trusts
https://cache.numtide.comvia the configuredniks3.numtide.com-1public key. Treat that cache as part of the build trust root for binaries it serves. nonois built from pinned source (rustPlatform.buildRustPackage). The build checks that upstream default policy has not regained broad/rungrants, plus an install-time CLI smoke check. Patch files live underpackages/and should be reviewed when upstream nono is updated.iron-proxyis built from pinned source (buildGoModule). Its install check verifies the reported version and CA generation command. 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. claude-codeandcodexcome from the pinnedllm-agents.nixinput.- The repo does not add signature verification for these remaining prebuilt binaries.
- The Harmonia cache client defaults to HTTPS URLs. Plain HTTP clients
or direct public server exposure require
allowInsecureHttp = true. Integrity still relies on Nix nar signatures, but HTTP metadata and availability can be observed or interfered with by the network path.
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 Iron 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.tests/vm/credential-proxy.nixproves an Iron-brokered fake-provider request keeps the raw provider key out of the child environment while the upstream receives the injected header.
- 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.
- Private overlays can weaken the network model by enabling
services.nonoSandbox.allowDirectNetworkor adding broad direct egress. - The persisted SSH host key at
/persist/etc/ssh/ssh_host_ed25519_keyis plaintext on disk unless the private overlay adds disk encryption. That key is also the default sops age identity, so compromise affects both SSH host identity and secret decryption. - Operator root SSH keys generated with
tsurf-init --no-passphraseare intentionally unencrypted. Prefer the interactive prompt or--passphrase-file. - The public disk layout does not enable full-disk encryption by default.
- Harmonia cache clients trust the configured signing key; incorrect or over-broad cache trust can freeze, roll back, or leak build provenance through served paths even when Nix signature checks hold.