← Back to document history
Document version

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.

Preview
Source path
sentinel/HOWITWORKS.md
Source commit
No commit recorded
Created at
Oct 18, 2026, 8:50 AM UTC
Source digest
4866ee475fade1782fa5ddff09411df150a9ddd2e3d96e8d7956d9e6fe14007d

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

  1. Supervisor. The sentinel service 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 needs CAP_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 live leash-* agent account, and has PID 1 start sentinel-worker-<run id>.service (systemd-run --uid=<account>) running exactly python -m sentinel._worker <run id>, with the same environment and a sandbox that restates, directive for directive, what a worker inherits from sentinel-web's hardening drop-in (memory/task ceiling included). The supervisor then waits on that unit (an unprivileged systemctl show), reads its exit status like a Popen'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 stay 0600 and out of reach: see SECURITY.md.

    What an agent-account worker can reach. It runs as leash-<agent> (its own primary group plus sentinel-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):

    PathReachWhat 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 onlyreads work; writes (write_skill, remember into a shared bank, …) come back as tool errors
    its own memory bank <state>/memory/<agent>read + write (0770, owned by its account and private group; ACL keeps the control plane in)remember and recall work; other agents cannot read it
    the leashed user's workspace <base>/workspaceread onlynot 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_unwritable now means provisioning did not reach this agent (see sentinel agent provision)
    another agent's workspace or memory banknone (0770, another account's private group)before 2026-10-14 these were 0755, readable by every agent account
    MCP / datasource / workspace definitions, secrets.jsonnone (0600 leashed)reported as mcp_server_unreachable / datasource_unreachable / workspace_mount_unavailable / the tool's own "stored but not readable" error
    workspace trees <state>/workspaces/*/tree (0770 sentinel-sync:sentinel-ws)nonethe mount is dropped and reported as workspace_mount_unavailable
    creating another run (in-process subagent spawn)cannotrefused 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_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 fd, grants u:<agent>:rwX on 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, and sentinel agent provision re-runs it for agents that already exist.

  2. 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-runs copies 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 moves running → 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.

  3. 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 is done.
  4. 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.jsonl directly. 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 to approvals.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 durable approval_applied event 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_block event 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 guardrails skill 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.sh creates 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 the edit_soul tool (auto; each write is backed up to .soul.md.bak).
  • user.md — its model of the operator, shared across the head agent. Appended via the update_user tool (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_skill writes ~/.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, so create_skill / list_skills run auto.
  • Tools are Python. create_tool writes ~/.sentinel/tools/<name>/{tool.yaml,tool.py} (the code must define run(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-in run_command tool 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/cgroup MemoryMax; see SECURITY.md.) A failure never crashes the run: a sandbox/Leash denial comes back as a legible guardrail reason (and a guardrail_block event — see When a guardrail blocks), while other failures (timeout, exception) come back as an error: … string fed to the model. create_tool / edit_tool / list_tools / delete_tool run 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/N right there (the async queue is still there for headless run);
  • offers completion menus as you type: / for commands (/help, /agents, /use, /model, /status, /memory, /skills, /new, /quit…), @name to 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 to workflow 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 clean 502, never a crash.
  • Structured input: pick an input class. Each call validates the request input against it in a leashed subprocess; a Pydantic error returns a 400 naming 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. a transcript derived from a messages list) 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,