Seal projects to OS accounts
peer_auth - one daemon, a Unix socket that names each caller by its uid, projects only their own OS account can reach, and the deployment that makes the files hold too.
On this page
Project isolation keeps agents working on one project out of another's knowledge. It is a policy fence: it trusts the session binding an agent reports, every caller shares one bearer key, and the corpus is files in your home directory that any process of yours can read.
peer_auth is the boundary underneath it, for when the threat is an account rather than a stray agent: you run a sensitive system's agents under their own macOS or Linux user, and nothing running as you - no agent, no script, no tool - may read that system's memory, through Seamless or through a file. One daemon serves everyone. It identifies each caller by the uid the kernel reports for its Unix-socket connection, never by anything the caller sends, and it maps OS accounts to the projects they may reach.
It is off by default, and off means exactly the behavior every release before it had. It also asks for a different deployment: the daemon has to run as an account of its own, or the files would not hold.
How it decides
Every request carries a principal, set by the transport before any route runs:
| Connection | Principal |
|---|---|
| TCP with the bearer key | the owner account (peer_auth.owner) |
| The socket, from the owner's uid | the owner |
The socket, from a uid listed under peer_auth.users |
that account |
| The socket, from any other uid | refused with 403 before any route runs |
Two rules follow, and they are the whole policy:
- A project sealed to an account is reachable only from that account's socket connections. The owner - TCP key and socket alike - never reaches it.
- A configured account reaches only its own projects (the ones sealed to it plus any it shares), and it reads the global scope but never writes it: a global memory is injected into every project's briefing, the owner's included.
| A project sealed to this account | Sealed to another account | The owner's projects | Global, read | Global, write | |
|---|---|---|---|---|---|
| Owner | - | no | yes | yes | yes |
| Configured account | yes | no | only if listed under projects |
yes | no |
"Reach" means everything: recall, briefings, the prompt matcher, every MCP tool by name or by id, tasks, trials, sessions, the gardener's proposals, the hooks, A2A, the console and its live feed. A project an account may not reach is simply absent from what it lists, and a call that names one is refused with the rule and the remedy, never with any of its content.
A configured account works in its own projects whatever its working directory: its sessions, and any call nothing else places, land in its home project - its first sealed project, else its first shared one. It never grows the repo map. The owner opening a repository whose project is sealed to another account gets a one-line notice in place of a briefing, and no session.
The console belongs to the owner: a configured account gets 403, and the
owner's console shows no project sealed to another account - not in a list, a
count's detail, a search, a chart, the event log or the live feed.
Sealing works with isolation rather than replacing it: every sealed project must
also be confidential or sealed, which keeps it standalone (no family, no
parent) and inside the existing fences between agents.
Configure it
peer_auth:
enabled: true
owner: alice # the account the TCP bearer key stands for
socket:
path: /opt/seamless/run/seamlessd.sock
mode: "0666" # who may knock; the kernel still names each one
users:
_payroll: # an OS user name, or a numeric uid
sealed: [payroll] # reachable only from _payroll
_ops_bot:
projects: [ops] # shared: _ops_bot and the owner both reach it
Checked when the file loads. The users map always, whether or not enabled
is set: a project sealed to two accounts, a project sealed to one account and
listed for another, a project listed twice for one account, the global scope or
the reserved all listed, and an account reaching nothing. With enabled set,
also: the owner is named and not listed under users, the socket path is
absolute and outside data_dir, and the mode is octal and keeps owner read and
write.
Checked when serve starts, and refused if any fails:
- every account resolves on this machine (macOS role accounts live in the directory service, and are found there), and no two entries share a uid;
- every project the block names exists - a misspelled seal would leave the project it meant open, which is exactly what this feature exists to prevent;
- every sealed project is
confidentialorsealed; - the files would hold too (the next section).
seamlessd doctor, run as the daemon's account against the daemon's config,
prints the policy as it resolved and every reason serve would refuse.
Deploy it: the daemon runs as its own account
The data directory holds every project's memory in plain files and a SQLite
database. If the daemon runs as you, every process of yours reads them. So with
peer_auth on, serve refuses to start unless:
- it runs as neither the owner nor root;
data_dirbelongs to the daemon's account and is mode0700;- the socket's directory belongs to the daemon's account or root and is not group- or world-writable (whoever can write it can replace the socket and impersonate the daemon to every caller);
- every directory above the data, the socket, the config file and the binary belongs to root or the daemon's account and is not writable by anyone else - a directory you own anywhere above the data is one you can rename it out of;
- the config file and the
seamlessdbinary belong to root or the daemon's account and are not group- or world-writable: whoever can rewrite either can switch the boundary off at the next start.
A layout that satisfies all of it (macOS shown; Linux is the same with
useradd --system and a systemd unit):
| Path | Owner | Mode | Holds |
|---|---|---|---|
/opt/seamless |
root:wheel |
0755 |
|
/opt/seamless/bin |
root:wheel |
0755 |
seamlessd, seam (both 0755, root's) |
/opt/seamless/etc |
root:wheel |
0755 |
seamless.yaml (root:_seamless, 0640) |
/opt/seamless/data |
_seamless |
0700 |
the database and corpus |
/opt/seamless/run |
_seamless |
0755 |
the socket |
/opt/seamless/home |
_seamless |
0700 |
the account's home (caches, nothing else) |
Create the account (macOS, as an administrator; pick an unused uid in the role
range - dscl . -list /Users UniqueID lists the taken ones):
sudo dscl . -create /Groups/_seamless PrimaryGroupID 480
sudo dscl . -create /Users/_seamless UniqueID 480
sudo dscl . -create /Users/_seamless PrimaryGroupID 480
sudo dscl . -create /Users/_seamless UserShell /usr/bin/false
sudo dscl . -create /Users/_seamless NFSHomeDirectory /opt/seamless/home
sudo dscl . -create /Users/_seamless Password '*'
sudo dscl . -create /Users/_seamless IsHidden 1
The service is a LaunchDaemon that names the account (on Linux, a system unit
with User=), so it runs whether or not anyone is logged in. The plist, its
controls, and how to retire the per-user service first are in
the service reference.
Connect the owner
Nothing changes for the owner's agents. They keep the TCP listener and the bearer
key, and they are the owner: every project except the sealed ones. Their config
becomes a client's, because the owner's account no longer runs the daemon - and a
server-role config there would have seamlessd doctor open (and create) a
database in the owner's home:
role: client
server_url: "http://127.0.0.1:8081"
mcp:
api_key: "<the same key as the daemon's config>"
The owner can use the socket instead (server_url: unix:///opt/seamless/run/seamlessd.sock, no key): same principal, no key on disk.
The console still needs TCP - a browser cannot dial a socket.
Connect a sealed account's agents
An agent for a sealed system runs as that system's account. That is also how
a person works on the system with its memory: they run the session as the
account, with sudo -u, and their own account's sessions cannot reach it.
The account's config, in its own home (~/.config/seamless/seamless.yaml, mode
0600):
role: client
server_url: "unix:///opt/seamless/run/seamlessd.sock"
No key: the daemon names the caller by its uid. Then install its hooks and MCP registration, as the account:
sudo -u _payroll -H /opt/seamless/bin/seamlessd install-hooks --client claude \
--seam /opt/seamless/bin/seam
With a socket server_url every hook is a seam hook <event> command (Claude
Code's own http hook cannot dial a socket) and the MCP registration is the seam mcp-proxy stdio bridge. If you write them by hand, the account's
~/.claude/settings.json hook entries look like:
{"type": "command", "command": "/opt/seamless/bin/seam",
"args": ["hook", "session-start", "--config", "/home/of/_payroll/.config/seamless/seamless.yaml"]}
one per event (session-start, user-prompt-submit, session-end, post-tool-use,
subagent-start, subagent-stop, permission-request), and its ~/.claude.json
MCP server is:
"seamless": {"type": "stdio", "command": "/opt/seamless/bin/seam",
"args": ["mcp-proxy", "--config", "/home/of/_payroll/.config/seamless/seamless.yaml"]}
Then run the session as the account: sudo -u _payroll -H claude. The account
needs its own Claude Code sign-in, and its own copy of anything the session
reads - it cannot traverse your home directory, which is the point.
Verify
# As root or the daemon's account: the policy, and nothing serve would refuse.
sudo -u _seamless env SEAMLESS_CONFIG=/opt/seamless/etc/seamless.yaml /opt/seamless/bin/seamlessd doctor
# As the owner: the files are out of reach, and so is the sealed project.
ls /opt/seamless/data # Permission denied
seam recall something --project payroll # refused: not reachable from this connection
# As the sealed account: its own project, and the global scope.
sudo -u _payroll -H /opt/seamless/bin/seam doctor
sudo -u _payroll -H /opt/seamless/bin/seam recall something
# As any other account: refused at the door.
curl --unix-socket /opt/seamless/run/seamlessd.sock http://localhost/healthz # 403
Move an existing install over
The data directory moves as a whole; the database stores paths relative to it, so nothing inside needs rewriting. On one volume the move is a rename.
- Seal first, in the old world. Make each sealed project
confidentialorsealed(seam project isolation <slug> confidential --yes) and runseamlessd doctorwith the new config to catch a misspelled slug. - Back up, as the owner, while the old daemon still serves:
seamlessd export -o ~/seamless-before-peer-auth.tar.gz. The archive holds every project, the sealed ones included - keep it where the sealed accounts' data would be kept. - Create the account and the layout above, and install root-owned copies of
both binaries in
/opt/seamless/bin. - Write the daemon's config at
/opt/seamless/etc/seamless.yaml(root:_seamless 0640): the owner's old config withdata_dir: /opt/seamless/data, thepeer_authblock, and the samemcp.api_keyand LLM keys. - Stop the old service and move the data: stop and remove the per-user
service (how), then
sudo mv ~/.seamless /opt/seamless/data,sudo chown -R _seamless:_seamless /opt/seamless/dataandsudo chmod -R go-rwx /opt/seamless/data. - Start the daemon: install and load
the LaunchDaemon, then
curl -s http://127.0.0.1:8081/healthzand the doctor line above. - Switch the owner's config to the client config above.
- Wire each sealed account as described above, and verify.
Roll back
Unload the LaunchDaemon (how), move the
data back (sudo mv /opt/seamless/data ~/.seamless and sudo chown -R $(id -un):staff ~/.seamless), restore the owner's server config without the
peer_auth block - a release that predates it refuses the key - and reinstall
the per-user service (make install, or the installer). The export from step 2
is the fallback if anything in the move went wrong.
What changes with peer_auth on
- The daemon reads no agent files. Session-end findings and token totals
harvested from transcripts, model sniffing, and plan-file and subagent capture
are off: under peer_auth every path a hook names is one some other account
chose, and the daemon can read its own data directory, so honoring one could
make it read a sealed project's files on a caller's behalf. Agents still
record findings with
session_end. (The daemon could not read your transcripts anyway: it no longer runs as you.) - Updates are an administrator's step. A LaunchDaemon install is not one the
installer made, so the daemon only notifies; replace
/opt/seamless/binwithsudoand restart it. Runmake installonly with a client-role config, so it updates your ownseamand hooks and leaves the service alone. - Counts stay machine-wide in
usage_summary, as they are for isolation: volume, never content, and the same number for every caller. - The boundary is the OS account. Root, and anyone who can become the
daemon's account or a sealed account, reads everything; passwordless
sudovoids the whole arrangement. Whoever holds the bearer key acts as the owner (which reaches no seal). Backups of the data directory, and exports, hold every project - treat them as the most sealed thing in them. - The gardener still runs over every project, as the daemon, for no caller.
Its proposals about a sealed project reach only that account's agents
(
gardener_proposals); the owner's console does not show them.