feat(env-bridge): Local Environments ready for cloud GA — server policy, the exec click, audit/Stop/settings, posture docs, and the exit gate - #2590
Open
2witstudios wants to merge 129 commits into
Open
2witstudios wants to merge 129 commits into
2witstudios wants to merge 129 commits into
Conversation
…ay mint a grant
The server's say in the three-way intersection (invariant 4) had no
decision function: serverPolicy was a column nothing read. decideSign
answers {ok} | {ok:false, reason} with a FIXED deny order
flag_disabled → revoked → paused → server_denied, tested per adjacent
pair like decideBind. 'paused' is reserved now for the Stop button so
the order is settled once. parseServerPolicy is the untrusting parser
for the stored jsonb: null (deny) for anything not fully recognized.
Pure: no I/O, clock or crypto (source-scanned by the test).
Mutation: 11/11 killed by line index, no-op control green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…s to mint what the policy excludes EnvBridgeClient.sendGrant now runs decideSign over the env's drive_env_local sibling (findLocalByEnvId, parsed strictly) BEFORE the socket lookup and BEFORE signGrantFrame. On a refusal the signing key is never touched, no grant id is minted, nothing is sent, and the caller gets a typed EnvBridgeError kind 'server_denied' carrying the reason (flag_disabled | revoked | server_denied). Every refusal is audited (authz.access.denied on the drive_env). A missing sibling is treated as revoked; a stored policy the parser refuses denies. The refusal keeps its own word all the way to the agent: the production transport maps it onto LocalEnvServerDeniedError (a lib type), and the tool layer answers local_server_denied — distinct from local_not_connected (the requester fixes) and local_bind_denied (the owner's machine policy) because a third party owns this fix: the machine's owner, on the environment's settings page. Mutation: bridge-client 9/9, transport 2/2, tool-runners 3/3 killed; no-op controls green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…in the dialog
POST /envs {substrate:'local'} now REQUIRES serverPolicy (400 without
it — the column's deny-all default is a fail-closed backstop, never a
path a request falls to). The contract schema accepts only ops from
GRANT_OPS, each once, and checkpoint pinned to false (invariant 12).
The policy threads createEnvInDrive → createDriveEnv →
createIfUnderLimit and lands in the SAME transaction as the code hash
(real-Postgres row, non-UTC session).
Dialog: 'This computer' preselects Read files + Write files; Run
commands is OFF behind its own toggle carrying the CLI README's
boundary copy (runs as you, no sandbox); no terminal, no checkpoint
offered.
Mutation: contract 5/5, service 1/1, store 1/1 (Postgres), route 2/2,
dialog 3/3 killed; controls green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
… of is refused at bind Appended LAST to BIND_DENY_ORDER (after bind_policy, so a stranger never learns the policy state): a live, allowed machine whose serverPolicy permits no operation fails at bind with the one reason the owner can fix, instead of binding and then having every grant refused at signing. local-env-gate passes the sibling's serverPolicy from the read it already makes (parsed strictly; drift denies) — exactly one read, pinned. The spawn route maps it to 409 with a message naming Drive settings → Environments; the tool layer answers local_server_denied. Mutation: decide-bind 5/5, gate 2/2, tool-runners 1/1, route 2/2 killed; controls green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
… make two docblocks true DAEMON_SERVER_POLICY claimed to be a stand-in for a server decision that did not exist. Now that decideSign runs at signing, the constant is SERVER_POLICY_CARRIED_BY_SIGNATURE: the server's say is the signature over op — every op in a verified grant was allowed by the server when it signed — so the daemon's server-policy input is satisfied by verifyGrant and widens nothing; the daemon still enforces the two inputs it alone owns. audit-log.ts stops claiming a server-side join exists: the daemon's JSONL is the one side of it until the visibility phase writes the other. Both pinned by source-reading rows in invariants.test.ts; intersectCapabilities keeps its tests. Mutation: constant value 2/2 killed by the dispatcher suite; control green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…t on the row's owner (D-6)
PATCH /envs/[envId] accepts { serverPolicy } (exactly one of name |
serverPolicy per request: two fields, two rules). The caller must be
drive_env_local.ownerId — the enrolling human, never a drive role: a
drive admin who did not enrol the machine is 403 naming the owner,
audited; a plain member who did enrol it may. The store write is ONE
UPDATE … WHERE envId AND ownerId AND revokedAt IS NULL returning the
row count; the service reads the row only after a lost CAS, to choose
the honest word (not_found | revoked | not_owner). Real-Postgres rows
(non-UTC session): owner ok; non-owner refused with the row
byte-identical; revoked refused; missing false. The rename keeps its
owner-or-admin rule, tested apart.
Also: single-row reads of a LOCAL env now go through the facts join
(readEnvDTO); the bare toDriveEnvDTO throws for a local row by design,
which made GET a 500 for every local env.
Mutation: route 5/5, store 3/3 (Postgres), service 3/3 killed;
controls green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…ns|members structurally (D-6, invariant 13)
BindPolicy is 'owner' and that is the whole closed set: the values that
would have widened it are gone from the type, from decideBind (the
default branch still denies drift — a row somehow holding a removed
value denies every non-owner, tested), and from the schema CHECK via
db:generate (0291_late_selene.sql: DROP + ADD CONSTRAINT … IN
('owner'); second generate 'No schema changes'; drizzle-kit check
clean; never hand-edited). Real-Postgres row: inserting 'admins' or
'members' is refused 23514.
There is no actor ROLE in the gate's input any more: local-env-gate
no longer resolves a drive role at all (resolveDriveActorRole and the
provision deps' seam are deleted), so no caller can widen the answer
by fetching one. The GDPR export keeps emitting bindPolicy, so Art 15
output does not change shape. Invariant 13 is on the epic page.
Mutation: decide-bind 3/3, gate 1/1 killed (the second gate mutant —
hard-coding 'owner' instead of reading the column — is EQUIVALENT
under D-6: the column no longer changes any verdict, which is the
point).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…its only principal (D-6) The enroll answer now carries ownerId (no secret — the enrolling user's id). If no policy file exists, enroll writes one at the daemon's path (PAGESPACE_ENV_POLICY or ~/.pagespace/env-policy.json, 0700/0600, wx): mode ask, no pre-approved ops, the enrol directory as the only root, principals [ownerId] — so this machine refuses everyone but its owner on its own, even if the server were wrong (defence in depth for invariant 13). An existing policy is never overwritten; one that does not name the owner gets a warning that the owner's own requests would be denied principal_not_allowed. A hand-edited policy naming others is honoured (owner's machine, owner's call), and env policy / env connect print a warning naming D-6. A write failure never fails the enrollment (the key is pinned) and never prints the key. --json reports the policy path and whether it was scaffolded. Mutation: policy.ts 2/2, env.ts 5/5 killed; controls green. Changelog entries (root + CLI) and README updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…env-ga The GA waves of the Local Environments epic are PRs against that stacked branch, not master; neither workflow listed it as a BASE, so PR #2582 opened to a check-less rollup. Both lists are base branches (see the comment security.yml already carried on exactly this misreading). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…Codex P1 on #2582) 'Run commands' stayed on from the previous create step, so the next local environment inherited command execution instead of the documented safe default. Files on, commands off, every time the step opens — beside the name/substrate/label reset. Rows: enable exec, cancel, reopen ⇒ off; enable exec, create, open another ⇒ off. Mutation 2/2 killed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…erver_ops fact instead of naming a page that does not exist; policies may name only implemented ops (Codex P1 + P2 on #2582) - The local DTO variant now carries serverPolicy (the row through the same strict parser the signing gate uses; drift projects as deny-all), so wave 3's settings page reads it from the listing and the POST/GET answers. The editor itself is wave 3. - The spawn route's no_server_ops message states the fact — the owner has not allowed the environment to run anything yet — and names the API field for a technical reader; the settings-page pointer returns when that page lands. - SERVER_POLICY_OPS = ['exec','fs_read','fs_write'] is what a policy may name today (GRANT_OPS stays the wire vocabulary); a pty_open-only or mixed policy is 400 at create and PATCH rather than a bindable env the daemon refuses everything on. Mutation: contract 2/2, projection 3/3, message 1/1 killed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
… bash (Codex P2 on #2582) writeSandboxFile, readSandboxFile, readSandboxFileForCopy and both legs of editSandboxFile now map LocalEnvServerDeniedError to the typed denial. One row per runner plus the read-ok/write-denied edit case. Mutation 5/5 killed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…ffold step exits non-zero (Codex P2 on #2582) The scaffold is parsed with the same strict parser connect uses before it is written; enrolling from '/' (or any refused root) writes nothing, says the enrollment succeeded, exactly why no policy was written, and what to write. A scaffold failure (refused root, unwritable path) now exits non-zero so a script notices, while the key stays pinned. Mutation 2/2 killed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…w required on the local variant) apps/web#typecheck went red on two env-groups fixtures typed as DriveEnvDTO; vitest does not typecheck. The sidebar's shared localEnv fixture gets it too. apps/web tsc --noEmit: no errors in touched files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…now reads; prove server_denied through the ws route Six rows in route.security.test.ts and revoke.test.ts drove a grant through the real EnvBridgeClient with a sibling that carried no serverPolicy, so the gate refused (no policy ⇒ deny, as it must), the 'pending' grant was never pending, and two rejected promises nobody awaited became the job's 2 Errors. The fakes now carry a live sibling allowing the op under test and settle the client's dynamic store import before reading the socket; the audit mocks export audit(). New row: a sibling whose serverPolicy excludes exec ⇒ sendGrant rejects server_denied and no frame reaches the socket. The gate is untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
…needs With the flag unmocked the signing gate answers flag_disabled — the right answer — so the grant was never pending. The fake, not the gate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5BeXN5nJqUpLfJArq4ye2
feat(env-bridge): serverPolicy enforced at signing + owner-only binding (GA wave 1)
…ovals keyed (envId, userId, op, subject), consulted after normalisation (GA wave 2, leaf 2) An approval is keyed on what the owner looked at — the resolved program for exec (every program a `sh -c` script names; the shell is never a subject), the policy root for file ops — never a session. `matchApproval(approvals, grant, request, now, deps) → covered | ask | expired` has no I/O: the PATH resolver and clock are injected. `decideExecution` consults it AFTER confinePath and scrubEnv, against the normalised request, so a retargeted symlink cannot ride an old approval; the existing LocalApproval byte-compare path is untouched as the fresh-prompt path. The approvals file schema is strict: any defect ⇒ null, never partial. Mutation 8/8 red (skip-the-match, unconfined paths, each key part, expiry boundary, shell lexing bypass, null subjects) with a no-op control green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…chosen expiry, persisted in ~/.pagespace/env-approvals.json (GA wave 2, leaf 1) The ask prompter no longer remembers anything: the in-process Set keyed (userId, sessionId, op) is gone. The dispatcher consults the durable store through decideExecution (after normalisation) and writes an approval ONLY after the owner's byte-compared approval, under the subjects the prompt named — the resolved program (every program a `sh -c` line names), or the policy root — never the session. Scope once | session | 30d (default) | until_revoked; `once` and unresolvable subjects remember nothing. The file follows the policy file's trust rules through the same one-descriptor adapter; any defect ⇒ empty, never partial; a refused file is never overwritten; writes are atomic 0600 and never throw into the dispatcher. `env connect` reports the file at start and the terminal prompt gets a scope picker. README and changelog say what an approval now covers. Mutation 10/11 red with a no-op control green; the survivor (`answer.scope !== 'once'` in the dispatcher) is equivalent — the store drops `once` itself, and that guard is mutation-tested. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…nv-policy.json — exec never (GA wave 2, leaf 3)
The enroll answer now carries the env's serverPolicy (it was already in hand;
no secret — the owner chose it). `pagespace env enroll` writes
`ops = serverPolicy.ops ∩ {fs_read, fs_write}` into the scaffold: Tier A file
work inside the root runs headless from the first connect. `exec` is never
written into machine ops, however permissive the server policy, so a command
reaches the daemon's `ask` verdict by construction (Tier B). The wire value is
shape-checked strictly (`[]` for anything unrecognised). Wave 1's
never-overwrite behaviour stands; on an existing file enroll prints the diff
it would have written.
Mutation 5/5 red (exec in the headless set, skip the shape check, return the
server's ops unfiltered, drop the diff, drop serverPolicy from the route) with
a no-op control green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…he request and answers ask_pending:<id> (GA wave 2, leaf 4) Tier B's first half on the machine. On an `ask` verdict with no prompter (or PAGESPACE_ENV_ASK=chat), the dispatcher freezes the normalised request under a challenge id whose TTL is the grant's exp, audits `ask:pending:<id>`, and replies `grant_denied ask_pending:<id>` carrying the frozen request — a new optional `pending` field on the frame, covered by the machine's result signature so the chat card can only show what the machine froze. The store is bounded (64) and evicted synchronously like nonce-store.ts; a second ask for the same subject reuses the pending id. `env connect` starts in ask mode without a TTY now instead of refusing. Mutation 7/7 red (drop the TTL ⇒ "expired challenge is refused" red; no eviction; no reuse; no bound; pending stripped from the reply; preferChat ignored; store unwired) with a no-op control green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…alIntent is byte-compared by the daemon against the request it froze (GA wave 2, leaf 7)
Pure core: `Grant.approvalIntent { challengeId, scope, expiresAt }` (optional,
strict) is signed with the grant under the pinned key — appended to the
canonical bytes only when present, so every existing grant keeps its bytes
and a click cannot be added, edited or moved onto another grant in flight.
Daemon: on a verified grant carrying an intent, the dispatcher looks the
frozen request up in the challenge store, refuses an unknown id
(`approval_unknown`), a late click (`approval_expired`), or another user's
(`approval_mismatch`), then re-normalises the NEW request and byte-compares it
against the frozen one through the existing LocalApproval path. Only a match
spends the challenge, writes the durable approval under the challenge id, and
runs; a mismatch executes nothing, is audited, and leaves the genuine question
pending. Every refusal reaches the JSONL with the grant id.
EXIT CRITERION mutation: removing the sameBytes compare in decide-execution,
or feeding the daemon's compare the new request instead of the frozen one,
turns "a chat click cannot introduce a request the machine did not frame"
red. 5/6 further mutants red with a no-op control green; the survivor is the
redundant `once` guard (the store drops `once` itself, mutation-tested).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…injected only for a local-env session, never ask_user (GA wave 2, leaf 5) A sandbox runner that meets the machine's `ask_pending:<id>` now answers the agent `local_approval_required` carrying the challenge id (every runner, not only bash). The new execute-less `request_env_approval` tool is injected in both chat turns with ask_user's discipline — after every transform, never in baseTools / tool_search / execute_tool — and ONLY when the conversation's bound session is on a local environment. It pauses the turn like ask_user; the pausing-tool set is now defined once (`pausing-tools.ts`) and the answer/resume plumbing consults it instead of six string comparisons, with each tool validating its own client result. A new chat message dismisses pending questions but never a pending click. The card fetches the FROZEN request from the approvals route (as the machine signed it: principal, op, argv, cwd, paths, env, limits — the fields the daemon prompt renders), never the model's paraphrase; Allow/Deny post the owner's decision and submit the route's answer under the tool's own name. The authorization never passes through the model: a test pins that neither the tool module nor the card imports ask_user. Mutation: the chat-turn guard, the runner prefix check and the resume schema each go red when removed; control green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…ver-signed approvalIntent — env OWNER only (GA wave 2, leaf 6)
`EnvBridgeClient.sendGrant` remembers a machine's `ask_pending:<id>` answer
(the unsigned frame as sent, the principal, the grant exp, and the frozen
request the machine signed) in a bounded, TTL'd pending store — only when the
reason's id and the signed pending body agree. `signGrantFrame` signs an
optional `approvalIntent { challengeId, scope, expiresAt }` INTO the grant
under the same pinned key.
`GET|POST /api/env-bridge/approvals/[challengeId]`: the clicker must be
`drive_env_local.ownerId` — a drive admin is 403 and audited ([D-6]); after
the challenge TTL the answer is 410 `approval_expired`; Allow spends the
question and sends the SAME frame under the SAME principal with the intent;
the machine's typed refusals (`approval_mismatch` / `approval_expired` /
`approval_unknown`) are relayed, never rewritten as success. Every answer is
audited on the env with the challenge id.
Mutation 5/5 red (owner check, intent dropped from the re-issue, server TTL,
id-agreement guard, intent dropped from the signer call); control green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…oke frame; the machine file stays authoritative for allow (GA wave 2, leaf 8)
The `revoke` frame gains an optional `approvalId`, signed under its OWN
domain (`revoke-approval/v1`) over {envId, enrollmentId, keyId, issuedAt,
approvalId}: a captured approval revoke with the id stripped is bad_signature
(never a key deletion), an enrollment revoke with an id added is bad_signature
too, and the enrollment-revoke bytes are exactly what they were. The daemon
deletes exactly that approval's rows, audits `approval_revoked:<id>:<n>`,
reports `approval_revoked` (never `revoke_verified`) and stays connected.
apps/web: `revokeLocalEnvApproval` sends the frame over the env's live
authorized socket without closing it and is honest about reach
(`no_live_socket`); `DELETE /api/drives/[driveId]/envs/[envId]/approvals/
[approvalId]` for the env owner or a drive admin (Revoke stays with admins,
D-6), 409 when the machine is not there to receive it.
THE ASYMMETRY, pinned: invariants.test.ts reads the source — the store touches
only the local file, the dispatcher writes an approval in exactly two places
(both after a byte-compared allow) and never from a revoke; a behaviour row
proves a revoke, a grant and a click for an unframed challenge add nothing.
Mutation 5/5 red (shared domain, delete skipped, approval revoke answered as
an enrollment revoke, id dropped from the frame, owner-or-admin check);
control green. Changelogs and README updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…uthoritative for ALLOW, a server record is never read to grant; approval_mismatch for an unframed challenge (GA wave 2 follow-up) Two rows the leaf page requires, mutation-checked: - invariants.test.ts (a): the ONLY writer to the approvals store is the daemon's own remember() after a byte-compared allow — both call sites sit inside handleGrant after the LocalApproval compare, none after handleRevoke, the remembered subjects come from the daemon's own state, and no other daemon file calls remember. Mutant: a fake ping-frame branch writing an approval from a frame ⇒ red. - invariants.test.ts (b): the frame codec has NO frame type that can ADD an approval; `approvalId` appears once (on revoke), `approvalIntent` never. Mutant: an `approve` frame type ⇒ red. - dispatcher.test.ts: COMPROMISED SERVER — a server-signed grant whose approvalIntent names a challenge this daemon never froze ⇒ approval_mismatch, nothing executes, nothing remembered, across a retry. Mutant: an unknown challengeId falling through to a manufactured frozen request ⇒ red. An unframed challenge is now `approval_mismatch` (the click matches nothing the machine framed) rather than a separate word; the route relays it as `mismatch`. knip: the file serialiser moves into the CLI store (its only caller), an unused input type export is dropped, the card uses AskUserOutput. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…cho "$(rm x)"` can no longer ride an echo approval (Codex P1 on #2583) The lexer accepted every character inside a double-quoted string, so `$(…)`, `${…}` and backticks in double quotes were invisible and the line resolved to its outer program. Double quotes do not stop substitution; single quotes do (POSIX). Rows: `echo "$(rm -rf x)"`, `echo "`rm x`"`, `echo "${HOME}"`, `${x:-$(rm x)}`, a quoted `--format=$(…)` ⇒ ask; `echo '$(rm x)'` ⇒ covered by echo. Mutation: removing the check ⇒ red. Swept: no other shell-line classifier exists (command-resolver resolves a bare argv0 only). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…s on agent-supplied input (CodeQL js/polynomial-redos on #2583) `token.replace(/[)}]+$/, '')` and `root.replace(/\/+$/, '')` were polynomial on a long run of the repeated character. One pure `trimTrailing(value, chars)` walks back from the end and serves both sites. Rows: identical results on the ordinary cases; a 100k-char run of `)` (and of `/` in a root) resolves in bounded time with the same answer; a source row keeps `[…]+$` out of the file (the two remaining `.+$` schema anchors are linear). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…nd POST — the click now carries the CSRF token the route requires (Codex P1 on #2583) A raw fetch answered 403 CSRF_TOKEN_MISSING on the requireCSRF POST, so the owner's click could never answer. Both requests now use the repo's auth fetch helper (session cookie + X-CSRF-Token, refresh-and-retry on a CSRF 403). Card test: the helper is mocked and asserted for the GET and the POST, and a global raw fetch stub throws if anything bypasses it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…tool output bounds so the result always merges (Codex P1 on #2583) A ~786 KiB exec_result was forwarded whole, failed the 200k/50k schema at resume, and left the tool call pending. The bounds are now exported once from env-approval-tools (`ENV_APPROVAL_STDOUT_MAX_CHARS`, `ENV_APPROVAL_STDERR_MAX_CHARS`, UTF-16 units — what zod measures) and used by both the schema and the route's `outcomeOf`, which slices to them and sets `truncated: true` when it cut. Row: a 300 KiB stdout / 60 KiB stderr reply answers 200 and validates against `requestEnvApprovalOutputSchema` with `truncated: true`; a small reply is untouched. Mutation: skipping the cut ⇒ red. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7yLZ7eEUvtJf6DZNfmeQP
…cally, and prove more while doing it The per-line suppressions did not clear alerts 340/341: this repo runs `security-extended` through GitHub's default setup, which does not honour inline `codeql[...]` comments. They are kept, reworded to say plainly that they are documentation and not a control. What clears it is breaking the taint. CodeQL was tracing a value from `validateOAuthAccessToken` / `authenticateOAuthRequest` into the fixture's SHA-256 calls, because the challenge being signed came from the ROUTE's GET response. It is now derived locally by `deriveOwnerApprovalChallenge` over literal inputs, and the route's value is ASSERTED equal to it before signing. That makes the test prove strictly more than it did: every row now pins that the server's per-scope challenge IS the pure derivation, rather than assuming it. The end-to-end row still builds real authenticator data with the real rpIdHash, still signs `authenticatorData || SHA-256(clientDataJSON)`, and still runs the daemon's own verifier over the result — nothing was weakened to suit the analyzer. Four rows pass; web tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
…can take The migration test loaded migration 0291 by number and asserted it did exactly one thing — narrow the `bindPolicy` CHECK. Both halves of that stopped being true when master landed its own `0291` and the chain was re-derived onto it: 0291 is now master's, and this branch's five migrations collapsed into one that carries the whole GA delta. So it now finds the migration by CONTENT (the `bindPolicy` CHECK it adds), which survives the next renumber, and asserts the whole delta rather than a single statement: the CHECK narrowed to `'owner'` with no `'admins'`/`'members'` left, both new tables, and EXACTLY `daemonEpoch` and `pausedAt` added to `drive_env_local` with nothing dropped or retyped. That is strictly more than the block it replaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… actual cause of the 5s timeout MEASURED FIRST, and the eager-import hypothesis did NOT hold. Timing `run.test.ts` with the four env routes left eager vs made lazy, three runs each: eager: transform 365/356/371 ms, collect 722/674/783 ms, tests 4.35/3.79/2.90 s lazy: transform 366/666/368 ms, collect 742/1260/685 ms, tests 4.00/3.94/3.72 s Indistinguishable. In the built dist the whole env subtree is ~35 ms (`lib-core`), ~35 ms (`commands/env`) and ~65-89 ms (`env/connect`) against a 382 ms route-table import, and the test BODY — not collection — is what takes seconds. So the env import is not what blows the budget, and the routes are left eager. (Whether the published CLI should load the bridge lazily is a real question, but it is a startup-cost question, not this failure, and it belongs with the open bundle-or-publish decision rather than here.) THE ACTUAL CAUSE, found by instrumenting `fetch`: `makeDeps` fakes the credential store but nothing faked the network, so every row that resolves a credential and runs a command makes REAL outbound requests to the production host. `run(['whoami'])` alone does two — `https://pagespace.ai/api/auth/me` and `/api/drives`. Locally that is ~235 ms and invisible. On a CI runner where egress to that host is blocked or slow it hangs until the socket gives up, which is exactly `Test timed out in 5000ms`, intermittently, on whichever branch drew an unlucky runner. That is why it tracked no code change and why it "passed on retry". Fixed at the cause: `fetch` is stubbed for the whole file (401, the honest answer for a fake credential — every row asserts on what the CLI SAYS, never on a server payload), and a new row pins that the stub is load-bearing by naming the exact production URL this file used to hit, so removing it fails readably instead of becoming a timeout somebody calls flake. The flagged test now runs below vitest's reporting threshold; 42 rows pass, and the whole CLI suite is 1393 green. WHY THIS IS IN A PASSKEY PR: it was the last red check on #2599 and it also fails on `pu/local-env-ga`, so it blocks this branch and #2590. It is a test fix only — no production code changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyyANbQkCSQGL611UEu8id
feat(env-bridge): the machine verifies the owner's click itself — a passkey assertion bound to the frozen request (hardening B)
… chain Master moved 123 commits and took BOTH 0292 and 0293 (`cold_the_phantom`, `backfill_message_attachments`), colliding with this branch's own pair. Three conflicts, all resolved by keeping both sides: - `packages/db/drizzle/**` — took master's chain wholesale, deleted this branch's `0292_round_robbie_robertson` and `0293_serious_devos` with their snapshots, and re-derived the whole delta with `bun run db:generate` into one migration on top of master's: `0294_secret_moira_mactaggert.sql`. Nothing hand-edited. It carries every change this branch makes — `drive_env_grant_audit`, `drive_env_approvals`, `drive_env_local.pausedAt`, `.daemonEpoch` and `.ownerCredentials`, and the `bindPolicy` CHECK narrowed to `'owner'` — with its indexes and foreign keys. Proofs: a second `db:generate` says "No schema changes, nothing to migrate"; `drizzle-kit check` says "Everything's fine". - `CHANGELOG.md` — additive both sides, kept both. The migration test finds its migration by CONTENT, so the renumber did not retarget it. Its one expectation moved deliberately: the delta now adds three columns to `drive_env_local` rather than two, because `ownerCredentials` used to live in its own migration and is now folded into the same file. 22/22 green. Posture doc citations updated to the new filename and number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KM8i1q6MosQbJ6dSnhvfHi
…assistant, default off Leaf A of "a global assistant reaches any environment it may see". The global assistant is the one agent whose context spans every drive a person belongs to, so what it may reach is a deliberate choice rather than a consequence of ownership. `drive_envs.visibleToGlobalAssistant` records that choice: NOT NULL, DEFAULT false, and the absence of a value is never a grant. The flag lives on `drive_envs`, not on the local sibling, so a cloud Sprite env and a user's own machine answer one question through one column — the schema docblock says why. A Sprite env has no enrolling owner, so no surface can set it true for one today: fail-closed for that substrate by construction rather than by omission. Visibility is not authority. Turning it on changes nothing about who may drive the machine — `decideBind`'s owner-only bind ([D-6], invariant 13) is untouched, and the test asserts a non-owner is still refused on a fully visible env. Changing the flag is the OWNER's alone, decided by `drive_env_local.ownerId` and never by a drive role: the store's compare-and-set is one UPDATE on `drive_envs` whose owner predicate is a correlated EXISTS on the sibling. `decideEnvReach` (pure) is asked on EVERY call rather than once at bind, which is what makes turning visibility off refuse the next call instead of honouring an earlier reach. Its three refusals — not_found, not_owner, not_visible — surface ONE sentence (`ENV_UNREACHABLE_MESSAGE`), so an id that does not exist and one the caller may not see are indistinguishable from outside. Migration 0295 from `bun run db:generate` (second run: "No schema changes"; `drizzle-kit check`: "Everything's fine"). `drive_envs` is table-level excluded from the GDPR export, so the new column needs no collector. Mutation: 10/10 killed, 2 no-op controls survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
… opaque ids Leaf B of "a global assistant reaches any environment it may see". The mandatory id the next leaf adds is only safe if the model gets it from somewhere rather than inventing it. This is that somewhere. `listVisibleToGlobalAssistantByOwner` applies TWO conditions in SQL, not one: the caller OWNS the machine (`drive_env_local.ownerId`) AND the environment is visible to the global assistant. Ownership is the real access filter and is deliberately not drive membership — a drive relationship is not an entitlement to every row inside it (the trap PR #2609 hit) — which is also why a machine in a drive the owner has since LEFT is still listed: it is their computer. A revoked machine is excluded: listing it would hand the model an id guaranteed to refuse. Each row carries an opaque id, a human label, the substrate and the owning drive, so the model addresses by id and speaks to the person by label. The conversation's OWN sandbox is a row like any other, listed first and addressed by the conversation's own id — the address the runtime already resolves a session through — so leaf C leaves no implicit path a model can take while believing it is somewhere else. An empty list is never returned as an empty list: a model handed `[]` with a mandatory id to fill has every incentive to invent one, so the answer says in words that there is nothing to copy. Every answer, empty or not, carries the anti-invention instruction — July's post-mortem (`cf576fbc1`) records that the prompt and the tool descriptions actively encouraged the bad value, so the wording is part of the fix. `GET /api/env-bridge/environments` is the HTTP surface, 404 when the flag is off like every other route in the family; the tool answers an empty environment set on that path rather than a bare failure. Mutation: 7/7 killed, 2 no-op controls survived. The store's own two-condition predicate is pinned by three new rows in the Postgres integration suite (CI). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…cution tool Leaf C of "a global assistant reaches any environment it may see". `bash`, `writeFile`, `readFile` and `editFile` now take a REQUIRED `environmentId`. Three properties, each the fix for a different way July's removed `target` went wrong (`cf576fbc1`): - **Mandatory.** Omitting it is a zod validation error the model sees and can correct — never a silent fallback to the conversation's own sandbox. The fallback is removed at every layer, not just the schema: `acquireSandbox` refuses `missing_environment` rather than picking a default, and the two deliberately-unaddressed consumers (the git/gh toolkit, `copy_content`) now name the conversation's own target explicitly. - **Opaque.** The zod boundary constrains the SHAPE, not free text. `isCuid` is a loose heuristic that accepts `main`, `staging` and `prod` — precisely the values the post-mortem saw invented — so the schema also requires 20-32 characters, which every real 24-character cuid2 satisfies and no plausible invention does. - **Described.** Every tool description says to copy an id from `list_environments` and never to construct one. July's post-mortem records that the prompt and the descriptions actively encouraged the bad value, so the wording is part of the mechanism. `productionResolveEnvironmentTarget` resolves the id server-side: the conversation's own id addresses its own sandbox (so the default sandbox is named exactly like any other environment and has no implicit path), and any other id must pass the pure `decideEnvReach` — it exists, the caller owns it, and its owner made it visible. All three refusals, plus the flag-off case, surface ONE sentence, so an id that does not exist and one the caller may not see are indistinguishable from outside. Visibility is re-read on every call, so switching it off refuses the next call rather than honouring an earlier reach. The resolved target rides the context to the runner as `ctx.environment`, with `label` read from the ROW and never echoed from the model's input — what leaf E names in results. INTERMEDIATE STATE, closed by leaf D: a valid, reachable NAMED environment resolves and is then refused `environment_routing_not_enabled` at acquire, rather than running somewhere the caller did not name. Leaf D replaces that one branch with real routing. Mutation: 10/10 killed, 3 no-op controls survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
Leaf D of "a global assistant reaches any environment it may see" — the blocker [D-4] recorded, closed. `spawnAgentSession` compared `env.driveId !== driveId`, and a dashboard conversation carries `driveId === null` against a NOT NULL column, so the comparison could never succeed. It now branches on substrate: - a LOCAL env goes through `gateLocalEnvBind` — [D-6] made binding structurally owner-only (`decideBind` compares the requester to `drive_env_local.ownerId`, with no actor role anywhere in its input), so the ownership check already exists, is load-bearing, and is what a driveless session passes or fails on. This removed a redundant scoping check, not an ownership one. - every OTHER substrate keeps the drive agreement unchanged and for the original reason: a Sprite env is drive-owned with no owner of its own, and `decideAgentSessionAccess` derives access from `driveId` alone, so a driveless session bound to one would route work into a drive's shared filesystem through a path that never looked at that drive. A driveless spawn is still refused a Sprite env, structurally. `agent_workspaces_env_needs_drive_check` is dropped (migration 0296, generated; second run "No schema changes", `drizzle-kit check` clean) because it could not express that distinction. Its schema docblock is rewritten to say where the guarantee now lives, and the service's matrix pins BOTH arms — including the negative that a non-owner is still refused a local env with or without a drive, so removing the redundant check cannot have made the bind gate the only thing standing between a stranger and someone's hardware. Routing: a named environment runs in ITS OWN session, found or spawned once per (owner, environment) via `findActiveByOwnerAndEnv`. One conversation naming two environments holds two sessions — never one session re-pointed, which is the predecessor's `switch_machine`: it races every concurrent call in the same session and silently moves work a previous call believed was elsewhere. That session is driveless (invariant 1) and holds no conversation: it is a working context a conversation borrows by naming it, not a thread container. Billing and authorization follow the ENVIRONMENT, not the conversation. The resolver returns the payer coordinates alongside the target, the call-time gate runs on those, and `resolveBillingSession` resolves the same session through the same find-or-spawn — so a drive environment bills that drive's owner exactly as it does today, and a credit-exhausted or ineligible payer is refused before a sandbox wakes. A vanished drive fails the call closed rather than charging the caller. No new refusal vocabulary: a paused, revoked, disconnected or policy-refusing environment refuses through the existing bind/sign gates with the existing typed reason, and `reconnect` still resolves a local address through the host registry with the ACTING principal — so a mis-addressed call at the owner's own machine still waits for their passkey-verified click. Mutation: 7/7 killed, 2 no-op controls survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
Leaf E of "a global assistant reaches any environment it may see".
With a mandatory id the remaining failure mode is copying a REAL id for the
WRONG environment — a call that succeeds, somewhere nobody meant. Every
`bash` / `writeFile` / `readFile` / `editFile` result now carries
`environment: { id, label }`, which puts the target in the model's context each
turn and makes a wrong one visible to the person immediately rather than only to
whoever opens the audit log.
Both fields come from the SERVER's resolved target and are never echoed from the
tool's input — the test names a different id than the resolver returns and
asserts the result reports the resolver's. If they were echoed, a mis-addressed
call would confirm the address the model already believed, which is the opposite
of what this is for.
Refusals name it too, so a wrong target reads as a wrong target rather than as a
broken tool — but only refusals that got far enough to resolve one. A refusal
that never resolved an environment names none: there is nothing to name, and
naming one would be inventing it.
Stamped in ONE place at the tool boundary rather than at the several dozen sites
that build a result, so a runner added later cannot forget. It records nothing
new: `drive_env_grant_audit` already carries `envId` for every grant, and the id
surfaced is that same id — asserted, alongside the audit-write count staying at
one.
Mutation: 4/4 killed, 1 no-op control survived.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…elog for the global assistant's reach Leaf F of "a global assistant reaches any environment it may see". The posture document gains a section that records what changed and states plainly that the global assistant is the agent most exposed to prompt injection in the product — its context spans every drive the person belongs to, so R-6's surface is not one drive's shared pages but all of them at once, which is the reason its reach is a deliberate per-environment choice and default off. It records the decision behind the mandatory opaque id with the July evidence that produced it: an optional free-text `target` on these exact tools, removed two days later (`cf576fbc1`) because the model habitually invented a plausible value, and the post-mortem's finding that the prompt and descriptions actively encouraged it. Three new layer rows, two new register entries (R-16 session-level taint, R-17 a real id for the wrong environment), two new flag-on checklist items, and [D-4] rewritten from deferred to resolved. Session-level taint is named as the remaining gap rather than left implied, on every surface: this phase adds *visibility* and *addressing*, and neither is a control on what the assistant was persuaded to want. **The local-only limit is stated everywhere, because it would otherwise be discovered.** A Sprite (cloud) env has no enrolling owner, so nothing can make one visible and the discovery list is local-only in practice: what ships is "any local machine you made visible, plus this conversation's own sandbox", not "any sandbox". The posture doc also says what would decide a cloud env's visibility if that is ever taken — a drive role rather than an enrolling owner, most plausibly the drive owner alone, and what it would change in `decideEnvReach` and the discovery read — and files it as its own decision rather than building it. The customer page carries the same limits as the internal document and no capability the document does not: each new claim is followed by its exact limit, including that a real id for the wrong machine is still possible and that nothing treats a conversation as tainted by what it has read. The changelog tells a person what they must turn on, by name and by page, for any of this to happen. A documentation test pins all of it, for the same reason `tool-registry-docs` exists: these are the claims an operator and a machine owner act on. Mutation: 5/5 killed, 1 no-op control survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…sture document Review catch: the posture document — what an auditor and the founder actually read — was silent on the most consequential change in the phase. `grep -c env_needs_drive` returned 0, the layers table had rows for visibility, the mandatory id and the named result but NONE for routing, and migration 0296's DROP of `agent_workspaces_env_needs_drive_check` appeared only in the schema file's docblock. Three places, because a dropped constraint is exactly what [D-4] warned about: - a LAYERS row for the drive agreement, naming both branches — `gateLocalEnvBind` (owner-only, [D-6]) for a local env, the surviving `env.driveId === driveId` for every other substrate — with the verdict that this row exists because the guarantee MOVED: from a database CHECK that cannot be got wrong to two code branches that can; - PROSE saying the CHECK was dropped, why it was right about a Sprite env (still enforced, and pinned by a negative test rather than inferred) and wrong about a local one (where [D-6] already made binding structurally owner-only, so this removed a redundant scoping check rather than an ownership one), and where the guarantee lives now; - R-18 in the residual register for the trade itself: a CHECK is enforced against every writer forever, including one nobody has written yet; two branches are enforced only against callers that go through them. Any new writer of `agent_workspaces.envId` must go through `spawnAgentSession`. The documentation test lands with the prose and asserts all three, so they cannot drift apart. Mutation: 3/3 killed (heading removed, layer row gutted, R-18 emptied). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…nmentId comes from The other half of July's fix. `cf576fbc1`'s post-mortem found that the PROMPT and the tool descriptions both encouraged the invented `target`; leaf C fixed the descriptions with the field, and the sandbox guidance section said nothing about addressing at all — so a model meeting a newly-required field would learn where an id comes from only by failing a call first. One bullet, gated on actually holding one of the four addressed tools (the git/gh toolkit and the shell family take no `environmentId`, and telling them to pass one is the same class of mistake). It says the two things that matter: call `list_environments` and copy an id exactly — never construct, guess or shorten one, and there is no default — and read back the environment every result names, because that is what makes a wrong target recoverable. Mutation: 2/2 killed (bullet dropped; bullet named for tools that take no environmentId), 1 no-op control survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…vironment Codex P1 on #2616, and it broke a promise the product makes in three places: the column is `visibleToGlobalAssistant`, the settings toggle says "Let your global assistant use this machine", and the changelog says the same — but nothing enforced it. `buildSandboxTools()` is constructed ONCE (`ai-tools.ts`) and shared by every turn, `resolveSandboxToolEligibilityForConversation` is called from both the global and the page chat pipelines, and `decideEnvReach` took only `{ actorId, env }` — so by construction nothing downstream could tell a dashboard turn from a page turn. A sandbox-enabled page agent in a drive the person can execute in could list and address the machine they had switched on for their dashboard assistant. Switching a laptop on for the assistant you talk to from the dashboard is not switching it on for every sandbox-enabled agent in every drive you belong to. The fix puts the fact in the PURE decision rather than at a call site, because there are two call sites and they must not drift: `decideEnvReach` gains `conversationKind` and refuses `not_global` FIRST, before the row is consulted, so a page turn cannot probe for an id's existence through the deny order or through a read it was never entitled to make. `conversationMayReachPersistentEnvironments` is the one predicate discovery and resolution share. `conversationKind` rides `SandboxActorContext`, resolved from the chat source and failing CLOSED: only an explicit `'global'` is global, and a surface that did not say what it is reads as `'page'`. This narrows PERSISTENT ENVIRONMENTS, not code execution — a page conversation still reaches its own sandbox exactly as before, and there is a row for that. The toggle copy is unchanged: the promise was already the narrow one, and the code now matches it rather than the copy being widened to match the code. Rows: a page conversation gets the own-sandbox row and nothing else and never touches the flag or the store; a page conversation naming a REAL visible env it owns is refused with the SAME sentence as an unreachable one, while the same id from the dashboard assistant resolves; an absent kind fails closed. Mutation: 5/5 killed, 2 no-op controls survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
… not drive membership Codex P2 on #2616: the owner-after-departure case the discovery list deliberately advertises always failed. `list_environments` still shows a machine in a drive its owner has left — "it is their computer", which the leaf required and the machines route has always done — but every call on it was refused `no_drive_access`: both gates passed the env's `driveId` to `canRunCode`, and `authorizeUser` denies when `getUserDrivePermissions(...).hasAccess` is false. The entitlement for a local env is OWNING THE MACHINE, and the codebase already says so: [D-6] makes binding structurally owner-only, `decideBind` compares the requester to `drive_env_local.ownerId` with no actor role in its input at all, and `decideEnvReach` has just proved the same thing. Authorizing against the drive as well was a redundant check with a real cost — the same shape as leaf D's drive comparison, and removed for the same reason. `canRunCode` already keeps the two legs apart, so the seam existed: the drive is now supplied as the PAYER coordinate only. The tool path carries it as `SandboxPayerCoordinates.gateDriveId` (absent for a local target, present for every other substrate and for the conversation's own sandbox); the bind path calls `canRunCode` with no `driveId`. The kill switch and the tier leg — resolved against the env's drive owner — both still run, and a non-owner now falls to `bind_policy`, which is the accurate reason rather than a drive one. **The consequence is stated, not slipped in** (R-19 and a new layers row): a person who has left a drive can run on their own machine and that drive's owner is still metered for the active runtime, because the env row is drive-owned and drive-paid. The drive's remedy is the one [D-6] already gives it — Delete or Revoke, both held by drive admins and neither needing the owner. Rows on both paths: the departed owner runs; the billing coordinate is unchanged; the kill switch still refuses them; the tier leg still refuses them and still keys on the env's payer; a Sprite env keeps the drive-role leg. Mutation: 4/4 killed, 1 no-op control survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…nversation's payer Codex P1 on #2616, plus the two CI failures on the previous head. **Discovery gated on the wrong payer.** `list_environments` runs no code, but it went through the same gate as a run — on the CONVERSATION's coordinates, because it has no target. So a free-tier person who owns a visible machine in a Pro-owned drive could never obtain the id of the one place they can run, even though `openAt` deliberately switches to the ENVIRONMENT's payer and would have authorized it. `canRunCode`'s own docblock says the tier leg keys on the payer precisely so "a free-tier member of a Pro-owned drive is still eligible"; discovery was the single surface contradicting it. `gateDiscovery` asks the honest question — may this actor run ANYWHERE they can reach? — trying the conversation's own coordinates first and, only if those refuse for something other than the KILL SWITCH, each reachable environment's payer in the shape `openAt` will actually gate the run with. The kill switch is never second-guessed. It is never the security boundary either: the id it hands back is useless without `openAt`'s full gate on the target's own payer. **The pipeline strip needed the same fix or the tool fix changed nothing**: `resolveSandboxToolEligibilityForConversation` strips the whole compute family from the request before `list_environments` can be called, by the same conversation-tier decision. It now falls back to the same reachable-environment question, bounded (owner-scoped, after the cheap check fails) and failing closed on any error, since it widens eligibility. Also in this commit, both CI failures from the previous head: - **knip**: `LIST_ENVIRONMENTS_TOOL_NAME` was a new unused export. Used rather than ignored — it and the new `ADDRESSED_SANDBOX_TOOL_NAMES` now back the literals in `SANDBOX_CORE_TOOL_NAMES` and the system prompt's addressing bullet, so the tool's name has one definition. - **A STALE integration row, not a regression**: `env-sessions.integration.test.ts` asserted "a local env in ANOTHER drive is env_not_found — the drive check runs before the gate", which is the rationale leaf D deliberately inverted. The caller OWNS that machine, so it now reaches the bind gate and refuses `not_connected`. Re-pointed AND re-titled, with three sibling rows so it proves the new intent rather than accepting a new string: the same env BINDS once connected (asserting the session's drive and the env's drive genuinely differ), a non-owner is still refused `bind_policy`, and a DRIVELESS global-assistant session binds a connected local env it owns — the case [D-4] blocked. The suite's helper no longer hardcodes the session's drive into the gate row; it reads the env's own, which is what made the cross-drive case expressible at all. Verified against a real Postgres this time, not blind: migrations 0295 and 0296 applied to a clean database (`visibleToGlobalAssistant` NOT NULL DEFAULT false present; `agent_workspaces_env_needs_drive_check` absent; its sibling CHECK still there), env-sessions 23/23, the DB-backed lib suites 857/857, and web agent-workspaces + ai 5153/5153. Mutation: 1/1 killed, 1 no-op control survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…declared in the bridge inventory Two repo-wide gates caught the new surface, and both were doing their job. Found by running the FULL web unit suite against a real Postgres rather than only the directories I had touched — neither of these lives near the code they guard. - **`registry-coverage`**: every AI tool must have a rich renderer, and `list_environments` had none, so it would have rendered as raw JSON. It gets a real one rather than a stub, because this is the one place a person can see WHICH of their machines the assistant can currently reach — and, by absence, which they have not switched on. It shows the label (what a person recognises) above the id (what the next call will name), so a wrong target is legible here too, and carries the notice so "nothing to copy" reads the same way to the person as it does to the model. - **`list-route`'s bridge inventory**: the `no code path sends an approval TO the machine` invariant pins the set of `api/env-bridge/*` directories, so a new route family has to be acknowledged on purpose by someone who has just read what the test is for. `environments` is added with that reasoning stated: it is a GET over rows the caller owns and returns no frame to any machine, which the scan above it is what actually enforces. Mutation: 1/1 killed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
… per environment The payer is a property of the DRIVE, so several machines in one drive are one question rather than several. Both fallbacks — the discovery gate and the pipeline eligibility strip — now iterate distinct drive ids: a person with ten machines across two drives costs two payer lookups instead of ten, and both paths are only reached when the cheap conversation-payer check has already refused. Behaviour is unchanged (the answer is a disjunction over the same set); this bounds a loop that was bounded only by the listing cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…e-environment fallback, with rows Review catch on `3fcaaab17`: the per-drive dedup was correct but written TWICE — the same `new Set(...map(env => env.driveId))` loop in the discovery gate and in the pipeline eligibility strip — and asserted in neither. That is the shape this PR already rejected once: when discovery and resolution had to agree, the rule was extracted into one predicate with a row asserting they cannot drift. Same standard here. `anyReachableEnvironmentPayerAllows` owns the ITERATION — which payers get asked, once per distinct drive, failing closed. Both callers keep their own AUTHORIZER, and that difference is deliberate rather than accidental: the eligibility strip stands where tool REGISTRATION stands and asks `canRunCodeForSession`, the same question the rest of that module asks, while the discovery gate stands where a call stands and asks the full call-time gate. The module doc and both call sites say which and why. Sharing it matters for the reason the fallback exists at all: fixing one without the other changes nothing, because the strip removes the tool before the gate can allow it. Rows: two machines in ONE drive resolve that payer ONCE and are still eligible; machines across TWO drives ask twice; a drive whose payer does not resolve is skipped WITHOUT refusing the others; the first allowing drive stops the loop; and every uncertainty fails closed (flag off, listing throws, nothing reachable). Plus a source scan pinning that neither caller has grown its own copy of the loop, and that each still names its own authorizer. The kill switch is unaffected and still refuses regardless of what is reachable — `productionSandboxDiscoveryGate` returns the direct verdict on `kill_switch_off` before this is reached, which its own suite covers. Mutation: 3/3 killed, 1 no-op control survived. The flag-off row initially SURVIVED its mutant — a throwing authorizer was being swallowed by the fail-closed catch, so the assertion held with the flag check deleted. It now asserts the observable consequence (nothing is listed, nothing is authorized) and kills it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
knip ignores `src/**/__tests__/**` (knip.json), so an export consumed only by a test reads to it as dead code — the same gate that failed the Lint job on an earlier head for `LIST_ENVIRONMENTS_TOOL_NAME`. `ReachableEnvironmentPayerDeps` and `defaultReachableEnvironmentPayerDeps` were both in that position. Both are now module-private, and the test derives the deps shape from the function's own signature (`NonNullable<Parameters<typeof anyReachableEnvironmentPayerAllows>[0]['deps']>`), which is stricter than the import it replaces: the fake cannot drift from what the parameter actually takes. Caught before pushing by reading knip.json rather than by another CI round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
Fresh-eyes read of the finished diff. `openAt` spread the whole `SandboxPayerCoordinates` onto the context, so `gateDriveId` — an input to the GATE, not a fact about the actor — rode along as an undeclared field on an object passed to every runner. It is inert today: nothing spreads `ctx` wholesale (every consumer, including the audit writer, picks explicit fields), and TypeScript will not let anyone read it since it is not on `SandboxActorContext`. But a stray property on a context this widely passed is a trap for the first person who writes `...ctx`, and it would reach an audit row the day they do. Split off at the one place it is produced, so the property cannot exist rather than merely being unused. Behaviour is identical — the gate still receives the same value, pinned by the mutant that points it back at the billing drive and makes the departed-owner row go red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…s for the dropped constraint The Unit Tests failure on `72db9a541`, and the same class as the lib one I already re-pointed — in a package whose integration suite I had never run. `bun run --filter @pagespace/db test:integration` is its own command; running lib and web is not a substitute, and that is how these two survived. Both assertions were about `agent_workspaces_env_needs_drive_check`, dropped by migration 0296: - the CHECK inventory expected TWO constraints and now finds one. Re-pointed to assert the ABSENCE explicitly against `pg_constraint`, which is the only place a dropped constraint can be observed — the schema file no longer mentions it, so nothing else can. The surviving `agent_workspaces_env_no_sprite_check` is still asserted NOT VALID, so the two-stage migration rule is untouched. - "should REFUSE an env-bound session with no drive" asserted the rationale leaf D deliberately inverted. Re-pointed AND re-titled: the insert now succeeds, and the row reads the shape back (`driveId` null, `envId` set) rather than merely not throwing. Its comment records why the CHECK was right about a SPRITE env and wrong about a LOCAL one, and where the guarantee lives now — `spawnAgentSession`'s two branches, whose own matrix pins the sprite negative directly. Verified on a CLEAN database: dropped and recreated with `timezone = UTC`, migrated from zero, then `@pagespace/db test:integration` 71/71 with zero assertion failures (the 4 remaining files need `ADMIN_DATABASE_URL`, a separate admin database CI provides). Two artefacts of my own local rig, both confirmed as rig rather than code: `accessible-page-ids`' expired-grant row fails on a non-UTC server (`now()` resolves via session TZ) and passes under UTC; and pointing `ADMIN_DATABASE_URL` at the same database lets the admin provisioning tests mutate cluster roles and break unrelated suites — hence the rebuild. Lib drive-envs + agent-workspaces 29/29 files and web ai + agent-workspaces + env-bridge + tool-calls 296/296 files re-run green on that clean database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…canRunCode parity SCOPE CORRECTION on #2616. Local-only was the wrong deliverable: the founder's ask was "any of the sandboxes", and a Sprite env IS the cloud sandbox. **Founder ruling (2026-09-12): "if the user can, their global assistant should be able to."** Exact parity with what the person can already do inside the drive — nothing more, nothing less. A CLOUD env has no owner, so there is nothing to opt in to and no per-env toggle: the DRIVE PERMISSION is the visibility. `decideEnvReach` gains a `sprite` arm keyed on an injected `mayRunCodeInEnvDrive`, which the caller computes with `canRunCode({ userId, driveId: env.driveId })` — the same kill switch, payer tier (the drive's owner), drive access and `canEdit`. A VIEWER is refused exactly as in-drive. It is `canRunCode` VERBATIM, never re-derived, and the parity promise is a TEST rather than a comment: for every verdict `canRunCode` can produce, the reach verdict must equal it, and neither a stray visibility flag nor the actor's identity can make them diverge. **The session shape (point 3), which is what keeps every Sprite invariant intact rather than relaxed:** a cloud env's session is bound to THAT ENV'S DRIVE (`driveId = env.driveId`). So `spawnAgentSession`'s `env.driveId !== driveId` comparison is SATISFIED, not bypassed; the negative that a DRIVELESS spawn is refused a Sprite env stays true and untouched; `decideAgentSessionAccess` keeps deriving access from the drive; billing keeps resolving to that drive's owner. Only a LOCAL machine's session is driveless — it belongs to a person, not a drive. No STOP clause was needed. **Local machines are unchanged**: owner-only + `visibleToGlobalAssistant` + [D-6] bind. `visibleToGlobalAssistant` stays a local-machine column and is not consulted for a cloud env in either direction — as a gate it would make every cloud env permanently unreachable, and as a grant it would let a row widen what the drive decided. Per-call re-check stays: losing drive edit access, or the kill switch going off, refuses the next call even with a live session. Page conversations are unchanged (`not_global`, before the permission is even asked). Discovery lists cloud envs from drives the person owns or has ACCEPTED membership of — a candidate filter, never the authorization answer (the trap PR #2609 hit) — then applies `canRunCode` once per DISTINCT drive. Bounded, per the findMany-limit rule. `NO_VISIBLE_ENVIRONMENTS_NOTICE` now names both ways in. Docs corrected on all four surfaces, and R-20 names the new blast radius honestly: injection in a global conversation can act in any cloud env the person can edit. That is parity, not escalation — their own in-drive agents can already do it — but it AGGREGATES, reachable from one conversation instead of many, by the agent that reads the most untrusted content. Red-first throughout. Mutation: 6/6 killed (parity broken open, broken shut, arms swapped, permission assumed, cloud session driveless, billing driveless), 1 no-op control survived. Verified: lib 570 files, web 20,725 tests, db integration 71/71 — zero assertion failures (the only reds are the known ADMIN_DATABASE_URL and unbuilt-editor classes). tsc clean across lib/db/web/marketing, eslint clean on all 26 touched web files, knip flags none of the new modules, `db:generate` a no-op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
… reach was dead in production BLOCKING review finding on `c72c4996a`, and it made the whole cloud correction a no-op on every deployment: `LOCAL_ENVS_ENABLED` is off everywhere, and it was short-circuiting the cloud path in THREE places — the discovery gate's fallback, the pipeline eligibility strip, and (worse, and not in the report) RESOLUTION itself, which refused before it had even looked the environment up. So the founder's ruling failed for exactly the case it exists for: a person whose OWN tier cannot run a personal sandbox, but who has edit access to a PAID team drive, can run code in that drive — and got no tools, no discovery and no resolution from the dashboard. Only people who could already run a personal sandbox got through. The flag is the cloud opt-in for exposing PERSONAL HARDWARE to a shared drive (invariant 11). A drive's own cloud sandbox is not personal hardware. It is now applied PER SUBSTRATE, in the one place that decides it — `listGlobalAssistantEnvironments` — so discovery, resolution and the eligibility strip share one answer: - local rows are not listed, and not even READ, while the flag is off; - cloud rows are never gated on it; - resolution asks the flag only AFTER the lookup, and only for a local env, because the question cannot be answered without knowing the substrate. The refusal is still the single message, so a caller learns nothing from it. The kill switch and the payer-tier leg are untouched: both still apply to cloud through `canRunCode`. **Red first, against the flag's REAL reader** (`process.env.LOCAL_ENVS_ENABLED`), never an injected stub — the defect was invisible to every existing test precisely because they injected `true` while production is always off. The new rows: flag off, a free-tier user with edit on a paid drive keeps the cloud env listed; flag off, a visible local machine is neither listed nor read; flag on, both appear; and the cloud half still answers to `canRunCode` either way. Also the minor finding: discovery and resolution asked `canRunCode` with different origins, so an agent-origin call could see a laxer list than the next call would honour. The origin is now threaded into discovery and asserted, so the two ask the identical question; the registration-time strip has no request to take an origin from and documents its user-origin default. One stale row re-pointed rather than deleted: it asserted the flag refused BEFORE any read, which is the exact short-circuit that had to go. Mutation: 4/4 killed — a local machine leaking in with the flag off, the local opt-in ignored entirely, the opt-in no longer gating resolution, and the flag gating CLOUD resolution again (the production defect restored). 1 no-op control survived. Verified: web 20,734 tests, lib 13,262 tests, zero assertion failures; tsc clean; eslint clean on every touched web file; knip flags none of these modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
… and row harness Prep for the app proof of #2616 @ f7b4a24. Everything here needs no build; the production build and the rows themselves wait for a build slot. `seed-gate.ts` creates EVERY precondition, so the run is reproducible from a clean database — the M1 gate was run once with two fields set by hand and was caught as a P1 twice for exactly that. Seeded: U on a sandbox-INELIGIBLE free tier; drive P (paid) with U an accepted editing member and a Sprite env; drive V (paid) with U bounded VIEW-ONLY; drive X (paid) with U not a member at all; a page agent and a `type='page'` conversation for the page-refusal row; and the view-only role inside P that the revocation row demotes U to. **A discovery worth recording:** there is no `VIEWER` in the `MemberRole` enum (`OWNER | ADMIN | MEMBER`). A view-only collaborator is a `MEMBER` carrying a custom role whose `driveWidePermissions.canEdit` is false — the one shape `getUserDrivePermissions` reads as `canEdit: false`, and therefore the only way `canRunCode` can answer `insufficient_role`. Seeding a plain `MEMBER` would have made U an EDITOR of drive V, and the row meant to prove a refusal would have proved its opposite. `preflight.ts` asserts the fixture yields the authorization answers the rows depend on, by asking `canRunCode` directly, BEFORE a contended build slot is spent on it. It expects the EXACT reason, never a category: "refused" would pass on `drive_access_denied` from an unaccepted invite while proving nothing about the permission under test. Currently 4/4 PASS on a clean database. `rows.ts` drives the rows through the real chat API as the browser sends it (session cookie + `X-CSRF-Token` + matching `Origin`), and makes every permission change through `PATCH /api/drives/<id>/members/<userId>` rather than psql — rows 5 and 6 are about the product honouring a permission change, so a gate that reached past the application to arrange them would be testing a database. Two wrong assumptions were found and fixed while writing it, which is the point of doing this before the slot: there is no `POST /api/ai/conversations`, and `/api/ai/chat` is the single entry for BOTH surfaces (it decides from the conversation, not the URL), so the page row uses a seeded `type='page'` conversation against that same endpoint. Credentials lined up and proven, not assumed: a Sprites token minted from a Fly org token drove a real create → exec → delete round trip (`uname -a` returned `Linux … -fly`, which is the evidence row 2 needs), and the probe sprite was deleted. Provider keys for the model that must actually call the tools are present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
…aint broken CodeQL alert 342 (`js/file-access-to-http`) on `rows.ts`: the seed file's data flows to `fetch(GATE_BASE_URL)`. That is a real risk, not a false positive — the seed carries REAL session cookies for the seeded users, and the harness sends them as request headers, so a `GATE_BASE_URL` pointing anywhere but this machine would ship those credentials off the box on a single mistyped environment variable. Fixed by breaking the taint, not by annotating it: default-setup CodeQL ignores inline `codeql[...]` suppressions, so a comment would neither clear the alert nor reduce the risk. `resolveLocalGateOrigin` parses the caller's string, checks it, and then THROWS IT AWAY: the origin every request actually uses is rebuilt from string LITERALS chosen by the check (`localhost` / `127.0.0.1` compared exactly, never as a pattern) plus a port validated as an integer in range. Nothing caller-influenced reaches `fetch`. It also refuses a non-`http:` protocol, embedded credentials, and an unparseable value, and it runs BEFORE the seed is read — so the refusal happens before the cookies are even loaded. `gateUrl` assigns `pathname`/`search` onto an origin-only `URL` rather than `new URL(path, origin)` or template concatenation. Both of those stay flagged behind a host guard because the analysis follows the string — and the assignment form additionally cannot be talked into changing hosts by a path beginning `//evil.test`, which is asserted. Rows: a remote target refuses and makes NO request (a `fetch` spy proves it); suffix look-alikes (`localhost.attacker.test`, `127.0.0.1.attacker.test`), cloud metadata, `[::1]`, non-http, credentials and a non-URL all refuse; an accepted loopback URL yields an origin with path, query and fragment stripped; out-of-range ports refuse rather than coerce; and a protocol-relative path cannot move the request off the origin. Mutation: 3/3 killed — the host check weakened to a substring match, a non-http protocol accepted, and the path allowed to move the origin. 1 no-op control survived. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
The GA wave 3 Stop test waited on `existsSync(pidFile)` and then read it.
`/bin/sh -c 'echo $$ > file'` creates the file and writes to it as two
separate steps, so there is a window where the file exists and holds `''`.
A read in that window yields `Number('') === 0`, and the run fails as a bare
`false !== true` that reads like a Stop bug in the daemon rather than the
test losing a race with the shell.
`readPidWhenComplete` polls on the same 5s deadline until the trimmed
content parses as a positive integer, and on expiry throws naming the last
content it read — an empty file and a missing one are different failures,
and the assertion this replaces distinguished neither.
Test-only: the daemon is untouched.
Proof, by line index on the accept condition (packages/cli, single file):
M1 `return pid` (accept on existence alone, the old semantics) RED 2/27
C `pid > 0 && Number.isInteger(pid)` (operand order) GREEN 27/27
The forced-race tests stub a file that exists immediately and is filled in
after a delay, so the window is guaranteed rather than hoped for.
Limit stated in the helper: this closes CREATE-then-WRITE, not a torn write
— a prefix of a pid parses as a positive integer. `echo $$ > file` emits the
line in one write, so that window does not exist at this call site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTEebBGVxXnTZ9S6xAn646
feat(env-bridge): a global assistant reaches any environment it may see — cloud by canRunCode parity, local by the owner's opt-in, both by a mandatory opaque envId
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Local Environments — an agent runs on the user's own computer — from "works on my machine" to a feature that can be released. Four verified waves on
pu/local-env-ga: #2582 (server layer), #2583 (approvals + the exec click), #2585 (audit, activity, Stop, settings), #2584 (security posture docs), #2588 (the exit gate, run). BehindLOCAL_ENVS_ENABLED, which is off everywhere.The one-sentence proofs
execoff inserverPolicythe server refuses to sign and no frame reaches the daemon — proven on hardware,GATE P19 … grantId NULL.bindPolicyis the single valueownerin the type, the CHECK and the gate; a drive admin is 403 on policy, Stop, activity and approvals, in a real browser.approval_mismatch.curl … | sh, read by the owner's agent: the model quoted the payload back and declined; when made to comply, the machine froze it and the card showed the literal command.grep -c '"verdict":"allow'on the daemon's audit returns 0.pauseframe kills the running process group (exit 137, pid gone) and the machine signs the acknowledgement.What each wave did
decideSignrefusesflag_disabled → revoked → paused → server_deniedbefore the signing key is touched;serverPolicywritten at mint from an explicit dialog choice; owner-only PATCH as a compare-and-set;no_server_opsat bind;admins/membersbinding removed structurally(env, user, op, subject)and made durable, so a new chat does not re-prompt and approvinggit statusnever authorisesrm;execis never scaffolded into the machine allowlist, so it reaches theaskverdict by construction; the owner's click in chat, verified by the machinedocs/security/local-environment-bridge.md+ a customer page, every claim citing the file that makes it trueWhat this does NOT claim
Stated plainly here because the posture doc states it plainly:
execis not sandboxed. A command you approve runs as you, with your credentials and your network. Roots confine what an agent can name, not what a process does ([D-1], accepted). Unattendedexecwithout a sandbox is materially less safe than Codex and roughly Claude Code minus the watching human; the mitigation at general availability is the owner's click on every exec class, and OS confinement is a post-general-availability milestone. Approval is remembered per program, not per command line. An owner who hand-editsexecinto their machine allowlist turns the click off — honoured as their choice ([D-7]), and the daemon now says so loudly at connect. The residual register in the posture doc carries all twelve risks with owners.Review found what CI could not
Fourteen defects across the waves, one continuous-integration failure among them. Three broke a wave's own guarantee: Stop changed server state but never reached the running process; an unacknowledged Stop was never retried; a failed reconnect replay released the block so grants could sign while the machine still held a revoked approval. The gate then found a destructive-on-refusal bug —
DELETE …/envs/[envId]revoked the machine before thelive_sessionsguard, so a delete the product refused had already deleted the machine key and revoked 22 sessions. Fixed here: the revoke is now the delete transaction's hook, under the row lock, after the guard. The three sibling paths were audited and do not have it (register R-12).The gate's own first run set two database fields by hand and did not put them in the seed script. Codex caught it; the run was re-done from a clean seed with a new control row proving the admin can start an ordinary session, so the refusal under test is the environment gate and not non-membership. Without that fix the injection check would have passed for the wrong reason — the agent could not read the page at all.
Migrations
0291(bind policy) ·0292(grant audit) ·0293(paused) ·0294(approval mirror) ·0295(daemon epoch). Each generated bydb:generate, a no-op on the second run,drizzle-kit checkclean.Wire protocol
The signed
hellonow requiresdaemonEpoch, andpause/pause_result/approval_revoke_resultare new frames. A CLI built before this branch cannot connect. Acceptable now only because the flag is off everywhere and the CLI ships from this tree.Before turning the flag on
The posture doc's flag-on checklist — every item a command or a task id, not a sentence. The gate report is on task
ukqmwy41zkf192hwgh6sqsxf; the epic isj945few5ssv75k5ad0bowbb4.🤖 Generated with Claude Code