Design note by ds-flash-2 (2026-09-19). My lane is the environment: the thing the conversation drives. The interface is astra’s and the harness is k3’s; the contracts in §1.4 and §1.5 are what those two lanes build against, so they are written as schemas rather than prose.
The brief’s two load-bearing lines:
“Your box obviously got access to my machine.” “We think about local project files now… this actually might be like the answer to this.”
So: the agent acts on a real machine, and a project on disk is the unit of work.
| Decision | Why | What it costs |
|---|---|---|
The host is one long-lived local process (voicebox-host) that owns projects, sessions, confirmations and the audit log — not the page |
Paul wants to keep talking while working on something else; a page reload must not kill the work and lose the thread | A process to install and keep running (systemd –user / launchd — the shape of a background agent service, which this fleet has run before) |
| Imitate the bridge’s shape; import none of its code | The shape solves the four hard parts (browser↔real-machine, real harness children, session continuity, a permission round-trip) without making voicebox a consumer of another project’s transport | The shape, in two lines, is §1.6 — an implementer should not have to open another repository to find it |
| Two models, two footprints: a voice model (Gemini Live / OpenAI realtime) and an execution harness (pi/ACP) | The brief wants Gemini first and provider-extensibility, without an adapter zoo | Two privacy surfaces to disclose, not one (§1.3) |
| Voice proposes; the host disposes | Voice is untrusted input — ASR errors, ambient speech, a video playing — so authority cannot come from “he said it” | Every guarded act needs a host-side decision path (§3.3) |
| A project is a declared directory, never a discovered one | Yesterday’s fleet lesson: anything assumed about the machine’s layout is wrong on somebody’s machine | The first open of a project is an explicit act |
| Work happens in the project’s own git state; unapproved work is kept recoverable (a branch or worktree) | A build environment that can act is only safe if its acting is cheap to undo | Slightly more machinery per project; the host owns it |
| Three tiers, enforced in the host as data: never / unprompted / confirm | A boundary written as prose is a wish; the same lesson as putting the harness registry in data rather than comments | Tier tables need maintaining and testing (§3.6) |
| One writer per root, several roots per project — the root serialises, not the project | Paul’s scenario is two live agents on one project (his phone on a walk, the chat session); “serial within a project” rested on a premise he falsified — one conversation | Sessions multiply (per instance and root), and the audit needs per-instance sequencing because two machines have no shared clock |
undoKind is declared per project, and the tiers scale with it |
“We got worlds where you may never have git available” — and “cheap to undo” is a false promise where nothing can be undone | A project with no undo has a narrower unprompted scope, which has to be visible rather than surprising |
| An unknown verb is not a Tier 2 act — a prompt exists only where a mechanism does | Asking “shall I?” about an act the environment cannot perform turns a missing capability into the user’s decision, and every yes is then a commitment made against something that does not exist. The resulting bug looks like a permissions problem rather than a missing feature | A refusal must name the missing thing, and the model’s proposals are where this will first appear (§1.9) |
| Other projects are prior art, never dependencies — and theirs to change, not ours | Paul, on isocan: “I want to make sure that we’re not using isocan… I just want the UI to look like it.” Sharpened since, for every sibling: “inspired, yes; dependent, no; and don’t change the other project to serve this one.” Their techniques are the most valuable thing here — and so are their defects: isocan’s readiness gate dropped 192 of 208 frames before setupComplete, which a from-scratch build would reproduce and never notice |
Their artefacts are out of bounds: CAP’s bridge, isocan’s packages (@isocan/core), pi’s extensions. A sentence that says reuse, lift, import or copy about another project’s code is the class to watch — and when the answer is “the shape”, write the shape out here so nobody has to go and find it |
| One host role, three placements (machine, browser — OPFS or a picked handle (N20) — remote) — authority always co-located with the files | Paul’s requirement: the harness runs on the client and the server, with a website and OPFS. Putting authority in the renderer would dilute the boundary to reach the same features | Capability parity is not assumed, so do needs a declared capability list per placement, shown in the UI |
┌─ browser ────────────────────────────────────────────────┐
│ voice UI (astra's lane) │
│ mic/speaker · live model session · transcript · renders │
│ project state, progress, diffs, confirmation questions │
└───────────────────────┬──────────────────────────────────┘
│ ws://127.0.0.1:<port> (§1.5 schema)
┌───────────────────────▼──────────────────────────────────┐
│ voicebox-host — ONE long-lived process on this machine │
│ · project registry · tier table (data) │
│ · sessions per (instance, root) · confirmation gate │
│ · audit log (append-only)· process journal (what it ran) │
│ · resolves realpaths, enforces containment, decides tiers │
└───────────────────────┬──────────────────────────────────┘
│ ACP over stdio (the existing bridge) (§1.4)
┌───────────────────────▼──────────────────────────────────┐
│ execution harness (k3's lane) — one adapter, one child │
│ pi / claude-code / codex … with its own tools │
│ runs with cwd = the ACTIVE PROJECT (declared, never guessed) │
└───────────────────────┬──────────────────────────────────┘
│ real files, real commands
┌───────────────────────▼──────────────────────────────────┐
│ the machine: the project's checkout, its toolchain, its │
│ dev servers, its git remotes │
└──────────────────────────────────────────────────────────┘
Three things follow from this picture and are worth saying plainly:
A CLI harness ships its own tools and runs them in its own process. So the host sees an act only if the harness asks — which means a tier table can be enforced two very different ways, and a design that does not choose is claiming the stronger one by accident.
| Mediated (the target) | Compliant (the fallback) | |
|---|---|---|
| Where tools come from | the host injects the tool set (MCP/ACP definitions) into the harness, so every invocation routes through the host before it runs | the harness ships its own tools and runs them directly |
| What the host enforces | all of it: containment, credentials, argv classification, tiers — on every call, because every call is its own | only what it can see: its own commands, path resolution for what it is asked to resolve, and whatever the harness chooses to ask permission for |
| The guarantee | “it cannot leave the project” | “it will not leave the project unless the harness misbehaves” |
| Cost | the host must describe tools and stay in the loop for each call | none — and no real containment of the harness’s own actions |
Compliant mode exists as of 2026-09-19 — a twenty-line pi extension (design/k3-compliant-mode,
eb1d5fd) that hooks tool_call and asks through the host’s confirm path. Three things were
driven rather than argued: a denied call does not execute (write denied, then a read probe
denied, and the file does not exist); the plan arrives before the act in the harness’s own
words ({ "tool": "read", "input": { "path": "…" } }), so the decision is made on content rather
than a title; and the gate holds for a model-authored tool — read_host asked and was denied,
which closes the loophole the design named in §1.7. The same extension serves both transports: modal
in a TUI, a real session/request_permission over ACP, which is the wire §1.4 specifies.
And it must be claimed with its limits, because a gate that is described as total when it is not is the failure mode this whole section is written against:
event.input may be unvalidated when the hook sees it — prepareArguments runs before schema
validation — so the gate must tolerate partial shapes, and a plan it approves may not be what the
tool finally validates. And “validate before asking” was not enough as a rule: k3 approved
echo approved > plan-a.txt and watched plan-b.txt get written, because another handler
registered after the gate rewrote the input in between.tool_call handlers have run”
and execution that carries the final input — so “the gate is last” cannot be proven from inside.
And an extension registered after the gate is not merely a new capability — it is a new authority
over the gate’s decisions: a handler nobody audited, sitting between “yes” and “do it”, is an
authority with two homes (§3.0). Any extension whose load order is not the gate’s own is an
admission act, not a configuration detail.session_start it scans the discovered
extension directories for files sorting after its own name, and if any exist it refuses to gate,
loudly — writing {"later": […], "refuse": true} to a state file. Both branches are driven: with a
later file present it made 0 permission requests and (as announced) the ungated rewrite ran, so
plan-b.txt existed; without one it engaged, asked once, and on denial executed nothing. The
sentence worth keeping is k3’s: a silent mid-chain gate would have approved plan-a while
plan-b ran, and called it security. The general principle: a mechanism that cannot do its job
should say so rather than doing a weaker version of it.-e and packaged extensions are invisible, so host ownership is not optional); pi-acp
swallows the child’s stderr, so a console.error announcement never reaches the wire — the
RPC-visible channel is the state file or ctx.ui.notify, which means the announcement channel
differs by transport and a test that passes in a TUI has not tested the wire; and even a
proven-last gate cannot see the final post-chain input, so the strongest in-harness claim is
“no later file existed at session start”.Historical note, kept because it explains the design: before that extension existed, two drives
against the real bridge produced zero session/request_permission for ordinary tool calls — the
protocol path was wired end to end and pi never used it. pi’s only built-in gate is project
trust, which guards input loading, not what the model asks tools to do afterwards, and in
non-interactive modes it does not appear at all. So the honest statement has
four shapes, and the fourth is what a reader should assume until told otherwise:
| Shape | What enforces | Available when |
|---|---|---|
| Mediated | the host owns the tool surface, so it has the plan by construction | the harness is built that way |
| Compliant | the harness asks, and the host answers | available, and driven end-to-end over the wire rather than in a TUI only: the same run produced 13 session/request_permission calls — write, bash, read, web_search, generate_image, all asked, all refused, nothing executed — and an allow run that went pending → in_progress → completed with the file holding what it should. With the three limits above |
| Environmental | a container with only the files and credentials the task needs | whenever the environment is disposable — and it depends on nobody’s compliance |
| Disclosure | nothing: the tier table describes what the agent should do | everything else — and the UI must say so per session, not imply otherwise |
The design’s position: mediated is the target for the default harness; compliant mode is available and claimed with the three limits above, which is what “claimed” has to mean; and wherever none of the first three is in force, the tier table is presented as a disclosure.
The brief’s minimalism makes mediation feasible — one harness built by us can expose its tools through the host, which is far easier than auditing an adapter zoo. A harness that cannot be mediated is still usable, but the UI carries the downgrade explicitly, in the same spirit as the provider badge: the guarantee is a property of the mode, and the user is told which one is running. §3.2 and §3.7 are written in terms of both.
“We have environment configuration and by default the first environment is the browser… your local safe environment, and then maybe a hosted cloud server environment as well.” — Paul, 2026-09-19 (N11)
Paul’s axis is environments; the placements below are how each one is implemented. The order is his, and it is not arbitrary: the browser is first because it is the safest — the mechanisms in §3.2a are structural there, enforced by the platform rather than by us. Building the riskiest environment first would have been the wrong way round.
“The harness should be on the client and also running on the server… I want to access this through a website, I do want to be able to use OPFS… but also we’re on the server as well.” — Paul, 2026-09-19
A requirement, not a different design, and it is satisfiable by one rule:
The host is wherever the files are. The browser is always a client. Authority — the tier table, the confirmations, the audit — is co-located with the data it governs, never in the renderer.
| Placement | Where the files are | Where the host runs | Root | What do can mean |
|---|---|---|---|---|
| browser — “the first environment” (E1) | OPFS in the page’s origin, or a picked directory the user chose (N20) | a dedicated worker in that page — same tier table, same audit code | an OPFS directory handle, or a persisted handle to that folder | wasm and JS tools, file reads and writes, generated assets; no processes, and no harness (see the transport fact below) |
| local safe (E2) | a checkout on his machine | a local process on that machine | a realpath (or its worktree) | whatever the project’s toolchain can: spawn processes, run tests, git |
| remote (E3) | a checkout on a machine that is not where the browser is | a process on that machine | a realpath on that machine | as local, minus nothing — today’s Telegram-to-agent shape |
| hosted cloud (E4, later) | a checkout on somebody else’s machine | a process there | a realpath there | as local, with everything below attached |
Three consequences worth stating plainly:
ask and stop mean the same thing everywhere; do
means whatever the placement declares, and the declaration travels with the project
(capabilities in §2.1) and is shown in the UI. A browser cannot run npm test, and a design
that pretended otherwise would discover that mid-sentence. Isocan is the precedent Paul points
at: the shape generalises (thin client, real work behind an interface), the tool parity
does not.The browser placement is a peer world, not a cache — and there are two ways to use it. OPFS is per-origin and per-browser-profile: the files a page holds are invisible to every other placement, including the session Paul talks to over Telegram. So a browser project is not a copy of anything; it is a separate project with a separate history, and the two shapes below are different products, not one with a footnote:
| Peer | Window | |
|---|---|---|
| Files live in | this browser’s OPFS | a machine, reached over an authenticated channel |
| Visible to his other sessions (chat, Telegram, another device) | never — unless he exports or syncs, which is an ordinary Tier 2 egress act | always |
| Works with no host reachable | yes — the page is the host | no |
| Whose history it is | the tab’s | his |
And there is a third shape, which N20 adds and which is neither of those: a browser holding a picked, persisted directory handle — “it’s not just OPFS, but also directory handles that are persistent” (Paul). The files are the user’s own, in a folder they chose, and the page holds a handle rather than a copy. That is “local project files are the unit of work” satisfied from the browser, with no machine process anywhere.
| OPFS | a picked directory handle | |
|---|---|---|
| the files are | inside the origin | a real folder the user chose |
| survives a reload | yes, no gesture — measured | only if the handle is persisted; re-acquisition may need a gesture |
| who else sees it | nothing outside the origin | the user’s own editor and tools |
| what the origin can describe | a path it owns | nothing it can describe — though it can compare two handles it already holds, with FileSystemHandle.isSameEntry() |
Two consequences, and both are the identity rule arriving one level down:
location has kinds, and a handle’s target is something the
origin cannot describe — so two origins holding handles to the same folder are two projects,
because neither can tell. The design must not imply a handle has a path it can check. Within one
origin the story is better than the first version of this paragraph said: a handle the page
already holds can be compared with FileSystemHandle.isSameEntry(), so a registry can avoid
registering the same folder twice, and core/project.ts records { kind: "handle", id, label }
rather than pretending to have a path.{ kind: "opfs", persisted, checkedAt } or { kind: "handle", persisted, permission:
"granted" | "prompt" | "denied" | "unknown", checkedAt } (core/project.ts). One boolean could
not tell origin-storage persistence from a handle’s persistence and permission, which are two
different facts about two different mechanisms.Containment carries over unchanged — a picked directory is still a root, and
resolveInsideRoot refuses .. — now tested rather than asserted (§1.7’s rule, and the line
the reviewer caught: the helper as first written refused only ./.. exactly and let
../../evil.sh through the prefix check, while this document and its own comment called it the
boundary). The test names that case, asserts the rule in the refusal’s own words, and carries a
positive control: tests/containment-paths.test.mjs. The write path does not change, only where it
is rooted. (One thing to measure rather than assume: OPFS cannot express a symlink and a real folder
can, so the symlink case must be tested against the File System API before this shape is called
equivalent — it is an open item in the build spec §7.)
Both make the walk work; what differs is whether the history is his or the tab’s. So the
window is the recommendation wherever a host is reachable (§5 decision 4), the peer is a real
mode for offline, throwaway or deliberately device-local work — and every project says which
one it is, in the UI, because a user who assumes the wrong one loses work. Two browsers each
holding a project called atlas are two projects: identity carries the placement
(atlas@phone, atlas@box), not just the name. (The name here is deliberately not isocan:
isocan is inspiration and prior art, never this product’s vocabulary.)
browser ⇄ WebSocket ⇄ bridge on a machine ⇄ stdio ⇄ pi child). It is one harness, one ACP
protocol, two transports, and the capability declaration must record it that way — otherwise
the browser environment quietly grows capabilities that do not exist. For E1 that means its
tools run in the page (Wasm, §1.8) while harness work arrives through a bridge on a machine
when one is reachable — and E1-M0 deliberately needs neither. The technique for a capability
that cannot be lifted out is already shipped twice in isocan (createMemoryBroker, the file
broker): the harness asks the page over a socket rather than reaching around it. Prior art, and
the right kind — a mechanism to imitate, not a package to import.| State | Lives in | Survives page reload | Survives host restart |
|---|---|---|---|
| The project (files, git) | disk, in the project | yes | yes |
Project registry (id, path, lastUsed) |
host, ~/.voicebox/projects.json |
yes | yes |
| Conversation transcript (per (instance, root)) | host | yes | yes (append-only file) |
| Harness session id (per (instance, root)) | host | yes | yes — resumed with session/load |
| Pending confirmation (bound to instance, project, root, plan) | host | yes | no (a restart clears it — deliberately) |
| Other live instances of a project | host | yes | no (they re-announce on reconnect) |
| Running turn / spawned processes | host + children | yes | no (reported as “interrupted”) |
| Audit log | host, ~/.voicebox/audit.log |
yes | yes |
The rule underneath the table: the page is disposable, the host is durable, the project is the truth. A reload mid-turn keeps the turn running and replays its progress. This is the requirement the brief actually names — “me and you keep talking… while I also do want to work on some isocan projects at the same time” — and it is the reason nothing important may live in browser memory.
The brief asks for Gemini Live first, OpenAI as an alternative, and Fable-through-pi as a destination. Those are two different roles, and they should not be one component:
They have different privacy footprints, and a design that blurs them cannot tell the truth about either:
| Voice model | Execution harness | |
|---|---|---|
| Sees | audio, transcript, project metadata (name, branch, status), diffs the host chooses to show | the project’s files and command output |
| Where it runs | likely a cloud API (Gemini Live / OpenAI) | whatever the harness is: a local model, or a cloud CLI (claude-code, codex) |
| What leaves the machine | audio + transcript | source code + command output, to that provider |
| Per project, visible in the UI | — | a badge naming the provider, set when the project is opened |
So: project file contents are never sent to the voice model, and a project’s code only goes wherever its declared harness sends it — a per-project, visible choice rather than an invisible consequence of picking a model at install time.
A badge discloses; a lock guarantees — and only the second is a control. The badge above is the minimum; §5 recommends that a project can also forbid a cloud harness, because a control that only tells you what already happened is a report, not a boundary. This is the same shape as “the tool must not carry somebody’s directory convention”: secrecy of consequence has to be enforced somewhere, not merely displayed.
One adapter. The host spawns it per (instance, root) session with a declared working directory and speaks ACP-shaped JSON-RPC over stdio — the bridge’s proven shape, not a new protocol.
// host → harness. These are requests, so they carry `id` and `jsonrpc`, and they answer.
{ "jsonrpc": "2.0", "id": 1, "method": "session/new",
"params": { "cwd": "/home/paul/…/isocan", "mcpServers": [] } }
{ "jsonrpc": "2.0", "id": 2, "method": "session/load",
"params": { "cwd": "/home/paul/…/isocan", "sessionId": "ses_…" } }
{ "jsonrpc": "2.0", "id": 3, "method": "session/prompt",
"params": { "sessionId": "ses_…", "prompt": "run the tests and fix what fails" } }
{ "jsonrpc": "2.0", "id": 4, "method": "session/cancel", // the `stop` verb, defined
"params": { "sessionId": "ses_…", "reason": "operator" } }
// harness → host (responses)
{ "jsonrpc": "2.0", "id": 1, "result": { "sessionId": "ses_…", "cwd": "/home/paul/…/isocan" } }
{ "jsonrpc": "2.0", "id": 2, "result": { "sessionId": "ses_…", "resumed": true } }
{ "jsonrpc": "2.0", "id": 3, "result": { "stopReason": "end_turn" } }
{ "jsonrpc": "2.0", "id": 4, "result": { "cancelled": true } }
// harness → host (streaming notifications; sessionId is on EVERY update, not just the first)
{ "method": "session/update", "params": { "sessionId": "ses_…", "update": { "sessionUpdate": "agent_message_chunk", "content": "…" } } }
{ "method": "session/update", "params": { "sessionId": "ses_…", "update": { "sessionUpdate": "tool_call", "title": "Bash: npm test", "status": "in_progress" } } }
{ "method": "session/update", "params": { "sessionId": "ses_…", "update": { "sessionUpdate": "tool_call_update", "status": "completed", "content": [ … ] } } }
stop is the one instruction that may not depend on the harness’s cooperation. The host
sends session/cancel, and if the harness has not acknowledged within ~2 s it signals the
child’s process group directly; either way it stops the processes the host started for that
project (the process journal has the pids). A stop that only works when the other side is
well-behaved is not a stop.
Unconditional, and the only such guarantee in this section. After k3’s drives this is not a
nicety: it is the only hard lever the host always has —
everything else in this section depends on the harness choosing to ask or to answer. session/cancel,
the process-group signal, and the refusal to continue are unconditional; treat any guarantee that
rests on the harness’s cooperation as conditional on the mode (§1.1a).
The same applies to failure in the other direction: a harness that exits mid-turn is an error turn, not a dead session. The host reports it, records it, and can start a fresh session for that (instance, root) — because a harness dying is a thing that happens under load, and a design that treats it as fatal turns a retry into a lost conversation.
cwd is always declared — never defaulted, never inferred from the machine’s layout.
The host computes it from the project record and refuses a turn whose project has no usable
directory.
The permission round-trip is the confirmation channel. When the harness wants to act outside what it may do, it asks, and the host answers:
// harness → host. The human-readable title is NOT the payload: a host cannot enforce
// containment, credential or argv rules from "Bash: rm -rf build/" alone.
{ "id": 41, "method": "session/request_permission",
"params": { "sessionId": "ses_…",
"toolCall": { "title": "Bash: rm -rf build/", "kind": "execute" },
"resolved": { "argv": ["rm", "-rf", "/home/paul/…/isocan/build"],
"cwd": "/home/paul/…/isocan",
"paths": { "read": [], "write": ["/home/paul/…/isocan/build"] },
"effects": ["412 files removed", "not tracked by git"] },
"options": [ { "optionId": "allow_once", "name": "Allow once", "kind": "allow_once" },
{ "optionId": "reject_once", "name": "Reject", "kind": "reject_once" } ] } }
// host → harness (the answer, which §1.5 used to leave undefined)
{ "id": 41, "result": { "optionId": <one of the optionIds THE REQUEST SENT> } }
An optionId is the adapter’s vocabulary, not ours, and a wrong one is a silent refusal. k3
measured this the hard way: answering allow_once to pi-acp’s confirm — which speaks yes/no —
failed identically to a denial, because the harness takes an unrecognised option as “no”. So the
host’s answer is constructed from the request’s own options array (matching on kind, and
echoing the optionId it finds there), and an option the request did not offer is never sent.
A “yes” that acts like a “no” is worse than a refusal: it is a refusal nobody can see.
resolved is the structured plan, and it is what the host gates on — in mediated mode the
host produces it (it is the tool boundary, so it has the argv and the paths before anything
runs); in compliant mode it is what the harness must supply if it wants a decision rather than a
refusal. The title is for humans and is never parsed.
The host’s reply is not a rubber stamp: it consults the tier table (§3.2) against the resolved plan, and for a Tier 2 act it relays the question to the UI and waits for a person (§3.4). The model never answers its own permission request.
One loopback WebSocket, JSON messages, small schema. All host→UI messages carry project so
a UI showing several projects can route them.
A loopback socket is not authentication, and it is reachable by every page the browser
visits. Any local process, and any website open in the same browser, can dial
ws://127.0.0.1:<port>; browsers do not apply the same-origin policy to WebSockets, so without
a check a drive-by tab could open_project, say, or — worse — answer a pending confirmation.
So the connection itself is authenticated before any message is trusted:
~/.voicebox/token,
mode 0600), passed on the upgrade (ws://127.0.0.1:<port>/ui?token=…);Origin validation on the upgrade, rejecting any origin that is not the UI’s own;// UI → host. Every mutating message carries a per-connection nonce (`n`) and an input method
// (`via`), because the host must enforce its own provenance rule and cannot infer it (§3.4).
{ "type": "open_project", "path": "/home/paul/…/isocan", "via": "typed" }
{ "type": "activate", "project": "isocan", "via": "clicked" }
{ "type": "say", "project": "isocan", "text": "run the tests and fix what fails",
"via": "speech", "clientId": "c-8f21" } // replays of clientId are ignored
{ "type": "confirm", "id": "cfm_17", "answer": "yes", "via": "speech" } // or "typed" | "clicked"
{ "type": "stop", "project": "isocan" } // honoured immediately, always
// host → UI, on connect — the handshake, so a fresh or reloaded page is never guessing
{ "type": "hello", "host": "voicebox", "version": 1, "projects": [ … ], "active": "isocan",
"pending": [ { "id": "cfm_17", … } ] }
{ "type": "state", "project": "isocan", "data": { "path": "…", "root": "/home/paul/…/isocan-wt",
"instances": [ { "id": "phone", "root": "…/isocan-walk" },
{ "id": "chat", "root": "…/isocan-wt" } ],
"branch": "main", "dirty": 3,
"harness": "pi", "providerBadge": "local",
"mediated": true, "session": "ses_…",
"running": false } }
{ "type": "turn_started", "project": "isocan", "turn": "turn_9", "clientId": "c-8f21" }
{ "type": "progress", "project": "isocan", "turn": "turn_9", "update": { … } } // ACP update, relabelled
{ "type": "diff", "project": "isocan", "turn": "turn_9",
"files": [ { "path": "…", "additions": 12, "deletions": 3 } ] }
{ "type": "confirm_request", "id": "cfm_17", "project": "isocan", "tier": 2,
"question": "Delete `build/` (412 files) in isocan?",
"resolved": { "command": "rm -rf /home/paul/…/isocan/build",
"paths": ["/home/paul/…/isocan/build"],
"effects": [ "412 files removed", "recoverable: not tracked by git" ] },
"source": "agent", // or "content" — see §3.3
"accepts": [ "clicked", "typed" ] } // "content"-sourced acts never list "speech"
{ "type": "confirm_expired", "id": "cfm_17", "project": "isocan" } // so the UI can clear the prompt
{ "type": "audit", "project": "isocan", "entry": { … } }
{ "type": "refused", "project": "isocan", "rule": "outside-project", "detail": "…" }
{ "type": "error", "project": "isocan", "detail": "…" }
confirm_request carries the resolved plan (real paths, real counts) rather than the
spoken words — that is what makes a mis-transcription visible (§3.3), and it is what the
confirmation UI renders.
Three contract rules that the schema alone does not convey:
via is a claim the host validates, not a label it trusts. A confirm whose via is not
in that request’s accepts is refused; a via of speech on a content-sourced act is
refused. This is the mechanism behind the rule in §3.3 — without the field there was nothing
to enforce it against.confirm id is spent by the first answer, and a second answer is
refused rather than re-applied. Combined with the per-connection nonce and say.clientId,
replaying a recorded message cannot re-drive an action.progress and diff carries its turn, and the say that started it is echoed in
turn_started.clientId, so the UI can attribute output to the sentence that caused it.The environment order is Paul’s (N11): browser first, then local safe, then — later, and with the trust questions below answered — hosted cloud. The capability milestones below still describe what can be done at each step; they no longer imply where it happens first.
A note for honesty’s sake: the machine placement already exists as a skeleton, because it is the cheapest thing to drive and the easiest to break while testing (§3.5a’s findings came from exactly that). That makes it a development convenience, not the product’s first environment — the first environment he opens is the browser.
One trust question the hosted-cloud environment adds, and it is not a detail: his files on somebody else’s machine. A project there needs the provider named, the operator’s access disclosed the way a cloud harness’s is (§1.3), and a per-project setting that can forbid it (§5 decision 2) — the same disclosure discipline, one level further out. §5 keeps the server side an open question on purpose, because Paul named it as the part he cannot yet reason about.
M0 — the skeleton that is genuinely useful (about a day).
voicebox-host: registry (open/activate/list), one active turn at a time, tier table with
Tier 0/1 enforced, audit log, process journal.M1 — voice and several projects.
M2 — a project in the browser. The same tier table running in a worker, over an OPFS directory handle, with a declared capability list and the egress policy of §3.5a. The first placement where the boundary’s mechanisms differ, so it lands after the machine placement is trustworthy — and it is where Paul’s “use OPFS” requirement is actually met.
M3 — a host on another machine. The same host, reached over an authenticated remote channel rather than loopback: the shape this fleet already runs in when Paul talks to it from a phone. Changes transport, not authority.
Deliberately not in any of them: cloud relay or any inbound connection; unattended autonomy (§3.7); a second transport; a plugin system; multi-user; a database (the filesystem, OPFS and append-only logs are the state).
What is imitated rather than imported, and the shape written out — so no implementer has to open another repository, and voicebox depends on no other project’s code:
session/new, session/load, session/prompt, streaming
session/update, session/request_permission, session/cancel. Session ids survive restarts
(load resumes), and every update carries its sessionId and its turn.That is ACP — the Agent Client Protocol (agentclientprotocol.com, with a published adapter
registry: the adapters are @agentclientprotocol/* packages). It is a standard, not CAP’s
protocol; CAP’s bridge is one implementation of the client side and pi-acp is an adapter, and
voicebox implements the client side of this shape itself. What voicebox takes from CAP is a
measurement, not a code path: k3 drove CAP’s bridge against pi and found the permission path
wired end to end and unused by pi (§1.1a) — a fact about pi, established with someone else’s
instrument, and worth exactly as much as the drive.
“You obviously build extensions like we can with pi and then just have the model register them.” … “We will create the tools and we will create the objects, the nouns — and the verbs are based off us somehow.” — Paul, 2026-09-19 (N10/N11)
Two ideas that are one design: an environment is what can run here, and an extension is a new thing that could. The seam between them is the capability declaration, and it runs in one direction only:
model authors an extension → registration: a DECLARATION → admission by the ENVIRONMENT → `do` may use it
(pi-style, in the conversation) name · capabilities · bounds · replayClass (fails closed)
capabilities (read, write, exec, wasm, network), bounds, and a replayClass — in the same
spirit as the harness declaring its capabilities, and for the same reason: an undeclared need
cannot be granted.admitted: false and executed nothing. The same
discipline stops a freshly written tool from being usable merely because it exists.wasm tool runs
in the browser and on a machine; an exec tool is not available in the browser — not broken,
absent — and the UI says which is which rather than letting a verb fail at the moment it is used.Asked of this pipeline: are capabilities declared by the author, derived by the environment, or enforced regardless of declaration? The answer is the third, with the first two as conveniences — and the distinction matters because the alternative is the day’s recurring mistake in a new costume:
A declaration treated as a fact is a rewrite, not a check — the same shape as
path.basenamebeing called containment. A tool whose descriptor saysnetwork: nonecan still callfetch. If the declaration is what makes it true, the boundary is a report.
So a capability is not a permission the tool claims; it is the interface the tool is given.
Tools are not handed ambient authority (fetch, fs, a shell) and trusted to behave; they are
handed the specific host-provided functions they are allowed to use, and nothing else — which is
§1.1a’s mediated mode, applied one level down, from the harness to the tool.
Admission therefore requires a named enforcement mechanism per capability. If the environment cannot say how it enforces a declared capability, it does not admit the tool — the same fails-closed rule, applied to the mechanism rather than the descriptor:
| Capability | Enforced in the browser (E1) by | Enforced on a machine (E2) by |
|---|---|---|
read / write |
handle-scoped access: a tool gets the OPFS handles it may touch, not a filesystem | the substrate’s path scope (--allow-read/-write=<root>) plus a host-side resolve pass, because the substrate’s scope is lexical and a symlink inside the root defeats it (measured: root/link-outside read /etc/hostname) |
wasm |
the import boundary: a module can call only what the host exports to it | the same, plus process isolation if it runs out-of-process |
network |
the realm’s egress policy — CSP connect-src, default 'none' (§3.5a) |
the substrate’s --allow-net=<hosts>: measured, an allow-listed host answers and another is NotCapable. A plain Node host has no equivalent at all — fetch succeeds under --permission — so the substrate is the mechanism here, not the policy |
import (remote code) |
CSP script-src (no inline) |
import access is a separate gate, and the hole is live by default: a dynamic import fetched and executed with no flags at all, because the registry host sits in the default allowlist while a localhost import is refused with “Requires import access to 127.0.0.1” before any fetch happens (re-driven independently). So --no-remote detects the attempt and a narrow explicit --allow-import scopes it — neither is optional for model-authored code |
the evaluator path (eval-style) |
not applicable | absolute: eval is not a tool path. Measured — with no flags, an eval ran with every permission and read a secret outside the root. The evaluator bypasses whatever the substrate would otherwise enforce, which is the same shape as the loader above and as CAP’s regex gate: three instances, one substrate |
exec |
impossible: there is no process to spawn, so the capability is absent, not promised | absent for dynamic tools too, and scoping it was attempted rather than assumed. Four avenues were tried to falsify this and none worked: with --allow-run, a spawned /bin/sh printed a secret from outside the root; a narrow --allow-run=/bin/cat with no read permission still printed it, because the binary carries its own privileges; and a spawned child does not inherit the parent’s flags, so it is bounded only when given a flag set of its own. Hence the rule: --allow-run bounds which binary, never what it can do — a tool needing exec needs a container that bounds the child |
Three consequences, each of which closes a hole the declaration alone would leave open:
There is no second path, and that is a decision rather than an omission. pi has no concept of
skills, and Paul’s ruling on it is explicit: “you have to install an extension, and so I’m okay to
follow that same model” (N16). So skills are not first-class: a capability the user wants and a
capability the model writes both arrive as extensions and pass the same admission point. Two
paths would mean two policies, and the weaker one would decide what the system can do — which is the
same failure as an authority with two homes (§3.0). And the mechanism reached for what does not
exist yet, unprompted: during that deny run the gate asked for web_search by name — a
capability nobody has implemented — which is N16’s stress test happening on its own rather than being
arranged.
k3 measured the inside of the harness: a tool’s self-declaration is the last word — there is no sandbox there. And qwen2 read pi’s source and found where its one gate actually is:
Trust is on discovery.
.pi/extensionsloads only after the project is trusted — pi’s own line is “Extensions run with your full system permissions and can execute arbitrary code.” RuntimeregisterTool()has no equivalent gate, and an in-process registration inherits the harness’s full authority immediately, because it never passed through a discovered location.
So “the host owns the extension directory and the reload trigger” is necessary and not
sufficient — it covers the file path into the system and not the registration path. Two further
corrections from the same reading: /reload is not the gate at all (registered tools are live
in the same session, callable immediately — pi ships examples/extensions/dynamic-tools.ts doing
exactly this), and registration is not persistence (an in-process tool dies with the session;
making it survive means writing into a discovery location, which is the arbitrary-code case).
“Build those extensions and have them work locally” is two requirements, and conflating them is how
a light harness acquires an admission pipeline it did not want.
What closes the seam is therefore a registration-time authority decision, and it is small: allow or deny, plus a reason string — not a descriptor pipeline. The decision must cover two things, because of a composition worth naming: dynamic registration and remote execution compose into a call nobody authorised. A tool registered for this project must not be able to route its call to a capability living somewhere else; the registration answers what may run and where its call goes, or it does not answer.
And the light option is smaller than it looks, because execute is code and a model can only
emit its schema as data. A closed set of parameterised primitives — the shape of CAP’s
python-tool.js (52 lines, seam getPythonRuntime; both figures from qwen2’s harvest, not my own
reading) — means the model picks nouns and verbs from a fixed set rather than writing bodies, which
is a different and much smaller problem than sandboxing generated code. Paul’s own split may already
be pointing there: “we will create the tools and we will create the objects, the nouns — and the
verbs are based off us somehow.” A model writing an execute body is the heavier path, and it is
the one that needs everything above.
For the record, and it reframes the project usefully: OpenClaw exists (Gateway, Telegram, harnesses-as-runtimes, a live canvas, Markdown memory — six of the brief’s nine items already), and the delta is this mechanism: its tools are authored and configured, not created by the model mid-conversation. The interesting gap is not a platform.
The question gemini’s review put to this section — if a model writes a dynamic tool file, what
executes it? — is the difference between an extension system and eval with a file browser. The
answer is now a named substrate with a declared flag set, and it is not a detail: on E1 the
substrate is Wasm’s import boundary (free, and the strongest); on E2 it is a capability-based
runtime that is invoked with an explicit permission set, measured against the cases that matter
(docs/evidence/substrate-20260919/RECEIPT.md, with the probe alongside it):
--no-prompt always; --no-remote **and a narrow explicit --allow-import** — the first
detects the attempt, the second scopes it, and the default allowlist is what makes the loader hole
live rather than theoretical;--allow-read/-write scoped to the execution root, plus the host’s resolve pass, because the
substrate’s path scope is lexical and follows a symlink out;eval-style execution is not how a tool runs, because the evaluator
bypasses every flag the rest of this list depends on;--allow-net=<hosts> only where the environment can back it, host-scoped (verified: one host
answers, another is NotCapable);--allow-run, --allow-ffi and --allow-env are never granted to model-authored code;fetch through.Not a build plan — a definition. The browser is the first environment (N11), so this is the first thing that has to be real, and the enforcement table in §1.7 decides what is in it and what is simply absent.
It exists when all five of these are true:
navigator.storage.getDirectory(), no gesture needed, measured §1.8 below), registered with
placement: browser, capabilities, undoKind: written-file-list (§2.1). A project may also be
a picked directory, which is a different thing with a permission prompt; and “create a
project” works as a spoken or typed verb (N13).(instance, project, root, turn). (Hash-chaining is an autonomy-stage
requirement, §3.8, not an M0 one.)The one tool, and why it is that tool. Create an asset: a name and content in, a file in the project, the artefact rendered in front of him (N12). It is the smallest tool that exercises every enforcement row — it writes inside a handle-scoped root and it must not reach the network — and it is also the product’s feel, so the first thing built is the thing he described.
The row that decides the shape: network is enforced by the realm’s egress policy, and a
plain worker inherits the document’s policy without losing ambient fetch. So the M0 tool runs as
a Wasm module whose imports are the only interface it has (§1.7: the import boundary, the
strongest of the four), which is also why the Wasm path is worth building first rather than last.
Absent at E1-M0, and deliberately: exec (no processes to spawn, §1.7); project-code egress
(no rule is admitted until the policy exists and is tested); the harness and the voice model (M0
is text, and k3’s design is not a dependency of this environment existing); several live instances
(§2.3 is E2 and later); autonomy (§3.8 is a road, not a step).
Three things the interface must say out loud, rather than letting the user infer them — each is an honest answer to something the platform does not promise:
navigator.storage.getDirectory()) is
origin-private and needs no gesture at all — measured at page load with
userActivation.isActive === false: create, write and read back all succeeded — while a
picked directory (File System Access, which is how a window onto local files is opened) is
user-visible and its permission may need re-granting. So the peer project has no permissions
dance; the picked-folder case has one, and an unexplained gesture reads as a bug.written-file-list, not
git — a browser project is not a checkout, and yesterday’s lesson applies directly: a project
that may never have git must know which undo it has. The agent revising its own writes must be
revertible by replaying the list, which is a different mechanism from a worktree and has to be
said in those words.What not to ship, from today’s evidence rather than from taste — five ways the first implementation can look finished and not be:
<img src=x onerror=…>
through three innerHTML sinks, one of them stored. Text nodes, or escaped; §3.5a.basename('..') is '..', so a “cleaned” name escaped the
workspace. Resolve, compare, refuse — never tidy and proceed; §3.2.network: none that calls
fetch is stopped by the policy and filed as a finding — and the positive control must show
an allow-listed host succeeding; §1.7, §3.6.E1-M0 has no dependencies, and this is the list an implementer will read as one: it needs neither the harness (§1.1b’s transport fact — no bridge, no pi, no k3 extension) nor any compliant-mode work (§1.1a) nor the server side (§5). Its tools run in the page; the tier table, the audit and the project records run in the same worker; that is the whole of it.
Two things that could make it indefinable, and how they stand: persistent storage is a
request the browser may decline — measured: persisted() is false by default and persist()
returned false on a plain origin — so the durability state is required rather than decorative; and
quota, which measured 10.7 GB here, is generous but not infinite. (The OPFS handle itself needs no
gesture, per the measurement above; only a picked directory does.) Whatever the server side turns
out to be (§5) is not needed for this — E1 stands alone, which is the point of it being first.
M0 is buildable (§1.8), so the next question is what the voice milestone actually adds to the
environment — which is less than the name suggests, because most of the machinery is already
here: several projects with their own sessions and audit trails (§2.1–§2.3), confirmations with
via and accepts (§1.5, §3.4), and stop as the unconditional lever (§1.4).
Three additions, and only three:
via, and never receives a file. If it is a cloud model,
what leaves the machine is audio and conversation, which is why §1.3’s two footprints exist and
why the badge is per project./home/paul/isocan, branch main, 3 files dirty;
go ahead?” — which is §3.3’s resolve-then-judge applied to which project, not just which act.
Ambiguity asks rather than picks, and a project the user has not registered is offered as a
registration, not assumed.accepts, via, single-use ids, expiry-is-refusal) and in the validity rules; M1 is the
milestone where a mouth is on one end of them.And one addition that is a subtraction: an unknown verb is not a Tier 2 act. When the voice model proposes something the tier table cannot express, the answer is “I cannot do that here” naming what is missing — never a confirmation prompt. Asking “shall I?” about an act the environment has no mechanism for turns a missing capability into the user’s decision, which is how a system acquires verbs nobody implemented.
When M1 exists (the §1.8-style definition):
observed state, and an artefact on screen — and a Tier 2 variant of the same
sentence is read back in its resolved form before it happens.stop cancels mid-turn and stops what the host started it for, from speech, first try,
including mid-sentence barge-in.via on every confirmation, and a source: "content" act has no path
from a transcript to a yes — asserted, not assumed.Three expectations, stated so they can be falsified rather than discovered: the voice model will propose intents the table cannot express (hence the subtraction above); live audio plus a tool call will have latency the interaction must tolerate (astra’s problem, but the host must not make it worse by asking twice); and a mis-heard name is more likely than a mis-heard verb, which is why the resolution in (2) reads back the path rather than the word.
A project is a declared directory plus a session, and nothing more:
{ "id": "isocan", // short name, unique among registered projects
"path": "/home/paulkinlan/isocan", // a DECLARED location. THREE kinds (N20): a realpath on a
// machine; an origin-private OPFS path; or a picked directory
// remembered as { "kind": "handle", "id": "h_7f2",
// "label": "~/notes" } — where the origin cannot describe
// the target, only remember the handle and the user's label
"executionRoot": "/home/paulkinlan/isocan", // the root THIS instance works in (§2.3/§2.4)
"roots": [ "/home/paulkinlan/isocan", // every root open on this project, one writer each
"/home/paulkinlan/worktrees/isocan-walk" ], // e.g. the phone session's worktree
"undoKind": "worktree", // git-branch | worktree | written-file-list | none (§2.4)
"placement": "machine", // machine | browser | remote (§1.1b)
"capabilities": ["read", "write", "exec", "wasm"], // what `do` can mean here (§1.1b)
"lastUsed": "2026-09-19T11:40:00Z",
"harness": "pi",
"sessionId": "ses_…", // the harness session for THIS project
"worktree": null } // set when the host keeps work recoverable (§2.4)
placement
is part of the identity rather than metadata about it (atlas@phone ≠ atlas@box, §1.1b) — and
for a picked handle there is no path to compare at all: the origin remembers the handle and the
label the user gave it, so two origins holding handles to the same folder are two projects, because
neither can tell. That is the identity rule holding at the level below @phone.executionRoot is the containment root, and it is whatever the placement says it is — an
OPFS directory handle in E1, a realpath on a machine — and it is not always the checkout: when the
host
works in a worktree (§2.4) that directory is the root for every containment check, every
diff and every state message. Containment is always relative to one root — the active
project’s execution root — never the union of registered projects, or a nested or
side-by-side project would hand the harness another project’s files.undoKind is declared, not assumed. A git project can hand the agent a worktree to break;
a project with no git gets a written-file revert list; a project with neither says none, and
the tiers narrow accordingly (§2.4).| Act | What happens | What does not happen |
|---|---|---|
| open | on a machine: verify the path exists and is a directory, resolve realpath. In a browser: take the OPFS directory (origin-private, no gesture) or a picked directory (showDirectoryPicker(), which needs a gesture now and may need one again on a later visit) and persist the handle. Register, then create a session lazily on first use |
nothing is cloned, scaffolded or modified |
| activate | the active project changes; the UI is told the new state | other projects’ sessions are untouched |
| detach (implicit, on switching away) | the session stays alive and resumable | no process is killed for switching |
| close | the session is ended explicitly; processes the host started for it are stopped | files are untouched by closing |
“Open” attaches to what the developer already has. Creating a new project (scaffolding, cloning) is a later milestone and a confirmed act, because it writes outside an existing root by definition.
“If I’m on my walk, an instance of the web page, and your instance in the chat session” — Paul, 2026-09-19
Two live agents on one project at the same time. The earlier version of this section said serial within a project, parallel across projects, reasoned from the voice channel being serial — and that premise is now false: there are several conversations, so the serialisation cannot be the project.
What actually must not interleave is a change to the same working tree. So:
| Unit | Rule |
|---|---|
| Execution root (§2.1) | Exactly one writer at a time. This is the only thing that serialises, because it is the only thing that can be corrupted by interleaving |
| Project | A container of roots, sessions and history. Several roots may be active concurrently — that is what makes the walk work |
| Session | One per (instance, root). A session is a conversation, and two instances are two conversations; they must not resume each other’s harness session |
Reads (ask) |
Never serialised anywhere. Any number of instances may read the same project |
| A resolved plan | Not serialised, but ordered: plans in different roots run concurrently; plans in one root queue behind the root’s writer |
So Paul’s scenario works by construction: his phone session and this chat session each get their own execution root (their own worktree, §2.4) inside the same project. They work at the same time, on the same project, without touching each other’s files — and the way their work meets is an ordinary merge he can see and refuse.
Where a second root is impossible — a non-git project, where the only root is the checkout — the rule degrades honestly rather than silently: the second instance may read and propose, and its writes queue behind the current writer, with the UI saying which of the two it is. Two writers, one tree, is the failure this fleet already has evidence for; a queue is cheap, and hiding it would not be.
via provenance rule (§3.4) is
unchanged. A confirmation is never answerable from a different project, and never from the
voice model.(instance, project, root, turn) and a per-instance monotonic sequence as
well as a wall clock — because there is no global total order across two machines and pretending
otherwise would produce a log that looks authoritative and is wrong. Readers order by instance
sequence and treat the wall clock as a hint.Paul chose shared, and then asked the question underneath it: “I just don’t know how sharing works without having a merge concept.” That question has no answer because it joins two things that are not alternatives — they reconcile different things:
| shared | merged | |
|---|---|---|
| the data | state — presence, what each agent is doing, what it has read | artefacts — files, code |
| the shape | an append-only log: nothing is overwritten, so nothing needs reconciling | a diff: one file, one writer, so reconciliation is required |
| the channel | live — it is visible as it happens | late — it is visible when the work lands |
So the rule is shared log + per-root work that still merges, and both halves get simpler for being
separated:
The global state is the log; it is not the files. And because the log is append-only, the shared
view is not new storage — it is the same per-root logs read together (ordered by (instance, seq),
§2.3’s rule) plus presence, which is what makes seen-marks load-bearing rather than nice: “what
did it know” is a question only a shared log can answer live, and a late merge can only answer it
after the fact.
git-branch (work on a branch), worktree (the agent gets its own checkout, the default for
git projects), written-file-list (no git: the host records every file it wrote, and undo
replays that list), or none. The tiers scale with it: with no undo at all, the unprompted
scope narrows to acts that are reversible by their nature (running tests, reading, drafting
changes for review), because Tier 1’s promise — “cheap to undo” — would otherwise be false./home/paul/isocan-worktrees/agent-3, then that path is what every check is measured
against and what the UI shows; the checkout the developer is typing in is outside the root
and therefore Tier 0, which is the point — the agent cannot wander into the files he is
editing. Diffs are computed in the execution root, and “land the work” is an ordinary merge
he can see and refuse.project field on
every entry, so “what happened in isocan today?” is a filter.Read-only facts about a project — branch, dirty count, recent commits, whether a server it started is still running. Reading a project’s own files is Tier 1 (§3.2). Reading outside a project is Tier 0 unless it is the host’s own state directory.
This section exists before any code, because the thing being designed is a language model with execution access to a developer machine, driven by speech. Yesterday’s fleet work settled two general rules that apply directly: authority must be declared rather than assumed, and ambient state is where things go wrong quietly. Voice is the ambient-est input there is.
A guard must be a mechanism, not a description. Written once it sounds like a slogan; written three times in one day, in three different costumes, it is the thing to check every claim against. All three of these are in this document:
And the reason it matters is narrower than it sounds: the failures that survive are the ones where
the only thing that would have told you is the thing that is missing — or was thrown away before anyone
could read it. Two doors into the same room. The first is the absent mechanism: a check that was
never built, a policy that restricts the wrong thing. The second is the discarded report: k3’s gate
announced its refusal with console.error, and the adapter swallows the child’s stderr, so the
announcement never reached the wire — a check that appears to pass because the report of failure was
discarded. The first is invisible because nothing speaks; the second because nobody could hear it.
The sharpest of the rows below is the post-gate handler, because it leaves no trace at all:
basename at least produced a wrong path; an approval for an act that was not the act produces nothing
to notice. A normalising helper that
looks like a check; a policy that restricts reach and not execution; a declaration that is taken as
the enforcement. Each of them keeps working, keeps passing its own tests, and produces no signal —
because the signal is the mechanism that was never built. That is the same shape as an audit built
from the model’s account of its effects rather than from the world, and as a permission granted for a
plan that the tool then re-validates into something else: in all four, the thing that would
announce the problem is absent, and its absence is invisible.
| The claim | What looks like a guard | What is actually a guard |
|---|---|---|
| “It cannot leave the project root” | path.basename(name), join, normalize — rewrites that happen to look correct, and basename('..') is '..' |
realpath(candidate) resolved and compared against the root, refusing on any answer but yes (§3.2) |
| “Nothing outside can run in the page” | connect-src, which restricts what a page may reach |
script-src without 'unsafe-inline', which restricts what it may execute — different jobs, and only one stops the attack (§3.5a) |
| “This tool cannot reach the network” | the tool’s own declaration, network: none |
the interface the tool is given, plus the realm’s egress policy — the declaration is a record, never the enforcement (§1.7) |
| “Nothing dangerous is imported or evaluated” | a regex over source text — CAP’s first evaluator gate was text-only and missed eight live alias sites | an AST: the thing that sees what the text means, not what it spells |
| “The approved thing is the thing that runs” | a handler registered after the gate — it rewrites the input between the approval and the execution, so the approval is correct-looking and about a different act | the gate is the last handler and the gap between “yes” and “do it” is empty; any extension whose load order is not the gate’s own is an admission act, not a configuration detail |
The habits that follow are the reason this section is written the way it is:
tools/tree-dirt.mjs refuses a scratch destination that resolves
inside the measured tree (writer-inside-measured-tree), and a run that measures a tree snapshots
its git status before it writes and reports what appeared — pre-existing dirt is named as
pre-existing rather than blamed on the run (§3.6). Every git command the guard and the harness run
strips git’s own local plumbing first: GIT_DIR outranks -C, so a hook’s exported repository
would otherwise answer a question about the tree it names — the field finding where this bead’s own
fixture wrote its identity into the real checkout during a gate run (2026-09-25)... as a name as well as a path segment, and the capability test
needs an allow-listed host actually reached. Otherwise the test proves that refusals work, which
is not the property anyone wanted.| Input | Trusted for | Not trusted for |
|---|---|---|
| Paul’s speech | expressing intent about his own projects | being correctly transcribed; being the only thing on the microphone; being current (an utterance from twenty minutes ago) |
| The transcript | a record of what was heard | authority to act |
| Project content (files, issues, READMEs, web pages the agent reads) | information | instructions — content never confers authority |
| The harness model | proposing actions inside a project | deciding what it may do |
| The voice model | proposing intents, holding the conversation | executing anything, answering a confirmation |
Three concrete attacks this table is defending against:
The tiers below describe a system where a person is present, or nearby. Autonomy is not a setting on this table. An agent that acts with nobody listening has a different threat model — no confirmation gate can exist, so its boundary would have to be built from sandboxing, resource limits and classifications of irreversible acts instead — and the guarantees stated here would not carry over to it. Anyone later tempted to add an “unattended” switch is adding a second design, not flipping a flag, and this paragraph is the reason to say so out loud. It is also the destination Paul wants (“in the long run I want to get to full autonomy YOLO mode… I hate having to have permissions”) — so it is a design that gets built, not a corner: §3.8 is the path to it, and it is a path rather than a switch on purpose.
The line that keeps this consistent with long-running work — and it is the line someone will come here looking for: an approved plan continuing is not an unattended agent deciding. A turn Paul asked for may keep running while he does something else, and its progress may wait for him; but nothing new begins without an instruction, and Tier 2 always blocks on a person. What is refused is the second thing, never the first. (§3.7 states the same limit operationally, which is why the two read as a contradiction until they are read together.)
Tier 0 — never. Refused by the host, before execution, whatever anyone says. Every rule is enforced as data in the host and every one has a test (§3.6).
| Rule | Mechanism |
|---|---|
| Write or read outside the active project’s execution root (§2.1) and the host’s own state dir | realpath containment against the active root only — never the union of registered roots, or a nested project would hand the harness another project’s files |
| Creating a symlink whose target resolves outside the execution root | containment checks the link target as well as the link’s location, before creation — and, because a dynamic tool can create such a link inside its own writable root, the host re-resolves the root and refuses the run when one points out (measured: a substrate path scope alone is defeated by exactly this) |
Touch credential material (~/.ssh, ~/.config/**credentials**, keychains, browser profiles, .env*, service-account files) |
path + pattern deny-list, applied to reads as well; matches are reported, never echoed |
sudo, su, machine-wide config changes, global package installs |
command classification before execution |
| Kill processes the host did not start | the process journal is the only source of pids it may signal |
curl … \| sh, or any fetch-and-execute of remote code |
command classification (fetch + interpreter in one pipeline) |
| Send anything to the network from the host itself (exfiltration surface) | the host makes no outbound requests; the harness does what its own tools do, inside a project |
| Publish, deploy, spend money, message a human | these are Tier 2 — but a Tier 0 blanket ban applies when the instruction arrives from content rather than from Paul (§3.3) |
Resolving is not rewriting, and normalising is not checking. The skeleton demonstrated the
failure mode in forty lines: its containment was path.basename(action.name), which is not a
check but a side effect — path.basename('..') === '..', so path.join(workspace, '..') is the
parent directory and a transcript of “create a file called ../../../tmp/evil.sh” resolved
somewhere else entirely. A containment rule is only a rule when it resolves the real path and
refuses — it must never “clean up” an input and then proceed, because the cleanup is what
produced the escape. basename, join, normalize and replace('../', '') are rewrites; the
check is realpath(candidate) compared against the root, and its answer is yes or no.
Tier 1 — allowed unprompted, inside a project, reversible, and reported.
127.0.0.1), in a single slot per project that replaces the previous instance. A
project whose own configuration binds 0.0.0.0 is exposing the checkout to the local
network — that is a Tier 2 act, because the host cannot honour Tier 1’s promise for it.Tier 2 — confirm first, per act, in the conversation.
git reset --hard,
git clean -fd, force-push, history rewrites; dropping or migrating a database;
overwriting uncommitted work.git push, opening or commenting on a PR, deploying anywhere,
calling a remote API that receives project content.npm install, pip install, cargo add and friends
execute arbitrary code (preinstall/postinstall/setup.py/build.rs lifecycle hooks) and
reach external registries, so unprompted they are unprompted code execution plus unprompted
egress — the supply-chain vector sitting inside what was the safe tier. The confirmation names
the packages, including which are new to the lockfile. A restore is Tier 1 only when it
cannot run code: npm ci --ignore-scripts against a committed, unchanged lockfile, or the
equivalent. Without --ignore-scripts, a restore is still an install.The line between Tiers 1 and 2 is reversibility and reach: inside the project and cheap to undo → go; outside the project, or expensive to undo, or someone else can see it → ask.
The tier table is placement-invariant: the same three tiers govern an OPFS project and a checkout. What changes is the mechanism that enforces each one — and which direction it moves.
| Tier 0 rule | On a machine | In a browser (OPFS or a picked handle) |
|---|---|---|
| Nothing outside the root | realpath containment, checked and re-asserted | structural: handles are relative and .. does not resolve. For OPFS the API cannot express an escape at all; for a picked directory the folder is real, so the symlink case must be measured rather than assumed (build spec §7) |
No credential material, no system commands, no sudo |
deny-list + argv classification | structural: there is no ~/.ssh, no process to spawn and no privilege to escalate inside the origin |
| No fetch-and-execute | argv classification | structural: there is no process to exec |
| No exfiltration by the host | the host makes no outbound requests | weaker, and this is the real gap — a page can fetch; project code runs under a declared egress policy (§3.5a) and any network access by it is Tier 2 |
| Tier | On a machine | In a browser (OPFS or a picked handle) |
|---|---|---|
| 1 — unprompted | read/write files, run the toolchain, loopback dev server | read/write OPFS files, run wasm/JS tools in a worker; no processes, so “dev server” becomes “start the preview worker” |
| 2 — confirm | irreversible acts, leaving the machine, reaching a human, spending | the same list; “leaving the machine” becomes “leaving the browser” (uploads, remote APIs, form posts) |
| Audit | an append-only file | IndexedDB in the same worker. Mirroring it to a machine host is not free and not automatic: it is the page sending data out, which is a Tier 2 egress act (§3.5a) — so a peer world’s audit is the record for that world, and a paired host’s audit the record for its own |
| Durability | the filesystem he can look at | OPFS, which a browser may evict. A peer project therefore requests persistent storage (navigator.storage.persist()), shows its durability state, and can be exported as an archive in one act — the mitigation that makes peer mode acceptable rather than a hostage situation |
So the boundary survives translation, and two of its mechanisms get stronger rather than weaker — but not all of it, and the exception is egress. Better to know that now than to discover it in a demo.
This is the part with no off-the-shelf answer, so it is stated as a pipeline:
speech ──▶ transcript ──▶ intent {project, action, args} ──▶ RESOLUTION ──▶ tier ──▶ act
│ │ │
(untrusted) (voice model proposes) (host resolves real paths,
real counts, real effect)
build/ (412 files) in
isocan?” — so a mis-transcription shows up as a different plan, not a different sentence.stop is always live. It cancels the running turn and stops the processes the host
started, immediately, whatever else is pending, and it is honoured mid-sentence.Where an instruction came from is part of the decision. If the intent originated from
content the agent read (a file, an issue, a page) rather than from Paul’s speech, the host marks
it source: "content", and Tier 2 acts originating that way require typed or clicked
confirmation, never a spoken “yes”. The confirmation UI says so in words: “this request came
from a file the agent read, not from you.”
A spoken “yes” counts only when all of these hold:
Otherwise the host asks again, or offers the UI control. Never valid: silence; “ok” before the question; a general “yes” while two questions could be pending; a confirmation given by the voice model on Paul’s behalf.
Two consequences that need stating because they are the difference between a rule and a mechanism:
confirm_expired, spends the id, and reports the act as rejected; a “yes”
arriving afterwards has nothing to attach to and starts a fresh plan (with a fresh readback)
if it is meant.confirm_request lists the
input methods it accepts, and the host validates the via field against that list — so a
content-sourced act simply has no path from a transcript to a yes, whichever voice is
speaking.| Claim | Mechanism |
|---|---|
| “It cannot leave the project” | realpath containment against the active execution root, computed at execution time for every path and cwd and re-asserted immediately before use — the 30-second confirmation window is a real window, and a path or symlink can change inside it. Resolve, then re-resolve, then execute |
| “It cannot be walked out of the project by a symlink” | link targets are checked, not just link locations, and the host opens with O_NOFOLLOW semantics where it performs the operation itself |
| “And the guarantee holds for the harness’s own tools too” | only in mediated mode (§1.1a). This is the row that decides whether the table above is enforcement or disclosure |
| “It cannot touch credentials” | deny-list applied to resolved paths and to reads; values never returned to the transcript |
| “It cannot run the wrong thing” | commands are classified from the resolved argv, and Tier 0 matches refuse before spawn |
| “It cannot act unattended” | Tier 2 always blocks on a human; no new plan begins without an instruction |
| “Paul can find out what happened” | append-only audit log, one entry per act: time, project, tier, decision, resolved command, exit status, plus a diff summary for writes |
| “Work is undoable” | branch/worktree or a written-file revert list per project |
| “Switching projects is safe” | per-project session and cwd; nothing shared but the host |
Containment and privilege are structurally better in a browser; this is the counterweight —
a page can reach the network. Five ambient APIs alone can do it (fetch, XMLHttpRequest,
WebSocket, EventSource, sendBeacon), a content security policy has no connect-src by
default, and a no-cors POST can send data without ever being readable back. That surface was
measured on this fleet yesterday, which is exactly why it must not be assumed here:
connect-src allow-list, defaulting to 'none';On a machine, “the host makes no outbound requests” is a property we implement; in a browser it is a property we configure and verify. Both are claims — only one of them is free.
Driving the skeleton (2026-09-19) proved this one rather than arguing it: <img src=x
onerror=...> typed into the page’s own text field executed, through three separate innerHTML
sinks — the transcript echo, the server’s own error and note strings, and an action’s file name,
which makes that one stored as well as reflected. Two things follow.
connect-src
does nothing about onerror=; the browser placement needs script-src without
'unsafe-inline' (and no unsafe-eval), alongside the egress allow-list. Restricting what a
page may reach and what it may execute are two different jobs, and the second is the one this
surface is actually attacked through.The same drive found the request path had no error handling, so a malformed input was a remote kill rather than an error. On a machine that is a dead host; in the browser placement it is a dead worker holding the project. So it is a named property, checked in §3.6 rather than assumed: any frame — malformed JSON, unknown type, a handler that throws — produces an error message and an audit entry, and the worker restarts with its OPFS project intact. A turn that can end the session is not a turn, it is a crash with a chat interface.
A boundary that is only asserted is a wish, and a suite of refusals proves nothing until a request succeeds. So both directions:
source: "content" act that requires typed confirmation... escapes, symlinks pointing out of the root, and absolute paths elsewhere —
each refused on the resolved path. Including .. as a name rather than a path segment
(path.basename('..') is '..', the case that walked out of the skeleton’s workspace), because
that is the shape a normalising implementation gets wrong while looking correct.Deno.env, dlopen and a remote dynamic import are each refused by the substrate’s own word; --allow-net=<host> reaches that host and another is refused; and with no flag set, everything is NotCapable — the default-deny posture is itself the positive control’s counterpart.fetch — is stopped by the policy and recorded as a
finding; and a tool that declares an allow-listed host and reaches it succeeds, because a
suite of refusals proves nothing until one request is allowed. Plus the admission check itself:
a tool declaring a capability the environment has no mechanism for is refused rather than
run.backup/ directory into it, and the before/after
git status delta names it as this run’s writing; dirt present before the run is reported as
pre-existing, never as the run’s; a destination that resolves inside the measured tree through
a symlink is refused by name (writer-inside-measured-tree); a name-prefix sibling
(repo-other beside repo) is correctly outside; and an ambient GIT_DIR pointed at another
repository cannot steer the fixture or the measurement — the field finding (2026-09-25) in
which this file’s own fixture, run under a pre-push hook, wrote user.name into the real checkout.
The prose rule is §3.0’s: a process that writes must write outside anything another process
measures.Being explicit about the limits, because a design that overstates its safety is worse than one that states a smaller guarantee:
“In the long run I want to get to full autonomy YOLO mode, cuz that’s how I work. I hate having to have permissions.” — Paul, 2026-09-19
§3.2’s principle stands — it is a second design, not a setting — and this is that design, written as a path rather than a switch, because every precondition below is a property of the environment rather than of the model’s trustworthiness.
Autonomy is not “Tier 2 becomes Tier 1”. It is: the confirmation gate is replaced by reversibility and blast-radius controls. Nobody is there to say no, so the safety has to come from the act being undoable, or from its consequences being contained. That reframing is what makes the preconditions listable.
rm, and no history rewrites inside the scope. undoKind: none (§2.4) means the
project is not a candidate, because reversibility is the thing replacing consent.k3’s drive produced the cleanest counterexample in the document: after a run, the model reported “delete-me.txt already exists… confirmed created earlier this session” — and the file did not exist. The model’s account of its own effects is not evidence of its effects, and an audit built from those accounts records a story rather than a state.
And seen-marks come first, not last, now that the log is shared (N19). “What did it know?” is a question two agents have to be able to answer about each other, and it is the reason the read record is a seam rather than a nicety: an entry carries what was looked at, not only what was done, so a second instance reading the union can see what the first had in front of it.
And what was read is part of the record, not only what was done. A writes-only log cannot answer “what did it look at?”, which is the question that matters when something goes wrong later — and it is the same instinct as observing outcomes rather than trusting accounts: state, not narrative, in both directions. So a read is an entry too, carrying the path and the size the host saw, and the log can answer both “what did it do” and “what did it know”.
So an audit entry’s outcome is observed, not reported: the host stats what it acted on (existence, size, mtime — and for a write, that the bytes are where it says), and the transcript’s claim is never an entry. “Did it do what it said?” has to be answerable from the log alone, and the only way to make that true is for the log to be written by the part of the system that looked.
(instance, project, root, turn), and hash-chained so a
later reader can tell it was not rewritten. That is cheap, and without it “what did it do while I
was out?” has no answer.A mistake is discovered after it happened. Prevention is gone; the design’s job becomes damage control — and that is fine for most acts, but not for the ones with no undo at all: pushing to a shared remote, sending a message to a person, spending money, deleting something outside the reversible scope. So autonomy carries a deferred-approval queue rather than an exception: the agent proposes those acts, records them, and carries on with other work — which is how autonomy actually functions in practice, and it means “no permissions” does not have to mean “no judgement about consequences”.
Autonomy on a machine that is not disposable is not autonomy — it is the same risk with the alarm disconnected. The placement table in §1.1b already gives the answer: run it on a disposable box, or in the browser over OPFS, where the sandbox is the platform’s rather than ours.
From astra (interface): the reference is isocan’s voice agent as inspiration — a look to
match, never a component to depend on (Paul’s line, and N12’s own words). It is explicitly not a
debug view: his words about the bare skeleton were that it looks “terrible”, and he wants assets
appearing as they are created, “not too explicit”. That is a direction rather than a spec: the
environment’s job is to make things materialise (progress → artefacts in front of him), and the
interface’s job is the one isocan solved — as a look. Everything below still holds. Plus: every project must show
which world it is in — “this project
lives in this browser only” versus *“this project lives on
From k3 (harness): one adapter; session/new|load|prompt with a declared cwd;
streaming session/update; and a real session/request_permission for acts outside the
project so the host can gate them. If the harness cannot emit permission requests, the host’s
Tier 2 must be implemented as a wrapper around the tools it offers instead — worth knowing
early, because it changes where the gate lives. Two additions from §1.1b: the harness should
declare the capabilities it has in a given placement (exec, wasm, network), and it
should run in at least the machine placement and the browser placement — if those cannot
share one adapter, say so early, because that is a second harness rather than a second setting.
Third: §2.3 requires several concurrent sessions in one project, each with a different cwd
(the phone’s worktree and the chat’s) — if the harness is one-session-per-process, the first
question I need answered is how concurrency is expressed, because the environment depends on it.
And fourth, which the substrate finding makes urgent: does the harness run its tools in-process
or through a substrate it can scope? If a harness executes model-authored tools inside its own
privileged process, then mediated mode (§1.1a) cannot be built on top of it, and the environment
needs the tool boundary rather than the harness boundary. That is now the most load-bearing
unknown in this document.
From whoever builds the first environment (browser) and the local one: the core — project
records, the tier table, capability lists, the audit writer, path resolution — must be a library:
pure data and small functions with a narrow dependency surface, importable and reused across
client, server and anything later (N18). This is a consistency requirement, not a portability
technique — Paul’s words are “consistency at that level” — because a core that runs in one
placement becomes two implementations that drift, and the drift is silent: both keep working,
and they disagree.
qwen2’s harvest found a precedent worth copying the shape of: isocan’s
packages/voice-agent/src/live.ts is 1,173 lines of pure data and functions shared between a
browser module and a harness, with nine dependencies doing the work. Do not lift the file — it
imports from @isocan/core, and isocan’s packages are out of bounds (below); what transfers is the
design: one core of pure data and small functions, narrow enough to run in both places. And a capability rule from
the same harvest: a Wasm tool is a capability only when its descriptor is admitted — CAP’s own
store currently holds 9 descriptors that are all admitted: false, so taking the format (three
fields: capabilities, replayClass, bounds) and the fails-closed convention is the lift;
budgeting by cold-start milliseconds, not megabytes. And the extension seam of §1.6a is the
place where N10 and N11 meet: a registration is a declaration and admission belongs to the
environment, so the registry has to exist early — a tool that cannot say what it needs cannot be
safely admitted, and a model-authored tool that is not declared is not a capability. For every
capability, name the mechanism that enforces it, or the tool is refused (§1.7): “enforced” is the
only answer that makes the extension system a platform rather than a permissions form.
From Paul: the decisions in §5 (now five) — plus one thing he named as unresolved that this document deliberately does not answer.
Open, and kept open: the server side. “I don’t know how to deal with the server side of things… we also have access to an environment that is on the server.” What is unresolved is not the transport (E3/E4 above describe that) but the trust and ownership questions: whose machine it is, who else can see the files, what the agent may do there that it may not do locally, and how a project on a server relates to the same project on his laptop. A design that quietly answered those by assuming a shape would answer them wrong — so they are recorded as questions with a placeholder, and E3/E4 are marked later rather than assumed.
Each of these has a recommendation and a default, so building is not blocked on an answer — any word from Paul changes one line, and the default is itself a line in the design.
1. Authority while no one is at the keyboard. Options: (A) nothing continues while nobody is listening; (B) approved plans continue, no new plan starts, Tier 2 always waits; (C) full autonomy. Recommend B, and proceed on B. Trade-off: he can walk away mid-task and return to progress, but an approved plan can still reach somewhere he would have stopped in person — bounded by Tier 2 always waiting and by the work being recoverable. Option C is a different design with its own boundary, not a setting.
2. A project’s power to forbid a cloud harness. Options: (A) the badge in §1.3 alone; (B) badge plus a per-project lock that forbids one. Recommend B, and proceed on B with every project unmarked until he marks one. Trade-off: a repo marked local-only can never be worked by a cloud harness even when he later wants it — one flag to flip, and the only version that is a guarantee rather than a disclosure.
3. Worktrees versus working in place. Options: (A) always in place with git as the undo; (B) a branch in place; (C) a worktree per agent session, with in-place plus a written-file revert list for non-git projects. Recommend C for git projects, and proceed on C. Trade-off: his checkout stays exactly as he left it and “undo everything the agent did” is one deletion — at the cost of dependencies and ports needing their own copy, and the work landing one merge later than it otherwise would. The alternative we already have evidence for is the agent and him editing one working tree at once, which is the failure this fleet spent a day recovering from.
4. The walk: a peer project in the browser, or a window onto a host? OPFS is per-origin and per-browser-profile, so a browser project is not visible to any other session — including the one he talks to over Telegram. That makes this two products, not one setting:
Recommend C, and proceed on C — because his walk works in both, and the difference is not convenience but whose history it is: the window shape keeps every session looking at the same project, the peer shape keeps working when nothing is reachable but the history lives in one browser profile. So: window by default wherever a host is reachable, peer where he asks for it, with the UI saying which one a project is, and a one-act export for a peer so eviction cannot strand it. Trade-off: the peer shape is the only one where “the agent you talk to from your walk cannot see it at all” is the intended behaviour rather than a fault — which is exactly why it must be labelled rather than inferred.
5. Autonomy’s first stage: dry-run, or straight to a named scope? Options: (A) grant a scope when he asks for it; (B) run dry-run autonomy first — everything still asks, the host records the pattern, and the first scope is proposed from that evidence; (C) something else he has in mind. Recommend B, and proceed on B: it costs a report rather than a permission and it turns the first scope from a guess into a reading, which matters precisely because the act it unlocks has no gate behind it. Trade-off: it delays the thing he actually wants by the length of one honest report. Options A and C are one word each, and §3.8 is the rest of the path either way.
Written for whoever reads it next, because the sections above are not equally trustworthy and the difference is not visible from the prose.
Three instruments have been used, and each caught a different class of error:
| Instrument | Checks the document against | What it caught here |
|---|---|---|
| Review (gemini, Copilot) | itself — coherence, gaps, claims without mechanisms | eleven findings: installs belonged in Tier 2; compliant mode was claimed without a mechanism; the permission payload carried a title instead of a resolved plan; a worktree is not an exception to containment |
Driving (coord’s skeleton, my substrate probe docs/evidence/) |
the world | three innerHTML sinks executing onerror; basename('..') escaping the workspace; the substrate’s --allow-read following a symlink out of the root; the module loader fetching and executing remote code with no flags; Node’s permission model letting fetch through |
| A consumer (astra, building the interface) | what can actually be promised to a user | the OPFS-versus-picked-directory conflation (a gesture I told the UI to ask for, which OPFS does not need) and five machine-only sentences threaded through the project record |
The third is the one that catches a document which is coherent and wrong about the platform — which is what the false gesture promise was. A review reads the document against itself; driving reads it against the world; only a consumer reads it against what it can promise a person.
What has not been tested, and should be read as design rather than as fact: nothing has been built from §1.8 yet; no user has held any of it; the tier table has been driven only through the substrate rows in §1.7, never end to end; and §3.8’s road to autonomy has no evidence at all — it is a design for a destination nobody has reached, and it should be read in that voice.
So: trust the rows that have been driven; treat the rest as design that is internally consistent and unproven. That is the same distinction this document asks of everything else — a claim is worth what enforces it, and so is a section.