agent-hub connects agents that can each run commands. This page says what the hub defends, how, and what it does not.
- Other agents' text is untrusted. Every message that crosses from one peer to another is framed as untrusted input: a channel tag with
meta.sourcefor Claude started withahub claude; otherwise a fixed header line (frame(),[agent-hub message from ...]) with an instruction that says it is untrusted, the plugin's server instructions for a tools-only Claude readinghub_inboxand a standing instruction for the others. A message body cannot forge the hub's own headers: such lines are quoted (sanitize). Replies inherit a hop count capped at 3, so agents cannot ping-pong forever; neither a digest nor a steer can reset it. - The control link is loopback plus a secret. The daemon and the Codex proxy bind 127.0.0.1 only. The control WebSocket requires a per-run token (
.agenthub/state/control-token, mode 600), and both servers refuse any request that carries anOriginheader: any web page can open a WebSocket to localhost, and browsers always sendOrigin. The bearer header a Codex TUI sends to the proxy carries only the terminal launch id (#586): it binds terminal ownership and is not a credential. External clients cannot claim the console user's id or a hub-managed peer's id. - Write retry keys grant no authority (#1078). The
tool_write_idempotencyfeature binds top-level controlrequestIdkeys to authenticated actor/role/launch/generation/execution scope and canonical business arguments. For task proposals and reviews, MCPrequest_idis an explicit retry argument, independent of JSON-RPC correlation; it is removed before dispatch. A timeout or connection loss remainsoutcome: unknown, with a settling read, while a pre-admission refusal says this attempt did not run. Reusing another scope's key, changed arguments, expired or old-instance IDs never starts a new write. Current authority and privacy are rechecked before a cached result is returned, including the PII status of overlap references. Request payloads are hashed rather than retained; result records are bounded and remain in daemon memory. Limits are per authenticated actor (512 entries/15 minutes), not one shared admission budget. Delivery acknowledgement uses the journal's existing idempotence and socket/generation ownership checks without consuming that cache, so another actor's filled window cannot prevent settlement. Legacy clients without keys keep their path; current clients refuse explicit replay to an older daemon that would ignore it. See safe retry and refusal semantics. - Control-write diagnostics carry metadata only (#1126). The daemon's write spans and event-loop probe record a closed set of operation kinds, phases, task numbers and durations, never task text, paths, ids of transports, actors or sessions, credentials, error text or external output, and nothing for a PII task or a private turn. A frame from a client that has not authenticated is never inspected for them. Retention is capped (128 requests, 256 spans, 512 recent intervals, five-minute expiry) and a record in
hub.logis at most 2,048 bytes; client timing on stderr is off unlessAHUB_CONTROL_WRITE_TIMING=1. - Permission prompts stay on unless an operator selects a permission mode.
askleaves native decisions unchanged.ask-when-neededgrants scoped Claude file tools or Pi/local writes and edits, and a headless Claude implementer's proven shell commands through the host wrapper (#845, below); Kimi and Codex use their own native policies.never-askrequires explicit runtime confirmation, a person's consoleyfor a config default at hub start, or retained autonomy consent bound to that peer's exact never-ask value in the untrackedconfig.local.json(#999, below). Without one of these the peer runs inask. Kimi's andlocal's permission requests are relayed to the console and cancelled afterapprovals.timeout_s(default 120 s) of silence; the macOS notification for a waiting request carries the peer and the tool name only. The one exception is the hub's own tools (hub_sendand the task tools, matched by exact name): Kimi's requests for them are approved once without a prompt and logged by name, the same trust Codex gets throughapproval_modein the hub's config. They invoke hub-owned operations rather than arbitrary file or shell tools, and every call passes the hub's own checks. A call the always-ask policy classes (below) is never approved this way, whatever its title. Identity comes from the exact permission title, or from the earlier tool-call title bound to the same call id and resolved against the configured MCP servers when the permission title contains argument JSON (Qwen). Payload text and unrelated display titles never establish identity. Configured ACP kinds (#451) never get this exception: a request whose title spells a hub tool exactly still goes to the person approval path, and only a mapped native mode changes what the agent itself asks. For ACP agents, a payload longer than the console shows is marked as cut and never offers a session-wide grant. Pi's write, edit, shell and git write requests are relayed the same way and can be answeredAlways allow <tool> until Pi restarts: later calls of that tool by the same Pi start skip the prompt but stay inside the path guard and sandbox. The grant covers every later call of that tool whatever its payload, is kept in memory only, cannot be given from the dashboard, and each call it allows is logged by tool name. A Pi orlocalrequest whose call was cancelled or whose turn ended is withdrawn at once: a later answer is refused and grants nothing, anAlways allowincluded. One shell command runs without a hub approval: the!commanda person types in the managed Pi TUI. It stays inside the same sandbox and denylist, and the hub takes it only from the verified owner process of a Pi in TUI mode, while Pi is idle, on a reservation that is used once; a model's own bash call never takes that path (issue #253).--unattendedturns prompts off, says so loudly, and is never the default; a classed call from a hub-owned ACP peer, the local worker or Pi still waits for a person under it. - A committed config cannot choose launch commands, credential files, data endpoints, permission modes or a wider sandbox. The machine-local fields (
kimi_cmd,permission_modes,codex_bin,pi.cmd,checks,mlx.bin,mlx.runtimeDir,mlx.modelPath,omniroute.urls,omniroute.access_hosts, theomniroutekey files,memory.worker_url(still accepted, ignored since 0.16.0),local.read_allow,local.bash_network,local.network_allow,local.doc_read,local.web_fetch,terminal.open,tasks.auto_pull,tasks.queue_backpressure,context.max_assignment_used,schedules(theevery,cronandtzforms alike),folder_triggers,corpus.paths,invocation,start.agents,instances,action_policy,rooms) apply only from a config file git confirms nobody committed (instances,action_policyandroomsare read only from an untracked.agenthub/config.local.json; inconfig.jsonthey are ignored even when untracked):.agenthub/config.jsonor.agenthub/config.local.json, matched by file identity so no other spelling the file system accepts slips past, and.agenthubitself not a committed symlink or submodule. Without a repository, or when git fails, they keep their defaults; an empty value always means the default. "Untracked" is answered by the repository that contains the project: a checkout copied or extracted into an unrelated repository, or into an ignored directory of one, is trusted like your own files. So a cloned repository cannot choose a launch command, a completion check, a gateway to send a key file to, a memory endpoint, a permission mode, the command that opens a terminal, which agentsahub upopens terminals for, a folder the hub reads, work proposed on a timer, a host a model may fetch from, which of your agents' skills other peers may call, or a wider sandbox. A command inchecksruns as you, outside the local worker's sandbox, like a git hook. So do thesetupandverifycommands of a benchmark suite (issue #251):ahub bench run <suite.json>is a person's command, refused from an agent session, and it resets the work tree for each attempt, so it runs only withbench.enabled, at the root of a clean repository that tracks nothing under.agenthub/. Nothing inrouting.tomlor in task text is ever run.routing.tomland the other shared fields still come from the checkout, and they matter:routing.tomlpicks the models the local worker and the hub's inference use at your gateway and can turn the PII constraint off, and roles and budget shape who does what. Review them in a repository you do not trust. The hub's own git calls outside the sandbox (snapshots, facts, config trust, project discovery, memory recall, the bench runner) all use the single hardened implementation in the exact-pinned first-party@staix/agent-mem/platformpackage, re-exported bysrc/hub/git.ts, which disables the programs a repository's configuration can name — the file-system monitor, hooks, external diff, the pager, and the credential and askpass helpers, with prompts failing closed — so a config that ever does get written cannot run code with the hub's rights. Of those, the monitor, hooks and external diff are reachable by commands the hub issues (the planted-program test exercises all three; adiff --no-indexreads the repo's config, so that call also passes--no-ext-diffand--no-textconv); the pager is not (every call pipes stdout). Credential and askpass helpers are disabled for project repositories; memory sync alone may use credentials for its own memory repository, without prompts. The cost:core.fsmonitor=falsealso turns off a person's enabled built-in monitor for the hub's own calls, so the per-turn snapshot'sadd -Ascans the whole tree (the snapshot Git calls of one burst share a 250 ms budget, each call at most 10 s; a boundary that runs out is recorded as unknown, never as a stale baseline) (#576). git has no global switch for filter drivers, and of the hub's commands only the snapshot'sadd/restorewould run one (its driver command lives in the protected.git/config). - Telemetry holds no bodies.
.agenthub/state/events.jsonl(issue #40) records envelope ids, routing and sizes, task ids and states, overlapping paths and token counts, and the task id (with a PII flag) that each usage and token record is attributed to. It never records a message body, a task title or detail, and marks private (PII) envelopes and tasks as such. Budget pauses, resumes and quota resets carry the peer, window, times and the hub's own reason text, and a person's release authorization its commit, version and docs digest. The usage roll-up (#683) folds the usage, token and limit records intohub.dband joins issue, PR and release from the board when read;ahub usageand the console's Usage panel answer only the console and a configured planner, and a planner not during a private turn. Kimi's token records (#285) are counted from its own session wire log under its home: only the four token integers, the model label and the row time are copied, and the record id is an opaque hash, never a path, session id or text. agy's (#1280) are read from its session transcript through the hook-gated readback the same way: the token integers and an opaque hash of session and step. A person's model override (model_change, #1317) records the peer, the previous and new model ids and the next-turn boundary; a visual (#1058) its id, kind, byte and image counts and optional task id. The log stays on the machine;ahub exportonly prints it. Two opt-in stores copy numbers from it into your home directory, shared by every project on the machine (directories mode 700, files 600): research records (issue #247,research.enabled; a person's explicitahub setup --researchor--no-researchsaves the default for new projects in the owner-only~/.agenthub/defaults.json, whichahub initonly reads, and a missing, unreadable or untrusted file means off, #1347) in~/.agenthub/research/hold per approved or cancelled task (record v2) the ids, states, counts, tokens, times and PII flag the events hold, counts and closed words only, a person's label from a closed list, and a hash in place of the project id, andahub research export --dataset <dir>writes an allowlisted copy meant to leave the machine (peers reduced to family names, project-chosen role and profile names dropped, owner-only files in a directory inside the home and outside every registered project, compared as real paths); benchmark runs (issue #251) in~/.agenthub/bench/hold suite name and hash, arm label, the attached peers' ids, models and permission modes, task ids, outcomes, exit codes, counts, tokens and times, never suite text or a command's output. - Snapshots stay in your repository. Per-turn snapshots (issue #33) are git objects in the project's own object store, written through a temporary index; nothing is referenced, pushed or copied elsewhere, and
git gcprunes them. They hold what the work tree held, including untracked files that are not ignored, so keep secrets in ignored files. They carry the repository's own permissions, and nothing caps their disk use butgit gc. A turn of a peer holding an open PII task is not snapshotted; a PII file left in the project is snapshotted by later turns like any other file.ahub undorestores only files whose current content is exactly what the turn left. - The edit hook reads, never decides.
ahub check-path --hook(issue #32) reads hub.db and returns context for Claude and a line for you; it sets no permission decision, so your permission rules stay in charge. It names other owners' task ids, titles and states, which then reach Claude's model; PII tasks are left out. - The session record holds identities only.
.agenthub/state/sessions.json(issue #37, mode 600) keeps each attached peer's recovery metadata: launch options, session and thread ids, Pi's session file path. No message or task text; loss notices name deliveries by id, sender and public task title. The Pi continuity bookmark that recovery keeps across stop and archive (.agenthub/state/recovery-continuity.json, mode 600, at most 16 KiB) holds identities only too: operation and project ids, the project root, and Pi's session id, session file, backend and model name; a bookmark that is not a private regular file, is larger than that or names another project or operation reads as invalid, which is kept distinct from no bookmark. - Context checkpoints have a separate text record. Native occupancy readings carry numeric metadata only. An optional context checkpoint persists its summary in
.agenthub/state/context-checkpoint-<peer>.jsonwith mode 0600 and, when memory is enabled, proposes it as a machine-local memory candidate that only a person can accept. Completion requires the outstanding request id, current peer/native session and transport generation; new connection claims invalidate old requests. Private turns, every open PII task associated with the peer as owner or reviewer (including pending review), and PII-pattern summaries are refused before persistence or memory. Its text is never broadcast, and context pressure does not pause, reassign or replace the session. - The local worker is boxed in. Paths are resolved through symlinks and must stay inside the project, and name-based refusals (the denylist,
.git,.agenthub) judge both the resolved name and the name typed inside the project, because Bun's realpath can name another hardlink of the same file (#541; facts, deliverables andahub source addtoo, #552); a secrets denylist (.env*, keys, credential files, the hub's own state and the state archivesahub reset --allleaves) applies to its file tools, its git arguments and its memory capture alike;.gitand.agenthubare not writable. Writes, edits, shell commands and mutating git wait for approval unless the operator's permission mode grants them (writes and edits inside the grant filter inask-when-needed, all four innever-ask; see below), and the approver sees what will be written or run, with control characters escaped. Everything it executes runs under the macOS sandbox, attended or not. Since 0.10 the profile starts from deny default (issue #39): commands run and read only the system, toolchain and project directories (and the project's git dir and the selected developer dir; with network on, the public CA bundles); no writes outside the project, its git dir and a temp dir of its own (TMPDIR, made for each command and removed when it ends; issue #63); no.gitname at any depth, closing the creation, rename, symlink and gitfile routes to a planted repository, noconfig,config.worktreeorhooksdirectly under one, nor in any submodule git directory (every modules tree the project can reach is covered: the common dir's, each linked worktree's own under<common>/worktrees/<wt>/modules/, matched by pattern so a tree or worktree that does not exist yet is covered too, and each discovered submodule git directory's ownmodules/, nested submodules and a submodule's own linked worktrees included; under a modules tree every write that is not inside a discovered submodule git directory is refused, whatever its name, so a fake git directory cannot be planted for a later unsandboxedgit submodule update --initto reuse; inside each discovered one its ownconfig,config.worktreeandhooksand its linked worktrees'config.worktreeare refused, while the index, logs and objects stay writable and, since #1123, itsrefs,packed-refs,HEADandworktreesare refused like the project's own, so commit, branch, tag and rebase fail there too; a linked worktree's git directory under<common>/worktrees/gets the sameconfig,config.worktreeandhooksrefusal; every term is anchored at the discovered path,hooksby depth, so a directory namedconfigorhooksabove the project neither disables the rule nor trips it; the discovered git directories, the modules roots, the worktrees container, each worktree git directory and every component in between are also refused as targets, closing the rename, replace and symlink routes to the same files), nocommondirat any depth below one, with every such name matched folded against the code points HFS+ ignores (the fold covers the security-relevant names; on a volume that folds names, folded spellings of the components between.gitand a submodule's git directory are not matched — the 1024-byte SBPL literal cannot hold a folded path — the same residual the external git dir rules carry since #270), and no.agenthubanywhere in the project (in an external git dir theconfig/config.worktree/hooksrules cover only its own children and the hooks directory's contents, so refs namedconfigorhooksstill work); a nested repository in bare layout stays open — the directory is the git dir, with no.gitname to anchor a rule on, and the hub never runs git there, whatever the project root is: its calls are all-C <project root>or--no-indexon temp files, and none descends into a nested directory's git layout, so a planted hook fires only if a person or another tool runs git in that directory themselves; the rules are probed on HFS+, which folds the 16 code points the patterns tolerate, and on a case-sensitive APFS image, which folds nothing (a folded spelling is an inert new file there and the real names stay refused); the shared object store takes only new loose objects (#864):objects/info(alternates, http-alternates, commit-graph) andobjects/packare refused, and so are theobjects,objects/infoandobjects/packentries, moving a fan-out directory, and changing, removing or replacing an existing loose object, because git reads packs first and never rehashes what it reads, so a planted pack or alternate would change what every checkout and the implementer's host add, commit and push (#845) see; a hardlink to an existing object is refused as well; an object id that does not exist yet can still be planted before git writes it (its exact content must be predicted), which a local checkout then shows, while a push of it is rejected by the receiving side's rehash; the cost is thatgit init,git cloneandgit worktree adddo not run inside the sandbox (git add,restore,checkout -- <file>and the read commands still do; since #1123commit,stash,branch,tag,switch,merge,rebaseand every other command that moves a ref or HEAD do not, see "Refs, HEAD and worktree admin writes" below;git gc,repack,fetchandmaintenancerun outside it, #864),git submodule update --initdoes not either (its git directory'sconfigwrite is refused), and neither dogit worktree remove,git worktree pruneof a stale record,git clean -ffdin a project with a nested repository, orrm -rfof a directory that holds a.git(refusing the unlink is what stops the rename-out route); no network, loopback included, unlesslocal.bash_network; with it, only through the hub's egress proxy to the hosts inlocal.network_allow(issue #65), which refuses names that resolve to internal addresses (behind NAT64 with the well-known prefix, by the IPv4 address the answer carries; a network-specific prefix is not recognised), so claude-mem and the Codex app-server stay out of reach ("direct"opens everything, until 0.13.0). One channel stays open:trustd, which TLS clients need, can fetch a certificate's AIA or OCSP URL on a command's behalf, outside the proxy. The allow-default profile of 0.9 and earlier was removed in 0.12.0 (issue #83); alocal.sandboxsetting is ignored with a note. The shared temp dirs (the user's and/private/tmp) are closed. Without the sandbox there is nobashtool. Two opt-in tools, both machine-local and off by default, work outside the command sandbox:doc_read(local.doc_read) reads a PDF, DOCX or HWPX file through the path guard and thepii.pathsscreen, converts it with the person'stext_projectionand screens the text before the model sees it;web_fetch(local.web_fetch.allow) sends a bare https GET only to the listed hosts, through its own egress proxy (never the shell's), re-checking every redirect, and is refused for the whole turn of a PII task. Neither result is captured to memory. - Capabilities are enforced, not suggested.
capabilities(issue #39) is checked by the daemon where task operations and messages arrive, so a peer cannot get round it by phrasing. A peer can never answer a permission request: the control link takespermitfrom the console role only, and a message that quotes a permit command is just text. Both bind the hub's own tool paths: a vendor agent with its own shell in the project (Codex, Claude, Kimi) can read.agenthub/state/control-tokenand connect as the console, which only the local worker's and Pi's sandbox prevents. - PII has an enforced path. A task matching
signals.pii_patternsgoes tolocalor to nobody; its text is absent from other peers' envelopes, the console stream, the log and the board listing; the console user reviews it;localanswers such a turn to the console only, keeps it out of its history, refuses it when the only gateway is off campus, and may not save notes or spin off tasks during it. Nothing about it enters hub-managed memory; local storage does not relax this privacy fence. - A model screen can add to the patterns (issue #198). With
signals.pii_screen = "local"inrouting.toml(defaultoff; it applies whileconstraints.pii = "local_only"), a newly proposed task is read by a model before the hub does anything else with it: until the verdict it is on no board, in no envelope, before no triage model, and the hub makes no brief or note call for it. The local worker's amem capture leaves its hub tool calls out (issue #230), so a proposal it makes in an ordinary turn cannot reach shared memory ahead of the verdict that way. The screen runs on the model on this machine (MLX or Ollama, loopback only) or, without one, on the gateway only whileonCampus()holds, and the gateway call is refused once more right before transport if the gateway is behind Access; task text never goes off campus to be screened. When the on-device generation slot is taken (Pi generating, another screen), the screen goes to the campus gateway instead of waiting, and on campus a device that fails or is still loading after 60% of the deadline hands over to the gateway too; off campus it waits for the slot and the device within the deadline. With the legacy MLX provider the screen may start anmlx_lm.server, as Pi's relay does, and it stays running untilahub models stop. Its answer ispiiorclearplus a category from a closed list (name, student id, phone, address, grade, health, other). Anything else, a timeout (8 s, one call and one generation slot per item), a missing or off-campus model, or text longer than 6000 UTF-8 bytes (about 2000 Hangul syllables) isunknown, and an unknown task is a PII task: this is the hub's one model call that fails closed, so with the screener down every new task waits forlocal. An unknown verdict has the costs of any PII task, even when a second look clears it later: opening it lifts every silent turn-free cohort for good and turns facts off while it is open, and off campus a proposal without a class is refused (triage is not asked for a PII task there), with an error that says so. Off campus with the on-device slot taken, a task staysunknownuntil it is screened again: while it is still proposed and no peer ever owned it, the hub's release timer (every 60 s, one task at a time, the least tried first, at mosttasks.rescreen_max(default 10) times per task in a hub run; never text too long to read) asks again, andclearlifts itspiisignal and routes it through the class peers (a named owner is not remembered; a reserved one is offered first, as always). A tasklocaltook keeps its PII path, also once it is declined back to nobody. A pattern match is PII without a call. The task's history records the source of itspiisignal (regex,screenwith the category, orunknownwith the reason), an unknown verdict is a console line naming the task id only, andevents.jsonlgets apii_screenrecord with the item kind, label, source, category, reason and latency, never the text. A routing explanation of a draft does not call the screen and says so. - Free text is screened too. On an ordinary task, a done summary (with its check output: a match withholds the whole note), a review note, an unmet item or a budget handoff that matches a PII pattern (issue #69) is not saved to hub-managed memory, and every peer,
localincluded, gets a stub namingahub task show <id>; the board keeps the text,hub.lognotes the withholding by task id only, andahub askshows such a note only when its model is reached on campus.hub_rememberrefuses a match; what a vendor agent writes to its own memory is not screened. With the model screen on, the same items (andhub_remembernotes) go through it as well, one call each and only after the call's own checks (owner, state, verdict, a running check, memory enabled) pass: apiiorunknownverdict withholds the item exactly like a pattern match, and the board marks the history entry (withheld) so later views (task lists, completed-change notices,ahub ask, a reviewer replaced after a restart) keep withholding it. A budget hand-off is screened once per hand-off, whatever the number of tasks it moves; an escalation's reason is the hub's own text and is not screened. Pattern-only, without the model: the check output tail in a done note (only the summary is screened), a plan given withhub_task_accept, a decline reason, a task's refs (paths, branch), and context checkpoint summaries. Ordinary-turn amem capture uses deterministic secret, routing-pattern and private-path screening of complete input/output before bounding; it makes no model classification or network call. Private turns remain excluded before capture. Raw capture is machine-local and never curated evidence. Tools are identified by host session/turn/step/invocation rather than trusting provider ids to be unique. Its hub tool calls (hub_send, the task tools, the conductor tools) are not captured (issue #230), so a title, detail, done summary, review note orhub_remembertext it writes reaches shared memory only through the screened paths above. - Sources and the notes corpus are read through screens. Registered sources (
ahub source add, a person's command, #508) are screened bypii.pathsand the PII patterns when added and again, with the model screen when it is on, each timehub_source_readreads one; a failed or unavailable screen counts as private, a private verdict is never downgraded, and private text goes only to the in-process local worker during a PII task turn with a confirmed on-campus gateway. The notes corpus (corpus.paths, machine-local, #510) refuses a folder that is, sits in or holds an Obsidian vault, a protected folder or anything it cannot read, indexes structure but not text, and rereads and screens a note before an excerpt reachesahub askor a task brief; a PII question or private task context never queries it.hub_source_readcalls and results are never captured to memory. - Secrets stay where they are read. The gateway key and Cloudflare Access values are read inside the gateway client and go only into request headers: never into logs, errors, envelopes, tool output, memory. Subscription logins of Claude, Codex and Kimi are never proxied or pooled.
- The hub's own model calls are fenced. Digest condensation, task triage and the PII screen read agent-written text as data; their output is capped text framed as untrusted, or a value checked against a closed list. It is never used as a route, a peer id, a tool call or an instruction. The hub model's intake split (#511) is JSON that must pass the same parts schema a planner's
hub_backlog_splittakes and may name only the request's own sources; it only writes draft backlog cards that wait for a person's or the conductor'sintake approve, and a PII request goes only to a model reached on campus, else stays unsplit.
The portable core is the exact-pinned first-party @staix/agent-mem 0.23.1 package; it does not replace the hub's caller identity, PII policy or task-write authority. Deny, hardened git, real paths and process identity are single-sourced in its Node-safe platform entry. Current project privacy is applied by the hub before promotion and again before model-facing reads. Standalone person reads are unscreened, and the standalone --policy-free acknowledgement grants no hub or peer authority.
hub_memory_search, hub_memory_show and hub_memory_graph take the project from the daemon, require memory_read, refuse private turns and validate UTF-8 input before logs or storage work. Complete records and graph metadata are screened before counts and truncation. Search is at most 20 lines/4 KiB (default 8); show accepts at most 8 ids and returns at most 8 KiB; graph is at most 120 nodes/240 edges/6 KiB (default 60 nodes), with no edge lacking an endpoint. Unknown, ambiguous and withheld ids share one neutral refusal. Arguments and results are excluded from hub-managed capture; this does not govern a vendor's independent transcript or memory hooks.
Code neighbours (#684 phase 4). For a symbol start, hub_memory_graph and the dashboard Memory view's Code neighbours toggle add one hop of callers and callees, read at the current commit through the optional code-facts boundary and shown with that commit; these edges are never stored. They share the graph limits above, an ambiguous or name-only seed is withheld, attached notes are screened again when published, current HEAD, profile, policy and caller privacy are rechecked across awaits, and a PII task or private turn starts no code query. ahub doctor --stale-anchors (#684 phase 3) only reads: it never edits, deletes or repairs a note, and an anchor whose task record is now private, foreign or missing stays unknown.
hub_memory_correct requires memory_correct and proposes a machine-local candidate after the same project/privacy checks, including revalidation after asynchronous screening. It cannot accept or alter a note. Public review failures with unmet items may also propose candidates through Tasks, and ahub ask --remember answers and context checkpoints are saved only as candidates, never as curated notes (agent-mem#13 P4). Candidates and usage counters stay at M/candidates.db, are not synced/imported, and never enter recall, briefs, ask or graph evidence. A person accepts shared-v1 candidates through the package CLI's TTY/policy checks; the accepted result is an ordinary v1 note, while the sidecar remains local. Generation amem review is supported since agent-mem 0.7.0, subject to the package CLI's applicable TTY and policy checks. Citation counters are observation proxies, not evidence that an agent understood or used a note.
The hub binds the selected root/profile at open. Invalid/missing generation metadata refuses native memory without a shared-v1 fallback; the bus and board continue. Explicit checks at hub operation entry and before sidecar writes stop later stale calls, but are not atomic against a selector change during an in-flight core operation. Migration therefore requires quiescing consumers and closing handles before changing selection, then reopening through the supported lifecycle. A public core pre-write hook is the upgrade path for atomic in-flight cutover.
Before migrating any real home, every hub sharing it must first be on 0.12.30 or later with positive authenticated binding or unbound/quiesced proof from ahub memory profile-check in each relevant registry. An unknown or unreadable runtime is not proof of absence, and a matching ready binding still holds a handle that must be closed. Profile-check reports only its named registry inventory. Root paths, selector receipts and malformed-legacy counts are person-local diagnostics, excluded from ordinary peer status and allowed agent CLI reads. Source/fixture checks do not establish native capture, real migration or account/device-sync success.
On a home split by amem migrate --shards, the hub opens only its own project's shard, with the user shard as overlay, under the alias-resolved project key (#534, #537); other projects' private notes are not in that store. Each memory entry rechecks the shard identity, and an alias that later remaps the key latches the binding unavailable. A split that is not active refuses and is never read as an empty or legacy store. A hub on 0.12.36 or earlier does not detect a split and keeps writing the old layout, so every hub on the home must run 0.12.37 or later, or be stopped, before splitting. ahub memory profile-check is split-aware (#536): it compares each running hub with its own project's shard and reports an inactive split with the resume remedy.
A failed owned Claude print result can carry the last observed failure stage:
pre_permission, invalid_permission_request, wrapper_refused,
manual_queue_expired or native_error. pre_permission means only that no
host permission observation was recorded; it does not diagnose an unseen native
MCP or provider failure. Wrapper refusal requires a matched host ticket refused
before spawn. Target timeouts, disconnects, surviving descendants, output failures
and a target's nonzero exit are not wrapper refusal. Ordinary proof fallback to
person approval is not a failed execution.
Evidence contains only the daemon's actual permission id when known, the daemon
instance UUID and the parsed native version from its binary-fenced proof. It
contains no command, input, prompt, native tool_use_id, wrapper ticket,
launch/session id or native diagnostic prose. PII or unreadable privacy emits
only Claude implementer turn failed (details withheld), or the fixed host fact
Claude implementer cleanup incomplete; details withheld when cleanup is
incomplete. Native stderr remains
ignored. The original task 544 incident and real-account approval behavior are
not established by these diagnostic fixtures.
The host trace belongs to an exact owner, launch, generation and signal. It is sealed before child authority is revoked and consumed once after exit; success and cancellation discard it. Later permission attempts, expiry and stale or foreign launch traffic cannot rewrite the sealed result. The MCP wire cannot authenticate whether the native dispatcher or the model sent a permission call: the stage describes the last request observed on that wire. A probe starts a new diagnostic observation, so a pending older manual request's later expiry cannot overwrite it in the failure trace.
Approval keeps the existing required-value checks and accepts extra argument fields. Diagnostic shape validation is separate.
permission_failure_probe is a separate validation-only control operation. Its
temporary finite JSON input is capped before send at 4,096 UTF-8 bytes, depth
eight and 256 nodes, and limited to four attempts per owned launch by both client
and daemon. The daemon validates then drops the payload, retaining only a fixed
stage. It never invokes approval or execution, echoes input, or accepts a caller
stage enum. Every request id gets a reply. An old daemon's unknown-op reply,
an unavailable probe, or even a response claiming allow cannot change the
channel's fixed denial of the malformed native request. Skipped oversized probes
make no invalid-stage claim.
X1 is the crypto and wire layer the later room slices build on: the blind broker (X2), outbound membership and enrollment (X3), screened application traffic (X4) and the metadata views and person inbox (X5) are described below. The X1 protocol contract fixes algorithms, canonical transcripts, limits and the required durable host ports. Bun 1.4.2 is the minimum; room operations await a cached built-in WebCrypto known-answer/negative self-test, which the broker's startup and every wire operation await before effects.
The intended broker is untrusted: it may drop, reorder, replay or replace packets. Owner-signed membership and key wraps, member signatures, authenticated encryption and invitation HMAC protect the accepted content/key bindings. Labels, exposed local peer ids and body/addressing metadata are encrypted. Room ids, hub public keys/fingerprints, epochs, recipient hub sets, packet sizes, timing and network endpoints remain visible. Encryption cannot ensure availability or hide traffic analysis. Members holding an epoch root can read that epoch's recorded traffic; recipient lists enforce delivery policy, not secrecy from other room members. Long-term key theft plus recorded wraps defeats past confidentiality; this is not a forward-secret group protocol. A revoked member retains old plaintext and keys but receives no new epoch wrap. Current membership must refuse its traffic even within the previous epoch's 60-second grace.
Private key export is explicit machine-local storage, not a transport format. X3 must enforce 0700 directories/0600 files, durable room/sender counters, atomic replay/inbox acceptance, one-time invite enrollment, persisted owner/epoch pins, and non-extending grace deadlines derived from local rotation installation time plus 60,000 ms. Frame nonces are random 96-bit values, independent of sequence; counter rollback therefore does not deterministically reuse an encryption nonce. Lost or rolled-back state still compromises replay freshness and ordering. X3 must reconcile that state before delivery; fresh enrollment is a defence-in-depth recovery option, not a confidentiality prerequisite for nonce uniqueness. The X1 tests use memory state ports; they do not prove crash safety, filesystem permissions or publication races. A same-user malicious process and member key theft are outside this protection. Later X4 framing/PII/role boundaries must hold before decrypted bytes reach the local bus; crypto alone grants no tool, board, task, approval or memory authority. Native and two-machine use remain unverified until their named person legs are run.
ahub broker serve is a separate person-started process, default loopback, with an
explicit WSS audience behind operator TLS termination. Agent markers and
--live-test delegation refuse before startup. No daemon listener, remote local
peer, board/role authority or local room delivery is added by this slice.
The broker receives public owner-signed rosters, challenge signatures and sealed X1 frames, never epoch roots, private hub keys or OOB invite secrets. It neither unwraps keys nor opens bodies. Immutable public proof products defensively copy bytes, Buffer included. One-use socket challenges bind broker audience, room, expiry, member and roster digest. Any Origin header refuses. Pinned owner/epoch/ digest and installation + 60,000 ms grace survive restart. Removed members cannot authenticate or receive queued traffic; awaited work rechecks membership/generation before effects. Exact roster replays never refresh grace.
Initial roster creation is restricted by a repeatable exact owner-fingerprint allowlist, mandatory for WSS public audiences and non-loopback binds. Open loopback mode is explicitly development-only and must not be proxied publicly. Authenticated but unapproved throwaway owners consume no room slots. Existing membership stays owner-signed; the creation allowlist alone is not a revocation mechanism.
Count/byte/TTL-bounded private SQLite storage holds original encrypted packets.
Exclusive kernel ownership prevents competing writers without stale-lock takeover;
unknown/corrupt state fails closed. Populated unrelated or non-private directories
refuse without chmod. An offline, person-only broker room drop <room> --yes
reclaims one room under exclusive ownership, deleting its pins/floors/queue without
resetting other rooms. Clients keep their own pins; remove an unwanted creator
from the allowlist before restart. Indexed epoch/expiry maintenance avoids parsing
all queued packets on ordinary reads, and a 4 MiB per-room byte share bounds one
room's new admissions. Each recipient ACK affects only its own queue
entry. Broker storage/queue ACKs never mean application completion. Logs/stats are
fixed codes plus public ids/counts only; see the X2 protocol and limits.
Fixtures keep the encrypting client in a different process and check the broker's inputs, persisted data and logs for a unique plaintext canary. They do not establish live TLS, full heap forensics or two-machine delivery. A malicious peer can send its own plaintext; rejected networking bytes do not cease to exist in buffers. The confidentiality claim covers the sealed-client path. X3 host authorization/ enrollment and X4 privacy/framing/role boundaries are in their own sections below; traffic metadata, availability, malicious owners and same-user compromise remain outside the encryption boundary.
Room presence uses a signed ephemeral: true header (#1204 T1). This reveals
presence traffic class to the blind broker, not peer state or body text. Only the
latest sender observation is retained for connected, ready recipients; offline
recipients and broker restart do not retain presence. Durable queue limits and
receiver replay/admission checks are unchanged. A queue_full storage refusal
names the verified frame digest; local bounded backoff retries that same sealed
frame and grants no completion authority.
The ephemeral advertisement is a transport capability, not authentication or completion authority. Old brokers receive the legacy frame shape. The broker withholds ephemeral frames from older member hubs until they upgrade; chat frames remain compatible. See room wire compatibility.
Room liveness (#1204 T3) is advisory availability, never native execution or
process ownership proof. Periodic refresh and three-period expiry run only after
broker ephemeral negotiation. A legacy broker reports liveness untracked, with
no periodic refresh or aging. Unknown room presence adds no native PeerState
value or authority. Aging neither grants nor removes outbound admissibility;
signed roster, exposure, link and Bus proofs still govern sending. Explicit signed
offline presence keeps its existing handling. Freshness uses validated local
observations only; restored presence and rolled-back clocks cannot renew the
advisory liveness view, and status remains read-only.
Graceful stop fences publications synchronously and attempts one signed offline
frame in FIFO order within two seconds; durable intents/receipts remain, and
failure relies on supporting recipients' aging rather than remote process stop.
Before a person changes a room epoch, an ids-only preview binds already sealed
unresolved Store intents and the roster digest. The final commit rechecks both
with link publication quiesced. Old active intents are quarantined atomically
using the existing boundary_refused reason and schema, with sendable ciphertext
removed. T3's local Bus needs_review journal reason is the fixed
room epoch rotated; inspect ahub queue show <id> text; room status labels its
holdReason as epoch rotated. No body enters either reason. Bus-only queued work, terminal results and person
dispositions are unchanged. T2's existing crash-gap quarantine remains the
recovery boundary. No completion, replay, cancellation, new wire codec or new
native authority follows from the preview, rotation or liveness metadata.
- A malicious local process running as you. It can read the control token,
hub.db, the project and your home directory without the hub's help. The token keeps web pages and other users out, not your own processes. - An agent you launched without its own safeguards. The hub frames and routes; it does not sandbox Claude Code, Codex or Kimi.
--unattendedplus untrusted peer text is a risk you opt into. - What an approved command does inside the project. The sandbox bounds where a command can write and what it can read, not whether
rm -rf srcwas a good idea. Read what you approve. - Network-level proof for PII. The hub proves at its own boundaries (tests search every output) that PII text does not leave; packet-level verification of your gateway path is an operations check.
- Other operating systems' sandboxes. Only macOS seatbelt is implemented.
Permission modes control vendor prompts; the CLI identity gate is an operating
policy, not a security boundary against a hostile agent. Kimi or another agent
whose mode permits unasked shells can clear shell markers, invoke the CLI with
--yes, or read the control token and connect as the console to raise any
peer's mode. A role check on the ordinary hub tool or conductor path does not
contain an unrestricted shell running as the same user.
Pi and local retain the hub's sandbox, path guard and denylist in every mode.
Their ask-when-needed write/edit grants also exclude canonical native agent
configuration segments in any case, using the same list as Claude's hook. A changed canonical target after approval
is refused before write/edit effects.
Kimi executes its own tools outside that sandbox; Claude and Codex retain their
vendor boundaries. The never-ask confirmation names this difference. No mode
turns off the sandbox or grants a session-wide allow-always option.
The always-ask action policy (#636) classes a call as publish, external-write,
destroy or install; action_policy is read only from an untracked
.agenthub/config.local.json. A local policy that disables enforcement, omits a
class or raises a budget above its default is flagged on every status policy row;
startup still adopts it without a person's confirmation. Claude's
managed PreToolUse hook (the headless Claude implementer's included) returns ask for a classed call in every permission
mode, never-ask included, and that ask replaces any allow the mode would
add; the hook never denies, and a hub that is down, slow or refusing decides
nothing, which leaves Claude's own policy. A hub-owned ACP peer's
session/request_permission for a classed call is never auto-approved (a
hub-tool title, a mode grant and --unattended included), offers no
allow-always, shows Action approval (<classes>): <command> built from the
task's public view and the PII screen (withheld for a private turn or an open PII
task), and is denied when the approval timeout passes. Since slice 3 (#636) a
command approval request Codex sends (v2 command execution or legacy exec) is
classed before it reaches the TUI and answered once, bound to its link, thread
and live turn; a cancelled, disconnected or stale one is denied. The local
worker's and Pi's model tool calls are classed at the hub's permit boundary
before never-ask, ask-when-needed, Pi's tool-wide grant and --unattended,
and a replayed Pi call spends no allowance twice. Their approval shows the
complete tool command with its class line on the person's console, private
turns included; logs, public status, other peers and the dashboard keep the
screened view. Any view that is cut ([cut, N chars]) or withheld offers deny
only, with no allow-once or class budget. A class budget above one
adds one person-only choice that allows at most N calls of those classes in the
same native session; a new session, a changed policy or a stopped, crashed or
restarted peer starts from zero, and an ordinary allow never spends it. The
limits are the point of the visibility rule: the hub enforces only a call it
sees before it runs. A Claude session without the hub hook or not yet proven to
have run it (the project-neutral profile hook that ahub setup may install, #999
T2, acts only for a session that inherited AGENTHUB_STATE_DIR and AGENTHUB_PEER_ID
without AGENTHUB_LAUNCH_ID, and its presence is configuration, not proof it ran), an --unattended Claude launch (it skips hooks), an ACP agent in a
native mode that sends no request for a call it allows (Kimi's yolo and
auto), a call whose payload the agent did not report, a Codex command its
native policy runs without sending a request (so status lists Codex as not
enforceable even though the requests it sends are gated), Jcode, an offline
local worker, a Pi without a verified live tool bridge and a headless Claude
implementer with no current child are not enforced, and ahub status says
always-ask not enforceable: <reason> for each such peer rather than claiming
it. The classifier reads command text: unknown syntax keeps native handling, and
an agent with an unrestricted shell can still reach the same effect another way
(the hostile-agent boundary above).
Expansions (#762). A shell expansion ($VAR, ${...}, $(...), a backtick,
$'...') is an opaque word, and the literal words around it are still read, so
git push origin $B, npm publish $D, gh issue create --title "$T" and
rm -rf "$DIR" are classed like their literal forms. The command an expansion
runs (echo $(git push)) is classed too. Where the opaque part can change the
class the strictest class the command can reach is used, never none: git $CMD
is publish and destroy, npm $CMD is publish and install, gh $GROUP ... is
publish and external-write, gh <group> $ACTION is external-write (publish for
release), gh api with a dynamic method (-X $M), a dynamic option word, or a
trailing operand that is an expansion, is external-write, a GraphQL query with an
expansion in it is never a read, and rm, cp -f and mv -f with an opaque
target are destroy (an expansion may hold .. or an absolute path). A word that
starts with an expansion may be a flag ($OPTS as -rf), so where a class
depends on a flag it counts as that flag: rm $OPTS "$DIR", git reset $MODE,
git clean $F, git branch $F topic, cp $F a /etc/x and npm install $G pkg
are classed (a literal -n, or a word after --, still decides). The cost of
failing closed is a prompt for a call that may be harmless: git branch "$NAME"
and npm install "$PKG" ask, while rm "$F" (one operand), git log $RANGE and
cp "$A" b do not. A command
with no classed literal prefix (echo $X, cat "$F", sudo $CMD,
bash -c "$CMD") stays unclassed, and so does an opaque value in a position that
is data (the endpoint of gh api, a header, a --jq filter, a read-only
subcommand's argument). What the policy still cannot see is the content of a
variable used as the whole command, an alias or function, a script the command
runs, and text read from a file or stdin.
The floor policy (#999 T3). action_policy.floor: true, settable only in the
untracked .agenthub/config.local.json like the rest of action_policy and off by
default (the person's ahub autonomy on writes it and off removes it), stops the policy from asking about ordinary development work: an unforced
git push of a literal non-default branch to origin from inside the execution
root, and gh pr create|comment|review|merge in the project's own repository
(never --admin, never another repository by flag or URL). Everything else the
policy asked about still asks: force and delete pushes, the default branch and tags,
gh release, package publish, deletion outside the execution root, writes to
other repositories, mail and posting. It is a weakening, so every status policy row
carries the existing attention notice. The floor reads only host facts (the
default branch from the authoritative GitHub remote read, never a local
origin/HEAD or the agent's text; the project's own repository; whether a name is
also a local tag; the checked-out branch) and treats a missing or unproven fact as
the strict case: until the default branch is read, a failed read, a directory that
is not provably the project's own checkout (a nested clone has another origin), a
wrapper the classifier cannot read, -c, --git-dir, an expansion or an unplaceable
cd all keep the call asking. The flag is the only switch: facts alone never widen
a policy that did not set it, and a disabled policy is unchanged. With the floor on,
publish, external-write and destroy ask whatever always_ask says, so a coarse
selector cannot hand them back. It affects approval prompts only: the sandbox, deny
list, PII rule, path guard and trust checks do not change. The hub enforces the
floor only where it sees the call first. For Codex in never-ask, Kimi in auto,
Jcode and a Claude session without the hub hook it cannot, and the floor rests on
guards the hub does not verify (GitHub branch protection on the default branch,
release only through ahub sdlc authorize-release and the OIDC tag workflow, the
peer's native sandbox): ahub status names them on that peer's row and says they
are not verified, never that they hold. A missing guard, such as an unprotected
default branch, is the person's to find and report. The floor also reads, per call, the
checkout's effective push destinations (remote.origin.pushurl, url.*.pushInsteadOf, every URL),
the settings that add refs to a push (push.followTags, push.pushOption,
push.recurseSubmodules, remote.origin.push, remote.origin.mirror) and gh's default
repository (gh repo set-default, a second remote), and a call it cannot prove is
ordinary asks. A wrapper takes the floor away from a shell it wraps, and a command earlier
on the same line that can change those facts ends the floor for the rest of the line. That
includes git fetch (it creates tags and moves remotes, so a tag probe read before it is stale),
bun test and the other builds and test runs (they run the agent's own code), and rg --pre.
A find with any action that writes a file or runs a command (-fprintf, -fprint, -fprint0, -fls,
-exec, -execdir, -ok, -okdir, -delete) is not inert either: -fprintf .git/config can rewrite a
push destination. A glob anywhere in a find or rg call also ends the
floor, because the shell expands an unquoted one before the program runs and a file named -fprintf or
--pre becomes that option. tree, file and git diff|log|show|blame --output write named files and are
not inert. An option's value that holds an expansion or a glob character (--body $(cat f),
--body */*) keeps the call asking, because it can become several words, one of them a real
--repo. That glob test does not see quoting, so a prose --body holding *, ? or [ also asks
(a ponytail: in src/hub/action-class.ts names the upgrade).
A gh pr call is read option by option: a value belongs to its option (--body, --title,
--subject and the rest), so a pull request URL inside a body is text and never selects a
repository, and an option the floor does not know keeps the call asking. Every authority read
shares one budget, the project's origin query included. A project whose config.json or
config.local.json names action_policy.floor, true or false, in the raw file refuses an older
recovery target, even where the key is ignored (a shared file may not set action_policy) or
normalized away (false), because the older parser rejects the key before that rule runs.
What it cannot see: the environment of a native agent's own process. The hub reads its own
GH_REPO and GH_HOST, but a GH_REPO exported in a Claude, Codex or Kimi shell never
reaches the hub. So for every peer whose environment the hub does not build (Claude,
Codex, Kimi, Qwen, Jcode, agy) an unqualified gh pr call is never treated as the
project's own and asks; only an explicit own --repo, -R or pull request URL runs.
The local worker and Pi run commands in an environment the hub builds, so for them the
unqualified call is judged on the facts the hub can read.
The gh classes (#636, #756, #762). publish: gh release create|upload|edit|delete.
external-write: gh issue|pr|gist create|comment|edit|close|reopen|merge|review|delete,
gh run rerun|cancel|delete, gh workflow run|enable|disable, and gh api with a
non-GET method or any field or --input. gh api graphql is the one exception,
classed by its operation: a call with exactly one literal query field whose
every operation is a query (shorthand {...} included; fragments allowed) is a
read and is not asked about, so a reviewer's unresolved-thread check does not
prompt. A mutation, a subscription, a second query field, a document from a
file or stdin (-F query=@file, --input), an unknown option, a non-POST method
and any document the hub cannot parse stay external-write: unknown is never a
read. Read-only gh commands (view, list, status, watch, REST GET) are
unclassed. gh repo delete|archive|edit, gh secret and gh variable
set|delete, and gh label create|edit|delete are external-write as well.
Config defaults apply automatically only up to ask-when-needed. A never-ask request from an applied untracked config is held as ask until the person confirms its source file in the console at hub start, unless retained autonomy consent matches it (next paragraph). Git-tracked mode defaults are filtered by config trust and never offered for confirmation. This confirmation reduces accidental startup relaxation; an agent with unrestricted shell/token access can still impersonate the console. Merged and generated Claude settings live in 0600 state files and are passed by path, keeping caller settings out of process argv.
Project and machine autonomy (#999 T1, T2). ahub autonomy on|off is person-only: the daemon takes it from the console role only, --live-test refuses it, and no hub tool or conductor path reaches it; the hostile-agent boundary above still applies. After one confirmation bound to the preview it writes the shipped preset to the untracked config.local.json only: task flow switches, action_policy.floor: true, start defaults, never-ask for the kinds and forms the preset names, ask-when-needed for the headless Claude implementer, and no mode for an unmanaged kind or variant. It also writes the machine opt-in ~/.agenthub/defaults.json, an owner-only 0600 regular file outside every project root; any other form is ignored with a notice and keeps ask. Retained consent is an HMAC record under the hub home, bound to the project id and each peer's exact never-ask value from config.local.json. Only the daemon creates it: on the console on path, and at startup when the trusted machine opt-in exists and the local value equals the preset plan for the environment. A config.json value, _autonomy metadata, a config marker, an offline --all-projects write and ahub init inheritance never create it. Under the machine opt-in, a preset-matching never-ask that any process writes to config.local.json is consented at the next hub start: the person's opt-in is that consent. A changed mode invalidates that peer's consent, and off and preset undo clear it. The sandbox, deny, PII, path-guard and trust rules and the floor's asking classes are unchanged.
Claude ask-when-needed grants only resolving project file targets outside
.agenthub, .git, .claude, .codex, .qwen, .kimi, .kimi-code, .jcode, .pi, .mcp.json, .agents, .agent, _agents, _agent, .gemini, .grok and .claude.json. Symlink escapes, protected aliases, traversal and
unresolved wildcard targets receive no decision. Names are compared folded (letter case, compatibility forms and
the code points HFS+ ignores; case-insensitive APFS folds case but ignored none in the probe; so a look-alike such
as a long s or a name with a zero-width joiner is the same name), the path is judged inside the project, and
a file with a second hard link receives no decision,
for Claude and for the Pi/local write and edit grants alike. The local worker's and Pi's write guard refuses .git
and .agenthub by the same folded comparison in every mode, so a new .GIT/config cannot be planted for git to run. Native rules then apply;
this is a grant filter, not a filesystem sandbox.
Inside an agent session, ahub identifies the caller from AGENTHUB_PEER_ID,
or the native Claude/Codex shell marker when the hub marker is absent. It connects
as that peer in tools mode. Messages and task changes retain that actor;
say defaults to status priority and important messages still require the peer's
capability. Conflicting or malformed markers fail closed. Refusals name only the marker variable names actually present, never their values. There is no --as-user.
By default, console operations are refused before connecting, including permission answers
and permission modes, agent version trust, budget overrides, role assignment, --as-user, recovery
operations, the interactive terminals (watch, a launcher's person-only flags), and ask
(#933 keeps the person's class to trust, money, attribution and interactive terminals).
For live verification explicitly authorized by the user, an agent may invoke
ahub --live-test [--project <path|id>] <command> [args...]. The flag delegates
the existing console authority for console-class operations and conductor-class peer starts/holds, including approvals,
settings, roster and lifecycle operations. It does not restrict effects to a
scratch project: the agent must stay within the authorized commands and targets.
Malformed or conflicting caller markers still refuse. The original peer and
closed command label are audited before execution. Ordinary delegation requires
the authenticated welcome feature cli_audit_delegated before publishing that
outcome. The negotiated connection is reused, helper connections are pinned to
its authenticated instance, and local dispatch checks that instance. Replacement
control connections refuse. Before command effects, a console-only acknowledgement
must confirm the exact UUID was retained by a successful event append in that
instance; an unavailable acknowledgement refuses. Later local work therefore does
not depend on a future consumer draining the spool. An older or unavailable consumer gets a compatible refused row and the
command refuses with an upgrade remedy. An unwritable outbox refuses before the
connection; an event append failure refuses before command effects. Upgrade/recovery instead records compatible run spool rows and
keeps authoritative ids-only delegatedAudits in its operation receipt before
runtime effects, including resume through an older retained coordinator. Local bootstrap commands with no daemon manifest use run too. Stored attribution
never grants authority; the current invocation still needs its own validated peer
and flag. The current daemon emits agent_cli with delegated: true and
refused: false for the negotiated outcome. Allowed-class commands retain the peer identity and capability checks, including
messages, proposals and reviews. Peer starts, pause and resume may delegate
operational console authority; task assign/escalate under the flag refuse. --as-user, person task labels,
person-authored planning/release/memory/goal writes, SDLC record, spec and plan-departure acceptance, release authorization, design-spec stamps (ahub specs stamp), agent version trust, benchmark run,
ahub recovery reattach, a --fresh-session dispose that would replace a repaired
session's conversation (or whose scope cannot be read), ahub answer with an
operand and the ahub results receipt forms are refused; presentation flags such as --color or --verbose before
the recovery action do not hide it from this check. No argument text is copied into this audit.
Delegated dashboard restrictions are bound to its server-issued ticket/session:
operational settings/start/stop controls remain, but message, proposal and
workflow-history actions refuse even if the body tries to clear the restriction.
The flag is not stored in settings or exported to native agent children. Ahub's
own daemon launch, console subprocesses, recovery runner, interactive follow-up commands and upgrade coordinator
handover carry it explicitly in argv. Each new top-level invocation needs its
own flag. An older CLI does not support it; use a candidate with this feature
for the continuation too. Staging rejects a target without the pinned
LIVE_TEST_SUPPORTED declaration before preparation or commit, including a
previously cached stage. Delegated headless-native restoration uses the ordinary target CLI with its
operationId through that operational console authority. Native and TUI
restoration keep their existing identity and ownership checks. External computer-use restrictions remain unchanged.
The operator uses a plain terminal or ahub console. A Claude ! command that
inherits the agent markers follows the same rule. The console strips control
sequences from agent and daemon text before it quotes forged headers, and its
colors come only from a fixed palette applied afterwards, so message text cannot
set styles or operate the terminal. The recovery blocked screen shows a recorded
error only when no typed cause exists: its first line, passed through the
console's terminal sanitizer with control characters removed, capped at 512
code points and marked as an untyped recorded error. A restart snapshot whose
kept hub notices fail validation is reported with a fixed message, never with
its content.
This is an honest default against accidental impersonation and injected commands. It does not make the token inaccessible to an agent with unrestricted project shell access. The existing OS sandbox and loopback authentication boundaries apply.
Distinguishable sessions and sender provenance (#1036, #1037) make a mix-up
between two sessions of one person visible; they are not authentication. A tools
hello that names a Claude channel peer while presenting a launch id that is not
that peer's current launch record, or that asks to be a session (the channel
plugin does when its process has no project of its own and inherited another
project's state directory), is bound to an id the daemon mints, <peer>~<origin>.
Such a session may send, read status and run the read task ops in
SESSION_READ_OPS; every other message is refused with this session is <id>, not this project's <peer>. It is never a bus peer: no delivery, no push, none of
the peer's roles, and it replaces nobody. Its envelopes carry session: true and
recipients see the minted id in the header; an origin label is kept only when it
already is a plain lowercase token, so no path or sentence reaches a sender name.
Every envelope event and one attach event per admitted peer or tools hello in
events.jsonl carry origin: the connection's role, peer and launch or instance
id, never a path, cwd, token or body. The channel plugin applies the CLI's
ownership proof to an inherited AGENTHUB_STATE_DIR instead of trusting it, and
does not join a hub whose status.json lacks session_identity as a session,
since that hub would bind the real peer id. What this does not guarantee: a hello
with no launch id still becomes the peer (the native facts hook, helpers and a
person's shell connect that way), a launch id is an environment value every child
of the session inherits, not a secret, and a same-user process that reads the
control token can present the right id, present none, or connect as the console.
Nothing that connected before is refused. A recipient weighs a <peer>~<origin>
sender as another session's message; a plain peer id proves no more than it did
before. A restart, upgrade or recovery onto a daemon without session_identity
is refused while an unsettled delivery holds a session's message.
Steering tools require an explicit conductor role, independent of default-allow
capabilities. Only one peer may hold that role. Two task-scoped exceptions need
no role: a task's current owner and reviewer may read its public view with
hub_task_show, and the peer that proposed a task may redirect it with
hub_task_assign while it is proposed, nobody ever accepted it and its latest
move other than the hub's own was not made from the console (to another peer
only with assign when its capabilities are listed).
A conductor may inspect public state, assign or escalate work, start a peer in
the start mode a person set (it cannot choose the mode) and place its own
delivery holds. Through the ahub CLI under its own peer identity it may also
run the closed operational family of #812 and #933: roster add|remove,
stop <peer>, queue show|resolve|discard, doctor --orphans --kill, logs,
export, settings set on thirteen named live keys, board import,
intake approve, schedule run|pause|resume, research backfill, bench run,
source add, goal link and starting a launcher-only peer the conductor may
start (Jcode). Memory changes and the other goal changes stay the person's
while amem records their actor as the user. task unassign is a conductor form;
it keeps the conductor as the board actor. task cancel remains separately
available through hub_task_cancel only on the caller's own eligible task
(#933), and for the conductor only on an unaccepted task another project handed
off (#1175), not as a general conductor operation; task role, task label and
ahub deliverables retain their own-task checks. models start|stop remains
Console. restart --yes,
upgrade --yes --to <exact-version> and recovery resume|abort have the
separate continuity proof described below. Each conductor operation is admitted
after a recheck of role and project membership; effectful operations and history
reads are audited as closed labels without argument text (queue show and a
settings preview recheck authority without an admission record), refusals are
audited as refused, and ordinary operation failures keep their own cause (#839).
Logs and export are bounded public projections; orphan cleanup signals only a
positively identified hub-owned process in the selected project, and stop keeps
its ownership and recovery guards. Other agents may run only the read-only
forms of #812 (doctor, projects, models status, recovery status, queue list, roster, settings reads, sdlc status|explain|queue|release,
release show and --dry-run upgrade/restart previews) through public views,
never console identity. backlog list, release list and sdlc costs|metrics
remain Console for ordinary agents.
The recovery continuation is a separate, narrow capability, not a generic
console command. Only the configured conductor currently connected to the source
hub may receive a proof while creating that project's restart or authorized
upgrade. The source stores a bounded, fixed-shape private operation record sealed by its host HMAC
key, separate from public recovery JSON and actor/audit fields. It binds the
initiating peer, operation, canonical project root/id, source instance, target
root and package digest, plan fingerprint, and a version-neutral digest of
bounded source inputs plus fresh actor/privacy evidence. The digest covers raw
config-layer and routing-file texts, and the source-resolved public routing
template bytes only when the project has no routing file. It does not hash
expanded defaults, default roles or parsed routing. The public
conductorRoutingDefaults map is advisory; the target inherits the sealed
source fallback. The private record stores digests, not raw config, routing
contents or the HMAC key. Every planned project's source capability and authority are
preflighted before staging. An upgrade then stages and verifies its exact
target; restart has no package stage. The source issues the HMAC record after
target bytes and feature support are verified, before the public operation
receipt, lock or runtime holds. A source preflight refusal prevents staging; a
target mismatch or failed proof prevents the public operation and its holds. A
target requiring a newer-coordinator handoff is person-only.
Recovery cannot apply an unpublished protected configuration or routing edit. The source compares the fresh candidate and raw source inputs with its accepted running config/routing inputs. A conductor bootstrap verifies those same inputs against the private grant before module imports and uses the sealed source fallback when the project had no routing file; a later file edit cannot replace it. A person may explicitly take over recovery to adopt a changed policy.
The coordinator must match the source-start package digest or the same
authorized, verified upgrade target. Upgrade identity includes the exact
hoisted @staix/agent-mem package; restart verifies the source package's own
dependency. Another source tree or an invalid target version cannot obtain a
continuation.
Target compatibility labels are read from the sealed target bytes; a tools request or public receipt cannot waive a target feature floor. Computed or unreadable feature metadata remains person-only.
Captured TUI terminal restoration remains person-only: a delayed native launcher cannot re-prove the conductor at its eventual spawn. Headless restoration uses the target-owned snapshot through a tools connection.
An unmanaged reconnect-only Claude channel may reattach itself without a native launch. The source keeps a SHA-256 fingerprint of its observed session identity separate from restoration metadata (the raw unmanaged session id is omitted from the plan) and verifies the exact peer, kind and attachment against the live channel. This exception requires no terminal or restoration arguments, no managed launch proof, and an absent or confirmed-ended launcher; unknown launcher evidence refuses. Other peer identities still compare exactly.
Before each effect and after awaits, the host rechecks the same initiating
conductor's current role, roster membership, project and writable-instance
authority, and non-PII/privacy policy. Offline checks are limited to bounded
current actor and privacy evidence; unrelated public task writes do not revoke
the proof. The same conductor may resume or abort only its own restart/upgrade
operation. A different conductor or a person-created operation cannot be taken
over with this proof; the person retains the normal recovery path. The detached
recovery-run and launcher continuation accept only the captured operation and
target arguments, never arbitrary CLI commands. Loss, replacement or unreadable
proof refuses the next effect. This host HMAC record is not an OS boundary: a
same-user process able to replace both the mode-0600 private key and record can
replace the proof. The existing --live-test path is separate and does not mint
this record or widen it into generic console authority. Fixture tests do not
establish native-account or real-account evidence.
Pending approval summaries exclude titles; PII tasks remain public stubs. Pending approval summaries exclude titles; PII tasks remain public stubs. Conduct events contain ids only. Supervision feeds use structured reasons and never carry check output or approval bodies.
Please report vulnerabilities privately through GitHub's "Report a vulnerability" on this repository rather than in a public issue.
ahub uiuses the authenticated console control connection to start an ephemeral HTTP listener on127.0.0.1. No UI listener exists before that command. The control and Codex proxy ports continue to refuse everyOriginheader.- The daemon issues a cryptographically random, single-use bootstrap ticket valid for 60 seconds. The CLI opens the dashboard with that ticket in the URL fragment, never a query string or the control token. The static page removes the fragment immediately and exchanges it with a same-origin POST. Tickets are consumed once; expired tickets cannot create sessions.
- The exchange creates a separate random session, valid for one hour without renewal, in an
HttpOnly; SameSite=Strict; Path=/cookie named for this listener's port. Sessions and tickets live only in memory and die with the daemon. The page itself keeps one more cookie,agent-hub-theme(issue #246): script-set,SameSite=Strict, kept for a year for the whole127.0.0.1host, and read back only as one ofsystem,lightordark. A second script-set cookie,agent-hub-office(issue #264), keeps the Office view choice under the same attributes and is read back only asoff,flatorisometric. The #391 view split adds two more script-set cookies under the same attributes:agent-hub-view, read back only as one of the seven view names, andagent-hub-column-focus, read back only as one of the five board columns or empty. HTTP is loopback-only; the cookie is not a substitute for the origin checks. - Every request must have the exact listener Host. Every data or action request, including the ticket exchange and snapshot polling, must be POST with the exact listener Origin and JSON content type. Foreign and missing origins, missing or expired sessions, oversized bodies and unknown actions are rejected. There is no CORS support. The only unauthenticated GET is the fixed, data-free HTML shell. No files or paths are served dynamically.
- Responses are non-cacheable, cannot be framed, suppress referrers, and use a content security policy restricting scripts/styles to the shipped inline content and connections to this origin. Browser text is rendered with textContent, never interpreted as HTML.
- The browser receives a bounded redacted event stream, peer states and queue counts, public task views (with a task's screened changed-symbol observation, #684: ids, paths, names and kinds, never source or line spans, and its Code group, #684 phase 2c: those symbols marked against the plan's names, each name re-screened by the public metadata screen; a private task has no group and its plan names never enter the snapshot), budget windows, each peer's DOING line (#878: a native session title or current plan item, at most 120 characters, kept in daemon memory only and screened when observed and again when shown; a failed screen, a private turn or the peer's open PII task shows
[private]), pending approvals, the settings rows (closed values and ids, the file each comes from) and each startable peer's start plan (issue #269), benchmark run summaries (issue #251): the newest runs in~/.agenthub/bench/, whichever project on this machine ran them, by suite name, arm label, state, the task id in progress, counts, tokens and times, never suite text, the Usage view (#683 U4), computed only while the Agents view is open: peer ids, UTC days and token counts with their source, budget pause, resume and reset events without their reason text, and per-task rows of the task id, a PII flag and token counts, never a title, and the office view's optional fields (issue #264): each peer's open turn (start time, attributed task id, attribution kind), acheckingflag on tasks whose check is running, and warninglinksof overlapping, conflicting or cohort tasks (task and peer ids only, never paths, symbols or reasons; a PII task never joins a link, and a private or PII-attributed turn exposes no task id). Private envelope bodies and PII task text never cross this endpoint. Local-worker and Pi approval titles can contain PII, so their details remain terminal-only; the dashboard identifies the request and directs the operator toahub tailbefore allowing it. Budget checkpoint summaries are also omitted. An ordinary (not live-test) session's snapshot also carries the open questions with their full text, a private one included, for the person's answer form (#1053), and unread results with the detail built when shown (#1054). An explicit review refresh of one task adds its person review comment threads and placement, with PII and private-path comments as counts only (#1056). - The closed action list includes permission response, peer pause/resume, console message, task proposal/assignment and the settings actions below (set, undo, preview, reload, and the structural edit preview and apply of a settings session). Project-board actions add revision-fenced planning, Backlog pull/return/split/undo and release planning/state changes through Tasks; they do not tag, publish or write GitHub state. These use the existing daemon/task paths and budget pause rules.
start_peer(issue #269) starts a peer from a closed list in the mode a person set, with a command the hub assembles: the request carries a peer id and nothing else. There is no generic control proxy, task-detail read, shell, file access, free-form configuration edit, budget override, launch of a caller-chosen command or daemon shutdown API. - The listener stops with the daemon. Expired sessions must be reopened with
ahub ui; the page does not silently obtain new credentials.
- Settings are a closed registry (
src/hub/settings.ts). The dashboard andahub settingscan write only its keys, each checked against its own closed values, and only to.agenthub/config.local.jsonand.agenthub/routing.local.toml. A file git tracks is never written, so the sharedconfig.jsonandrouting.tomlstay byte-identical; in a repository where git cannot answer, nothing is written either. Commands and binaries (kimi_cmd,codex_bin,pi.cmd,checks,mlx), credential files and gateway URLs (omniroute,memory.worker_url), the local worker's reach (local.read_allow,local.bash_network,local.network_allow),bench.enabled,terminalandinstancesare refused by name from every registry surface: they choose what the hub runs or where secrets and task text go. The structural editor below (#689) is the only other writer, and it keeps the executable and secret-locating keys console only. routing.local.tomlholdsstay_switchand, per class,peers,escalate_to,route(an idrouting.tomldefines) andpi_backend, and nothing else: targets, routes, signals and the PII constraint cannot be set through it. A write is read by the hub's own loader before it replaces the file (temp file and rename), and one previous version is kept for undo.- An ordinary dashboard session (
ahub ui) reads settings, lowers a mode toask, turnspi.auto_start,task_sweep.enabled,task_sweep.auto_reassignortasks.auto_pulloff, sets a start mode back totuior a WIP limit to 0, and changes the safe switches (research.enabled,approvals.notify,snapshots.enabled,coordination). Whatever can widen what agents do without asking needs a settings session: a non-ask mode now or at hub start, routing, starting Pi with the hub, turning on automatic backlog pulls, removing a machine-local value, and undo.ahub ui --settingsopens one: its ticket marks the session for 15 minutes, after which it is an ordinary session.never-askalso needs the peer's id typed as the confirmation, and a stored never-ask default still waits for the console'syat each hub start unless retained autonomy consent matches it. An undo never puts never-ask back, and removing a machine-local value that uncovers a never-ask in the shared file needs the same confirmation. "Lowering toask" means handing the decision back to the agent's own rules: for Codex that is the approval policy the person configured natively, which the hub does not judge. - A settings session is as strong as the console for these settings and no stronger elsewhere. A mode change is the console's own request with the same refusals; tool approvals stay deny-only for Pi and local; there is still no shell, file, task-detail or launch-command API.
ahub ui --settingsandahub settingspass the same identity gate asahub permission, which is an operating policy, not authentication. The hostile-agent boundary above applies unchanged: an agent with an unrestricted shell as the same user can impersonate the console, and so can open a settings session. - Every change writes a
settingsevent (the key, the value before and after, the sourcedashboardorterminal) and a line inhub.logand open consoles; opening a settings session is logged too. The unified dashboard (ahub ui --all) forwards settings actions as an ordinary session and opens no settings session. - Configuration reload (issue #439). The daemon watches
config.json,config.local.json,routing.tomlandrouting.local.tomland re-reads the complete candidate after a change, as the dashboard's Reload configuration andahub settings reloaddo; an invalid candidate applies nothing. Each changed key is then authorized on its own against its file's actual git provenance. A privacy tightening (local-only PII, the model screen on, added PII patterns, narrower roles or capability lists, unless it moves the planner or conductor) and a safe registered setting apply from any file. Every other change, machine-local fields included, keeps its running value when its file is tracked or git cannot answer. From an untracked file such a guarded change is adopted only byahub settings reloadin an interactive terminal, after it lists each guarded key with its full before and after values and the person typesywithin 30 seconds; the confirmation is bound to the file contents and accepted state it was shown for. File watches and dashboard reloads, a settings session's included, never confirm, and no reload changes the roster: membership moves only through the roster setting. Keys whose consumers are bound at start (memory, the gateway and MLX, checks, the privacy and sandbox policy of existing sessions, the terminal provider and others) stay pending until a restart. - Structural configuration (issue #689). A second, wider list than the registry can be edited, in three tiers (
src/hub/config-tier.tsis the one rule). Tier 2 (instance fields that choose no executable or home, the review, snapshots, approvals andcontext.gatekeys, andsdlc.tomlmodeandrequired_checks) is editable from the console and from a settings session. Tier 3 is everything that chooses what the hub executes or where secrets go (the rest ofNEVER_EDITABLE, and an instance'sjcode_home,provider,modelandprovider_profile), plus a fresh reviewer'sreview_scope, which decides whether its approval covers tests and CI (#852): console only, confirmed by typing the key's name, one key per write, never by--yes, and the dashboard shows it as text with the console command. A browser page must never be able to set what the hub runs, so the refusal is in the hub, not in the page:config_edit_previewandconfig_edit_applyfrom a dashboard session refuse a tier 3 path whatever else the request carries, and an ordinary session is refused before anything about the project is read. Reload authority admits a structural key from a settings session only while the daemon's own validated edit holds that key's scope (structuralinauthorizeConfigLeaf) andstructuralLeafsays tier 2; a file edit made by hand still needs the terminal's confirmed reload. The candidate is read by the same loaders the daemon starts with before anything is written, the file revision is checked twice (planning and writing), the previous file is kept as one of the newest five backups, a symlinked, oversized or git-tracked file is never written, and unknown request fields and keys are refused. One write changes exactly one tier 3 key, counting the keys the plan adds from what the file holds (plan.tier3Keys), not only the ones the request names, and the audit record carries no hash of a tier 3 value. A whole instance that holds a tier 3 field is tier 3 to remove or replace (decided from what the file holds, not from the request), a preview or write reply masks every tier 3 value as(set), and.agenthubitself must be a real directory. Theconfig_editevent and the notice carry key paths and hashes, never a value, so a tier 3 key's home or model never reaches the event log,hub.logor the dashboard. Agent shells and hub tools cannot reach any of it: the CLI isconsoleinclassifyPeerCommandand the control requests answer the console role only. - Start modes. A start the hub makes itself (the dashboard's Start control,
peer_startfrom a console child, the conductor'shub_peer_start,pi.auto_start) takes the peer's start mode. Through settings only a settings session or the terminal can set it to headless; a config file can also setpeers.<id>.start_mode, the shared one included (it is not a machine-local field: headless was the only form for Pi, Kimi and the local worker before this change, and for Jcode before 0.12.39, a hub-made headless Codex start is the app-server the hub already ran for it, and a start mode grants nothing a permission mode or a capability controls). A declared extra instance accepts onlyheadlessfrom any surface; a Claude channel instance accepts no start mode (one that implements or verifies needs its own worktree, and a planner or reviewer one refuses a worktree, #525-1), and a headless Claude instance is either a reviewer (roles exactly["reviewer"], no worktree) or an implementer with its own registered worktree and permission modeaskorask-when-needed(#525-4). For a TUI the hub opens a terminal running<runtime> <its own entry point> --project <this project> <peer>(with--mode tuifor Pi, Kimi and Jcode): every word is the hub's own, the command line is those words each quoted for a POSIX shell, and no request field, tool argument or agent message is part of it. In aterminal.opentemplate{command}is that quoted line and{title}and{cwd}are raw text for argv elements of their own; each placeholder is substituted once, so a project path that itself contains a placeholder, a quote or$stays one word. A provider's own error text goes tohub.log, not to the caller. The terminal comes fromterminal.open, a machine-local command template that config trust refuses from a committed file and that no settings surface can write, or from Orca. The opened terminal's environment carries no agent marker, soahubthere has the console's identity. Without a provider the start is refused with the command to run by hand; it is never downgraded to headless. After a successful headless start of a watch-capable peer (Kimi, Qwen) the hub also opens<runtime> <its own entry point> --project <this project> watch <peer>through the same provider under the same fixed-words rule, at most once per peer per 30 s and never for an already attached peer or a declared instance; the pane has the console's identity, and a missing or failing provider leaves the start standing and reports the command (#286). A conductor can start a peer but cannot choose its mode.
The manager has its own loopback session server and authenticated local control
endpoint. Browsers provide registered project IDs and expected daemon instance
IDs; they cannot submit arbitrary paths, ports, PIDs or control tokens. The
manager forwards the same closed, redacted dashboard operations as a project-local
page. A snapshot request's view is forwarded only when it is one of the page's
seven view names (#683 U4), and the project hub checks it again. It cannot expose
private task history or allow local-worker tools.
Per-project control handshakes verify project, root and instance against the runtime manifest. Shutdown removes only the owning instance's runtime files and waits for owned child processes. Registry and manager startup claims serialize concurrent starts; uncertain ownership is reported, not forcibly reclaimed. An open whose spawned manager lost to another one stops the process it started, so no unowned manager keeps running (#584). A stale browser action against a restarted daemon is rejected, and mutations are never automatically replayed after a connection failure.
The manager can start registered hubs in attended mode. Its project launches do not inherit state, unattended or gateway URL/key overrides from the session that opened the manager. Native memory is deliberately not a project-isolation boundary: its existing basename/worktree aliases and provider accounts can remain shared.
@staix/agent-hub/ui-plugin runs inside the agent-console (acon) process as the
same user. It holds no credential of its own: it reaches project hubs only through
the multi-project manager, which connects as the console with each project's
control token. Beyond what ahub ui --all has, it uses dedicated manager ports the
dashboard page cannot reach: answer (#1053), receipt (#1054) and the review port
(review_comment_put|delete|send, review_comments, review_blob, #1056). Every
write through them requires acon's verified ActionMeta, as described in those
sections. Every other action must be on the manager's closed list and every view one
of the seven view names. There is no generic control proxy, task-detail read, shell
or caller-chosen command, and the only file read is the bounded review material of a
public task (review_blob).
- Registration.
ahub setupwritesconsole/plugins/ahub.jsonunder the hub home (directory 0700, file 0600, atomic replace) with the module path and its sha256; acon refuses to load a module whose hash no longer matches. - Confirmation. Outward or destructive actions (
stop,permit,send,setting,setting_undo,setting_reload,config_edit_apply,start_peer,backlog_split,worktree_override_set,worktrees_refresh_prs,review_comment_send,review_comment_deleteand the inbox's Allow once and Deny) first answerneedsConfirmwith a preview and a fingerprint, a sha256 of the request's own fields; the call runs only when it comes back withconfirmedand that fingerprint. The fingerprint binds a confirmation to one exact request, so a changed request needs a new confirmation; it is not a secret, and the confirm step is acon's host UI contract, not authentication. A process that can load the module already has the console's reach, and the hostile-agent boundary above applies unchanged. - Inbox. Items are the pending approvals, the in-review tasks whose reviewer
is the person, open questions (#1053) and, for a console that gives receipts
person evidence, unread results (#1054), read from the same dashboard snapshot: a local-worker or
Pi approval shows only the fixed private stub and offers Deny, and Allow once for
it is refused with "answer this approval with ahub permit". Allow once and Deny
resolve to the hub's own
permitwith an option the pending request offers, looked up again at the time of the call; nothing else is reachable from an approval item. A question's Answer and a result's mark-read go only through the person-evidence paths in "Questions a peer asks the person" and "Results a person's runs leave behind". Task titles are the public view's, so a PII task shows the[pii]stub. - Space. The Space scene is built only from the office model the dashboard
already allowlists (#264): peer ids, conditions and counts, task ids, classes,
states, owners and public titles, approval counts, and overlap, conflict, cohort
and message link ids. No envelope body, task detail, approval title, reason, path
or symbol reaches it, a PII task never joins a link, and the scene is trimmed to
acon's caps.
test/acon-plugin.test.tspins this with a marker fixture.
The running peer roster changes only through console-only settings requests.
ahub roster is a person's CLI command, including its read form; no hub tool
exposes the catalog or edits membership. An ordinary dashboard can remove a peer,
but adding requires the existing settings-session authority. Removing the
conductor holder is refused. Local and Pi scoped tools cannot write .agenthub.
Claude, Codex and Kimi still follow their own file permissions and can edit a
config file; such an edit changes participation only at the next hub start. A
live settings write or undo applies its own roster key and does not adopt other
file edits, and a configuration reload refuses roster changes. External ids outside the known native catalog keep their prior
admission rules. A roster key left by an undeclared instance loads as inert and grants
nothing; the only change it accepts is its removal from config.local.json.
Handing a peer to a standby session (#1174) is the person's: peer_takeover is
console-role only, and ahub peer takeover is refused to agent shells and
--live-test. A standby connection runs no peer operation until it owns the peer,
and the selected standby is rechecked for membership, launch and isolation,
lifecycle and claim generation after its preface. Standby ids and reported PIDs are
display metadata, never native-session or permission evidence. The current owner's
attachment facts in the console's detailed status (#1310: client pid, attach time
and, once the host verified it, a short native session id) are display metadata
too, dropped with the socket and withheld from status.json, events, logs and memory. Takeover detaches
the old hub connection, not its native process; that session's later hub calls are
refused, and takeover never marks a delivery completed.
Kimi's pull-only TUI owns its tool approvals and session. The person's launcher
merges/removes only the hub MCP entry; the daemon never edits that file and
refuses headless starts while a stale entry exists. An attached Kimi TUI blocks
controlled recovery instead of being treated as a hub-owned headless process.
Its completion facts (#1282) are admitted only on the receipt-verified kimi-tui
compat entry (2.5.2) through the launcher's native_hook_launch authority and a
launch-scoped plugin under the native home; another version launches the plain
TUI without completion receipts. The launcher replaces another launch's plugin only
after its recorded owner is proven gone, under one fail-closed lock per native home
that is never removed automatically. Re-reads carry no prompt, response, error,
task command or title text, and native-person results are private stubs.
Scoped Pi and local-worker write/edit grants check both the original project-relative
path and its resolved target for protected agent configuration names. A protected
directory symlinked to an ordinary project directory still asks a person; an alias
into a protected directory also asks. Effects use the guarded real path and recheck
it after approval. Explicit approval and never-ask retain their existing meaning
(#322). ACP permissions arriving offline are cancelled before any auto-approval or
console request; pending permissions are withdrawn on watchdog, offline or stop,
and a late person answer cannot grant them (#311).
The shared scoped-grant exclusion also covers .agents skills and .claude.json.
It also covers the Antigravity, Gemini CLI and Grok project configuration
directories .agent, _agents, _agent, .gemini and .grok (#485), folded like
the other names; look-alikes such as .agent-backup/ or src/gemini.ts stay grantable.
The latter is conservatively protected by name; its default documented location
is in the home directory, not a project loader. The source inventory and limits
are in the configuration inventory.
Instruction files AGENTS.md, CLAUDE.md, QWEN.md and GEMINI.md (#1280) remain grantable by default.
A person can set scoped_grants.protect_instructions to true to require approval
for scoped writes and edits through original or resolved spellings. It applies
live after a settings write, undo or reload (turning it off from an edited file needs the confirmed terminal reload), never changes reads or never-ask, and cannot
be set through an agent, tools or conductor surface. Claude's daemon response
withholds the scoped mode on these writes so an older hook grants nothing too.
Agent CLI versions (#691). One policy gates the Codex and Kimi fresh reviewers, the headless Claude implementer, Jcode, agy, Qwen (ACP and the dual-output TUI) and Pi (#843): a receipt-verified version passes, Jcode and agy also start an untested version inside their compatible line with a note, and any other version is refused unless the person trusts that exact version on this machine with ahub agents trust <id> <version>. The record is agent-compat.json in the hub home (~/.agenthub), written atomically with mode 600 and at most 64 entries; an unreadable, linked, oversized or invalid record trusts nothing. Agent shells and --live-test may read ahub agents but cannot trust or revoke. Trust lifts only the version pin: the fresh reviewers' runtime proofs still run before every child, and an untested version's native contract stays unverified.
Qwen Code's optional production ACP peer requires the qwen-code native identity, a compat-admitted version (receipt-verified 0.25.0, 0.26.0, 0.26.1, 0.26.4, 0.26.5, 0.26.8 and 0.26.9) and advertised mode ids before setting a policy or starting work. Its own tools have no hub sandbox; auto-edit approves file edits, yolo every tool. Configured never-ask still requires the person's confirmation. qwen_cmd is machine-local and never editable through settings. Legacy benchmark adapters remain unmanaged by this production table. Startup diagnostics withhold native host/payload details.
Jcode is optional, source/version-fenced and runs native tools without a hub sandbox or hub permission-mode mapping. .jcode joins the scoped-grant exclusion, including lexical/resolved aliases. Only a person writes its unique project MCP entry; foreign/override/symlink entries refuse. The hub owns a private daemon/socket and ACP group, never the global daemon, and copies no credentials. It accepts a plain jcode release from 0.98.0 up to, not including, 1.0.0 (#529; only 0.98.1, 0.99.0 and 0.99.1 are verified, and another version in that line starts with a note), or an exact version the person trusts (#691), and runs every owned jcode process (probe, serve, ACP and TUI) with JCODE_NO_TELEMETRY=1, because jcode's telemetry can report the provider account's email. Native diagnostics withhold payloads/hosts. Hub tools are bound to an owned launch (task 368): the shared project entry names no peer, each owned Jcode process carries its own peer id and launch id, the daemon admits a hub-tools connection for a Jcode peer only with the id of that peer's current owned launch, and a tools-mode server started without a peer id refuses every tool. Extra Jcode instances (#685, at most 8) are declared only through instances in an untracked .agenthub/config.local.json, start headless, and each needs its own jcode_home, which may not be the base home or another instance's, nor nested in or around one, however it is spelled. Unproved conversation loads refuse; no fresh replacement is called a restore. A finished task's advisory about the project root checkout names the branch only after the hub's whole public-text screen (Tasks.publicMetadataText: a PII pattern, a secret shape and a project-private path) passes it and it is a plain ref name; otherwise a generic sentence carries no part of it (#572). Native TUI launch exposes its private socket/session tuple only to the authenticated console, bound to the same live owned socket and session after read-only native persistence proof. Tools/status do not receive it. The TUI has no single-writer guard: person input may interleave with ACP. Native policy remains unmanaged and closing the frontend does not stop ACP. An important message reaches a busy Jcode as a soft interrupt written to the hub's own private server socket for the owned session (#572); the hub never cancels a turn to deliver, and a steer that was sent but not acknowledged, or whose turn ended first, is held as needs_review, never replayed. Jcode has no switch to choose its MCP servers, so the owned serve and ACP processes load every server named in <JCODE_HOME>/mcp.json and the project's .jcode/mcp.json, .mcp.json and .claude/mcp.json, besides the hub's own; ahub doctor lists their names only. Point jcode_home at a separate home, or remove a server from those files, to keep it out.
Antigravity (agy, #485) is optional, cloud and off roster by default. The launcher accepts the Antigravity CLI 1.4.x line and exact 1.5.3; only 1.4.2, 1.4.7, 1.4.12 and 1.5.3 have a native receipt (1.4.7, 1.4.12, 1.5.3), so another 1.4.x patch launches with a warning that it is unverified, and every other version, other 1.5.x included, is refused before a hub connection or plugin write (#589) unless the person trusts that exact version (#691). It runs native tools under its own unmanaged permissions, without a hub sandbox or hub permission-mode mapping; headless, resume, unattended and native permission flags are refused. Its roles are capped at implementer and verifier, so it never plans, reviews or receives PII work. Only the person's ahub agy launcher writes its three plugin files in .agents/plugins/agent-hub/ (plugin.json, mcp_config.json, hooks.json): linked or foreign entries refuse before any write, cleanup removes only that launch's matching files, and it never writes project-level .agents/hooks.json or the person's ~/.gemini settings. Conductor starts are refused, and a declared agy extra instance is refused at config load (#625). Hook authority (native_hook_launch) comes only from that launcher, belongs to the console socket that began it and ends when that socket closes; hook input over 1 MiB is refused, and observations are bound to the daemon, launch, native session and project.
Configured ACP adapters (#451 A3) are declared only by the person, in a Git-confirmed untracked .agenthub/config.local.json. The whole declaration (command, argv, identity, roles and mode maps) is machine-local authority: a shared or tracked declaration is ignored with a notice, settings never edit it, and changes need a hub restart. The hub runs only the declared argv, never a module or callback. Argv that selects a leader, attach, connect or remote mode is refused, and a Grok declaration needs --no-leader. Each start requires the pinned native identity, an exact session/load echo on resume, and positive ownership of the spawned process and its original process group; unknown ownership refuses, and stop signals only that verified group. An agent that hands its work to a process outside that group is outside this boundary. Without a mode map native policy is unmanaged, and there is no hub sandbox either way. Configured kinds are cloud peers (never PII), headless only and off roster by default, and the hub makes no quota, context or controlled-recovery claims for them. A read-only instance needs the native read-only mode acknowledged before a prompt and is limited to read and review hub tools, both in the published MCP list and at the host; its native non-read tool requests are rejected, and no setting or runtime change can raise it. These agents' stderr and error text are withheld from logs, and previews redact the executable, argv and session id.
External adapter modules (#451 A4) are the one way to run adapter code that ships outside the hub, and the pin is code authorization, not a sandbox. adapter_modules is read only from a Git-confirmed untracked .agenthub/config.local.json (never from a shared, tracked or cloned config, and never written by settings or a hub tool). Only daemon startup imports a module, from the captured bytes that matched the sha256 pin, not by reopening the path; the file must sit inside the project with no symlinked component and not be world-writable. Its imports are bound to the running hub's own @staix/agent-hub/adapter and builtins (bun:ffi and bun:test excluded), and dynamic imports, require, eval and Function are refused by a conservative scan. Once imported, the module runs with the daemon's privileges: the 2 s import deadline stops waiting but does not cancel its code or undo side effects, and the host's default against hub-tool title auto-approval holds only while the module keeps the factory options it is given. Inspect the whole bundle before recording its hash. Refusal reasons withhold source and module error text. A loaded kind keeps its own family, cloud trust (never PII), headless start and off-roster default, and fixed-builtin capabilities are refused. The daemon reads a native proof port (a status notice, the Codex frontend proxy, a native turn revert, a Claude launch proof) only from a fixed builtin definition, so a module product that carries one gains nothing (#790). Extra instances of a module kind (#754) exist only when the module opts in (headlessInstances: headless, implementer, verifier or continuing reviewer) and the person declares them in instances. Each needs its own managed worktree, on an attached branch unless it is read-only, and is bound to what startup verified (module pin, API, capability and policy digests, execution root): a changed binding, a replaced worktree, a branch switch or a lost owned process revokes its authority, and a recorded native session resumes only under the exact same binding.
- Watch activity can contain file contents, thoughts and command output. It is console-role only, kept in a bounded in-memory ring and exposed only through the
watchcontrol request. Agent shells, conductor tools, dashboard snapshots, hub tools and memory observers cannot read it. Terminal bodies pass through the console's sanitization and wrapping helpers. Slow sockets close instead of accumulating unbounded output. - Private/PII turns show a single stub; the adapter latches privacy for that turn. The existing final-answer bus and native approval paths (console and dashboard) remain independent, with their existing privacy rules. Watch never adds activity to either path or any disk log.
- Qwen's dual-output TUI (#286 Part 3, receipt) is a separate transport, covered next.
- The input file is a prompt channel. Whatever is appended to
<state dir>/qwen/qwen-dual/input.jsonlis read by Qwen as typed input: asubmitline is a prompt, aconfirmation_responseline answers a tool approval. The files are created 0600 in a 0700 directory owned by the person running the hub, opened without following links, refused if they are not regular files owned by that person with no group or other access, and written only by the daemon's adapter. No other peer can write them through a hub tool. - Qwen's own write and shell tools can reach the file, but only through approval. The hub's approval path therefore refuses, without asking anyone, any Qwen tool request other than the hub's own tools whose input names the hub state directory, its real path,
.agenthub/state, theqwen-dualdirectory orinput.jsonl. That check is a lexical one on the request, as the managed local and Pi tools' state-dir guards are: it is not a sandbox, and a Qwen started with--approval-mode yoloby a person in their own terminal is outside it. - Text another agent wrote reaches Qwen only inside a hub-framed digest. A digest that would begin with
/,!,@or?(a slash command, a shell line, a file include or a help key in Qwen's prompt) is prefixed with a hub line first. - Hub tools are auto-approved by the exact
mcp__agent-hub__<tool>name Qwen reports for the call and never by prefix, title or argument text. Everything else asks the console, and an always-ask class call (#636) is never auto-approved. A prompt answered in the Qwen terminal withdraws the hub's pending approval: a late answer finds nothing to grant. - Identity: the adapter attaches only a Qwen whose
session_startstates a verified version, protocol 2 and the events the hub reads, and it binds the running owner from the host's process table (the root of the process tree whose argv names this adapter's input file: Qwen 0.26.1 runs as a wrapper, a node launcher and acli.jschild that all name it; two separate roots is no owner), never from a message the launcher sends. The hub never signals that process and never types into its terminal; the person's terminal owns it. - Environment:
ahub qwen --mode tuiexecs Qwen in the person's own environment (Qwen needs their PATH, HOME and provider key, which the daemon never copies) with the hub's peer marker over it. Every otherAGENTHUB_*variable butAGENTHUB_HOME, the other agents' vendor markers, and recovery and unattended authority are dropped, so a Qwen started from another agent's shell never carries that agent's identity, launch id or mode. - A hub deny is not a readiness signal (#1371). The denied delivery completes without an answer, and later queued input stays unsubmitted behind a durable fence until a new personal turn completes in the Qwen terminal or a verified fresh native generation starts; the denied turn's own Stop, a readable event file again, a replayed
session_startor a hub restart does not release it, and the peer reports offline after 30 s. A short or unconfirmed input write holds the deliveryneeds_reviewand appends nothing more. - Not defended: a person who gives Qwen yolo mode, an agent that Qwen runs and that writes the file through a path this lexical check does not recognise, and another process of the same user (the usual same-user boundary of the state directory).
The optional status needs projection contains act/watch counts, a known state
and the first item (kind, level, peer/id and numeric times only). Status JSON has
no console-local unread-message count and no full item list. The console-role
reply to the existing status request additionally supplies attentionItems,
using the same body-free allowlist so stream navigation needs no board-text read.
Peer/tools status callers do not receive that full list. The summary and item
projection never reads task title/detail, approval title/options, pause reason,
error text or paths, and is never forwarded to memory. PROTOCOL remains 16 and
EVENTS_SCHEMA remains 1; clients that ignore optional fields remain compatible.
The file-reading pet command verifies the recorded process identity through the shared liveness contract and treats missing/unconfirmed evidence as unavailable. Recovery uses the existing lock/runner read path; unknown is never absent and navigation grants no permission or mutation authority. Built-in characters and the dashboard projection use the same structural privacy boundary described below.
Built-in text-frame characters are literal data, not user files or runtime image loads. Presentation config admits only known ids/sizes/glyph modes, never paths or arbitrary glyph bodies. Character cycling emits no workflow effects. Larger frames carry only structural need ids/counts and exclude source/notice prose; private task and approval details remain in their existing operator views.
The optional dashboard attention input contains only structural task ids, states, owner/reviewer ids, approval ids and numeric times, peer delivery states and budget pause times. It is assembled from the same allowlisted input as status; task text, plans, approval prose, error text and paths are excluded. This input is supplied only inside console-authorized dashboard snapshots, never ordinary status replies or status.json. The #391 dashboard projects this input into nav and tab-title counts; the in-page pet and its character/pixel injection are gone. Attention navigation switches a local view and focuses an exact visible row id, with structural guidance when missing. It cannot grant permissions or invoke a mutation. Compact-card detail/dialog controls retain the existing authenticated workflow actions and project/instance fencing. Existing CSP hashing, headers, origin and session authorization remain in force; data uses text/DOM nodes, without HTML insertion, inline handlers or external assets.
With a project config, native hub memory is enabled unless memory.native is
false. Append-only segments and their derived index stay in the private hub-home
memory directory, created with 0700 directory permissions. New note segments request mode 0600, and index.db receives a best-effort
0600 chmod. Existing file modes and SQLite-created WAL/shared-memory sidecar
modes are not a per-file guarantee; the store protects access through its
explicitly chmodded 0700 parent directory.
PII-task notes, PII-pattern notes and secret-looking text are refused before
persistence; promotions are
screened again. The index can be rebuilt from segments and does not replace the
historical claude-mem database used only by the explicit read-only import.
Sync starts only after a person configures a remote. Public GitHub remotes are
refused; unknown visibility proceeds with a warning for the configured remote.
Short-term segments, machine identity and the derived index are excluded from
sync. Sync and direct long-term writes apply the configured agent-mem sync policy
narrowed by the hub's deny paths, PII patterns and project-private paths; a promotion
that fails it leaves the note short-term (#1173). Status exposes safe sync state and
counts, including verified withheld/exported counts from a successful SDK placement
result. Failure logs retain only the closed cause and known declined count.
ahub memory declined is person-only (refused to agent shells and --live-test)
and uses bounded physical-row inspection to show IDs and rule categories, never
note text or literal patterns; incomplete inspection is not a zero count.
Since agent-mem 0.23.0 the daemon passes a fresh placementAttestation on every
sync pass, built by the package's own createPlacementAttestation for the memory
home (src/hub/memory-sync-attestation.ts, #1337); the hub supplies no writer
facts of its own and never fabricates the proof. A refusal is the closed cause
writer with a category, cause class and next step; the package's message, paths
and versions never enter status or logs. The native amem CLI provides person-only
activation from 0.22.0; the hub does not invoke it, and whether an activated store
syncs on a person's machine is not established by the isolated fixtures (#1173). Suggested graph edges are off by default
and are not evidence until a person confirms them. Operational details and limits: hub memory.
Agent-authored Records (#1320). Managed headless Codex, Kimi, Qwen, Jcode, Pi and
local turns get the agent-mem Record instruction; reviewers and person-owned TUI
prompts do not. amem's effective records policy (recordsEnabled) gates
instruction, capture and Record preference, and an unavailable policy disables
them. Only a completed final answer of a public, local, task-bound turn is
captured: private, PII, review-scoped, remote, unbound and incomplete turns are
excluded, and complete text and anchors clear the secret, PII, private-path and
deny screens before storage as Record capture rows, never curated notes or
evidence. amem: lines are stripped before invocation routing, shared-answer
publication, digests, the journal and the console event tap; the native console
can still show the original answer. Diagnostics are counts and byte sizes only,
and no model or network call is added.
The standalone JXA host, WKWebView, window bridge and its packaged assets are
removed. ahub pet --window [--close] only prints the acon ui Mini pointer and
exits successfully; it has no launcher, process-control or network effect and
is available to agent shells and --live-test. Legacy pet.window is ignored
as compatibility input and grants no configuration or conductor authority.
Terminal pet reads retain their existing behavior. The retired pet --palette
command validates its argument and prints the same Mini pointer without writing
.agenthub/config.local.json. Existing pet.palette values remain validated
compatibility input and have no rendering effect.
The Console message view already exists in the agent-hub acon plugin
(#935). Its existing send action retains message audience, PII and actor
checks; the retired window adds no alternative composer path.
The planner setting is person-controlled: one peer id or a list of up to eight (#525-2). A valid pin determines the planner holders without adding them to the roster; otherwise configured rostered planner roles apply, then the first rostered peer in the closed planner catalog. The automatic choice is not written to disk. Each new non-PII task and each planner-created release records one effective planner from the same pure walk ahub route explain shows; only that planner or the person changes its planning. A bound planner keeps that authority while busy, paused or offline; one removed from the holders or the roster falls back to the current holders, and older rows with no binding, PII tasks included, keep holder-wide authority. Only the person moves a task's planner (ahub task assign <id> <peer> --role planner). Another holder may record a plan review without gaining the task's other planning powers; an agent cannot review its own plan while another rostered holder is not offline, and otherwise the receipt records selfReview: true with planAuthor. Planner guidance does not grant conductor authority or bypass roster, PII, WIP or owner capability checks.
cancelled is closed, never approval. The person can cancel non-approved work with a reason and optional supersession reference; a planner can cancel only its own proposed/backlog work with no historical acceptance, and only as that task's effective planner. The configured, rostered conductor can cancel only a task another project handed this one (#1175), while it is proposed or backlog, nobody has accepted it, it has no execution evidence and it is not PII. Current task revision, privacy and recovery/shutdown holds are checked before writing and again after awaited screening. Cancellation preserves history, releases reservations and overlap membership, writes no outcome memory, and does not stop native turns or delete branches/worktrees. Owner/reviewer stop notices remain relevant; earlier task requests become moot. Dependents remain blocked and receive ids-only advice, including backlog dependents.
Current daemons advertise task_cancel in hello and status metadata. A current CLI refuses cancellation against a daemon without that feature. Controlled upgrade/restart refuses an unsupported target while cancelled task ids exist; source prepare and commit recheck compatibility, and the rows are never rewritten as approved. In the same way it refuses a target without task_planners once a planner list or an instance planner is configured or any task or release planner binding or plan-review authorship is stored, even after that configuration is removed. A manually started older binary is outside this compatibility guarantee: it can display cancelled rows as open or fail to move them.
Stall causes are persisted and deduplicated by cause and replanning revision. Planner advice joins its next delivery as bounded ids-only context; it does not manufacture an ordinary task turn. Pull-only offer attention clears only after acceptance, decline, supersession or durable positive evidence for that exact offer and recipient. Operator, hub or owner completion of an uncertain delivery is not evidence that the offer was read.
hub_delivery_retry grants an eligible recipient one retry of its own needs_review work, never pause removal. hub_delivery_resolve (#1112) lets a local agent mark only its own needs_review row completed or discard, at the revision hub_inbox showed, with a public, screened reason recorded under its actor. An attached Claude push-channel session may also settle its own accepted row that way (#1172); any other peer's accepted row is refused. It never replays the originals or changes task state. An unknown id, another peer's row and a row in neither state share one refusal, so the answer never confirms another peer's row. A channel accepted row that gets neither native completion nor that disposition within bus.accepted_settle_ms (default ten minutes) becomes needs_review with no replay. With conductor.feed on, the configured conductor gets one ids-only notice for an important, task or review timeout. The person, the hub, a remote peer, a paused or held peer, and recovery, shutdown or storage holds are refused. A row that carries an invocation stays person-only, because its outcome is uncertain. An explicit hub_inbox read may return newly queued messages behind such a hold, and only when no recovery, pause, lifecycle or storage fence applies. It may also show the caller's own held originals (#1359) as a read-only inspection: at most 32 of its own needs_review rows (and accepted rows only for the current explicit-settlement channel) within the host pull byte budget, through the public queue body projection (private and missing tasks and private envelopes stubbed, unsafe metadata withheld, bodies sanitized); the caller cannot name another recipient, and the view never retries, resolves, acknowledges or changes an attempt. A held review inspection stays excluded from observer capture across restart until an explicit disposition. Durable per-original evidence survives journal pruning; unknown retry history or a used retry requires the person. Hub presence prefaces are excluded from attempted-work identity, so a shared context preface does not consume an unrelated message's retry. Delivery-only local and ACP peers (including Jcode) cannot invoke recipient retry; tool exposure hints do not replace the daemon's actual-adapter guard. Their held work requires person resolution. Before transport handoff, storage failure preserves the queue and preface; after handoff an uncertain effect is never automatically replayed.
A declared instance whose only role is reviewer gets review requests, task messages addressed to it and the person's messages; agent chat, digests and presence are discarded and journaled, and it gets no memory recall (task 223). The headless Claude reviewer runs one new claude -p per review with --setting-sources "", --strict-mcp-config, --no-session-persistence, tools limited to Read, Grep, Glob, hub_review and hub_task_show, and dontAsk. Its prompt holds hub facts and a bounded diff, never the builder's summary, and its hub_task_show drops the builder's notes. Changes touching local.deny or pii.paths are withheld and passed as Read deny rules, which Claude enforces, not the hub. Private tasks are never given to it. Its hub_review counts only while the child started for that task still runs; a stop or exit revokes it first, and surviving processes hold the delivery as needs_review and keep the reviewer busy.
A terminal Claude implementer or verifier instance (#525-5) owns write-class tasks only after the daemon proves its execution root: the current launch id, a private single-link launch record naming the root, the cwd the channel's MCP server reports (verified model-free on Claude Code 2.1.309) and the daemon's own validation of the declared non-canonical branch worktree must all agree, and the proof belongs to the attached claim generation and socket. A detach or a replacement claim revokes it; a changed launch record, worktree or branch is caught and revoked at the next routing admission, facts permission reply or recovery owner proof, before any effect (status shows the last verified binding and grants nothing). Missing, legacy or mismatched evidence still attaches the channel, but write-owner routing is refused and facts grants give nothing, never a fall-back to the canonical tree; terminal recovery revalidates the recorded root against the current declaration. This is same-user launch evidence, not filesystem isolation: Claude can still write wherever its own permissions allow.
A planner-only instance (#525-6: planner without implementer or verifier) plans on the project checkout, so a declared worktree is refused. A Codex planner's thread/start and thread/resume are requested read-only, and a thread is adopted only when Codex's reply positively confirms a read-only sandbox; an unconfirmed or malformed reply refuses that RPC and closes the link before the hub adopts the thread or delivers work, and the current roles are rechecked before each injection. This is an initial ceiling, not confinement of the person's attached TUI, which can change its native sandbox later. A declared planner-only Claude channel instance gets no automatic grant from its facts hook for any tool but Read, LS, Glob and Grep, whatever the permission mode.
The headless Claude implementer (#525-4) runs one owned claude -p child per turn in its declared worktree, with --setting-sources "", --strict-mcp-config, native permission mode default, the hub's managed PreToolUse hook and --permission-prompt-tool mcp__agent-hub__hub_permission_request; never --bare, acceptEdits, dontAsk or bypassPermissions, and never-ask is refused. Each task keeps its own persisted session; unscoped chat reuses one only while exactly one task is in progress, and a hub_inbox read refuses a batch attributed to another task. The approval path fails closed: hub_permission_request is accepted only from the current child's launch and generation, goes to the console's existing approval queue and approvals.timeout_s, and offers allow-once and deny only, with no allow-always or session grant. A timeout, a prompt failure, a disconnect, a hub that is down or refusing, or a stale child binding answers deny; a failed hook decides nothing, so the call still waits for a person. The approval title is the tool name and a JSON rendering of the arguments passed through sanitize() and the public-text screen (any PII, secret or private-path match withholds the whole value as [withheld], before any cut), never task text, cut at 600 characters with a [cut, N chars] marker; the original arguments stay with the child and are not logged. The MCP wire cannot tell a native permission dispatch from a call the model makes itself; a person allow still refers only to its original arguments. Automatic Bash authorization uses the separate single-use host wrapper contract below, never a caller-supplied tool id as native provenance. A stop holds an uncertain delivery until the owned process group is confirmed gone. The native permission receipt is model-free on Claude Code 2.1.309, repeated on 2.1.310, on 2.1.314, on 2.1.316 on 2.1.318, on 2.1.319 and on 2.1.322; it is not sandbox proof, and a real account, model and person interaction remains a live leg.
The #845 host shell wrapper is the only automatic Bash path for a headless implementer. In ask-when-needed, a complete public input must pass the existing #816 read-only proof or the closed task-command proof, with an active owned task and registered worktree. Enabled action-policy classes still ask, and ask, PII handling and the never-ask refusal are unchanged. The native request and authenticated host response both name wrapper protocol 1, so an older plugin cannot drop the rewrite and execute an original command automatically.
A grant uses a random, expiring single-use nonce and an in-memory record bound to task, child generation, root, branch, exact argv, executable and host environment. The fixed wrapper has no command-selection arguments. Redemption consumes the ticket before rechecking current task/child/root/branch and proof; unknown, expired, reused or mismatched tickets return a fixed nonzero refusal and execute nothing. Restart revokes tickets. The host service streams stdout/stderr and exit status from an owned command process, using the proven cwd, absolute executable and the #816 fixed environment plus proxy/peer identity and private command TMPDIR. Native GIT_DIR, RIPGREP_CONFIG_PATH, PATH and other inherited native values are not execution authority. Host audit logs include neither nonce nor command arguments. The requesting MCP call and wrapper argv necessarily see the nonce; a direct model-selected permission call receives no broader authority and can record the same branch binding after the same host proofs. Read-only and executable code grants (check/test/build) execute through the existing local-worker Seatbelt profile and own-temp rules. Code may write the worktree and owned temp, but all writes to the project's Git common/current metadata are denied, including loose and packed objects, refs, HEAD, index, logs, other worktrees and shared/private submodule metadata. It cannot poison objects that a later checkout would materialize; protected metadata directory entries cannot be renamed or replaced. Code cannot read credentials or the live hub state, even with a canonical-checkout read allowance; network follows local.bash_network, with the proxy for ordinary enabled mode and direct network access when the person explicitly selects "direct". The default branch is captured once at startup from a bounded remote symref probe against the captured GitHub repository, with private HOME/cwd and isolated config; local origin/HEAD is never authority. Unknown or unauthenticated private-remote evidence asks. Sandbox protections are rebuilt and compared at ticket redemption and immediately before spawn; a changed submodule Git-directory inventory invalidates the ticket. Without sandbox-exec these commands ask. Only closed host Git add/commit/push and gh operations run outside the profile. Filter/external-diff/textconv drivers (including global Git LFS) make only add/commit ask; push/gh do not execute those drivers and keep their grants. This deliberately preserves LFS conversion semantics on the person-approved path instead of neutralizing filters. Config includes and partial clones still refuse privileged Git/gh; host Git disables signing and automatic maintenance in addition to core hooks/fsmonitor/credential hardening. A deliberately setsid-detached target descendant can outlive owned-group cleanup and retain its confined worktree writes. This confines automatic task code, not the entire native Claude process.
Task branch authority uses recorded refs.branch, otherwise a host-observed non-default attached branch on the first eligible task-command request, excluding main, master, unknown/detached HEAD and another open task's branch. The binding is a normal Tasks write with history and an opaque canonical-root hash; a changed branch or worktree asks and never rebinds. Push grants accept only git push origin <bound> or git push -u origin <bound>; force, refspec, deletion and other remotes ask. Every origin push URL is checked against the captured project repository and PR argv pins that repository. Execution uses an explicit same-branch refspec; configured remote.origin.push/mirror refuses. Project/worktree PATH directories and aliases are removed, and gh child Git inherits core SAFE_CONFIG through fixed environment configuration. PR body-file content is bounded, screened and snapshotted into executed --body bytes. See the design contract for the exact closed forms and the native receipt for verification limits.
The fresh Codex reviewer (#519) needs review_mode: "fresh" and a compat-admitted Codex (receipt-verified 0.146.0, 0.163.0, 0.164.0 and 0.166.0; ahub agents shows the current policy); an unverified/untrusted version is unavailable. Before each review it requires a model-free isolation receipt: binary and config unchanged, only the two hub tools in the configured MCP inventory (each approved by its own name, never by a server-wide default, #783), plugins, hooks and Apps disabled (Apps by a separate feature readback), and a named filesystem profile whose fixtures prove public reads, private-path denial (exact, glob and symlink), standard host credential denial and write denial. Credential and tool-home roots are denied even with an empty local.deny; that list does not cover every personal file. A dangling or unreadable credential root refuses the reviewer, and the reason names only its path class (~/.docker, $DOCKER_CONFIG), never the path or its contents. A failed per-review start names one class from a closed list (version, binary, mcp_inventory, plugins_hooks_apps, sandbox_proof, credential_root, timeout, unknown) with a fixed sentence in the unavailable reason, hub.log and the reviewer line of ahub status; it never carries a path, native CLI text or a token (#722). Its shell is sandboxed read-only, not Claude's Read/Grep/Glob whitelist. Network and web search are configured disabled but not observed: managed requirements can still select cached web search, and no run has shown the review selecting the profile through default_permissions. A failed receipt or start never falls back to a continuing native session, and a child that ends without a saved verdict is never approval. The agy reviewer slot is an unavailable placeholder and never runs; it binds nothing, so agy's own hub_task_show reads are not fenced as a review (#792).
The fresh Kimi reviewer (#533) needs review_mode: "fresh" and a compat-admitted Kimi Code (receipt-verified 2.2.0, 2.3.0, 2.3.1, 2.3.2, 2.4.1 and 2.5.1; ahub agents shows the current policy) with its native OAuth login; an unverified/untrusted version, or a failed preparation, is unavailable. The 2.3.0, 2.3.1 and 2.5.1 receipts are model-free and do not prove real login or a model review. It is a native tool-authority boundary, not a sandbox: no Seatbelt wraps it, and same-user native code is not isolated from the filesystem or network. Each review starts a new child in a private KIMI_CODE_HOME with managed-provider-only config, a clean temporary working directory and an empty skills directory. Its agent may use only hub_review and hub_task_show, served by a loopback MCP session bound to the task, head and review revision that never carries the hub's control token; it has no native read, file or shell tool and sees only the screened review brief and host facts. Its credentials/ and oauth/ directories and device_id link to the person's native ones, so refresh keeps Kimi's own lock and atomic rename; no token is copied and KIMI_DISABLE_OAUTH_LOCK is never set. A stop revokes its verdict authority before termination; killing it mid-refresh may require a native re-login. Private tasks are never given to it, and a child that ends without a saved verdict is never approval.
The review brief (#520) withholds private changes whole (both sides of a move) and counts them; a rule file that fails the private-data screen is named, not shown. Attributes and rules are read at the merge base, so a change cannot loosen its own review. Optional changed symbols (#684) come from ripwire 0.6.7 on the daemon's host PATH, run as an owned process on a disposable snapshot of the task's commit; each path, name and kind passes the hub's public metadata screen before the brief, the console's task detail or the dashboard shows it, a private task stores and shows none, and a failed or missing read is unknown, never a gate. Spec acceptance, plan review, plan conformance, premature-review, cost, test-integrity (#481 S4), issue and PR identity linking (#694) and dogfood scorecard (#687) reads fetch GitHub metadata only (edit times, check states, thread authorship and resolution, changed file names, open PR heads, workflow run conclusions), never issue bodies, comments or patches. Required-check discovery at ahub init --sdlc advise|enforce (#1344) makes at most three capped GitHub reads (the default branch, the latest completed default-branch run, that run's jobs), each bounded to 5 s within a 20 s deadline, and otherwise reads only literal job names from .github/workflows/*.yml, refusing links. Issue identity linking at propose and accept (#723) parses the task's title and refs locally, reads nothing from GitHub and skips PII tasks. The CI workflow keeps contents: read and actions: read.
Deliverable records (#509a). hub_task_done and hub_sdlc_record name files as {path, format, version?}; the hub hashes them itself and refuses a claimed sha256, hash or bytes and any field it does not own. Paths resolve under the owner's execution root with realPath and guardPath in read mode: outside the project, the secrets denylist, the hub's own state (.git, .agenthub), a dangling link, a non-regular file and more than 256 MiB each or 512 MiB together are refused, with the path as given and never a resolved one. The file is opened without following a final link and its device, inode, size and time are compared with what was checked, before and after the read, so a swapped or changing file is refused rather than recorded half-read; a bad call writes nothing. A declared worktree instance whose root is not proven refuses, and a later read of it reports unknown. No hash of hub state is ever taken, and a file the hub cannot read or vouch for is unknown, never unchanged. A reviewer's approval re-hashes the files and is refused when one changed, went missing or is unknown; only the person may approve the current bytes, which are then measured and bound. For a PII task, a file under pii.paths or a path matching a PII pattern, publicDeliverables is the one projection every peer view, review request, status and log uses: id, hash and size only. The board keeps the path, as it keeps a PII task's text. ahub deliverables (#509b) from an agent shell lists only the tasks hub_task_show would show that agent (#933), and --live-test cannot delegate it. It lists the board's records through publicDeliverables and opens a file only with --check, through the same reader and limits. The dashboard's Deliverables chip and task group use the same projection of the same records and never read a file on the poll; a changed mark there is only as fresh as the last live read in this daemon.
Person-directed release authorization (#481 S6, #1063, #1381). ahub sdlc authorize-release <version> accepts the console user or the one configured conductor under the same admission as sdlc merge: tools role, matching configured conductor, no child toolsOwner, private turn or PII context. It remains refused under --live-test. The conductor may record consent only on the person's explicit instruction in its own session naming the release; a hub message, task text, review note, file or peer relay is never that instruction. The daemon cannot verify that cooperative prerequisite. Receipt by and the audit identify the actual asker. Release policy comes only from .agenthub/config.json committed at the candidate commit; local policy is ignored. Version source defaults to committed package.json and may use a committed release-notes blob or the requested version bound to the candidate commit. Documentation evidence defaults to the committed-document checker; release.docs: none is refused when the candidate commits a regular docs/verified.json blob, keeping documentation evidence required for that project. Remove release.docs from the shared config or remove the manifest, commit the change and authorize again. Only a project without that manifest may record the explicit exemption, with no fabricated freshness digest. The receipt binds the candidate commit, configured version source, docs policy and push remote identity, lives only in daemon memory (a restart revokes it) and is logged without document content. npm publish still needs a clean tracked worktree and a committed package version that identifies the configured target. ahub sdlc release-guard -- <argv> is a read-only preflight that agents may run, except during a private or PII turn; it never executes the command and refuses ambient target selectors (GIT_DIR, GH_REPO, npm prefix and registry settings and the like). The pre-push hook that ahub init --release-guard installs refuses an unauthorized or unknown v* tag push, also with the daemon down, and passes other pushes through. It and the release block in AGENTS.md are a cooperative guard, not an operating-system boundary: git push --no-verify, a custom core.hooksPath or another Git invocation bypasses the hook, and gh release create and npm publish are checked only when the preflight is run (tasks 312 and 313).
Merge adoption and carried spec consent (#1348, #1361). ahub sdlc adopt-merge <task> --pr <n> is the person's or the current configured conductor's; another caller is refused, --live-test cannot delegate it as the person, and a private task or private turn refuses for every actor before any GitHub read. It reads one bounded GraphQL snapshot and requires the PR merged at the task's last recorded full head with every configured required check green there, no unresolved or unknown review thread, no pending or unknown review request, no change request and no open Important finding. It records merged outside the hub under the actual actor and moves the task to approved, without inventing a reviewer verdict or reviewed head, running gh pr merge or removing a branch or worktree. A superseding task may carry an older cancelled task's person spec consent only for the same canonical repository and issue, named by a whole local supersededBy reference, when a fresh host GitHub read proves the same issue lastEditedAt; no issue body is read, caller timestamps cannot grant it, unknown or truncated metadata leaves consent absent, and manual acceptance stays person-only.
SDLC metrics and control bands (#481 S7). ahub sdlc metrics and hub_sdlc_metrics are planner or person only, read-only, and refused during a private or PII turn. A private task is counted as withheld and read for nothing else; the report calls no model and makes no GitHub read. Band breaches are written only by the daemon's evaluator through Tasks, never while it is stopping, recovering or has a storage error. A diagnose or propose card is a backlog task (triage or plan) whose text is fixed words, numbers and task ids, never task text; it passes the PII signal check and the candidate-text screen before it is written, and no GitHub issue is created. A log breach is one hub notice, remembered in the board's band_notices table as a marker (<metric>:#<task>:log, checked against a fixed pattern) and a time, nothing else.
Design specs (#1097, #1102, #1107). hub_specs is read-only and refused during a private or PII turn. PII tasks and private turns publish no spec paths through task notices. spec_ack on hub_task_done is refused for private work and while specs review is off. Each id and reason must be public metadata, a withheld reason is refused, and the acknowledgements are recorded at the exact head. ahub specs stamp is person-only: refused for an agent identity and under --live-test, and refused without a terminal even with --yes. After the person answers, it rechecks HEAD, the state of each covered path and every target file's bytes, and any change refuses the whole run.
Declared task needs (#1081). A task's needs_network or needs_worktree is set only by an explicit true, never inferred from its text. Routing, assignment and accept keep such work from a peer whose capability the hub knows is missing (a hub-sandboxed Pi or local worker without local.bash_network, or one that cannot run git worktree add inside its sandbox, #270), and the accept refusal comes before any write, so no branch ref is left. An unknown capability is never a refusal, so this is advice for routing, not a sandbox boundary; the sandbox profile itself is unchanged.
Hub-made task worktree (#1115, #1129, src/hub/task-worktree.ts). For write-class work (implement, bulk_edit) and for any work that declares needs_worktree, the hub makes the worktree of a hub-sandboxed owner (Pi, the local worker) at the fixed root .worktrees/<peer>-<task> on a new branch task/<task>-<peer>, before the accept write, through the hardened git helper (hubGitSync, no hook, no helper, no prompt, repositoryIsolation). The base is origin/HEAD when the repository has one, else the main checkout's HEAD, with no fetch, and it is recorded in the hub's state dir (task-worktrees.json), which no sandbox root contains. A refused accept (not a git repository, the branch or path is taken, the repository's own configuration names a filter, include, textconv, ssh command or remote override) creates nothing and leaves no ref. That guard reads only what a repository or a peer can plant: the shared .git/config (git config --local, which a linked worktree reads too) and, where extensions.worktreeConfig is on, the worktree's config.worktree. The person's global and system git config is theirs and is not read by it, because git-lfs alone puts filter.lfs.* there on most machines and refusing it would switch the feature off for them; the hardened git helper's isolation still applies to that config. The sandbox profile is not rebuilt per task and not widened: test/task-worktree-sandbox.test.ts compares profile() before and after the hub makes a worktree.
What the sandbox can and cannot write is stated here so nobody relies on more. A sandboxed command CAN write files in the worktree and it can add NEW loose objects (#864). It CANNOT write a .git gitfile, config, config.worktree, hooks, commondir, packs or objects/info, and since #1123 it cannot write refs/**, packed-refs, HEAD or a worktree's admin dir (.git/worktrees/<name>/), so its own git commit or git branch fails (see "Refs, HEAD and worktree admin writes" below). The peer is also told not to run git. The hub does not rely on either: it never trusts what is under .git for this flow, whatever the profile of the day allows (the profile of #1115 alone did not refuse ref writes, and a rule can be loosened later). At hub_task_done it commits only when the worktree is still registered at its fixed path, HEAD is still the task branch, and the branch tip still equals the base the hub recorded (or the hub's own previous commit); a peer that moved the branch to another existing commit is refused with the cause and the next step, and nothing is committed or pushed. It then rebuilds the index from the recorded tip (the peer can write the worktree index), stages the working tree, and refuses the whole commit when any changed path is on the denylist or is a .git, .agenthub, agent-configuration or protected instruction name, or is a submodule, or when more than 512 paths changed. The commit is the hub's, in the peer's name, with --no-verify and no signing, and its message is the task's title. The push is one explicit refspec refs/heads/<branch>:refs/heads/<branch>, only after every origin URL names this project's GitHub repository, with followTags, mirror and submodule recursion off. Fetch and push stay with the hub. A retry after a failed push pushes the commit the hub already made and never makes a second one.
On decline or cancel the hub removes the worktree and deletes the branch while it is still the empty base. A worktree with uncommitted changes, or that cannot be checked, is never force-removed: it is kept with its branch and reported as a console notice. A branch the hub committed to is kept, because its commit may be pushed and under review. A peer that makes its own worktree (Claude, Codex, Kimi) is untouched, and a PII task never gets a hub worktree. Residual: a same-user process outside the sandbox can still plant a ref under .git/refs/heads; the sandbox no longer lets a sandboxed command do it (#1123), and the hub refuses to commit or push on top of a moved ref either way.
Worktree shelf (#1057). Apart from the empty hub-made task worktree above, the daemon never removes a worktree or deletes a branch; the shelf only reads and records the person's Park or Pin. ahub worktrees and the ahub worktrees tidy preview are reads an agent may run outside a private turn; Park, Pin, "Refresh PR states" and tidy --confirm are the person's (the console, or the dashboard session the manager forwards for it; a live-test dashboard is refused, and tidy --confirm is person-only for an agent identity and refused under --live-test). The only removal path is ahub worktrees tidy --confirm <fingerprint> in the person's own CLI process (src/cli/worktree-tidy.ts, which the daemon does not import). It re-reads every row and refuses the whole run when the fingerprint changed, operates only on a path git itself lists, and runs exactly git worktree remove <path> and git branch -d <branch>, never --force or -D. A row is refused when it is the main checkout, an instance worktree, outside the project, dirty, locked, without a branch, unpushed, without an upstream, not merged into the default branch, bound to a task that is not approved or cancelled, or on a branch that changed since the preview, and whenever git cannot tell; a parked row passes the same checks. No read makes a GitHub call; only the explicit refresh does, for at most 8 PRs. A worktree bound to a PII task keeps that binding on the host, so it stays live and tidy refuses it, but its row carries no task id, PR or task state.
Refs, HEAD and worktree admin writes (#1123). A hub-sandboxed peer (Pi, the local worker) cannot move what a branch, HEAD or a linked worktree points at. The profile (gitRefRules in src/local/deny.ts, built into hubWriteRegexes) refuses every write to refs/** (branches, tags, refs/stash, remote refs), packed-refs (and its lock and new files), HEAD (and HEAD.lock) and worktrees/** (a linked worktree's admin dir: its HEAD, index, logs, anything new, and a new admin dir). The rules sit directly under the git dir, so a project below a directory that happens to be named refs, worktrees, HEAD or packed-refs keeps working. They cover a plain .git, and each external git dir (the common dir and a linked worktree's own, whether the worktree is inside the project or beside it, and a bare repository's common dir that is not named .git). Every name is folded for the code points HFS+ ignores like the other git names, and each regex literal stays under seatbelt's 1024-byte limit at any project depth (test/sandbox-git-refs.test.ts, run with the real sandbox-exec in the main checkout, in a hub-made worktree, in a worktree outside its project and in a bare repository's worktree). The hub's own git is hardened and runs outside the sandbox, so #1115's hub commit and push are unaffected.
What a sandboxed command can still write under .git, each with its reason, probed with the real profile: the main checkout's index and index.lock (git add and git restore stage there), logs/HEAD, ORIG_HEAD, FETCH_HEAD, MERGE_HEAD, COMMIT_EDITMSG, info/exclude and description (none can run a program or move a branch), and NEW loose objects under objects/ (a staged blob, #864; existing objects, objects/info and objects/pack stay refused). Still refused as before: a gitfile, config, config.worktree, hooks, commondir (#270). Inside a linked worktree the sandbox cannot write the worktree's own admin dir, so git add there fails on its index.lock; the working tree itself is writable and git status, diff, log, show, blame, ls-files, rev-parse, for-each-ref and cat-file all still work.
What stops working in the sandbox, stated so nobody is surprised: git commit, branch, tag, stash, switch, checkout -b, merge, rebase, cherry-pick, revert, reset and update-ref, anywhere, and git add inside a linked worktree. A sandboxed peer's work is therefore committed by the hub: for every write-class task (implement, bulk_edit) it owns, and for any task that declares needs_worktree, the hub stages, commits and pushes the hub-made worktree at hub_task_done (#1115, #1129), whether or not the task was flagged. Read-only classes (test, summarize, triage, review, plan) and PII tasks get no worktree and no hub commit, because they commit nothing; a hub-sandboxed peer on a project where the hub cannot make a worktree is not offered write-class work at all, and the refusal names the cause (no worktree: ...). The local git tool says so in its result when a command is refused this way, and its pointer to hub_task_done is true for the work that can reach it.
Submodules (#1123, the person's ruling of 2026-11-03: deny .git writes, submodule git dirs included). Every DISCOVERED submodule git dir gets the same four refusals as the project's own: .git/modules/<name>, nested ones (.git/modules/<a>/modules/<b>) and a linked worktree's own (.git/worktrees/<wt>/modules/<name>, nested too) refuse refs/**, packed-refs*, HEAD* and worktrees/**, so a sandboxed git commit, branch, tag, stash or switch inside a submodule fails with no ref moved (test/sandbox-git-refs.test.ts runs commit, branch and tag in a submodule, a nested one and a linked worktree's, with the real sandbox-exec). A submodule git dir that is not discovered is refused whole by #518 and #520, so nothing is left open by not being found. What stays writable there is what a read needs and nothing that moves a ref or runs code: index and index.lock (git status refreshes it), logs/HEAD, ORIG_HEAD, MERGE_HEAD, COMMIT_EDITMSG and info/exclude (a test writes each in a submodule git dir), and new loose objects as for the project (#864; not separately tested for a submodule). The working tree of a submodule stays writable and its status, diff, log and show work. The .git writes that stay open (the main checkout's index, logs, ORIG_HEAD, MERGE_HEAD) can be used to corrupt local state the person later reads, not to run code or to move a ref. Consequence for work inside a submodule: the sandbox cannot commit there, and the hub's commit (#1115) is a commit of the project's own repository, which records a submodule only as a commit id. So edits made inside a submodule's working tree are not part of the hub's commit and stay uncommitted in that submodule, while a changed submodule pointer (a gitlink) makes the hub refuse the whole commit, naming the path. A task that edits inside a submodule therefore needs a person or a vendor agent (Claude, Codex, Kimi) to commit that part.
Campaign authority is the person's. ahub study draft|revise|approve|approve-protocol|close
are Console in classifyPeerCommand, and the daemon answers the study_* operations
to the console role only; ahub study show is the only form an agent shell may run.
Agents use hub tools instead: hub_study_draft and hub_study_revise take the
configured planner's planning authority and store drafts only, hub_study_show is
read-only for every connection, and hub_study_handoff takes only the current
in-progress owner of the bound unit task. No tool approves a campaign or a protocol,
closes a campaign or attests anything, and a unit's purpose or a research role
grants no authority: dispatch, ownership and review use the existing routing and
role admission.
- Approval creates work, nothing else does. Only the person's
ahub study approvecreates unit tasks, as Backlog cards the planner offers through its ordinary pull. The approval revalidates and screens the revision before a single transaction that rechecks it is still the latest draft; a changed campaign, task or dependency refuses whole. A campaign never assigns an owner or approves a task. - Dispatch is rechecked at the write. Before a unit task is offered, assigned,
pulled or handed to another owner, after any asynchronous screen, the hub rereads
the stored campaign and holds the task when the revision is not the latest
approved one, the deadline has passed or the stored stop policy is exceeded. A
contract's
budget.hardCapis checked against that exact budget id at approval and again at every dispatch and owner change; another budget covering the task proves nothing. A hold never stops a running native turn. - Provenance stays separate. Experiment declarations remain
declared. Separatehost-observedvalues come from a finished check receipt for the same task and commit, or from hashing a deliverable through the #509 path policy; an agent-supplied hash is never host evidence.ahub study approve-protocolbinds approval to one exact protocol hash and evaluator identity hash, and nothing copies it to a changed protocol. Environment descriptors are refused when they contain assignment forms, Bearer credentials or known secret shapes; that screen detects those forms only and does not prove a secret is absent. Signed external receipts use the separaterunner-attestedlevel described below; their fields never overwrite declared or host-measured evidence. Public experiment recording tools and the receipt CLI are the separate #602 slice. - Claim evidence links (#931 T1).
hub_evidence_recordis published only to research-profile peers. It admits only the current in-progress owner of a stored research task, outside a private turn, on a non-PII task and a registered public source whose raw hash matches. The checks repeat after screening and inside the append transaction. Its relation, projection hash and locator are declared provenance, never an entailment judgment or host verification; person verdicts stay the only judgment record. Receipts carry the link id, revision and task id only, and arguments and results are excluded from capture. - Privacy. A campaign whose parent task is PII, or that names a private source,
is private, and so is an attempt with a private task, input or output path.
Public views keep ids, counts, states and provenance and stub every question,
scope, text, path, excerpt and metric name as
[pii]; source text is never stored in a campaign or handoff, only source ids. Study tool arguments and results are excluded from hub-managed memory capture, and raw contract, handoff and attempt text never enters task history, notices or memory. - Recovery. A restart, upgrade or recovery onto a daemon lacking the matching
research_*feature is refused while campaign, dispatch, re-offer, experiment or comparison rows exist, or whileresearch.runnersis configured.
A runner registration is a trust root, not permission to execute a command. The
person registers an Ed25519 SPKI public key, allowed project ids and a revocation
flag under research.runners in untracked .agenthub/config.local.json. Shared,
tracked or untrusted sources refuse even an empty block. Whole-block structural
edits require the person's tier2 confirmation; the conductor cannot register a
key. The hub stores no private key, contacts no runner and schedules no compute.
Receipt admission verifies the domain-separated canonical body, signature, current accepted registration and exact project/attempt/protocol binding. A commit-based protocol also requires an explicitly supplied commit claim to match. The current owner or the person may relay a receipt; signature authority never bypasses current task, source, campaign, privacy or request authority. All checks are repeated after asynchronous screening before any write. An unknown/revoked key, foreign project, changed identity or tampered signature refuses before write.
Stored fields are runner-attested, including claimed timestamps, exit status,
image, network, resources and file hashes. Missing fields remain unknown. A signed
commit for a deliverable-based protocol stays an independent claim with unknown
code match. Sealing is attested only with distinct candidate/evaluator containers,
valid mount references and nonempty evaluator code/data mounts that are all
read-only and absent from candidates. Other receipts retain declared/unverified
sealing. This is a signed runner statement, not host observation or proof of
scientific correctness, isolation enforcement or evaluator secrecy.
An identical canonical receipt and signature with a known receipt id returns its stored proof without re-verification or writes, including after revocation. A changed body/signature at that id refuses; every new id needs a currently trusted key. Readback retains as-of-signing provenance and separately shows whether the signing key is active, revoked, replaced or unavailable now. Private records stub the signed fields, mount/output names, signer identity and key details.
The first stored attestation, its event and the hub.db format-2 marker commit in
one transaction; unused databases keep the format-1 baseline. Recovery requires
research_runners for stored receipts or raw/accepted registration presence,
including empty blocks. Its release-equality integrity digest covers stored proofs only and is present
only when receipts exist. Accepted registrations remain configuration, governed by
the feature floor and the config-reload path; pending edits do not change the
board-state equality required for release. Earlier research guards remain intact. Fixtures
use test key pairs with no GPU, runner network request or live account.
A handoff crosses a project boundary, but not a security boundary: both projects
belong to the same machine user. The origin daemon reads the target's control
token file and connects to the target's daemon as the console role through
ControlClient, like every control message; the hello checks the target's project
root and instance. It sends that connection only the four handoff messages, and
it has no reach a same-user process lacks (see "What is not defended").
- Who may hand off.
ahub task handoffis Console inclassifyPeerCommand; agents usehub_task_handoff, admitted for the person, a configured planner or the conductor only, and never for a remote room peer. The target is chosen only from the machine registry by canonical root or id, this project is refused, and the target's ownstatus.jsonmust advertisetask_handoffand name no other project. A target that is not running is refused with the action; nothing is queued. - No authority in the target. The target answers
task_handoff,task_handoff_event,task_handoff_by_keyandtask_handoff_statesto a console connection only, and refuses a new handoff while it recovers or shuts down. The task is an ownerless proposal its own hub makes and routes. The origin project and peer are a record intask_handoff_originand a line at the end of the detail, never an actor, a roster peer or a role: the origin cannot accept, review, approve, assign or cancel there. - What crosses. To the target: a title (300 characters), a class, a detail
(4,000), up to 16 repository-relative paths, a spec issue number, an optional
duplicate_okreason (300, kept in the target's detail), a handoff key (a UUID, sent only to a target that advertisestask_handoff_key), and the origin project id, peer id and task id, at most 8 KiB in all. The request is parsed closed on both sides: an unknown field, an accessor or a control character is refused, never dropped, and the target sanitizes before it stores. No memory, evidence, turn content or task history crosses. Back to the origin: an event name, a sequence number, a PR number and a full 40-hex head, never the target's title, notes or other text; a duplicate-issue refusal names only the target's own open non-PII task ids. The catch-up (at start, every five minutes and when a target's descriptor changes) and the key lookup answer only for tasks whose recorded origin is the asking project, with the same fields. A key the target already holds answers with the task it made and never files a second one; the same key with a different request is refused. The origin peer gets that as a hub-writtenkind: taskmessage. - Privacy. The origin refuses during a private turn and for a
[pii]task named as the source, before anything leaves. The text itself is checked by the target, against the target's PII patterns and, when the target's model screen is on, that screen under the target's routing; a match or an uncleared verdict refuses the whole handoff and nothing is stored on either board. The origin's own patterns are not applied to the text, so keep private text out of a handoff.hub_task_handoffandhub_task_handoffsarguments and results are excluded from hub-managed memory capture. - Recovery. A restart, upgrade or recovery onto a daemon lacking
task_handoffis refused while either handoff table holds a row.
hub_task_spec reads only the GitHub issues named by a current owner's or reviewer's task in this project's origin repository, including the validated canonical GitHub intake source when its title/body has no issue token. Bare #N references count only in the title; detail references require owner/repo#N or https://github.com/owner/repo/issues/N. Bare detail numbers denote hub tasks, and arbitrary file paths are not parsed as issue references. A qualified reference cannot begin after a slash or a name character, so src/hub/tasks.ts#12 is a file path. Routing uses this same parser. Without a GitHub project identity, bare title numbers do not require spec access, but explicit qualified/URL references, refs.repo and imported GitHub identity still fail closed. refs.repo, which hub_task_propose and hub_task_accept take since #476 to scope overlap per repository, is metadata only: it grants no file access or execution root, and a spec read for a task naming another repository refuses. The daemon resolves authenticated and container executables outside the project, pins their absolute paths and uses fixed arguments; repository-writable PATH shims are refused. Runner Git resolves its host executable through a restricted PATH. For specs, the model receives no credential or arbitrary authenticated URL capability. Foreign/transferred issues, more than three references, bodies over 64 KiB and aggregate results over 80 KiB fail explicitly. Spec fetches share a 25-second budget inside the control client's 30-second cap; each gh call remains bounded to 15 seconds. Current PII, secret and private-path policy withholds the complete spec rather than returning a partial contract. PII turns and PII tasks refuse these tools. The independent spec_read and check_run capabilities apply when a peer has a narrowed capability list. Arguments and results are excluded from capture. Proven deterministic pre-effect guards return PreEffectToolRefusal so Pi settles the exact refusal receipt; generic transport/storage failures and failures after durable receipt/output changes remain uncertain, never reclassified from error text.
hub_check_start accepts only a current owner's in-progress task, its recorded branch/commit and either typecheck or one to 32 existing test/*.test.ts paths. checks.runner = "container" is a person opt-in in an untracked config file; a tracked config cannot grant it. Missing service/image, an unverified internal network, stale task/owner/native generation, unavailable dependencies or an invalid path fails closed. There is no host-shell, network or Seatbelt fallback.
The runner uses Apple container, pinned Bun 1.4.2 and a verified hostOnly internal network with DNS disabled, two CPUs and 4 GiB. It starts a named, labeled VM with a bounded idle process, copies a hub-owned snapshot into the running VM, then invokes a fixed container exec check. This run/copy/exec sequence is required because Apple container copy rejects a stopped container. No host directory, home, credential directory, hub state or socket is mounted. Source blobs are read from the exact Git commit; symlinks and gitlinks anywhere in the source refuse the run before exclusions. Directory prefixes and filenames are checked for NFC/Unicode case-fold collisions before staging writes, including denied entries; folding must reach a fixed point within a bounded number of passes or the run refuses. This is a conservative collision screen, not a claim of exact parity with every host filesystem. Denied and PII paths are excluded and counted. Dependency files require an equal lockfile, bounded regular files/directories and internal relative symlinks. No dependency installation or Internet access occurs in the VM.
The optional remote backend (#793) selects checks.runner = "remote" and a strict checks.remote object containing host, digest-pinned image and absolute root. Both the grant and destination must come from an untracked config.local.json; a tracked or shared-file grant is refused. The destination is host-owned configuration, never a peer argument. OpenSSH uses BatchMode, disables TTY allocation (-T), agent/X11 forwarding (-a, -x), configured forwardings and local SSH commands, and retains only the hub-owned agent socket for local SSH authentication. It uses fixed Docker operations, and every remote argument is shell-quoted because OpenSSH joins the command before the remote shell interprets it. SSH diagnostics are discarded. Only actual check execution merges remote command stderr into screened output; readiness JSON keeps stdout separate from withheld diagnostics. Exit 255 discards stdout too; retained container stdout is screened and destination/root strings are replaced before receipt/output persistence. Public image identity is digest-only. Platform comes from inspected Linux image metadata and the actual fixed Bun probe architecture, with only a numeric Docker version prefix. No host, account or remote root is included in those projections.
Remote checks reuse the same request validation, exact Git snapshot, queue, authority fences, repository heavy lease, receipt retention and cleanup lifecycle. They stream a trusted host tar producer into docker exec -i --user 65532:65532 ... tar -xf - -C /work; no working-tree sync or Mac node_modules copy occurs. Darwin pins the system /usr/bin/tar regardless of PATH; Linux keeps the hub-owned executable resolver. The archive excludes extended attributes, macOS metadata and ACLs, and normalizes owner/group identifiers and names instead of carrying host identity. The staging parent remains mode 0700; snapshot directories are explicitly 0755 and files 0644 or Git-executable 0755 even under umask 077, so non-root execution can traverse and read the copy. Extraction does not restore archive ownership: the copied files belong to the container uid and may be changed only in its throwaway work tmpfs. No privileged chown or root extraction is used. Only the throwaway work tmpfs and home/temp tmpfs can be writable, never the host source. The tar producer is cancelled and reaped on failure or cancellation.
Docker checks use a non-root numeric uid, --network none, read-only root filesystem, home/temp/work tmpfs, all capabilities dropped, no-new-privileges and fixed pids, CPU (4) and memory (4 GiB) caps. No host path, socket, home or credential mount is allowed. A uniquely named/labeled run uses only a locally present image (--pull never). Removal is confirmed through project/name-filtered Docker inventory before the existing heavy lease is released. Inventory projects individual hub label fields instead of parsing Docker's comma-joined label display, and does not enumerate unrelated projects' containers. Readiness checks Docker reachability/version, the exact image digest and architecture, and a fixed hardened network-none probe that uses the same archive transfer and non-root readback as a run; any failed proof is remote runner unavailable, with no local fallback. Probe cleanup remains bounded and runs even after its admission signal is cancelled. Exact project/probe-UUID/name rows without a task run id are left to their fixed 600-second expiry and excluded from task-run orphan warnings. Malformed probe rows and unknown task-run owners still produce warnings; labels alone never authorize killing a probe.
Remote dependencies are prepared in a separate, admitted step inside the same run, never in the check (#793b). A named Docker volume holds node_modules; its name is agenthub-check-deps-<project key>-<32 hex>, the hash of the commit's bun.lock SHA-256, a digest of every other install input in the staged source (each package.json, bunfig.toml, .npmrc and patches/ file, by path and content), the Bun version and architecture the readiness probe observed in the pinned image, and the image digest, so a different lockfile, Bun, architecture or image never shares a volume. Because a root or workspace lifecycle script (preinstall, install, postinstall, prepublish, preprepare, prepare, postprepare) can build node_modules from tracked source the key does not cover, a commit whose package.json declares one, or whose manifest is not valid JSON, is refused before any volume is created; the install stays exactly bun install --frozen-lockfile and is never weakened with --ignore-scripts. The volume carries agenthub.deps.project and agenthub.deps.key labels, and an existing volume is used only when both match exactly: a same-named volume with other labels is refused and never mounted or removed. A missing volume is prepared: a root, capability-dropped, network-less container only changes the new volume's ownership to uid 65532, then one container, identical to a check container apart from a writable volume mount and a bridge network, runs exactly bun install --frozen-lockfile on the commit's own source (a source-only snapshot with no Mac node_modules) and writes a marker holding the key. That container is the only one in the whole backend with a network, and the only one that mounts the volume writable. Every prepared volume, new or existing, is then proven by a separate network-less container that mounts it read-only, reads the marker and confirms a write fails; a volume that does not verify is removed and prepared again, never repaired in place, and a failed preparation removes its candidate volume. The check container mounts only that volume, read-only, with --network none, and never installs. The preparation, ownership and verification containers reuse the run's name and labels, are removed and confirmed absent through the existing inventory before the next step, and are covered by the same cancellation, deadline (the ten-minute budget includes preparation), cleanup and orphan-sweep paths and the heavy lease. A missing or unprovable volume refuses the run with remote dependencies unavailable, never a writable shared cache and never a local fallback. After a verified volume, the project's own volumes beyond the newest three (the current one always kept) are removed, but only those whose name matches the exact pattern, whose project label is this project and whose key label is a 64-hex key that reproduces the name; a volume with an absent, malformed or inconsistent key label, another project's volume and any other name are neither counted nor touched. Install output is not retained or shown. The backend's root stays reserved, validated metadata, and no Docker command uses a host path. Fake transport and argv tests establish the contract, and a local Docker run of the production transport showed one network-attached install on the first run and none on the second (see the receipt). Actual machine opt-in, remote SSH and pi's live tools remain unverified (AC6).
Dependency copies detect observed inode, size and timestamp changes and refuse final-component symlinks using O_NOFOLLOW. A same-user writer can still exchange and restore a parent directory between observations; this is not an atomic immutable-dependency guarantee. Source commits remain immutable. Do not treat a dependency copy as protection against a hostile process running as the same host user.
Checks serialize through the repository's existing ahub-heavy.lock, with one active and at most two queued runs, a ten-minute execution ceiling after heavy-slot admission and a bounded heavy-slot wait. Container startup has a 120-second cap for cold VM boot, and copy has a 180-second cap for the bounded 512 MiB source plus 1 GiB dependency corpus. Each stage is clamped to the remaining ten-minute budget and uses the same cancellation signal; exec receives only the remaining budget. Network/probe commands retain a 15-second cap, also clamped and cancellable during an admitted run. Named-container cleanup has its separate bounded teardown commands and must still be confirmed before releasing the heavy slot. Queued cancellation settles its receipt and releases queue capacity immediately; active cancellation, timeout and shutdown stop only the named check container. Cleanup is confirmed through inventory before releasing the heavy slot; unknown cleanup keeps the slot and reports an error. Startup removes only this project's labeled containers whose signed recorded hub owner is confirmed gone. Unknown owners are reported for the person, never killed.
Only an unchanged task/owner/native-generation binding receives an agent result. Stale receipts are person-only. Public receipts include the exact source/tree/lockfile, runner/platform, command, exit and bounded output; they omit internal host paths, owner process identity and generation binding. The output tail is screened again before any tool response or hub envelope. For inactive valid receipts, local full output is bounded to 1 MiB and removed when the owner reads its result (person inspection does not consume it), after 24 hours or beyond the newest 20 outputs; at most 200 inactive valid receipts remain, with active runs excluded from retention. Unknown receipts are an explicit retention exception: malformed/unreadable receipt bytes, full output and source staging remain until ownership/inactivity can be established or the receipt is removed. Their ID-only warnings remain visible. Unknown payload maintenance performs no path-based deletion: a same-user process can swap a parent directory after an entry check, and no atomic descriptor-relative cleanup is available at this boundary. Maintenance never removes an unknown container. Removing the whole receipt directory counts as an empty record set, clears its ID warnings and allows ordinary expired unindexed-staging cleanup. Linux VM evidence is separate from macOS Seatbelt and managed-native acceptance.
Routing receives capability availability and its reason as pure inputs. Capability admission applies to task owners only: non-PII implement/test/bulk-edit owners require tests, and non-PII issue-backed owners in every class (including review-class owners) require spec access. Designated reviewers are not gated by these owner-only capabilities; roster, privacy, role and other eligibility rules still apply. PII tasks are exempt from these two capability requirements and retain the existing local-only owner and person reviewer rules; spec/check tools still refuse them. Explicit nominations and reservations cannot bypass these checks. Inactive sandboxed peers establish their GitHub probe at first spec use, and every explicit local/Pi start path refreshes it (including a prior authentication failure) with concurrent probes shared; host-provided embeddings can inject bounded probe implementations without implicitly contacting real accounts in fixtures. Neither path grants a model control over the probes.
The amem host screens the actual data that may persist, including literal keys/values and its JSON form. It decodes authoritative tool argument/result JSON once before any excerpt cap, so escaping cannot hide whitespace-matching PII, a private data path or a structured secret key/value. It does not recursively interpret arbitrary embedded encodings. Non-persisted host locators (cwd and transport/session identifiers) are kept outside text-policy content; current project/profile authority is checked separately. Ordinary events with a valid private-path policy still capture public data. Private turns remain excluded before this code runs.
Room mutations require a person console and refuse agent markers and --live-test.
One-use connection tickets let the CLI print both fingerprints before any room
install or invitation consumption. Execution then requires an explicit TTY y/N
(default No), or a matching 16..64 lowercase-hex member fingerprint prefix through
--confirm; the daemon verifies that confirmation against the ticket. Abort, EOF
and missing/mismatched confirmation install and consume nothing. Joining pins the owner from a private OOB
invitation; a broker-provided key or roster cannot establish that pin. The
invitation secret is sent only to the joining person, never to the broker. The
HMAC'd request and signed roster omit it and every private key.
Room identity, consumed invitations, owner/epoch pins and sequence/replay records are machine-local in a private SQLite store (0700 directory/0600 database), under exclusive process ownership. Restart never resets counters or extends the persisted 60-second adjacent-epoch grace. Clock rollback, unknown schema and corrupt state refuse. This is not protection against another process running as the same user. Machine-local exposure policy and the signed roster must both permit a presence id. Status/read output has ids, epochs and link states only. Presence contains no task text, titles or credentials; no chat or remote tool effect is enabled by X3.
Local leave retains pins and replay records; actual member revocation is the owner's signed epoch rotation. Broker storage ACKs never assert application completion. The live TLS/proxy/two-machine receipt and X4 envelope privacy boundary are separate obligations, not proved by the fake broker/hub integration tests.
Canonical refused or stale room frames are durably recorded as digest/fixed-reason/ timestamp diagnostics before a broker transport retirement ACK. This drops a poison queue head without accepting its body, advancing replay state or issuing an application-completion receipt. Unproven session/recipient authority or failed durable storage receives no ACK. Only bounded diagnostic records may be evicted; sequence/replay records retain their existing rules.
Room encryption authenticates a hub, not local role authority. Remote ids contain
@ and cannot connect to local control, own tasks, review, plan, conduct, start a
native runtime or become a recovery session. Incoming chat is explicitly directed
to a current exposed local native peer, framed as untrusted, and capped at status.
Since X6 (#992), a remote peer may also ask an exposed local peer to run one
capability that both persons pinned as read: the target's own invocation.share
entry and the source's exact peer@label entry. Task and approval refs, and write,
external or destructive requests, never cross. The target re-applies its schema,
digest, class and privacy gates before any native request, and an unknown handoff or
result is needs_review, never replayed.
The same live sender-exposure check applies to restored inboxes. A removed
sender's registered pending application is quarantined before its plaintext is
retired, so it remains available for person disposition.
roomBoundary is the outbound privacy owner. It snapshots inputs, drops refs,
refuses private/PII/unknown-authority contexts and rechecks after awaits. Host
private-turn provenance is retained at enqueue, so waiting offline cannot wash
it away. Screen failure sends only a fixed stub with a public application id.
Optional model screening uses the existing local/campus-only rule; unknown
never permits the body. Patterns/models do not prove that paraphrased text is
free of private meaning. Remote bodies enter no digest model, memory observation,
public event payload or hub log; the local delivery journal retains the evidence.
The local worker disables Capture and tool-body observation for an entire mixed
or remote-origin turn, and does not commit that transcript to reusable local
history. A later local-only turn therefore cannot capture the retained remote
input through that history. Single Claude channel pushes sanitize bodies just
as digests do.
Recipient room/fingerprint bindings survive queued, active and uncertain journal work. Label reuse cannot retarget old text, including an explicit retry after restart. X4 permits one remote destination per send and refuses multiple remote targets before any local effect; implicit broadcast never crosses the boundary.
Signed completion proves remote durable queue admission only and is displayed as completed (admitted). Crypto inbox, application claim and Bus publication use separate durable stages; a receiver cross-DB failure is uncertainty, never an exactly-once or native-execution claim. A late authenticated ordinary-chat receipt may settle its matching unresolved sender row under current room/member identity, without overriding a person disposition. Invocation uncertainty remains person-held and is never inferred from transport proof. Person accept/discard is local disposed metadata and never a signed application receipt.
Sender retries retain the application id, canonical content and original queue TTL. A distinct root delivery requires the same retained content hash and recipient, with no disposition or permanent hold; it inherits the original creation/expiry. Legacy unknown content hashes refuse cross-root reuse. Receipt ciphertext commits to the local durable outbox before chat transport ACK and replays until broker storage confirmation. Deferred admission may ACK its durable crypto inbox entry and commit its receipt when admission resumes. Neither path claims native execution.
Schema3 reserves a separate durable lane for at most eight canonical receipt
payloads per room, at most 1,024 bytes each and 512 KiB extra globally. A receipt
can take a reserved slot only after its authenticated sender and exact
frame/application/peer match this hub's outstanding outgoing row. Authenticated
junk cannot consume the reserve. Normal chat retains its 200-row/16 MiB ceiling;
presence, signatures and replay floors are unchanged. Settlement frees the slot,
and reopen validates the marker, payload and bounds, refusing an over-cap store.
Legacy rows migrate as ordinary rows, never with inferred receipt authority.
Only at the physical cap, a current authenticated member's non-matching receipt
is durably refused with invalid_frame and transport-ACKed without advancing
the floor. A matching receipt with a full reserve remains unACKed until a slot
frees. Neither path invents admission or a signed application outcome.
Fully validated presence at the ordinary cap is durably refused with
invalid_presence and transport-ACKed without a floor or state observation.
Capacity recovery requests fresh presence once per transition; runtime and
other storage errors stay unACKed.
Fresh, fully authenticated chat at the ordinary cap is likewise durably refused
and transport-ACKed without admission, floor advance or signed receipt. Its
sender retries at the ACK deadline under the same immutable application id, without
renewing its queue TTL. Queue expiry or a permanent refusal ends automatic sending.
The refused chat cannot hold matching receipt traffic behind it in the broker FIFO.
Permanent signed sequence floors survive all application retention/eviction. Application-id and trace guards retain 30 days/10,000 rows per room, settled first; outside that retention they make no historical claim. Automatic resend can reach this settled-eviction ceiling and repeat admission under a fresh ciphertext sequence: exactly once within dedupe ceiling. MAX_HOP 3 remains absolute. All-unsettled capacity refuses with ids/counts, and person disposition releases settled capacity. This state protects against other users, not hostile code running as the same user with access to the private room keys. Native/account, live TLS/proxy forwarding and X7 two-machine behavior remain unverified here.
An X4-only receiver is a one-way room endpoint for a person. Replies to
user@label receive an authenticated unsupported-person-inbox NACK, retained as a known terminal
failed_safe outcome, never silently retried or claimed as person admission.
X5b adds the separate durable inbox boundary below.
The console Rooms panel (8) and dashboard Agents > Rooms use only the canonical
room metadata projection: local name/id, epoch, owner/self fingerprints, member
count, link state and exposed peer ids/states, closed link cause and regenerated
next step, retained refusal counts, outbox depth and transport ages. No body,
title, arbitrary reason/error prose, broker URL, key material or delivery history
enters this projection. Reading it creates no room storage or keys; missing rooms
stay hidden and unavailable metadata stays explicitly unknown.
Relay counts are daemon-session observations, reset at restart: successful
outbound handoffs, confirmed inbound admissions and withheld handoffs as a subset
of outbound. Missing values remain null/unknown rather than zero. They neither
certify native completion nor reconstruct historical traffic. This display adds
no body delivery or authority and never reads X5b inbox bodies.
The only inbox-body output is the person's ahub room inbox show <id>, one
untrusted frame with hub-header sanitization and terminal controls stripped.
Inbox list is metadata-only, including --json; show/done/discard accept no JSON
output or fingerprint-confirmation flag. The RPC checks the console role; as
with X3, agent/conductor and --live-test refusal occurs at the CLI before it
connects. Console hello has no delegation marker, so the RPC does not separately
identify a raw console connection as live-test. Dashboard/status projections, logs,
memory, models and observers receive no body or title. Reads and dispositions
also refuse during recovery or a Bus hold in this first slice.
completed/inbox_admitted is an immutable receipt of durable inbox admission,
not a person-read or work-completion receipt. Done/discard/expiry purge the body
locally and retain only metadata; they neither resend nor revise admission.
Duplicate delivery cannot re-admit or increment inbound counts twice.
The private local store uses the existing protected directory/file modes, with
plaintext bodies and no added at-rest encryption or secure-erasure claim. Caps
are 100 unread messages per room, 64 KiB decoded body each and 4 MiB stored
canonical payload bytes globally, counting JSON overhead and escaping; metadata
bytes still means decoded UTF-8 body size; the unchanged entire-wire cap further limits sendable
content. Schema 4 binds the 30-day TTL to the first durable local crypto arrival.
Unknown-age legacy person traffic fails safely with inbox_age_unavailable,
without changing native peer admission. Settled stubs retain at most 1,000 per
room for 30 days after settlement, oldest first; unread rows and their admission
identities, and shared-cap unsettled identities, are protected. The loaded-state
30-second expiry hook covers left rooms without creating a store or keys.
Metadata list replies cap at 1,100 complete rows; larger global lists explicitly refuse and ask for a room filter, rather than truncating. Older X4 codecs reject the new fixed inbox receipt reasons, leaving uncertainty rather than claiming mixed-version delivery success. Live two-machine/account evidence remains separate.
Person disposition and actual TTL purge can wake existing X4 deferred traffic once, without a new outcome or receipt for the purged entry. Idle expiry ticks never poll plaintext; all resumed traffic retains the existing trust and role checks.
Indirect TTL cleanup retains one committed wake signal until the host consumes it; a rolled-back cleanup cannot create a signal. This adds no body to the wake path and no polling when no real cleanup occurred.
Done/discard acknowledgement precedes the background room retry. A network retry failure or socket loss during that later work cannot report a committed local disposition as failed.
hub_set_title (#1055) is offered only to fixed Pi/local definitions with a
hub-owned title slot. Publication grants no authority: the daemon checks the
current adapter/session, private turn, open PII ownership or review and public
metadata policy. An agent may write only while the native/current slot remains
unnamed. A person's console rename may replace it, but cannot bypass privacy.
The title and any unnamed display label are screened again when shown; unknown
screening is [private]. Title tool inputs/results are excluded from capture
and progress observations. No title crosses rooms, changes task/Git/instance
records, or causes an extra model call.
A fallback label is never persisted. Its original message has the existing
journal retention; the separate session-title-inputs.json stores only peer,
title-session, delivery and envelope IDs written by positive delivery
acknowledgement, never by a title tool. Restart resolves a still-retained
original only for the same title-session ID. A missing or different session,
invalid binding or pruned original yields no inherited label. Live status may
show it only to the console or configured conductor; other readers see
unnamed. Metadata screening does not grant audience membership. The label's
source note identifies incoming-message text and never attributes it as the
recipient peer's own speech. status.json, events, logs and memory never
receive a derived copy. Removed-instance and Local bindings are not retained.
ahub model set|clear|list is person-only: the daemon refuses a non-console
caller, so agent shells, --live-test and every hub-tool caller are refused,
list included, and a conductor role grants nothing. The dashboard path needs a
person settings session with preview and confirm. An override is desired
runtime state in hub.db (cleared by ahub reset), applies from the next
eligible turn and never changes a running turn; a stale callback cannot mark a
newer value or another adapter owner applied. Model ids are one bounded token
(letters, digits and ._:/@+-, at most 128 bytes, no leading dash), so an id
cannot inject a flag. The override grants no model, provider or account
authority, leaves the machine-local instances.*.model and *_cmd tier 3
values as they are, and an unsupported surface is refused without a restart.
A decision route with decision_backend = "adec" is off unless configured and
supports shadow only (enforce is refused at config load); the proposed pair
never replaces the heuristic model, effort or conversation. The endpoint must be
numeric HTTP loopback with path /v1/route, no userinfo, query or fragment; no
redirects, ambient proxies, credentials or browser headers are used, and the hub
never starts the service or reads its credentials. Evidence is a bounded 8 KiB
projection (a capped latest-user fragment, recent assistant and tool tails,
screened tool names and numeric dimensions) that passes the public-view pattern,
private-path and secret checks; tool arguments, system or developer instructions
and older history are never sent. Review-observer-excluded and remote turns skip
it. A PII or private turn needs positive onCampus() before any evidence is
built and then sends dimensions only with its privacy flags. Privacy, observer
and authority facts are rechecked across every await, a changed authority
refuses the stale turn, and route events keep closed metadata only, never
evidence, credentials or service error prose.
A stage route with classifier = "decision" (#1373) uses the same endpoint
rules and modes and asks only when its own score lies within ambiguous_band
(default 0.05, at most 0.5) of its threshold, once per user turn. The question
carries the route's closed numeric stage evidence (signals, dimensions and
scores) and the two configured tiers, never the conversation projection; a PII
or private turn sends only dimensions and needs a positive onCampus(). The
answer is recorded and changes no tier, model, effort or conversation. A held
answer is reused only while its privacy and observer facts still hold, and a
change across the request refuses the stale turn. The PII screen, assign() and
the escalation and advisor triggers never use it.
hub_visual stores a validated acon VisualPayload in hub.db under hub
admission ceilings (1 MiB serialized and per-field UTF-8 limits; oversized data is
refused, never truncated), screening every text field and record key before and
after sanitization. Peers and the bus get only a stub
([visual <id>] <kind>: <title or caption> and refs.visual); an explicit
remote recipient refuses the whole call and nothing crosses the room broker. Publication is refused in a
private turn or on a PII task, including when privacy changes during an awaited
image read, and current privacy gates hub_visual_show, snapshots and asset
reads later. Images go through the guarded deliverable reader: a public PNG, JPEG
or WebP of at most 8 MiB whose signature matches its extension, refusing SVG,
secret or PII paths, hub state, outside paths, links swapped during inspection
and two names for one file; paths are replaced with opaque asset ids, and each
asset read repeats the guard and hash check, with the acon provider binding the
project and instance. Raw tool input and results never reach observer capture,
even on refusal.
supervision.mode is machine-local and off by default: a committed
.agenthub/config.json cannot turn it on. shadow stores bounded proposals and
does nothing else (no inbox row, notice or turn); advisory may also show a
pending one in the console inbox. The queue holds at most 100 pending proposals,
three per task revision, and keeps 400 terminal rows for dedupe; one read
returns at most 50, and a reading past a bound is refused and counted. Rules read
only public structured facts (task history, the hub's own deliverable measurement
and declared-overlap rows), never notes, review findings, transcripts or file
bodies, and a private task is never proposed on, read or decided. Only a present
role on the task (its owner or reviewer, a configured planner, the conductor or
the person) reads or decides a proposal, and there is no supervisor role. An
accept runs the one existing action the proposal names through that action's own
authority, so an escalate is accepted only by the conductor or the person; a
refusal starts nothing. There is no automatic pause, retry, cancel, reroute,
approval or rollback, no resident worker and no timer, and an action whose
outcome the hub could not observe is recorded unknown and never retried.
ahub guidance create|list|show|withdraw and the matching hub tools record
immutable advisory role variants on one task. Writes follow task-role admission:
the person or the task's current rostered owner; an agent cannot write under
review, every actor is refused on an approved or cancelled task, a conductor
designation grants nothing, and the CLI refuses --live-test person promotion.
Private turns, review-observer input and PII tasks refuse every actor before any
id, count or body is read. Raw role snapshots and variant bytes stay in private
local hub tables, separate from the task, its history, public views, bus notices
and memory. Reads return a closed metadata projection and a screened section diff;
the rationale texts and experiment id are field-screened and shown as withheld
when they do not clear. Bounds are 8192 bytes per role file and 32768 bytes per
record. The tools' arguments and results never reach observer capture, and a
variant grants no activation, permission, role or probe.
ahub lineage show and hub_lineage_show read a bounded public metadata
neighborhood: at most 100 nodes, 200 edges and 256 KiB per page, with explicit
truncation. They expose no titles, prompts, paths, session ids or review bodies;
unsupported, missing and denied reads share one unavailable shape, and only an
authorized diagnostic operator sees a reason. ahub lineage link and
hub_lineage_link admit an inert declared reference through Tasks under the
task's current actor, revision, project and privacy; a stale or changed one
refuses without writing. A reference never fetches its producer or attests to a
runner outcome, and foreign project keys stay opaque. The acon console reads the
same page through a provider port with no new action.
hub_ask lets any peer put a structured question (one to four questions, two to eight options each) to the person. A question is not a permission: it is its own record in hub.db (questions), has its own control messages, and never expires as a denial.
- Who answers. Only the console role. The control message
answer { questionSet, answers, surface? }is refused, with a reply and no change to the row, for everytoolsorpeerconnection, the asking peer's own included. Nothing a peer writes counts as an answer: ahub_sendbody, a tool result, a file or an envelope that quotes one (the exact JSONhub_askreturns included) is text, andtest/questions-daemon.test.tspins that none of them changes a row. The answer envelope the peer receives is built by the hub (kind: "answer",from: "user"), so its header cannot be forged: every body goes throughsanitize(), andkind, never the sender, is what code keys on. - One terminal transition.
openbecomesanswered,superseded,cancelledorexpiredby one compare-and-set (UPDATE ... WHERE state = 'open'). Of any number of concurrent answers, stops and expiries exactly one wins, and every other caller is told the row's real state. A second answer is refused and publishes nothing. - Validation. An answer is checked against the stored row before anything is written: every question answered once, only offered option ids, one option unless
multi, free text only whereallowOtherand at most 500 characters. The refusal names the question by position and never echoes the offered or submitted text. Input is closed and bounded the same way (parseAsk). - Answering from acon (#1053 T5). The plugin accepts an
answeraction only when the console advertisesaction.metaand passes its ownmeta(via: "console", a verifiedidentityandnode), which the page cannot set. Without it, or with page-suppliedidentity,surface,questionSet,projectIdorinstanceIdfields, the action is refused before any request reaches the hub (test/questions-plugin.test.ts). The project and set come from the inbox item id the plugin built, never from the page. The plugin reaches the hub through a dedicated manager port (projectConsole(...).answer), not through the dashboard action whitelist, so the dashboard page ofahub ui --alland the manager'sui_actionwhitelist have noansweraction, and a page can never answer for the person. The hub re-validates every answer against the stored row. The verifiedidentitythe plugin read from the console's ownmetarides with the answer (answer { ..., surface: "acon", person }); the hub records it, one bounded line, on the answered row and on thequestionevent, and readspersononly for theaconsurface, so a terminal or console answer cannot claim another person. It is never question or answer text. The acon types of the form and of the submitted answer (ActionMeta,InboxVerb,VerbForm,InboxAnswers) are type-only imports from the exact-pinned@staix/agent-console/contractentry, never hand-copied.ahub answerwith any operand is refused inliveTestRefusal(a delegated--live-testrun is promoted to console identity, so without it an agent shell could answer its ownhub_askas the person); only the bare listing is allowed there. - Residual. The same as
permit: a vendor agent that can read the control token can act as the console (see "What is not defended"). This feature does not narrow that and does not widen it. - Privacy. Question and answer text live in
hub.dband in the envelope addressed to the asking peer, nowhere else: neverhub.log(anansweror question-notice envelope logs a fixed stub), neverevents.jsonl(thequestionevent carries the set id, peer, event name, counts, latency, surface and a closed reason), never the console tail or the dashboard feed (redactBusEventstubs the body), never memory capture (QUESTION_TOOLS) or the progress observer. A question asked in a PII turn, or about a PII task, is storedprivateand its late envelope is private too. The inbox title of a private question is a stub (<peer> asks a question); its text is only in the answer form, the person's own answer surface, and the status attention model, the stream line and the CLI audit carry ids only.test/questions-console-daemon.test.tspins these with a PII task, the status reply, the dashboard snapshot,hub.log,events.jsonland memory capture. - Not defended. A peer that is cancelled, superseded or restarted loses a pending question's wait, never its row, until the person or
questions.ttl_ssettles it. An answer a person gives is shown to the asking peer in full; do not ask the person for a secret.
A result is the record that a run the person started has settled. Reading one and marking it read are separate acts, and only the person makes the second.
- A receipt is the person's, on every surface.
result_seen,results(read-all,unread-last) and the dashboard'sresult_seen,results_read_allandresults_unread_lastwrite or delete a receipt, and each is refused unless the caller is the console role or a dashboard session the console opened: a peer's connection, ahub_sendbody, a tool result or an agent shell (ahub resultsis the person's command, and its receipt forms--read,--read-alland--unread-lastare inliveTestRefusal, so a delegated--live-testrun of them is refused too; the bare listing stays allowed there) is text and never reaches the store. A snapshot, a listing and the Results section's refresh never write one; a poll is not a person looking (test/results-dashboard-1054.test.ts). - From acon. The plugin accepts
result_seen,results_read_allandresults_unread_lastonly when the console advertisesaction.metaand passes its ownmeta(via: "console", a verifiedidentityandnode), which the page cannot set. Without it, or with page-suppliedidentity,surface,personor a completion key, the action is refused before any request reaches the hub (test/results-plugin-1054.test.ts). The project and the result come from the inbox item id the plugin built or, from the panel, from the project it is showing; acon'sseenAtandexplicitare accepted and dropped, because the hub stamps its own time. The write goes through a dedicated port (projectConsole(...).receipt) as a console-role control message on theaconsurface, never through the dashboard's forwarding list, and a plugin that is not given the person's meta cannot widen that. - A live-test dashboard gets no results and is refused every receipt, in two places that each hold alone: the page server's live-test allowlist and the daemon's own guard (
test/results-dashboard-1054.test.ts). - Residual. The same as
permit: a vendor agent that can read the control token can act as the console (see "What is not defended"), and so can write a receipt. This feature does not narrow that and does not widen it. A receipt is not an authority: it clears an unread need and nothing else. - Privacy. No prompt or reply text is stored: a completion row keeps envelope ids only. The one-line detail shown beside a result (the head of the starting prompt) is built when the person looks, from the envelope the bus still holds, after
sanitize(so it cannot forge a hub header) and the hub's secret, PII and private-path screens; it is thrown away after it is shown and is in nohub.dbrow,hub.logline,events.jsonlevent, status field or memory capture (canary tests:test/results-dashboard-1054.test.ts,test/completions-daemon.test.ts). A private run showsa private requestwithout its body being read, a head the screens refuse shows a withheld stub, and a request the bus no longer holds shows nothing. The title is built from the peer and the outcome, never from request text, and thecompletion_seenevent carries the id and the surface. - Not defended. The detail is the person's own request shown back to them on their own surfaces, so a request that holds something the screens do not recognise is shown to the person who typed it. Marking a result read does not mean the person read it: the hub takes the console's word, and acon's seeing rule (agent-console#27) is what makes that word mean the result was in view.
Person comment writes (review_comment_put, review_comment_delete, review_comment_send) are console-role control messages, and their actor is always the person: the conductor, a planner and every peer are refused. From acon they require the console's verified ActionMeta (via: "console", identity, node). Page-supplied author, meta, personMeta, at, state or sentAt fields are refused before any request, and delete and send also need acon's confirmation fingerprint. The standalone ahub ui page is read-only for comments.
- Material. Placement material comes only from guarded immutable Git snapshots at the task's recorded head (declared refs, plan paths, or same-head verified PR paths), bounded per operation to eight files and eight seconds; a sent package is at most 12,000 bytes.
review_blobrefuses a PII task, and periodic snapshots make no comment-material Git or GitHub read. PII tasks and private paths keep comments on the board and count-only in every public view. - Delivery. A sent package carries the host-only
refs.reviewPackagemarker: the intended owner gets the body, while logs, stream projections, incoming labels, memory capture and observers get an ids-only stub; review markers a peer supplies are discarded. Open sent comments, anchored findings and unsettled review deliveries keep the original recipient's responses out of shared memory and observers until resolution, dismissal, task close or explicit disposition (#1192, #1205). The marker grants no task, console or PII authority. - Retry and recovery. A send is never retried automatically, and a queued receipt proves publication, not that the owner read it. A restart, upgrade or recovery onto a daemon lacking
review_commentsis refused while anchored comments are stored.
Room health projects closed cause codes, retained refusal counts and transport
ages only. Raw store errors, broker URLs, bodies, titles and key material never
enter status. Codec advertisements are signed compatibility evidence scoped to
the current local session, source connection token and roster. Missing observation
is pending and permits no wire effect; only positively known legacy or unsupported
members receive an upgrade refusal. Pending sends retain their original queue
expiry and wake on a current member observation. Peer connection metadata is
compatibility evidence only, never an authorization source. The existing ephemeral
header remains exactly 1; an extra ignored codec header preserves released
broker behavior. A feature-presence variant reaches opted-in sessions only; legacy members retain
the unchanged presence payload. Codec support grants no exposure, role, privacy,
invocation or native permission. A storage-recovery wake retries deferred
admission under its existing guards and never replays uncertain native work.