Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions apps/desktop/main/capabilities/broker-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ class Grants implements GrantProvider {
expiresAt: Number.MAX_SAFE_INTEGER,
mode: "read_write",
label: "project",
// Default OFF, stated explicitly: a fixture that omitted it would be the
// one place absence is allowed to mean something other than "no commands".
shellEnabled: false,
status: "active",
createdAt: 1,
updatedAt: 1,
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/main/capabilities/broker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ function makeGrant(overrides: Partial<Grant> = {}): Grant {
root: "/data/private",
mode: "read_only",
label: "private",
// Default OFF, stated explicitly: a fixture that omitted it would be the
// one place absence is allowed to mean something other than "no commands".
shellEnabled: false,
status: "active",
createdAt: 1,
updatedAt: 1,
Expand Down Expand Up @@ -213,6 +216,11 @@ describe("CapabilityBroker", () => {
"mode",
"mount",
"root",
// PRD-shell-execution §7.3 prerequisite 3 — the runtime's ONLY read of
// the per-workspace command decision. Listed here because this assertion
// is the broker wire's shape contract, and an unlisted key would mean the
// flag reached the worker without anyone deciding it should.
"shellEnabled",
"status",
]);
expect(g.root).toBe("/data/private");
Expand Down Expand Up @@ -246,6 +254,30 @@ describe("CapabilityBroker", () => {
expect(text).not.toContain("/data/gone");
});

it("a detached workspace hands out no COMMAND capability either", async () => {
// The same rule as `root`, for the flag the runtime reads as §7.1's third
// prerequisite. A revoked (or expired — the store reports those revoked too)
// grant authorizes nothing, so shipping `shellEnabled: true` for it would
// let a worker treat a folder the user explicitly detached as still
// command-capable. Absent decodes on the Python side as `False`.
grants.grants = [
makeGrant({ grantId: "live", status: "active", shellEnabled: true }),
makeGrant({ grantId: "gone", status: "revoked", shellEnabled: true }),
];

const res = await fetch(`${baseUrl}/v1/grants/list`, {
method: "POST",
headers: H(),
body: "{}",
});
const body = (await res.json()) as {
grants: Array<Record<string, unknown>>;
};

expect(body.grants[0].shellEnabled).toBe(true);
expect(body.grants[1]).not.toHaveProperty("shellEnabled");
});

it("snapshots only active grants, path-free with a stable mount id", async () => {
grants.grants = [
makeGrant({ grantId: "a", status: "active" }),
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/main/capabilities/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ export const CAPABILITY_CHANNELS = {
listGrants: "capability.list-grants",
/** Renderer → main: revoke a grant by id. */
revokeGrant: "capability.revoke-grant",
/**
* Renderer → main: turn per-workspace SHELL EXECUTION on/off for one grant
* (PRD-shell-execution §7.3). The FIFTH capability channel, and the only one
* that mutates an existing grant — before it, `GrantStore` had `create` and
* `revoke` and nothing else, so the renderer had no way to reach a change of
* this kind at all.
*
* It carries a decision, never an execution. Nothing on this channel runs a
* command, and the broker gains no execute verb; main records what the user
* chose and the runtime reads it back off the active-grant snapshot.
*/
setGrantShellEnabled: "capability.set-grant-shell-enabled",
/** Renderer → main: decide one digest-pinned workspace stage revision. */
decideWorkspaceApproval: "capability.decide-workspace-approval",
} as const;
Expand Down
10 changes: 9 additions & 1 deletion apps/desktop/main/capabilities/feature-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const fakeCapability = {
requestFolderGrant: async () => null,
listGrants: async () => [],
revokeGrant: async () => null,
setGrantShellEnabled: async () => null,
};

function registerWithGate(env: Record<string, string | undefined>) {
Expand All @@ -144,25 +145,32 @@ function registerWithGate(env: Record<string, string | undefined>) {
}

describe("capability subsystem gate → IPC registration (G4)", () => {
it("default (flag unset): all three capability channels are registered", () => {
it("default (flag unset): all four capability channels are registered", () => {
const ipcMain = registerWithGate({});
expect(ipcMain.has(CAPABILITY_CHANNELS.requestFolderGrant)).toBe(true);
expect(ipcMain.has(CAPABILITY_CHANNELS.listGrants)).toBe(true);
expect(ipcMain.has(CAPABILITY_CHANNELS.revokeGrant)).toBe(true);
expect(ipcMain.has(CAPABILITY_CHANNELS.setGrantShellEnabled)).toBe(true);
});

it("explicit opt-out: capability channels are NOT registered (calls fail closed)", () => {
const ipcMain = registerWithGate({ [DESKTOP_FILESYSTEM_FLAG]: "0" });
expect(ipcMain.has(CAPABILITY_CHANNELS.requestFolderGrant)).toBe(false);
expect(ipcMain.has(CAPABILITY_CHANNELS.listGrants)).toBe(false);
expect(ipcMain.has(CAPABILITY_CHANNELS.revokeGrant)).toBe(false);
// PRD-shell-execution §7.3. With the capability subsystem off there are no
// grants, so there is nothing to enable commands ON — and the one channel
// that can turn command execution on must not outlive the subsystem that
// owns the authority list it writes to.
expect(ipcMain.has(CAPABILITY_CHANNELS.setGrantShellEnabled)).toBe(false);
});

it("unreadable flag value: also NOT registered (fail closed, not fail open)", () => {
const ipcMain = registerWithGate({ [DESKTOP_FILESYSTEM_FLAG]: "maybe" });
expect(ipcMain.has(CAPABILITY_CHANNELS.requestFolderGrant)).toBe(false);
expect(ipcMain.has(CAPABILITY_CHANNELS.listGrants)).toBe(false);
expect(ipcMain.has(CAPABILITY_CHANNELS.revokeGrant)).toBe(false);
expect(ipcMain.has(CAPABILITY_CHANNELS.setGrantShellEnabled)).toBe(false);
});
});

Expand Down
119 changes: 119 additions & 0 deletions apps/desktop/main/capabilities/grant-shell-projection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// @vitest-environment node
//
// PRD-shell-execution §7.3 — where the per-workspace command flag is allowed to
// GO, and where it must not.
//
// `Grant` holds the decision; three projections carry it (or refuse to) to three
// different audiences. Each has a different reason, and each fails differently:
//
// toRendererGrant → the Settings toggle. Needs the CURRENT state, so the
// control shows what main holds rather than what the
// renderer last asked for.
// toBrokerGrant → the runtime worker. THE READ PATH (§7.1 prerequisite
// 3). Present only while the grant is live.
// toHostSessionGrant → the C2 write-authority bootstrap. Must NOT carry it:
// `_assert_host_session_wire_is_private` allow-lists
// five grant keys by name and raises
// `BrokerProtocolError` on any sixth, so a leak here
// fails every live host session closed while every test
// that builds the payload by hand stays green.
//
// A projection test is cheap and this one is not decorative: the flag decides
// whether an agent may run code on the machine, so "which audience learns it"
// is a security question with three different right answers.

import { describe, expect, it } from "vitest";

import type { Grant } from "./types";
import { toBrokerGrant, toHostSessionGrant, toRendererGrant } from "./types";

function makeGrant(overrides: Partial<Grant> = {}): Grant {
return {
grantId: "g_atlas",
root: "/Users/x/projects/atlas",
mode: "read_write",
label: "atlas",
shellEnabled: false,
status: "active",
createdAt: 1,
updatedAt: 1,
...overrides,
};
}

describe("toRendererGrant — the Settings toggle's view", () => {
it("reports the flag so the toggle can show the state main actually holds", () => {
expect(
toRendererGrant(makeGrant({ shellEnabled: true })).shellEnabled,
).toBe(true);
expect(
toRendererGrant(makeGrant({ shellEnabled: false })).shellEnabled,
).toBe(false);
});

it("a grant that authorizes nothing advertises nothing", () => {
// `GrantStore.list` reports an expired grant as revoked, so `status` here is
// already the EFFECTIVE one and expiry rides this same line. Without it a
// detached workspace's row would read "commands allowed" over authority
// that has lapsed.
expect(
toRendererGrant(makeGrant({ shellEnabled: true, status: "revoked" }))
.shellEnabled,
).toBe(false);
});

it("stays path-free — the flag did not become a path oracle", () => {
const projected = toRendererGrant(makeGrant({ shellEnabled: true }));
expect(Object.keys(projected).sort()).toEqual([
"grantId",
"label",
"mode",
"shellEnabled",
"status",
]);
expect(JSON.stringify(projected)).not.toContain("/Users/x");
});
});

describe("toBrokerGrant — the runtime's read path (§7.1 prerequisite 3)", () => {
it("carries the flag for a live grant", () => {
const projected = toBrokerGrant(makeGrant({ shellEnabled: true }), "mnt_1");
expect(projected.shellEnabled).toBe(true);
expect(projected.root).toBe("/Users/x/projects/atlas");
});

it("OMITS the flag entirely for a revoked grant, alongside the root", () => {
// Absence is the same answer an older Electron main gives, and the Python
// side defaults `shell_enabled` to False — so every route by which the fact
// can be missing means "this workspace cannot run commands", never "it can".
const projected = toBrokerGrant(
makeGrant({ shellEnabled: true, status: "revoked" }),
"mnt_1",
);
expect(projected).not.toHaveProperty("shellEnabled");
expect(projected).not.toHaveProperty("root");
});
});

describe("toHostSessionGrant — the one projection that must NOT carry it", () => {
it("emits exactly the five allow-listed keys", () => {
// Adding a sixth key here does not merely widen a contract: the ai-backend
// asserts this wire field-by-field and fails the whole host session closed
// on anything it does not recognise. The flag has no business here anyway —
// this bootstrap carries WRITE authority for staged effects, and shell
// enablement is read off `/v1/grants/snapshot`.
const projected = toHostSessionGrant(
makeGrant({ shellEnabled: true }),
"mnt_1",
);
expect(Object.keys(projected).sort()).toEqual([
"grantId",
"label",
"mode",
"mount",
"status",
]);
expect(projected).not.toHaveProperty("shellEnabled");
expect(projected).not.toHaveProperty("root");
});
});
Loading