Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5cd1f72
feat(env-bridge): derive the approval challenge from the frozen reque…
2witstudios Sep 9, 2026
54a225e
feat(env-bridge): pin the owner's passkeys to the machine at enrolmen…
2witstudios Sep 9, 2026
47fdaff
feat(env-bridge): carry the owner's assertion to the machine in the a…
2witstudios Sep 9, 2026
ab9a176
feat(env-bridge): the daemon verifies the owner's assertion itself (B4)
2witstudios Sep 9, 2026
53ae331
feat(env-bridge): fail closed when no owner credential is pinned, and…
2witstudios Sep 9, 2026
e9a6710
test(db): allow the one credential-shaped column explicitly, and pin …
2witstudios Sep 9, 2026
15ed48b
refactor(env-bridge): drop two exports nothing consumes
2witstudios Sep 9, 2026
303abc7
fix(db): drop a duplicate readFileSync import in the schema test
2witstudios Sep 9, 2026
45223f2
fix(env-bridge): the grant signer dropped the owner's assertion, so n…
2witstudios Sep 9, 2026
395be78
fix(env-bridge): bind the owner's chosen SCOPE into the assertion (Co…
2witstudios Sep 9, 2026
93b27d4
fix(env-bridge): verify every algorithm a passkey can be registered w…
2witstudios Sep 9, 2026
7475c1f
fix(env-bridge): a refusal that is not the owner's decision must not …
2witstudios Sep 9, 2026
e7568c7
test(env-bridge): make the ReDoS guard test a real guard instead of a…
2witstudios Sep 9, 2026
ce1db9d
fix(env-bridge): keep hardening A's writeModes inside what the owner'…
2witstudios Sep 9, 2026
1591f51
test(env-bridge): restructure the WebAuthn fixture so CodeQL's taint …
2witstudios Sep 9, 2026
f15f0be
test(env-bridge): suppress the WebAuthn hash alerts narrowly, with th…
2witstudios Sep 9, 2026
1e9f8a2
test(env-bridge): break the CodeQL taint by deriving the challenge lo…
2witstudios Sep 9, 2026
f21574b
fix(cli): stop run.test.ts making real requests to pagespace.ai — the…
2witstudios Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ All notable user-facing changes to PageSpace are documented here. Format follows
agent to `~/.ssh`, your shell startup files and every project at once. `enroll`, `pagespace env
connect` and `pagespace env policy` all warn, naming the consequence and how to narrow it — and
honour it anyway: it is your machine.
- **Local Environments: approving a command in the chat now needs your passkey, and your computer
checks it itself (opt-in).** When you answer an approval card, your browser asks your
authenticator — Touch ID, Windows Hello, a security key — to sign, and your machine verifies
that signature before it runs anything. The signature covers the exact request your machine
froze and how long you chose to remember it, so it cannot be moved to a different command, a
different question or a different computer, nor turned into a longer-lasting approval than the
one you gave, and PageSpace cannot produce one. Until now your machine took our word that the click
had come from you: the owner-only check ran on our servers, which your computer has no way to
observe. It no longer has to. The passkeys your machine trusts are pinned when you enrol it,
while you are at the keyboard; `pagespace env owner-keys` prints them, `pagespace env enroll`
and `pagespace env connect` each say in one line what was pinned and what happens without it.
Two consequences, both deliberate: **nothing can add a key to a machine afterwards** — not us,
not the CLI, not any message on the bridge — so register a new passkey and **re-enrol** the
machine to use it; and if you had no passkey when you enrolled, approvals in the chat are
refused and requests prompt in the terminal running `pagespace env connect` instead. Nothing
here is weaker than before: where your machine cannot prove a human clicked, it asks somewhere
it can.

- **Local Environments: you can see what your computer is doing, and you can stop it (opt-in).**
Every request PageSpace signs for a local Environment — and every one it refuses — is now
Expand Down
18 changes: 18 additions & 0 deletions apps/marketing/src/app/docs/security/local-environments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ Each guarantee is followed by its exact limit.
removes the click just the same. And you are judging a command line, and approving it is
remembered — see the next section.*

- **Approving in the chat needs your passkey, and your machine checks it itself.** When you
answer an approval card, your browser asks your authenticator — Touch ID, Windows Hello, a
security key — to sign, and your machine verifies that signature before it runs anything. The
signature covers the exact request your machine froze **and how long you chose to remember
it**, so it cannot be moved to a different command, a different question, a different computer,
or turned into a longer-lasting approval than the one you gave — and **PageSpace cannot produce
one**.
That is the point: without it, anyone who could sign requests *and* stand where our servers
stand could answer your card for you, and your machine would have no way to tell. The
passkeys your machine trusts are pinned when you enrol it, from your account, while you are
at the keyboard — \`pagespace env owner-keys\` prints them. *Two consequences, both
deliberate: nothing can add a key to a machine afterwards (not us, not the CLI, not any
message on the bridge) — register a new passkey and **re-enrol** the machine to use it; and
if you had no passkey when you enrolled, chat approvals are refused and requests prompt in
the terminal running \`pagespace env connect\` instead. The terminal prompt was never exposed
to this, which is why it is the fallback. Limit: the signature proves that you pressed a key
for this request — it cannot prove you read it.*

- **File access is confined to the folders you declared.** Every file an agent reads or writes,
and every working directory, must resolve inside a folder in your policy file; symlinks are
resolved and \`..\` is refused. *Limit: this confines the paths an agent can name. It does not
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/app/api/env-bridge/__tests__/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,21 @@ describe('the cloud opt-in (invariant 11)', () => {

describe('POST /api/env-bridge/enroll', () => {
it('given a valid code and key, should pin and answer 200 with the server key to pin, auditing the pin', async () => {
vi.mocked(enrollLocalEnv).mockResolvedValue({ ok: true, envId: 'env_1', enrollmentId: ENROLLMENT, serverKeyId: 'k1', serverPublicKey: 'U0VSVkVS', ownerId: 'user_owner', serverPolicy: { ops: ['fs_read', 'exec'], checkpoint: false } });
vi.mocked(enrollLocalEnv).mockResolvedValue({ ok: true, envId: 'env_1', enrollmentId: ENROLLMENT, serverKeyId: 'k1', serverPublicKey: 'U0VSVkVS', ownerId: 'user_owner', serverPolicy: { ops: ['fs_read', 'exec'], checkpoint: false }, ownerCredentials: { rpId: 'pagespace.test', origin: 'https://pagespace.test', credentials: [{ credentialId: 'cred-a', publicKeyCose: 'cose-a' }] } });
const response = await enroll(enrollReq());
expect(response.status).toBe(200);
// `ownerId` rides the answer so the enroller can scaffold `principals: [owner]` (D-6).
// `serverPolicy` rides too (GA wave 2): the enroller scaffolds the FILE ops it allows into the machine policy — and never `exec`, whatever it says.
expect(await json(response)).toEqual({ enrollmentId: ENROLLMENT, envId: 'env_1', serverKeyId: 'k1', serverPublicKey: 'U0VSVkVS', ownerId: 'user_owner', serverPolicy: { ops: ['fs_read', 'exec'], checkpoint: false } });
// `ownerCredentials` rides too (hardening B, leaf B1): the owner's PASSKEY PUBLIC KEYS, for the machine to pin, so it can verify the owner's click itself instead of taking this server's word for it.
expect(await json(response)).toEqual({
enrollmentId: ENROLLMENT,
envId: 'env_1',
serverKeyId: 'k1',
serverPublicKey: 'U0VSVkVS',
ownerId: 'user_owner',
serverPolicy: { ops: ['fs_read', 'exec'], checkpoint: false },
ownerCredentials: { rpId: 'pagespace.test', origin: 'https://pagespace.test', credentials: [{ credentialId: 'cred-a', publicKeyCose: 'cose-a' }] },
});
expect(enrollLocalEnv).toHaveBeenCalledWith(enrollBody);
expect(auditRequest).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ eventType: 'auth.token.created', resourceId: 'env_1' }));
});
Expand All @@ -78,6 +87,8 @@ describe('POST /api/env-bridge/enroll', () => {
['used', 409],
['already_enrolled', 409],
['race', 409],
// The passkey store could not be read, so NOTHING was pinned and the code was not spent — retryable, not a refusal of the code (hardening B, leaf B1).
['owner_credentials_unavailable', 503],
['mismatch', 401],
['malformed', 400],
['bad_public_key', 400],
Expand Down
87 changes: 82 additions & 5 deletions apps/web/src/app/api/env-bridge/approvals/[challengeId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ import { getPendingApprovalStore, type PendingEnvApproval } from '@/lib/env-brid
import { getApprovalMirrorStore, getDriveEnvStore, markEnvApprovalAcknowledged, markEnvApprovalRevoked, rememberEnvApproval } from '@/lib/drive-envs/drive-envs-runtime';
import { revokeLocalEnvApproval } from '@/lib/env-bridge/revoke';
import { approvalExpiry } from '@pagespace/lib/env-bridge/decide-approval';
import { approvalAssertionSchema } from '@pagespace/lib/env-bridge/grant';
import { deriveOwnerApprovalChallenge } from '@pagespace/lib/env-bridge/owner-approval';
import { envBridgeSha256 } from '@/lib/env-bridge/crypto';

const AUTH_OPTIONS_READ = { allow: ['session'] as const, requireCSRF: false };
const AUTH_OPTIONS_WRITE = { allow: ['session'] as const, requireCSRF: true };
Expand All @@ -52,13 +55,26 @@ const bodySchema = z
.object({
decision: z.enum(['allow', 'deny']),
scope: z.enum(ENV_APPROVAL_SCOPES).optional(),
/**
* The owner's WebAuthn assertion (hardening B, leaf B3). Deliberately
* added to a `.strict()` schema — an unknown field is a 400 here, so this
* had to be opened on purpose.
*
* The server RELAYS it and does not verify it. That is not an oversight
* and it is not the step-up flow's shape: the party that must be
* convinced a human clicked is the MACHINE, and a check performed here
* would be exactly the server-attestation this whole change exists to
* remove. Optional at this layer so the machine — not this route — is
* what refuses a click without one, with its own typed reason.
*/
assertion: approvalAssertionSchema.optional(),
})
.strict();

type Params = { params: Promise<{ challengeId: string }> };

/** The pending entry and the sibling it belongs to, or the response that ends the request. */
async function loadForOwner(request: Request, challengeId: string, userId: string, now: number): Promise<{ ok: true; pending: PendingEnvApproval; ownerId: string } | { ok: false; response: Response }> {
async function loadForOwner(request: Request, challengeId: string, userId: string, now: number): Promise<{ ok: true; pending: PendingEnvApproval; ownerId: string; sibling: NonNullable<LocalSibling> } | { ok: false; response: Response }> {
const pending = getPendingApprovalStore().get(challengeId, now);
if (!pending) {
auditRequest(request, { eventType: 'authz.access.denied', userId, resourceType: 'drive_env_approval', resourceId: challengeId, details: { route: 'env-bridge/approvals', reason: 'unknown_or_expired' } });
Expand Down Expand Up @@ -87,7 +103,34 @@ async function loadForOwner(request: Request, challengeId: string, userId: strin
),
};
}
return { ok: true, pending, ownerId: sibling.ownerId };
return { ok: true, pending, ownerId: sibling.ownerId, sibling };
}

/** The row's pinned owner credentials, as `drive_env_local` stores them. */
type LocalSibling = Awaited<ReturnType<Awaited<ReturnType<typeof getDriveEnvStore>>['findLocalByEnvId']>>;

/**
* The WebAuthn options the card runs `startAuthentication` with. `available:
* false` means the machine pinned nothing (or an empty set): the card must
* say the approval has to be answered in the terminal instead, because the
* daemon will refuse a chat click on this machine (leaf B5).
*/
function webauthnOptionsFor(sibling: LocalSibling, challengeId: string, envId: string, request: PendingEnvApproval['pending']['request']) {
const pinned = sibling?.ownerCredentials ?? null;
const credentials = pinned?.credentials ?? [];
return {
available: credentials.length > 0,
rpId: pinned?.rpId ?? null,
/**
* ONE CHALLENGE PER SCOPE. The challenge binds the scope (Codex P1 on
* #2599) and the owner picks the scope on the card, after this response —
* so the card signs the one matching its selection. Deriving all four here
* keeps the browser free of hashing, and a card that signs the wrong one
* simply fails on the machine rather than approving anything.
*/
challenges: Object.fromEntries(ENV_APPROVAL_SCOPES.map((scope) => [scope, deriveOwnerApprovalChallenge({ envId, challengeId, request, scope }, envBridgeSha256)])) as Record<(typeof ENV_APPROVAL_SCOPES)[number], string>,
allowCredentials: credentials.map((credential) => ({ id: credential.credentialId, type: 'public-key' as const })),
};
}

export async function GET(request: Request, context: Params) {
Expand All @@ -98,7 +141,7 @@ export async function GET(request: Request, context: Params) {
if (isAuthError(auth)) return auth.error;
const loaded = await loadForOwner(request, challengeId, auth.userId, Date.now());
if (!loaded.ok) return loaded.response;
const { pending } = loaded;
const { pending, sibling } = loaded;
auditRequest(request, { eventType: 'data.read', userId: auth.userId, resourceType: 'drive_env', resourceId: pending.envId, details: { route: 'env-bridge/approvals', operation: 'read', challengeId } });
return NextResponse.json({
challengeId,
Expand All @@ -113,6 +156,15 @@ export async function GET(request: Request, context: Params) {
// word the server or the model composed.
...(pending.pending.files !== undefined && { files: pending.pending.files }),
scopes: ENV_APPROVAL_SCOPES,
// What the card needs to run the WebAuthn ceremony (hardening B). The
// challenge is DERIVED from the frozen request, never random, and the
// machine recomputes it from the request IT froze — so a wrong
// challenge here cannot make anything run, it only fails the click.
// `allowCredentials` is the set the MACHINE pinned at enrolment, not
// every passkey the owner has: offering a key registered since would
// have them touch one the daemon then refuses, with nothing to explain
// it.
webauthn: webauthnOptionsFor(sibling, challengeId, pending.envId, pending.pending.request),
});
} catch (error) {
loggers.api.error('Failed to read a pending environment approval', error instanceof Error ? error : new Error(String(error)));
Expand Down Expand Up @@ -152,6 +204,10 @@ function outcomeOf(challengeId: string, scope: RequestEnvApprovalOutput['scope']
case 'grant_denied':
if (reply.reason === 'approval_mismatch') return { challengeId, outcome: 'mismatch', error: reply.reason };
if (reply.reason === 'approval_expired') return { challengeId, outcome: 'expired', error: reply.reason };
if (reply.reason === 'approval_unproven') {
// Say what to fix, because the question is still answerable.
return { challengeId, outcome: 'failed', error: 'approval_unproven: the machine could not verify that you clicked. Approve in the terminal running "pagespace env connect", or — if this machine has no passkey pinned — register one and re-enrol it. The request is still pending until it expires.' };
}
return { challengeId, outcome: 'failed', error: reply.reason };
}
}
Expand Down Expand Up @@ -181,23 +237,44 @@ export async function POST(request: Request, context: Params) {
}

const scope = parsed.data.scope ?? '30d';
// Spent before the re-issue: one click answers one question, whatever the machine says next.
/**
* Spent before the re-issue, so two concurrent clicks cannot both run —
* but RESTORED below when the machine's answer is not a decision (Codex on
* #2599). A proof the machine could not use is a recoverable error, and
* burning the question turns it into a dead end: the owner would be told
* "unproven" with nothing left to retry against, and the daemon still
* holds its own challenge (it only spends one on a verified allow), so the
* two would disagree until the TTL.
*/
store.take(challengeId, now);
/** Put the question back exactly as it was, for anything that is not the owner's decision. */
const restorePending = () => {
if (pending.expiresAt > Date.now()) store.remember(pending, Date.now());
};
let reply: MachineResultFrame;
try {
reply = await getEnvBridgeClient().sendGrant({
envId: pending.envId,
frame: pending.frame,
principal: pending.principal,
approvalIntent: { challengeId, scope, expiresAt: pending.expiresAt },
// The assertion rides INSIDE the intent, so the grant signature covers
// it — relayed intact, never verified-and-discarded here.
approvalIntent: { challengeId, scope, expiresAt: pending.expiresAt, ...(parsed.data.assertion !== undefined && { assertion: parsed.data.assertion }) },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Carry the assertion into the signed grant

In the actual route → bridge-client → signer path, this assertion is dropped: signGrantFrame reconstructs approvalIntent in apps/web/src/lib/env-bridge/grant-signer.ts with only challengeId, scope, and expiresAt. Consequently every browser approval reaches the daemon without an assertion, verifyOwnerApproval returns approval_unproven, and the route has already consumed the pending challenge. Preserve the assertion when constructing the signed grant and test the full path through the signer.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and this was the important one — the feature did not work at all. Fixed in 822015b92.

signGrantFrame rebuilt approvalIntent from {challengeId, scope, expiresAt}, so every real browser approval reached the machine with no assertion, was refused approval_unproven, and the route had already spent the pending question — so the owner could not even retry. Your reading of why the tests missed it is exactly right: the route, the daemon and the codec each had coverage, and none crossed the hop between them.

So the fix is one line and the test is the point. apps/web/src/app/api/env-bridge/approvals/__tests__/click-end-to-end.test.ts drives the whole path with nothing mocked between the hops — the card's POST body → the real route → the real signGrantFrame under a real Ed25519 keyring → the real canonical bytes, JSON round-tripped as a socket sends them → decodeFrame → verifyGrant → verifyOwnerApproval — and asserts the assertion survives each one and the daemon allows the run. The last three are the daemon's own gates; only the socket is stubbed. It also covers stripping or altering the assertion after signing (bad_signature, since it is under the server's signature) and a click the owner never signed (still approval_unproven — the route cannot invent a proof).

Mutation: reintroducing the exact defect turns that row red; the identity control survived.

Separately, and prompted by your last paragraph: the route no longer burns the question on a refusal that is not the owner's decision (1b4e77ebe). approval_unproven and a transport failure now restore it — the daemon had not spent its challenge either, so the two sides were disagreeing until the TTL — while allow, deny and approval_mismatch still spend it.

});
} catch (error) {
// The machine never answered, so the owner never decided: the question stands.
restorePending();
const kind = error instanceof EnvBridgeError ? error.kind : 'error';
auditRequest(request, { eventType: 'data.write', userId: auth.userId, resourceType: 'drive_env', resourceId: pending.envId, details: { route: 'env-bridge/approvals', operation: 'allow', challengeId, scope, outcome: 'failed', error: kind } });
const output: RequestEnvApprovalOutput = { challengeId, outcome: 'failed', scope, error: kind };
return NextResponse.json(output, { status: 502 });
}
const output = outcomeOf(challengeId, scope, reply);
// `approval_unproven` means the machine could not USE the proof — no
// passkey pinned, a cancelled or malformed assertion, a scope it was not
// made for. The owner has not answered anything, and the daemon has not
// spent its challenge either, so neither does this.
if (reply.type === 'grant_denied' && reply.reason === 'approval_unproven') restorePending();
// The MIRROR (GA wave 3, leaf 5): the machine remembered this approval
// (it ran on a byte-compared match, under the challenge id, for every
// scope but `once`), so the server records what the owner can now see and
Expand Down
Loading
Loading