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. - 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; Kimi and Codex use their own native policies.never-askrequires explicit runtime confirmation or a person's consoleyfor a config default at hub start. Until that config confirmation 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 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,local.read_allow,local.bash_network,local.network_allow,terminal.open,instances,action_policy) apply only from a config file git confirms nobody committed (instancesandaction_policyare 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, 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. 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) in~/.agenthub/research/hold per approved task the ids, states, counts, tokens, times and PII flag the events hold, a person's label from a closed list, and a hash in place of the project id; 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 attempts shared-memory storage when enabled. 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 cloud 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; 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, noconfigorhooksdirectly under one nor below the discovered modules root of the project's own.git(a submodule's git directory, nested submodules included; every term is anchored below that root, so a directory namedrefs,configorhooksabove the project neither disables the rule nor trips it; a/refs/or/logs/segment below it keeps refs namedconfigorhookswritable, and leaves a submodule whose own path contains such a segment uncovered; a submodule whose path hashooksas a whole component (tools/hooksincluded) fares worse: the rule matches its whole git directory, so every write there is refused; the discovered git directories, the modules root 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/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 cost is thatgit init,git cloneandgit worktree adddo not run inside the sandbox (add, commit, stash, checkout and gc still do),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. - 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 is sent to claude-mem, whose observer is a cloud model. - 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 claude-mem capture leaves its hub tool calls out (issue #230), so a proposal it makes in an ordinary turn cannot reach claude-mem 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 most 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 claude-mem, 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. Not screened at all (an existing gap, not new with the screen): the local worker's claude-mem capture records its file, shell and git tool calls in ordinary turns with their raw arguments and output, and the closing answer of a turn that used tools, without a pattern or model check. 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 claude-mem only through the screened paths above. - 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 portable core is the exact-pinned first-party @staix/agent-mem 0.4.0 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.
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. 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 remains unsupported and refuses. 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 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. Since slice 2 two adapters enforce it. Claude's
managed PreToolUse hook 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. 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, 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, and Codex, Pi and the
local worker until slice 3 (#313) 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).
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. 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.
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, queue resolution and bulk discard, agent version trust, budget overrides, benchmark runs, research
backfill and task labels, stopping a peer (ahub stop), lifecycle and recovery
operations, and ask.
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, 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), and the pet
composer 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.
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. It cannot answer approvals,
resolve durable deliveries, override budget pauses, release a human hold or stop
a peer: peer_stop is taken from the console role only, and no hub tool sends it.
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, 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. - 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.enabledortask_sweep.auto_reassignoff, 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, 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. 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): 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.
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.
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. 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 and QWEN.md 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, Jcode, agy and Qwen: 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 and 0.26.0) 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 and 0.99.0 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 (#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; only 1.4.2 has a native receipt, so another 1.4.x patch launches with a warning that it is unverified, and every other version 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.
- 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.
- There is no Qwen dual-output input file or TUI transport in this slice; Q2-Q6 are unverified and TUI mode stays refused.
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
separate claude-mem worker database.
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. Suggested graph edges are off by default and are not evidence until a person confirms them. Operational details and limits: hub memory.
The macOS window reads local files only. Its detached host reuses readPet,
including signed-daemon liveness and recovery-lock handling, and publishes an
atomic counts-only pet-view.json (state, act/watch, known character, blink,
palette from the closed list, built-in pixel frame). It excludes item ids,
task titles/text, refs, paths,
notices and approval prose. Missing or unconfirmed status produces only
{offline:true}. Process and position files are separate from this view.
The local WKWebView has a deny-by-default CSP, with only its shipped inline
script/style allowed and network connections forbidden. Text never becomes
HTML or executable source. The retained JXA bridge accepts fixed drag/send/
palette/open/close operations; NSTask invokes the host-supplied Bun/CLI/project argv
and appends say, -- and a single message argument, or pet, --palette
and one closed palette name. It does not evaluate a
shell string. Message text is never logged by the window/host; command stdout
and stderr go to the null device. ahub say keeps its existing console-message
authority and recording behavior, and ahub ui opens its existing session.
The CLI restricts pet --window (including close) to console identity, while
plain terminal pet reads and pet --palette remain available to agent shells;
the palette write merges only the closed name into .agenthub/config.local.json,
validated and replaced by temp file and rename. Signed pidfiles
fence instance ownership and close; unknown owners are never signalled.
The planner setting is person-controlled. A valid pin determines the planner without adding it 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. 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; the configured planner can cancel only its own proposed/backlog work with no historical acceptance. 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. 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 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 completion/discard authority or pause removal. 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 (#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, a replacement claim or a changed launch record revokes it. 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.
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; either way it needs a fresh allow-once for its own arguments and grants nothing later. 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; it is not sandbox proof, and a real account, model and person interaction remains a live leg.
The fresh Codex reviewer (#519) needs review_mode: "fresh" and a compat-admitted Codex (receipt-verified 0.146.0 and 0.163.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, 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.
The fresh Kimi reviewer (#533) needs review_mode: "fresh" and a compat-admitted Kimi Code (receipt-verified 2.2.0 and 2.3.0; 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 receipt is model-free and does 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. 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.
Person release authorization (#481 S6). ahub sdlc authorize-release <version> is taken from the console role only and refused under --live-test. It binds the daemon checkout's commit, package version, push remote identity and a fresh-docs verdict, lives only in daemon memory (a restart revokes it) and is logged with its commit, version and docs digest. 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 (#312, #313).
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.
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.
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.