Skip to content

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
masterfrom
pu/local-env-ga
Open

2witstudios wants to merge 129 commits into
masterfrom
pu/local-env-ga

Conversation

@2witstudios

Copy link
Copy Markdown
Owner

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). Behind LOCAL_ENVS_ENABLED, which is off everywhere.

The one-sentence proofs

  • PageSpace's say is real. With exec off in serverPolicy the server refuses to sign and no frame reaches the daemon — proven on hardware, GATE P19 … grantId NULL.
  • A machine is driven by its owner only ([D-6]). bindPolicy is the single value owner in the type, the CHECK and the gate; a drive admin is 403 on policy, Stop, activity and approvals, in a real browser.
  • A chat click can only unblock a request the machine itself froze. The daemon byte-compares the re-issued request against the frozen one; anything else is approval_mismatch.
  • The injection case executes nothing. A page written by another member, carrying 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.
  • Stop reaches the process. Not just the server row: a signed pause frame kills the running process group (exit 137, pid gone) and the machine signs the acknowledgement.

What each wave did

Wave What changed
1 · #2582 decideSign refuses flag_disabled → revoked → paused → server_denied before the signing key is touched; serverPolicy written at mint from an explicit dialog choice; owner-only PATCH as a compare-and-set; no_server_ops at bind; admins/members binding removed structurally
2 · #2583 Approvals re-keyed (env, user, op, subject) and made durable, so a new chat does not re-prompt and approving git status never authorises rm; exec is never scaffolded into the machine allowlist, so it reaches the ask verdict by construction; the owner's click in chat, verified by the machine
3 · #2585 Server-side audit rows joined to the daemon's log by grant id (fail-closed: no row, no grant); live activity to the owner's room only; Stop; drive and account settings; the approval mirror and a reconnect replay that blocks the env until the machine acknowledges
Docs · #2584 docs/security/local-environment-bridge.md + a customer page, every claim citing the file that makes it true
Gate · #2588 The 26 negatives re-scoped row by row, 14 added, 40/40 PASS, 0 SKIP on a production build

What this does NOT claim

Stated plainly here because the posture doc states it plainly: exec is 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). Unattended exec without 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-edits exec into 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 the live_sessions guard, 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 by db:generate, a no-op on the second run, drizzle-kit check clean.

Wire protocol

The signed hello now requires daemonEpoch, and pause / pause_result / approval_revoke_result are 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 is j945few5ssv75k5ad0bowbb4.

🤖 Generated with Claude Code

2witstudios and others added 30 commits September 8, 2026 23:32
…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
2witstudios and others added 3 commits September 9, 2026 16:33
…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
2witstudios and others added 8 commits September 9, 2026 17:14
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
2witstudios and others added 17 commits September 12, 2026 07:36
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant