Credentials & isolation
Genie’s main security rule is built in, not screened for: a credential never enters a model context. The guards and the redactor are a backstop for that rule, not a replacement for it.
Each credential is held by the one process that uses it:
| Credential | Held by | Never given to |
|---|---|---|
| Tier 1 model API key | genie-root | Tier 1’s pi process: not in its argv, environment or models.json |
genie-guard model API key | genie-guard | Any other process |
| GitHub token for gists and PRs | The broker, inside root | Any model |
GitHub read token for gh_read (optional, zero permissions) | Root | Any model |
| S3 archive key (optional) | Root | Any model |
| Expert Claude token | genie-dispatcher@<tier>, briefly, to hand to one session | Tier 1, the other tier, the herdr server’s environment |
| Matrix access token | The Matrix bot | Genie |
Credential paths must be outside the Nix store. The NixOS module refuses a store path.
Tier 1: credential custody
Section titled “Tier 1: credential custody”Root talks to the model on tier 1’s behalf. Each thread’s pi process gets its own Unix socket to root, and the socket binds every call to that thread. Pi sees the model’s public name and a fixed placeholder where a key would go. Root picks the API, the destination and the authentication; pi cannot change headers, credentials or the destination.
Tier 1 has no shell.
With bubblewrap
Section titled “With bubblewrap”When usable bubblewrap (bwrap) is present on Linux,
root starts each tier-1 process inside a jail:
- new user and PID namespaces, so tier 1 cannot see root or other threads’ processes;
- read-only binds of the system paths pi needs, and a private
/tmp; - root’s state and worktree directories hidden behind empty mounts, and only the thread’s own session directory writable;
- no view of root’s configuration or credential files.
The file tools (read, grep, find, ls) run in root, confined to the thread’s worktrees.
Root checks at startup that no credential file lies under any path the jail binds. Under the
NixOS module, genie-agent.service also runs with PrivatePIDs=true and a hidden /proc for
other processes.
The portable baseline
Section titled “The portable baseline”On a host without usable bwrap (macOS, a container without user namespaces, a locked-down kernel), root does not refuse to run. It logs:
baseline: credential custody, no OS isolationand runs tier 1 without a jail. Custody still holds: root never hands a credential to pi and still enforces its tool and file policy.
A separate container for tier 1
Section titled “A separate container for tier 1”Root can start each tier-1 worker in a separate container instead. Set tier1.executor to a
start command and a stop command (an argument array, not shell text). Only the per-thread socket
directory is shared with the container. See
configured executors. A
configured executor that fails is an error; it never falls back to local execution.
Evaluations
Section titled “Evaluations”eval runs nix eval (or nix flake check --no-build) on one attribute of one repository.
Every evaluation, trusted or not, runs with:
pure-eval = true, an empty flake registry and no substituters, sobuiltins.getEnv,<nixpkgs>and paths outside the inputs fail;accept-flake-config = false;- an environment built from nothing, carrying nothing of the thread;
- a throwaway, per-thread Nix store;
- a timeout, and a memory budget (
eval.memoryMax, 4 GiB by default).
An evaluation that hits a limit reports which one in limit_hit: oom, timeout, egress or
unknown.
A trusted evaluation adds allow-import-from-derivation = true and the configured remote
builders. It still builds nothing locally. Only root’s trust clamp opens this path (see
Tiers & experts).
Where evaluations run
Section titled “Where evaluations run”| Backend | Isolation | When the platform lacks it |
|---|---|---|
local with bwrap | bwrap jail; a delegated cgroup enforces memoryMax | Without bwrap, the evaluation runs unjailed. Without a delegated cgroup, memoryMax is reported as an unenforced request. |
kubernetes | One Job per evaluation, with CPU and memory limits, and an optional runtimeClassName such as gVisor | No runtime class is assumed. Set it only when the cluster provides one. |
A Kubernetes Job mounts only the thread’s own worktree, read-only, and its store. The builder key Secret is mounted only into a trusted Job with a configured builder. Network policy, the runtime class and namespace permissions are the deployment’s job; genie creates none of them.
Expert tokens
Section titled “Expert tokens”Each expert tier has its own Claude token (experts.<tier>.tokenEnvironmentFile). The path from
the file to the session is built so that the token never sits where something else can read it:
- Under systemd, the token file reaches
genie-dispatcher@<tier>throughLoadCredential=. WithloadCredential = false, the dispatcher reads the configured path directly. This is the fallback for platforms without systemd credentials. - The dispatcher does not read the token into its own memory. A short-lived helper opens the credential and passes it to the session’s launcher over a Unix socket pair, as a file descriptor.
- The launcher (
genie-claude-shim) removes any token variable from its environment and starts Claude Code withCLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR, so the token is in no argv and no environment. - Claude Code runs inside a bwrap jail: the root filesystem read-only, a private
/tmp, genie’s state directory hidden, and only the thread’s worktree and its own Claude directory writable. Itssettings.jsonandCLAUDE.mdare read-only, and its hooks, plugins, commands, skills and agents directories are empty and read-only. A session cannot plant files that a later session would load.
The expert units also run with PrivatePIDs, a perf_event_open syscall filter, and the module
requires kernel.yama.ptrace_scope >= 1, so one process cannot attach to another.
One host, or one pod per tier
Section titled “One host, or one pod per tier”The NixOS module runs tier 1, both dispatchers and every expert session on one host, as one OS
user (services.genie-agent.user), on that user’s xmsg bus. The tiers use separate tokens and do
not share the owner’s ~/.claude.
The design’s target is the second shape: one OS user, or one pod, per tier, each with its own xmsg instance. xmsg’s same-UID trust model makes this work without extra machinery. A process can reach only its own user’s sockets, so a session on the public tier’s bus cannot send into the trusted tier’s bus at all. Only pinned federation links cross between nodes, and the design allows no link between the two tier nodes.
The redactor
Section titled “The redactor”The redactor runs over every string before anything is posted or acted on. Then guard-out
judges the redacted text, and only what it passes is published (see the
guard model). The redactor replaces each match with [redacted:<kind>].
It matches:
- SSH and PEM private keys;
- Claude OAuth tokens and Anthropic API keys;
- GitHub tokens (
github_pat_…,ghp_…and the othergh?_forms); - Matrix access tokens;
- age secret keys;
- bare ed25519 private keys, including the form xmsg leaf keys use;
- AWS-format and Garage S3 key ids, and Garage secret keys;
- any long, high-entropy value after a word like
token,secret,passwordorBearer; - any line whose hash matches a line of a configured secret file.
It deliberately leaves bare git revisions, store paths and narHashes alone: those are the
substance of a Nix answer. It cannot recognise private facts in prose. That is guard-out’s job;
see Guard model.
Palettes adapted from Catppuccin (Macchiato) (MIT), Tokyo Night (Apache-2.0), gruvbox (MIT), Catppuccin (Latte) (MIT), Rosé Pine (Dawn) (MIT).