How Hurricane Sentinel Works v17
This is the stored snapshot for the approved document version. The diff below shows what changed from the previous version.
Document snapshot
How Hurricane Sentinel Works
Summary
Sentinel is a local agent harness for Linux. A supervisor runs each agent as a Leash-leashed user with model calls pointed at a Muzzle listener, so enforcement is genuine: Leash gates the agent's file/network/command access per-UID and Muzzle inspects its model I/O. Runs are durable and checkpointed, and a flagged action pauses the run for a human approval before it proceeds.
The whole control plane (supervisor, web, API, cron) runs as the leashed user, not
root — a worker for the leashed user's own runs is a same-user subprocess of it. The control
plane holds no capabilities (CapEff: 0), so it cannot start a process as any other
uid: a worker for an agent with its own Linux account is launched by the broker (below)
as a transient systemd unit running as that account. The one operation that still needs
root — changing Leash egress policy — is brokered by a tiny root sentinel-broker
service over a root:<leashed> socket with a closed set of schema-validated verbs and
no arbitrary execution; it is the only Sentinel service that runs as root. (Creating
the per-agent Linux account an agent's uid comes from needs root too, so it is one of
those verbs.)
The supervised run
-
Supervisor. The
sentinelservice owns run lifecycle and the approval queue. For each run it starts a worker process as the run's own subject — the agent's Linux account when it has one, the leashed user otherwise — with the worker's model endpoint set to a Muzzle listener. The leashed user's worker is a plain same-user subprocess. An agent account's worker cannot be: switching uid needsCAP_SETUID, which the leashed control plane does not hold. So the supervisor asks the root broker (worker_spawn, which takes only the run id); the broker reads the account from the run's own directory, refuses anything that is not a liveleash-*agent account, and has PID 1 startsentinel-worker-<run id>.service(systemd-run --uid=<account>) running exactlypython -m sentinel._worker <run id>, with the same environment and a sandbox that restates, directive for directive, what a worker inherits fromsentinel-web's hardening drop-in (memory/task ceiling included). The supervisor then waits on that unit (an unprivilegedsystemctl show), reads its exit status like aPopen's, and stops it through the broker (worker_signal: SIGTERM to the worker, then SIGKILL to the whole unit after the grace period). A root control plane still spawns directly. If the broker refuses or is down the run fails with that reason — it is never run as the leashed user instead, which would drop the agent's own Leash policy. That subject is read from the run's state directory, so the uid the kernel confines is always the uid that can write the run it is driving. A worker's first act is to read/etc/sentinel/sentinel.yaml, which belongs to the leashed user's group — so agent accounts are given read on it by ACL when they are created. What they are not given is the leashed user's tool secrets, which stay0600and out of reach: see SECURITY.md.What an agent-account worker can reach. It runs as
leash-<agent>(its own primary group plussentinel-agents), so the kernel judges every open against that account, not the leashed user. Measured against a simulated tree with the real modes (tests/test_agent_worker_file_access.py):Path Reach What happens its run dir runs/leash-<agent>/<id>/(state.json,events.jsonl,.worker.pid,.cancel,steer.jsonl, on-run approvals)read + write (segment ACL) works other subjects' segments, incl. runs/<leashed>/none ( 0700)skipped when locating its own run; a run it cannot locate is an error, never "not there" the relocated base /var/lib/sentinel-hometraverse only ( g:sentinel-agents:x)path walks open each component search-only ( O_PATH)/etc/sentinel/sentinel.yamlread ( g:sentinel-agents:r)works; if not, the worker exits with a reason agent profiles, skills, models, authored tools, team files/log, other memory banks (the head's, a shared memory:bank)read only reads work; writes ( write_skill,rememberinto a shared bank, …) come back as tool errorsits own memory bank <state>/memory/<agent>read + write ( 0770, owned by its account and private group; ACL keeps the control plane in)rememberandrecallwork; other agents cannot read itthe leashed user's workspace <base>/workspaceread only not its cwd (it is the head's) its own workspace <state>/agents/<agent>/workspaceread + write ( 0770, owned by its account and private group; ACL keeps the control plane in)its cwd; file writes work. workspace_unwritablenow means provisioning did not reach this agent (seesentinel agent provision)another agent's workspace or memory bank none ( 0770, another account's private group)before 2026-10-14 these were 0755, readable by every agent accountMCP / datasource / workspace definitions, secrets.jsonnone ( 0600leashed)reported as mcp_server_unreachable/datasource_unreachable/workspace_mount_unavailable/ the tool's own "stored but not readable" errorworkspace trees <state>/workspaces/*/tree(0770 sentinel-sync:sentinel-ws)none the mount is dropped and reported as workspace_mount_unavailablecreating another run (in-process subagent spawn) cannot refused with a reason (it cannot search every segment, so it cannot prove an id unused) Nothing in that table is widened to make a row work: the rows that do not work are provisioning decisions, each of them tracked and reported on the run rather than quietly worked around. The two "own" rows are the root broker's: when an agent is provisioned,
ensure_agent_usercreates each directory0770on anO_NOFOLLOWfd, placesu:<leashed>:rwxplus the defaultsd:u:<leashed>:rwXandd:u:<agent>:rwXon that fd, grantsu:<agent>:rwXon what was already inside (an fd walk that skips symlinks and hard-linked files), and only then hands it to the account. A failure is reported on the verb's result (workspace_dir_error/memory_dir_error) and logged, and leaves the directory with the leashed user. It re-runs on every provisioning, andsentinel agent provisionre-runs it for agents that already exist. -
Run engine (durable + checkpointed). A run is one agent task. Its state — message history, step, status — is persisted to the leashed user's
~/.sentinel/runs/<subject>/<id>/, where<subject>is the Linux account the run belongs to: the agent's own account, or the leashed user for a run with no agent. One directory per subject is what lets an agent's own worker write its run state while keeping it out of another agent's; the control plane is not excluded — it writes approvals and status transitions, and a default ACL on each subject directory is what keeps it in. Run directories written before this layout existed stay flat at~/.sentinel/runs/<id>/and are still read;sentinel migrate-runscopies them into their segments, with the services stopped, and leaves the flat originals in place as the rollback (a frozen one — once the services restart, writes land on the nested copy only). Status movesrunning → waiting_approval → done | failed, and every step (and every pause) is checkpointed so a run resumes exactly where it stopped. A run's directory is reserved exclusively before anything is written to it, and the id is a full UUID rather than a short prefix of one: a run must never be able to land on top of another run's state or append its work to another run's audit history. Run directories created before this are shorter and keep working unchanged — nothing reads meaning into the length. -
Agent worker (as the run's subject). The loop: build messages → call the model through Muzzle → read the model's tool calls → for each, consult the action policy:
auto→ run the tool (its file/network/command effects are enforced by Leash);needs_approval→ checkpoint, record an approval, and pause the run;deny→ feed the denial back to the agent. No tool calls → the model's answer is final → the run isdone.
-
Async approvals. Pending approvals are persisted. Where depends on what the worker runs as, not on what kind of run it is. A run whose worker is the leashed user writes the shared queue at
~/.sentinel/approvals.jsonldirectly. A run whose worker is an agent's own account — or whose scope sandboxes it away from the state directory's root — records the approval on its own run state instead and pauses; the supervisor (root) then copies it into the shared queue under the same id, so the operator sees one list either way. The agent is never given write access toapprovals.jsonl, because that would let it resolve its own approval; the supervisor is the only writer of a resolution, and it writes it back onto the run for the worker to read. An operator lists pending approvals and approves or denies via the CLI or the console; on approval the supervisor resumes the run and the tool executes, then the loop continues. Each approval is applied at most once, recorded as a durableapproval_appliedevent before the tool runs, so neither a crash nor a resume can repeat an approved side effect.
When a guardrail blocks
Enforcement is only half the story — a block the agent can't understand just makes it fail or loop. So every block is made legible to both the agent and the operator, without adding any new execution path:
- Muzzle blocks (model I/O). A rejected model call is typed by direction and category (secrets / PII / prompt-injection / content-policy). If the block is on the agent's output, the worker feeds it a rewrite note and re-drives the turn (bounded by a per-run retry cap) so it can produce a compliant reply; exhausting the cap fails the run cleanly. If the block is on the input it was handed, the agent can't process it — the run stops with a plain explanation and does not retry.
- Leash / sandbox denials (OS actions). A tool failure is classified: a filesystem denial (Landlock or Leash's fs surface) comes back to the model as the path is outside your workspace — write to your workspace or /tmp, and a blocked network reach comes back as the host isn't on the operator's allow-list — legible guidance, not a raw errno.
- Operator visibility. Each block emits a
guardrail_blockevent that the web console renders as a color-coded card in the run feed (source-tinted for Muzzle vs Leash/sandbox). For a Leash denial the supervisor (leashed) correlates the event with Leash's decision log and attaches the exact reason (e.g. "1.1.1.1:80 not in whitelist") — the correlation is a supervisor concern, done once outside the run, not per worker. - In-context guidance. When a run is subject to Muzzle and/or the sandbox, a compact
guardrailsskill is auto-injected into the agent's system prompt (only the parts that apply to that run), teaching it what each block means and how to respond — rewrite, stop-and-explain, or pick an allowed path/host.
This is pure observability layered on top of the existing enforcement: a guardrail_block
never executes the blocked action, the injected skill is text only (it grants no tools),
and the correlation with Leash's log happens root-side only. Making a block visible
never makes it bypassable.
Why files live in the leashed user's home
Per-agent runtime state (souls/, skills/, memory/, runs/) lives under the
leashed user's ~/.sentinel, so the worker — which runs as that user under Leash's
filesystem enforcement — can read and write its own state without anyone having to open
up a privileged system directory.
Two deployment shapes, one runtime
Sentinel's guarantees are Linux kernel features — Landlock, seccomp-BPF, cgroups, nftables, fanotify — so Sentinel runs on Linux. There are two ways to get there:
- Direct install on a Linux host.
install.shcreates the leashed user, writes the systemd units, and registers with Leash and Muzzle. - The macOS desktop app. A Tauri shell around a managed Lima VM (Apple Virtualization, Ubuntu guest). The Mac runs the window, the CLI shims and the browser; every enforcement mechanism runs inside the guest.
These are not two ports. The VM installs Sentinel by fetching the same artifact from
the same endpoint and running the same install.sh with the same production profile
and leashed user — so the guest is a normal Linux install, and every CLI subcommand
behaves identically because it is the same code on the same kernel. The only
platform-conditional code in the product is a handful of sys.platform != "linux" guards
that make the sandbox modules importable on a developer's Mac; they never fire in a real
deployment.
What differs is reach, not behaviour: on macOS the host sentinel has no config (nothing
is installed there), so commands go through sentinel-runtime cli <command>, which
forwards into the guest. The console is port-forwarded to the Mac's loopback only — the
VM is NAT-isolated, so the guest binds 0.0.0.0 internally but nothing is exposed to the
LAN.
CLI-runtime agents run with a reduced boundary
A codex or claude-code agent is that CLI: Sentinel hands it the prompt and it drives
its own agentic loop, with the agent's granted Sentinel tools bridged in as MCP servers so
it can still call them. It remains an ordinary agent — scoped, teamed, workspace-bound.
But because the CLI owns the loop and brings its own sandbox, Landlock and seccomp are skipped and its model traffic never passes through Muzzle. What still applies is the leashed user, Leash's egress enforcement, the per-run cgroup and the workspace as cwd.
That trade is deliberate, and it is not silent. A production or airgapped profile
refuses such a run unless the operator sets allow_cli_runtime: true, and every run's
security_attestation records cli_runtime and muzzle_inspected: false — so an audit
can identify exactly which runs had the weaker boundary rather than inferring it.
Their subscription credential is Sentinel's, not the machine's: stored in the secrets
store and materialized into a private HOME for the duration of a single run, so the CLI
only ever sees the credential chosen for that agent, and it disappears with the run.
Dependencies
Sentinel requires Muzzle and Leash. The installer verifies (and can install) both, and
sentinel init --leash <user> [--muzzle] registers the agent's user with Leash,
ensures Leash is running, and checks Muzzle.
Identity: one name for the agent and its user
First-run setup asks for one name (sentinel setup prompts "Name your agent (also
its Linux user)"; headless installs default to naomi). That single name becomes both
the head agent and the leashed Linux user: the display name is preserved for the
agent (e.g. Naomi), and a normalized form (lowercase, [a-z0-9_]) is the OS account
(e.g. naomi). There is no generic main agent anymore — init creates the named head
agent, points default_agent at it, and writes its starter soul.md. --leash <name>
doubles as the name on either command.
Migration: existing deployments keep their current leashed user and main agent until
you re-run setup with a name (which creates the new named head agent alongside main —
nothing is renamed or deleted).
Who is signed in: accounts, sessions, and roles
The console authenticates people, not just "whoever has the token". An operator signs in with a username and password; the password is verified against a scrypt hash and, on success, the server mints a session token — random, per-user, expiring — that the browser sends on every request exactly like the old bearer token, so the streaming and ticket paths are unchanged. Only the SHA-256 of that token is stored, so the session file can't be read back into a working session.
Every request resolves to an identity before anything else runs, and the role is re-read
from the user store at that moment (not taken from the session), so disabling an account
or demoting someone takes effect on their next request rather than their next login.
Roles gate the surface: auditor is read-only everywhere, member chats and nothing
else, operator drives runs and the resources runs need (agents, teams, workspaces,
memory, cron, MCP), approver additionally resolves approval gates, and admin
additionally holds the deployment's credentials and external surface — models and
providers, secrets, API endpoints, guardrails, users. An account can also be limited to
named agents, which narrows chat.use and leash.edit and is checked on /api/chat
itself (the agent is named in the request body, so the route gate cannot see it).
A role is a preset, not a ceiling. An account's Access section is the authority on
what it may do: an admin ticks capabilities on or off for that one account, and the store
keeps the difference from the role (extra_permissions / denied_permissions) rather
than a copy of the bundle — so widening a role still widens everyone holding it, and what
is recorded is the decision somebody made. permissions.effective() is that arithmetic,
deny wins, and both lists are filtered against the permission vocabulary on read as well as
on write.
Two permissions have a reach as well as a yes/no. models.edit and endpoints.manage
govern records that carry an owner, so an account can hold either for the things it set up
or for everyone's (permissions.OWN_SCOPED, stored as scopes: {permission: "own"};
absent means all, which is what every account had before). Models and providers grew an
owner field for it, defaulting to "" so records already on disk load unchanged — and an
unowned record is nobody's, which is what stops own quietly reaching everything a
deployment already had. The check is permissions.covers_record(), called in the handler
rather than at the gate for the same reason as the per-agent chat grant: the gate would have
to read the record to learn who owns it. Guards that matter in practice are enforced server-side — you cannot demote,
disable, or delete the last remaining admin.
A fresh install creates its first admin on the sign-in screen. With no accounts yet,
GET /api/auth/methods reports bootstrap: true and the screen offers a "create the first
admin" form instead of a password form that could only fail. It posts to
POST /api/auth/bootstrap with the shared token as the Authorization header, and that
route is refused the moment any account exists — so it is a door that closes, not a standing
one. It accepts only the shared token: were a live session accepted, one that outlived an
emptied user store could mint itself an admin.
Signing in is rate-limited per connection, before the hash. A sliding window counts
failures per (username, ip) and per ip, and the worse of the two decides — the first
catches one host grinding one account, the second catches one host spraying many. Past five
failures in fifteen minutes the delay doubles to a sixty-second cap, and the check runs
before scrypt, which costs 64 MiB per call and would otherwise make a flood of wrong
passwords a cheap way to exhaust the box. The counter is in memory and keyed on the
connection, not persisted per account: a durable per-account lockout is a denial of service
an attacker gets for free by failing against someone else's username. The durable record of
a failed sign-in is the audit log.
An account manages itself. POST /api/auth/password changes your own password (the old
one required) and revokes every session including the browser that made the change — the
usual reason to change a password is that it leaked, so the sessions minted under it are as
compromised as the password was. GET /api/auth/sessions lists your live sessions as
metadata and a "this device" flag, never a token, and DELETE /api/auth/sessions is "sign
out everywhere". None of the three takes a username: they act on the caller, so they cannot
be pointed at anyone else.
The original single shared token still authenticates, mapping to a break-glass admin, and it
keeps existing scripts working. It is admin-equivalent, shared by everyone who holds it, and
audits as (shared-token) rather than as a person, so while it is in use the console shows a
standing banner saying so (break_glass on /api/auth/session). sentinel token status|rotate|disable manages it from the box; disable refuses unless an active admin
account exists, because removing the only way in is not a security improvement.
For a headless install, or a box where every admin is disabled or forgotten, sentinel user add|list|role|disable|enable|reset-password|delete does the same work from the command line.
It is root-only and writes the account store directly rather than calling the API — an API
call would need the credential those verbs exist to create or replace. The last active
admin cannot be demoted, disabled or deleted there either; a disabled admin does not count
toward it, since it cannot sign in.
One table, one gate
Authorization is one question asked in one place. sentinel/permissions.py holds the
permission list, the role bundles and can(); sentinel/webui/route_permissions.py says
what each route needs; and a single middleware in the web app asks the engine for every
/api/ request. Three callers share that engine — the API gate, the console (which renders
off the permission set it is told, so a custom role shows the right controls) and @-mention
resolution — so none of them can disagree with the others.
A route the table does not know is refused. That is the design, not a side effect. An
access-control engine in an app with 219 endpoints does not fail because the engine is wrong;
it fails because endpoint #220 never asked it. A decorator can be forgotten silently, so the
requirement lives in a table instead: an undeclared route gets a 403 naming the file to edit,
and tests/test_route_permissions.py fails the build. A new endpoint is dead until someone
says what it needs. Mutating routes are listed one by one (a missed declaration on a write is
the dangerous one), reads are matched by area, and the four routes reachable without any
credential are a list short enough to read.
The permissions. chat.use, runs.read_all, runs.share, approvals.decide,
agents.edit, teams.edit, resources.edit, models.edit, endpoints.manage,
secrets.manage, guardrails.edit, leash.edit, leash.floor, users.manage,
audit.read, system.admin.
leash.floor is deliberately not implied by leash.edit: the floor changes every subject
on the host rather than one agent, which is a different blast radius, and Leash keeps the two
apart at every layer below.
The bundles were measured, not invented. Every route was enumerated with the gate it carries today, and the four built-in roles reproduce exactly that — so an upgrade changes nobody's access. The operator keeps the resource editing it has always had; guardrails, the head agent and user management stay admin-only; the approver keeps approval gates, refinery writes and a codebase push.
Endpoints are their own permission, and they carry an owner. A programmable HTTP
endpoint is a standing API key that starts runs against an agent on its own — a different
kind of power from registering a model, which is why endpoints.manage is separate from
models.edit. Reading the endpoint LIST is ordinary (it omits every key); reading one
endpoint returns its key, so that is a credential read and takes the permission too. The
endpoint records the account that created it, taken from the authenticated caller and never
from the request body, and the runs it starts are filed under that account — a standing
key has no caller identity of its own, and its owner is the only person who can be said to
have asked for those runs. An edit does not move the owner: whoever last touched an
endpoint would otherwise inherit everything it does.
Custom roles are the new capability: a named set of permissions in <state>/roles.json,
managed under Users & Roles. A built-in name cannot be redefined, a role still held by an
account cannot be deleted (the account would resolve to an unknown role and silently lose
everything), and an admin cannot grant a permission they do not themselves hold — a role
is a way to hand out rights, and handing out more than you have is privilege escalation
however it is spelled.
Per-account agent grants. An account can be limited to named agents; empty means every
agent, which is what every existing account has. The grant narrows the agent-scoped
permissions (chat.use, leash.edit) and is checked at the gate for routes whose path
carries an agent name — explicitly listed, because the {name} on the Leash profile route
is a profile, and scoping that by an agent grant would refuse the wrong thing.
Mentioning a person
One namespace. Accounts, agents and teams share one set of names, so @name means one
thing. All four creation sites check it — a namespace enforced on one side is not a
namespace — and a store that cannot be read refuses the name rather than calling it free,
because a name that looks free because a lookup failed is how the collision gets created.
An existing collision is reported, never renamed. A live box can already hold the agent
naomi and a user naomi, and neither is safe to rename underneath its owner: renaming an
agent breaks its run history, its Leash bindings and its leash-<name> account, and
renaming a user breaks their sign-in. sentinel doctor warns and names the winner — the
agent, which is the behaviour that was already there.
Two mention rules, and both can apply to one message. An agent mention must lead
(@vader fix it), because it decides who runs the turn. A person mention can be anywhere
(@vader fix it, cc @dave), because it only decides who is told afterwards. A message that
starts with a person's name is not an agent mention at all, so @dave can you look at this goes to the current agent and tells dave, rather than being refused as an unknown
agent. dave@x.com is not a mention — the pattern rejects a name preceded by @ or by name
characters — and .. is refused even for an account with that name, because a username
becomes a path component downstream.
A mention grants access, and only someone who could share may grant it. The mentioned
person gets reply access, a notification and an audit line, and the response tells the
sender what actually happened per person. A mention by someone with reply rights but no
runs.share notifies without granting — otherwise @ is a way to hand out access around
the permission engine. The grant degrades to read for an account that cannot use that agent,
by the same rule the share route follows.
The grant is written before the worker starts, and that ordering is load-bearing: the share list lives on the run document, and the worker loads that document when it starts and saves the whole thing back, so a share written after the spawn is erased by the worker's own save.
The badge is the interim surface, by decision. <state>/notifications/<user>.jsonl, one
0600 file per account, with the username validated before it is used as a filename. The
console polls it on the refresh it already runs and when the tab regains focus. Because the
record is written server-side, whatever replaces the badge — email, Slack, a desktop
notification — reads the same file and nothing else moves. A notification that cannot be
written fails the turn: the mention is why the sender wrote the message, and sending it
while silently telling nobody is worse than refusing.
The audit trail
<state>/audit.jsonl, 0600, one JSON line per action: the account that asked, how it
signed in, what it asked for, against what, the outcome, the address. It appends through
privilege.append_private rather than open(path, "a") — the secrets audit shipped exactly
that bug, root appending to whatever a planted symlink pointed at.
Two streams, reported separately. This log records who asked; the root broker's own log records what root then did. They are shown side by side rather than interleaved, because merging them would imply a correspondence that does not always hold: a broker action can have no request behind it, and a refused request never reaches the broker.
Completeness by default, detail by hand. One middleware records every non-GET /api/
request, so a new endpoint is audited whether or not anyone remembered — the same argument
as the permission table. The action is the route template and the specifics go in
target. On top of that, named records carry the meaning a path cannot: auth.login,
auth.login_failed (with the claimed name and the address, never a password),
auth.login_throttled, user.role_changed (with what it changed from), run.shared,
approval.decided, and every Leash write with its acknowledged tokens. Reads are not
recorded: they are the overwhelming majority of requests and would bury the actions.
A deleted line is visible. Every record carries a monotonic sequence number read back
from the file, so removing one leaves a hole that cannot be closed without rewriting
everything after it. There is no rewrite path in the class at all — a test asserts that —
and rotation renames the file rather than truncating it, so old records survive as
evidence. sudo sentinel audit verify reports gaps and unreadable lines and exits non-zero,
which makes it usable as a cron tripwire.
An unrecordable action is refused, unless an admin says otherwise. The log is probed
before the request is dispatched, so a refusal means nothing happened — afterwards the
only options would be to lie about the outcome or to report an action that is not in the
trail. The host setting on_write_failure is refuse by default; an admin can set allow,
and then actions proceed and are counted, with a standing banner and a doctor warning.
Three rules stop the toggle being an exit from being audited: the default is strict,
switching to allow needs an appendable log so the switch is itself always recorded, and
switching back needs nothing because tightening never needs permission from the thing being
tightened. sudo sentinel audit policy --allow --reason "…" is the one path that works while
the log is broken, and it writes the reason to the journal instead.
Who can see a conversation
A run has an owner — whoever started it — and a share list. Nobody else sees it.
run_access.level answers one question for every run route and both run-list backends,
in four levels: own (the owner, and anyone with system.admin: may share, reassign,
delete), reply (read it and add turns — what a share or an @-mention grants), read,
and none.
A run you cannot see answers 404, not 403. Otherwise a run id is an oracle: guess one, read the status code, and learn whether it exists and whose it is. A run you can see but may not change answers 403, because there the existence is not the secret.
A conversation can have guests. RunState.guests names agents somebody brought into
this chat and left in it, and the only thing it changes is REACHABILITY: a guest is
@-able for the rest of the conversation whatever the scope says — which is how an
agent:<a> chat (whose roster is, by design, nobody) gets a second agent at all. The
run's own agent still drives; a guest answers when it is addressed, in its own sandbox
(_mention_scope), because being invited into a conversation must not widen what an agent
can reach. A visit ("peek in and work, then leave") stores nothing — it is a mention, and
the mention path handles it — while a stay is a name on that list plus a guest_joined
event, and leaving is guest_left. Bringing one in needs chat.use on that agent and
reply on the run.
A mention can ask to widen ONE conversation. @-ing somebody whose account may not
use this chat's agent used to hand them a transcript they could not answer in, silently. The
send now reports the shortfall, and the console asks the sender then and there: allow in
this chat only, or leave them read-only. Permitting writes the person into
RunState.agent_waivers, which lifts the account's agent list inside that run and
nothing else — never chat.use itself (so a read-only role cannot be invited into
replying), never outside the run, and only when the granter holds that agent themselves,
which is what makes it an invitation rather than an escalation. It is shown and revoked in
the share control, and unsharing takes it with it.
Two rules are easy to get backwards and are therefore fixed in tests. Ownership wins
over the per-agent grant — losing your own conversation because an admin narrowed your
agent list would be a surprise with no upside, since the transcript already exists and is
already yours. And a share cannot hand out what the role withheld: a reply share
degrades to read for an account that may not use that agent, or sharing becomes a way
around the permission engine. Sharing itself needs runs.share as well as ownership —
being able to read a conversation must not be enough to pass it on.
Runs from before this have no owner, as do cron fires and agent-API calls — and what
that means now depends on the run's KIND. A cron, task or api run is the deployment's
plumbing: it stays readable by every signed-in account, which is how every run behaved
before ownership existed, and making those admin-only was tried and rejected because it hid
the Cron tab from the operators who manage the jobs. An unowned chat is somebody's
conversation, and since 2026-10-16 it is not communal: it is readable by an admin or an
auditor and by nobody else. That was measured on a live box — an operator account created
minutes earlier could read every chat on the deployment, because every chat written before
ownership existed is unowned. The one exception is the cron: <job> chat a scheduled job
posts its transcript into: it is a job's output wearing a chat's clothes, it names the job
in cron_job, and it stays communal with the rest of the cron rows.
A missing or unrecognised kind is treated as private, which is the fail-closed direction
and makes the "no owner, no share list" answer RunStore.access_facts gives for a corrupt
run mean what it says. Nobody inherits reply on a run they do not own, new runs always
carry an owner (so the unowned set only shrinks), and POST /api/runs/{id}/owner lets an
admin hand one to the person who actually ran it — which makes it private from that moment.
Every human turn carries the account that wrote it (Message.speaker). The console
labels each one with that name instead of the word "user", and the model is told the same
thing two ways: the turn reaches the provider as name: … (there is no wire field for it —
Ollama's chat shape has no name), and a separate system message, refreshed on every
drive, lists the people with access and what each of them may do. It is its own message
because the main system prompt is composed once per run on purpose: someone shared into a
chat on turn nine would otherwise stay invisible to the agent for the rest of it.
The live transcript needed fixing for this. The SSE stream cannot send headers, so it authenticates with a single-use ticket and the authorization gate skips it by design. That ticket used to be run-agnostic, which gave nothing away while every account could see every run — and became a hole the moment runs became private: mint a ticket for your own chat, open someone else's stream, watch their transcript arrive live. The ticket now carries the run and the minting account, a ticket minted for another run (or for the VNC socket, which has no run) will not open a transcript, and access is re-checked when it is redeemed so a share revoked in those seconds takes effect.
Both list backends filter through the same helper. RunStore.summaries() and
SqliteRunStore.summaries() swap under a listing when the mirror drops; their ordering
was already pinned to match, and now their visibility and row shape are too.
Roles gate the settings that decide how much review happens, not only the actions. Two kinds of write used to be available to any signed-in operator even though each one quietly removes a control:
- Rewriting the guardrails themselves — pushing a new Muzzle policy or a new Leash egress config — now requires admin. These are the boundaries every run is judged against; an operator who can rewrite them is not bounded by them.
- Turning on automatic tool approval — on a chat, a cron job, or an endpoint — now requires approver or admin, the same authority as resolving an approval gate. Granting a run the standing right to skip the queue is the same decision as approving every call it will ever make, so it is the same role. Driving a run that already has automatic approval (continuing it, steering it, regenerating, answering it) takes that authority too: otherwise the gate would only apply to who turned it on, not to who uses it.
A tightening takes effect on the next tool, not the next run. A run's approval policy
lives in its own small control record, written atomically, separate from the worker's
state checkpoint. Workers re-read it before every tool gate, and it is overlaid whenever
run state is loaded or saved, so a worker that checkpointed while the policy was loose
cannot write the loose value back over an operator's change. A tool already dispatched
finishes; every gate after the change sees the new policy. A missing record means the
policy set at creation still stands; a corrupt or unreadable one falls back to queue,
the strictest setting, rather than to whatever was there before.
Self-state: soul, operator model, and memory
The head agent maintains its own context across sessions through three self-state files
under ~/.sentinel/, all loaded into the system prompt each session:
agents/<name>.soul.md— its persona (overrides the profile soul). Edited via theedit_soultool (auto; each write is backed up to.soul.md.bak).user.md— its model of the operator, shared across the head agent. Appended via theupdate_usertool (auto).- memory — durable notes via
remember/recall(see below).
All four self-state/memory tools run auto (no per-edit approval) so the agent can keep itself current without interrupting the operator.
Agents author their own skills and tools
An agent can extend itself at runtime, not just remember things:
- Skills are markdown.
create_skillwrites~/.sentinel/skills/<name>/SKILL.md(frontmatter + body) and attaches it to the authoring agent; it loads into that agent's system prompt on the next turn. Skills carry no execution, socreate_skill/list_skillsrun auto. - Tools are Python.
create_toolwrites~/.sentinel/tools/<name>/{tool.yaml,tool.py}(the code must definerun(args)returning a string). The tool then loads into the registry like any built-in, and a call runs the code out-of-process as the leashed user — so it inherits the same Leash egress allowlist and Landlock filesystem sandbox as the worker, plus a CPU-time limit and a wall-clock timeout. The built-inrun_commandtool runs through the same bounded runner the whole harness is standardizing on: a wall-clock timeout, a whole-process-group kill so a command that forks children can't survive the timeout, a CPU ceiling, and an in-memory output cap. (Hard RSS/memory bounding is a deployment control — a systemd/cgroupMemoryMax; seeSECURITY.md.) A failure never crashes the run: a sandbox/Leash denial comes back as a legible guardrail reason (and aguardrail_blockevent — see When a guardrail blocks), while other failures (timeout, exception) come back as anerror: …string fed to the model.create_tool/edit_tool/list_tools/delete_toolrun auto — managing a tool is not the same as running it.
Approve-once-then-auto. Running an authored tool is gated by trust keyed on a SHA-256 of its code. The first call to a freshly authored (or freshly edited) tool pauses for approval, showing the operator the code they're about to trust; approving runs it and marks that exact code trusted, so later calls run automatically. Editing the tool clears trust (the code changed), so the next call re-prompts with the new code. Denial does not trust it. This keeps the "agent evolves itself" loop fast while keeping a human in the loop the first time any new code would run.
The approval is bound to the bytes, and those bytes are what run. An approval records
the digest of the code as it was when the operator read it, and execution re-verifies
against that digest and then hands the verified source itself to the child process,
which compiles it from memory rather than re-opening the file. So the window between "a
human read this code" and "this code ran" contains no second read of the file — editing
tool.py after approval cannot change what executes. A tool whose bytes no longer match
its approval is refused with authored code requires fresh review and must be approved
again; an approval recorded before digests existed carries none, and is refused the same
way rather than trusted by default.
Whether code must be verified is decided from the tool registry, which knows an entry
was built from authored code, never from the presence of the tool's metadata file. That
distinction is load-bearing: when the check was keyed on tool.yaml, deleting that file
made an authored tool look like a built-in and skipped verification entirely, while the
registry still ran its code. A guard must not be conditional on a file the attacker can
remove.
Authored tools run through the same bounded runner as run_command — wall-clock
timeout, whole-process-group kill so forked children cannot outlive it, CPU ceiling, and
an output cap. Output that exceeds the cap is now an error, not a silently truncated
success: a half-result handed to a model reads exactly like a whole one, and the model
cannot tell that the rest is missing.
Persistent agents and orchestration
An agent is a named, on-disk profile under the leashed user's ~/.sentinel/agents/:
a soul (persona / system prompt), a bound model chosen from your configured list
(routed via Muzzle), an extensible skill set (SKILL.md files injected into its
context), and the tools it may call. Run one with sentinel run --agent <name>.
One agent acts as the orchestrator. Given a task it can:
- delegate the task to a persistent specialist it picks from the roster, or
- spawn a temporary subagent — composing a persona, model, and skills on the fly — when no specialist fits.
A delegated or spawned subagent runs as its own durable child run under the same Muzzle
- Leash enforcement (possibly a different model), and returns its result to the parent.
Delegation is the approval gate (you approve each delegation, seeing the agent + task +
grants); within it the child's granted tools run automatically. Tool grants flow
parent → child (least privilege), depth is capped, and a temporary subagent is
discarded,saved to the roster, or queued for your approval (ask) per config.
Several agents in one conversation
A conversation is not tied to a single agent. Typing @name hands that turn to
another agent inside the same thread: it answers there, attributed, and the
agent that normally drives the chat does not take a turn. There is no delegation
tool call in between — you addressed someone directly.
Underneath, the conversation is the shared thing and each agent's turn is still its
own run. That is deliberate. A run's sandbox — its Landlock paths, seccomp filter,
tool grants, model and approval queue — is fixed when the run starts, so letting a
second agent speak inside someone else's run would either execute it with that
agent's reach or require rebuilding the boundary mid-flight. Instead the mentioned
agent gets a real child run in its own scope, and its answer is written back
into the transcript. Being named in a conversation never widens what an agent can
do, and every @ is as visible, cancellable and auditable as any other run.
A mentioned agent joins the discussion rather than receiving a brief: it reads the conversation so far, with each turn attributed to whoever said it, and knows which other agents are present and what each is for. What it does not get is another agent's raw tool output — those arrive as one-line summaries. That boundary does two jobs at once. It keeps file contents, command output and secret-bearing results inside the sandbox that produced them; and it keeps the context budget on the conversation, since tool results are the bulk of a transcript and replaying them would push the discussion into compaction — leaving the agent knowing less, not more. An agent that needs a file reads it itself, with its own tools.
Who @ can reach follows the run scope: a team chat reaches that team's members, a
head-scope chat reaches any agent, and a single-agent chat reaches nobody. The rule
is enforced server-side, so it holds for the console, the interactive shell and the
agent API alike.
Reliable on small models
Because small local models are inconsistent at tool calling, the harness compensates: a tool-use prompt scaffold tells the model to emit real tool calls; the gateway recovers calls a model emits as plain text; tool arguments are normalized and validated against their schema, with a clear correction fed back so the model retries; and a subagent automatically receives the orchestrator's original request so a terse hand-off never loses detail.
Memory and context compaction
Each agent has its own memory bank under ~/.sentinel/memory/<scope>/ — a flat
index.md plus an Obsidian-style vault/ of notes (YAML frontmatter + body +
[[links]]). The scope comes from the agent's profile (memory: field): private to the
agent by default, or shared when several agents name the same scope. The agent uses two
tools: remember(title, content, tags?, links?) writes a bounded note, and recall(query)
performs model-agnostic lexical retrieval with stopword filtering, light inflection
normalization, title/tag/phrase weighting, and recency tie-breaking. Relevant memories
are also surfaced at the start of a task, but are explicitly marked as untrusted and
potentially stale context. Knowledge persists across runs without requiring an embedding
service. Semantic/embedding recall remains a later enhancement.
Context compaction keeps long runs inside the model's window: when a run's history
crosses a (character-estimated, tokenizer-agnostic) threshold, Sentinel summarizes the
older middle turns into one running "summary so far" message via a model call, and sends
the system prompt (soul + skills) plus the most recent turns verbatim alongside it. The
threshold is measured against what a compaction can actually shrink: the leading system
prefix (soul, attached skills, recalled memory) is kept verbatim, so it is subtracted from
the budget up front and only the rest of the transcript is compared. An agent whose prefix
alone exceeds the threshold is never compacted — no summary could bring it under — and the
run records one compaction_disabled event with the numbers, which is the cue to raise
context.compact_threshold_tokens or lighten the agent.
Compaction shapes what the MODEL is sent. It never rewrites what the run keeps. The transcript on disk stays whole, so the chat still renders every turn and your own words are never replaced by a summary of them. This is a correction: compaction used to save the summarized list over the run's messages, which meant a long conversation could come back as stacked "Summary of earlier conversation" blocks with the operator's real prompts gone for good. A background cost optimization must not rewrite the record.
Keeping that cheap needs a cache rather than a rewrite. The run carries a summary plus the
point it covers up to, so a long conversation summarizes again only when its tail outgrows
the budget — not on every turn — and each extension feeds the previous summary back in, or
the earliest history would quietly fall out. The trade is that state.json is no longer
bounded by compaction and every save rewrites the whole transcript; that cost is the right
side of the bargain against destroying the record.
The conversation record
Every run keeps an append-only events.jsonl beside its state. It records what the model
did — each assistant turn with its content, the tools it called, guardrail blocks,
approvals, delegations — and, as of this version, what the operator said.
That second half used to be missing: only the model's side was written down, so a
transcript could not be rebuilt from the log at all. Each operator turn now appends a
user event carrying the text, a timestamp, and a source naming who authored it —
web or shell for a person typing, task for the instruction a run was created with,
and delegated for a parent agent writing a child run's task. That last distinction is
the point of the field: filing agent-written text as operator input would put words in
your mouth in the audit trail.
The record is written before the state save and fsynced, because it is the evidence least
worth losing; it goes through the same redactor as every other event, so a credential
pasted into a prompt never persists verbatim; and images are recorded as a count rather
than inline base64. Machine-authored turns that merely carry the user role — a guardrail
note, a loop nudge — are deliberately NOT filed as operator input; each already emits its
own event.
Validated today
Built and validated on Linux end-to-end: supervised runs (as the leashed user, model
via Muzzle) that pause on a needs_approval action and resume on approval — including
with Leash filesystem enforcement live; a main agent delegating to a persistent
specialist that runs as a parent-linked child and produces the correct result on a
small local model; and an agent remembering a fact in one run and recalling it correctly
in a later one. A local web UI is the remaining milestone.
The chat sidebar
Chats are ordered by last activity, not creation: a conversation you replied to a minute ago sits above one started last week and never touched. Any chat can be pinned to hold it at the top regardless. Pinning is deliberately not treated as activity — it would otherwise move the chat within the very ordering the pin exists to sit above.
Runs written before this carry no activity stamp; every reader falls back to the creation time rather than treating them as never-touched and sinking them to the bottom. The filesystem and SQLite listings implement the same order, because those two swap under a listing when the mirror drops and a sidebar that reshuffles on failover is its own bug.
The interactive shell
sentinel chat opens a colorized REPL for talking to an agent. Run it as the leashed
user (sudo -u <user> sentinel chat) so the agent's tools execute under that account
and write to its workspace. The shell:
- streams live progress (each model call and tool call) and types out the final, Muzzle-cleared answer;
- handles approvals inline — a flagged action prompts
y/Nright there (the async queue is still there for headlessrun); - offers completion menus as you type:
/for commands (/help,/agents,/use,/model,/status,/memory,/skills,/new,/quit…),@nameto send a task to a specific specialist, and$to browse and attach skills; - keeps ↑/↓ history across sessions and colorizes commands as you type.
The same loop that powers headless runs drives the chat, so durable state, memory,
delegation, and the small-model safeguards (tool-call dedup, the bundled
file-discipline skill) all apply. Agents work from a per-user workspace, so a bare
filename like notes.txt lands somewhere the leashed user owns.
Chat sessions are durable and resume by default. A plain sentinel chat
auto-resumes the most recent session (with a one-line banner); sentinel chat --new (or
/new in the shell) starts fresh. sentinel chats lists saved sessions by id and title,
and sentinel restore <id> reopens a specific one. Each session is a durable run, so its
history and self-state survive across invocations.
Saved workflows
A workflow is a saved, named pipeline at ~/.sentinel/workflows/<name>.yaml. Each
step names an agent and a task; the runner executes the steps in order, and outputs
thread into later steps through templating:
{{input}}— the value passed toworkflow run --input,{{<step>.output}}— a named earlier step's result,{{previous}}— the immediately prior step's result.
Each step runs as its own supervised run with its agent (so Muzzle inspection and
Leash enforcement apply, and the step's granted tools run within the workflow's
authorization). Trigger a workflow with sentinel workflow run <name> [--input …], the
chat's /workflow run <name>, or let an agent invoke the run_workflow(name, input)
tool as part of its own work (gated by approval policy, like delegate). Workflows are
linear in v1 — no branching or loops yet.
Teams
A team is a leader-led roster with a shared workspace, an authored guidelines.md
(prepended to every team-scope run), and an activity log — persisted under
~/.sentinel/teams/<name>/. The head agent delegates a task to a team; that spawns the
team's leader as a team:<name>-scoped child run, and the leader in turn delegates
to its members (delegate_to_member(s)) — each a scoped, sandboxed, approval-gated
child run of its own. The supervisor drives the members concurrently in a bounded pool,
joins their results back to the leader, and the leader synthesizes an answer. The
boundaries are hard: a member's Landlock sandbox excludes other teams' and other agents'
private workspaces, cross-team delegation is only reachable from the head, fan-out is
bounded (concurrency cap + a per-round item cap), and a hard-killed child is reaped so
the leader never hangs waiting on it.
The shared workspace is a Linux group as well as a sandbox grant. Each team records
one (Team.os_group, sentinel-team-<slug>, minted once from the team name by the same
slug engine as an agent's account and never re-derived); saving a team creates it and
syncs its membership through the root broker, and the workspace is 0770 with a default
ACL granting that group rwX. So the boundary does not depend on Landlock alone: an
account outside the team gets nothing from the directory itself. Leaving a team removes
that one supplementary group, and deleting a team never deletes the group — a gid files
may still own must not be recycled onto another team.
Model providers (local + external)
Every model call goes through a Muzzle listener. Local Ollama is the default; an external provider (OpenAI today) is added as a keyless model alias whose per-provider listener + API key Sentinel pushes into Muzzle — keys live only in Muzzle, never in Sentinel's model store or its API responses. The worker picks the right gateway from the model's resolved provider and dials the listener on loopback. A Muzzle-less install can point straight at Ollama (no external providers, no scrubbing) for a minimal box.
CLI-runtime agents (Codex / Claude Code)
An agent's model can be a CLI runtime instead of a chat model: pick codex or
claude-code in the agent editor's model dropdown (they appear there only when that CLI is
installed on the host). Such an agent is driven by the CLI's own agentic loop with its own
native tools — shell, file edit, web — rather than Sentinel's model-plus-approval-gate loop:
Sentinel renders the agent's soul and the conversation into the CLI's task, runs it in the
agent's workspace, and returns the CLI's final message as the turn. The point is to run
these coding agents on your Codex / Claude subscription, not on a metered API key — the
CLI owns its own subscription session (logged in as the leashed user).
The rest of the agent still means what it always did. Any Sentinel tools you grant the agent are handed to the CLI as MCP servers (Sentinel's tools are already MCP-based), so a CLI-runtime agent gets its native tools plus exactly the Sentinel tools you granted — and those flow through the same grant list as any agent. Skills, memory, and workspace stay meaningful. What changes is enforcement: because the CLI sandboxes its own tool calls, Sentinel does not wrap these runs in its per-run Landlock/seccomp or gate each inner tool call — it relies on the OS boundary it always owns (leashed user, Leash egress, the per-run cgroup, the workspace cwd). The console labels the choice "native tools" and says so. See SECURITY.md ("CLI-runtime agents — delegated trust") for the exact posture.
A CLI-runtime agent needs egress its floor does not grant, and Sentinel ships the
answer. A Codex agent on a subscription talks to chatgpt.com — not api.openai.com —
so a sensible whitelist blocks it, and the symptom is a CLI that produces nothing. The
profiles that are known to be needed ship as files in sentinel/leash_profiles/
(codex-agent, claude-agent) and are published at
ai.hurricaneindustries.co/api/leash-profiles. They are bundled as well as published
because the hosts that most need them have the strictest egress: a box that cannot reach the
website still has to be able to configure its agents. A test pins the two copies identical,
and the codex profile records that its hosts were measured off a live decision log while
the claude one says plainly that they were not.
Importing one adds it to the library and binds nothing. That is what makes accepting a fragment from somewhere else defensible: it applies to nobody until an operator binds it, and the bind still refuses until every loosening is acknowledged by field and value. The console offers two sources — a standard profile by name, or an uploaded file — and deliberately not a URL, because a console that fetches policy from wherever a request says is a console that can be told to fetch policy from anywhere. The fragment goes through Leash's own validation on the way in, so a malformed or over-reaching one is refused at import rather than at bind time.
The subscription credential reaches the agent through the root broker. It lives in the
secrets store — 0600, owned by the leashed user — and an agent with its own Linux account is
deliberately unable to read that file, the same wall that keeps it out of secrets.json. But
the worker is that account, so it was the one process asked to materialise the credential,
and every CLI-runtime agent with an account failed with the CLI's own silence ("codex produced
no final agent message"). Two features that each worked alone.
So the broker does it: cli_credential_stage takes a run id, reads the agent's profile as root
to decide whether a credential is needed and which runtime's, reads the secret, and writes
<run_dir>/cli-home/ owned by the agent's account. The worker then only points HOME at it.
The caller names nothing but the run — not the runtime, the path, the uid or the credential —
so it cannot ask for a subscription its agent was never given, and the verb's result never
carries the credential, because the broker audits results and a token there would be a live
credential in the log. Staging happens before the worker starts; afterwards it would arrive
too late to be read.
That tree is written symlink-safely throughout. Root writing into a directory the agent
owns is the hazard: a planted cli-home would otherwise have had root write a live bearer
token wherever the agent pointed — or into another agent's run directory, to read that agent's
subscription.
The CLI's step trace is surfaced, not swallowed: Sentinel parses the CLI's event stream
(codex exec --json; claude --output-format stream-json) and emits each reasoning step,
tool call, and tool result as a run event as it streams. In the console they render as a
collapsed "Agent steps" disclosure above that turn's answer, so you can watch what the
agent did — its shell/edit/MCP calls and their output — not just the final reply.
Uploaded tools (the MCP tool host)
Beyond built-in and agent-authored tools, an operator can upload a plain .py file.
The root process AST-scans it (never importing it) and lists its functions; the operator
picks which become tools, and Sentinel appends a managed marker to those functions. A
private, built-in stdio MCP server (sentinel-uploads, seeded automatically) serves
the marked functions to agents as mcp__sentinel-uploads__<file>__<func> — approval-gated
like any MCP tool. Each call runs in a short-lived, killable subprocess (the leashed
host spawns one per call and re-checks the managed marker there before importing), so a
timeout terminates the work — a runaway or forking uploaded tool can't keep running in
the host after its deadline. The
file's third-party requirements are detected and installed on one click (leashed pip
into a per-user site dir). These tools travel with portability exports.
Workspace file browser
The web console's Files tab is a Finder-like view of what the agents actually produce. Pick a workspace root — the shared leashed workspace, any agent's private workspace, or a team's shared workspace — then navigate folders with a breadcrumb, preview text/code and images inline, and upload / new-folder / rename / delete / download. It's read-write, so it doubles as a way to hand files to an agent (drop them in its workspace) and take files out.
The security-critical part is containment: every path — read or write — resolves through a
real-path jail (fsbrowse.safe_path) that confines it to the selected root. A .., an
absolute path, or a symlink that points outward is rejected, because the resolved real path
must still live inside the root. The browser runs as the leashed user (the web process), so
it can only ever touch what that user already can; the jail additionally stops browsing out
of the workspace into the rest of that user's home (state, secrets, configs). Uploads are
reduced to a bare filename, and the workspace root itself can't be deleted.
Structured endpoints (validated input + output)
A programmable endpoint (sentinel api, POST /api/<name> with a per-endpoint bearer key)
turns a request into a normal supervised run — and can shape both ends with an uploaded
Pydantic class. The operator uploads a plain .py of BaseModel classes in the web UI;
the root process never imports it — a leashed subprocess introspects it (its classes
and each class's fields, declared + computed) and records them.
- Structured output: pick an output class for the endpoint. When the agent's run
finishes, a leashed
with_structured_output(<class>)call — using the agent's own provider, routed through the Muzzle listener like every model call — coerces the answer into a validated instance and returns it as JSON. A model that can't be forced (or a missing class) yields a clean502, never a crash. - Structured input: pick an input class. Each call validates the request
inputagainst it in a leashed subprocess; a Pydantic error returns a400naming the field, and no run is created. The endpoint's single prompt template then lays the validated data out for the model with%field%tokens — scalars inline, lists/objects as JSON, and computed fields (e.g. atranscriptderived from amessageslist) for custom formatting. The web UI offers each class's fields as insertable chips and highlights valid vs. unknown%tokens%as you type.
Untrusted uploaded classes execute only in the leashed sandbox — at upload (introspect), and per request (validate + coerce) — never in the root web/api process. Endpoints without a selected structure keep their prior free-text behavior.
Codebases (attaching a whole repository)
A git datasource attaches an entire repository read-only. The operator adds it
once; agents get search, read, tree, map and schema tools (plus log
when history was fetched), and never a write tool.
sentinel datasource add acme-api git \
--remote git@gitlab.internal:acme/api.git --ref main
sentinel codebase sync acme-api
The split that makes it safe. Cloning and fetching happen control-plane-side
as the sentinel-sync identity — never root, never the leashed worker. The
connector that answers an agent's search reads the already-materialized tree and
cannot open a socket. The thing that talks to the network doesn't run in the
sandbox; the thing that runs in the sandbox doesn't talk to the network.
Where the clone lives, and why. /var/lib/sentinel-codebases/<name>/tree/,
owned by sentinel-sync, mode 0755 — deliberately not under the state dir. The
naomi scope's sandbox grants the whole state tree read-write, so a clone there
would be editable in-sandbox by any naomi-scope run, and that same checkout is
what every other agent reads through the tools. Landlock has no deny rule and
cannot subtract a grant on a parent from a child, so the boundary is an ordinary
POSIX owner: readable at every scope, writable at none. Verified on the runtime
VM — the leashed user can read the clone and is refused on append, create and
delete, while the same file under the state dir is writable.
Search, with no embedding model and no network. Three layers: ripgrep when present, a repo map, and the agent iterating across separate tools.
- ripgrep is an optimisation, not a dependency. Absent one, a bounded Python walker does the same job. rg's only role is producing the candidate file list — snippet extraction and ranking are the same code either way, so the two paths cannot drift into disagreeing about results. Measured on this repository (12,681 files): 3.8–5.4 s per search on the fallback, milliseconds with rg. Install ripgrep for anything repository-sized.
- The repo map is paths plus their top-level symbols, ranked and budgeted, so an
agent can choose where to look instead of guessing filenames. Extraction is
regex-based on purpose — tree-sitter is the obvious upgrade and compiled grammar
wheels land hardest on the airgapped deployments this is for. The map is cached
by commit SHA and rebuilt incrementally: after a fetch,
git diff --name-only <old>..<new>names exactly the files to re-map, which is what makes a large repository affordable to keep current. - Agentic iteration is the actual semantic layer. "Where do we handle retries?" is answered by three cheap probes and a few reads, not by one clever call.
The repository is untrusted input, on two axes. Its text can address the agent
reading it, so every result is labelled as repository content — data, not
instructions — and flows through Muzzle's prompt-injection detector like any other
model input. And real repositories contain committed credentials: search snippets
are always masked for credential shapes, and read is masked by default with a
per-datasource opt-out, because redacting source can corrupt what the agent is
trying to understand and that trade belongs to the operator.
Airgapped is a workflow, not a refusal. On the airgapped profile a network
remote is refused at add time, naming the profile — enforced where the
datasource is saved, in both the CLI and the console, because a rule checked in
one entry point is a suggestion. A local mirror path or a git bundle file is
accepted on every profile: git bundle create acme.bundle --all on a connected
machine, copy it over, --remote /path/to/acme.bundle.
Sync is a fetch and a hard reset to the pinned ref, never a merge — drift self-heals. A dirty working tree is reported and then reset, never silently repaired, because silently repairing tampering destroys the only evidence that it happened.
Working in a codebase (writable checkouts)
A codebase is read-only. A git workspace is a per-run writable checkout of
one, so an agent can edit, build, test and commit — and a reviewable branch comes
out.
sentinel workspace add acme-work --type git --codebase acme-api
sentinel agent set backend --workspace acme-work
/var/lib/sentinel-codebases/acme-api/tree/ shared, sync-owned, read-only
<state>/workspaces/acme-work/runs/<run-id>/tree/ per-run, leashed-owned, writable
A checkout needs no network and no credential. It is a local clone of a tree already on disk. The deploy key is used when the codebase is first cloned, and again only if an operator approves a push — never in between — so a compromised run cannot reach the remote no matter what it decides.
Per run, not per workspace. Two concurrent runs sharing one working tree
corrupt each other silently: one checks out a branch while the other is mid-edit,
git status reports the other's changes as its own, and a commit captures both.
The mount resolver gives each run its own tree, so Landlock hands them different
writable directories.
The checkout has no remotes at all. A local clone leaves origin pointing at
the codebase, and an agent's git push then works — writing into the tree every
other agent reads. The remote is not needed (the clone already copied every
object), so it is removed, and the diff base is recorded at clone time as
refs/sentinel/base. There is nothing to push to from inside, at any permission
level.
Hooks are off (core.hooksPath=/dev/null). A repository's own hooks are an
execution path belonging to whoever wrote the repository, and git commit should
not be a way for them to run code in the sandbox.
Pushing is the one privileged action. Never automatic, never by the agent,
never --force, and only to sentinel/<agent>/<run-id> — so agent work always
lands somewhere a human expects to find it. The operator sees the branch, the
commit count and the diffstat before deciding. A human merges it; Sentinel does
not.
Checkouts are reaped by a sweeper, not by the worker's exit path — a worker killed mid-run never runs its own cleanup, and two mechanisms that disagree about a crash are worse than one that handles both. A run that is still live is spared, along with its uncommitted work.
Cross-uid git needs help, and this is where the surprises live. The codebase
belongs to sentinel-sync and the checkout is made by the leashed worker, so
git refuses both ends as "dubious ownership" and clone --local cannot hardlink
across owners. Sentinel runs those calls under a throwaway HOME whose gitconfig
names exactly the paths involved, and copies objects instead of linking them when
the owners differ. The practical consequence: a checkout costs a real object
copy, so --max-checkouts is a disk decision, not a formality.
Portability
Export an agent or a whole team as a secret-free .sentinelpkg (a zip): its skills,
authored tools, MCP configs with secrets scrubbed, keyless model aliases, souls, team
definition + guidelines, and any uploaded tool files + a recomputed requirements list.
Import it into another deployment — collisions resolve by operator-chosen rename with all
cross-references rewritten, imported tools arrive untrusted and MCP servers disabled, the
reserved uploads host is never overwritten, and fresh workspaces are scaffolded. The
receiver supplies their own keys and installs requirements, and the team runs.
Importing from OpenClaw and Hermes
sentinel import openclaw <path> / sentinel import hermes <path> (also More ▾ → Import
agent in the console, which takes a zipped home) migrate an existing assistant into native
Sentinel objects: agents (model resolved against config.models), skills (SKILL.md +
resources, source-only frontmatter stripped, resources path-jailed), memory notes (as
keyword/token-overlap notes — the importer warns that embedding-backed semantic recall does
not carry over), cron jobs, timers (OpenClaw heartbeat → an interval cron), and
tools (mcpServers/mcp_servers → MCP servers, imported needs_approval, never auto).
Both parsers are defensive (OpenClaw JSON5, Hermes YAML + profiles/ dirs) and warn on
anything they can't map. Cron specifics: an imported job keeps its source timezone (see
below), and its last-run time is carried so it doesn't play catch-up on import; a
schedule that can't become a 5-field cron (a one-shot, or an odd interval) is skipped with a
warning, and a script/command job is imported disabled. --dry-run previews with zero
writes; existing agents/cron/tools are kept unless --force. Because tool paths now expand a
leading ~ to the leashed user's home at execution, imported skills that use ~/… paths
work without rewriting.
Scheduled jobs carry an optional per-job IANA timezone (sentinel cron add --timezone,
or preserved on import): the scheduler evaluates the cron expression in that zone via
zoneinfo, so a job pinned to e.g. America/New_York fires at that wall-clock time wherever
the host runs, with daylight-saving handled per instant. No timezone = the server's local
zone (the prior default).
Operating it
The sentinel CLI: chat [--new], chats, restore <id>, setup, init,
run "<task>" [--agent <name>], runs, show <id>, approvals,
approve <id> / deny <id>, resume <id>,
agent …, team …, workflow …, mcp …, model …, cron …,
package export/import, import openclaw/import hermes, tools install-reqs, status (config + Muzzle/Leash
health), preflight, and logs. Or drive all of it from the browser: sentinel web
(control console) and sentinel api (programmable per-endpoint HTTP API). Both bind
127.0.0.1 by default and refuse a non-loopback bind unless you pass
--allow-external — the console is plaintext HTTP, so exposing it is a deliberate
choice to make behind TLS / a reverse proxy (or reach it over an SSH tunnel). The
bearer token never appears in a URL: the SSE event stream and the VNC WebSocket
authenticate with a single-use, short-lived stream ticket minted over the
Authorization header, keeping the token out of logs and history.
Deployment profile + preflight. A profile config field declares the security
level: production (and airgapped) require Muzzle (enabled + running), Leash
(running), and Landlock (available, fs_sandbox on); development (the default)
permits a degraded box but flags it. sentinel preflight checks the running host
against the profile and exits non-zero when a required control is missing —
turning the old silent downgrades (a muzzle-less box that still reported protected, a
Leash that quietly no-oped, a kernel without Landlock that ran unsandboxed) into an
explicit gate. The gate starts at install time: install.sh refuses to finish a
production/airgapped box (set via SENTINEL_PROFILE, or inherited from an
existing config) when Muzzle or Leash isn't a runnable install — so a degraded
production deploy fails fast instead of surfacing later. Run preflight too, after
install and in CI before a production cut-over. The web console surfaces the same
assessment as a prominent banner (critical/warning/unknown) whenever the live posture
is degraded for the profile, so it's visible without opening a terminal. Under
production/airgapped this is also enforced at run start — a run whose required
controls aren't active is recorded failed (with a security_preflight_failed event)
instead of executing, and an insecure production config is rejected at load. Every
confined run also emits a security_attestation event recording the posture it
actually ran under — profile, leashed user + effective worker UID, the live Landlock
result and ABI, the cgroup slice that bounds it, and Muzzle routing — so a run's own
event stream is a verifiable audit record, not just the config it was launched with.
Diff from previous
--- HOWITWORKS@16
+++ HOWITWORKS@17
@@ -66,8 +66,9 @@
| workspace trees `<state>/workspaces/*/tree` (`0770 sentinel-sync:sentinel-ws`) | none | the mount is dropped and reported as `workspace_mount_unavailable` |
| creating another run (in-process subagent spawn) | cannot | refused with a reason (it cannot search every segment, so it cannot prove an id unused) |
- Nothing in that table is widened to make a row work; the rows that do not work are
- provisioning decisions, listed in `docs/plans/2026-10-13-leash-phase4-plan.md`.
+ Nothing in that table is widened to make a row work: the rows that do not work are
+ provisioning decisions, each of them tracked and reported on the run rather than
+ quietly worked around.
The two "own" rows are the root broker's: when an agent is provisioned,
`ensure_agent_user` creates each directory `0770` on an `O_NOFOLLOW` fd, places
`u:<leashed>:rwx` plus the defaults `d:u:<leashed>:rwX` and `d:u:<agent>:rwX` on that
@@ -231,15 +232,289 @@
Every request resolves to an identity before anything else runs, and the role is re-read
from the user store at that moment (not taken from the session), so disabling an account
or demoting someone takes effect on their **next request** rather than their next login.
-Roles gate the surface: **auditor** is read-only everywhere, **operator** drives runs and
-resources, **approver** additionally resolves approval gates, and **admin** additionally
-manages users. Guards that matter in practice are enforced server-side — you cannot demote,
+Roles gate the surface: **auditor** is read-only everywhere, **member** chats and nothing
+else, **operator** drives runs and the resources runs need (agents, teams, workspaces,
+memory, cron, MCP), **approver** additionally resolves approval gates, and **admin**
+additionally holds the deployment's credentials and external surface — models and
+providers, secrets, API endpoints, guardrails, users. An account can also be limited to
+named agents, which narrows `chat.use` and `leash.edit` and is checked on `/api/chat`
+itself (the agent is named in the request body, so the route gate cannot see it).
+
+**A role is a preset, not a ceiling.** An account's **Access** section is the authority on
+what it may do: an admin ticks capabilities on or off for that one account, and the store
+keeps the *difference* from the role (`extra_permissions` / `denied_permissions`) rather
+than a copy of the bundle — so widening a role still widens everyone holding it, and what
+is recorded is the decision somebody made. `permissions.effective()` is that arithmetic,
+deny wins, and both lists are filtered against the permission vocabulary on read as well as
+on write.
+
+**Two permissions have a reach as well as a yes/no.** `models.edit` and `endpoints.manage`
+govern records that carry an owner, so an account can hold either for *the things it set up*
+or for *everyone's* (`permissions.OWN_SCOPED`, stored as `scopes: {permission: "own"}`;
+absent means all, which is what every account had before). Models and providers grew an
+`owner` field for it, defaulting to `""` so records already on disk load unchanged — and an
+**unowned record is nobody's**, which is what stops `own` quietly reaching everything a
+deployment already had. The check is `permissions.covers_record()`, called in the handler
+rather than at the gate for the same reason as the per-agent chat grant: the gate would have
+to read the record to learn who owns it. Guards that matter in practice are enforced server-side — you cannot demote,
disable, or delete the last remaining admin.
-The original single shared token still authenticates, mapping to a break-glass admin. That
-is deliberate: it's how a brand-new install creates its first account, and it keeps existing
-scripts working. Once named admins exist, it should be rotated and treated as emergency
-access only.
+**A fresh install creates its first admin on the sign-in screen.** With no accounts yet,
+`GET /api/auth/methods` reports `bootstrap: true` and the screen offers a "create the first
+admin" form instead of a password form that could only fail. It posts to
+`POST /api/auth/bootstrap` with the shared token as the `Authorization` header, and that
+route is refused the moment any account exists — so it is a door that closes, not a standing
+one. It accepts **only** the shared token: were a live session accepted, one that outlived an
+emptied user store could mint itself an admin.
+
+**Signing in is rate-limited per connection, before the hash.** A sliding window counts
+failures per `(username, ip)` and per `ip`, and the worse of the two decides — the first
+catches one host grinding one account, the second catches one host spraying many. Past five
+failures in fifteen minutes the delay doubles to a sixty-second cap, and the check runs
+*before* `scrypt`, which costs 64 MiB per call and would otherwise make a flood of wrong
+passwords a cheap way to exhaust the box. The counter is in memory and keyed on the
+connection, not persisted per account: a durable per-account lockout is a denial of service
+an attacker gets for free by failing against someone else's username. The durable record of
+a failed sign-in is the audit log.
+
+**An account manages itself.** `POST /api/auth/password` changes your own password (the old
+one required) and revokes every session including the browser that made the change — the
+usual reason to change a password is that it leaked, so the sessions minted under it are as
+compromised as the password was. `GET /api/auth/sessions` lists your live sessions as
+metadata and a "this device" flag, never a token, and `DELETE /api/auth/sessions` is "sign
+out everywhere". None of the three takes a username: they act on the caller, so they cannot
+be pointed at anyone else.
+
+The original single shared token still authenticates, mapping to a break-glass admin, and it
+keeps existing scripts working. It is admin-equivalent, shared by everyone who holds it, and
+audits as `(shared-token)` rather than as a person, so while it is in use the console shows a
+standing banner saying so (`break_glass` on `/api/auth/session`). `sentinel token
+status|rotate|disable` manages it from the box; `disable` refuses unless an **active** admin
+account exists, because removing the only way in is not a security improvement.
+
+For a headless install, or a box where every admin is disabled or forgotten, `sentinel user
+add|list|role|disable|enable|reset-password|delete` does the same work from the command line.
+It is root-only and writes the account store directly rather than calling the API — an API
+call would need the credential those verbs exist to create or replace. The last **active**
+admin cannot be demoted, disabled or deleted there either; a disabled admin does not count
+toward it, since it cannot sign in.
+
+### One table, one gate
+
+Authorization is **one question asked in one place**. `sentinel/permissions.py` holds the
+permission list, the role bundles and `can()`; `sentinel/webui/route_permissions.py` says
+what each route needs; and a single middleware in the web app asks the engine for every
+`/api/` request. Three callers share that engine — the API gate, the console (which renders
+off the permission set it is told, so a custom role shows the right controls) and `@`-mention
+resolution — so none of them can disagree with the others.
+
+**A route the table does not know is refused.** That is the design, not a side effect. An
+access-control engine in an app with 219 endpoints does not fail because the engine is wrong;
+it fails because endpoint #220 never asked it. A decorator can be forgotten silently, so the
+requirement lives in a table instead: an undeclared route gets a 403 naming the file to edit,
+and `tests/test_route_permissions.py` fails the build. A new endpoint is dead until someone
+says what it needs. Mutating routes are listed one by one (a missed declaration on a write is
+the dangerous one), reads are matched by area, and the four routes reachable without any
+credential are a list short enough to read.
+
+**The permissions.** `chat.use`, `runs.read_all`, `runs.share`, `approvals.decide`,
+`agents.edit`, `teams.edit`, `resources.edit`, `models.edit`, `endpoints.manage`,
+`secrets.manage`, `guardrails.edit`, `leash.edit`, `leash.floor`, `users.manage`,
+`audit.read`, `system.admin`.
+`leash.floor` is deliberately **not** implied by `leash.edit`: the floor changes every subject
+on the host rather than one agent, which is a different blast radius, and Leash keeps the two
+apart at every layer below.
+
+**The bundles were measured, not invented.** Every route was enumerated with the gate it
+carries today, and the four built-in roles reproduce exactly that — so an upgrade changes
+nobody's access. The operator keeps the resource editing it has always had; guardrails, the
+head agent and user management stay admin-only; the approver keeps approval gates, refinery
+writes and a codebase push.
+
+**Endpoints are their own permission, and they carry an owner.** A programmable HTTP
+endpoint is a standing API key that starts runs against an agent on its own — a different
+kind of power from registering a model, which is why `endpoints.manage` is separate from
+`models.edit`. Reading the endpoint LIST is ordinary (it omits every key); reading one
+endpoint returns its key, so that is a credential read and takes the permission too. The
+endpoint records the account that created it, taken from the authenticated caller and never
+from the request body, and **the runs it starts are filed under that account** — a standing
+key has no caller identity of its own, and its owner is the only person who can be said to
+have asked for those runs. An edit does not move the owner: whoever last touched an
+endpoint would otherwise inherit everything it does.
+
+**Custom roles** are the new capability: a named set of permissions in `<state>/roles.json`,
+managed under Users & Roles. A built-in name cannot be redefined, a role still held by an
+account cannot be deleted (the account would resolve to an unknown role and silently lose
+everything), and **an admin cannot grant a permission they do not themselves hold** — a role
+is a way to hand out rights, and handing out more than you have is privilege escalation
+however it is spelled.
+
+**Per-account agent grants.** An account can be limited to named agents; empty means every
+agent, which is what every existing account has. The grant narrows the agent-scoped
+permissions (`chat.use`, `leash.edit`) and is checked at the gate for routes whose path
+carries an agent name — explicitly listed, because the `{name}` on the Leash *profile* route
+is a profile, and scoping that by an agent grant would refuse the wrong thing.
+
+### Mentioning a person
+
+**One namespace.** Accounts, agents and teams share one set of names, so `@name` means one
+thing. All four creation sites check it — a namespace enforced on one side is not a
+namespace — and a store that cannot be *read* refuses the name rather than calling it free,
+because a name that looks free because a lookup failed is how the collision gets created.
+
+**An existing collision is reported, never renamed.** A live box can already hold the agent
+`naomi` and a user `naomi`, and neither is safe to rename underneath its owner: renaming an
+agent breaks its run history, its Leash bindings and its `leash-<name>` account, and
+renaming a user breaks their sign-in. `sentinel doctor` warns and names the winner — the
+agent, which is the behaviour that was already there.
+
+**Two mention rules, and both can apply to one message.** An agent mention must *lead*
+(`@vader fix it`), because it decides who runs the turn. A person mention can be anywhere
+(`@vader fix it, cc @dave`), because it only decides who is told afterwards. A message that
+*starts* with a person's name is not an agent mention at all, so `@dave can you look at
+this` goes to the current agent and tells dave, rather than being refused as an unknown
+agent. `dave@x.com` is not a mention — the pattern rejects a name preceded by `@` or by name
+characters — and `..` is refused even for an account with that name, because a username
+becomes a path component downstream.
+
+**A mention grants access, and only someone who could share may grant it.** The mentioned
+person gets reply access, a notification and an audit line, and the response tells the
+sender what actually happened per person. A mention by someone with reply rights but no
+`runs.share` notifies without granting — otherwise `@` is a way to hand out access around
+the permission engine. The grant degrades to read for an account that cannot use that agent,
+by the same rule the share route follows.
+
+**The grant is written before the worker starts**, and that ordering is load-bearing: the
+share list lives on the run document, and the worker loads that document when it starts and
+saves the whole thing back, so a share written after the spawn is erased by the worker's own
+save.
+
+**The badge is the interim surface, by decision.** `<state>/notifications/<user>.jsonl`, one
+0600 file per account, with the username validated before it is used as a filename. The
+console polls it on the refresh it already runs and when the tab regains focus. Because the
+record is written server-side, whatever replaces the badge — email, Slack, a desktop
+notification — reads the same file and nothing else moves. A notification that cannot be
+written *fails the turn*: the mention is why the sender wrote the message, and sending it
+while silently telling nobody is worse than refusing.
+
+### The audit trail
+
+`<state>/audit.jsonl`, 0600, one JSON line per action: the account that asked, how it
+signed in, what it asked for, against what, the outcome, the address. It appends through
+`privilege.append_private` rather than `open(path, "a")` — the secrets audit shipped exactly
+that bug, root appending to whatever a planted symlink pointed at.
+
+**Two streams, reported separately.** This log records who *asked*; the root broker's own
+log records what root then *did*. They are shown side by side rather than interleaved,
+because merging them would imply a correspondence that does not always hold: a broker
+action can have no request behind it, and a refused request never reaches the broker.
+
+**Completeness by default, detail by hand.** One middleware records every non-GET `/api/`
+request, so a new endpoint is audited whether or not anyone remembered — the same argument
+as the permission table. The action is the route *template* and the specifics go in
+`target`. On top of that, named records carry the meaning a path cannot: `auth.login`,
+`auth.login_failed` (with the claimed name and the address, never a password),
+`auth.login_throttled`, `user.role_changed` (with what it changed *from*), `run.shared`,
+`approval.decided`, and every Leash write with its acknowledged tokens. Reads are not
+recorded: they are the overwhelming majority of requests and would bury the actions.
+
+**A deleted line is visible.** Every record carries a monotonic sequence number read back
+from the file, so removing one leaves a hole that cannot be closed without rewriting
+everything after it. There is no rewrite path in the class at all — a test asserts that —
+and rotation renames the file rather than truncating it, so old records survive as
+evidence. `sudo sentinel audit verify` reports gaps and unreadable lines and exits non-zero,
+which makes it usable as a cron tripwire.
+
+**An unrecordable action is refused, unless an admin says otherwise.** The log is probed
+*before* the request is dispatched, so a refusal means nothing happened — afterwards the
+only options would be to lie about the outcome or to report an action that is not in the
+trail. The host setting `on_write_failure` is `refuse` by default; an admin can set `allow`,
+and then actions proceed and are counted, with a standing banner and a `doctor` warning.
+Three rules stop the toggle being an exit from being audited: the default is strict,
+switching *to* `allow` needs an appendable log so the switch is itself always recorded, and
+switching back needs nothing because tightening never needs permission from the thing being
+tightened. `sudo sentinel audit policy --allow --reason "…"` is the one path that works while
+the log is broken, and it writes the reason to the journal instead.
+
+### Who can see a conversation
+
+A run has an **owner** — whoever started it — and a **share list**. Nobody else sees it.
+`run_access.level` answers one question for every run route and both run-list backends,
+in four levels: `own` (the owner, and anyone with `system.admin`: may share, reassign,
+delete), `reply` (read it and add turns — what a share or an `@`-mention grants), `read`,
+and `none`.
+
+**A run you cannot see answers 404, not 403.** Otherwise a run id is an oracle: guess one,
+read the status code, and learn whether it exists and whose it is. A run you *can* see but
+may not change answers 403, because there the existence is not the secret.
+
+**A conversation can have guests.** `RunState.guests` names agents somebody brought into
+this chat and left in it, and the only thing it changes is REACHABILITY: a guest is
+`@`-able for the rest of the conversation whatever the scope says — which is how an
+`agent:<a>` chat (whose roster is, by design, nobody) gets a second agent at all. The
+run's own agent still drives; a guest answers when it is addressed, in its own sandbox
+(`_mention_scope`), because being invited into a conversation must not widen what an agent
+can reach. A *visit* ("peek in and work, then leave") stores nothing — it is a mention, and
+the mention path handles it — while a *stay* is a name on that list plus a `guest_joined`
+event, and leaving is `guest_left`. Bringing one in needs `chat.use` **on that agent** and
+`reply` on the run.
+
+**A mention can ask to widen ONE conversation.** `@`-ing somebody whose account may not
+use this chat's agent used to hand them a transcript they could not answer in, silently. The
+send now reports the shortfall, and the console asks the sender then and there: *allow in
+this chat only*, or *leave them read-only*. Permitting writes the person into
+`RunState.agent_waivers`, which lifts the account's agent **list** inside that run and
+nothing else — never `chat.use` itself (so a read-only role cannot be invited into
+replying), never outside the run, and only when the granter holds that agent themselves,
+which is what makes it an invitation rather than an escalation. It is shown and revoked in
+the share control, and unsharing takes it with it.
+
+Two rules are easy to get backwards and are therefore fixed in tests. **Ownership wins
+over the per-agent grant** — losing your own conversation because an admin narrowed your
+agent list would be a surprise with no upside, since the transcript already exists and is
+already yours. And **a share cannot hand out what the role withheld**: a `reply` share
+degrades to `read` for an account that may not use that agent, or sharing becomes a way
+around the permission engine. Sharing itself needs `runs.share` as well as ownership —
+being able to read a conversation must not be enough to pass it on.
+
+**Runs from before this have no owner**, as do cron fires and agent-API calls — and what
+that means now depends on the run's KIND. A `cron`, `task` or `api` run is the deployment's
+plumbing: it stays readable by every signed-in account, which is how every run behaved
+before ownership existed, and making those admin-only was tried and rejected because it hid
+the Cron tab from the operators who manage the jobs. An unowned **chat** is somebody's
+conversation, and since 2026-10-16 it is not communal: it is readable by an admin or an
+auditor and by nobody else. That was measured on a live box — an operator account created
+minutes earlier could read every chat on the deployment, because every chat written before
+ownership existed is unowned. The one exception is the `cron: <job>` chat a scheduled job
+posts its transcript into: it is a job's output wearing a chat's clothes, it names the job
+in `cron_job`, and it stays communal with the rest of the cron rows.
+
+A missing or unrecognised kind is treated as private, which is the fail-closed direction
+and makes the "no owner, no share list" answer `RunStore.access_facts` gives for a corrupt
+run mean what it says. Nobody inherits *reply* on a run they do not own, new runs always
+carry an owner (so the unowned set only shrinks), and `POST /api/runs/{id}/owner` lets an
+admin hand one to the person who actually ran it — which makes it private from that moment.
+
+**Every human turn carries the account that wrote it** (`Message.speaker`). The console
+labels each one with that name instead of the word "user", and the model is told the same
+thing two ways: the turn reaches the provider as `name: …` (there is no wire field for it —
+Ollama's chat shape has no `name`), and a separate `system` message, refreshed on every
+drive, lists the people with access and what each of them may do. It is its own message
+because the main system prompt is composed once per run on purpose: someone shared into a
+chat on turn nine would otherwise stay invisible to the agent for the rest of it.
+
+**The live transcript needed fixing for this.** The SSE stream cannot send headers, so it
+authenticates with a single-use ticket and the authorization gate skips it by design. That
+ticket used to be run-agnostic, which gave nothing away while every account could see
+every run — and became a hole the moment runs became private: mint a ticket for your own
+chat, open someone else's stream, watch their transcript arrive live. The ticket now
+carries the run and the minting account, a ticket minted for another run (or for the VNC
+socket, which has no run) will not open a transcript, and access is re-checked when it is
+redeemed so a share revoked in those seconds takes effect.
+
+**Both list backends filter through the same helper.** `RunStore.summaries()` and
+`SqliteRunStore.summaries()` swap under a listing when the mirror drops; their ordering
+was already pinned to match, and now their visibility and row shape are too.
**Roles gate the settings that decide how much review happens, not only the actions.** Two
kinds of write used to be available to any signed-in operator even though each one quietly
@@ -568,6 +843,47 @@
call — it relies on the OS boundary it always owns (leashed user, Leash egress, the per-run
cgroup, the workspace cwd). The console labels the choice "native tools" and says so. See
SECURITY.md ("CLI-runtime agents — delegated trust") for the exact posture.
+
+**A CLI-runtime agent needs egress its floor does not grant, and Sentinel ships the
+answer.** A Codex agent on a subscription talks to `chatgpt.com` — not `api.openai.com` —
+so a sensible whitelist blocks it, and the symptom is a CLI that produces nothing. The
+profiles that are known to be needed ship as files in `sentinel/leash_profiles/`
+(`codex-agent`, `claude-agent`) and are published at
+`ai.hurricaneindustries.co/api/leash-profiles`. They are **bundled as well as published**
+because the hosts that most need them have the strictest egress: a box that cannot reach the
+website still has to be able to configure its agents. A test pins the two copies identical,
+and the codex profile records that its hosts were **measured** off a live decision log while
+the claude one says plainly that they were not.
+
+**Importing one adds it to the library and binds nothing.** That is what makes accepting a
+fragment from somewhere else defensible: it applies to nobody until an operator binds it,
+and the bind still refuses until every loosening is acknowledged by field and value. The
+console offers two sources — a standard profile by name, or an uploaded file — and
+deliberately not a URL, because a console that fetches policy from wherever a request says
+is a console that can be told to fetch policy from anywhere. The fragment goes through
+Leash's own validation on the way in, so a malformed or over-reaching one is refused at
+import rather than at bind time.
+
+**The subscription credential reaches the agent through the root broker.** It lives in the
+secrets store — 0600, owned by the leashed user — and an agent with its own Linux account is
+deliberately unable to read that file, the same wall that keeps it out of `secrets.json`. But
+the worker *is* that account, so it was the one process asked to materialise the credential,
+and every CLI-runtime agent with an account failed with the CLI's own silence ("codex produced
+no final agent message"). Two features that each worked alone.
+
+So the broker does it: `cli_credential_stage` takes a run id, reads the agent's profile as root
+to decide whether a credential is needed and **which** runtime's, reads the secret, and writes
+`<run_dir>/cli-home/` owned by the agent's account. The worker then only points `HOME` at it.
+The caller names nothing but the run — not the runtime, the path, the uid or the credential —
+so it cannot ask for a subscription its agent was never given, and the verb's result never
+carries the credential, because the broker audits results and a token there would be a live
+credential in the log. Staging happens **before** the worker starts; afterwards it would arrive
+too late to be read.
+
+That tree is written symlink-safely throughout. Root writing into a directory the **agent**
+owns is the hazard: a planted `cli-home` would otherwise have had root write a live bearer
+token wherever the agent pointed — or into another agent's run directory, to read that agent's
+subscription.
The CLI's **step trace is surfaced**, not swallowed: Sentinel parses the CLI's event stream
(codex `exec --json`; claude `--output-format stream-json`) and emits each reasoning step,