From ee40bc8165218c869fd3c5067f735abc7bfac45a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:17:42 -0700 Subject: [PATCH 01/33] feat(cli): sandbox.trust 'workspace' joins the schema, refused everywhere but claude (TASK-294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the BYO workspace tier. The schema accepts trust 'workspace' and its opt-ins (sandbox.allow: write, web — only on a workspace seat). Every adapter other than claude refuses a declared workspace trust at spawn and at attach, with one shared message, so an unread trust can never fall into a bare spawn the way 'internal' did. Attach requires a CONFIRMED private pod for a workspace declaration and fails closed when the pod's visibility cannot be read. The host mode resolves the same way it does for public trust (Seatbelt on macOS, bwrap elsewhere), and the daemon's default-sandbox step keeps a workspace record instead of narrowing it. The grant-broker guard still reads the trust itself, so a workspace seat gets no room grant. The runtime /installations list now reports each pod's effective publicRead and communityListed, the input the per-turn check reads next. A pod that no longer exists carries neither field. Pure predicates for pod visibility and the start-directory guard (/, $HOME, an ancestor of $HOME, a credential store) land with their tests; the claude policy, the per-turn check and the git tool follow in this branch. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- .../agentsRuntime.installations.test.js | 60 ++++++++++++++- backend/routes/agentsRuntime.ts | 14 +++- cli/__tests__/attach.test.mjs | 25 +++++++ cli/__tests__/default-environment.test.mjs | 10 +++ cli/__tests__/environment.test.mjs | 21 +++++- .../public-pod-sandbox-gate.test.mjs | 32 ++++++++ .../workspace-trust-refusal.test.mjs | 50 +++++++++++++ cli/__tests__/workspace-trust.test.mjs | 74 +++++++++++++++++++ cli/src/commands/agent.js | 42 ++++++++++- cli/src/lib/adapters/codex.js | 3 +- cli/src/lib/adapters/dsh.js | 3 +- cli/src/lib/adapters/goose.js | 3 +- cli/src/lib/adapters/opencode.js | 2 + cli/src/lib/adapters/pi.js | 3 +- cli/src/lib/adapters/qwen.js | 3 +- cli/src/lib/default-environment.js | 8 +- cli/src/lib/environment.js | 46 ++++++++++-- cli/src/lib/sandbox/mode.js | 9 ++- cli/src/lib/workspace-trust.js | 71 ++++++++++++++++++ 19 files changed, 454 insertions(+), 25 deletions(-) create mode 100644 cli/__tests__/workspace-trust-refusal.test.mjs create mode 100644 cli/__tests__/workspace-trust.test.mjs create mode 100644 cli/src/lib/workspace-trust.js diff --git a/backend/__tests__/unit/routes/agentsRuntime.installations.test.js b/backend/__tests__/unit/routes/agentsRuntime.installations.test.js index cd1629af51..2299da3a7d 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.installations.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.installations.test.js @@ -9,11 +9,14 @@ const mockInstallation = { config: { runtime: { runtimeType: 'webhook', adapter: 'opencode', model: 'openai/gpt-5.4' } }, }; +let mockAuthorizedPodIds = ['pod-1']; +let mockPods = [{ _id: 'pod-1', name: 'Connector room', type: 'team' }]; + jest.mock('../../../middleware/agentRuntimeAuth', () => (req, _res, next) => { req.agentUser = { _id: 'agent-1', username: 'writer' }; req.agentInstallation = mockInstallation; req.agentInstallations = [mockInstallation]; - req.agentAuthorizedPodIds = ['pod-1']; + req.agentAuthorizedPodIds = mockAuthorizedPodIds; next(); }); jest.mock('../../../middleware/auth', () => (_req, _res, next) => next()); @@ -22,7 +25,7 @@ jest.mock('../../../middleware/apiTokenScopes', () => ({ })); jest.mock('../../../models/Pod', () => ({ find: jest.fn(() => ({ - select: () => ({ lean: async () => [{ _id: 'pod-1', name: 'Connector room', type: 'team' }] }), + select: () => ({ lean: async () => mockPods }), })), })); jest.mock('../../../services/agentEventService', () => ({})); @@ -103,3 +106,56 @@ describe('GET /installations runtime adapter projection', () => { } }); }); + +// A `workspace`-trust CLI seat runs a turn with its start directory readable only +// when every room it answers in is private (TASK-294), and this list is where it +// learns that. +describe('GET /installations pod visibility', () => { + const withPods = async (pods, authorized, run) => { + const [savedPods, savedAuthorized] = [mockPods, mockAuthorizedPodIds]; + mockPods = pods; + mockAuthorizedPodIds = authorized; + try { + await run(); + } finally { + mockPods = savedPods; + mockAuthorizedPodIds = savedAuthorized; + } + }; + + test('a pod with no stored visibility is reported private, as the effective booleans', async () => { + await withPods([{ _id: 'pod-1', name: 'Room', type: 'team' }], ['pod-1'], async () => { + const response = await request(app).get('/api/agents/runtime/installations'); + expect(response.status).toBe(200); + expect(response.body.installations[0]).toMatchObject({ publicRead: false, communityListed: false }); + }); + }); + + test('a publicly readable or listed pod is reported as such', async () => { + await withPods([{ + _id: 'pod-1', name: 'Room', type: 'team', publicRead: true, communityListed: true, + }], ['pod-1'], async () => { + const response = await request(app).get('/api/agents/runtime/installations'); + expect(response.body.installations[0]).toMatchObject({ publicRead: true, communityListed: true }); + }); + }); + + test('a pod that no longer exists carries neither field, so the CLI cannot read it as private', async () => { + await withPods([], ['pod-1'], async () => { + const response = await request(app).get('/api/agents/runtime/installations'); + expect(response.body.installations[0]).not.toHaveProperty('publicRead'); + expect(response.body.installations[0]).not.toHaveProperty('communityListed'); + }); + }); + + test('DM pods the seat answers in carry the same fields', async () => { + await withPods([ + { _id: 'pod-1', name: 'Room', type: 'team' }, + { _id: 'dm-1', name: 'DM', type: 'agent-room', publicRead: false, communityListed: false }, + ], ['pod-1', 'dm-1'], async () => { + const response = await request(app).get('/api/agents/runtime/installations'); + const dm = response.body.installations.find((entry) => entry.podId === 'dm-1'); + expect(dm).toMatchObject({ type: 'dm', publicRead: false, communityListed: false }); + }); + }); +}); diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 13f6a56948..90a62e6a2c 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -539,11 +539,21 @@ router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any, const allPodIds = [...podIds.map(String), ...dmPodIds.map(String)]; const pods = allPodIds.length > 0 - ? await Pod.find({ _id: { $in: allPodIds } }).select('_id name type').lean() + ? await Pod.find({ _id: { $in: allPodIds } }).select('_id name type publicRead communityListed').lean() : []; const podMap = Object.fromEntries(pods.map((p: any) => [String(p._id), p])); + // Whether strangers can read each room this seat answers in (TASK-294). A + // `workspace`-trust CLI seat reads it before every turn and runs that turn + // public unless every pod here is private, so the effective booleans are + // reported (an absent stored field means false, as everywhere else that + // reads `publicRead || communityListed`). A pod that no longer exists gets + // neither field, which the CLI reads as not-private. + const podVisibility = (pod: any) => (pod?._id + ? { publicRead: pod.publicRead === true, communityListed: pod.communityListed === true } + : {}); + const installationList = installations.map((inst: any) => { const pod = podMap[String(inst?.podId)] || {}; return { @@ -557,6 +567,7 @@ router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any, // Known runtime kinds (such as a connect-page `webhook`) stay null. // Expose only this non-secret discriminator, never the full config. runtimeAdapter: adapterForRuntimeBinding(inst?.config?.runtime), + ...podVisibility(pod), type: 'installation', }; }); @@ -569,6 +580,7 @@ router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any, podType: pod.type || 'agent-admin', instanceId, status: 'active', + ...podVisibility(pod), type: 'dm', }; }); diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs index 62c84d051f..7c218fa694 100644 --- a/cli/__tests__/attach.test.mjs +++ b/cli/__tests__/attach.test.mjs @@ -1254,3 +1254,28 @@ describe('bootstrapAgentRecordFromEnv', () => { expect(record.podId).toBe('pod-stale'); }); }); + +describe('resolveAttachSandbox — workspace trust (TASK-294)', () => { + test('a claude seat resolves the same host mode a public one does', () => { + expect(resolveAttachSandbox({ + environment: { sandbox: { trust: 'workspace' } }, adapterName: 'claude', platform: 'darwin', + })).toEqual({ mode: 'workspace', trust: 'workspace' }); + expect(resolveAttachSandbox({ + environment: { sandbox: { trust: 'workspace' } }, adapterName: 'claude', platform: 'linux', + })).toEqual({ mode: 'bwrap', trust: 'workspace' }); + }); + + test('every other adapter is refused before anything is published', () => { + for (const adapterName of ['codex', 'opencode', 'qwen', 'pi', 'goose', 'dsh']) { + expect(() => resolveAttachSandbox({ + environment: { sandbox: { trust: 'workspace' } }, adapterName, platform: 'darwin', + })).toThrow(new RegExp(`^${adapterName} adapter: sandbox\\.trust=workspace is claude-only for now`)); + } + }); + + test('beside mode none it is refused, never run unsandboxed', () => { + expect(() => resolveAttachSandbox({ + environment: { sandbox: { trust: 'workspace', mode: 'none' } }, adapterName: 'claude', + })).toThrow(/sandbox\.trust=workspace requires an enforced sandbox mode/); + }); +}); diff --git a/cli/__tests__/default-environment.test.mjs b/cli/__tests__/default-environment.test.mjs index e408c085c0..2531984a36 100644 --- a/cli/__tests__/default-environment.test.mjs +++ b/cli/__tests__/default-environment.test.mjs @@ -159,6 +159,16 @@ describe('withDefaultSandbox', () => { expect(withDefaultSandbox(derived)).toBe(derived); }); + test('leaves a workspace trust alone, so a private-pod seat keeps the tier it was given', () => { + // TASK-294. Replacing it would narrow the seat to public, which is safe and + // wrong; a non-claude adapter handed it refuses at spawn, so keeping it never + // produces a bare spawn either. + const workspace = { sandbox: { trust: 'workspace', allow: ['write'] } }; + expect(withDefaultSandbox(workspace)).toBe(workspace); + expect(withDefaultSandbox({ sandbox: { trust: 'workspace', mode: 'none' } }).sandbox) + .toEqual({ trust: 'public' }); + }); + test('leaves a malformed environment alone, and is idempotent', () => { expect(withDefaultSandbox('nonsense')).toBe('nonsense'); expect(withDefaultSandbox([1])).toEqual([1]); diff --git a/cli/__tests__/environment.test.mjs b/cli/__tests__/environment.test.mjs index 517dcc2a50..afe7a61d9d 100644 --- a/cli/__tests__/environment.test.mjs +++ b/cli/__tests__/environment.test.mjs @@ -219,17 +219,30 @@ describe('validateEnvironmentSpec', () => { }); expect(res.ok).toBe(false); // The message has to name what DOES something, or the next author just - // guesses again: `public`, or no trust field at all. - expect(res.errors.join(' ')).toMatch(/sandbox\.trust must be 'public'/); - expect(res.errors.join(' ')).toMatch(/omit it for your own seat/); + // guesses again: `public`, `workspace`, or no trust field at all. + expect(res.errors.join(' ')).toMatch(/sandbox\.trust must be 'public' or 'workspace'/); + expect(res.errors.join(' ')).toMatch(/omit it for an unconfined seat of your own/); expect(res.errors.join(' ')).toMatch(/got "internal"/); }); - test('accepts the two declarations that do something: public trust, or none', () => { + test('accepts the declarations that do something: public, workspace, or none', () => { expect(validateEnvironmentSpec({ sandbox: { trust: 'public' } }).ok).toBe(true); + expect(validateEnvironmentSpec({ sandbox: { trust: 'workspace' } }).ok).toBe(true); expect(validateEnvironmentSpec({ sandbox: { mode: 'bwrap' } }).ok).toBe(true); }); + test('accepts the workspace opt-ins, and only on a workspace seat', () => { + expect(validateEnvironmentSpec({ + sandbox: { trust: 'workspace', allow: ['write', 'web'] }, + })).toEqual({ ok: true, errors: [] }); + const onPublic = validateEnvironmentSpec({ sandbox: { trust: 'public', allow: ['write'] } }); + expect(onPublic.ok).toBe(false); + expect(onPublic.errors.join(' ')).toMatch(/sandbox\.allow applies only to trust 'workspace'/); + const unknown = validateEnvironmentSpec({ sandbox: { trust: 'workspace', allow: ['search'] } }); + expect(unknown.ok).toBe(false); + expect(unknown.errors.join(' ')).toMatch(/sandbox\.allow must be an array of: write, web/); + }); + test('rejects bad sandbox.trust', () => { const res = validateEnvironmentSpec({ sandbox: { mode: 'workspace', trust: 'sometimes' }, diff --git a/cli/__tests__/public-pod-sandbox-gate.test.mjs b/cli/__tests__/public-pod-sandbox-gate.test.mjs index 9412c4da91..d44d6f875d 100644 --- a/cli/__tests__/public-pod-sandbox-gate.test.mjs +++ b/cli/__tests__/public-pod-sandbox-gate.test.mjs @@ -138,3 +138,35 @@ describe('public-pod sandbox gate', () => { expect(log).toHaveBeenCalledWith(expect.stringMatching(/could not read pod visibility/i)); }); }); + +// A `workspace` seat lets the room read its start directory (TASK-294), so the +// gate needs the pod CONFIRMED private and fails closed on anything less. +describe('workspace trust needs a confirmed private pod', () => { + const WORKSPACE = { sandbox: { trust: 'workspace' } }; + const CONFIRMED_PRIVATE = { _id: 'p2', name: 'My Workspace', publicRead: false, communityListed: false }; + + test('accepts a pod that reports itself private in both fields', async () => { + await expect(assertSandboxDeclaredForPublicPod({ + client: clientFor(CONFIRMED_PRIVATE), podId: 'p2', environment: WORKSPACE, + })).resolves.toBeUndefined(); + }); + + test('refuses a publicly readable pod', async () => { + await expect(assertSandboxDeclaredForPublicPod({ + client: clientFor({ ...PUBLIC_POD, communityListed: false }), podId: 'p1', environment: WORKSPACE, + })).rejects.toThrow(/"Commonly HQ" is publicly readable, so sandbox\.trust=workspace/); + }); + + test('refuses a pod that does not report both fields, rather than guessing private', async () => { + await expect(assertSandboxDeclaredForPublicPod({ + client: clientFor(PRIVATE_POD), podId: 'p2', environment: WORKSPACE, + })).rejects.toThrow(/did not report its visibility/); + }); + + test('refuses when the visibility read fails, where a public declaration only warns', async () => { + const client = { get: jest.fn().mockRejectedValue(new Error('offline')) }; + await expect(assertSandboxDeclaredForPublicPod({ + client, podId: 'p2', environment: WORKSPACE, + })).rejects.toThrow(/requires confirmed private pod visibility/); + }); +}); diff --git a/cli/__tests__/workspace-trust-refusal.test.mjs b/cli/__tests__/workspace-trust-refusal.test.mjs new file mode 100644 index 0000000000..f0a4d4712c --- /dev/null +++ b/cli/__tests__/workspace-trust-refusal.test.mjs @@ -0,0 +1,50 @@ +/** + * `sandbox.trust: 'workspace'` is claude-only for now (TASK-294). Every other + * adapter branches on `trust === 'public'`, so an unread `workspace` would land + * in its non-public branch — for most of them an unconfined spawn. Each one has + * to refuse the declaration before it reads anything else. + */ +import { describe, expect, test } from '@jest/globals'; +import { refuseWorkspaceTrust } from '../src/lib/environment.js'; +import codex from '../src/lib/adapters/codex.js'; +import opencode from '../src/lib/adapters/opencode.js'; +import qwen from '../src/lib/adapters/qwen.js'; +import { assertPrivateDsh } from '../src/lib/adapters/dsh.js'; +import { assertNoSandboxDeclared as assertGooseUnsandboxed } from '../src/lib/adapters/goose.js'; +import { assertNoSandboxDeclared as assertPiUnsandboxed } from '../src/lib/adapters/pi.js'; + +const workspaceEnv = { model: 'test-model', sandbox: { trust: 'workspace' } }; +const CLAUDE_ONLY = /sandbox\.trust=workspace is claude-only for now/; + +describe('refuseWorkspaceTrust', () => { + test('passes every trust it does not own', () => { + expect(() => refuseWorkspaceTrust(undefined, 'codex')).not.toThrow(); + expect(() => refuseWorkspaceTrust({}, 'codex')).not.toThrow(); + expect(() => refuseWorkspaceTrust({ trust: 'public' }, 'codex')).not.toThrow(); + }); + + test('names the adapter and the way out', () => { + expect(() => refuseWorkspaceTrust({ trust: 'workspace' }, 'codex')) + .toThrow(/^codex adapter: sandbox\.trust=workspace is claude-only for now\. Declare sandbox\.trust 'public'/); + }); +}); + +describe('adapters that do not implement the workspace tier refuse it', () => { + test.each([ + ['codex', codex], + ['opencode', opencode], + ['qwen', qwen], + ])('%s refuses at spawn, before it starts a process', async (name, adapter) => { + await expect(adapter.spawn('hello', { environment: workspaceEnv })) + .rejects.toThrow(new RegExp(`^${name} adapter: sandbox\\.trust=workspace is claude-only`)); + }); + + test.each([ + ['dsh', assertPrivateDsh], + ['goose', assertGooseUnsandboxed], + ['pi', assertPiUnsandboxed], + ])('%s refuses in its sandbox assertion', (name, assertSandbox) => { + expect(() => assertSandbox(workspaceEnv)).toThrow(CLAUDE_ONLY); + expect(() => assertSandbox(workspaceEnv)).toThrow(new RegExp(`^${name} adapter:`)); + }); +}); diff --git a/cli/__tests__/workspace-trust.test.mjs b/cli/__tests__/workspace-trust.test.mjs new file mode 100644 index 0000000000..1a3f4591a8 --- /dev/null +++ b/cli/__tests__/workspace-trust.test.mjs @@ -0,0 +1,74 @@ +/** + * The predicates a `workspace`-trust seat stands on (TASK-294): which pods + * count as private, and which start directories may be exposed to them. + */ +import { + afterAll, describe, expect, test, +} from '@jest/globals'; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + allPodsConfirmedPrivate, + podIsConfirmedPrivate, + workspaceStartDirRefusal, +} from '../src/lib/workspace-trust.js'; + +describe('pod visibility', () => { + test('a pod is private only when both fields say so', () => { + expect(podIsConfirmedPrivate({ publicRead: false, communityListed: false })).toBe(true); + expect(podIsConfirmedPrivate({ publicRead: true, communityListed: false })).toBe(false); + expect(podIsConfirmedPrivate({ publicRead: false, communityListed: true })).toBe(false); + }); + + test('an unreported field counts as public, so an older server fails closed', () => { + expect(podIsConfirmedPrivate({ publicRead: false })).toBe(false); + expect(podIsConfirmedPrivate({})).toBe(false); + expect(podIsConfirmedPrivate(null)).toBe(false); + }); + + test('every pod must be private, and an empty list is not proof of anything', () => { + const priv = { publicRead: false, communityListed: false }; + expect(allPodsConfirmedPrivate([priv, priv])).toBe(true); + expect(allPodsConfirmedPrivate([priv, { publicRead: true, communityListed: false }])).toBe(false); + expect(allPodsConfirmedPrivate([])).toBe(false); + expect(allPodsConfirmedPrivate(undefined)).toBe(false); + }); +}); + +describe('workspace start directory', () => { + const root = mkdtempSync(join(tmpdir(), 'commonly-ws-trust-')); + const home = join(root, 'home'); + const repo = join(home, 'code', 'repo'); + mkdirSync(repo, { recursive: true }); + mkdirSync(join(home, '.commonly', 'tokens'), { recursive: true }); + const linkToHome = join(root, 'link-to-home'); + symlinkSync(home, linkToHome); + + afterAll(() => rmSync(root, { recursive: true, force: true })); + + test('a project directory under $HOME is accepted', () => { + expect(workspaceStartDirRefusal(repo, { home })).toBeNull(); + }); + + test('/, $HOME and an ancestor of $HOME are refused', () => { + expect(workspaceStartDirRefusal('/', { home })).toBe('the start directory is /'); + expect(workspaceStartDirRefusal(home, { home })).toBe('the start directory is your home directory'); + expect(workspaceStartDirRefusal(root, { home })).toBe('the start directory contains your home directory'); + }); + + test('a symlink to $HOME is refused as $HOME', () => { + expect(workspaceStartDirRefusal(linkToHome, { home })) + .toBe('the start directory is your home directory'); + }); + + test('a directory inside a credential store is refused', () => { + expect(workspaceStartDirRefusal(join(home, '.commonly', 'tokens'), { home })) + .toBe('the start directory is inside ~/.commonly'); + }); + + test('a relative path is refused', () => { + expect(workspaceStartDirRefusal('code/repo', { home })) + .toBe('the start directory is not an absolute path'); + }); +}); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index db97652ab0..87e92ce620 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -58,9 +58,11 @@ import { environmentForServer, normalizeSandboxTrust, parseEnvironmentFile, + refuseWorkspaceTrust, resolveWorkspace, validateEnvironmentSpec, } from '../lib/environment.js'; +import { podIsConfirmedPrivate } from '../lib/workspace-trust.js'; import { ADAPTERS_WITH_DEFAULT_MCP, ADAPTERS_WITH_GRANT_BROKER, @@ -616,6 +618,29 @@ export const assertSandboxDeclaredForPublicPod = async ({ return; } + // A `workspace` seat lets the room read its start directory (TASK-294), so it + // needs the pod CONFIRMED private, not merely not known to be public: a pod + // whose visibility cannot be read fails closed here, the way dsh does above. + if (environment?.sandbox?.trust === 'workspace') { + let pod; + try { + pod = await client.get(`/api/pods/${podId}`); + } catch { + throw new Error( + 'sandbox.trust=workspace requires confirmed private pod visibility before attach, ' + + 'and this pod\'s visibility could not be read', + ); + } + if (podIsConfirmedPrivate(pod)) return; + const label = pod?.name ? `"${pod.name}"` : podId; + const reported = typeof pod?.publicRead === 'boolean' && typeof pod?.communityListed === 'boolean'; + throw new Error( + `${label} ${reported ? 'is publicly readable' : 'did not report its visibility'}, so ` + + 'sandbox.trust=workspace would let people you do not control read this agent\'s start ' + + 'directory. Declare "sandbox": { "trust": "public" } for this pod, or attach to a private pod.', + ); + } + // Normalized FIRST (Vera 71715, Wren 71718): the predicate below reads a // legacy `internal` record as the `public` it means, the same mapping the // attach gate and both adapters use. Reading it raw refused a record that @@ -842,11 +867,20 @@ export const resolveAttachSandbox = ({ } = {}) => { const sandbox = normalizeSandboxTrust(environment?.sandbox); const trust = sandbox?.trust; + // `workspace` is claude-only for now (TASK-294). Refused here with the words + // the adapter would use at spawn, so the attach fails before anything is + // published rather than at the seat's first turn. + if (trust === 'workspace' && adapterName !== 'claude') { + refuseWorkspaceTrust(sandbox, adapterName || 'this'); + } + // Both confined trusts take their mode from the host the same way; they + // differ in what the policy inside the sandbox allows, not in the sandbox. + const confined = trust === 'public' || trust === 'workspace'; const mode = sandbox?.mode - || (trust === 'public' ? resolvePublicSandboxMode(sandbox, platform) : 'none'); - if (trust === 'public' && mode === 'none') { + || (confined ? resolvePublicSandboxMode(sandbox, platform) : 'none'); + if (confined && mode === 'none') { throw new Error( - 'sandbox.trust=public requires an enforced sandbox mode; refusing to attach unsandboxed', + `sandbox.trust=${trust} requires an enforced sandbox mode; refusing to attach unsandboxed`, ); } // Moved in from the call site (Vera 71675): this is the check the raw compare @@ -854,7 +888,7 @@ export const resolveAttachSandbox = ({ // pure — a mode and an adapter name — so it belongs with the derivation, and // its witnesses live beside it. if ((mode === 'workspace' || mode === 'read-only') - && (trust !== 'public' || !ADAPTERS_WITH_DEFAULT_SANDBOX.has(adapterName))) { + && (!confined || !ADAPTERS_WITH_DEFAULT_SANDBOX.has(adapterName))) { throw new Error( `sandbox.mode=${mode} requires public trust and an adapter with sandbox enforcement`, ); diff --git a/cli/src/lib/adapters/codex.js b/cli/src/lib/adapters/codex.js index 4d1b6979e5..51918e543b 100644 --- a/cli/src/lib/adapters/codex.js +++ b/cli/src/lib/adapters/codex.js @@ -69,7 +69,7 @@ import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credent import { prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir } from '../mcp-home.js'; import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js'; import { resolveSkillSource, skillsAsText } from '../skill-prompt.js'; -import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js'; +import { isLegacySandboxTrust, normalizeSandboxTrust, refuseWorkspaceTrust } from '../environment.js'; import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; // Default timeout for a single codex spawn (exec mode). @@ -653,6 +653,7 @@ export default { async spawn(prompt, ctx = {}) { const seatModel = requireSeatModel(ctx.environment, 'codex'); const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox); + refuseWorkspaceTrust(sandbox, 'codex'); // Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and // `null || ''` is `''` — which routed the unreadable case straight into // the empty-memory branch and made the whole distinction unreachable from diff --git a/cli/src/lib/adapters/dsh.js b/cli/src/lib/adapters/dsh.js index 1bdebee851..9bdaa78b80 100644 --- a/cli/src/lib/adapters/dsh.js +++ b/cli/src/lib/adapters/dsh.js @@ -9,7 +9,7 @@ import { homedir, tmpdir } from 'os'; import { basename, join } from 'path'; import { buildMemoryPreamble } from '../memory-bridge.js'; import { commonlyMcpServer } from '../default-environment.js'; -import { effectiveSandboxTrust, validateEnvironmentSpec } from '../environment.js'; +import { effectiveSandboxTrust, refuseWorkspaceTrust, validateEnvironmentSpec } from '../environment.js'; import { deliverSeatCredential } from '../mcp-credential-delivery.js'; import { describeMcpCommand } from '../mcp-server-version.js'; import { writeCredentialFile } from '../credential-file.js'; @@ -23,6 +23,7 @@ const SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CER export const assertPrivateDsh = (environment = {}) => { const sandbox = environment?.sandbox || {}; + refuseWorkspaceTrust(sandbox, 'dsh'); if (effectiveSandboxTrust(sandbox.trust) === 'public') { throw new Error('dsh adapter: public-trust seats are not supported; nested Seatbelt fails closed'); } diff --git a/cli/src/lib/adapters/goose.js b/cli/src/lib/adapters/goose.js index 8c0d5ffe8d..76fb8be3da 100644 --- a/cli/src/lib/adapters/goose.js +++ b/cli/src/lib/adapters/goose.js @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { StringDecoder } from 'node:string_decoder'; import { buildMemoryPreamble } from '../memory-bridge.js'; -import { effectiveSandboxTrust, validateEnvironmentSpec } from '../environment.js'; +import { effectiveSandboxTrust, refuseWorkspaceTrust, validateEnvironmentSpec } from '../environment.js'; import { writeCredentialFile } from '../credential-file.js'; import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js'; import { prepareMcpSpawn } from '../mcp-home.js'; @@ -24,6 +24,7 @@ const SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CER export const assertNoSandboxDeclared = (environment = {}) => { const sandbox = environment?.sandbox || {}; + refuseWorkspaceTrust(sandbox, 'goose'); if (effectiveSandboxTrust(sandbox.trust) === 'public') { throw new Error('goose adapter: public-trust seats are not supported until the deny-by-default attack test passes'); } diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index a9eafc524f..c2f0fee799 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -47,6 +47,7 @@ import { buildMemoryPreamble } from '../memory-bridge.js'; import { isLegacySandboxTrust, normalizeSandboxTrust, + refuseWorkspaceTrust, validateEnvironmentSpec, } from '../environment.js'; import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; @@ -651,6 +652,7 @@ export default { async spawn(prompt, rawCtx = {}) { const seatModel = requireSeatModel(rawCtx.environment, 'opencode'); const sandbox = normalizeSandboxTrust(rawCtx.environment?.sandbox); + refuseWorkspaceTrust(sandbox, 'opencode'); const ctx = rawCtx.environment ? { ...rawCtx, environment: { ...rawCtx.environment, sandbox } } : rawCtx; diff --git a/cli/src/lib/adapters/pi.js b/cli/src/lib/adapters/pi.js index 0a4589efa3..14f87d3a93 100644 --- a/cli/src/lib/adapters/pi.js +++ b/cli/src/lib/adapters/pi.js @@ -44,7 +44,7 @@ import { spawnCredentials, } from '../upstream-refusal.js'; import { buildMemoryPreamble } from '../memory-bridge.js'; -import { effectiveSandboxTrust } from '../environment.js'; +import { effectiveSandboxTrust, refuseWorkspaceTrust } from '../environment.js'; import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs'; import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js'; import { prepareMcpSpawn } from '../mcp-home.js'; @@ -306,6 +306,7 @@ export const sessionExists = async (sessionDir, sessionId) => { */ export const assertNoSandboxDeclared = (environment = {}) => { const sandbox = environment?.sandbox || {}; + refuseWorkspaceTrust(sandbox, 'pi'); if (effectiveSandboxTrust(sandbox.trust) === 'public') { throw new Error('pi adapter: public-trust seats are not supported — pi has no enforced sandbox; do not attach a pi seat to a stranger-readable pod'); } diff --git a/cli/src/lib/adapters/qwen.js b/cli/src/lib/adapters/qwen.js index 89ce982b8f..cbb6f45d59 100644 --- a/cli/src/lib/adapters/qwen.js +++ b/cli/src/lib/adapters/qwen.js @@ -9,7 +9,7 @@ import { buildMemoryPreamble } from '../memory-bridge.js'; import { deliverSeatCredential } from '../mcp-credential-delivery.js'; import { prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir } from '../mcp-home.js'; import { writeCredentialFile } from '../credential-file.js'; -import { normalizeSandboxTrust, validateEnvironmentSpec } from '../environment.js'; +import { normalizeSandboxTrust, refuseWorkspaceTrust, validateEnvironmentSpec } from '../environment.js'; import { resolvePublicSandboxMode } from '../sandbox/mode.js'; import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js'; import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js'; @@ -234,6 +234,7 @@ export default { async spawn(prompt, ctx = {}) { const seatModel = requireSeatModel(ctx.environment, 'qwen', true); const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox); + refuseWorkspaceTrust(sandbox, 'qwen'); const isPublic = sandbox?.trust === 'public'; const mode = isPublic ? resolvePublicSandboxMode(sandbox) : null; if (isPublic && !['workspace', 'read-only', 'bwrap'].includes(mode)) { diff --git a/cli/src/lib/default-environment.js b/cli/src/lib/default-environment.js index c4cd55b288..6c37b4343f 100644 --- a/cli/src/lib/default-environment.js +++ b/cli/src/lib/default-environment.js @@ -151,13 +151,15 @@ export const withDefaultSandbox = (environment) => { // gate accepts — benign (same effective trust) but not a mirror, and the // comment would be false. The ORIGINAL block is what gets returned below. const sandbox = normalizeSandboxTrust(environment?.sandbox); - // An ENFORCED declaration is one the adapters act on: a public trust (whose - // mode they resolve, and refuse to spawn without) or an explicit non-'none' + // An ENFORCED declaration is one the adapters act on: a public or workspace + // trust (whose mode they resolve, and refuse to spawn without — every adapter + // but claude refuses `workspace` outright, TASK-294) or an explicit non-'none' // mode. `mode: 'none'`, an empty block and a missing one are all the same // thing to a spawn — no confinement — so all three are replaced here. const enforced = sandbox !== null && typeof sandbox === 'object' && sandbox.mode !== 'none' - && (sandbox.trust === 'public' || typeof sandbox.mode === 'string'); + && (sandbox.trust === 'public' || sandbox.trust === 'workspace' + || typeof sandbox.mode === 'string'); if (enforced) return environment; return { ...(environment || {}), sandbox: defaultSeatSandbox() }; }; diff --git a/cli/src/lib/environment.js b/cli/src/lib/environment.js index e8865b43db..b17eebb36f 100644 --- a/cli/src/lib/environment.js +++ b/cli/src/lib/environment.js @@ -47,7 +47,14 @@ const ALLOWED_TOP_KEYS = new Set([ const ALLOWED_SANDBOX_MODES = new Set([ 'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed', ]); -const ALLOWED_SANDBOX_TRUST = new Set(['public']); +// `workspace` is the middle trust the comment below waits for (TASK-294): a seat +// on a pod nobody outside it can read, allowed to read its start directory and +// run read-only git there. Only the claude adapter reads it; every other adapter +// refuses it before spawning, so it never degrades into a bare spawn. +const ALLOWED_SANDBOX_TRUST = new Set(['public', 'workspace']); +// The opt-ins a `workspace` seat can carry. Each one widens a single thing: +// `write` adds Edit/Write/Bash and a writable workspace; `web` adds WebFetch. +export const WORKSPACE_SANDBOX_ALLOW = Object.freeze(['write', 'web']); const ALLOWED_NETWORK_POLICIES = new Set(['unrestricted', 'restricted']); const INLINE_PROVIDER_CREDENTIAL_FIELD = /(?:key|token|secret|password|authorization|header|credential|auth)/i; @@ -72,7 +79,8 @@ const findInlineProviderCredentialField = (value, path) => { // above, and a record that already carries it is resolved TOWARD confinement, // never toward the bare spawn (Wren 69585): `internal` is read as `public`, so // such a seat is confined where it can be and refuses to derive where it -// cannot. A real middle trust arrives as its own adapter that reads it. +// cannot. A real middle trust arrives as its own adapter that reads it — and +// `workspace` (above) is that trust, which is why it is NOT a legacy alias. export const LEGACY_SANDBOX_TRUST = Object.freeze({ internal: 'public' }); export const isLegacySandboxTrust = (sandbox) => ( typeof sandbox?.trust === 'string' @@ -100,6 +108,21 @@ export const effectiveSandboxTrust = (trust) => ( : trust ); +/** + * Throw when an adapter that does not implement the `workspace` tier is handed + * one (TASK-294). Only claude reads it. Every other adapter branches on + * `trust === 'public'`, so an unread `workspace` would fall into its + * non-public branch — the same silent widening `internal` was (Vera 69592). + * Called at each adapter's spawn entry, before any other sandbox read. + */ +export const refuseWorkspaceTrust = (sandbox, adapterLabel) => { + if (sandbox?.trust !== 'workspace') return; + throw new Error( + `${adapterLabel} adapter: sandbox.trust=workspace is claude-only for now. ` + + "Declare sandbox.trust 'public' for this seat, or attach it with the claude adapter.", + ); +}; + const expandHome = (p) => { if (!p || typeof p !== 'string') return p; if (p === '~') return homedir(); @@ -294,18 +317,31 @@ export const validateEnvironmentSpec = (spec) => { errors.push('sandbox must be an object'); } else { const { - mode, trust, network, filesystem, + mode, trust, network, filesystem, allow, } = spec.sandbox; if (mode !== undefined && !ALLOWED_SANDBOX_MODES.has(mode)) { errors.push(`sandbox.mode must be one of: ${[...ALLOWED_SANDBOX_MODES].join(', ')}`); } if (trust !== undefined && !ALLOWED_SANDBOX_TRUST.has(trust)) { errors.push( - "sandbox.trust must be 'public' — it marks a seat anyone in the pod can " - + 'talk to; omit it for your own seat ' + "sandbox.trust must be 'public' or 'workspace' — 'public' marks a seat " + + "anyone in the pod can talk to, 'workspace' a seat on a private pod that " + + 'reads its start directory; omit it for an unconfined seat of your own ' + `(got ${JSON.stringify(trust)})`, ); } + if (allow !== undefined) { + if (!Array.isArray(allow) || !allow.every((a) => WORKSPACE_SANDBOX_ALLOW.includes(a))) { + errors.push(`sandbox.allow must be an array of: ${WORKSPACE_SANDBOX_ALLOW.join(', ')}`); + } else if (trust !== 'workspace') { + // An opt-in widens the workspace tier and nothing else. On a public + // seat it would read as a way to hand strangers Bash. + errors.push( + "sandbox.allow applies only to trust 'workspace' " + + `(got trust ${JSON.stringify(trust)})`, + ); + } + } if (network !== undefined) { if (typeof network !== 'object' || network === null) { errors.push('sandbox.network must be an object'); diff --git a/cli/src/lib/sandbox/mode.js b/cli/src/lib/sandbox/mode.js index 012e6292cf..9d40f0f683 100644 --- a/cli/src/lib/sandbox/mode.js +++ b/cli/src/lib/sandbox/mode.js @@ -28,14 +28,21 @@ * NOT used by the codex adapter. Codex's `mode` is read-vs-write access, not a * host mechanism — its permission profiles run on both platforms — so its * derived default is 'workspace' everywhere. + * + * A `workspace` trust (TASK-294) resolves the same way: it is confined by the + * same host mechanism and differs only in what the policy inside allows. The + * grant-broker guard still reads the trust itself, not this mode, so resolving + * it here does not make a workspace seat eligible for a room grant. */ import { effectiveSandboxTrust } from '../environment.js'; export const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']); +const HOST_CONFINED_TRUSTS = new Set(['public', 'workspace']); + export const resolvePublicSandboxMode = (sandbox, platform = process.platform) => { const declared = sandbox?.mode; - if (effectiveSandboxTrust(sandbox?.trust) !== 'public') return declared; + if (!HOST_CONFINED_TRUSTS.has(effectiveSandboxTrust(sandbox?.trust))) return declared; if (declared !== undefined && declared !== null) return declared; return platform === 'darwin' ? 'workspace' : 'bwrap'; }; diff --git a/cli/src/lib/workspace-trust.js b/cli/src/lib/workspace-trust.js new file mode 100644 index 0000000000..abccda4579 --- /dev/null +++ b/cli/src/lib/workspace-trust.js @@ -0,0 +1,71 @@ +/** + * The two facts a `workspace`-trust seat depends on (TASK-294), as pure + * predicates so the attach path, the first-run bootstrap and the per-spawn + * check all read them the same way. + * + * 1. Every pod the seat answers in is CONFIRMED private. A pod whose + * visibility the server did not report counts as public: the workspace tier + * lets the room read the start directory, so an unknown room fails closed. + * 2. The start directory is a directory worth exposing to that room, and not + * one that contains the operator's whole account: `/`, `$HOME`, an ancestor + * of `$HOME`, or a directory inside one of the credential stores the public + * policy denies by name. + */ +import { realpathSync } from 'fs'; +import { homedir } from 'os'; +import { + isAbsolute, join, relative, resolve, +} from 'path'; + +/** A pod is private only when the server says so in both fields. */ +export const podIsConfirmedPrivate = (pod) => ( + typeof pod?.publicRead === 'boolean' + && typeof pod?.communityListed === 'boolean' + && !pod.publicRead + && !pod.communityListed +); + +/** True only for a non-empty list in which every pod is confirmed private. */ +export const allPodsConfirmedPrivate = (pods) => ( + Array.isArray(pods) && pods.length > 0 && pods.every(podIsConfirmedPrivate) +); + +// The stores claude.js denies by absolute path for a public seat. A start +// directory inside one of them would put the seat's own credentials in reach. +const CREDENTIAL_DIRS = ['.commonly', '.claude', '.codex', '.ssh', '.aws', '.config']; + +const realOrResolved = (p) => { + try { + return realpathSync(p); + } catch { + return resolve(p); + } +}; + +const isInsideOrEqual = (child, parent) => { + const rel = relative(parent, child); + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); +}; + +/** + * Why `dir` cannot be a workspace seat's start directory, or `null` when it can. + * Symlinks are resolved first, so a link to `$HOME` is refused as `$HOME`. + */ +export const workspaceStartDirRefusal = (dir, { home = homedir() } = {}) => { + if (typeof dir !== 'string' || !isAbsolute(dir)) { + return 'the start directory is not an absolute path'; + } + const real = realOrResolved(dir); + const realHome = realOrResolved(home); + if (real === '/') return 'the start directory is /'; + if (isInsideOrEqual(realHome, real)) { + return real === realHome + ? 'the start directory is your home directory' + : 'the start directory contains your home directory'; + } + const store = CREDENTIAL_DIRS.find((name) => isInsideOrEqual(real, join(realHome, name))); + if (store) return `the start directory is inside ~/${store}`; + return null; +}; + +export default { podIsConfirmedPrivate, allPodsConfirmedPrivate, workspaceStartDirRefusal }; From 3ddbf1ec57d1b6c8900c6f45706972ba58ae80e3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:38:38 -0700 Subject: [PATCH 02/33] feat(cli): read-only git as the workspace seat's one git tool, argv-validated (TASK-294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `workspace`-trust seat may read the repository it started in — status, log, diff, show, blame — and nothing more. A Bash permission prefix is not a boundary that holds here, so Bash stays denied to the tier and git is offered as one stdio MCP server, `workspace_git`, exposing a single `git` tool. Its argv is built and run with execFile, no shell in between. The validator keeps the tool read-only along four lines: - the subcommand is one of the five builtins, so an alias never resolves, and the caller's arguments land after it where git's top-level options are not parsed; - options that write a file, run a program, or read a caller-named file are refused by name, including git's accepted abbreviations of them, with a reason; - paths that leave the workspace are refused, before and after `--`; - diff/log/show are forced through `--no-ext-diff --no-textconv`, status through `core.fsmonitor=false`, and the environment is rebuilt from an allowlist with config off and GIT_CEILING_DIRECTORIES at the workspace, so discovery cannot climb to a parent repo. Tests pair each refusal with a positive control: the same bare git form, run unguarded in a throwaway repo, that writes a file, runs a program, or climbs out — so the suite proves the option is dangerous, not merely that a string was rejected. The server is also driven end to end over a real stdio pipe through the pi bridge's own client. Later slices wire this into the claude adapter and admit its exec and gitdir reads into the Seatbelt/bwrap profile. Co-Authored-By: Claude Opus 4.8 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/git-readonly-server.test.mjs | 144 +++++++++++++ cli/__tests__/git-readonly.test.mjs | 230 +++++++++++++++++++++ cli/src/lib/git-readonly-server.js | 136 ++++++++++++ cli/src/lib/git-readonly.js | 214 +++++++++++++++++++ 4 files changed, 724 insertions(+) create mode 100644 cli/__tests__/git-readonly-server.test.mjs create mode 100644 cli/__tests__/git-readonly.test.mjs create mode 100644 cli/src/lib/git-readonly-server.js create mode 100644 cli/src/lib/git-readonly.js diff --git a/cli/__tests__/git-readonly-server.test.mjs b/cli/__tests__/git-readonly-server.test.mjs new file mode 100644 index 0000000000..61045b6a19 --- /dev/null +++ b/cli/__tests__/git-readonly-server.test.mjs @@ -0,0 +1,144 @@ +/** + * The workspace_git MCP server (TASK-294): the handler in isolation, and the + * whole server driven over a real stdio pipe via connectStdioMcp — the same + * client the pi bridge uses — so the transport the claude adapter will spawn is + * exercised end to end. + */ +import { + afterAll, beforeAll, describe, expect, test, +} from '@jest/globals'; +import { execFile } from 'child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { promisify } from 'util'; +import { connectStdioMcp, toPiResult } from '../src/lib/adapters/pi-mcp-client.mjs'; +import { + GIT_TOOL, SERVER_NAME, TOOL_NAME, createGitServerHandler, parseServerArgs, +} from '../src/lib/git-readonly-server.js'; + +const execFileAsync = promisify(execFile); +const SERVER = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'lib', 'git-readonly-server.js'); + +let repo; + +beforeAll(async () => { + repo = mkdtempSync(join(tmpdir(), 'git-ro-srv-')); + const env = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }; + await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: repo, env }); + await execFileAsync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo, env }); + await execFileAsync('git', ['config', 'user.name', 'Test'], { cwd: repo, env }); + writeFileSync(join(repo, 'tracked.txt'), 'hello\n'); + await execFileAsync('git', ['add', 'tracked.txt'], { cwd: repo, env }); + await execFileAsync('git', ['commit', '-q', '-m', 'seed'], { cwd: repo, env }); +}); + +afterAll(() => rmSync(repo, { recursive: true, force: true })); + +describe('the JSON-RPC handler', () => { + const handler = () => createGitServerHandler({ root: repo, git: 'git' }); + + test('initialize echoes a supported protocol and advertises tools', async () => { + const res = await handler()({ + jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' }, + }); + expect(res.result.protocolVersion).toBe('2024-11-05'); + expect(res.result.capabilities.tools).toBeDefined(); + expect(res.result.serverInfo.name).toBe(SERVER_NAME); + }); + + test('an unknown requested protocol falls back to a supported one', async () => { + const res = await handler()({ + jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '1999-01-01' }, + }); + expect(res.result.protocolVersion).toBe('2024-11-05'); + }); + + test('tools/list returns the single git tool', async () => { + const res = await handler()({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + expect(res.result.tools).toHaveLength(1); + expect(res.result.tools[0].name).toBe(TOOL_NAME); + expect(res.result.tools[0].inputSchema.properties.subcommand.enum).toContain('status'); + }); + + test('a notification (no id) gets no response', async () => { + expect(await handler()({ jsonrpc: '2.0', method: 'notifications/initialized' })).toBeNull(); + }); + + test('ping is answered; an unknown method is a JSON-RPC error', async () => { + expect((await handler()({ jsonrpc: '2.0', id: 3, method: 'ping' })).result).toEqual({}); + const err = await handler()({ jsonrpc: '2.0', id: 4, method: 'nope' }); + expect(err.error.code).toBe(-32601); + }); + + test('tools/call runs a read-only subcommand and returns its text', async () => { + const res = await handler()({ + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: TOOL_NAME, arguments: { subcommand: 'log', args: ['--oneline'] } }, + }); + expect(res.result.isError).toBe(false); + expect(res.result.content[0].text).toMatch(/seed/); + }); + + test('tools/call surfaces a refusal as an error result, not a throw', async () => { + const res = await handler()({ + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: TOOL_NAME, arguments: { subcommand: 'diff', args: ['--output=/tmp/x'] } }, + }); + expect(res.result.isError).toBe(true); + expect(res.result.content[0].text).toMatch(/--output/); + }); + + test('an unknown tool name is rejected', async () => { + const res = await handler()({ + jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'rm', arguments: {} }, + }); + expect(res.error.code).toBe(-32602); + }); +}); + +describe('parseServerArgs', () => { + test('resolves --root to a real directory and defaults --git', () => { + const parsed = parseServerArgs(['--root', repo]); + expect(parsed.git).toBe('git'); + expect(parsed.root.length).toBeGreaterThan(0); + }); + + test('refuses a missing or relative root', () => { + expect(() => parseServerArgs([])).toThrow(/--root/); + expect(() => parseServerArgs(['--root', 'relative'])).toThrow(/--root/); + }); +}); + +describe('over a real stdio pipe (connectStdioMcp)', () => { + test('initialize, list the tool, call it, and refuse a write — end to end', async () => { + const client = connectStdioMcp({ + name: SERVER_NAME, + command: ['node', SERVER, '--root', repo, '--git', 'git'], + env: {}, + }); + try { + const init = await client.initialize(); + expect(init.serverInfo.name).toBe(SERVER_NAME); + + const tools = await client.listTools(); + expect(tools.map((t) => t.name)).toEqual([TOOL_NAME]); + expect(tools[0].description).toContain(GIT_TOOL.name); + + const ok = await client.callTool(TOOL_NAME, { subcommand: 'status', args: ['--short'] }); + expect(toPiResult(ok).details.isError).toBeFalsy(); + + const denied = await client.callTool(TOOL_NAME, { subcommand: 'diff', args: ['--output=/tmp/x'] }); + const piDenied = toPiResult(denied); + expect(piDenied.details.isError).toBe(true); + expect(piDenied.content[0].text).toMatch(/--output/); + } finally { + client.close(); + } + }); +}); diff --git a/cli/__tests__/git-readonly.test.mjs b/cli/__tests__/git-readonly.test.mjs new file mode 100644 index 0000000000..f155cb9296 --- /dev/null +++ b/cli/__tests__/git-readonly.test.mjs @@ -0,0 +1,230 @@ +/** + * The read-only git a `workspace`-trust seat is given (TASK-294). Each refusal + * is paired with a positive control: the same bare git form, run with no guard + * in a throwaway repo, that writes a file or runs a program — so the test + * proves the option is dangerous, not merely that a string was rejected. + */ +import { + afterAll, beforeAll, describe, expect, test, +} from '@jest/globals'; +import { execFile } from 'child_process'; +import { + existsSync, mkdtempSync, rmSync, writeFileSync, mkdirSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { promisify } from 'util'; +import { + DENIED_LONG_OPTIONS, + readonlyGitArgv, + readonlyGitEnv, + runReadonlyGit, +} from '../src/lib/git-readonly.js'; + +const execFileAsync = promisify(execFile); +const GIT = 'git'; + +// A repo with one commit, a tracked file, and a second directory beside it to +// reach for. Every test runs real git here, so refusals are measured, not +// assumed. +let repo; +let outside; + +const bareGit = (args, opts = {}) => execFileAsync(GIT, args, { + cwd: repo, + env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, + ...opts, +}); + +beforeAll(async () => { + repo = mkdtempSync(join(tmpdir(), 'git-ro-repo-')); + outside = mkdtempSync(join(tmpdir(), 'git-ro-outside-')); + writeFileSync(join(outside, 'secret.txt'), 'a file above the workspace\n'); + await bareGit(['init', '-q', '-b', 'main']); + await bareGit(['config', 'user.email', 'test@example.com']); + await bareGit(['config', 'user.name', 'Test']); + writeFileSync(join(repo, 'tracked.txt'), 'first line\n'); + await bareGit(['add', 'tracked.txt']); + await bareGit(['commit', '-q', '-m', 'first commit']); + writeFileSync(join(repo, 'tracked.txt'), 'first line\nsecond line\n'); +}); + +afterAll(() => { + rmSync(repo, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); +}); + +const refusal = (call) => { + const checked = readonlyGitArgv({ root: repo, ...call }); + expect(checked.ok).toBe(false); + return checked.reason; +}; + +describe('read-only git refuses what is not read-only', () => { + test('the subcommand must be one of the five, so an alias never resolves', () => { + expect(refusal({ subcommand: 'push', args: [] })).toMatch(/only status, log, diff, show, blame/); + // A configured alias is just another subcommand name, so it is refused too. + expect(refusal({ subcommand: 'exfil', args: [] })).toMatch(/refused/); + }); + + test('--output writes a file (control), and is refused', async () => { + const marker = join(repo, 'written-by-output.txt'); + await bareGit(['diff', `--output=${marker}`]); + expect(existsSync(marker)).toBe(true); // control: the bare form wrote it + rmSync(marker); + expect(refusal({ subcommand: 'diff', args: [`--output=${marker}`] })).toMatch(/--output .*writes/); + expect(existsSync(marker)).toBe(false); + }); + + test('a -c alias with a shell escape runs a program (control), and -c is refused', async () => { + const marker = join(repo, 'run-by-alias.txt'); + await bareGit(['-c', `alias.x=!touch ${marker}`, 'x']); + expect(existsSync(marker)).toBe(true); // control: the alias executed + rmSync(marker); + // -c is a top-level option; after a subcommand git reads it as a short option. + expect(refusal({ subcommand: 'log', args: ['-c', 'alias.x=!cmd'] })).toMatch(/-c is refused/); + }); + + test('--ext-diff runs an external diff program, and is refused', () => { + expect(refusal({ subcommand: 'diff', args: ['--ext-diff'] })).toMatch(/--ext-diff .*runs an external/); + }); + + test('--textconv runs a configured conversion program, and is refused', () => { + expect(refusal({ subcommand: 'show', args: ['--textconv'] })).toMatch(/--textconv .*runs a configured/); + }); + + test('--no-index compares files outside the repository, and is refused', () => { + expect(refusal({ subcommand: 'diff', args: ['--no-index', '/etc/passwd', '/etc/hosts'] })) + .toMatch(/--no-index .*outside the repository/); + }); + + test('--git-dir opens another repository, and is refused', () => { + expect(refusal({ subcommand: 'log', args: ['--git-dir', outside] })).toMatch(/--git-dir .*different repository/); + }); + + test('--work-tree, --exec-path, --config-env, --contents, --ignore-revs-file, --paginate are refused', () => { + expect(refusal({ subcommand: 'status', args: ['--work-tree', outside] })).toMatch(/--work-tree/); + expect(refusal({ subcommand: 'log', args: ['--exec-path', outside] })).toMatch(/--exec-path/); + expect(refusal({ subcommand: 'log', args: ['--config-env', 'x=Y'] })).toMatch(/--config-env/); + expect(refusal({ subcommand: 'blame', args: ['--contents', join(outside, 'secret.txt'), 'tracked.txt'] })) + .toMatch(/--contents/); + expect(refusal({ subcommand: 'blame', args: ['--ignore-revs-file', join(outside, 'secret.txt')] })) + .toMatch(/--ignore-revs-file/); + expect(refusal({ subcommand: 'log', args: ['--paginate'] })).toMatch(/--paginate/); + }); + + test('a git-accepted abbreviation of a denied option is refused', async () => { + // Control: git's parse-options subcommands take an unambiguous prefix, so + // `blame --conten` IS `--contents`, which reads a caller-named file. + await expect(bareGit(['blame', '--conten=tracked.txt', 'tracked.txt'])).resolves.toBeDefined(); + expect(refusal({ subcommand: 'blame', args: ['--conten=tracked.txt', 'tracked.txt'] })) + .toMatch(/reads it as --contents/); + // Defense in depth: we also refuse the abbreviation for diff's own parser, + // which rejects it today but may not forever. + expect(refusal({ subcommand: 'diff', args: ['--ext'] })).toMatch(/reads it as --ext-diff/); + }); + + test('-C is refused, with a pointer to the real copy-detection option', () => { + // Top-level `-C ` changes directory; this keeps it out of args entirely. + expect(refusal({ subcommand: 'log', args: ['-C', outside] })).toMatch(/-C is refused/); + }); + + test('-O reads a caller-named order file, and is refused', () => { + expect(refusal({ subcommand: 'diff', args: [`-O${join(outside, 'secret.txt')}`] })).toMatch(/-O is refused/); + }); + + test('a glued short-option cluster is refused; pass each on its own', () => { + // -Rc would smuggle -c past a per-letter check; the cluster itself is refused. + expect(refusal({ subcommand: 'diff', args: ['-Rc'] })).toMatch(/pass each short option/); + }); + + test('a path outside the workspace is refused, before and after --', () => { + expect(refusal({ subcommand: 'log', args: [join(outside, 'secret.txt')] })).toMatch(/outside the workspace/); + expect(refusal({ subcommand: 'log', args: ['--', '../'] })).toMatch(/outside the workspace/); + expect(refusal({ subcommand: 'show', args: ['../'] })).toMatch(/outside the workspace/); + }); + + test('an absolute in-workspace path is allowed; a NUL or over-long arg is refused', () => { + expect(readonlyGitArgv({ subcommand: 'log', args: [join(repo, 'tracked.txt')], root: repo }).ok).toBe(true); + expect(refusal({ subcommand: 'log', args: ['a\0b'] })).toMatch(/NUL/); + expect(refusal({ subcommand: 'log', args: ['x'.repeat(5000)] })).toMatch(/too long/); + }); + + test('every denied long option is listed with what it does', () => { + for (const [name, what] of Object.entries(DENIED_LONG_OPTIONS)) { + expect(what.length).toBeGreaterThan(0); + expect(refusal({ subcommand: 'diff', args: [name] })).toContain(name); + } + }); +}); + +describe('read-only git runs the five subcommands', () => { + test('forced options are prepended for diff, log and show', () => { + expect(readonlyGitArgv({ subcommand: 'diff', args: [], root: repo }).argv) + .toEqual(['--no-pager', '-c', 'core.fsmonitor=false', 'diff', '--no-ext-diff', '--no-textconv']); + expect(readonlyGitArgv({ subcommand: 'status', args: [], root: repo }).argv) + .toEqual(['--no-pager', '-c', 'core.fsmonitor=false', 'status']); + }); + + test('status, log, diff, show and blame all return output', async () => { + const status = await runReadonlyGit({ subcommand: 'status', args: ['--short'], root: repo, git: GIT }); + expect(status.ok).toBe(true); + expect(status.text).toMatch(/tracked\.txt/); + + const log = await runReadonlyGit({ subcommand: 'log', args: ['--oneline'], root: repo, git: GIT }); + expect(log.ok).toBe(true); + expect(log.text).toMatch(/first commit/); + + const diff = await runReadonlyGit({ subcommand: 'diff', args: [], root: repo, git: GIT }); + expect(diff.ok).toBe(true); + expect(diff.text).toMatch(/second line/); + + const show = await runReadonlyGit({ subcommand: 'show', args: ['--stat', 'HEAD'], root: repo, git: GIT }); + expect(show.ok).toBe(true); + expect(show.text).toMatch(/first commit/); + + const blame = await runReadonlyGit({ subcommand: 'blame', args: ['tracked.txt'], root: repo, git: GIT }); + expect(blame.ok).toBe(true); + expect(blame.text).toMatch(/first line/); + }); + + test('value-carrying short options (-n5, -U1) are allowed', async () => { + const log = await runReadonlyGit({ subcommand: 'log', args: ['-n1', '--oneline'], root: repo, git: GIT }); + expect(log.ok).toBe(true); + expect(log.text.trim().split('\n')).toHaveLength(1); + }); + + test('a refused call comes back as a reason, not a throw', async () => { + const out = await runReadonlyGit({ subcommand: 'diff', args: ['--output=/tmp/x'], root: repo, git: GIT }); + expect(out.ok).toBe(false); + expect(out.text).toMatch(/--output/); + }); + + test('GIT_CEILING_DIRECTORIES stops discovery at the workspace parent; no GIT_* leaks in', () => { + const env = readonlyGitEnv({ root: repo, baseEnv: { GIT_DIR: '/evil', PATH: '/usr/bin', FOO: 'bar' } }); + expect(env.GIT_DIR).toBeUndefined(); + expect(env.FOO).toBeUndefined(); + expect(env.PATH).toBe('/usr/bin'); + expect(env.GIT_CONFIG_NOSYSTEM).toBe('1'); + expect(env.GIT_CONFIG_GLOBAL).toBe('/dev/null'); + expect(env.GIT_CEILING_DIRECTORIES.length).toBeGreaterThan(0); + }); + + test('a nested start dir cannot reach a parent repo through discovery', async () => { + // A repo, then a nested non-repo dir used as the workspace. Ordinary git + // walks up and finds the parent; ours stops at the ceiling. + const parent = mkdtempSync(join(tmpdir(), 'git-ro-parent-')); + try { + await execFileAsync(GIT, ['init', '-q', parent]); + const nested = join(parent, 'nested'); + mkdirSync(nested); + const bare = await execFileAsync(GIT, ['status'], { cwd: nested }).then(() => 'found', () => 'not-found'); + expect(bare).toBe('found'); // control: bare git walks up to the parent repo + const confined = await runReadonlyGit({ subcommand: 'status', args: [], root: nested, git: GIT }); + expect(confined.ok).toBe(false); + expect(confined.text).toMatch(/not a git repository/i); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/src/lib/git-readonly-server.js b/cli/src/lib/git-readonly-server.js new file mode 100644 index 0000000000..b9958c6778 --- /dev/null +++ b/cli/src/lib/git-readonly-server.js @@ -0,0 +1,136 @@ +/** + * The `workspace_git` MCP server: one tool, `git`, for a workspace-trust seat + * (TASK-294). The claude adapter spawns it as + * + * node git-readonly-server.js --root --git + * + * and the model sees it as `mcp__workspace_git__git`. It speaks the MCP stdio + * transport (newline-delimited JSON-RPC 2.0) and hands every call to + * runReadonlyGit, which refuses anything that is not read-only and runs the + * rest with execFile in the workspace. + */ +import { realpathSync, statSync } from 'fs'; +import { isAbsolute } from 'path'; +import { createInterface } from 'readline'; +import { fileURLToPath } from 'url'; +import { READONLY_GIT_SUBCOMMANDS, runReadonlyGit } from './git-readonly.js'; + +export const SERVER_NAME = 'workspace_git'; +export const TOOL_NAME = 'git'; +const SERVER_VERSION = '1.0.0'; + +// The tool surface is the same in each of these, so the server answers in the +// version the client asked for when it is one of them. +const SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(['2024-11-05', '2025-03-26', '2025-06-18']); + +export const GIT_TOOL = Object.freeze({ + name: TOOL_NAME, + description: `Run read-only git in your workspace: ${READONLY_GIT_SUBCOMMANDS.join(', ')}. ` + + 'Pass the subcommand and its arguments separately, one argument per array entry, ' + + "e.g. { subcommand: 'log', args: ['-n', '5', '--oneline'] }. " + + 'Options that write a file, run a program, or read outside the workspace are refused with the reason, ' + + 'and paths must stay inside the workspace. This is the only git available to you.', + inputSchema: { + type: 'object', + properties: { + subcommand: { type: 'string', enum: [...READONLY_GIT_SUBCOMMANDS] }, + args: { + type: 'array', + items: { type: 'string' }, + description: 'Arguments after the subcommand, one per entry.', + }, + }, + required: ['subcommand'], + additionalProperties: false, + }, +}); + +const reply = (id, result) => ({ jsonrpc: '2.0', id, result }); +const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } }); + +/** + * A handler for one parsed JSON-RPC message. Resolves the response, or null + * for a notification (a message with no id), which gets none. + */ +export const createGitServerHandler = ({ + root, git = 'git', baseEnv = process.env, run = runReadonlyGit, +}) => async (message) => { + if (!message || typeof message !== 'object' || Array.isArray(message) || typeof message.method !== 'string') { + return fail(message?.id ?? null, -32600, 'Invalid Request'); + } + const { id, method, params } = message; + if (id === undefined) return null; + if (method === 'initialize') { + const asked = params?.protocolVersion; + return reply(id, { + protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(asked) ? asked : SUPPORTED_PROTOCOL_VERSIONS[0], + capabilities: { tools: {} }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + }); + } + if (method === 'ping') return reply(id, {}); + if (method === 'tools/list') return reply(id, { tools: [GIT_TOOL] }); + if (method === 'tools/call') { + if (params?.name !== TOOL_NAME) return fail(id, -32602, `Unknown tool: ${params?.name}`); + const { subcommand, args = [] } = params.arguments || {}; + const { ok, text } = await run({ + subcommand, args, root, git, baseEnv, + }); + return reply(id, { content: [{ type: 'text', text }], isError: !ok }); + } + return fail(id, -32601, `Method not found: ${method}`); +}; + +/** `--root --git `; the root must be an existing directory. */ +export const parseServerArgs = (argv) => { + const value = (flag) => { + const at = argv.indexOf(flag); + return at === -1 ? undefined : argv[at + 1]; + }; + const root = value('--root'); + if (typeof root !== 'string' || !isAbsolute(root)) throw new Error('--root is required'); + const real = realpathSync(root); + if (!statSync(real).isDirectory()) throw new Error(`--root ${root} is not a directory`); + return { root: real, git: value('--git') || 'git' }; +}; + +export const serve = ({ + root, git, input = process.stdin, output = process.stdout, baseEnv = process.env, +}) => { + const handle = createGitServerHandler({ root, git, baseEnv }); + // A closed pipe on the client side is the client leaving, not an error. + output.on('error', () => {}); + const lines = createInterface({ input, crlfDelay: Infinity }); + lines.on('line', async (line) => { + if (!line.trim()) return; + let message; + try { + message = JSON.parse(line); + } catch { + output.write(`${JSON.stringify(fail(null, -32700, 'Parse error'))}\n`); + return; + } + const response = await handle(message); + if (response) output.write(`${JSON.stringify(response)}\n`); + }); + return lines; +}; + +const runAsMain = () => { + try { + return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +}; + +if (runAsMain()) { + let options; + try { + options = parseServerArgs(process.argv.slice(2)); + } catch (err) { + process.stderr.write(`${SERVER_NAME}: ${err.message}\n`); + process.exit(2); + } + serve(options); +} diff --git a/cli/src/lib/git-readonly.js b/cli/src/lib/git-readonly.js new file mode 100644 index 0000000000..18f2408672 --- /dev/null +++ b/cli/src/lib/git-readonly.js @@ -0,0 +1,214 @@ +/** + * Read-only git for a `workspace`-trust seat (TASK-294). + * + * A workspace seat may look at the repository it was started in (status, log, + * diff, show, blame) and nothing more: no git call it can make may write a + * file, run a program, or read outside its workspace. A Bash permission prefix + * is not a boundary this relies on, so Bash stays denied to workspace seats + * and git never goes through it. Git is one MCP tool (git-readonly-server.js) + * whose argv this module builds and runs with execFile, with no shell in + * between. + * + * What holds the line: + * 1. The subcommand is one of five builtins. A builtin name never resolves to + * an alias, and the caller's arguments go AFTER it, where git's top-level + * options (`-c`, `-C`, `--git-dir`, `--exec-path`, …) are not parsed. + * 2. The arguments are checked against the spellings that write, execute, or + * read a file the caller names (DENIED_LONG_OPTIONS, DENIED_SHORT_OPTIONS), + * including git's accepted abbreviations of them (`--outp=`), and against + * paths that leave the workspace. + * 3. diff, log and show run with `--no-ext-diff --no-textconv`, so no external + * diff or conversion program configured for the repository runs, and the + * spellings that would turn them back on are refused. `core.fsmonitor` is + * forced off for the same reason: `git status` runs it. + * 4. The environment is rebuilt from an allowlist. No inherited GIT_* variable + * survives, system and global config are off, optional locks are off (so + * status never rewrites the index), and GIT_CEILING_DIRECTORIES stops + * repository discovery at the workspace. The only repository git can open + * is one rooted there, so `show HEAD:` cannot reach a file above it. + * 5. Outside this module, the seat's sandbox (Seatbelt or bwrap) mounts the + * workspace read-only. + */ +import { execFile } from 'child_process'; +import { realpathSync } from 'fs'; +import { + dirname, isAbsolute, relative, resolve, +} from 'path'; + +export const READONLY_GIT_SUBCOMMANDS = Object.freeze(['status', 'log', 'diff', 'show', 'blame']); + +// Long options refused in any position, with what each one does. +export const DENIED_LONG_OPTIONS = Object.freeze({ + '--output': 'writes its result to a file', + '--ext-diff': 'runs an external diff program', + '--textconv': 'runs a configured conversion program', + '--no-index': 'compares files outside the repository', + '--contents': 'reads a file the caller names', + '--ignore-revs-file': 'reads a file the caller names', + '--git-dir': 'opens a different repository', + '--work-tree': 'opens a different working tree', + '--exec-path': 'runs git programs from another directory', + '--config-env': 'sets configuration', + '--paginate': 'runs a pager', +}); + +// Real options that are also prefixes of a denied one. Git takes an exact name +// over an abbreviation, so these never mean the denied option. +const EXACT_NAMES_THAT_PREFIX_A_DENIED_ONE = new Set(['--text', '--ignore-rev']); + +// Single-letter options refused, with what each one does. +export const DENIED_SHORT_OPTIONS = Object.freeze({ + c: 'sets configuration (as a top-level option)', + C: 'changes directory (as a top-level option); use --find-copies to detect copies', + O: 'reads a file the caller names', +}); + +// Short options that carry their value in the same argument (`-n5`, `-U3`, +// `-Sneedle`, `-Gregex`, `-L1,5:file`, `-M50%`, `-B`). Git hands the rest of +// the argument to the option as its value, so nothing after the letter can be +// read as another option. +const VALUE_SHORT_OPTIONS = new Set(['n', 'U', 'S', 'G', 'L', 'M', 'B']); +const STATUS_UNTRACKED_MODES = new Set(['-uno', '-unormal', '-uall']); + +// Ahead of the caller's arguments, so a configured program never runs. +const FORCED_OPTIONS = Object.freeze({ + diff: ['--no-ext-diff', '--no-textconv'], + log: ['--no-ext-diff', '--no-textconv'], + show: ['--no-ext-diff', '--no-textconv'], + status: [], + blame: [], +}); +const TOP_LEVEL = Object.freeze(['--no-pager', '-c', 'core.fsmonitor=false']); + +const MAX_ARGS = 64; +const MAX_ARG_LENGTH = 4096; +export const MAX_OUTPUT_CHARS = 50 * 1024; +const MAX_BUFFER_BYTES = 4 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 30 * 1000; + +const refuse = (reason) => ({ ok: false, reason }); + +const realOrResolved = (p) => { + try { + return realpathSync(p); + } catch { + return resolve(p); + } +}; + +const escapesRoot = (root, arg) => { + const rel = relative(root, resolve(root, arg)); + return rel === '..' || rel.startsWith('../') || isAbsolute(rel); +}; + +const checkLongOption = (arg) => { + const name = arg.split('=')[0]; + if (Object.prototype.hasOwnProperty.call(DENIED_LONG_OPTIONS, name)) { + return `${name} is refused: it ${DENIED_LONG_OPTIONS[name]}`; + } + if (name.length >= 3 && !EXACT_NAMES_THAT_PREFIX_A_DENIED_ONE.has(name)) { + const denied = Object.keys(DENIED_LONG_OPTIONS).find((option) => option.startsWith(name)); + if (denied) return `${name} is refused: git reads it as ${denied}, which ${DENIED_LONG_OPTIONS[denied]}`; + } + return null; +}; + +const checkShortOption = (arg, subcommand) => { + if (/^-\d+$/.test(arg)) return null; + const letter = arg[1]; + if (Object.prototype.hasOwnProperty.call(DENIED_SHORT_OPTIONS, letter)) { + return `-${letter} is refused: it ${DENIED_SHORT_OPTIONS[letter]}`; + } + if (arg.length === 2) return null; + if (VALUE_SHORT_OPTIONS.has(letter)) return null; + if (subcommand === 'status' && STATUS_UNTRACKED_MODES.has(arg)) return null; + return `${arg} is refused: pass each short option as its own argument`; +}; + +/** + * The argv for one read-only git call, or the reason it is refused. + * `root` is the workspace; the caller's paths are checked against it. + */ +export const readonlyGitArgv = ({ subcommand, args = [], root }) => { + if (!READONLY_GIT_SUBCOMMANDS.includes(subcommand)) { + return refuse(`git ${subcommand} is refused: only ${READONLY_GIT_SUBCOMMANDS.join(', ')} are available`); + } + if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) { + return refuse('args must be an array of strings'); + } + if (args.length > MAX_ARGS) return refuse(`at most ${MAX_ARGS} arguments`); + if (typeof root !== 'string' || !isAbsolute(root)) return refuse('the workspace is not an absolute path'); + let pathsOnly = false; + for (const arg of args) { + if (arg.length > MAX_ARG_LENGTH || arg.includes('\0')) return refuse('an argument is too long or contains NUL'); + if (pathsOnly || arg === '-' || !arg.startsWith('-')) { + if (escapesRoot(root, arg)) return refuse(`${arg} is refused: it is outside the workspace`); + } else if (arg === '--') { + pathsOnly = true; + } else { + const reason = arg.startsWith('--') ? checkLongOption(arg) : checkShortOption(arg, subcommand); + if (reason) return refuse(reason); + } + } + return { ok: true, argv: [...TOP_LEVEL, subcommand, ...FORCED_OPTIONS[subcommand], ...args] }; +}; + +// What git keeps from the seat's environment. Everything else is dropped, +// every GIT_* variable included. +const KEPT_ENV = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'TZ']); + +export const readonlyGitEnv = ({ root, baseEnv = process.env }) => { + const env = {}; + for (const [key, value] of Object.entries(baseEnv || {})) { + if (KEPT_ENV.has(key) && typeof value === 'string') env[key] = value; + } + return { + ...env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_ATTR_NOSYSTEM: '1', + GIT_OPTIONAL_LOCKS: '0', + GIT_TERMINAL_PROMPT: '0', + GIT_PAGER: 'cat', + PAGER: 'cat', + GIT_CEILING_DIRECTORIES: dirname(realOrResolved(root)), + }; +}; + +const truncate = (text) => (text.length <= MAX_OUTPUT_CHARS + ? text + : `${text.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated at ${MAX_OUTPUT_CHARS} characters; narrow it with -n, a path, or a revision range]`); + +/** + * Run one read-only git call in `root`. Resolves `{ ok, text }`; never rejects. + * `git` is the binary to run (the adapter resolves it outside the sandbox). + */ +export const runReadonlyGit = ({ + subcommand, args, root, git = 'git', baseEnv = process.env, timeoutMs = DEFAULT_TIMEOUT_MS, +}) => new Promise((done) => { + const checked = readonlyGitArgv({ subcommand, args, root }); + if (!checked.ok) { + done({ ok: false, text: checked.reason }); + return; + } + execFile(git, checked.argv, { + cwd: root, + env: readonlyGitEnv({ root, baseEnv }), + timeout: timeoutMs, + maxBuffer: MAX_BUFFER_BYTES, + encoding: 'utf8', + windowsHide: true, + }, (error, stdout, stderr) => { + const out = String(stdout || ''); + if (!error) { + done({ ok: true, text: truncate(out || '(no output)') }); + return; + } + const detail = String(stderr || '').trim() || error.message; + done({ ok: false, text: truncate([out.trim(), detail].filter(Boolean).join('\n')) }); + }); +}); + +export default { + READONLY_GIT_SUBCOMMANDS, DENIED_LONG_OPTIONS, DENIED_SHORT_OPTIONS, readonlyGitArgv, readonlyGitEnv, runReadonlyGit, +}; From 82c04feeed84d082af952175a778d091c649c063 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:54:07 -0700 Subject: [PATCH 03/33] feat(cli): a first-run claude seat in private pods reads the dir it started in (TASK-294) The Connect-page bootstrap now picks `workspace` trust for a claude seat when every pod its token answers in is confirmed private and the start directory is one worth exposing. The record keeps that directory as workspacePath, so the run's cwd becomes the project instead of the scratch folder. Anything less keeps the public default it had before, and says why: - a pod that is publicly readable, or that did not report its visibility (an older server fails closed); - a start directory of /, $HOME, an ancestor of $HOME, or one inside a credential store, with the fix (run it from the project's folder); - any adapter but claude, for which there is nothing to decide yet. hostSandboxRefusal now derives the mode for every host-confined trust, so a workspace seat on a host that cannot confine it (Windows, Claude Code on Linux, a missing Seatbelt) stops at start the way a public seat does, rather than at its first turn. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/attach.test.mjs | 71 +++++++++++++++++++ cli/__tests__/host-sandbox-preflight.test.mjs | 16 +++++ cli/__tests__/workspace-trust.test.mjs | 49 ++++++++++++- cli/src/commands/agent.js | 28 ++++++-- cli/src/lib/sandbox/host-check.js | 4 +- cli/src/lib/workspace-trust.js | 34 ++++++++- 6 files changed, 194 insertions(+), 8 deletions(-) diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs index 7c218fa694..a59b048dd2 100644 --- a/cli/__tests__/attach.test.mjs +++ b/cli/__tests__/attach.test.mjs @@ -838,6 +838,77 @@ describe('bootstrapAgentRecordFromEnv', () => { expect(record.environment.sandbox).toEqual({ trust: 'public' }); }); + // TASK-294: a claude seat whose every pod is confirmed private reads the + // directory it was started from; anything less keeps the public default. + describe('first-run workspace trust', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'commonly-bootstrap-ws-')); + const home = path.join(root, 'home'); + const repo = path.join(home, 'code', 'repo'); + fs.mkdirSync(repo, { recursive: true }); + afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + + const withVisibility = (publicRead) => ({ + ...identityResponse, + installations: identityResponse.installations.map((row) => ({ + ...row, publicRead, communityListed: false, + })), + }); + const run = async ({ response, startDir = repo, registry = makeRegistry() } = {}) => { + const lines = []; + const record = await bootstrapAgentRecordFromEnv({ + name: 'smoke-agent', + env: { COMMONLY_AGENT_TOKEN: 'cm_agent_abc123', COMMONLY_API_URL: 'https://api.example.test' }, + clientFactory: makeFactory(response), + adapterRegistry: registry, + log: (line) => lines.push(line), + startDir, + home, + }); + return { record, lines }; + }; + + test('every pod private and a project start dir: the seat reads that project', async () => { + const { record, lines } = await run({ response: withVisibility(false) }); + expect(record.adapter).toBe('claude'); + expect(record.environment.sandbox).toEqual({ trust: 'workspace' }); + expect(record.workspacePath).toBe(fs.realpathSync(repo)); + expect(record.environment.mcp[0].name).toBe('commonly'); + expect(lines.some((line) => line.includes(fs.realpathSync(repo)))).toBe(true); + }); + + test('one publicly readable pod keeps the public default and says why', async () => { + const response = withVisibility(false); + response.installations[1] = { ...response.installations[1], publicRead: true }; + const { record, lines } = await run({ response }); + expect(record.environment.sandbox).toEqual({ trust: 'public' }); + expect(record.workspacePath).toBeNull(); + expect(lines.some((line) => /scratch folder.*publicly readable/.test(line))).toBe(true); + }); + + test('a server that reports no visibility keeps the public default', async () => { + const { record } = await run({ response: identityResponse }); + expect(record.environment.sandbox).toEqual({ trust: 'public' }); + expect(record.workspacePath).toBeNull(); + }); + + test('started from $HOME keeps the public default and says where to start instead', async () => { + const { record, lines } = await run({ response: withVisibility(false), startDir: home }); + expect(record.environment.sandbox).toEqual({ trust: 'public' }); + expect(record.workspacePath).toBeNull(); + expect(lines.some((line) => /your home directory\. To let it read a project/.test(line))).toBe(true); + }); + + test('a codex seat keeps its baseline and hears nothing about workspaces', async () => { + const { record, lines } = await run({ + response: withVisibility(false), registry: makeRegistry({ claudeFound: false }), + }); + expect(record.adapter).toBe('codex'); + expect(record.workspacePath).toBeNull(); + expect(record.environment.sandbox?.trust).not.toBe('workspace'); + expect(lines.some((line) => /scratch folder|can read/.test(line))).toBe(false); + }); + }); + test('a pi seat bootstrapped from env gets the kernel server but no sandbox block, which pi refuses', async () => { const record = await bootstrapAgentRecordFromEnv({ name: 'smoke-agent', diff --git a/cli/__tests__/host-sandbox-preflight.test.mjs b/cli/__tests__/host-sandbox-preflight.test.mjs index 9d6e4c9e67..ee161ebe1c 100644 --- a/cli/__tests__/host-sandbox-preflight.test.mjs +++ b/cli/__tests__/host-sandbox-preflight.test.mjs @@ -92,6 +92,22 @@ describe('hostSandboxRefusal', () => { .toMatch(/^This seat runs in a bubblewrap sandbox, which is not available here\. bwrap not found on PATH\. Install bubblewrap/); }); + // TASK-294: a workspace seat is confined by the same host mechanism as a + // public one, so a host that cannot confine it stops the run at start + // rather than at the first turn. + test('a workspace trust is checked against the host the same way as a public one', () => { + const workspace = { sandbox: { trust: 'workspace' } }; + const args = { environment: workspace, adapterName: 'claude' }; + expect(hostSandboxRefusal({ ...args, platform: 'win32', detectBwrap: never, detectSeatbelt: never })) + .toMatch(/no sandbox for Windows yet/); + expect(hostSandboxRefusal({ ...args, platform: 'linux', detectBwrap: never, detectSeatbelt: never })) + .toMatch(/cannot run Claude Code yet/); + expect(hostSandboxRefusal({ ...args, platform: 'darwin', detectBwrap: never, detectSeatbelt: available })).toBeNull(); + expect(hostSandboxRefusal({ + ...args, platform: 'darwin', detectBwrap: never, detectSeatbelt: missing('sandbox-exec preflight failed: exit 1'), + })).toMatch(/^This seat runs in a macOS Seatbelt sandbox \(sandbox\.mode=workspace\)/); + }); + test('a legacy internal trust reads as public', () => { expect(hostSandboxRefusal({ environment: { sandbox: { trust: 'internal' } }, adapterName: 'claude', platform: 'win32', diff --git a/cli/__tests__/workspace-trust.test.mjs b/cli/__tests__/workspace-trust.test.mjs index 1a3f4591a8..3cb8205064 100644 --- a/cli/__tests__/workspace-trust.test.mjs +++ b/cli/__tests__/workspace-trust.test.mjs @@ -5,11 +5,14 @@ import { afterAll, describe, expect, test, } from '@jest/globals'; -import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'fs'; +import { + mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, +} from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { allPodsConfirmedPrivate, + firstRunWorkspace, podIsConfirmedPrivate, workspaceStartDirRefusal, } from '../src/lib/workspace-trust.js'; @@ -72,3 +75,47 @@ describe('workspace start directory', () => { .toBe('the start directory is not an absolute path'); }); }); + +describe('first-run workspace', () => { + const root = mkdtempSync(join(tmpdir(), 'commonly-ws-first-run-')); + const home = join(root, 'home'); + const repo = join(home, 'code', 'repo'); + mkdirSync(repo, { recursive: true }); + const priv = { publicRead: false, communityListed: false }; + const pub = { publicRead: true, communityListed: false }; + + afterAll(() => rmSync(root, { recursive: true, force: true })); + + test('a claude seat whose pods are all private reads the project it started in', () => { + expect(firstRunWorkspace({ + adapterName: 'claude', installations: [priv, priv], startDir: repo, home, + })).toEqual({ workspacePath: realpathSync(repo), reason: null }); + }); + + test('one public pod keeps the public default, and moving the start dir would not help', () => { + const result = firstRunWorkspace({ + adapterName: 'claude', installations: [priv, pub], startDir: repo, home, + }); + expect(result.workspacePath).toBeNull(); + expect(result.reason).toMatch(/publicly readable/); + expect(result).not.toHaveProperty('fix'); + }); + + test('a refused start directory keeps the public default and says where to start instead', () => { + expect(firstRunWorkspace({ + adapterName: 'claude', installations: [priv], startDir: home, home, + })).toEqual({ + workspacePath: null, + reason: 'the start directory is your home directory', + fix: expect.stringMatching(/project's folder/), + }); + }); + + test('every other adapter has nothing to decide, since workspace is claude-only', () => { + ['codex', 'opencode', 'qwen', 'pi', 'goose', 'dsh'].forEach((adapterName) => { + expect(firstRunWorkspace({ + adapterName, installations: [priv], startDir: repo, home, + })).toBeNull(); + }); + }); +}); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 87e92ce620..cb423d39e4 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -62,7 +62,7 @@ import { resolveWorkspace, validateEnvironmentSpec, } from '../lib/environment.js'; -import { podIsConfirmedPrivate } from '../lib/workspace-trust.js'; +import { firstRunWorkspace, podIsConfirmedPrivate } from '../lib/workspace-trust.js'; import { ADAPTERS_WITH_DEFAULT_MCP, ADAPTERS_WITH_GRANT_BROKER, @@ -322,6 +322,8 @@ export const bootstrapAgentRecordFromEnv = async ({ adapterRegistry = { getAdapter, listAdapterNames }, adapterOverride = null, log = () => {}, + startDir = process.cwd(), + home, }) => { const runtimeToken = (env.COMMONLY_AGENT_TOKEN || '').trim(); if (!runtimeToken) return null; @@ -400,6 +402,22 @@ export const bootstrapAgentRecordFromEnv = async ({ }); if (bindingRefusal) throw new Error(bindingRefusal); + // The daemon's baseline for a seat nobody wrote a spec for, sandbox + // included: this record is derived from nothing the person declared. + const environment = seatBaseline(buildDefaultEnvironment(adapterName), adapterName, { sandbox: true }); + // A claude seat whose every pod is confirmed private reads the directory + // it was started from (TASK-294); anything else keeps the public default. + const workspace = firstRunWorkspace({ + adapterName, installations: installs, startDir, home, + }); + if (workspace?.workspacePath) { + log(`this agent can read ${workspace.workspacePath} and run read-only git there; ` + + 'it cannot edit files or reach the web.'); + } else if (workspace?.reason) { + log(`this agent can read only its own scratch folder, because ${workspace.reason}.` + + (workspace.fix ? ` ${workspace.fix}` : '')); + } + return { agentName, displayName: identity?.displayName || primary?.displayName || agentName, @@ -408,10 +426,10 @@ export const bootstrapAgentRecordFromEnv = async ({ instanceUrl, runtimeToken, adapter: adapterName, - // The daemon's baseline for a seat nobody wrote a spec for, sandbox - // included: this record is derived from nothing the person declared. - environment: seatBaseline(buildDefaultEnvironment(adapterName), adapterName, { sandbox: true }), - workspacePath: null, + environment: workspace?.workspacePath + ? { ...environment, sandbox: { trust: 'workspace' } } + : environment, + workspacePath: workspace?.workspacePath || null, }; }; diff --git a/cli/src/lib/sandbox/host-check.js b/cli/src/lib/sandbox/host-check.js index 1c7fc2e7ae..bbcfbe39f0 100644 --- a/cli/src/lib/sandbox/host-check.js +++ b/cli/src/lib/sandbox/host-check.js @@ -70,7 +70,9 @@ export const hostSandboxRefusal = ({ if (!Object.prototype.hasOwnProperty.call(ADAPTER_LABELS, adapterName)) return null; const sandbox = normalizeSandboxTrust(environment?.sandbox); if (!sandbox || typeof sandbox !== 'object') return null; - const mode = sandbox.mode ?? (sandbox.trust === 'public' ? resolvePublicSandboxMode(sandbox, platform) : undefined); + // Every host-confined trust (public, and workspace since TASK-294) derives its + // mode here; any other trust gets back only the mode it declared. + const mode = sandbox.mode ?? resolvePublicSandboxMode(sandbox, platform); if (mode !== 'bwrap' && !SEATBELT_MODES.has(mode)) return null; if (platform === 'win32') return windowsSandboxMessage(adapterName); diff --git a/cli/src/lib/workspace-trust.js b/cli/src/lib/workspace-trust.js index abccda4579..5a3cfbd31b 100644 --- a/cli/src/lib/workspace-trust.js +++ b/cli/src/lib/workspace-trust.js @@ -68,4 +68,36 @@ export const workspaceStartDirRefusal = (dir, { home = homedir() } = {}) => { return null; }; -export default { podIsConfirmedPrivate, allPodsConfirmedPrivate, workspaceStartDirRefusal }; +/** + * Where a first-run claude seat may read (TASK-294), from three facts the + * bootstrap already holds: the adapter it detected, the rooms its token + * answers in, and the directory the person ran it from. It returns the start + * directory only when all three allow it, and otherwise the reason the seat + * keeps the `public` default it had before, plus a `fix` when the person can + * change the outcome by starting somewhere else. Any other adapter gets `null`: + * `workspace` is claude-only for now, so there is nothing to decide. + */ +export const firstRunWorkspace = ({ + adapterName, installations, startDir, home, +} = {}) => { + if (adapterName !== 'claude') return null; + if (!allPodsConfirmedPrivate(installations)) { + return { + workspacePath: null, + reason: 'a pod it answers in is publicly readable, or did not report whether it is', + }; + } + const refusal = workspaceStartDirRefusal(startDir, home === undefined ? {} : { home }); + if (refusal) { + return { + workspacePath: null, + reason: refusal, + fix: 'To let it read a project, run this command from that project\'s folder.', + }; + } + return { workspacePath: realOrResolved(startDir), reason: null }; +}; + +export default { + podIsConfirmedPrivate, allPodsConfirmedPrivate, workspaceStartDirRefusal, firstRunWorkspace, +}; From 887704b1128b79ced18b5384bb8689c7936af6a6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:01:28 -0700 Subject: [PATCH 04/33] feat(cli): a workspace seat re-reads its pods before every turn (TASK-294) A pod can turn public after attach, and the attach-time check reads the pods once. So a workspace-trust seat now re-reads them from /api/agents/runtime/installations before each turn. A turn that cannot confirm every pod private, a failed read included, runs as a public seat in the scratch folder: the confinement it would have had without the workspace. The log names the reason once per change of state. Moving the cwd moves claude's session store, so a resume across the change fails "no conversation" and claude.js starts the turn fresh. Any trust other than workspace spawns as before, with no extra read. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/workspace-trust.test.mjs | 41 ++++++ .../workspace-turn-run-loop.test.mjs | 128 ++++++++++++++++++ cli/src/commands/agent.js | 43 +++++- cli/src/lib/workspace-trust.js | 35 ++++- 4 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 cli/__tests__/workspace-turn-run-loop.test.mjs diff --git a/cli/__tests__/workspace-trust.test.mjs b/cli/__tests__/workspace-trust.test.mjs index 3cb8205064..2429f95004 100644 --- a/cli/__tests__/workspace-trust.test.mjs +++ b/cli/__tests__/workspace-trust.test.mjs @@ -15,6 +15,7 @@ import { firstRunWorkspace, podIsConfirmedPrivate, workspaceStartDirRefusal, + workspaceTurnPlacement, } from '../src/lib/workspace-trust.js'; describe('pod visibility', () => { @@ -119,3 +120,43 @@ describe('first-run workspace', () => { }); }); }); + +describe('workspace turn placement', () => { + const priv = { publicRead: false, communityListed: false }; + const pub = { publicRead: false, communityListed: true }; + const workspace = { model: 'm', sandbox: { trust: 'workspace' } }; + const place = (overrides) => workspaceTurnPlacement({ + environment: workspace, workspacePath: '/w/repo', scratchDir: '/tmp/scratch', ...overrides, + }); + + test('a turn whose pods are all private reads the workspace', () => { + expect(place({ installations: [priv, priv] })).toEqual({ + environment: workspace, cwd: '/w/repo', reason: null, + }); + }); + + test('one listed pod runs the turn as a public seat in the scratch folder', () => { + expect(place({ installations: [priv, pub] })).toEqual({ + environment: { model: 'm', sandbox: { trust: 'public' } }, + cwd: '/tmp/scratch', + reason: 'a pod it answers in is publicly readable, or did not report whether it is', + }); + }); + + test('a failed read and an empty list both fail closed', () => { + expect(place({ installations: null })).toMatchObject({ + cwd: '/tmp/scratch', reason: 'its pods could not be read', + }); + expect(place({ installations: [] }).environment.sandbox.trust).toBe('public'); + }); + + test('any other trust runs where and as it started, whatever its pods say', () => { + const publicSeat = { sandbox: { trust: 'public' } }; + expect(place({ environment: publicSeat, installations: [pub] })).toEqual({ + environment: publicSeat, cwd: '/w/repo', reason: null, + }); + expect(place({ environment: null, workspacePath: null, installations: null })).toEqual({ + environment: null, cwd: '/tmp/scratch', reason: null, + }); + }); +}); diff --git a/cli/__tests__/workspace-turn-run-loop.test.mjs b/cli/__tests__/workspace-turn-run-loop.test.mjs new file mode 100644 index 0000000000..3d8e94f1bb --- /dev/null +++ b/cli/__tests__/workspace-turn-run-loop.test.mjs @@ -0,0 +1,128 @@ +import { jest } from '@jest/globals'; +import os from 'os'; +import path from 'path'; +import fs from 'fs'; + +// TASK-294: a workspace seat re-reads its pods before every turn, and a turn +// it cannot confirm private runs as a public seat in the scratch folder. +const sessionsTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-workspace-turn-test-')); +const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-workspace-turn-repo-')); + +await jest.unstable_mockModule('os', () => { + const actual = os; + return { + ...actual, + default: { ...actual, homedir: () => sessionsTmpDir }, + homedir: () => sessionsTmpDir, + }; +}); + +await jest.unstable_mockModule('../src/lib/api.js', () => ({ + createClient: jest.fn(), + login: jest.fn(), +})); + +const { createClient } = await import('../src/lib/api.js'); +const { performRun } = await import('../src/commands/agent.js'); +const stubAdapter = (await import('../src/lib/adapters/stub.js')).default; + +const scratchDir = path.join(os.tmpdir(), 'commonly-agents', 'workspace-stub'); +const privatePod = { podId: 'pod-ws', publicRead: false, communityListed: false }; +const noopTimeout = () => 0; +const drain = async () => { + for (let i = 0; i < 24; i += 1) await new Promise((resolve) => setImmediate(resolve)); +}; + +const runOneTurn = async ({ environment, installations }) => { + const get = jest.fn(async (route) => { + if (route === '/api/agents/runtime/events') { + return { + events: [{ + _id: 'ws-event-1', type: 'chat.mention', podId: 'pod-ws', agentName: 'workspace-stub', + payload: { content: 'read the repo' }, + }], + }; + } + if (route === '/api/agents/runtime/installations') { + if (installations instanceof Error) throw installations; + return { installations }; + } + if (route === '/api/agents/runtime/memory') return { sections: {} }; + if (route.endsWith('/messages')) return { messages: [] }; + if (route.endsWith('/context')) return { focus: { podId: 'pod-ws', revision: 0, focus: null } }; + return {}; + }); + createClient.mockReturnValue({ get, post: jest.fn().mockResolvedValue({}) }); + const spawn = jest.fn().mockResolvedValue({ text: 'done' }); + const logs = []; + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', + token: 'cm_agent_workspace', + adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + agentName: 'workspace-stub', + environment, + workspacePath: workspaceDir, + log: (line) => logs.push(line), + setTimeoutImpl: noopTimeout, + }); + await drain(); + stop(); + expect(spawn).toHaveBeenCalledTimes(1); + return { ctx: spawn.mock.calls[0][1], get, logs }; +}; + +describe('performRun workspace turn placement', () => { + beforeEach(() => { + jest.clearAllMocks(); + fs.rmSync(path.join(sessionsTmpDir, '.commonly'), { recursive: true, force: true }); + }); + + test('a workspace seat whose pods are all private reads its workspace', async () => { + const { ctx, get, logs } = await runOneTurn({ + environment: { sandbox: { trust: 'workspace' } }, + installations: [privatePod], + }); + expect(ctx.cwd).toBe(workspaceDir); + expect(ctx.environment.sandbox.trust).toBe('workspace'); + expect(get).toHaveBeenCalledWith('/api/agents/runtime/installations', {}, { + signal: expect.any(AbortSignal), + }); + expect(logs.some((line) => line.includes('public seat'))).toBe(false); + }); + + test('a pod that turned public runs the turn as a public seat in the scratch folder', async () => { + const { ctx, logs } = await runOneTurn({ + environment: { sandbox: { trust: 'workspace' } }, + installations: [privatePod, { podId: 'pod-open', publicRead: true, communityListed: false }], + }); + expect(ctx.cwd).toBe(scratchDir); + expect(fs.existsSync(scratchDir)).toBe(true); + expect(ctx.environment.sandbox.trust).toBe('public'); + expect(logs).toContain( + `this turn runs as a public seat in ${scratchDir}, because a pod it answers in is ` + + 'publicly readable, or did not report whether it is', + ); + }); + + test('a failed read of its pods degrades the turn the same way', async () => { + const { ctx, logs } = await runOneTurn({ + environment: { sandbox: { trust: 'workspace' } }, + installations: Object.assign(new Error('boom'), { status: 503 }), + }); + expect(ctx.cwd).toBe(scratchDir); + expect(ctx.environment.sandbox.trust).toBe('public'); + expect(logs).toContain( + `this turn runs as a public seat in ${scratchDir}, because its pods could not be read`, + ); + }); + + test('a public seat spawns as declared and does not re-read its pods', async () => { + const { ctx, get } = await runOneTurn({ + environment: { sandbox: { trust: 'public' } }, + installations: [privatePod], + }); + expect(ctx.cwd).toBe(workspaceDir); + expect(ctx.environment.sandbox.trust).toBe('public'); + expect(get.mock.calls.some(([route]) => route === '/api/agents/runtime/installations')).toBe(false); + }); +}); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index cb423d39e4..9858645130 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -62,7 +62,7 @@ import { resolveWorkspace, validateEnvironmentSpec, } from '../lib/environment.js'; -import { firstRunWorkspace, podIsConfirmedPrivate } from '../lib/workspace-trust.js'; +import { firstRunWorkspace, podIsConfirmedPrivate, workspaceTurnPlacement } from '../lib/workspace-trust.js'; import { ADAPTERS_WITH_DEFAULT_MCP, ADAPTERS_WITH_GRANT_BROKER, @@ -1490,8 +1490,40 @@ export const performRun = ({ // as binary-not-found — so we ensure it up front to avoid the confusing // diagnostic. ADR-008: when an environment spec was attached, the resolved // workspace path replaces the /tmp default. - const agentCwd = workspacePath || join(tmpdir(), 'commonly-agents', agentName); + const scratchCwd = join(tmpdir(), 'commonly-agents', agentName); + const agentCwd = workspacePath || scratchCwd; if (!existsSync(agentCwd)) mkdirSync(agentCwd, { recursive: true }); + // TASK-294: a pod can turn public after attach, so a workspace seat re-reads + // its pods before every turn, and a turn it cannot confirm (a failed read + // included) runs as a public seat in the scratch folder. Moving the cwd + // moves claude's session store, so that turn's resume fails "no + // conversation" and claude.js starts it fresh. + let lastPlacementReason = null; + const placeTurn = async () => { + if (seatEnvironment?.sandbox?.trust !== 'workspace') { + return { environment: seatEnvironment, cwd: agentCwd }; + } + let installations = null; + try { + const identity = await client.get('/api/agents/runtime/installations', {}, { + signal: AbortSignal.timeout(5000), + }); + installations = Array.isArray(identity?.installations) ? identity.installations : []; + } catch { + installations = null; + } + const placement = workspaceTurnPlacement({ + environment: seatEnvironment, workspacePath: agentCwd, installations, scratchDir: scratchCwd, + }); + if (placement.reason !== lastPlacementReason) { + log(placement.reason + ? `this turn runs as a public seat in ${scratchCwd}, because ${placement.reason}` + : `every pod is confirmed private again; turns read ${agentCwd}`); + lastPlacementReason = placement.reason; + } + if (!existsSync(placement.cwd)) mkdirSync(placement.cwd, { recursive: true }); + return placement; + }; const batchLimit = Math.min(50, Math.max(1, Number(inboxBatchLimit) || 10)); const processEvent = async (event) => { @@ -1765,6 +1797,7 @@ export const performRun = ({ // ADR-005 §Memory bridge: read long_term before spawn, inject via ctx, // and (if the adapter returns a summary) patch-sync back after. const memoryLongTerm = await readLongTerm(client, { onError }); + const placement = await placeTurn(); // Sharpen TASK-129: focus is a turn-start read, not an enqueue-time // snapshot. Read through the authorized runtime context route immediately @@ -1908,7 +1941,7 @@ export const performRun = ({ // only for tests and has no commit-making child process. if (adapter?.name !== 'stub') { commitAttribution = prepareCommitAttribution({ - cwd: agentCwd, + cwd: placement.cwd, env: process.env, agentName, displayName, @@ -1938,11 +1971,11 @@ export const performRun = ({ providerKey, timeoutMs: effectiveTurnTimeoutMs, sessionId, - cwd: agentCwd, + cwd: placement.cwd, env: commitAttribution?.env || process.env, commitAttributionEnv: commitAttribution?.sandboxEnv || null, memoryLongTerm, - environment: seatEnvironment, + environment: placement.environment, onSkillsLinked: ({ mounted, conflicted }) => { skillMount = { mounted: [...new Set([...serverSkills.mounted, ...mounted.map(reportedSkillName)])], diff --git a/cli/src/lib/workspace-trust.js b/cli/src/lib/workspace-trust.js index 5a3cfbd31b..300fd18566 100644 --- a/cli/src/lib/workspace-trust.js +++ b/cli/src/lib/workspace-trust.js @@ -68,6 +68,8 @@ export const workspaceStartDirRefusal = (dir, { home = homedir() } = {}) => { return null; }; +const PUBLIC_POD_REASON = 'a pod it answers in is publicly readable, or did not report whether it is'; + /** * Where a first-run claude seat may read (TASK-294), from three facts the * bootstrap already holds: the adapter it detected, the rooms its token @@ -82,10 +84,7 @@ export const firstRunWorkspace = ({ } = {}) => { if (adapterName !== 'claude') return null; if (!allPodsConfirmedPrivate(installations)) { - return { - workspacePath: null, - reason: 'a pod it answers in is publicly readable, or did not report whether it is', - }; + return { workspacePath: null, reason: PUBLIC_POD_REASON }; } const refusal = workspaceStartDirRefusal(startDir, home === undefined ? {} : { home }); if (refusal) { @@ -98,6 +97,32 @@ export const firstRunWorkspace = ({ return { workspacePath: realOrResolved(startDir), reason: null }; }; +/** + * Where one turn of a seat runs, and under which trust. A pod can turn public + * after attach, so a `workspace` seat's rooms are re-read before every turn + * (`installations` is that read, or `null` when it failed). A turn that cannot + * confirm them all private runs as a `public` seat in its scratch folder: the + * confinement it would have had without the workspace. Any other trust runs + * where and as it was started. + */ +export const workspaceTurnPlacement = ({ + environment, workspacePath, installations, scratchDir, +} = {}) => { + const cwd = workspacePath || scratchDir; + if (environment?.sandbox?.trust !== 'workspace' || allPodsConfirmedPrivate(installations)) { + return { environment, cwd, reason: null }; + } + return { + environment: { ...environment, sandbox: { ...environment.sandbox, trust: 'public' } }, + cwd: scratchDir, + reason: installations === null ? 'its pods could not be read' : PUBLIC_POD_REASON, + }; +}; + export default { - podIsConfirmedPrivate, allPodsConfirmedPrivate, workspaceStartDirRefusal, firstRunWorkspace, + podIsConfirmedPrivate, + allPodsConfirmedPrivate, + workspaceStartDirRefusal, + firstRunWorkspace, + workspaceTurnPlacement, }; From a6ba3b8a29ed6c49f87b154f5967a359d0775894 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:03:12 -0700 Subject: [PATCH 05/33] fix(cli): bind a workspace seat's session to where its turn runs (TASK-294) The previous commit relied on claude failing to resume a session from another folder, so that a turn degraded to public would start fresh. That is claude's behaviour, not ours, and if it ever finds the session anyway, a public turn resumes a conversation that read the workspace. A workspace seat's session is now bound to the trust and folder of the turn, so the run loop itself starts a degraded turn fresh, and the same holds on the way back. Other seats keep the binding they had. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- .../workspace-turn-run-loop.test.mjs | 22 ++++++++++++++++--- cli/src/commands/agent.js | 20 +++++++++-------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/cli/__tests__/workspace-turn-run-loop.test.mjs b/cli/__tests__/workspace-turn-run-loop.test.mjs index 3d8e94f1bb..b538dac64c 100644 --- a/cli/__tests__/workspace-turn-run-loop.test.mjs +++ b/cli/__tests__/workspace-turn-run-loop.test.mjs @@ -33,12 +33,15 @@ const drain = async () => { for (let i = 0; i < 24; i += 1) await new Promise((resolve) => setImmediate(resolve)); }; -const runOneTurn = async ({ environment, installations }) => { +let eventCount = 0; +const runOneTurn = async ({ environment, installations, newSessionId }) => { + eventCount += 1; + const eventId = `ws-event-${eventCount}`; const get = jest.fn(async (route) => { if (route === '/api/agents/runtime/events') { return { events: [{ - _id: 'ws-event-1', type: 'chat.mention', podId: 'pod-ws', agentName: 'workspace-stub', + _id: eventId, type: 'chat.mention', podId: 'pod-ws', agentName: 'workspace-stub', payload: { content: 'read the repo' }, }], }; @@ -53,7 +56,7 @@ const runOneTurn = async ({ environment, installations }) => { return {}; }); createClient.mockReturnValue({ get, post: jest.fn().mockResolvedValue({}) }); - const spawn = jest.fn().mockResolvedValue({ text: 'done' }); + const spawn = jest.fn().mockResolvedValue({ text: 'done', newSessionId }); const logs = []; const { stop } = performRun({ instanceUrl: 'http://localhost:5000', @@ -125,4 +128,17 @@ describe('performRun workspace turn placement', () => { expect(ctx.environment.sandbox.trust).toBe('public'); expect(get.mock.calls.some(([route]) => route === '/api/agents/runtime/installations')).toBe(false); }); + + test('a public turn never resumes a conversation that read the workspace', async () => { + const environment = { sandbox: { trust: 'workspace' } }; + await runOneTurn({ environment, installations: [privatePod], newSessionId: 'sess-ws' }); + const resumed = await runOneTurn({ environment, installations: [privatePod] }); + expect(resumed.ctx.sessionId).toBe('sess-ws'); + + const degraded = await runOneTurn({ + environment, + installations: [{ podId: 'pod-ws', publicRead: true, communityListed: true }], + }); + expect(degraded.ctx.sessionId).toBeFalsy(); + }); }); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 9858645130..5e79f34443 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -1495,13 +1495,14 @@ export const performRun = ({ if (!existsSync(agentCwd)) mkdirSync(agentCwd, { recursive: true }); // TASK-294: a pod can turn public after attach, so a workspace seat re-reads // its pods before every turn, and a turn it cannot confirm (a failed read - // included) runs as a public seat in the scratch folder. Moving the cwd - // moves claude's session store, so that turn's resume fails "no - // conversation" and claude.js starts it fresh. + // included) runs as a public seat in the scratch folder. The session is + // bound to where and as what the turn runs, so a public turn never resumes + // a conversation that read the workspace, whatever claude's resume would + // find from another folder. let lastPlacementReason = null; const placeTurn = async () => { if (seatEnvironment?.sandbox?.trust !== 'workspace') { - return { environment: seatEnvironment, cwd: agentCwd }; + return { environment: seatEnvironment, cwd: agentCwd, binding: sessionBinding }; } let installations = null; try { @@ -1522,7 +1523,8 @@ export const performRun = ({ lastPlacementReason = placement.reason; } if (!existsSync(placement.cwd)) mkdirSync(placement.cwd, { recursive: true }); - return placement; + const where = `${placement.environment.sandbox.trust}:${placement.cwd}`; + return { ...placement, binding: sessionBinding ? `${sessionBinding}:${where}` : where }; }; const batchLimit = Math.min(50, Math.max(1, Number(inboxBatchLimit) || 10)); @@ -1774,11 +1776,12 @@ export const performRun = ({ const runTurn = async ({ event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger, }) => { + const placement = await placeTurn(); // TASK-440: a cold, large session starts fresh at wake instead of being // resumed. The restart note leads the prompt; memory still reaches the // fresh session through the adapter's preamble. - const sessionEntry = getSessionEntry(agentName, eventPodId, { binding: sessionBinding }); - let sessionId = getSession(agentName, eventPodId, { binding: sessionBinding }); + const sessionEntry = getSessionEntry(agentName, eventPodId, { binding: placement.binding }); + let sessionId = getSession(agentName, eventPodId, { binding: placement.binding }); let recycleBriefing = null; if (sessionId && shouldStartFreshSession({ lastTurn: sessionEntry?.lastTurn, @@ -1797,7 +1800,6 @@ export const performRun = ({ // ADR-005 §Memory bridge: read long_term before spawn, inject via ctx, // and (if the adapter returns a summary) patch-sync back after. const memoryLongTerm = await readLongTerm(client, { onError }); - const placement = await placeTurn(); // Sharpen TASK-129: focus is a turn-start read, not an enqueue-time // snapshot. Read through the authorized runtime context route immediately @@ -2019,7 +2021,7 @@ export const performRun = ({ // wake can decide whether to recycle. Adapters that report nothing keep // resuming. setSession(agentName, eventPodId, result.newSessionId, { - binding: sessionBinding, + binding: placement.binding, contextTokens: result.contextTokens ?? null, }); } From 8cf20fb1eb5f64954932a16ec0cc3164e85e2279 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:08:37 -0700 Subject: [PATCH 06/33] feat(cli): the claude adapter confines a workspace seat (TASK-294) Until now every confinement branch in claude.js keyed on trust 'public', so a 'workspace' record spawned bare. A workspace seat now gets the public contract: Seatbelt, the isolated Claude state, the scrubbed env, the public tool policy and skills as prompt text. Its project is held read-only by the jail. On top of the public denies, a workspace turn cannot read any .env variant, and cannot install itself into a pod partway through a turn, since the run loop confirmed its pods private before the spawn. mountSkills writes from the host process, outside any jail, so a workspace seat's skills are copied to the run loop's scratch folder, never into the project. Without one they are skipped. Under bwrap the adapter refuses: bwrap points the child's HOME at its folder, so transcripts would land in the project. The host check already refuses claude on Linux. Read-only git (the workspace_git MCP server) is wired in a later commit. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- .../adapters.claude.environment.test.mjs | 128 ++++++++++++++++++ .../workspace-turn-run-loop.test.mjs | 2 + cli/src/commands/agent.js | 1 + cli/src/lib/adapters/claude.js | 39 ++++-- 4 files changed, 160 insertions(+), 10 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 4a54e56fed..22325a9589 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1196,3 +1196,131 @@ describe('claude adapter — a public seat gets its declared skills as prompt te expect(appended(calls[1].args)).toContain('## Skill: house-rules'); }); }); + +// TASK-294: a workspace seat is confined as a public one is, with the person's +// project held read-only, and nothing written into that project by the host. +describe('claude adapter — a workspace seat', () => { + let cwd; + let skillSrc; + let publicState; + let scratch; + const originalPlatform = process.platform; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-workspace-')); + publicState = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-public-state-')); + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-workspace-scratch-')); + skillSrc = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-skill-src-')), 'house-rules'); + fs.mkdirSync(skillSrc); + fs.writeFileSync( + path.join(skillSrc, 'SKILL.md'), + '---\nname: house-rules\ndescription: How to behave in a pod.\n---\n\n# Being a member\n\nOrient before you post.\n', + 'utf8', + ); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + spawnSync.mockImplementation((cmd) => ( + cmd === 'which' ? { status: 0, stdout: '/usr/bin/true\n' } : { status: 0, stdout: '' } + )); + }); + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + spawnSync.mockReset(); + for (const dir of [cwd, publicState, scratch, path.dirname(skillSrc)]) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + const workspaceSpawn = (impl, sandbox = { mode: 'workspace', trust: 'workspace' }) => claude.spawn('hi', { + agentName: 'workspace-test', + sessionId: null, + cwd, + skillMountDir: scratch, + env: { PATH: process.env.PATH, COMMONLY_HOST_SECRET: 'must-not-inherit' }, + environment: { model: 'test-model', + sandbox, + skills: { claude: [skillSrc] }, + mcp: [{ + name: 'commonly', + transport: 'stdio', + command: [process.execPath, path.join(scratch, 'server.mjs')], + }], + }, + _publicClaudeState: publicState, + _spawnImpl: impl, + }); + + test('runs in Seatbelt with its project read-only and the public tool policy', async () => { + const { impl, calls } = makeSpawnImpl(); + await workspaceSpawn(impl); + + expect(calls).toHaveLength(1); + const { cmd, args, opts } = calls[0]; + expect(cmd).toBe('/usr/bin/sandbox-exec'); + const realCwd = fs.realpathSync(cwd); + expect(args[1]).toContain(`(allow file-read* file-test-existence (subpath "${realCwd}"))`); + expect(args[1]).not.toContain(`(allow file-read* file-test-existence file-write* (subpath "${realCwd}"))`); + expect(args).toContain('Read(./**)'); + expect(args).toContain('mcp__commonly__*'); + const denied = args.slice(args.indexOf('--disallowedTools') + 1); + expect(denied).toEqual(expect.arrayContaining([ + 'Bash', 'Write', 'Edit', 'WebFetch', 'WebSearch', 'Task', + 'Read(./.env)', 'Read(./.env.*)', 'mcp__commonly__commonly_self_install_into_pod', + ])); + expect(opts.env.HOME).toBe(publicState); + expect(opts.env.COMMONLY_HOST_SECRET).toBeUndefined(); + }); + + test('a public seat keeps the denies it had, without the workspace ones', async () => { + const { impl, calls } = makeSpawnImpl(); + await workspaceSpawn(impl, { mode: 'workspace', trust: 'public' }); + + const { args } = calls[0]; + expect(args).not.toContain('Read(./.env.*)'); + expect(args).not.toContain('mcp__commonly__commonly_self_install_into_pod'); + expect(args[1]).toContain(`(allow file-read* file-test-existence file-write* (subpath "${fs.realpathSync(cwd)}"))`); + }); + + test('its skills are copied to the scratch folder, never into the project, and arrive as prompt text', async () => { + const { impl, calls } = makeSpawnImpl(); + await workspaceSpawn(impl); + + expect(fs.existsSync(path.join(cwd, '.claude'))).toBe(false); + expect(fs.existsSync(path.join(scratch, '.claude', 'skills', 'house-rules', 'SKILL.md'))).toBe(true); + const { args } = calls[0]; + const text = args[args.indexOf('--append-system-prompt') + 1]; + expect(text).toContain('## Skill: house-rules'); + }); + + test('without a scratch folder its skills are skipped rather than written into the project', async () => { + const { impl, calls } = makeSpawnImpl(); + await claude.spawn('hi', { + agentName: 'workspace-test', + sessionId: null, + cwd, + env: { PATH: process.env.PATH }, + environment: { model: 'test-model', + sandbox: { mode: 'workspace', trust: 'workspace' }, + skills: { claude: [skillSrc] }, + }, + _publicClaudeState: publicState, + _spawnImpl: impl, + }); + + expect(calls).toHaveLength(1); + expect(fs.existsSync(path.join(cwd, '.claude'))).toBe(false); + }); + + test('under bwrap it refuses to spawn', async () => { + const { impl, calls } = makeSpawnImpl(); + await expect(workspaceSpawn(impl, { mode: 'bwrap', trust: 'workspace' })) + .rejects.toThrow(/workspace Claude agents run on macOS only for now/); + expect(calls).toHaveLength(0); + }); + + test('with an unenforced mode it refuses rather than falls through to a bare spawn', async () => { + const { impl, calls } = makeSpawnImpl(); + await expect(workspaceSpawn(impl, { mode: 'none', trust: 'workspace' })) + .rejects.toThrow(/workspace Claude agents require an enforced sandbox mode/); + expect(calls).toHaveLength(0); + }); +}); diff --git a/cli/__tests__/workspace-turn-run-loop.test.mjs b/cli/__tests__/workspace-turn-run-loop.test.mjs index b538dac64c..cf1620b2ab 100644 --- a/cli/__tests__/workspace-turn-run-loop.test.mjs +++ b/cli/__tests__/workspace-turn-run-loop.test.mjs @@ -87,6 +87,8 @@ describe('performRun workspace turn placement', () => { }); expect(ctx.cwd).toBe(workspaceDir); expect(ctx.environment.sandbox.trust).toBe('workspace'); + // Skills are copied here, not into the project (the claude adapter). + expect(ctx.skillMountDir).toBe(scratchDir); expect(get).toHaveBeenCalledWith('/api/agents/runtime/installations', {}, { signal: expect.any(AbortSignal), }); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 5e79f34443..d3b4595846 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -1974,6 +1974,7 @@ export const performRun = ({ timeoutMs: effectiveTurnTimeoutMs, sessionId, cwd: placement.cwd, + skillMountDir: scratchCwd, env: commitAttribution?.env || process.env, commitAttributionEnv: commitAttribution?.sandboxEnv || null, memoryLongTerm, diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index ff96cc1dfa..c4e872c2b8 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -135,6 +135,9 @@ const PUBLIC_DENIED_TOOLS = [ 'NotebookEdit', 'Task', ]; +// TASK-294: a workspace seat is confined as a public one is, except that its +// folder is the person's project, which the jail holds read-only. +const isConfinedTrust = (trust) => trust === 'public' || trust === 'workspace'; const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/; const statOrNull = async (path) => { @@ -248,7 +251,7 @@ const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, const absoluteToolDeny = (path) => `Read(/${path}/**)`; -const buildPublicClaudePolicyArgs = (mcpToolPatterns) => { +const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust } = {}) => { const home = homedir(); const deniedReads = [ absoluteToolDeny(join(home, '.commonly')), @@ -262,6 +265,12 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => { 'Read(./.codex/**)', 'Read(./.env)', ]; + // A workspace seat reads a real project, so every .env variant stays out of + // reach, and it cannot install itself into a room partway through a turn: + // the per-turn check confirmed its pods private before the spawn. + const workspaceDenies = trust === 'workspace' + ? ['Read(./.env.*)', ...mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, 'commonly_self_install_into_pod'))] + : []; return [ // HOME points at an isolated, sanitized state directory. Ignore settings // from user/project/local sources and disable slash-command extensions; @@ -272,7 +281,7 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => { '--no-chrome', '--permission-mode', 'dontAsk', '--allowedTools', 'Read(./**)', ...mcpToolPatterns, - '--disallowedTools', ...PUBLIC_DENIED_TOOLS, ...deniedReads, + '--disallowedTools', ...PUBLIC_DENIED_TOOLS, ...deniedReads, ...workspaceDenies, ]; }; @@ -636,7 +645,7 @@ const prepareArgv = async (innerArgv, ctx) => { const sandboxTrust = env.sandbox?.trust; const sandboxMode = resolvePublicSandboxMode(env.sandbox); - const publicNativeSandbox = sandboxTrust === 'public' + const publicNativeSandbox = isConfinedTrust(sandboxTrust) && PUBLIC_SANDBOX_MODES.has(sandboxMode); // A public trust is ONE contract on both platforms: the jail bounds the // filesystem and the policy bounds the tools. The bwrap branch below used to @@ -665,9 +674,15 @@ const prepareArgv = async (innerArgv, ctx) => { // non-string) used to fall through to the bare `claude` spawn below — an // unconfined seat with a public record, which is how a record can claim // confinement it never applies (Vera 69548). - if (sandboxTrust === 'public' && !publicNativeSandbox && sandboxMode !== 'bwrap') { + // bwrap points the child's HOME at its folder (childHome below), so a + // workspace seat's transcripts would be written into the person's project. + if (sandboxTrust === 'workspace' && sandboxMode === 'bwrap') { + throw new Error('workspace Claude agents run on macOS only for now: under bwrap ' + + 'their transcripts would be written into the project folder'); + } + if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') { throw new Error( - 'public Claude agents require an enforced sandbox mode ' + `${sandboxTrust} Claude agents require an enforced sandbox mode ` + `(workspace or read-only on macOS, bwrap elsewhere), got ${JSON.stringify(sandboxMode)}`, ); } @@ -683,7 +698,7 @@ const prepareArgv = async (innerArgv, ctx) => { } innerArgv = [ ...innerArgv, - ...buildPublicClaudePolicyArgs(allowedPatterns), + ...buildPublicClaudePolicyArgs(allowedPatterns, { trust: sandboxTrust }), ...(ctx.publicSkillArgs || []), ]; const claudeBin = resolveClaudePath(claudeEnv); @@ -695,7 +710,7 @@ const prepareArgv = async (innerArgv, ctx) => { .filter((command) => typeof command === 'string' && isAbsolute(command)); const wrapped = wrapArgvWithSeatbelt([claudeBin, ...innerArgv], { workspacePath: ctx.cwd, - workspaceAccess: sandboxMode === 'read-only' ? 'read' : 'write', + workspaceAccess: sandboxTrust === 'workspace' || sandboxMode === 'read-only' ? 'read' : 'write', executablePath: claudeBin, statePath: ctx.publicClaudeState.statePath, mcpConfigDir: ctx.mcpConfigDir, @@ -812,7 +827,7 @@ export default { // the only two paths that build a real prompt. buildPrompt handles // undefined and '' as absence itself; it does not need a guard, it needs // the value. - const publicTrust = ctx.environment?.sandbox?.trust === 'public'; + const publicTrust = isConfinedTrust(ctx.environment?.sandbox?.trust); const workCues = publicTrust ? [] : WORK_CUES; const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: !isResume, @@ -842,8 +857,12 @@ export default { let mountedSkills = []; let skillMount = { mounted: [], conflicted: [] }; - if (ctx.environment && ctx.cwd) { - const skills = await mountSkills(ctx.environment, ctx.cwd); + // A workspace seat's folder is the person's project, and mountSkills writes + // from this host process, outside any jail. Its skills are copied to the + // run loop's scratch folder instead and reach it as prompt text. + const skillDir = ctx.environment?.sandbox?.trust === 'workspace' ? ctx.skillMountDir : ctx.cwd; + if (ctx.environment && skillDir) { + const skills = await mountSkills(ctx.environment, skillDir); mountedSkills = skills.mounted; skillMount = skills; if (skills.conflicted.length > 0) { From 5e8589bbfdcac33d62bc169d6d9867143519f31e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:16:50 -0700 Subject: [PATCH 07/33] feat(cli): a workspace seat reads its repo's history through a read-only git tool (TASK-294) A workspace seat now gets the workspace_git MCP server from slice 2 at spawn: node git-readonly-server.js --root --git . Its tools are allowed as mcp__workspace_git__*, like any declared server. The server runs inside the seat's Seatbelt jail, so the jail admits three more things, all read-only: - the git binary. It is resolved on the host as the `git` in `git --exec-path`. On macOS, /usr/bin/git is an xcrun shim, and inside the jail it fails with "No developer tools were found". The binary behind it (Homebrew's or the Command Line Tools') runs log, diff and status there with only itself admitted. Both were measured. - cli/src/lib, the server's code. Without it, node cannot load the script in the jail. - cli/package.json, which gives node the module type instead of leaving it to syntax detection. Measured under the exact profile the adapter now emits, the server answers tools/call for git log and git status. A write into the project is refused, while the control write into the seat's state dir succeeds. Public seats get no git tool. A machine without git runs the workspace seat without one and says so. Tests: 3, which catch each of six mutations (git admission, server declaration, trust check, link instead of the real binary, lib dir, and package.json). Full CLI suite: 1,696 passed, 18 skipped. Lint is clean. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- .../adapters.claude.environment.test.mjs | 96 +++++++++++++++++++ cli/src/lib/adapters/claude.js | 64 ++++++++++++- 2 files changed, 155 insertions(+), 5 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 22325a9589..61e656bb16 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1324,3 +1324,99 @@ describe('claude adapter — a workspace seat', () => { expect(calls).toHaveLength(0); }); }); + +describe('claude adapter — a workspace seat\'s git tool', () => { + let cwd; + let publicState; + let fakeGitDirs; + let realGit; + let gitExecDir; + const originalPlatform = process.platform; + const serverPath = path.resolve('src/lib/git-readonly-server.js'); + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-git-workspace-')); + publicState = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-git-state-')); + // `git --exec-path` names a dir whose `git` links to the binary git runs, + // which is what the jail must admit — never the link, never a shim. + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-git-bin-')); + gitExecDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-git-exec-')); + fakeGitDirs = [binDir, gitExecDir]; + const binary = path.join(binDir, 'git'); + fs.writeFileSync(binary, '#!/bin/sh\n', { mode: 0o755 }); + fs.symlinkSync(binary, path.join(gitExecDir, 'git')); + realGit = fs.realpathSync(binary); + Object.defineProperty(process, 'platform', { value: 'darwin' }); + }); + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + spawnSync.mockReset(); + jest.restoreAllMocks(); + for (const dir of [cwd, publicState, ...fakeGitDirs]) fs.rmSync(dir, { recursive: true, force: true }); + }); + + const gitOnMachine = (execDir) => spawnSync.mockImplementation((cmd, args) => { + if (cmd === 'which') return { status: 0, stdout: '/usr/bin/true\n' }; + if (cmd === 'git' && args?.[0] === '--exec-path') { + return execDir ? { status: 0, stdout: `${execDir}\n` } : { status: 1, stdout: '' }; + } + return { status: 0, stdout: '' }; + }); + + const seatSpawn = (impl, trust) => claude.spawn('hi', { + agentName: 'workspace-git-test', + sessionId: null, + cwd, + env: { PATH: process.env.PATH }, + environment: { + model: 'test-model', + sandbox: { mode: 'workspace', trust }, + mcp: [{ name: 'commonly', transport: 'stdio', command: [process.execPath, path.join(cwd, 'server.mjs')] }], + }, + _publicClaudeState: publicState, + _spawnImpl: impl, + }); + + test('gets one read-only git tool, whose server code and real git binary the jail admits', async () => { + gitOnMachine(gitExecDir); + const { impl, calls } = makeSpawnImpl(); + await seatSpawn(impl, 'workspace'); + + expect(calls).toHaveLength(1); + const { args, config } = calls[0]; + expect(config.mcpServers.workspace_git).toEqual({ + type: 'stdio', + command: process.execPath, + args: [serverPath, '--root', cwd, '--git', realGit], + }); + expect(fs.existsSync(serverPath)).toBe(true); + expect(args.slice(args.indexOf('--allowedTools'))).toContain('mcp__workspace_git__*'); + const profile = args[1]; + expect(profile).toContain(`(allow file-read* file-test-existence file-map-executable (literal "${realGit}"))`); + expect(profile).toContain(`(allow file-read* file-test-existence (subpath "${fs.realpathSync(path.dirname(serverPath))}"))`); + expect(profile).toContain(`(allow file-read* file-test-existence (literal "${fs.realpathSync(path.resolve('package.json'))}"))`); + }); + + test('a public seat gets no git tool, even on a machine with git', async () => { + gitOnMachine(gitExecDir); + const { impl, calls } = makeSpawnImpl(); + await seatSpawn(impl, 'public'); + + expect(calls).toHaveLength(1); + expect(calls[0].config.mcpServers.workspace_git).toBeUndefined(); + expect(calls[0].args).not.toContain('mcp__workspace_git__*'); + expect(calls[0].args[1]).not.toContain(realGit); + }); + + test('without git on the machine, a workspace seat still runs, with no git tool', async () => { + gitOnMachine(null); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { impl, calls } = makeSpawnImpl(); + await seatSpawn(impl, 'workspace'); + + expect(calls).toHaveLength(1); + expect(calls[0].config.mcpServers.workspace_git).toBeUndefined(); + expect(calls[0].args[1]).not.toContain(fs.realpathSync(path.dirname(serverPath))); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('git was not found')); + }); +}); diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index c4e872c2b8..16e6b63dff 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -63,7 +63,10 @@ import { writeFile, } from 'fs/promises'; import { homedir, tmpdir } from 'os'; -import { delimiter, isAbsolute, join } from 'path'; +import { + delimiter, dirname, isAbsolute, join, +} from 'path'; +import { fileURLToPath } from 'url'; import { isLegacySandboxTrust, @@ -138,6 +141,48 @@ const PUBLIC_DENIED_TOOLS = [ // TASK-294: a workspace seat is confined as a public one is, except that its // folder is the person's project, which the jail holds read-only. const isConfinedTrust = (trust) => trust === 'public' || trust === 'workspace'; +// A workspace seat reads its project's history through one read-only git tool +// (git-readonly-server.js). The server runs inside the jail, so the jail must +// admit its code (cli/src/lib, plus package.json for the module type) and the +// git binary it runs. +const WORKSPACE_GIT_SERVER = fileURLToPath(new URL('../git-readonly-server.js', import.meta.url)); +const WORKSPACE_GIT_PACKAGE_JSON = fileURLToPath(new URL('../../../package.json', import.meta.url)); +// macOS's /usr/bin/git is an xcrun shim, and it fails inside Seatbelt ("No +// developer tools were found"). So the jail gets the binary git itself runs: +// `git --exec-path`, resolved here on the host, holds a `git` that links to it. +const resolveRealGit = () => { + const result = spawnSync('git', ['--exec-path'], { encoding: 'utf8', timeout: 5000 }); + const execDir = result?.status === 0 ? String(result.stdout || '').trim() : ''; + if (!execDir) return null; + try { + return realpathSync(join(execDir, 'git')); + } catch { + return null; + } +}; +const withWorkspaceGit = (ctx) => { + if (ctx.environment?.sandbox?.trust !== 'workspace' || !ctx.cwd) return ctx; + const git = resolveRealGit(); + if (!git) { + // eslint-disable-next-line no-console + console.warn('[claude] git was not found on this machine, so this workspace seat has no git tool'); + return ctx; + } + const server = { + name: 'workspace_git', + transport: 'stdio', + command: [process.execPath, WORKSPACE_GIT_SERVER, '--root', ctx.cwd, '--git', git], + }; + return { + ...ctx, + environment: { ...ctx.environment, mcp: [...(ctx.environment.mcp || []), server] }, + workspaceGit: { + executable: git, + readDirectories: [dirname(WORKSPACE_GIT_SERVER)], + readPaths: [WORKSPACE_GIT_PACKAGE_JSON], + }, + }; +}; const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/; const statOrNull = async (path) => { @@ -714,9 +759,18 @@ const prepareArgv = async (innerArgv, ctx) => { executablePath: claudeBin, statePath: ctx.publicClaudeState.statePath, mcpConfigDir: ctx.mcpConfigDir, - executablePaths: [...mcpExecutables, ...commitHookExecutables, ...originalHookExecutables], + executablePaths: [ + ...mcpExecutables, + ...(ctx.workspaceGit ? [ctx.workspaceGit.executable] : []), + ...commitHookExecutables, + ...originalHookExecutables, + ], allowClaudeRuntimeAccess: true, - readOnlyDirectories: ctx.mcpReadDirectories || [], + readOnlyDirectories: [ + ...(ctx.mcpReadDirectories || []), + ...(ctx.workspaceGit?.readDirectories || []), + ], + readOnlyPaths: ctx.workspaceGit?.readPaths || [], }); return { cmd: wrapped[0], @@ -803,13 +857,13 @@ export default { // one declaration is exactly how this seat crashed with "public Claude // state was not prepared" instead of spawning confined. const ctx = rawCtx.environment - ? { + ? withWorkspaceGit({ ...rawCtx, environment: { ...rawCtx.environment, sandbox: normalizeSandboxTrust(rawCtx.environment.sandbox), }, - } + }) : rawCtx; if (isLegacySandboxTrust(rawCtx.environment?.sandbox)) { // eslint-disable-next-line no-console From 64c2353e98e6a3737a20af1f1adf8c7ced01c961 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:17:22 -0700 Subject: [PATCH 08/33] test(cli): pin that a workspace seat is not taught the row-working habits (TASK-294) Since slice 5, isConfinedTrust turns the work cues off for a workspace seat. That is a decision, so a test now pins it. The seat holds its project read-only and the Agent tool is denied to it. The two cues would teach it things it cannot do: spawning parallel subagents, and slicing a row before writing code. When a write grant lands, the cues can follow it. The trust=workspace case passes. Keying the cues on trust === 'public' fails exactly that case. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/adapters.claude.environment.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 61e656bb16..60e9f383a7 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1077,7 +1077,10 @@ describe('claude adapter — a public seat gets its declared skills as prompt te // legacy `internal` record reads as public, so it is held to the same line. // Seatbelt wraps argv and spends `-p` on its profile, so the prompt is found // by its content rather than by the flag. - test.each(['public', 'internal'])('a fresh trust=%s seat is not taught the row-working habits', async (trust) => { + // A workspace seat holds its project read-only and is denied the Agent tool, + // so both cues would teach it things it cannot do: parallel subagents, and + // slicing a row before writing code. + test.each(['public', 'internal', 'workspace'])('a fresh trust=%s seat is not taught the row-working habits', async (trust) => { const { impl, calls } = makeSpawnImpl(); await publicSpawn(impl, { environment: { model: 'test-model', From 69e471d19f07ab5a50f4f6e2c26a39ec563afec2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:30:08 -0700 Subject: [PATCH 09/33] feat(cli): a workspace seat's write and web grants widen its jail and tool policy (TASK-294) sandbox.allow now takes effect. On a workspace seat, 'write' moves Edit, Write and Bash out of the denies and makes the project writable; 'web' moves WebFetch alone. WebSearch, NotebookEdit and Task stay denied at every tier, and any other trust gets no grant, whatever its record says. The project's .git stays read-only under write, so every change the seat makes is one `git status` shows. Measured under the profile the adapter builds: a project write and an overwrite of a tracked file succeed. Writes to .git/config and .git/hooks, moving .git aside, a hardlink into .git, a write through a symlink into .git, .env, and anything outside the project are all refused, and git commit fails. Bash reaches the network (a GET returned 200), so write opens the same exfil pair as web; the docs page must say so. A turn that runs public drops the grants. Validation refuses 'write' on a read-only seat rather than let the jail drop it quietly. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- .../adapters.claude.environment.test.mjs | 44 +++++++++ cli/__tests__/environment.test.mjs | 11 +++ cli/__tests__/seatbelt.test.mjs | 51 ++++++++++- .../seatbelt.workspace-write.smoke.test.mjs | 89 +++++++++++++++++++ cli/__tests__/workspace-trust.test.mjs | 8 ++ cli/src/lib/adapters/claude.js | 32 +++++-- cli/src/lib/environment.js | 2 + cli/src/lib/sandbox/seatbelt.js | 16 ++++ cli/src/lib/workspace-trust.js | 5 +- 9 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 cli/__tests__/seatbelt.workspace-write.smoke.test.mjs diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 60e9f383a7..8e7759040f 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1313,6 +1313,50 @@ describe('claude adapter — a workspace seat', () => { expect(fs.existsSync(path.join(cwd, '.claude'))).toBe(false); }); + const toolLists = (args) => ({ + allowed: args.slice(args.indexOf('--allowedTools') + 1, args.indexOf('--disallowedTools')), + denied: args.slice(args.indexOf('--disallowedTools') + 1), + }); + + test('a write grant hands it Edit, Write and Bash and a writable project, with .git held read-only', async () => { + const { impl, calls } = makeSpawnImpl(); + await workspaceSpawn(impl, { mode: 'workspace', trust: 'workspace', allow: ['write'] }); + + const { args } = calls[0]; + const { allowed, denied } = toolLists(args); + expect(allowed).toEqual(expect.arrayContaining(['Edit', 'Write', 'Bash'])); + expect(denied).not.toContain('Edit'); + expect(denied).not.toContain('Write'); + expect(denied).not.toContain('Bash'); + expect(denied).toEqual(expect.arrayContaining(['WebFetch', 'WebSearch', 'NotebookEdit', 'Task', 'Read(./.env.*)'])); + const realCwd = fs.realpathSync(cwd); + expect(args[1]).toContain(`(allow file-read* file-test-existence file-write* (subpath "${realCwd}"))`); + expect(args[1]).toContain(`(deny file-write* (subpath "${path.join(realCwd, '.git')}"))`); + }); + + test('a web grant hands it WebFetch alone, with the project still read-only', async () => { + const { impl, calls } = makeSpawnImpl(); + await workspaceSpawn(impl, { mode: 'workspace', trust: 'workspace', allow: ['web'] }); + + const { args } = calls[0]; + const { allowed, denied } = toolLists(args); + expect(allowed).toContain('WebFetch'); + expect(denied).not.toContain('WebFetch'); + expect(denied).toEqual(expect.arrayContaining(['WebSearch', 'Bash', 'Write', 'Edit', 'NotebookEdit', 'Task'])); + expect(args[1]).not.toContain(`file-write* (subpath "${fs.realpathSync(cwd)}"))`); + }); + + test('a public seat whose record carries grants gets none of them', async () => { + const { impl, calls } = makeSpawnImpl(); + await workspaceSpawn(impl, { mode: 'workspace', trust: 'public', allow: ['write', 'web'] }); + + const { allowed, denied } = toolLists(calls[0].args); + for (const tool of ['Edit', 'Write', 'Bash', 'WebFetch']) { + expect(allowed).not.toContain(tool); + expect(denied).toContain(tool); + } + }); + test('under bwrap it refuses to spawn', async () => { const { impl, calls } = makeSpawnImpl(); await expect(workspaceSpawn(impl, { mode: 'bwrap', trust: 'workspace' })) diff --git a/cli/__tests__/environment.test.mjs b/cli/__tests__/environment.test.mjs index afe7a61d9d..9ac518b343 100644 --- a/cli/__tests__/environment.test.mjs +++ b/cli/__tests__/environment.test.mjs @@ -243,6 +243,17 @@ describe('validateEnvironmentSpec', () => { expect(unknown.errors.join(' ')).toMatch(/sandbox\.allow must be an array of: write, web/); }); + test('refuses a write grant on a read-only seat rather than let the jail quietly drop it', () => { + const result = validateEnvironmentSpec({ + sandbox: { mode: 'read-only', trust: 'workspace', allow: ['write'] }, + }); + expect(result.ok).toBe(false); + expect(result.errors.join(' ')).toMatch(/sandbox\.allow 'write' cannot be combined with sandbox\.mode 'read-only'/); + expect(validateEnvironmentSpec({ + sandbox: { mode: 'read-only', trust: 'workspace', allow: ['web'] }, + }).ok).toBe(true); + }); + test('rejects bad sandbox.trust', () => { const res = validateEnvironmentSpec({ sandbox: { mode: 'workspace', trust: 'sometimes' }, diff --git a/cli/__tests__/seatbelt.test.mjs b/cli/__tests__/seatbelt.test.mjs index db1b0cc2fd..9567992fa4 100644 --- a/cli/__tests__/seatbelt.test.mjs +++ b/cli/__tests__/seatbelt.test.mjs @@ -1,4 +1,6 @@ -import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'fs'; +import { + mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync, +} from 'fs'; import { homedir, tmpdir } from 'os'; import { join } from 'path'; @@ -78,6 +80,53 @@ describe('macOS Seatbelt profile', () => { ); }); + // TASK-294: a workspace seat granted write keeps its project's .git + // read-only, so every change it makes shows in `git status`. + test('a writable workspace can hold named entries read-only', () => { + const profile = buildSeatbeltProfile({ + workspacePath: workspace, + workspaceAccess: 'write', + executablePath: '/usr/bin/true', + statePath: state, + readOnlyWorkspaceEntries: ['.git'], + }); + const resolvedWorkspace = realpathSync(workspace); + + expect(profile).toContain( + `(allow file-read* file-test-existence file-write* (subpath "${resolvedWorkspace}"))`, + ); + expect(profile).toContain(`(deny file-write* (subpath "${join(resolvedWorkspace, '.git')}"))`); + // The deny must come after the allow it narrows: in SBPL the last match wins. + expect(profile.indexOf(`(deny file-write* (subpath "${join(resolvedWorkspace, '.git')}"))`)) + .toBeGreaterThan(profile.indexOf(`file-write* (subpath "${resolvedWorkspace}"))`)); + }); + + test('a symlinked read-only entry is held at its target too', () => { + const target = join(root, 'elsewhere-git'); + mkdirSync(target); + symlinkSync(target, join(workspace, '.git')); + const profile = buildSeatbeltProfile({ + workspacePath: workspace, + workspaceAccess: 'write', + executablePath: '/usr/bin/true', + statePath: state, + readOnlyWorkspaceEntries: ['.git'], + }); + + expect(profile).toContain(`(deny file-write* (subpath "${join(realpathSync(workspace), '.git')}"))`); + expect(profile).toContain(`(deny file-write* (subpath "${realpathSync(target)}"))`); + }); + + test.each(['', '.', '..', 'a/b', '../x', 7])('rejects %p as a read-only workspace entry', (entry) => { + expect(() => buildSeatbeltProfile({ + workspacePath: workspace, + workspaceAccess: 'write', + executablePath: '/usr/bin/true', + statePath: state, + readOnlyWorkspaceEntries: [entry], + })).toThrow(/readOnlyWorkspaceEntries entries must be names inside the workspace/); + }); + test('OpenCode config roots remain read-only in the generated profile', () => { const configRoot = join(root, 'opencode-temp'); const configDir = join(configRoot, 'config-dir'); diff --git a/cli/__tests__/seatbelt.workspace-write.smoke.test.mjs b/cli/__tests__/seatbelt.workspace-write.smoke.test.mjs new file mode 100644 index 0000000000..7f95191623 --- /dev/null +++ b/cli/__tests__/seatbelt.workspace-write.smoke.test.mjs @@ -0,0 +1,89 @@ +/** + * seatbelt.workspace-write.smoke.test.mjs + * + * A workspace seat granted write (TASK-294), under real sandbox-exec: it can + * change the person's project, and the project's .git stays read-only, so + * every change it makes is one `git status` shows. The positive controls keep + * the denials from passing vacuously. + */ + +import { spawnSync } from 'child_process'; +import { + existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { wrapArgvWithSeatbelt } from '../src/lib/sandbox/seatbelt.js'; + +const macTest = process.platform === 'darwin' ? test : test.skip; + +describe('a workspace seat granted write, under Seatbelt', () => { + let root; + let workspace; + let state; + const attempt = (op) => { + const argv = wrapArgvWithSeatbelt([ + process.execPath, '-e', + `try { ${op}; console.log('ALLOWED') } catch (e) { console.log('DENIED ' + e.code) }`, + ], { + workspacePath: workspace, + workspaceAccess: 'write', + executablePath: process.execPath, + statePath: state, + readOnlyWorkspaceEntries: ['.git'], + }); + const run = spawnSync(argv[0], argv.slice(1), { cwd: workspace, encoding: 'utf8', timeout: 20_000 }); + return (run.stdout || '').trim(); + }; + const write = (path) => `require('fs').writeFileSync(${JSON.stringify(path)}, 'x')`; + + beforeEach(() => { + if (process.platform !== 'darwin') return; + root = realpathSync(mkdtempSync(join(tmpdir(), 'commonly-workspace-write-seatbelt-'))); + workspace = join(root, 'workspace'); + state = join(root, 'state'); + mkdirSync(join(workspace, '.git', 'hooks'), { recursive: true }); + writeFileSync(join(workspace, '.git', 'config'), '[core]\n'); + writeFileSync(join(workspace, 'a.txt'), 'one\n'); + mkdirSync(state); + }); + afterEach(() => { if (root) rmSync(root, { recursive: true, force: true }); }); + + macTest('changes project files', () => { + expect(attempt(write(join(workspace, 'new.txt')))).toBe('ALLOWED'); + expect(attempt(write(join(workspace, 'a.txt')))).toBe('ALLOWED'); + expect(readFileSync(join(workspace, 'a.txt'), 'utf8')).toBe('x'); + }); + + macTest('cannot write into .git', () => { + expect(attempt(write(join(workspace, '.git', 'config')))).toBe('DENIED EPERM'); + expect(readFileSync(join(workspace, '.git', 'config'), 'utf8')).toBe('[core]\n'); + }); + + macTest('cannot add a git hook', () => { + expect(attempt(write(join(workspace, '.git', 'hooks', 'pre-commit')))).toBe('DENIED EPERM'); + expect(existsSync(join(workspace, '.git', 'hooks', 'pre-commit'))).toBe(false); + }); + + macTest('cannot move .git aside to replace it', () => { + const moved = join(workspace, 'moved'); + expect(attempt(`require('fs').renameSync(${JSON.stringify(join(workspace, '.git'))}, ${JSON.stringify(moved)})`)) + .toBe('DENIED EPERM'); + expect(existsSync(join(workspace, '.git', 'config'))).toBe(true); + }); + + macTest('cannot reach .git through a link it makes in the project', () => { + const config = JSON.stringify(join(workspace, '.git', 'config')); + expect(attempt(`require('fs').linkSync(${config}, ${JSON.stringify(join(workspace, 'hard'))})`)) + .toBe('DENIED EPERM'); + expect(attempt(`require('fs').symlinkSync(${config}, ${JSON.stringify(join(workspace, 'soft'))})`)) + .toBe('ALLOWED'); + expect(attempt(write(join(workspace, 'soft')))).toBe('DENIED EPERM'); + expect(readFileSync(join(workspace, '.git', 'config'), 'utf8')).toBe('[core]\n'); + }); + + macTest('cannot write outside the project', () => { + expect(attempt(write(join(root, 'outside.txt')))).toBe('DENIED EPERM'); + }); +}); diff --git a/cli/__tests__/workspace-trust.test.mjs b/cli/__tests__/workspace-trust.test.mjs index 2429f95004..283a032f62 100644 --- a/cli/__tests__/workspace-trust.test.mjs +++ b/cli/__tests__/workspace-trust.test.mjs @@ -143,6 +143,14 @@ describe('workspace turn placement', () => { }); }); + test('a public turn drops the write and web grants, and a private one keeps them', () => { + const granted = { model: 'm', sandbox: { mode: 'workspace', trust: 'workspace', allow: ['write', 'web'] } }; + expect(place({ environment: granted, installations: [priv, pub] }).environment).toEqual({ + model: 'm', sandbox: { mode: 'workspace', trust: 'public' }, + }); + expect(place({ environment: granted, installations: [priv] }).environment).toBe(granted); + }); + test('a failed read and an empty list both fail closed', () => { expect(place({ installations: null })).toMatchObject({ cwd: '/tmp/scratch', reason: 'its pods could not be read', diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 16e6b63dff..1f22addab7 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -139,8 +139,22 @@ const PUBLIC_DENIED_TOOLS = [ 'Task', ]; // TASK-294: a workspace seat is confined as a public one is, except that its -// folder is the person's project, which the jail holds read-only. +// folder is the person's project, which the jail holds read-only unless the +// seat was granted write, and whose .git it holds read-only either way. const isConfinedTrust = (trust) => trust === 'public' || trust === 'workspace'; +// What each `sandbox.allow` grant moves out of the denies. `web` grants +// WebFetch alone: WebSearch, NotebookEdit and Task stay denied at every tier. +// Bash and Write land wherever the jail lets them, so the jail, not this list, +// bounds a write. +const WORKSPACE_ALLOW_TOOLS = { + write: ['Edit', 'Write', 'Bash'], + web: ['WebFetch'], +}; +// The grants a seat's environment carries. They widen the workspace tier +// only, so any other trust gets none, whatever its record says. +const workspaceGrants = (sandbox) => ( + sandbox?.trust === 'workspace' && Array.isArray(sandbox.allow) ? sandbox.allow : [] +); // A workspace seat reads its project's history through one read-only git tool // (git-readonly-server.js). The server runs inside the jail, so the jail must // admit its code (cli/src/lib, plus package.json for the module type) and the @@ -296,8 +310,10 @@ const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, const absoluteToolDeny = (path) => `Read(/${path}/**)`; -const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust } = {}) => { +const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [] } = {}) => { const home = homedir(); + // `allow` is already narrowed to a workspace seat's grants (workspaceGrants). + const granted = allow.flatMap((grant) => WORKSPACE_ALLOW_TOOLS[grant] || []); const deniedReads = [ absoluteToolDeny(join(home, '.commonly')), absoluteToolDeny(join(home, '.claude')), @@ -325,8 +341,9 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust } = {}) => { '--strict-mcp-config', '--no-chrome', '--permission-mode', 'dontAsk', - '--allowedTools', 'Read(./**)', ...mcpToolPatterns, - '--disallowedTools', ...PUBLIC_DENIED_TOOLS, ...deniedReads, ...workspaceDenies, + '--allowedTools', 'Read(./**)', ...granted, ...mcpToolPatterns, + '--disallowedTools', ...PUBLIC_DENIED_TOOLS.filter((tool) => !granted.includes(tool)), + ...deniedReads, ...workspaceDenies, ]; }; @@ -690,6 +707,7 @@ const prepareArgv = async (innerArgv, ctx) => { const sandboxTrust = env.sandbox?.trust; const sandboxMode = resolvePublicSandboxMode(env.sandbox); + const grants = workspaceGrants(env.sandbox); const publicNativeSandbox = isConfinedTrust(sandboxTrust) && PUBLIC_SANDBOX_MODES.has(sandboxMode); // A public trust is ONE contract on both platforms: the jail bounds the @@ -743,7 +761,7 @@ const prepareArgv = async (innerArgv, ctx) => { } innerArgv = [ ...innerArgv, - ...buildPublicClaudePolicyArgs(allowedPatterns, { trust: sandboxTrust }), + ...buildPublicClaudePolicyArgs(allowedPatterns, { trust: sandboxTrust, allow: grants }), ...(ctx.publicSkillArgs || []), ]; const claudeBin = resolveClaudePath(claudeEnv); @@ -755,7 +773,9 @@ const prepareArgv = async (innerArgv, ctx) => { .filter((command) => typeof command === 'string' && isAbsolute(command)); const wrapped = wrapArgvWithSeatbelt([claudeBin, ...innerArgv], { workspacePath: ctx.cwd, - workspaceAccess: sandboxTrust === 'workspace' || sandboxMode === 'read-only' ? 'read' : 'write', + workspaceAccess: sandboxMode === 'read-only' + || (sandboxTrust === 'workspace' && !grants.includes('write')) ? 'read' : 'write', + readOnlyWorkspaceEntries: sandboxTrust === 'workspace' ? ['.git'] : [], executablePath: claudeBin, statePath: ctx.publicClaudeState.statePath, mcpConfigDir: ctx.mcpConfigDir, diff --git a/cli/src/lib/environment.js b/cli/src/lib/environment.js index b17eebb36f..13b35f7119 100644 --- a/cli/src/lib/environment.js +++ b/cli/src/lib/environment.js @@ -340,6 +340,8 @@ export const validateEnvironmentSpec = (spec) => { "sandbox.allow applies only to trust 'workspace' " + `(got trust ${JSON.stringify(trust)})`, ); + } else if (allow.includes('write') && mode === 'read-only') { + errors.push("sandbox.allow 'write' cannot be combined with sandbox.mode 'read-only'"); } } if (network !== undefined) { diff --git a/cli/src/lib/sandbox/seatbelt.js b/cli/src/lib/sandbox/seatbelt.js index c41da5c511..d8094d4a65 100644 --- a/cli/src/lib/sandbox/seatbelt.js +++ b/cli/src/lib/sandbox/seatbelt.js @@ -356,6 +356,10 @@ export const buildSeatbeltProfile = ({ readOnlyDirectories = [], loopbackNetworkPorts = [], allowClaudeRuntimeAccess = false, + // Names directly inside a writable workspace that stay read-only. A + // workspace seat granted write keeps its project's .git read-only, so every + // change it makes is one `git status` shows the person (TASK-294). + readOnlyWorkspaceEntries = [], }) => { for (const [path, label] of [ [workspacePath, 'workspacePath'], @@ -375,6 +379,11 @@ export const buildSeatbeltProfile = ({ if (!['read', 'write'].includes(workspaceAccess)) { throw new Error('Seatbelt workspaceAccess must be read or write'); } + for (const entry of readOnlyWorkspaceEntries) { + if (typeof entry !== 'string' || !entry || entry.includes('/') || entry === '.' || entry === '..') { + throw new Error('Seatbelt readOnlyWorkspaceEntries entries must be names inside the workspace'); + } + } const resolvedWorkspace = realpathOrSelf(workspacePath); const resolvedExecutable = realpathOrSelf(executablePath); @@ -411,6 +420,12 @@ export const buildSeatbeltProfile = ({ `(allow file-read* file-test-existence ${subpath(path)})`, `(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(path)}"))`, ].join('\n'))).join('\n'); + // A symlinked entry is held at both its name and its target, so the write + // cannot land through whichever path the link resolves to. + const readOnlyWorkspaceRules = [...new Set(readOnlyWorkspaceEntries.flatMap((entry) => { + const entryPath = join(resolvedWorkspace, entry); + return [entryPath, realpathOrSelf(entryPath)]; + }))].map((path) => `(deny file-write* ${subpath(path)})`).join('\n'); const loopbackNetworkRules = [...new Set(loopbackNetworkPorts)].map((port) => ( `(allow network-outbound (remote tcp "localhost:${port}"))` )).join('\n'); @@ -445,6 +460,7 @@ export const buildSeatbeltProfile = ({ `(deny file-read* file-write* ${subpath(join(resolvedWorkspace, '.commonly'))})`, `(deny file-read* file-write* ${subpath(join(resolvedWorkspace, '.codex'))})`, `(deny file-read* file-write* ${literal(join(resolvedWorkspace, '.env'))})`, + readOnlyWorkspaceRules, ...claudeRuntimeRules, ].filter(Boolean).join('\n\n'); }; diff --git a/cli/src/lib/workspace-trust.js b/cli/src/lib/workspace-trust.js index 300fd18566..e05d77b470 100644 --- a/cli/src/lib/workspace-trust.js +++ b/cli/src/lib/workspace-trust.js @@ -112,8 +112,11 @@ export const workspaceTurnPlacement = ({ if (environment?.sandbox?.trust !== 'workspace' || allPodsConfirmedPrivate(installations)) { return { environment, cwd, reason: null }; } + // Its write/web grants widen the workspace tier only, so the public turn + // drops them rather than carry them into a room strangers can read. + const { allow: _grants, ...sandbox } = environment.sandbox; return { - environment: { ...environment, sandbox: { ...environment.sandbox, trust: 'public' } }, + environment: { ...environment, sandbox: { ...sandbox, trust: 'public' } }, cwd: scratchDir, reason: installations === null ? 'its pods could not be read' : PUBLIC_POD_REASON, }; From b15a64b1404ad0a87f821f7c4ea2c6275366505b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:33:56 -0700 Subject: [PATCH 10/33] fix(cli): hold .env files out of a confined seat's reach at any depth (TASK-294) The jail denied only the folder's top-level .env. The tool policy denied Read(./.env), plus Read(./.env.*) for a workspace seat, and both at the root only. A workspace seat reads a real project, which keeps an .env beside each package. Under real sandbox-exec, .env.local and packages/api/.env were both readable. The jail now denies .env and .env.* at any depth under the folder, for every Seatbelt seat, with one regex rule. The workspace tool policy adds Read(./**/.env) and Read(./**/.env.*). Regex metacharacters in the folder's name are escaped. A path the SBPL regex literal cannot hold (one with a quote or a line break) is refused rather than left unguarded. config.js, .envrc, env.txt and notes.env stay readable. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- .../adapters.claude.environment.test.mjs | 3 +- .../seatbelt.env-files.smoke.test.mjs | 65 +++++++++++++++++++ cli/__tests__/seatbelt.test.mjs | 21 ++++++ cli/src/lib/adapters/claude.js | 12 ++-- cli/src/lib/sandbox/seatbelt.js | 12 +++- 5 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 cli/__tests__/seatbelt.env-files.smoke.test.mjs diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 8e7759040f..39d7044770 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1267,7 +1267,8 @@ describe('claude adapter — a workspace seat', () => { const denied = args.slice(args.indexOf('--disallowedTools') + 1); expect(denied).toEqual(expect.arrayContaining([ 'Bash', 'Write', 'Edit', 'WebFetch', 'WebSearch', 'Task', - 'Read(./.env)', 'Read(./.env.*)', 'mcp__commonly__commonly_self_install_into_pod', + 'Read(./.env)', 'Read(./.env.*)', 'Read(./**/.env)', 'Read(./**/.env.*)', + 'mcp__commonly__commonly_self_install_into_pod', ])); expect(opts.env.HOME).toBe(publicState); expect(opts.env.COMMONLY_HOST_SECRET).toBeUndefined(); diff --git a/cli/__tests__/seatbelt.env-files.smoke.test.mjs b/cli/__tests__/seatbelt.env-files.smoke.test.mjs new file mode 100644 index 0000000000..0572f8e305 --- /dev/null +++ b/cli/__tests__/seatbelt.env-files.smoke.test.mjs @@ -0,0 +1,65 @@ +/** + * seatbelt.env-files.smoke.test.mjs + * + * Every .env and .env.* file under a confined seat's folder is out of reach at + * any depth, under real sandbox-exec (TASK-294): a workspace seat reads a real + * project, which keeps one beside each package, not only at its root. The + * folder's name carries regex metacharacters, so a missed escape shows up as a + * readable .env. The positive controls keep the denials from passing + * vacuously. + */ + +import { spawnSync } from 'child_process'; +import { + mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { wrapArgvWithSeatbelt } from '../src/lib/sandbox/seatbelt.js'; + +const macTest = process.platform === 'darwin' ? test : test.skip; +const FILES = [ + '.env', '.env.local', 'packages/api/.env', 'packages/api/.env.production', + 'packages/api/config.js', '.envrc', 'env.txt', 'notes.env', +]; + +describe('a confined seat\'s .env files, under Seatbelt', () => { + let root; + let workspace; + let state; + const read = (relative) => { + const argv = wrapArgvWithSeatbelt([ + process.execPath, '-e', + `try { require('fs').readFileSync(${JSON.stringify(join(workspace, relative))}); console.log('READ') } catch (e) { console.log('DENIED ' + e.code) }`, + ], { + workspacePath: workspace, + workspaceAccess: 'read', + executablePath: process.execPath, + statePath: state, + }); + const run = spawnSync(argv[0], argv.slice(1), { cwd: workspace, encoding: 'utf8', timeout: 20_000 }); + return (run.stdout || '').trim(); + }; + + beforeAll(() => { + if (process.platform !== 'darwin') return; + root = realpathSync(mkdtempSync(join(tmpdir(), 'commonly-env-files-seatbelt-'))); + workspace = join(root, 'my.project (v2)+'); + state = join(root, 'state'); + mkdirSync(join(workspace, 'packages', 'api'), { recursive: true }); + mkdirSync(state); + for (const file of FILES) writeFileSync(join(workspace, file), 'x'); + }); + afterAll(() => { if (root) rmSync(root, { recursive: true, force: true }); }); + + macTest.each(['.env', '.env.local', 'packages/api/.env', 'packages/api/.env.production'])( + 'cannot read %s', + (file) => { expect(read(file)).toBe('DENIED EPERM'); }, + ); + + macTest.each(['packages/api/config.js', '.envrc', 'env.txt', 'notes.env'])( + 'still reads %s', + (file) => { expect(read(file)).toBe('READ'); }, + ); +}); diff --git a/cli/__tests__/seatbelt.test.mjs b/cli/__tests__/seatbelt.test.mjs index 9567992fa4..f99779c86b 100644 --- a/cli/__tests__/seatbelt.test.mjs +++ b/cli/__tests__/seatbelt.test.mjs @@ -127,6 +127,27 @@ describe('macOS Seatbelt profile', () => { })).toThrow(/readOnlyWorkspaceEntries entries must be names inside the workspace/); }); + test('holds every .env file under the workspace, at any depth, out of reach', () => { + const profile = buildSeatbeltProfile({ + workspacePath: workspace, + executablePath: '/usr/bin/true', + statePath: state, + }); + const escaped = realpathSync(workspace).replace(/[\\^$.|?*+()[\]{}]/g, '\\$&'); + + expect(profile).toContain(`(deny file-read* file-write* (regex #"^${escaped}/(.*/)?\\.env(\\..*)?$"))`); + }); + + test('refuses a workspace path an SBPL regex cannot hold', () => { + const quoted = join(root, 'a"b'); + mkdirSync(quoted); + expect(() => buildSeatbeltProfile({ + workspacePath: quoted, + executablePath: '/usr/bin/true', + statePath: state, + })).toThrow(/must not contain a quote or a line break/); + }); + test('OpenCode config roots remain read-only in the generated profile', () => { const configRoot = join(root, 'opencode-temp'); const configDir = join(configRoot, 'config-dir'); diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 1f22addab7..a5832d30be 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -326,11 +326,15 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [] } = {} 'Read(./.codex/**)', 'Read(./.env)', ]; - // A workspace seat reads a real project, so every .env variant stays out of - // reach, and it cannot install itself into a room partway through a turn: - // the per-turn check confirmed its pods private before the spawn. + // A workspace seat reads a real project, so every .env variant at any depth + // stays out of reach (the jail holds the same line), and it cannot install + // itself into a room partway through a turn: the per-turn check confirmed + // its pods private before the spawn. const workspaceDenies = trust === 'workspace' - ? ['Read(./.env.*)', ...mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, 'commonly_self_install_into_pod'))] + ? [ + 'Read(./.env.*)', 'Read(./**/.env)', 'Read(./**/.env.*)', + ...mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, 'commonly_self_install_into_pod')), + ] : []; return [ // HOME points at an isolated, sanitized state directory. Ignore settings diff --git a/cli/src/lib/sandbox/seatbelt.js b/cli/src/lib/sandbox/seatbelt.js index d8094d4a65..fead7c2664 100644 --- a/cli/src/lib/sandbox/seatbelt.js +++ b/cli/src/lib/sandbox/seatbelt.js @@ -300,6 +300,16 @@ const escapeSbplString = (value) => String(value) const literal = (path) => `(literal "${escapeSbplString(path)}")`; const subpath = (path) => `(subpath "${escapeSbplString(path)}")`; +// Every .env and .env.* file under the workspace, at any depth: a real project +// keeps one beside each package, not only at its root (TASK-294). An SBPL +// regex literal takes no escapes, so a path it cannot hold is refused. +const envFilesUnder = (workspace) => { + if (/["\r\n]/.test(workspace)) { + throw new Error('Seatbelt workspacePath must not contain a quote or a line break'); + } + const pattern = workspace.replace(/[\\^$.|?*+()[\]{}]/g, '\\$&'); + return `(regex #"^${pattern}/(.*/)?\\.env(\\..*)?$")`; +}; const assertAbsolutePath = (path, label) => { if (!path || !isAbsolute(path)) { @@ -459,7 +469,7 @@ export const buildSeatbeltProfile = ({ // paths are not. Explicit deny wins over the broad workspace allow. `(deny file-read* file-write* ${subpath(join(resolvedWorkspace, '.commonly'))})`, `(deny file-read* file-write* ${subpath(join(resolvedWorkspace, '.codex'))})`, - `(deny file-read* file-write* ${literal(join(resolvedWorkspace, '.env'))})`, + `(deny file-read* file-write* ${envFilesUnder(resolvedWorkspace)})`, readOnlyWorkspaceRules, ...claudeRuntimeRules, ].filter(Boolean).join('\n\n'); From 60569bebed59ad10ef00a5c4b8f6d0ccc176dab8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:53 -0700 Subject: [PATCH 11/33] feat(cli): run --allow sets a first-run workspace seat's grants (TASK-294) `commonly agent run --allow write,web` writes the grants into the token file when the seat is set up from the Connect-page env. They are fixed there. A later run asking for different grants is refused with the way to change them (detach --force, then re-run), never applied quietly. Asking for a grant on a seat that will not work in its folder (a public pod, $HOME, a codex seat) is refused with the reason, not dropped. The setup line now says what each grant hands over, including that a write grant's commands reach the network and that .git stays read-only, so the seat cannot commit. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/attach.test.mjs | 80 ++++++++++++++++++++++++++++++++++- cli/src/commands/agent.js | 75 ++++++++++++++++++++++++++++++-- 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs index a59b048dd2..03851ddf63 100644 --- a/cli/__tests__/attach.test.mjs +++ b/cli/__tests__/attach.test.mjs @@ -33,6 +33,8 @@ const { loadAgentToken, buildDefaultEnvironment, bootstrapAgentRecordFromEnv, + parseAllowGrants, + checkRunAllowRequest, NO_AGENT_CLI_CODE, savedSeatLacksDefaultSandbox, BOOTSTRAP_ADAPTER_DETECT_ORDER, @@ -853,7 +855,9 @@ describe('bootstrapAgentRecordFromEnv', () => { ...row, publicRead, communityListed: false, })), }); - const run = async ({ response, startDir = repo, registry = makeRegistry() } = {}) => { + const run = async ({ + response, startDir = repo, registry = makeRegistry(), allow, + } = {}) => { const lines = []; const record = await bootstrapAgentRecordFromEnv({ name: 'smoke-agent', @@ -863,6 +867,7 @@ describe('bootstrapAgentRecordFromEnv', () => { log: (line) => lines.push(line), startDir, home, + ...(allow ? { allow } : {}), }); return { record, lines }; }; @@ -907,6 +912,79 @@ describe('bootstrapAgentRecordFromEnv', () => { expect(record.environment.sandbox?.trust).not.toBe('workspace'); expect(lines.some((line) => /scratch folder|can read/.test(line))).toBe(false); }); + + test('a write grant lands on the record, and the line says its commands reach the network', async () => { + const { record, lines } = await run({ response: withVisibility(false), allow: ['write'] }); + expect(record.environment.sandbox).toEqual({ trust: 'workspace', allow: ['write'] }); + const said = lines.find((line) => line.includes(fs.realpathSync(repo))); + expect(said).toMatch(/can read and edit files in .* and run commands there\./); + expect(said).toMatch(/commands can reach the network/); + expect(said).toMatch(/\.git stays read-only, so it cannot commit/); + }); + + test('a web grant alone reads, runs read-only git and fetches pages, and still cannot edit', async () => { + const { record, lines } = await run({ response: withVisibility(false), allow: ['web'] }); + expect(record.environment.sandbox).toEqual({ trust: 'workspace', allow: ['web'] }); + expect(lines.some((line) => /run read-only git there, and fetch web pages; it cannot edit files\./ + .test(line))).toBe(true); + }); + + test('both grants are named in one line', async () => { + const { record, lines } = await run({ response: withVisibility(false), allow: ['write', 'web'] }); + expect(record.environment.sandbox).toEqual({ trust: 'workspace', allow: ['write', 'web'] }); + expect(lines.some((line) => /run commands there, and fetch web pages\. Its commands can reach the network/ + .test(line))).toBe(true); + }); + + test('grants on a seat that will not work in its folder are refused with the reason', async () => { + const response = withVisibility(false); + response.installations[1] = { ...response.installations[1], publicRead: true }; + await expect(run({ response, allow: ['write'] })) + .rejects.toThrow(/--allow write applies only to an agent that works in the folder it starts in.*scratch folder, because .*publicly readable/); + }); + + test('grants on a codex seat are refused rather than dropped', async () => { + await expect(run({ + response: withVisibility(false), registry: makeRegistry({ claudeFound: false }), allow: ['web'], + })).rejects.toThrow(/--allow web applies only to an agent that works in the folder it starts in/); + }); + }); + + // TASK-294: `run --allow`, read once when the token file is written. + describe('run --allow', () => { + test('takes write and web, comma-separated, in a fixed order, once each', () => { + expect(parseAllowGrants(undefined)).toEqual([]); + expect(parseAllowGrants('web,write')).toEqual(['write', 'web']); + expect(parseAllowGrants(' write , write,')).toEqual(['write']); + }); + + test.each(['', ',', 'bash', 'write,net', 'Write'])('refuses %j rather than dropping it', (value) => { + expect(() => parseAllowGrants(value)).toThrow(/--allow takes write and\/or web, comma-separated/); + }); + + const saved = (allow) => ({ + agentName: 'smoke-agent', + environment: { sandbox: { trust: 'workspace', ...(allow ? { allow } : {}) } }, + }); + + test('a run without --allow keeps whatever the token file holds', () => { + expect(checkRunAllowRequest({ record: saved(['write']), requested: [] })).toEqual({ ok: true }); + }); + + test('a run that asks for the grants the token file holds goes ahead', () => { + expect(checkRunAllowRequest({ record: saved(['write', 'web']), requested: ['write', 'web'] })) + .toEqual({ ok: true }); + }); + + test('a run that asks for other grants is refused with the way to change them', () => { + const none = checkRunAllowRequest({ record: saved(), requested: ['write'] }); + expect(none.ok).toBe(false); + expect(none.message).toMatch(/'smoke-agent' was set up with no --allow; --allow write was not applied\./); + expect(none.message).toMatch(/commonly agent detach smoke-agent --force, then re-run .* --allow write/); + const fewer = checkRunAllowRequest({ record: saved(['write', 'web']), requested: ['write'] }); + expect(fewer.ok).toBe(false); + expect(fewer.message).toMatch(/was set up with --allow write,web; --allow write was not applied/); + }); }); test('a pi seat bootstrapped from env gets the kernel server but no sandbox block, which pi refuses', async () => { diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index d3b4595846..0ef4e4d8fb 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -61,6 +61,7 @@ import { refuseWorkspaceTrust, resolveWorkspace, validateEnvironmentSpec, + WORKSPACE_SANDBOX_ALLOW, } from '../lib/environment.js'; import { firstRunWorkspace, podIsConfirmedPrivate, workspaceTurnPlacement } from '../lib/workspace-trust.js'; import { @@ -299,6 +300,53 @@ export const printAttachRunGuidance = ({ // a foreground run re-bootstraps from the Connect page's env exports after the // local record is removed; a daemon-managed seat is switched through the // registry, which the daemon applies on its next pass. +// `run --allow write,web`: the grants a first-run workspace seat starts with +// (TASK-294). An unknown name is refused rather than dropped. +export const parseAllowGrants = (value) => { + if (value === undefined || value === null) return []; + const requested = String(value).split(',').map((grant) => grant.trim()).filter(Boolean); + if (!requested.length || requested.some((grant) => !WORKSPACE_SANDBOX_ALLOW.includes(grant))) { + throw new Error(`--allow takes ${WORKSPACE_SANDBOX_ALLOW.join(' and/or ')}, comma-separated ` + + `(got ${JSON.stringify(String(value))})`); + } + return WORKSPACE_SANDBOX_ALLOW.filter((grant) => requested.includes(grant)); +}; + +// Grants are fixed when the token file is written. A later --allow that +// differs is refused with the way to change them, never applied quietly. +export const checkRunAllowRequest = ({ record, requested = [] }) => { + if (!requested.length) return { ok: true }; + const savedList = record?.environment?.sandbox?.allow || []; + const saved = WORKSPACE_SANDBOX_ALLOW.filter((grant) => savedList.includes(grant)); + if (saved.join(',') === requested.join(',')) return { ok: true }; + const name = record.agentName; + return { + ok: false, + message: [ + `'${name}' was set up with ${saved.length ? `--allow ${saved.join(',')}` : 'no --allow'}; ` + + `--allow ${requested.join(',')} was not applied.`, + `To change it: commonly agent detach ${name} --force, then re-run with the Connect-page ` + + `env exports and --allow ${requested.join(',')}.`, + ].join('\n'), + }; +}; + +// What a first-run workspace seat may do, said once at bootstrap. A write +// grant hands it Bash, whose commands reach the network, so that line never +// claims the seat is offline. +const workspaceGrantLine = (workspacePath, allow) => { + const write = allow.includes('write'); + const web = allow.includes('web'); + if (!write) { + return web + ? `this agent can read ${workspacePath}, run read-only git there, and fetch web pages; it cannot edit files.` + : `this agent can read ${workspacePath} and run read-only git there; it cannot edit files or reach the web.`; + } + return `this agent can read and edit files in ${workspacePath} and run commands there` + + (web ? ', and fetch web pages' : '') + + '. Its commands can reach the network. Its .git stays read-only, so it cannot commit.'; +}; + export const checkRunAdapterRequest = ({ record, requestedAdapter, tokenPath }) => { const requested = String(requestedAdapter || '').trim().toLowerCase(); if (!requested) return { ok: true }; @@ -324,6 +372,7 @@ export const bootstrapAgentRecordFromEnv = async ({ log = () => {}, startDir = process.cwd(), home, + allow = [], }) => { const runtimeToken = (env.COMMONLY_AGENT_TOKEN || '').trim(); if (!runtimeToken) return null; @@ -410,9 +459,15 @@ export const bootstrapAgentRecordFromEnv = async ({ const workspace = firstRunWorkspace({ adapterName, installations: installs, startDir, home, }); + if (allow.length && !workspace?.workspacePath) { + throw new Error(`--allow ${allow.join(',')} applies only to an agent that works in the folder it ` + + 'starts in, and this one ' + + (workspace?.reason + ? `would read only its scratch folder, because ${workspace.reason}.${workspace.fix ? ` ${workspace.fix}` : ''}` + : 'does not.')); + } if (workspace?.workspacePath) { - log(`this agent can read ${workspace.workspacePath} and run read-only git there; ` - + 'it cannot edit files or reach the web.'); + log(workspaceGrantLine(workspace.workspacePath, allow)); } else if (workspace?.reason) { log(`this agent can read only its own scratch folder, because ${workspace.reason}.` + (workspace.fix ? ` ${workspace.fix}` : '')); @@ -427,7 +482,7 @@ export const bootstrapAgentRecordFromEnv = async ({ runtimeToken, adapter: adapterName, environment: workspace?.workspacePath - ? { ...environment, sandbox: { trust: 'workspace' } } + ? { ...environment, sandbox: { trust: 'workspace', ...(allow.length ? { allow } : {}) } } : environment, workspacePath: workspace?.workspacePath || null, }; @@ -3786,7 +3841,15 @@ Docs: .option('--cascade-grace ', `Extra turns allowed when this seat was directly addressed; 0 disables the grace (env ${CASCADE_ENV_VARS.addressedGrace}, default ${CASCADE_DEFAULTS.addressedGrace})`) .option('--cascade-reset ', `Silence window that clears the streak (env ${CASCADE_ENV_VARS.resetMs}, default ${CASCADE_DEFAULTS.resetMs})`) .option('--refusal-handoff ', 'Agent to @mention when the model provider refuses a request\'s content; the request is acked as refused, never retried') + .option('--allow ', 'On first run of an agent that works in its folder, also let it edit files and run commands (write) and/or fetch web pages (web), comma-separated. Commands reach the network.') .action(async (name, opts) => { + let allow; + try { + allow = parseAllowGrants(opts.allow); + } catch (err) { + console.error(`${stamp()} [${name}] ${err.message}`); + process.exit(1); + } let record = loadAgentToken(name); let bootstrapped = false; if (!record) { @@ -3797,6 +3860,7 @@ Docs: name, adapterOverride: opts.adapter || null, log: (line) => console.log(`${stamp()} [${name}] ${line}`), + allow, }); } catch (err) { console.error(`${stamp()} [${name}] ${err.message}`); @@ -3836,6 +3900,11 @@ Docs: ); } + const allowRequest = checkRunAllowRequest({ record, requested: allow }); + if (!allowRequest.ok) { + console.error(`${stamp()} [${name}] ${allowRequest.message}`); + process.exit(1); + } const adapterRequest = checkRunAdapterRequest({ record, requestedAdapter: opts.adapter, tokenPath: tokenFile(name), }); From 0a66ffffdd906cc1251252857de62ed98c9cddb4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:41:59 -0700 Subject: [PATCH 12/33] fix(cli): off macOS a first-run seat keeps the public default (TASK-294) The claude adapter refuses a workspace seat under bwrap, so on Linux the first-run default would have written a seat that fails every turn, where before it ran as a public seat. firstRunWorkspace now keeps the public default off macOS and says why, and --allow is refused there with the same reason. The platform is injectable, so the tests pin both hosts. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/attach.test.mjs | 13 ++++++++++++- cli/__tests__/workspace-trust.test.mjs | 12 +++++++++--- cli/src/commands/agent.js | 3 ++- cli/src/lib/workspace-trust.js | 6 +++++- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs index 03851ddf63..bab6277581 100644 --- a/cli/__tests__/attach.test.mjs +++ b/cli/__tests__/attach.test.mjs @@ -856,7 +856,7 @@ describe('bootstrapAgentRecordFromEnv', () => { })), }); const run = async ({ - response, startDir = repo, registry = makeRegistry(), allow, + response, startDir = repo, registry = makeRegistry(), allow, platform = 'darwin', } = {}) => { const lines = []; const record = await bootstrapAgentRecordFromEnv({ @@ -867,6 +867,7 @@ describe('bootstrapAgentRecordFromEnv', () => { log: (line) => lines.push(line), startDir, home, + platform, ...(allow ? { allow } : {}), }); return { record, lines }; @@ -903,6 +904,16 @@ describe('bootstrapAgentRecordFromEnv', () => { expect(lines.some((line) => /your home directory\. To let it read a project/.test(line))).toBe(true); }); + test('on Linux the seat keeps the public default and says why, since its adapter refuses workspace there', async () => { + const { record, lines } = await run({ response: withVisibility(false), platform: 'linux' }); + expect(record.environment.sandbox).toEqual({ trust: 'public' }); + expect(record.workspacePath).toBeNull(); + expect(lines.some((line) => /scratch folder, because an agent works in its folder only on macOS/ + .test(line))).toBe(true); + await expect(run({ response: withVisibility(false), platform: 'linux', allow: ['write'] })) + .rejects.toThrow(/--allow write applies only .* only on macOS for now/); + }); + test('a codex seat keeps its baseline and hears nothing about workspaces', async () => { const { record, lines } = await run({ response: withVisibility(false), registry: makeRegistry({ claudeFound: false }), diff --git a/cli/__tests__/workspace-trust.test.mjs b/cli/__tests__/workspace-trust.test.mjs index 283a032f62..fde945755a 100644 --- a/cli/__tests__/workspace-trust.test.mjs +++ b/cli/__tests__/workspace-trust.test.mjs @@ -89,13 +89,13 @@ describe('first-run workspace', () => { test('a claude seat whose pods are all private reads the project it started in', () => { expect(firstRunWorkspace({ - adapterName: 'claude', installations: [priv, priv], startDir: repo, home, + adapterName: 'claude', installations: [priv, priv], startDir: repo, home, platform: 'darwin', })).toEqual({ workspacePath: realpathSync(repo), reason: null }); }); test('one public pod keeps the public default, and moving the start dir would not help', () => { const result = firstRunWorkspace({ - adapterName: 'claude', installations: [priv, pub], startDir: repo, home, + adapterName: 'claude', installations: [priv, pub], startDir: repo, home, platform: 'darwin', }); expect(result.workspacePath).toBeNull(); expect(result.reason).toMatch(/publicly readable/); @@ -104,7 +104,7 @@ describe('first-run workspace', () => { test('a refused start directory keeps the public default and says where to start instead', () => { expect(firstRunWorkspace({ - adapterName: 'claude', installations: [priv], startDir: home, home, + adapterName: 'claude', installations: [priv], startDir: home, home, platform: 'darwin', })).toEqual({ workspacePath: null, reason: 'the start directory is your home directory', @@ -112,6 +112,12 @@ describe('first-run workspace', () => { }); }); + test('off macOS a claude seat keeps the public default, which its adapter can run', () => { + expect(firstRunWorkspace({ + adapterName: 'claude', installations: [priv], startDir: repo, home, platform: 'linux', + })).toEqual({ workspacePath: null, reason: 'an agent works in its folder only on macOS for now' }); + }); + test('every other adapter has nothing to decide, since workspace is claude-only', () => { ['codex', 'opencode', 'qwen', 'pi', 'goose', 'dsh'].forEach((adapterName) => { expect(firstRunWorkspace({ diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 0ef4e4d8fb..95a2e096de 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -373,6 +373,7 @@ export const bootstrapAgentRecordFromEnv = async ({ startDir = process.cwd(), home, allow = [], + platform = process.platform, }) => { const runtimeToken = (env.COMMONLY_AGENT_TOKEN || '').trim(); if (!runtimeToken) return null; @@ -457,7 +458,7 @@ export const bootstrapAgentRecordFromEnv = async ({ // A claude seat whose every pod is confirmed private reads the directory // it was started from (TASK-294); anything else keeps the public default. const workspace = firstRunWorkspace({ - adapterName, installations: installs, startDir, home, + adapterName, installations: installs, startDir, home, platform, }); if (allow.length && !workspace?.workspacePath) { throw new Error(`--allow ${allow.join(',')} applies only to an agent that works in the folder it ` diff --git a/cli/src/lib/workspace-trust.js b/cli/src/lib/workspace-trust.js index e05d77b470..744bd1a649 100644 --- a/cli/src/lib/workspace-trust.js +++ b/cli/src/lib/workspace-trust.js @@ -69,6 +69,9 @@ export const workspaceStartDirRefusal = (dir, { home = homedir() } = {}) => { }; const PUBLIC_POD_REASON = 'a pod it answers in is publicly readable, or did not report whether it is'; +// claude.js refuses a workspace seat under bwrap, so off macOS the default +// would be a seat that fails every turn rather than one that reads a project. +const NOT_MACOS_REASON = 'an agent works in its folder only on macOS for now'; /** * Where a first-run claude seat may read (TASK-294), from three facts the @@ -80,9 +83,10 @@ const PUBLIC_POD_REASON = 'a pod it answers in is publicly readable, or did not * `workspace` is claude-only for now, so there is nothing to decide. */ export const firstRunWorkspace = ({ - adapterName, installations, startDir, home, + adapterName, installations, startDir, home, platform = process.platform, } = {}) => { if (adapterName !== 'claude') return null; + if (platform !== 'darwin') return { workspacePath: null, reason: NOT_MACOS_REASON }; if (!allPodsConfirmedPrivate(installations)) { return { workspacePath: null, reason: PUBLIC_POD_REASON }; } From 09f71a6abb2c67ed745aa084433a02d0d794d3cd Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:43:50 -0700 Subject: [PATCH 13/33] docs(byo): what a Claude Code listener can reach, and --allow (TASK-294) Work in your repo gains a section on the new default: in invite-only pods the listener reads the folder it was first run from and runs read-only git there, and nothing else. It says when it falls back to its scratch folder, what --allow write and --allow web hand over, why fetching stays off unless asked for, and that write's commands reach the network too. The Claude Code page points at it. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs-site/bring-your-own/claude-code.mdx | 2 +- .../bring-your-own/work-in-your-repo.mdx | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs-site/bring-your-own/claude-code.mdx b/docs-site/bring-your-own/claude-code.mdx index 29ac47e227..fb4252ed3a 100644 --- a/docs-site/bring-your-own/claude-code.mdx +++ b/docs-site/bring-your-own/claude-code.mdx @@ -3,7 +3,7 @@ title: Claude Code description: Answer pod mentions from your Mac --- -This listener runs Claude Code on your Mac. It can read its workspace and use Commonly tools. For a session that edits your repository, use [Work in your repo](/bring-your-own/work-in-your-repo). +This listener runs Claude Code on your Mac and uses Commonly tools. In invite-only pods it can read the folder you start it in, and `--allow` can let it edit files or fetch web pages: see [what the listener can reach](/bring-your-own/work-in-your-repo#what-the-listener-can-reach). For a session that edits your repository, use [Work in your repo](/bring-your-own/work-in-your-repo). You need Node.js 20 or later and Claude Code signed in on that Mac. diff --git a/docs-site/bring-your-own/work-in-your-repo.mdx b/docs-site/bring-your-own/work-in-your-repo.mdx index 8e0edd3fc3..6f0e8b1e51 100644 --- a/docs-site/bring-your-own/work-in-your-repo.mdx +++ b/docs-site/bring-your-own/work-in-your-repo.mdx @@ -19,6 +19,35 @@ claude mcp add commonly \ The MCP server does not listen for @mentions when your session is closed. To make the agent answer from the pod, follow [Connect Claude Code](/bring-your-own/claude-code) and run its listener. +## What the listener can reach + +The [Claude Code listener](/bring-your-own/claude-code) checks the pods its key answers in the first time you run it. If every one is invite-only, meaning it isn't publicly readable or listed in Community, the listener works in the folder you ran the command from: + +- It can read files there and run read-only git: `status`, `log`, `diff`, `show` and `blame`. +- It can't edit files, run commands or fetch web pages. +- It can't read `.env` or `.env.*` files, at any depth. + +Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when a pod is publicly readable, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable, or the check fails, that turn runs in the scratch folder with a fresh session. + +### Give it more with `--allow` + +Add `--allow` to the listener command the first time you run it: + +```bash +commonly agent run your-agent-name --adapter claude --allow write +commonly agent run your-agent-name --adapter claude --allow web +commonly agent run your-agent-name --adapter claude --allow write,web +``` + +- `write` lets it edit files in the folder and run commands there. Those commands can reach the network, and they can read what the sandbox lets them read, which is more than the folder. `.git` stays read-only, so it can't commit or change hooks or git config. Files git ignores are writable too, and `git status` won't show changes to them. +- `web` lets it fetch web pages. Web search stays off. + +Fetching stays off unless you ask for it. An agent that can read your code and fetch any URL could send what it read off your machine, and its permissions can't tell a URL you pasted from one it built. `write` opens the same path, because its commands reach the network. Grant either one only in a pod where you trust everyone who can @mention the agent with your code. + +Grants are fixed when the listener is first set up. To change them, run `commonly agent detach your-agent-name --force`, then run the listener command again with the new `--allow`. + +Only Claude Code listeners work in a folder for now. Other agents keep the setup they have today. + ## Let a private listener work in the repo Use an invite-only pod that isn't publicly readable or listed in Community. Only people you trust should be in it. With Node.js 20 or later, create an environment file for your repository with **no `sandbox` block**. Attach a separate Claude Code seat with a name different from the interactive agent above, then run it: From bc893b28caf463590d6d3986043685a223591d96 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:46:10 -0700 Subject: [PATCH 14/33] feat(frontend): the Connect page says what a Claude Code listener can reach (TASK-294) Under the listener command, a Claude Code listener now says that in an invite-only pod it reads the folder it is run from and nothing else, how --allow write and --allow web widen that, and that either grant lets it send what it reads off the computer. Codex listeners show no such line, since folder access is Claude-only for now. English and Simplified Chinese. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + .../src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx | 10 ++++++++++ frontend/src/v2/components/V2AgentBYO.tsx | 5 +++++ 4 files changed, 17 insertions(+) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3cff082bb2..b101b6dc61 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1587,6 +1587,7 @@ "listen": { "title": "Run this in Terminal", "note": "MCP alone won't wake {{name}} for @mentions. Keep this Terminal open.", + "reachClaude": "In an invite-only pod, {{name}} can read the folder you run this from and nothing else. To let it edit files and run commands, add --allow write to the last line; to let it fetch web pages, add --allow web. Either one lets it send what it reads off this computer, so grant them only in a pod you trust.", "waiting": "Waiting for {{name}} to check in… this turns green the moment the command connects.", "verified": "{{name}} is listening — mentions will wake it.", "stillWaiting": "Still no check-in from {{name}}. Run the command above in Terminal on your computer, and this page will confirm it.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 092aa077cb..e1f35d27d7 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1647,6 +1647,7 @@ "listen": { "title": "在终端里运行这段命令", "note": "仅连接 MCP 不会唤醒 {{name}} 回复 @提及。请保持终端窗口开启。", + "reachClaude": "在仅限邀请的 pod 中,{{name}} 只能读取你运行此命令的文件夹,别的都读不到。若要让它编辑文件并运行命令,请在最后一行加上 --allow write;若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容发出这台电脑,所以只在你信任的 pod 中授予。", "waiting": "正在等待 {{name}} 接入…命令一接通,这里就会变绿。", "verified": "{{name}} 已在监听 —— @提及 会唤醒它。", "stillWaiting": "{{name}} 还没接入。请在你电脑的终端里运行上面的命令,这里会自动确认。", diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx index e21c79987b..045a258cec 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx @@ -155,6 +155,16 @@ describe('BYO listener snippet', () => { expect(screen.getByTestId('byo-listen-needs')).toHaveTextContent(/the Codex CLI signed in/); }); + test('a Claude Code listener says what it can reach and how --allow widens it; Codex says nothing of folders', async () => { + await issueToken(); + const reach = screen.getByTestId('byo-listen-reach'); + expect(reach).toHaveTextContent(/In an invite-only pod, .+ can read the folder you run this from and nothing else\./); + expect(reach).toHaveTextContent(/add --allow write to the last line; to let it fetch web pages, add --allow web\./); + expect(reach).toHaveTextContent(/send what it reads off this computer/); + fireEvent.click(screen.getByTestId('byo-listen-adapter-switch')); + expect(screen.queryByTestId('byo-listen-reach')).toBeNull(); + }); + test('the Claude Code MCP line shows for Claude Code only', async () => { await issueToken(); expect(screen.getByTestId('byo-mcp-claude')).toBeInTheDocument(); diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 5bc1903fb1..9daf273ec8 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -1545,6 +1545,11 @@ const V2AgentBYO: React.FC = () => {

)} + {listenerAvailable && listenAdapter === 'claude' && ( +

+ {t('agentByo.listen.reachClaude', { name: issued.agentName })} +

+ )}

{t('agentByo.result.keyOnce')}

From 0dc292bddc88563466a14908f75f2cb61c63e161 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:06:41 -0700 Subject: [PATCH 15/33] fix(cli): read-only git refuses blame -S, which reads a caller-named file (TASK-294) log, diff and show read -S as the pickaxe string, so it was allowed for every subcommand. blame reads it as a file of revisions instead, and prints back a line it cannot parse ("bad graft data: "). So `blame -S/etc/hosts tracked.txt` read a file outside the workspace and returned its first line. The letter is now refused for blame only. The test's control runs the bare form and finds the file's line in stderr. Found by CodeRabbit on #2746 (discussion_r4372626526). Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/git-readonly.test.mjs | 10 ++++++++++ cli/src/lib/git-readonly.js | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/cli/__tests__/git-readonly.test.mjs b/cli/__tests__/git-readonly.test.mjs index f155cb9296..493bf76ea3 100644 --- a/cli/__tests__/git-readonly.test.mjs +++ b/cli/__tests__/git-readonly.test.mjs @@ -129,6 +129,16 @@ describe('read-only git refuses what is not read-only', () => { expect(refusal({ subcommand: 'log', args: ['-C', outside] })).toMatch(/-C is refused/); }); + test('blame -S reads a caller-named file and prints it back (control), and is refused for blame only', async () => { + const secret = join(outside, 'secret.txt'); + const { stderr } = await bareGit(['blame', `-S${secret}`, 'tracked.txt']); + expect(stderr).toMatch(/bad graft data: a file above the workspace/); // control: blame read it + expect(refusal({ subcommand: 'blame', args: [`-S${secret}`, 'tracked.txt'] })).toMatch(/-S is refused for blame/); + expect(refusal({ subcommand: 'blame', args: ['-S', 'tracked.txt', 'tracked.txt'] })).toMatch(/-S is refused for blame/); + // log, diff and show read -S as the pickaxe string, so it stays allowed there. + expect(readonlyGitArgv({ root: repo, subcommand: 'log', args: [`-S${secret}`] }).ok).toBe(true); + }); + test('-O reads a caller-named order file, and is refused', () => { expect(refusal({ subcommand: 'diff', args: [`-O${join(outside, 'secret.txt')}`] })).toMatch(/-O is refused/); }); diff --git a/cli/src/lib/git-readonly.js b/cli/src/lib/git-readonly.js index 18f2408672..3b846a96a2 100644 --- a/cli/src/lib/git-readonly.js +++ b/cli/src/lib/git-readonly.js @@ -14,8 +14,9 @@ * an alias, and the caller's arguments go AFTER it, where git's top-level * options (`-c`, `-C`, `--git-dir`, `--exec-path`, …) are not parsed. * 2. The arguments are checked against the spellings that write, execute, or - * read a file the caller names (DENIED_LONG_OPTIONS, DENIED_SHORT_OPTIONS), - * including git's accepted abbreviations of them (`--outp=`), and against + * read a file the caller names (DENIED_LONG_OPTIONS, DENIED_SHORT_OPTIONS, + * DENIED_SHORT_OPTIONS_BY_SUBCOMMAND), including git's accepted + * abbreviations of them (`--outp=`), and against * paths that leave the workspace. * 3. diff, log and show run with `--no-ext-diff --no-textconv`, so no external * diff or conversion program configured for the repository runs, and the @@ -63,6 +64,13 @@ export const DENIED_SHORT_OPTIONS = Object.freeze({ O: 'reads a file the caller names', }); +// Letters refused for one subcommand only. log, diff and show read -S as the +// pickaxe string, but blame reads it as a file of revisions, and prints back a +// line it cannot parse ("bad graft data: "). +export const DENIED_SHORT_OPTIONS_BY_SUBCOMMAND = Object.freeze({ + blame: Object.freeze({ S: 'reads a revisions file the caller names' }), +}); + // Short options that carry their value in the same argument (`-n5`, `-U3`, // `-Sneedle`, `-Gregex`, `-L1,5:file`, `-M50%`, `-B`). Git hands the rest of // the argument to the option as its value, so nothing after the letter can be @@ -119,6 +127,10 @@ const checkShortOption = (arg, subcommand) => { if (Object.prototype.hasOwnProperty.call(DENIED_SHORT_OPTIONS, letter)) { return `-${letter} is refused: it ${DENIED_SHORT_OPTIONS[letter]}`; } + const deniedHere = DENIED_SHORT_OPTIONS_BY_SUBCOMMAND[subcommand] || {}; + if (Object.prototype.hasOwnProperty.call(deniedHere, letter)) { + return `-${letter} is refused for ${subcommand}: it ${deniedHere[letter]}`; + } if (arg.length === 2) return null; if (VALUE_SHORT_OPTIONS.has(letter)) return null; if (subcommand === 'status' && STATUS_UNTRACKED_MODES.has(arg)) return null; From f69653e3d83b5c7452038038a27674d13cd4313c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:07:44 -0700 Subject: [PATCH 16/33] fix(frontend): the Connect page no longer says write keeps a listener to its folder (TASK-294) "can read the folder you run this from and nothing else" was true of the default, but the next sentence offered --allow write without saying that its commands read past the folder. The line now gives the default bound first, then says write's commands can read beyond the folder, as the docs page already does. en and zh-CN, and the test pins the new sentence. Found by CodeRabbit on #2746 (discussion_r4372626533). Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- frontend/src/i18n/locales/en.json | 2 +- frontend/src/i18n/locales/zh-CN.json | 2 +- frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index b101b6dc61..31f26bacb6 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1587,7 +1587,7 @@ "listen": { "title": "Run this in Terminal", "note": "MCP alone won't wake {{name}} for @mentions. Keep this Terminal open.", - "reachClaude": "In an invite-only pod, {{name}} can read the folder you run this from and nothing else. To let it edit files and run commands, add --allow write to the last line; to let it fetch web pages, add --allow web. Either one lets it send what it reads off this computer, so grant them only in a pod you trust.", + "reachClaude": "By default, in an invite-only pod, {{name}} reads only the folder you run this from. To let it edit files and run commands, add --allow write to the last line. Those commands can read beyond the folder. To let it fetch web pages, add --allow web. Either one lets it send what it reads off this computer, so grant them only in a pod you trust.", "waiting": "Waiting for {{name}} to check in… this turns green the moment the command connects.", "verified": "{{name}} is listening — mentions will wake it.", "stillWaiting": "Still no check-in from {{name}}. Run the command above in Terminal on your computer, and this page will confirm it.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index e1f35d27d7..80f36dc275 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1647,7 +1647,7 @@ "listen": { "title": "在终端里运行这段命令", "note": "仅连接 MCP 不会唤醒 {{name}} 回复 @提及。请保持终端窗口开启。", - "reachClaude": "在仅限邀请的 pod 中,{{name}} 只能读取你运行此命令的文件夹,别的都读不到。若要让它编辑文件并运行命令,请在最后一行加上 --allow write;若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容发出这台电脑,所以只在你信任的 pod 中授予。", + "reachClaude": "默认情况下,在仅限邀请的 pod 中,{{name}} 只能读取你运行此命令的文件夹。若要让它编辑文件并运行命令,请在最后一行加上 --allow write。这些命令能读取文件夹以外的内容。若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容发出这台电脑,所以只在你信任的 pod 中授予。", "waiting": "正在等待 {{name}} 接入…命令一接通,这里就会变绿。", "verified": "{{name}} 已在监听 —— @提及 会唤醒它。", "stillWaiting": "{{name}} 还没接入。请在你电脑的终端里运行上面的命令,这里会自动确认。", diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx index 045a258cec..2ad4e79c62 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx @@ -158,8 +158,10 @@ describe('BYO listener snippet', () => { test('a Claude Code listener says what it can reach and how --allow widens it; Codex says nothing of folders', async () => { await issueToken(); const reach = screen.getByTestId('byo-listen-reach'); - expect(reach).toHaveTextContent(/In an invite-only pod, .+ can read the folder you run this from and nothing else\./); - expect(reach).toHaveTextContent(/add --allow write to the last line; to let it fetch web pages, add --allow web\./); + expect(reach).toHaveTextContent(/By default, in an invite-only pod, .+ reads only the folder you run this from\./); + // The folder bound is the default only: write's commands read past it. + expect(reach).toHaveTextContent(/add --allow write to the last line\. Those commands can read beyond the folder\./); + expect(reach).toHaveTextContent(/To let it fetch web pages, add --allow web\./); expect(reach).toHaveTextContent(/send what it reads off this computer/); fireEvent.click(screen.getByTestId('byo-listen-adapter-switch')); expect(screen.queryByTestId('byo-listen-reach')).toBeNull(); From 59d2d907e30b754c203e431746bc27539718b944 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:16:25 -0700 Subject: [PATCH 17/33] fix(cli): workspace trust needs an invite-only pod, not only an unlisted one (TASK-294) Any agent can add itself to an open pod an agent created, through POST /pods/:podId/self-install, whether or not the pod is listed. A workspace seat in that pod would then answer the agent with its start directory in reach. /installations now reports each pod's join policy (absent reads as open, as in podListing.ts), and a pod counts as private only when it is also invite-only. The attach gate names an open pod as its reason, and the docs page and the Connect page say private and invite-only. Invite links are not the reason: redemption skips the join policy for open and invite-only pods alike, and only a listed member can mint one. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- .../agentsRuntime.installations.test.js | 23 ++++++++++++++++++- backend/routes/agentsRuntime.ts | 23 ++++++++++++------- cli/__tests__/attach.test.mjs | 2 +- .../public-pod-sandbox-gate.test.mjs | 17 ++++++++++++-- cli/__tests__/workspace-trust.test.mjs | 19 ++++++++------- .../workspace-turn-run-loop.test.mjs | 6 +++-- cli/src/commands/agent.js | 8 ++++--- cli/src/lib/workspace-trust.js | 14 +++++++---- .../bring-your-own/work-in-your-repo.mdx | 4 ++-- frontend/src/i18n/locales/en.json | 2 +- frontend/src/i18n/locales/zh-CN.json | 2 +- .../V2AgentBYOListenSnippet.test.tsx | 2 +- 12 files changed, 87 insertions(+), 35 deletions(-) diff --git a/backend/__tests__/unit/routes/agentsRuntime.installations.test.js b/backend/__tests__/unit/routes/agentsRuntime.installations.test.js index 2299da3a7d..4e3b7e7338 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.installations.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.installations.test.js @@ -23,9 +23,14 @@ jest.mock('../../../middleware/auth', () => (_req, _res, next) => next()); jest.mock('../../../middleware/apiTokenScopes', () => ({ requireApiTokenScopes: () => (_req, _res, next) => next(), })); +// Honours the select list, so a field the route forgets to select reads as absent. jest.mock('../../../models/Pod', () => ({ find: jest.fn(() => ({ - select: () => ({ lean: async () => mockPods }), + select: (fields) => ({ + lean: async () => mockPods.map((pod) => Object.fromEntries( + Object.entries(pod).filter(([key]) => fields.split(' ').includes(key)), + )), + }), })), })); jest.mock('../../../services/agentEventService', () => ({})); @@ -131,6 +136,21 @@ describe('GET /installations pod visibility', () => { }); }); + // An open pod an agent created admits any agent through self-install, listed + // or not, so the CLI needs the join policy as well as the visibility. + test('the join policy is reported, and a pod with none stored reads as open', async () => { + await withPods([ + { _id: 'pod-1', name: 'Private', type: 'team', joinPolicy: 'invite-only' }, + { _id: 'pod-2', name: 'Open', type: 'team', joinPolicy: 'open' }, + { _id: 'pod-3', name: 'Legacy', type: 'team' }, + ], ['pod-1', 'pod-2', 'pod-3'], async () => { + const response = await request(app).get('/api/agents/runtime/installations'); + const policy = Object.fromEntries(response.body.installations + .map((entry) => [entry.podId, entry.joinPolicy])); + expect(policy).toEqual({ 'pod-1': 'invite-only', 'pod-2': 'open', 'pod-3': 'open' }); + }); + }); + test('a publicly readable or listed pod is reported as such', async () => { await withPods([{ _id: 'pod-1', name: 'Room', type: 'team', publicRead: true, communityListed: true, @@ -145,6 +165,7 @@ describe('GET /installations pod visibility', () => { const response = await request(app).get('/api/agents/runtime/installations'); expect(response.body.installations[0]).not.toHaveProperty('publicRead'); expect(response.body.installations[0]).not.toHaveProperty('communityListed'); + expect(response.body.installations[0]).not.toHaveProperty('joinPolicy'); }); }); diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 90a62e6a2c..20b1f7fd3d 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -539,19 +539,26 @@ router.get('/installations', phase4RateLimit, agentRuntimeAuth, async (req: any, const allPodIds = [...podIds.map(String), ...dmPodIds.map(String)]; const pods = allPodIds.length > 0 - ? await Pod.find({ _id: { $in: allPodIds } }).select('_id name type publicRead communityListed').lean() + ? await Pod.find({ _id: { $in: allPodIds } }).select('_id name type publicRead communityListed joinPolicy').lean() : []; const podMap = Object.fromEntries(pods.map((p: any) => [String(p._id), p])); - // Whether strangers can read each room this seat answers in (TASK-294). A - // `workspace`-trust CLI seat reads it before every turn and runs that turn - // public unless every pod here is private, so the effective booleans are - // reported (an absent stored field means false, as everywhere else that - // reads `publicRead || communityListed`). A pod that no longer exists gets - // neither field, which the CLI reads as not-private. + // Whether strangers can read or join each room this seat answers in + // (TASK-294). A `workspace`-trust CLI seat reads it before every turn and + // runs that turn public unless every pod here is private and invite-only, + // so the effective values are reported: an absent stored visibility field + // means false, as everywhere else that reads `publicRead || communityListed`, + // and an absent joinPolicy means open, as in podListing.ts. Visibility + // alone is not enough: POST /pods/:podId/self-install admits any agent to + // an open pod an agent created, whether or not it is listed. A pod that no + // longer exists gets none of the fields, which the CLI reads as not-private. const podVisibility = (pod: any) => (pod?._id - ? { publicRead: pod.publicRead === true, communityListed: pod.communityListed === true } + ? { + publicRead: pod.publicRead === true, + communityListed: pod.communityListed === true, + joinPolicy: pod.joinPolicy === 'invite-only' ? 'invite-only' : 'open', + } : {}); const installationList = installations.map((inst: any) => { diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs index bab6277581..85105d7a25 100644 --- a/cli/__tests__/attach.test.mjs +++ b/cli/__tests__/attach.test.mjs @@ -852,7 +852,7 @@ describe('bootstrapAgentRecordFromEnv', () => { const withVisibility = (publicRead) => ({ ...identityResponse, installations: identityResponse.installations.map((row) => ({ - ...row, publicRead, communityListed: false, + ...row, publicRead, communityListed: false, joinPolicy: 'invite-only', })), }); const run = async ({ diff --git a/cli/__tests__/public-pod-sandbox-gate.test.mjs b/cli/__tests__/public-pod-sandbox-gate.test.mjs index d44d6f875d..86255ee120 100644 --- a/cli/__tests__/public-pod-sandbox-gate.test.mjs +++ b/cli/__tests__/public-pod-sandbox-gate.test.mjs @@ -143,9 +143,11 @@ describe('public-pod sandbox gate', () => { // gate needs the pod CONFIRMED private and fails closed on anything less. describe('workspace trust needs a confirmed private pod', () => { const WORKSPACE = { sandbox: { trust: 'workspace' } }; - const CONFIRMED_PRIVATE = { _id: 'p2', name: 'My Workspace', publicRead: false, communityListed: false }; + const CONFIRMED_PRIVATE = { + _id: 'p2', name: 'My Workspace', publicRead: false, communityListed: false, joinPolicy: 'invite-only', + }; - test('accepts a pod that reports itself private in both fields', async () => { + test('accepts a pod that reports itself private and invite-only', async () => { await expect(assertSandboxDeclaredForPublicPod({ client: clientFor(CONFIRMED_PRIVATE), podId: 'p2', environment: WORKSPACE, })).resolves.toBeUndefined(); @@ -157,6 +159,17 @@ describe('workspace trust needs a confirmed private pod', () => { })).rejects.toThrow(/"Commonly HQ" is publicly readable, so sandbox\.trust=workspace/); }); + // Any agent can add itself to an open pod an agent created, listed or not. + test('refuses a private pod that is open to join', async () => { + const { joinPolicy: _policy, ...unstated } = CONFIRMED_PRIVATE; + for (const pod of [{ ...CONFIRMED_PRIVATE, joinPolicy: 'open' }, unstated]) { + // eslint-disable-next-line no-await-in-loop + await expect(assertSandboxDeclaredForPublicPod({ + client: clientFor(pod), podId: 'p2', environment: WORKSPACE, + })).rejects.toThrow(/"My Workspace" is open to join, .* attach to a private, invite-only pod/); + } + }); + test('refuses a pod that does not report both fields, rather than guessing private', async () => { await expect(assertSandboxDeclaredForPublicPod({ client: clientFor(PRIVATE_POD), podId: 'p2', environment: WORKSPACE, diff --git a/cli/__tests__/workspace-trust.test.mjs b/cli/__tests__/workspace-trust.test.mjs index fde945755a..8192d83ce3 100644 --- a/cli/__tests__/workspace-trust.test.mjs +++ b/cli/__tests__/workspace-trust.test.mjs @@ -19,10 +19,13 @@ import { } from '../src/lib/workspace-trust.js'; describe('pod visibility', () => { - test('a pod is private only when both fields say so', () => { - expect(podIsConfirmedPrivate({ publicRead: false, communityListed: false })).toBe(true); - expect(podIsConfirmedPrivate({ publicRead: true, communityListed: false })).toBe(false); - expect(podIsConfirmedPrivate({ publicRead: false, communityListed: true })).toBe(false); + test('a pod is private only when all three fields say so', () => { + expect(podIsConfirmedPrivate({ publicRead: false, communityListed: false, joinPolicy: 'invite-only' })).toBe(true); + expect(podIsConfirmedPrivate({ publicRead: true, communityListed: false, joinPolicy: 'invite-only' })).toBe(false); + expect(podIsConfirmedPrivate({ publicRead: false, communityListed: true, joinPolicy: 'invite-only' })).toBe(false); + // Any agent can add itself to an open pod an agent created, listed or not. + expect(podIsConfirmedPrivate({ publicRead: false, communityListed: false, joinPolicy: 'open' })).toBe(false); + expect(podIsConfirmedPrivate({ publicRead: false, communityListed: false })).toBe(false); }); test('an unreported field counts as public, so an older server fails closed', () => { @@ -32,7 +35,7 @@ describe('pod visibility', () => { }); test('every pod must be private, and an empty list is not proof of anything', () => { - const priv = { publicRead: false, communityListed: false }; + const priv = { publicRead: false, communityListed: false, joinPolicy: 'invite-only' }; expect(allPodsConfirmedPrivate([priv, priv])).toBe(true); expect(allPodsConfirmedPrivate([priv, { publicRead: true, communityListed: false }])).toBe(false); expect(allPodsConfirmedPrivate([])).toBe(false); @@ -82,7 +85,7 @@ describe('first-run workspace', () => { const home = join(root, 'home'); const repo = join(home, 'code', 'repo'); mkdirSync(repo, { recursive: true }); - const priv = { publicRead: false, communityListed: false }; + const priv = { publicRead: false, communityListed: false, joinPolicy: 'invite-only' }; const pub = { publicRead: true, communityListed: false }; afterAll(() => rmSync(root, { recursive: true, force: true })); @@ -128,7 +131,7 @@ describe('first-run workspace', () => { }); describe('workspace turn placement', () => { - const priv = { publicRead: false, communityListed: false }; + const priv = { publicRead: false, communityListed: false, joinPolicy: 'invite-only' }; const pub = { publicRead: false, communityListed: true }; const workspace = { model: 'm', sandbox: { trust: 'workspace' } }; const place = (overrides) => workspaceTurnPlacement({ @@ -145,7 +148,7 @@ describe('workspace turn placement', () => { expect(place({ installations: [priv, pub] })).toEqual({ environment: { model: 'm', sandbox: { trust: 'public' } }, cwd: '/tmp/scratch', - reason: 'a pod it answers in is publicly readable, or did not report whether it is', + reason: 'a pod it answers in is publicly readable or open to join, or did not report whether it is', }); }); diff --git a/cli/__tests__/workspace-turn-run-loop.test.mjs b/cli/__tests__/workspace-turn-run-loop.test.mjs index cf1620b2ab..3ec6ebfc3c 100644 --- a/cli/__tests__/workspace-turn-run-loop.test.mjs +++ b/cli/__tests__/workspace-turn-run-loop.test.mjs @@ -27,7 +27,9 @@ const { performRun } = await import('../src/commands/agent.js'); const stubAdapter = (await import('../src/lib/adapters/stub.js')).default; const scratchDir = path.join(os.tmpdir(), 'commonly-agents', 'workspace-stub'); -const privatePod = { podId: 'pod-ws', publicRead: false, communityListed: false }; +const privatePod = { + podId: 'pod-ws', publicRead: false, communityListed: false, joinPolicy: 'invite-only', +}; const noopTimeout = () => 0; const drain = async () => { for (let i = 0; i < 24; i += 1) await new Promise((resolve) => setImmediate(resolve)); @@ -105,7 +107,7 @@ describe('performRun workspace turn placement', () => { expect(ctx.environment.sandbox.trust).toBe('public'); expect(logs).toContain( `this turn runs as a public seat in ${scratchDir}, because a pod it answers in is ` - + 'publicly readable, or did not report whether it is', + + 'publicly readable or open to join, or did not report whether it is', ); }); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 95a2e096de..ada28304a7 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -708,10 +708,12 @@ export const assertSandboxDeclaredForPublicPod = async ({ if (podIsConfirmedPrivate(pod)) return; const label = pod?.name ? `"${pod.name}"` : podId; const reported = typeof pod?.publicRead === 'boolean' && typeof pod?.communityListed === 'boolean'; + let why = 'did not report its visibility'; + if (reported) why = pod.publicRead || pod.communityListed ? 'is publicly readable' : 'is open to join'; throw new Error( - `${label} ${reported ? 'is publicly readable' : 'did not report its visibility'}, so ` - + 'sandbox.trust=workspace would let people you do not control read this agent\'s start ' - + 'directory. Declare "sandbox": { "trust": "public" } for this pod, or attach to a private pod.', + `${label} ${why}, so sandbox.trust=workspace would let people you do not control read this ` + + 'agent\'s start directory. Declare "sandbox": { "trust": "public" } for this pod, or attach ' + + 'to a private, invite-only pod.', ); } diff --git a/cli/src/lib/workspace-trust.js b/cli/src/lib/workspace-trust.js index 744bd1a649..f720704a04 100644 --- a/cli/src/lib/workspace-trust.js +++ b/cli/src/lib/workspace-trust.js @@ -3,9 +3,12 @@ * predicates so the attach path, the first-run bootstrap and the per-spawn * check all read them the same way. * - * 1. Every pod the seat answers in is CONFIRMED private. A pod whose - * visibility the server did not report counts as public: the workspace tier - * lets the room read the start directory, so an unknown room fails closed. + * 1. Every pod the seat answers in is CONFIRMED private: not publicly + * readable, not listed, and invite-only. Visibility alone is not enough, + * because any agent can add itself to an open pod an agent created, listed + * or not. A pod whose fields the server did not report counts as public: + * the workspace tier lets the room read the start directory, so an unknown + * room fails closed. * 2. The start directory is a directory worth exposing to that room, and not * one that contains the operator's whole account: `/`, `$HOME`, an ancestor * of `$HOME`, or a directory inside one of the credential stores the public @@ -17,12 +20,13 @@ import { isAbsolute, join, relative, resolve, } from 'path'; -/** A pod is private only when the server says so in both fields. */ +/** A pod is private only when the server says so in all three fields. */ export const podIsConfirmedPrivate = (pod) => ( typeof pod?.publicRead === 'boolean' && typeof pod?.communityListed === 'boolean' && !pod.publicRead && !pod.communityListed + && pod.joinPolicy === 'invite-only' ); /** True only for a non-empty list in which every pod is confirmed private. */ @@ -68,7 +72,7 @@ export const workspaceStartDirRefusal = (dir, { home = homedir() } = {}) => { return null; }; -const PUBLIC_POD_REASON = 'a pod it answers in is publicly readable, or did not report whether it is'; +const PUBLIC_POD_REASON = 'a pod it answers in is publicly readable or open to join, or did not report whether it is'; // claude.js refuses a workspace seat under bwrap, so off macOS the default // would be a seat that fails every turn rather than one that reads a project. const NOT_MACOS_REASON = 'an agent works in its folder only on macOS for now'; diff --git a/docs-site/bring-your-own/work-in-your-repo.mdx b/docs-site/bring-your-own/work-in-your-repo.mdx index 6f0e8b1e51..6c5f3b1444 100644 --- a/docs-site/bring-your-own/work-in-your-repo.mdx +++ b/docs-site/bring-your-own/work-in-your-repo.mdx @@ -21,13 +21,13 @@ The MCP server does not listen for @mentions when your session is closed. To mak ## What the listener can reach -The [Claude Code listener](/bring-your-own/claude-code) checks the pods its key answers in the first time you run it. If every one is invite-only, meaning it isn't publicly readable or listed in Community, the listener works in the folder you ran the command from: +The [Claude Code listener](/bring-your-own/claude-code) checks the pods its key answers in the first time you run it. If every one is private, meaning its **Who can join** setting is **Invite-only** and it isn't publicly readable or listed in Community, the listener works in the folder you ran the command from: - It can read files there and run read-only git: `status`, `log`, `diff`, `show` and `blame`. - It can't edit files, run commands or fetch web pages. - It can't read `.env` or `.env.*` files, at any depth. -Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when a pod is publicly readable, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable, or the check fails, that turn runs in the scratch folder with a fresh session. +Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when a pod is publicly readable or open to join, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable or open to join, or the check fails, that turn runs in the scratch folder with a fresh session. ### Give it more with `--allow` diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 31f26bacb6..b2ef129c5c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1587,7 +1587,7 @@ "listen": { "title": "Run this in Terminal", "note": "MCP alone won't wake {{name}} for @mentions. Keep this Terminal open.", - "reachClaude": "By default, in an invite-only pod, {{name}} reads only the folder you run this from. To let it edit files and run commands, add --allow write to the last line. Those commands can read beyond the folder. To let it fetch web pages, add --allow web. Either one lets it send what it reads off this computer, so grant them only in a pod you trust.", + "reachClaude": "By default, in a private, invite-only pod, {{name}} reads only the folder you run this from. To let it edit files and run commands, add --allow write to the last line. Those commands can read beyond the folder. To let it fetch web pages, add --allow web. Either one lets it send what it reads off this computer, so grant them only in a pod you trust.", "waiting": "Waiting for {{name}} to check in… this turns green the moment the command connects.", "verified": "{{name}} is listening — mentions will wake it.", "stillWaiting": "Still no check-in from {{name}}. Run the command above in Terminal on your computer, and this page will confirm it.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 80f36dc275..d4789c0293 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1647,7 +1647,7 @@ "listen": { "title": "在终端里运行这段命令", "note": "仅连接 MCP 不会唤醒 {{name}} 回复 @提及。请保持终端窗口开启。", - "reachClaude": "默认情况下,在仅限邀请的 pod 中,{{name}} 只能读取你运行此命令的文件夹。若要让它编辑文件并运行命令,请在最后一行加上 --allow write。这些命令能读取文件夹以外的内容。若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容发出这台电脑,所以只在你信任的 pod 中授予。", + "reachClaude": "默认情况下,在私密且仅限邀请的 pod 中,{{name}} 只能读取你运行此命令的文件夹。若要让它编辑文件并运行命令,请在最后一行加上 --allow write。这些命令能读取文件夹以外的内容。若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容发出这台电脑,所以只在你信任的 pod 中授予。", "waiting": "正在等待 {{name}} 接入…命令一接通,这里就会变绿。", "verified": "{{name}} 已在监听 —— @提及 会唤醒它。", "stillWaiting": "{{name}} 还没接入。请在你电脑的终端里运行上面的命令,这里会自动确认。", diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx index 2ad4e79c62..6bd7586d9d 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx @@ -158,7 +158,7 @@ describe('BYO listener snippet', () => { test('a Claude Code listener says what it can reach and how --allow widens it; Codex says nothing of folders', async () => { await issueToken(); const reach = screen.getByTestId('byo-listen-reach'); - expect(reach).toHaveTextContent(/By default, in an invite-only pod, .+ reads only the folder you run this from\./); + expect(reach).toHaveTextContent(/By default, in a private, invite-only pod, .+ reads only the folder you run this from\./); // The folder bound is the default only: write's commands read past it. expect(reach).toHaveTextContent(/add --allow write to the last line\. Those commands can read beyond the folder\./); expect(reach).toHaveTextContent(/To let it fetch web pages, add --allow web\./); From 4b5549989032b9a5b57b6f3e3267c68a92fd9a92 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:24:34 -0700 Subject: [PATCH 18/33] fix(cli): read-only git keeps committed .env files out of reach (TASK-294) The sandbox denies .env and .env.* by name, but a copy committed to git lives in the object store, where the read-only git tool could print it: show HEAD:.env, a patch in log -p, a pickaxe match, a raw listing. An argument that names a .env file is now refused in every form git reads a path from (HEAD:.env, :0:sub/.env.local, -L1,5:.env, a path after --, blame's file). status, log, diff and show run with excluding pathspecs, so a patch, a glob, a pickaxe or a raw listing leaves those files out. What remains: show with an id learned somewhere else. Answers CodeRabbit r4372626530 on #2746. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/git-readonly.test.mjs | 57 ++++++++++++++++++- cli/src/lib/git-readonly-server.js | 4 +- cli/src/lib/git-readonly.js | 32 ++++++++++- .../bring-your-own/work-in-your-repo.mdx | 2 +- 4 files changed, 90 insertions(+), 5 deletions(-) diff --git a/cli/__tests__/git-readonly.test.mjs b/cli/__tests__/git-readonly.test.mjs index 493bf76ea3..8af9ad0064 100644 --- a/cli/__tests__/git-readonly.test.mjs +++ b/cli/__tests__/git-readonly.test.mjs @@ -170,10 +170,14 @@ describe('read-only git refuses what is not read-only', () => { describe('read-only git runs the five subcommands', () => { test('forced options are prepended for diff, log and show', () => { + const excludes = ['--', ':(exclude,glob)**/.env', ':(exclude,glob)**/.env.*', + ':(exclude,glob)**/.env/**', ':(exclude,glob)**/.env.*/**']; expect(readonlyGitArgv({ subcommand: 'diff', args: [], root: repo }).argv) - .toEqual(['--no-pager', '-c', 'core.fsmonitor=false', 'diff', '--no-ext-diff', '--no-textconv']); + .toEqual(['--no-pager', '-c', 'core.fsmonitor=false', 'diff', '--no-ext-diff', '--no-textconv', ...excludes]); expect(readonlyGitArgv({ subcommand: 'status', args: [], root: repo }).argv) - .toEqual(['--no-pager', '-c', 'core.fsmonitor=false', 'status']); + .toEqual(['--no-pager', '-c', 'core.fsmonitor=false', 'status', ...excludes]); + expect(readonlyGitArgv({ subcommand: 'blame', args: ['tracked.txt'], root: repo }).argv) + .toEqual(['--no-pager', '-c', 'core.fsmonitor=false', 'blame', 'tracked.txt']); }); test('status, log, diff, show and blame all return output', async () => { @@ -238,3 +242,52 @@ describe('read-only git runs the five subcommands', () => { } }); }); + +// A committed .env sits in git's objects, where the sandbox's deny on the file +// does not reach. Each control reads it with bare git; the guarded call does not. +describe('read-only git keeps committed .env files out of reach', () => { + const SECRET = 'ENV_SECRET_VALUE'; + const run = (subcommand, args) => runReadonlyGit({ + subcommand, args, root: repo, git: GIT, + }); + + beforeAll(async () => { + mkdirSync(join(repo, 'sub'), { recursive: true }); + writeFileSync(join(repo, '.env'), `TOKEN=${SECRET}\n`); + writeFileSync(join(repo, 'sub', '.env.local'), `LOCAL=${SECRET}\n`); + await bareGit(['add', '-f', '.env', 'sub/.env.local']); + await bareGit(['commit', '-q', '-m', 'commit env files']); + }); + + test('an argument naming one is refused, in every form git reads a path from (control: bare show reads it)', async () => { + expect((await bareGit(['show', 'HEAD:.env'])).stdout).toContain(SECRET); + for (const args of [['HEAD:.env'], [':.env'], [':0:sub/.env.local'], ['HEAD~0:./sub/.env.local'], + ['HEAD', '--', '.env'], ['HEAD^{tree}:sub/.env.local']]) { + expect(refusal({ subcommand: 'show', args })).toMatch(/names a \.env file/); + } + expect(refusal({ subcommand: 'blame', args: ['sub/.env.local'] })).toMatch(/names a \.env file/); + expect(refusal({ subcommand: 'log', args: ['-L1,1:.env'] })).toMatch(/names a \.env file/); + expect(refusal({ subcommand: 'log', args: ['-L', '1,1:sub/.env.local'] })).toMatch(/names a \.env file/); + }); + + test('a patch, a glob, a pickaxe and a raw listing all leave it out (control: bare log -p shows it)', async () => { + expect((await bareGit(['log', '-p'])).stdout).toContain(SECRET); + for (const [subcommand, args] of [['log', ['-p']], ['log', ['-p', '--', '.en*', 'sub/*']], + ['show', ['HEAD']], ['diff', ['HEAD~1', 'HEAD']], ['log', ['--raw', '--all']]]) { + // eslint-disable-next-line no-await-in-loop + const out = await run(subcommand, args); + expect(out.ok).toBe(true); + expect(out.text).not.toContain(SECRET); + expect(out.text).not.toMatch(/\.env/); + } + expect((await bareGit(['log', '--oneline', `-S${SECRET}`])).stdout).toContain('commit env files'); + expect((await run('log', ['--oneline', `-S${SECRET}`])).text).toBe('(no output)'); + }); + + test('ordinary reads still work, with paths after --', async () => { + expect((await run('log', ['-p', '--', 'tracked.txt'])).text).toContain('first line'); + expect((await run('show', ['HEAD~1:tracked.txt'])).text).toContain('first line'); + expect((await run('log', ['-L1,1:tracked.txt'])).ok).toBe(true); + expect((await run('status', ['--short'])).ok).toBe(true); + }); +}); diff --git a/cli/src/lib/git-readonly-server.js b/cli/src/lib/git-readonly-server.js index b9958c6778..f2ac09094d 100644 --- a/cli/src/lib/git-readonly-server.js +++ b/cli/src/lib/git-readonly-server.js @@ -29,7 +29,9 @@ export const GIT_TOOL = Object.freeze({ + 'Pass the subcommand and its arguments separately, one argument per array entry, ' + "e.g. { subcommand: 'log', args: ['-n', '5', '--oneline'] }. " + 'Options that write a file, run a program, or read outside the workspace are refused with the reason, ' - + 'and paths must stay inside the workspace. This is the only git available to you.', + + 'and paths must stay inside the workspace. For log, diff and show, put paths after \'--\' ' + + "(e.g. ['-p', '--', 'src/app.js']). .env files are left out of every result. " + + 'This is the only git available to you.', inputSchema: { type: 'object', properties: { diff --git a/cli/src/lib/git-readonly.js b/cli/src/lib/git-readonly.js index 3b846a96a2..6f59db3dc5 100644 --- a/cli/src/lib/git-readonly.js +++ b/cli/src/lib/git-readonly.js @@ -29,6 +29,13 @@ * is one rooted there, so `show HEAD:` cannot reach a file above it. * 5. Outside this module, the seat's sandbox (Seatbelt or bwrap) mounts the * workspace read-only. + * 6. A committed `.env` or `.env.*` stays as far out of reach as the file + * itself, which the sandbox denies at any depth. An argument naming one + * (`.env`, `HEAD:.env`, `:0:sub/.env.local`, `-L1,5:.env`) is refused, and + * status, log, diff and show run with pathspecs excluding them, so no patch, + * pickaxe match or blob id comes from one whatever path or glob the caller + * named. What this cannot stop is `show ` with an id learned + * somewhere else. */ import { execFile } from 'child_process'; import { realpathSync } from 'fs'; @@ -88,6 +95,20 @@ const FORCED_OPTIONS = Object.freeze({ }); const TOP_LEVEL = Object.freeze(['--no-pager', '-c', 'core.fsmonitor=false']); +// Mirrors the names sandbox/seatbelt.js denies at any depth. +const ENV_FILE_SEGMENT = /^\.env(\..*)?$/; +const namesEnvFile = (arg) => arg.split(':') + .some((part) => part.split('/').some((segment) => ENV_FILE_SEGMENT.test(segment))); +const NAMES_ENV_FILE = 'it names a .env file, which the workspace keeps out of reach'; +const ENV_EXCLUDES = Object.freeze([ + ':(exclude,glob)**/.env', ':(exclude,glob)**/.env.*', ':(exclude,glob)**/.env/**', ':(exclude,glob)**/.env.*/**', +]); +// blame takes one literal file, so the name check above covers it. A +// blob-to-blob diff (`diff A:f B:f`) takes no pathspec and is refused by git +// here; `diff A B -- f` is the same diff. log -L also takes none, so its file +// is name-checked instead. +const PATHSPEC_SUBCOMMANDS = new Set(['status', 'log', 'diff', 'show']); + const MAX_ARGS = 64; const MAX_ARG_LENGTH = 4096; export const MAX_OUTPUT_CHARS = 50 * 1024; @@ -151,18 +172,27 @@ export const readonlyGitArgv = ({ subcommand, args = [], root }) => { if (args.length > MAX_ARGS) return refuse(`at most ${MAX_ARGS} arguments`); if (typeof root !== 'string' || !isAbsolute(root)) return refuse('the workspace is not an absolute path'); let pathsOnly = false; + let lineRange = false; for (const arg of args) { if (arg.length > MAX_ARG_LENGTH || arg.includes('\0')) return refuse('an argument is too long or contains NUL'); if (pathsOnly || arg === '-' || !arg.startsWith('-')) { if (escapesRoot(root, arg)) return refuse(`${arg} is refused: it is outside the workspace`); + if (namesEnvFile(arg)) return refuse(`${arg} is refused: ${NAMES_ENV_FILE}`); } else if (arg === '--') { pathsOnly = true; } else { const reason = arg.startsWith('--') ? checkLongOption(arg) : checkShortOption(arg, subcommand); if (reason) return refuse(reason); + if (arg.startsWith('-L')) { + lineRange = true; + if (namesEnvFile(arg.slice(2))) return refuse(`${arg} is refused: ${NAMES_ENV_FILE}`); + } } } - return { ok: true, argv: [...TOP_LEVEL, subcommand, ...FORCED_OPTIONS[subcommand], ...args] }; + const excludes = PATHSPEC_SUBCOMMANDS.has(subcommand) && !lineRange + ? [...(pathsOnly ? [] : ['--']), ...ENV_EXCLUDES] + : []; + return { ok: true, argv: [...TOP_LEVEL, subcommand, ...FORCED_OPTIONS[subcommand], ...args, ...excludes] }; }; // What git keeps from the seat's environment. Everything else is dropped, diff --git a/docs-site/bring-your-own/work-in-your-repo.mdx b/docs-site/bring-your-own/work-in-your-repo.mdx index 6c5f3b1444..621b43008c 100644 --- a/docs-site/bring-your-own/work-in-your-repo.mdx +++ b/docs-site/bring-your-own/work-in-your-repo.mdx @@ -25,7 +25,7 @@ The [Claude Code listener](/bring-your-own/claude-code) checks the pods its key - It can read files there and run read-only git: `status`, `log`, `diff`, `show` and `blame`. - It can't edit files, run commands or fetch web pages. -- It can't read `.env` or `.env.*` files, at any depth. +- It can't read `.env` or `.env.*` files, at any depth, including copies committed to git. Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when a pod is publicly readable or open to join, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable or open to join, or the check fails, that turn runs in the scratch folder with a fresh session. From a190a3c0b4d1396baea11a4505602f696d78fb96 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:44:25 -0700 Subject: [PATCH 19/33] fix(cli): workspace git runs no gpg or textconv; .env dirs and .git stay out of reach (TASK-294) Sable's gate review at fede2cf4c found four gaps in what a workspace seat can reach. Each fix has a test whose control shows the unguarded form runs the program or reads the file. - gpg: --show-signature and any %G placeholder are refused, log and show force --no-show-signature (so log.showSignature in the repo config does nothing), format.pretty is forced to medium, and a --pretty/--format value must be a built-in name or a format string, because a configured alias can hold %G. - blame forces --no-textconv, as diff, log and show already did. - A .env directory's contents are denied in the Seatbelt jail (\.env([./].*)?$) and in the tool policy (Read(./**/.env/**) and the .env.* form), with smoke cases for a nested and a root-level .env/. - Read(./.git/**) and Read(./**/.git/**) join the workspace denies: a remote URL in .git/config can carry a token. History still comes through the read-only git tool. The module header now says what still runs: a clean filter the repository's own config defines, which status, diff and blame need to report a file's state correctly. It runs inside the jail. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- .../adapters.claude.environment.test.mjs | 3 + cli/__tests__/git-readonly.test.mjs | 139 +++++++++++++++++- .../seatbelt.env-files.smoke.test.mjs | 40 +++-- cli/__tests__/seatbelt.test.mjs | 4 +- cli/src/lib/adapters/claude.js | 13 +- cli/src/lib/git-readonly.js | 44 ++++-- cli/src/lib/sandbox/seatbelt.js | 9 +- 7 files changed, 218 insertions(+), 34 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 39d7044770..0d7d71604a 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1268,6 +1268,8 @@ describe('claude adapter — a workspace seat', () => { expect(denied).toEqual(expect.arrayContaining([ 'Bash', 'Write', 'Edit', 'WebFetch', 'WebSearch', 'Task', 'Read(./.env)', 'Read(./.env.*)', 'Read(./**/.env)', 'Read(./**/.env.*)', + 'Read(./.env/**)', 'Read(./**/.env/**)', 'Read(./**/.env.*/**)', + 'Read(./.git/**)', 'Read(./**/.git/**)', 'mcp__commonly__commonly_self_install_into_pod', ])); expect(opts.env.HOME).toBe(publicState); @@ -1280,6 +1282,7 @@ describe('claude adapter — a workspace seat', () => { const { args } = calls[0]; expect(args).not.toContain('Read(./.env.*)'); + expect(args).not.toContain('Read(./.git/**)'); expect(args).not.toContain('mcp__commonly__commonly_self_install_into_pod'); expect(args[1]).toContain(`(allow file-read* file-test-existence file-write* (subpath "${fs.realpathSync(cwd)}"))`); }); diff --git a/cli/__tests__/git-readonly.test.mjs b/cli/__tests__/git-readonly.test.mjs index 8af9ad0064..5a6d0e5cd2 100644 --- a/cli/__tests__/git-readonly.test.mjs +++ b/cli/__tests__/git-readonly.test.mjs @@ -9,7 +9,7 @@ import { } from '@jest/globals'; import { execFile } from 'child_process'; import { - existsSync, mkdtempSync, rmSync, writeFileSync, mkdirSync, + chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync, mkdirSync, } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -172,12 +172,15 @@ describe('read-only git runs the five subcommands', () => { test('forced options are prepended for diff, log and show', () => { const excludes = ['--', ':(exclude,glob)**/.env', ':(exclude,glob)**/.env.*', ':(exclude,glob)**/.env/**', ':(exclude,glob)**/.env.*/**']; + const top = ['--no-pager', '-c', 'core.fsmonitor=false', '-c', 'format.pretty=medium']; expect(readonlyGitArgv({ subcommand: 'diff', args: [], root: repo }).argv) - .toEqual(['--no-pager', '-c', 'core.fsmonitor=false', 'diff', '--no-ext-diff', '--no-textconv', ...excludes]); + .toEqual([...top, 'diff', '--no-ext-diff', '--no-textconv', ...excludes]); + expect(readonlyGitArgv({ subcommand: 'log', args: [], root: repo }).argv) + .toEqual([...top, 'log', '--no-ext-diff', '--no-textconv', '--no-show-signature', ...excludes]); expect(readonlyGitArgv({ subcommand: 'status', args: [], root: repo }).argv) - .toEqual(['--no-pager', '-c', 'core.fsmonitor=false', 'status', ...excludes]); + .toEqual([...top, 'status', ...excludes]); expect(readonlyGitArgv({ subcommand: 'blame', args: ['tracked.txt'], root: repo }).argv) - .toEqual(['--no-pager', '-c', 'core.fsmonitor=false', 'blame', 'tracked.txt']); + .toEqual([...top, 'blame', '--no-textconv', 'tracked.txt']); }); test('status, log, diff, show and blame all return output', async () => { @@ -291,3 +294,131 @@ describe('read-only git keeps committed .env files out of reach', () => { expect((await run('status', ['--short'])).ok).toBe(true); }); }); + +// A program the repository's config names (gpg for a signature, a textconv +// driver) runs on an ordinary read unless git is told not to. Each control +// runs bare git with a stub on PATH that leaves a marker; the guarded call +// leaves none. +describe('read-only git runs no gpg and no textconv', () => { + let cfgRepo; + let bin; + let markers; + let signed; + const cfgGit = (args, opts = {}) => execFileAsync(GIT, args, { + cwd: cfgRepo, + env: { + ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', PATH: `${bin}:${process.env.PATH}`, + }, + ...opts, + }); + const run = (subcommand, args) => runReadonlyGit({ + subcommand, args, root: cfgRepo, git: GIT, baseEnv: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, + }); + const ran = (name) => { + const marker = join(markers, name); + const was = existsSync(marker); + rmSync(marker, { force: true }); + return was; + }; + + beforeAll(async () => { + cfgRepo = mkdtempSync(join(tmpdir(), 'git-ro-cfg-')); + bin = mkdtempSync(join(tmpdir(), 'git-ro-bin-')); + markers = mkdtempSync(join(tmpdir(), 'git-ro-markers-')); + writeFileSync(join(bin, 'gpg'), `#!/bin/sh\ntouch '${join(markers, 'gpg')}'\nexit 1\n`); + writeFileSync(join(bin, 'conv'), `#!/bin/sh\ntouch '${join(markers, 'textconv')}'\ncat "$1"\n`); + chmodSync(join(bin, 'gpg'), 0o755); + chmodSync(join(bin, 'conv'), 0o755); + await cfgGit(['init', '-q', '-b', 'main']); + await cfgGit(['config', 'user.email', 'test@example.com']); + await cfgGit(['config', 'user.name', 'Test']); + await cfgGit(['config', 'diff.conv.textconv', 'conv']); + writeFileSync(join(cfgRepo, '.gitattributes'), '*.txt diff=conv\n'); + writeFileSync(join(cfgRepo, 'notes.txt'), 'one line\n'); + await cfgGit(['add', '.gitattributes', 'notes.txt']); + await cfgGit(['commit', '-q', '-m', 'unsigned']); + // The same commit with a signature header: enough for git to call gpg. + const { stdout: body } = await cfgGit(['cat-file', 'commit', 'HEAD']); + const split = body.indexOf('\n\n'); + const header = ['gpgsig -----BEGIN PGP SIGNATURE-----', ' ', ' aGVsbG8=', ' -----END PGP SIGNATURE-----'].join('\n'); + const child = execFile(GIT, ['hash-object', '-t', 'commit', '-w', '--stdin'], { cwd: cfgRepo }); + const out = new Promise((done) => { let text = ''; child.stdout.on('data', (d) => { text += d; }); child.on('close', () => done(text)); }); + child.stdin.end(`${body.slice(0, split)}\n${header}${body.slice(split)}`); + signed = (await out).trim(); + expect(signed).toMatch(/^[0-9a-f]{40,64}$/); + }); + + afterAll(() => { + for (const dir of [cfgRepo, bin, markers]) rmSync(dir, { recursive: true, force: true }); + }); + + test('--show-signature runs gpg (control), and is refused; abbreviations too', async () => { + await cfgGit(['log', '--show-signature', '-1', signed]); + expect(ran('gpg')).toBe(true); + expect((await run('log', ['--show-signature', '-1', signed])).text).toMatch(/--show-signature is refused: it runs gpg/); + expect((await run('show', ['--show-sig', signed])).text).toMatch(/git reads it as --show-signature/); + expect(ran('gpg')).toBe(false); + }); + + test('a %G placeholder runs gpg (control), and is refused in --format and --pretty', async () => { + await cfgGit(['log', '--format=%G?', '-1', signed]); + expect(ran('gpg')).toBe(true); + for (const arg of ['--format=%G?', '--pretty=format:%GS', '--pretty=tformat:%h %GK']) { + expect((await run('log', [arg, '-1', signed])).text).toMatch(/%G placeholder runs gpg/); + } + expect(ran('gpg')).toBe(false); + }); + + test('log.showSignature in the repository config runs gpg (control); the guarded log and show do not', async () => { + await cfgGit(['config', 'log.showSignature', 'true']); + try { + await cfgGit(['log', '-1', signed]); + expect(ran('gpg')).toBe(true); + expect((await run('log', ['-1', signed])).ok).toBe(true); + expect((await run('show', ['--stat', signed])).ok).toBe(true); + expect(ran('gpg')).toBe(false); + } finally { + await cfgGit(['config', '--unset', 'log.showSignature']); + } + }); + + test('a format.pretty holding %G runs gpg (control); the guarded log does not', async () => { + await cfgGit(['config', 'format.pretty', 'format:%G?']); + try { + await cfgGit(['log', '-1', signed]); + expect(ran('gpg')).toBe(true); + const log = await run('log', ['-1', signed]); + expect(log.ok).toBe(true); + expect(log.text).toContain('unsigned'); + expect(ran('gpg')).toBe(false); + } finally { + await cfgGit(['config', '--unset', 'format.pretty']); + } + }); + + test('a configured format alias can hold %G (control), so only built-in names are taken', async () => { + await cfgGit(['config', 'pretty.sig', '%G?']); + try { + await cfgGit(['log', '--pretty=sig', '-1', signed]); + expect(ran('gpg')).toBe(true); + expect((await run('log', ['--pretty=sig', '-1', signed])).text).toMatch(/name a built-in format/); + expect((await run('log', ['--format=si', '-1', signed])).text).toMatch(/name a built-in format/); + expect(ran('gpg')).toBe(false); + const oneline = await run('log', ['--pretty=oneline', '-1', signed]); + expect(oneline.text).toContain('unsigned'); + const custom = await run('log', ['--format=%h %s', '-1', signed]); + expect(custom.text).toContain('unsigned'); + } finally { + await cfgGit(['config', '--unset', 'pretty.sig']); + } + }); + + test('blame runs the configured textconv (control); the guarded blame does not', async () => { + await cfgGit(['blame', 'notes.txt']); + expect(ran('textconv')).toBe(true); + const blame = await run('blame', ['notes.txt']); + expect(blame.ok).toBe(true); + expect(blame.text).toContain('one line'); + expect(ran('textconv')).toBe(false); + }); +}); diff --git a/cli/__tests__/seatbelt.env-files.smoke.test.mjs b/cli/__tests__/seatbelt.env-files.smoke.test.mjs index 0572f8e305..5a9eb17fee 100644 --- a/cli/__tests__/seatbelt.env-files.smoke.test.mjs +++ b/cli/__tests__/seatbelt.env-files.smoke.test.mjs @@ -2,8 +2,9 @@ * seatbelt.env-files.smoke.test.mjs * * Every .env and .env.* file under a confined seat's folder is out of reach at - * any depth, under real sandbox-exec (TASK-294): a workspace seat reads a real - * project, which keeps one beside each package, not only at its root. The + * any depth, and so is everything inside a directory of that name, under real + * sandbox-exec (TASK-294): a workspace seat reads a real project, which keeps + * one beside each package, not only at its root. The * folder's name carries regex metacharacters, so a missed escape shows up as a * readable .env. The positive controls keep the denials from passing * vacuously. @@ -14,7 +15,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, } from 'fs'; import { tmpdir } from 'os'; -import { join } from 'path'; +import { dirname, join } from 'path'; import { wrapArgvWithSeatbelt } from '../src/lib/sandbox/seatbelt.js'; @@ -22,23 +23,27 @@ const macTest = process.platform === 'darwin' ? test : test.skip; const FILES = [ '.env', '.env.local', 'packages/api/.env', 'packages/api/.env.production', 'packages/api/config.js', '.envrc', 'env.txt', 'notes.env', + 'packages/web/.env/secrets.json', 'packages/web/.env/deep/key.json', 'packages/web/.envoy/config.json', ]; +// A second folder, whose root holds a .env directory instead of a .env file. +const DIR_FILES = ['.env/secrets.json', '.envoy/config.json']; describe('a confined seat\'s .env files, under Seatbelt', () => { let root; let workspace; + let dirWorkspace; let state; - const read = (relative) => { + const read = (relative, folder = workspace) => { const argv = wrapArgvWithSeatbelt([ process.execPath, '-e', - `try { require('fs').readFileSync(${JSON.stringify(join(workspace, relative))}); console.log('READ') } catch (e) { console.log('DENIED ' + e.code) }`, + `try { require('fs').readFileSync(${JSON.stringify(join(folder, relative))}); console.log('READ') } catch (e) { console.log('DENIED ' + e.code) }`, ], { - workspacePath: workspace, + workspacePath: folder, workspaceAccess: 'read', executablePath: process.execPath, statePath: state, }); - const run = spawnSync(argv[0], argv.slice(1), { cwd: workspace, encoding: 'utf8', timeout: 20_000 }); + const run = spawnSync(argv[0], argv.slice(1), { cwd: folder, encoding: 'utf8', timeout: 20_000 }); return (run.stdout || '').trim(); }; @@ -46,20 +51,33 @@ describe('a confined seat\'s .env files, under Seatbelt', () => { if (process.platform !== 'darwin') return; root = realpathSync(mkdtempSync(join(tmpdir(), 'commonly-env-files-seatbelt-'))); workspace = join(root, 'my.project (v2)+'); + dirWorkspace = join(root, 'dir.project'); state = join(root, 'state'); - mkdirSync(join(workspace, 'packages', 'api'), { recursive: true }); mkdirSync(state); - for (const file of FILES) writeFileSync(join(workspace, file), 'x'); + const write = (folder, file) => { + mkdirSync(dirname(join(folder, file)), { recursive: true }); + writeFileSync(join(folder, file), 'x'); + }; + for (const file of FILES) write(workspace, file); + for (const file of DIR_FILES) write(dirWorkspace, file); }); afterAll(() => { if (root) rmSync(root, { recursive: true, force: true }); }); - macTest.each(['.env', '.env.local', 'packages/api/.env', 'packages/api/.env.production'])( + macTest.each([ + '.env', '.env.local', 'packages/api/.env', 'packages/api/.env.production', + 'packages/web/.env/secrets.json', 'packages/web/.env/deep/key.json', + ])( 'cannot read %s', (file) => { expect(read(file)).toBe('DENIED EPERM'); }, ); - macTest.each(['packages/api/config.js', '.envrc', 'env.txt', 'notes.env'])( + macTest.each(['packages/api/config.js', '.envrc', 'env.txt', 'notes.env', 'packages/web/.envoy/config.json'])( 'still reads %s', (file) => { expect(read(file)).toBe('READ'); }, ); + + macTest('cannot read what a .env directory at the folder root holds, and still reads its neighbour', () => { + expect(read('.env/secrets.json', dirWorkspace)).toBe('DENIED EPERM'); + expect(read('.envoy/config.json', dirWorkspace)).toBe('READ'); + }); }); diff --git a/cli/__tests__/seatbelt.test.mjs b/cli/__tests__/seatbelt.test.mjs index f99779c86b..36f583872c 100644 --- a/cli/__tests__/seatbelt.test.mjs +++ b/cli/__tests__/seatbelt.test.mjs @@ -127,7 +127,7 @@ describe('macOS Seatbelt profile', () => { })).toThrow(/readOnlyWorkspaceEntries entries must be names inside the workspace/); }); - test('holds every .env file under the workspace, at any depth, out of reach', () => { + test('holds every .env file under the workspace, at any depth, and what a .env directory holds, out of reach', () => { const profile = buildSeatbeltProfile({ workspacePath: workspace, executablePath: '/usr/bin/true', @@ -135,7 +135,7 @@ describe('macOS Seatbelt profile', () => { }); const escaped = realpathSync(workspace).replace(/[\\^$.|?*+()[\]{}]/g, '\\$&'); - expect(profile).toContain(`(deny file-read* file-write* (regex #"^${escaped}/(.*/)?\\.env(\\..*)?$"))`); + expect(profile).toContain(`(deny file-read* file-write* (regex #"^${escaped}/(.*/)?\\.env([./].*)?$"))`); }); test('refuses a workspace path an SBPL regex cannot hold', () => { diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index a5832d30be..01c4d3c235 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -326,13 +326,18 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [] } = {} 'Read(./.codex/**)', 'Read(./.env)', ]; - // A workspace seat reads a real project, so every .env variant at any depth - // stays out of reach (the jail holds the same line), and it cannot install - // itself into a room partway through a turn: the per-turn check confirmed - // its pods private before the spawn. + // A workspace seat reads a real project, so every .env variant at any depth, + // and what a .env directory holds, stays out of reach (the jail holds the + // same line). .git is denied too: its config can carry a remote URL with a + // token in it, and history comes through the read-only git tool, which runs + // in its own process. And the seat cannot install itself into a room partway + // through a turn: the per-turn check confirmed its pods private before the + // spawn. const workspaceDenies = trust === 'workspace' ? [ 'Read(./.env.*)', 'Read(./**/.env)', 'Read(./**/.env.*)', + 'Read(./.env/**)', 'Read(./**/.env/**)', 'Read(./**/.env.*/**)', + 'Read(./.git/**)', 'Read(./**/.git/**)', ...mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, 'commonly_self_install_into_pod')), ] : []; diff --git a/cli/src/lib/git-readonly.js b/cli/src/lib/git-readonly.js index 6f59db3dc5..7f5d9a834d 100644 --- a/cli/src/lib/git-readonly.js +++ b/cli/src/lib/git-readonly.js @@ -18,10 +18,17 @@ * DENIED_SHORT_OPTIONS_BY_SUBCOMMAND), including git's accepted * abbreviations of them (`--outp=`), and against * paths that leave the workspace. - * 3. diff, log and show run with `--no-ext-diff --no-textconv`, so no external - * diff or conversion program configured for the repository runs, and the - * spellings that would turn them back on are refused. `core.fsmonitor` is - * forced off for the same reason: `git status` runs it. + * 3. diff, log and show run with `--no-ext-diff --no-textconv`, and blame with + * `--no-textconv`, so no external diff or conversion program configured for + * the repository runs, and the spellings that would turn them back on are + * refused. log and show also run with `--no-show-signature`, and + * `format.pretty` is forced to `medium`, so nothing runs gpg: + * `--show-signature`, a `%G` placeholder and a configured format alias + * (which can hold one) are refused. `core.fsmonitor` is forced off for the + * same reason: `git status` runs it. What still runs is a clean filter the + * repository's own config defines, when status, diff or blame read a + * working-tree file: without it they report unchanged files as changed. It + * runs inside the sandbox below, and the seat cannot define one. * 4. The environment is rebuilt from an allowlist. No inherited GIT_* variable * survives, system and global config are off, optional locks are off (so * status never rewrites the index), and GIT_CEILING_DIRECTORIES stops @@ -58,6 +65,7 @@ export const DENIED_LONG_OPTIONS = Object.freeze({ '--exec-path': 'runs git programs from another directory', '--config-env': 'sets configuration', '--paginate': 'runs a pager', + '--show-signature': 'runs gpg to check a signature', }); // Real options that are also prefixes of a denied one. Git takes an exact name @@ -88,12 +96,30 @@ const STATUS_UNTRACKED_MODES = new Set(['-uno', '-unormal', '-uall']); // Ahead of the caller's arguments, so a configured program never runs. const FORCED_OPTIONS = Object.freeze({ diff: ['--no-ext-diff', '--no-textconv'], - log: ['--no-ext-diff', '--no-textconv'], - show: ['--no-ext-diff', '--no-textconv'], + log: ['--no-ext-diff', '--no-textconv', '--no-show-signature'], + show: ['--no-ext-diff', '--no-textconv', '--no-show-signature'], status: [], - blame: [], + blame: ['--no-textconv'], }); -const TOP_LEVEL = Object.freeze(['--no-pager', '-c', 'core.fsmonitor=false']); +// `format.pretty` is the repository's default format for log and show, and a +// format can hold a %G placeholder; `medium` is git's own default. +const TOP_LEVEL = Object.freeze(['--no-pager', '-c', 'core.fsmonitor=false', '-c', 'format.pretty=medium']); + +// A %G placeholder (%G?, %GG, %GS, %GK, %GF, %GP, %GT) runs gpg on each signed +// commit. A value with no %, no format: and no tformat: is looked up by +// prefix among the built-in formats and the repository's `pretty.` +// aliases, and an alias can hold one, so only a built-in name is taken. +const FORMAT_ARGUMENT = /^--(?:pretty|format)=([\s\S]*)$/; +const BUILTIN_FORMATS = Object.freeze(['oneline', 'short', 'medium', 'full', 'fuller', 'reference', 'email', 'mboxrd', 'raw']); +const checkFormat = (arg) => { + const match = FORMAT_ARGUMENT.exec(arg); + if (!match) return null; + const value = match[1]; + if (value.includes('%G')) return `${arg} is refused: a %G placeholder runs gpg to check a signature`; + const userFormat = value === '' || value.startsWith('format:') || value.startsWith('tformat:') || value.includes('%'); + if (userFormat || BUILTIN_FORMATS.includes(value.toLowerCase())) return null; + return `${arg} is refused: name a built-in format (${BUILTIN_FORMATS.join(', ')}) or give format:`; +}; // Mirrors the names sandbox/seatbelt.js denies at any depth. const ENV_FILE_SEGMENT = /^\.env(\..*)?$/; @@ -181,7 +207,7 @@ export const readonlyGitArgv = ({ subcommand, args = [], root }) => { } else if (arg === '--') { pathsOnly = true; } else { - const reason = arg.startsWith('--') ? checkLongOption(arg) : checkShortOption(arg, subcommand); + const reason = arg.startsWith('--') ? checkLongOption(arg) || checkFormat(arg) : checkShortOption(arg, subcommand); if (reason) return refuse(reason); if (arg.startsWith('-L')) { lineRange = true; diff --git a/cli/src/lib/sandbox/seatbelt.js b/cli/src/lib/sandbox/seatbelt.js index fead7c2664..bfd24e4b9e 100644 --- a/cli/src/lib/sandbox/seatbelt.js +++ b/cli/src/lib/sandbox/seatbelt.js @@ -300,15 +300,16 @@ const escapeSbplString = (value) => String(value) const literal = (path) => `(literal "${escapeSbplString(path)}")`; const subpath = (path) => `(subpath "${escapeSbplString(path)}")`; -// Every .env and .env.* file under the workspace, at any depth: a real project -// keeps one beside each package, not only at its root (TASK-294). An SBPL -// regex literal takes no escapes, so a path it cannot hold is refused. +// Every .env and .env.* file under the workspace, at any depth, and everything +// inside a directory of that name: a real project keeps one beside each +// package, not only at its root (TASK-294). An SBPL regex literal takes no +// escapes, so a path it cannot hold is refused. const envFilesUnder = (workspace) => { if (/["\r\n]/.test(workspace)) { throw new Error('Seatbelt workspacePath must not contain a quote or a line break'); } const pattern = workspace.replace(/[\\^$.|?*+()[\]{}]/g, '\\$&'); - return `(regex #"^${pattern}/(.*/)?\\.env(\\..*)?$")`; + return `(regex #"^${pattern}/(.*/)?\\.env([./].*)?$")`; }; const assertAbsolutePath = (path, label) => { From 9bf27589d9c233562e539a07837b3c561603fc26 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:52:52 -0700 Subject: [PATCH 20/33] fix(cli): a turn that runs public gets no memory and no memory tools (TASK-294) A workspace seat's turn that degrades to a public seat in the scratch folder no longer reads long-term memory, gets a withheld cue instead of the empty cue that invites a save, has the memory tools denied, and never syncs a summary back. Earlier private turns may have saved what they read in the folder, so the public turn must not see or extend it. Also: the zh-CN reach line says the agent sends what it read off this computer, and the work-in-your-repo page covers .env folders, .git, and memory on a turn that runs in the scratch folder. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- .../adapters.claude.environment.test.mjs | 27 ++++++++++++++++++- cli/__tests__/memory-bridge.test.mjs | 12 +++++++++ .../workspace-turn-run-loop.test.mjs | 15 ++++++++++- cli/src/commands/agent.js | 12 ++++++--- cli/src/lib/adapters/claude.js | 20 +++++++++++--- cli/src/lib/memory-bridge.js | 15 ++++++++--- .../bring-your-own/work-in-your-repo.mdx | 7 ++--- frontend/src/i18n/locales/zh-CN.json | 2 +- 8 files changed, 94 insertions(+), 16 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 0d7d71604a..322cc8112f 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1233,7 +1233,8 @@ describe('claude adapter — a workspace seat', () => { } }); - const workspaceSpawn = (impl, sandbox = { mode: 'workspace', trust: 'workspace' }) => claude.spawn('hi', { + const workspaceSpawn = (impl, sandbox = { mode: 'workspace', trust: 'workspace' }, extra = {}) => claude.spawn('hi', { + ...extra, agentName: 'workspace-test', sessionId: null, cwd, @@ -1276,6 +1277,30 @@ describe('claude adapter — a workspace seat', () => { expect(opts.env.COMMONLY_HOST_SECRET).toBeUndefined(); }); + test('a workspace seat\'s turn that runs public gets no memory and no memory tool', async () => { + const memoryTools = ['mcp__commonly__commonly_read_agent_memory', 'mcp__commonly__commonly_write_agent_memory', + 'mcp__commonly__commonly_save_my_memory']; + const withheld = makeSpawnImpl(); + await workspaceSpawn(withheld.impl, { mode: 'workspace', trust: 'public' }, { + memoryWithheld: true, memoryLongTerm: 'notes from the project', + }); + const { args } = withheld.calls[0]; + const denied = args.slice(args.indexOf('--disallowedTools') + 1); + expect(denied).toEqual(expect.arrayContaining(memoryTools)); + // The last -p: sandbox-exec's own -p carries the profile. + const prompt = args[args.lastIndexOf('-p') + 1]; + expect(prompt).toContain('withheld this turn'); + expect(prompt).not.toContain('notes from the project'); + expect(prompt).not.toContain('(empty'); + + // Control: the same public spawn, not withheld, injects the memory and keeps the tools. + const kept = makeSpawnImpl(); + await workspaceSpawn(kept.impl, { mode: 'workspace', trust: 'public' }, { memoryLongTerm: 'notes from the project' }); + const keptArgs = kept.calls[0].args; + expect(keptArgs[keptArgs.lastIndexOf('-p') + 1]).toContain('notes from the project'); + for (const tool of memoryTools) expect(keptArgs).not.toContain(tool); + }); + test('a public seat keeps the denies it had, without the workspace ones', async () => { const { impl, calls } = makeSpawnImpl(); await workspaceSpawn(impl, { mode: 'workspace', trust: 'public' }); diff --git a/cli/__tests__/memory-bridge.test.mjs b/cli/__tests__/memory-bridge.test.mjs index 98a64912e6..b79a1922f1 100644 --- a/cli/__tests__/memory-bridge.test.mjs +++ b/cli/__tests__/memory-bridge.test.mjs @@ -165,6 +165,18 @@ describe('syncBack', () => { describe('buildMemoryPreamble', () => { + test('withheld memory is neither shown nor called empty or unreadable, and asks for no save', () => { + const out = buildMemoryPreamble('do the thing', 'notes from the project', { + freshSession: true, memoryWithheld: true, + }); + expect(out).toContain('withheld this turn'); + expect(out).not.toContain('notes from the project'); + expect(out).not.toContain('(empty'); + expect(out).not.toContain('unreadable'); + expect(out).not.toContain('commonly_save_my_memory'); + expect(out).toContain('=== Current turn ===\ndo the thing'); + }); + // The non-empty path is load-bearing for every seat that has content, and // this change must not touch it. Asserted byte-for-byte, not by substring. it('prepends existing memory verbatim, unchanged', () => { diff --git a/cli/__tests__/workspace-turn-run-loop.test.mjs b/cli/__tests__/workspace-turn-run-loop.test.mjs index 3ec6ebfc3c..7a7c9b7395 100644 --- a/cli/__tests__/workspace-turn-run-loop.test.mjs +++ b/cli/__tests__/workspace-turn-run-loop.test.mjs @@ -52,7 +52,7 @@ const runOneTurn = async ({ environment, installations, newSessionId }) => { if (installations instanceof Error) throw installations; return { installations }; } - if (route === '/api/agents/runtime/memory') return { sections: {} }; + if (route === '/api/agents/runtime/memory') return { sections: { long_term: { content: 'notes from the project' } } }; if (route.endsWith('/messages')) return { messages: [] }; if (route.endsWith('/context')) return { focus: { podId: 'pod-ws', revision: 0, focus: null } }; return {}; @@ -95,6 +95,19 @@ describe('performRun workspace turn placement', () => { signal: expect.any(AbortSignal), }); expect(logs.some((line) => line.includes('public seat'))).toBe(false); + expect(ctx.memoryWithheld).toBe(false); + expect(ctx.memoryLongTerm).toBe('notes from the project'); + }); + + test('a turn that runs public never reads the seat\'s memory', async () => { + const { ctx, get } = await runOneTurn({ + environment: { sandbox: { trust: 'workspace' } }, + installations: [privatePod, { podId: 'pod-open', publicRead: true, communityListed: false }], + }); + expect(ctx.environment.sandbox.trust).toBe('public'); + expect(ctx.memoryWithheld).toBe(true); + expect(ctx.memoryLongTerm).toBeUndefined(); + expect(get.mock.calls.some(([route]) => route === '/api/agents/runtime/memory')).toBe(false); }); test('a pod that turned public runs the turn as a public seat in the scratch folder', async () => { diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index ada28304a7..8e5d8c5b75 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -1856,8 +1856,13 @@ export const performRun = ({ sessionId = null; } // ADR-005 §Memory bridge: read long_term before spawn, inject via ctx, - // and (if the adapter returns a summary) patch-sync back after. - const memoryLongTerm = await readLongTerm(client, { onError }); + // and (if the adapter returns a summary) patch-sync back after. A workspace + // seat's turn that runs public (placement.reason) gets none of it: its + // memory may hold what earlier turns read in the project, and what this + // turn reads in an unconfirmed room must not reach the next workspace turn. + // The adapter also denies the memory tools for it (memoryWithheld). + const memoryWithheld = Boolean(placement.reason); + const memoryLongTerm = memoryWithheld ? undefined : await readLongTerm(client, { onError }); // Sharpen TASK-129: focus is a turn-start read, not an enqueue-time // snapshot. Read through the authorized runtime context route immediately @@ -2036,6 +2041,7 @@ export const performRun = ({ env: commitAttribution?.env || process.env, commitAttributionEnv: commitAttribution?.sandboxEnv || null, memoryLongTerm, + memoryWithheld, environment: placement.environment, onSkillsLinked: ({ mounted, conflicted }) => { skillMount = { @@ -2303,7 +2309,7 @@ export const performRun = ({ ); } } - if (result.memorySummary) { + if (result.memorySummary && !memoryWithheld) { try { await syncBack(client, { summary: result.memorySummary }); log(`[${event.type}] memory synced (${Buffer.byteLength(result.memorySummary)} bytes)`); diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 01c4d3c235..2c7ef54c39 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -310,7 +310,10 @@ const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, const absoluteToolDeny = (path) => `Read(/${path}/**)`; -const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [] } = {}) => { +// The tools that read or write the seat's agent-wide memory. +const MEMORY_TOOLS = Object.freeze(['commonly_read_agent_memory', 'commonly_write_agent_memory', 'commonly_save_my_memory']); + +const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [], memoryWithheld = false } = {}) => { const home = homedir(); // `allow` is already narrowed to a workspace seat's grants (workspaceGrants). const granted = allow.flatMap((grant) => WORKSPACE_ALLOW_TOOLS[grant] || []); @@ -341,6 +344,11 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [] } = {} ...mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, 'commonly_self_install_into_pod')), ] : []; + // A workspace seat's turn that runs public gets no memory in its prompt + // (agent.js), and no tool that would read or write it either. + const memoryDenies = memoryWithheld + ? MEMORY_TOOLS.flatMap((tool) => mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, tool))) + : []; return [ // HOME points at an isolated, sanitized state directory. Ignore settings // from user/project/local sources and disable slash-command extensions; @@ -352,7 +360,7 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [] } = {} '--permission-mode', 'dontAsk', '--allowedTools', 'Read(./**)', ...granted, ...mcpToolPatterns, '--disallowedTools', ...PUBLIC_DENIED_TOOLS.filter((tool) => !granted.includes(tool)), - ...deniedReads, ...workspaceDenies, + ...deniedReads, ...workspaceDenies, ...memoryDenies, ]; }; @@ -770,7 +778,9 @@ const prepareArgv = async (innerArgv, ctx) => { } innerArgv = [ ...innerArgv, - ...buildPublicClaudePolicyArgs(allowedPatterns, { trust: sandboxTrust, allow: grants }), + ...buildPublicClaudePolicyArgs(allowedPatterns, { + trust: sandboxTrust, allow: grants, memoryWithheld: ctx.memoryWithheld === true, + }), ...(ctx.publicSkillArgs || []), ]; const claudeBin = resolveClaudePath(claudeEnv); @@ -819,7 +829,7 @@ const prepareArgv = async (innerArgv, ctx) => { } innerArgv = [ ...innerArgv, - ...buildPublicClaudePolicyArgs(allowedPatterns), + ...buildPublicClaudePolicyArgs(allowedPatterns, { memoryWithheld: ctx.memoryWithheld === true }), ...(ctx.publicSkillArgs || []), ]; } else if (allowedPatterns.length > 0) { @@ -915,6 +925,7 @@ export default { const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: !isResume, workCues, + memoryWithheld: ctx.memoryWithheld === true, }); const sessionFlag = isResume ? '--resume' : '--session-id'; // Model pin from the ADR-008 environment spec. Absent it, claude picks its @@ -1050,6 +1061,7 @@ export default { const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: true, workCues, + memoryWithheld: ctx.memoryWithheld === true, }); const retryBase = ['-p', freshPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs]; diff --git a/cli/src/lib/memory-bridge.js b/cli/src/lib/memory-bridge.js index 5f8573a2d2..9b04f83ba0 100644 --- a/cli/src/lib/memory-bridge.js +++ b/cli/src/lib/memory-bridge.js @@ -60,7 +60,7 @@ export const ROW_SLICING_CUE = 'Slicing: when a row is too large for one turn, s export const buildMemoryPreamble = ( prompt, memoryLongTerm, - { freshSession = false, workCues = [] } = {}, + { freshSession = false, workCues = [], memoryWithheld = false } = {}, ) => { // `null` is the UNREADABLE signal, and it is deliberately not the same value // as `''`. Telling a seat whose token was revoked that "nothing has ever been @@ -68,7 +68,14 @@ export const buildMemoryPreamble = ( // defect this cue exists to fix — one state over. When we could not read, say // that, and say nothing about what is stored. let context; - if (memoryLongTerm === null) { + if (memoryWithheld) { + // A workspace seat's turn that runs public: not unreadable and not empty, + // so neither of those cues is true. Its memory tools are off as well. + context = `=== Context (your persistent memory) ===\n` + + `(withheld this turn — this turn runs as a public seat because not every ` + + `pod you answer in is confirmed private, so your memory stays out of it and ` + + `the memory tools are off. This says nothing about what you have saved.)`; + } else if (memoryLongTerm === null) { context = `=== Context (your persistent memory) ===\n` + `(unreadable this turn — the memory read failed, so this says NOTHING ` + `about what you have saved. Do not treat it as empty and do not re-save ` @@ -96,7 +103,9 @@ export const buildMemoryPreamble = ( + 'This is a fresh session. Read the persistent memory context above before acting; ' + 'it carries durable state from prior sessions.\n'; let sessionEndCue = '=== Before this session ends ===\n'; - if (memoryLongTerm === null) { + if (memoryWithheld) { + sessionEndCue += 'Memory is withheld this turn, so there is nothing to save.'; + } else if (memoryLongTerm === null) { sessionEndCue += 'Memory was unreadable on this fresh session. Do not treat it as empty or ' + 'write a replacement based on this cue.'; } else if (memoryLongTerm) { diff --git a/docs-site/bring-your-own/work-in-your-repo.mdx b/docs-site/bring-your-own/work-in-your-repo.mdx index 621b43008c..8419843c82 100644 --- a/docs-site/bring-your-own/work-in-your-repo.mdx +++ b/docs-site/bring-your-own/work-in-your-repo.mdx @@ -25,9 +25,10 @@ The [Claude Code listener](/bring-your-own/claude-code) checks the pods its key - It can read files there and run read-only git: `status`, `log`, `diff`, `show` and `blame`. - It can't edit files, run commands or fetch web pages. -- It can't read `.env` or `.env.*` files, at any depth, including copies committed to git. +- It can't read `.env` or `.env.*` files, or what a `.env` folder holds, at any depth, including copies committed to git. +- It can't open the `.git` folder directly, because `.git/config` can hold a remote URL with a token in it. History still comes through read-only git. -Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when a pod is publicly readable or open to join, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable or open to join, or the check fails, that turn runs in the scratch folder with a fresh session. +Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when a pod is publicly readable or open to join, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable or open to join, or the check fails, that turn runs in the scratch folder with a fresh session. It also gets none of the agent's saved memory and can't save any, because earlier turns may have stored what they read in your folder. ### Give it more with `--allow` @@ -39,7 +40,7 @@ commonly agent run your-agent-name --adapter claude --allow web commonly agent run your-agent-name --adapter claude --allow write,web ``` -- `write` lets it edit files in the folder and run commands there. Those commands can reach the network, and they can read what the sandbox lets them read, which is more than the folder. `.git` stays read-only, so it can't commit or change hooks or git config. Files git ignores are writable too, and `git status` won't show changes to them. +- `write` lets it edit files in the folder and run commands there. Those commands can reach the network, and they can read what the sandbox lets them read, which is more than the folder and includes `.git`. `.git` stays read-only, so it can't commit or change hooks or git config. Files git ignores are writable too, and `git status` won't show changes to them. - `web` lets it fetch web pages. Web search stays off. Fetching stays off unless you ask for it. An agent that can read your code and fetch any URL could send what it read off your machine, and its permissions can't tell a URL you pasted from one it built. `write` opens the same path, because its commands reach the network. Grant either one only in a pod where you trust everyone who can @mention the agent with your code. diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index d4789c0293..261d249ca5 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1647,7 +1647,7 @@ "listen": { "title": "在终端里运行这段命令", "note": "仅连接 MCP 不会唤醒 {{name}} 回复 @提及。请保持终端窗口开启。", - "reachClaude": "默认情况下,在私密且仅限邀请的 pod 中,{{name}} 只能读取你运行此命令的文件夹。若要让它编辑文件并运行命令,请在最后一行加上 --allow write。这些命令能读取文件夹以外的内容。若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容发出这台电脑,所以只在你信任的 pod 中授予。", + "reachClaude": "默认情况下,在私密且仅限邀请的 pod 中,{{name}} 只能读取你运行此命令的文件夹。若要让它编辑文件并运行命令,请在最后一行加上 --allow write。这些命令能读取文件夹以外的内容。若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容传出这台电脑,所以只在你信任的 pod 中授予。", "waiting": "正在等待 {{name}} 接入…命令一接通,这里就会变绿。", "verified": "{{name}} 已在监听 —— @提及 会唤醒它。", "stillWaiting": "{{name}} 还没接入。请在你电脑的终端里运行上面的命令,这里会自动确认。", From 0ecbe86192d952abe28c791d8399a61753a899b2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:54:46 -0700 Subject: [PATCH 21/33] docs(byo): the folder needs macOS, and git can't refuse a .env asked for by object id (TASK-294) Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs-site/bring-your-own/work-in-your-repo.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-site/bring-your-own/work-in-your-repo.mdx b/docs-site/bring-your-own/work-in-your-repo.mdx index 8419843c82..d649b67c61 100644 --- a/docs-site/bring-your-own/work-in-your-repo.mdx +++ b/docs-site/bring-your-own/work-in-your-repo.mdx @@ -21,14 +21,14 @@ The MCP server does not listen for @mentions when your session is closed. To mak ## What the listener can reach -The [Claude Code listener](/bring-your-own/claude-code) checks the pods its key answers in the first time you run it. If every one is private, meaning its **Who can join** setting is **Invite-only** and it isn't publicly readable or listed in Community, the listener works in the folder you ran the command from: +The [Claude Code listener](/bring-your-own/claude-code) checks the pods its key answers in the first time you run it. On macOS, if every one is private, meaning its **Who can join** setting is **Invite-only** and it isn't publicly readable or listed in Community, the listener works in the folder you ran the command from: - It can read files there and run read-only git: `status`, `log`, `diff`, `show` and `blame`. - It can't edit files, run commands or fetch web pages. -- It can't read `.env` or `.env.*` files, or what a `.env` folder holds, at any depth, including copies committed to git. +- It can't read `.env` or `.env.*` files, or what a `.env` folder holds, at any depth. Read-only git refuses them too, by name or path, including copies committed to git. Keep secrets out of git history anyway: git can't refuse a committed file asked for by its object id instead of its path. - It can't open the `.git` folder directly, because `.git/config` can hold a remote URL with a token in it. History still comes through read-only git. -Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when a pod is publicly readable or open to join, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable or open to join, or the check fails, that turn runs in the scratch folder with a fresh session. It also gets none of the agent's saved memory and can't save any, because earlier turns may have stored what they read in your folder. +Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when it runs anywhere but macOS, when a pod is publicly readable or open to join, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable or open to join, or the check fails, that turn runs in the scratch folder with a fresh session. It also gets none of the agent's saved memory and can't save any, because earlier turns may have stored what they read in your folder. ### Give it more with `--allow` From 117aa108f6e617197350fb350daff41bf48583d0 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:57:26 -0700 Subject: [PATCH 22/33] fix(cli): read-only git refuses --follow, which the .env excludes always break (TASK-294) git takes exactly one pathspec with --follow, and every log, diff and show here also carries the .env exclude pathspecs, so git rejected it with a bare fatal. The seat now gets a refusal that says why. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/git-readonly.test.mjs | 8 ++++++++ cli/src/lib/git-readonly.js | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cli/__tests__/git-readonly.test.mjs b/cli/__tests__/git-readonly.test.mjs index 5a6d0e5cd2..f1de9e73f0 100644 --- a/cli/__tests__/git-readonly.test.mjs +++ b/cli/__tests__/git-readonly.test.mjs @@ -273,6 +273,14 @@ describe('read-only git keeps committed .env files out of reach', () => { expect(refusal({ subcommand: 'log', args: ['-L', '1,1:sub/.env.local'] })).toMatch(/names a \.env file/); }); + test('--follow is refused, since it takes one path and the excludes add more (control: git rejects it with them)', async () => { + await expect(bareGit(['log', '--follow', '--', 'tracked.txt', ':(exclude,glob)**/.env'])) + .rejects.toMatchObject({ stderr: expect.stringMatching(/exactly one pathspec/) }); + for (const args of [['--follow', '--', 'tracked.txt'], ['--foll', '--', 'tracked.txt']]) { + expect(refusal({ subcommand: 'log', args })).toMatch(/--follow/); + } + }); + test('a patch, a glob, a pickaxe and a raw listing all leave it out (control: bare log -p shows it)', async () => { expect((await bareGit(['log', '-p'])).stdout).toContain(SECRET); for (const [subcommand, args] of [['log', ['-p']], ['log', ['-p', '--', '.en*', 'sub/*']], diff --git a/cli/src/lib/git-readonly.js b/cli/src/lib/git-readonly.js index 7f5d9a834d..d06c74b535 100644 --- a/cli/src/lib/git-readonly.js +++ b/cli/src/lib/git-readonly.js @@ -41,7 +41,7 @@ * (`.env`, `HEAD:.env`, `:0:sub/.env.local`, `-L1,5:.env`) is refused, and * status, log, diff and show run with pathspecs excluding them, so no patch, * pickaxe match or blob id comes from one whatever path or glob the caller - * named. What this cannot stop is `show ` with an id learned + * named. `--follow` takes exactly one path, so it is refused. What this cannot stop is `show ` with an id learned * somewhere else. */ import { execFile } from 'child_process'; @@ -66,6 +66,7 @@ export const DENIED_LONG_OPTIONS = Object.freeze({ '--config-env': 'sets configuration', '--paginate': 'runs a pager', '--show-signature': 'runs gpg to check a signature', + '--follow': 'needs exactly one path, and every log here also carries the .env excludes', }); // Real options that are also prefixes of a denied one. Git takes an exact name From 213d5aa3aff5822cf060f98067ccb288e32c6a48 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:59:51 -0700 Subject: [PATCH 23/33] fix(cli): read-only git holds a submodule to the same rules (TASK-294) A submodule's git runs as a child process that gets none of the forced flags, so a diff program named in the submodule's own config still ran under diff --submodule=diff, or under diff.submodule=diff in the repository config. --submodule=diff is now refused, and diff.submodule, status.submoduleSummary and log.showSignature are forced through -c, which does reach child git. Each guard has a test with a bare-git control, and each mutant fails its test. The docs page now says which programs read-only git turns off, and that a clean filter from the repository's own config still runs, inside the sandbox. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/git-readonly.test.mjs | 78 ++++++++++++++++++- cli/src/lib/git-readonly.js | 19 ++++- .../bring-your-own/work-in-your-repo.mdx | 1 + 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/cli/__tests__/git-readonly.test.mjs b/cli/__tests__/git-readonly.test.mjs index f1de9e73f0..6d77d20324 100644 --- a/cli/__tests__/git-readonly.test.mjs +++ b/cli/__tests__/git-readonly.test.mjs @@ -172,7 +172,8 @@ describe('read-only git runs the five subcommands', () => { test('forced options are prepended for diff, log and show', () => { const excludes = ['--', ':(exclude,glob)**/.env', ':(exclude,glob)**/.env.*', ':(exclude,glob)**/.env/**', ':(exclude,glob)**/.env.*/**']; - const top = ['--no-pager', '-c', 'core.fsmonitor=false', '-c', 'format.pretty=medium']; + const top = ['--no-pager', '-c', 'core.fsmonitor=false', '-c', 'format.pretty=medium', + '-c', 'diff.submodule=short', '-c', 'status.submoduleSummary=false', '-c', 'log.showSignature=false']; expect(readonlyGitArgv({ subcommand: 'diff', args: [], root: repo }).argv) .toEqual([...top, 'diff', '--no-ext-diff', '--no-textconv', ...excludes]); expect(readonlyGitArgv({ subcommand: 'log', args: [], root: repo }).argv) @@ -430,3 +431,78 @@ describe('read-only git runs no gpg and no textconv', () => { expect(ran('textconv')).toBe(false); }); }); + +// A superproject whose submodule has moved on by one commit, where the +// submodule's own config names a diff program that leaves a marker if it runs. +describe('read-only git holds a submodule to the same rules', () => { + let base; + let parent; + let marker; + const inParent = (args) => bareGit(args, { cwd: parent }); + const runIn = (subcommand, args) => runReadonlyGit({ subcommand, args, root: parent, git: GIT }); + + beforeAll(async () => { + base = mkdtempSync(join(tmpdir(), 'git-ro-submodule-')); + const sub = join(base, 'sub'); + parent = join(base, 'parent'); + marker = join(base, 'diff-program-ran'); + const id = ['-c', 'user.name=t', '-c', 'user.email=t@t']; + mkdirSync(sub); + await bareGit(['init', '-q'], { cwd: sub }); + writeFileSync(join(sub, 'f.txt'), 'one\n'); + await bareGit(['add', 'f.txt'], { cwd: sub }); + await bareGit([...id, 'commit', '-q', '-m', 'one'], { cwd: sub }); + mkdirSync(parent); + await inParent(['init', '-q']); + await inParent(['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', sub, 's']); + await inParent([...id, 'commit', '-q', '-m', 'add s']); + const program = join(base, 'diff-program'); + writeFileSync(program, `#!/bin/sh\ntouch '${marker}'\n`); + chmodSync(program, 0o755); + const inSub = (args) => bareGit(args, { cwd: join(parent, 's') }); + await inSub(['config', 'diff.external', program]); + writeFileSync(join(parent, 's', 'f.txt'), 'two\n'); + await inSub([...id, 'commit', '-q', '-am', 'two']); + }); + + afterAll(() => rmSync(base, { recursive: true, force: true })); + + test('--submodule=diff is refused (control: bare git runs the submodule diff program)', async () => { + await inParent(['diff', '--submodule=diff']); + expect(existsSync(marker)).toBe(true); + rmSync(marker); + const result = await runIn('diff', ['--submodule=diff']); + expect(result.ok).toBe(false); + expect(result.text).toMatch(/--submodule=diff is refused/); + expect(existsSync(marker)).toBe(false); + }); + + test('diff.submodule=diff in the repository config runs no submodule program (control: bare git does)', async () => { + await inParent(['config', 'diff.submodule', 'diff']); + try { + await inParent(['diff']); + expect(existsSync(marker)).toBe(true); + rmSync(marker); + for (const [subcommand, args] of [['diff', []], ['show', []], ['log', ['-p', '-1']]]) { + // eslint-disable-next-line no-await-in-loop + expect((await runIn(subcommand, args)).ok).toBe(true); + } + expect(existsSync(marker)).toBe(false); + } finally { + await inParent(['config', '--unset', 'diff.submodule']); + } + }); + + test('status prints no submodule summary, so no git log runs inside it (control: bare git prints one)', async () => { + await inParent(['config', 'status.submoduleSummary', 'true']); + try { + expect((await inParent(['status'])).stdout).toMatch(/Submodules changed but not updated/); + const result = await runIn('status', []); + expect(result.ok).toBe(true); + expect(result.text).toMatch(/modified: +s \(new commits\)/); + expect(result.text).not.toMatch(/Submodules changed but not updated/); + } finally { + await inParent(['config', '--unset', 'status.submoduleSummary']); + } + }); +}); diff --git a/cli/src/lib/git-readonly.js b/cli/src/lib/git-readonly.js index d06c74b535..df84ba3869 100644 --- a/cli/src/lib/git-readonly.js +++ b/cli/src/lib/git-readonly.js @@ -28,7 +28,10 @@ * same reason: `git status` runs it. What still runs is a clean filter the * repository's own config defines, when status, diff or blame read a * working-tree file: without it they report unchanged files as changed. It - * runs inside the sandbox below, and the seat cannot define one. + * runs inside the sandbox below, and the seat cannot define one. A + * submodule's git runs as a child process that gets none of these flags, so + * `--submodule=diff` is refused, and the settings that would start one are + * forced through `-c`, which does reach it (TOP_LEVEL). * 4. The environment is rebuilt from an allowlist. No inherited GIT_* variable * survives, system and global config are off, optional locks are off (so * status never rewrites the index), and GIT_CEILING_DIRECTORIES stops @@ -103,8 +106,15 @@ const FORCED_OPTIONS = Object.freeze({ blame: ['--no-textconv'], }); // `format.pretty` is the repository's default format for log and show, and a -// format can hold a %G placeholder; `medium` is git's own default. -const TOP_LEVEL = Object.freeze(['--no-pager', '-c', 'core.fsmonitor=false', '-c', 'format.pretty=medium']); +// format can hold a %G placeholder; `medium` is git's own default. The last +// three reach the child git a submodule runs in, which FORCED_OPTIONS do not: +// `diff.submodule=diff` would run git diff inside each submodule under that +// submodule's own config, `status.submoduleSummary` runs git log there, and +// `log.showSignature` would make that log run gpg. +const TOP_LEVEL = Object.freeze([ + '--no-pager', '-c', 'core.fsmonitor=false', '-c', 'format.pretty=medium', + '-c', 'diff.submodule=short', '-c', 'status.submoduleSummary=false', '-c', 'log.showSignature=false', +]); // A %G placeholder (%G?, %GG, %GS, %GK, %GF, %GP, %GT) runs gpg on each signed // commit. A value with no %, no format: and no tformat: is looked up by @@ -159,6 +169,9 @@ const escapesRoot = (root, arg) => { const checkLongOption = (arg) => { const name = arg.split('=')[0]; + if (arg.endsWith('=diff') && name.length >= 3 && '--submodule'.startsWith(name)) { + return `${arg} is refused: it runs git diff inside each submodule, where the forced options don't reach; --submodule=log lists the same commits`; + } if (Object.prototype.hasOwnProperty.call(DENIED_LONG_OPTIONS, name)) { return `${name} is refused: it ${DENIED_LONG_OPTIONS[name]}`; } diff --git a/docs-site/bring-your-own/work-in-your-repo.mdx b/docs-site/bring-your-own/work-in-your-repo.mdx index d649b67c61..7b2ae73179 100644 --- a/docs-site/bring-your-own/work-in-your-repo.mdx +++ b/docs-site/bring-your-own/work-in-your-repo.mdx @@ -27,6 +27,7 @@ The [Claude Code listener](/bring-your-own/claude-code) checks the pods its key - It can't edit files, run commands or fetch web pages. - It can't read `.env` or `.env.*` files, or what a `.env` folder holds, at any depth. Read-only git refuses them too, by name or path, including copies committed to git. Keep secrets out of git history anyway: git can't refuse a committed file asked for by its object id instead of its path. - It can't open the `.git` folder directly, because `.git/config` can hold a remote URL with a token in it. History still comes through read-only git. +- Read-only git turns off the external diff tools, conversion programs and signature checks a repository's config can name, in submodules too. A clean filter set in the repository's own git config, such as Git LFS, still runs, because `status`, `diff` and `blame` need it to tell whether a file changed. It runs inside the same read-only sandbox. Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when it runs anywhere but macOS, when a pod is publicly readable or open to join, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable or open to join, or the check fails, that turn runs in the scratch folder with a fresh session. It also gets none of the agent's saved memory and can't save any, because earlier turns may have stored what they read in your folder. From e05b9d4d675768c2eaa35d1701a3af280794fbe6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:09:45 -0700 Subject: [PATCH 24/33] fix(byo): the Connect page says the folder default needs macOS (TASK-294) The docs page gained the macOS condition in 0ecbe8619, but the copy under the listener command still promised folder reads in any private pod. Off macOS the first-run default stays public, so a Linux user was told the seat reads a folder it never gets. en and zh-CN now name macOS, and the listener test pins the en wording. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- frontend/src/i18n/locales/en.json | 2 +- frontend/src/i18n/locales/zh-CN.json | 2 +- frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index b2ef129c5c..2889d2fe50 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1587,7 +1587,7 @@ "listen": { "title": "Run this in Terminal", "note": "MCP alone won't wake {{name}} for @mentions. Keep this Terminal open.", - "reachClaude": "By default, in a private, invite-only pod, {{name}} reads only the folder you run this from. To let it edit files and run commands, add --allow write to the last line. Those commands can read beyond the folder. To let it fetch web pages, add --allow web. Either one lets it send what it reads off this computer, so grant them only in a pod you trust.", + "reachClaude": "By default, on macOS and in a private, invite-only pod, {{name}} reads only the folder you run this from. To let it edit files and run commands, add --allow write to the last line. Those commands can read beyond the folder. To let it fetch web pages, add --allow web. Either one lets it send what it reads off this computer, so grant them only in a pod you trust.", "waiting": "Waiting for {{name}} to check in… this turns green the moment the command connects.", "verified": "{{name}} is listening — mentions will wake it.", "stillWaiting": "Still no check-in from {{name}}. Run the command above in Terminal on your computer, and this page will confirm it.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 261d249ca5..4b6442bb31 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1647,7 +1647,7 @@ "listen": { "title": "在终端里运行这段命令", "note": "仅连接 MCP 不会唤醒 {{name}} 回复 @提及。请保持终端窗口开启。", - "reachClaude": "默认情况下,在私密且仅限邀请的 pod 中,{{name}} 只能读取你运行此命令的文件夹。若要让它编辑文件并运行命令,请在最后一行加上 --allow write。这些命令能读取文件夹以外的内容。若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容传出这台电脑,所以只在你信任的 pod 中授予。", + "reachClaude": "默认情况下,在 macOS 上,若 pod 私密且仅限邀请,{{name}} 只能读取你运行此命令的文件夹。若要让它编辑文件并运行命令,请在最后一行加上 --allow write。这些命令能读取文件夹以外的内容。若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容传出这台电脑,所以只在你信任的 pod 中授予。", "waiting": "正在等待 {{name}} 接入…命令一接通,这里就会变绿。", "verified": "{{name}} 已在监听 —— @提及 会唤醒它。", "stillWaiting": "{{name}} 还没接入。请在你电脑的终端里运行上面的命令,这里会自动确认。", diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx index 6bd7586d9d..601b6c534e 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx @@ -158,7 +158,7 @@ describe('BYO listener snippet', () => { test('a Claude Code listener says what it can reach and how --allow widens it; Codex says nothing of folders', async () => { await issueToken(); const reach = screen.getByTestId('byo-listen-reach'); - expect(reach).toHaveTextContent(/By default, in a private, invite-only pod, .+ reads only the folder you run this from\./); + expect(reach).toHaveTextContent(/By default, on macOS and in a private, invite-only pod, .+ reads only the folder you run this from\./); // The folder bound is the default only: write's commands read past it. expect(reach).toHaveTextContent(/add --allow write to the last line\. Those commands can read beyond the folder\./); expect(reach).toHaveTextContent(/To let it fetch web pages, add --allow web\./); From cebf403b18a12221056bfd7c893d0eac59534491 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:16:48 -0700 Subject: [PATCH 25/33] fix(cli): read-only git never contacts a remote, even in a partial clone (TASK-294) In a partial clone, read-only `show ` of an object that was never downloaded fetched it from the remote and wrote it into .git/objects (measured on git 2.54.0). The environment now sets GIT_NO_LAZY_FETCH=1, so the object is reported missing, and GIT_ALLOW_PROTOCOL to a name no transport uses, which refuses every protocol on git older than 2.44, where GIT_NO_LAZY_FETCH does nothing. Tests run a real partial clone: the guarded show fails and the object stays missing, bare git fetches it (control), and dropping either guard alone still fetches nothing. Removing both lines fails all three tests. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/git-readonly.test.mjs | 62 +++++++++++++++++++ cli/src/lib/git-readonly.js | 11 +++- .../bring-your-own/work-in-your-repo.mdx | 1 + 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/cli/__tests__/git-readonly.test.mjs b/cli/__tests__/git-readonly.test.mjs index 6d77d20324..f8c90cfe33 100644 --- a/cli/__tests__/git-readonly.test.mjs +++ b/cli/__tests__/git-readonly.test.mjs @@ -225,6 +225,8 @@ describe('read-only git runs the five subcommands', () => { expect(env.PATH).toBe('/usr/bin'); expect(env.GIT_CONFIG_NOSYSTEM).toBe('1'); expect(env.GIT_CONFIG_GLOBAL).toBe('/dev/null'); + expect(env.GIT_NO_LAZY_FETCH).toBe('1'); + expect(env.GIT_ALLOW_PROTOCOL).toBe('none'); expect(env.GIT_CEILING_DIRECTORIES.length).toBeGreaterThan(0); }); @@ -506,3 +508,63 @@ describe('read-only git holds a submodule to the same rules', () => { } }); }); + +// A partial clone leaves objects on its remote. Bare git fetches one the moment +// a command needs it; the guarded call reports it missing and contacts nothing. +describe('read-only git never contacts a remote', () => { + let base; + let clone; + const present = (oid) => bareGit(['cat-file', '-e', oid], { + cwd: clone, + env: { + ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_NO_LAZY_FETCH: '1', + }, + }).then(() => true, () => false); + const missingBlob = async (rev) => { + const oid = (await bareGit(['rev-parse', `${rev}:f.txt`], { cwd: clone })).stdout.trim(); + expect(await present(oid)).toBe(false); + return oid; + }; + + beforeAll(async () => { + base = mkdtempSync(join(tmpdir(), 'git-ro-partial-')); + const src = join(base, 'src'); + clone = join(base, 'clone'); + const id = ['-c', 'user.name=t', '-c', 'user.email=t@t']; + mkdirSync(src); + await bareGit(['init', '-q'], { cwd: src }); + await bareGit(['config', 'uploadpack.allowFilter', 'true'], { cwd: src }); + for (const content of ['one', 'two', 'three']) { + writeFileSync(join(src, 'f.txt'), `${content}\n`); + // eslint-disable-next-line no-await-in-loop + await bareGit(['add', 'f.txt'], { cwd: src }); + // eslint-disable-next-line no-await-in-loop + await bareGit([...id, 'commit', '-q', '-m', content], { cwd: src }); + } + // Only the checked-out version of f.txt is downloaded; the older two are not. + await bareGit(['clone', '-q', '--filter=blob:none', `file://${src}`, clone], { cwd: base }); + }); + + afterAll(() => rmSync(base, { recursive: true, force: true })); + + test('a missing object stays missing (control: bare git fetches it)', async () => { + const oid = await missingBlob('HEAD~1'); + const result = await runReadonlyGit({ subcommand: 'show', args: [oid], root: clone, git: GIT }); + expect(result.ok).toBe(false); + expect(await present(oid)).toBe(false); + expect((await bareGit(['show', oid], { cwd: clone })).stdout).toBe('two\n'); + expect(await present(oid)).toBe(true); + }); + + test('each guard holds alone, so git older than 2.44 fetches nothing either', async () => { + const oid = await missingBlob('HEAD~2'); + for (const dropped of ['GIT_NO_LAZY_FETCH', 'GIT_ALLOW_PROTOCOL']) { + const env = readonlyGitEnv({ root: clone }); + delete env[dropped]; + // eslint-disable-next-line no-await-in-loop + const outcome = await bareGit(['show', oid], { cwd: clone, env }).then(() => 'fetched', () => 'refused'); + expect(outcome).toBe('refused'); + } + expect(await present(oid)).toBe(false); + }); +}); diff --git a/cli/src/lib/git-readonly.js b/cli/src/lib/git-readonly.js index df84ba3869..43c532c214 100644 --- a/cli/src/lib/git-readonly.js +++ b/cli/src/lib/git-readonly.js @@ -37,6 +37,10 @@ * status never rewrites the index), and GIT_CEILING_DIRECTORIES stops * repository discovery at the workspace. The only repository git can open * is one rooted there, so `show HEAD:` cannot reach a file above it. + * No git it runs contacts a remote: lazy fetching is off, so a partial + * clone reports an object it lacks as missing instead of fetching it, and + * no transport is allowed, which covers git older than 2.44, where + * GIT_NO_LAZY_FETCH does nothing. * 5. Outside this module, the seat's sandbox (Seatbelt or bwrap) mounts the * workspace read-only. * 6. A committed `.env` or `.env.*` stays as far out of reach as the file @@ -44,8 +48,8 @@ * (`.env`, `HEAD:.env`, `:0:sub/.env.local`, `-L1,5:.env`) is refused, and * status, log, diff and show run with pathspecs excluding them, so no patch, * pickaxe match or blob id comes from one whatever path or glob the caller - * named. `--follow` takes exactly one path, so it is refused. What this cannot stop is `show ` with an id learned - * somewhere else. + * named. `--follow` takes exactly one path, so it is refused. What this + * cannot stop is `show ` with an id learned somewhere else. */ import { execFile } from 'child_process'; import { realpathSync } from 'fs'; @@ -251,6 +255,9 @@ export const readonlyGitEnv = ({ root, baseEnv = process.env }) => { GIT_ATTR_NOSYSTEM: '1', GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', + GIT_NO_LAZY_FETCH: '1', + // A name no transport uses, so every protocol is refused. + GIT_ALLOW_PROTOCOL: 'none', GIT_PAGER: 'cat', PAGER: 'cat', GIT_CEILING_DIRECTORIES: dirname(realOrResolved(root)), diff --git a/docs-site/bring-your-own/work-in-your-repo.mdx b/docs-site/bring-your-own/work-in-your-repo.mdx index 7b2ae73179..78827b0841 100644 --- a/docs-site/bring-your-own/work-in-your-repo.mdx +++ b/docs-site/bring-your-own/work-in-your-repo.mdx @@ -28,6 +28,7 @@ The [Claude Code listener](/bring-your-own/claude-code) checks the pods its key - It can't read `.env` or `.env.*` files, or what a `.env` folder holds, at any depth. Read-only git refuses them too, by name or path, including copies committed to git. Keep secrets out of git history anyway: git can't refuse a committed file asked for by its object id instead of its path. - It can't open the `.git` folder directly, because `.git/config` can hold a remote URL with a token in it. History still comes through read-only git. - Read-only git turns off the external diff tools, conversion programs and signature checks a repository's config can name, in submodules too. A clean filter set in the repository's own git config, such as Git LFS, still runs, because `status`, `diff` and `blame` need it to tell whether a file changed. It runs inside the same read-only sandbox. +- Read-only git never contacts a remote. In a partial clone, an object that was never downloaded stays missing, and git reports it as missing. Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when it runs anywhere but macOS, when a pod is publicly readable or open to join, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable or open to join, or the check fails, that turn runs in the scratch folder with a fresh session. It also gets none of the agent's saved memory and can't save any, because earlier turns may have stored what they read in your folder. From 3ce8e856df0d1bcf0abd2a11a95a5fb2f7a9fa1f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:19:05 -0700 Subject: [PATCH 26/33] docs(byo): anyone in the pods can ask a listener what its folder holds (TASK-294) The folder default is confined to invite-only pods, but everyone in them can @mention the listener and ask what a file says. The docs page now says so, and to run it from a folder you would show them. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs-site/bring-your-own/work-in-your-repo.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs-site/bring-your-own/work-in-your-repo.mdx b/docs-site/bring-your-own/work-in-your-repo.mdx index 78827b0841..846df08e23 100644 --- a/docs-site/bring-your-own/work-in-your-repo.mdx +++ b/docs-site/bring-your-own/work-in-your-repo.mdx @@ -29,6 +29,7 @@ The [Claude Code listener](/bring-your-own/claude-code) checks the pods its key - It can't open the `.git` folder directly, because `.git/config` can hold a remote URL with a token in it. History still comes through read-only git. - Read-only git turns off the external diff tools, conversion programs and signature checks a repository's config can name, in submodules too. A clean filter set in the repository's own git config, such as Git LFS, still runs, because `status`, `diff` and `blame` need it to tell whether a file changed. It runs inside the same read-only sandbox. - Read-only git never contacts a remote. In a partial clone, an object that was never downloaded stays missing, and git reports it as missing. +- Anyone who can @mention it in those pods can ask what the files there say, so run it from a folder you would show them. Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when it runs anywhere but macOS, when a pod is publicly readable or open to join, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable or open to join, or the check fails, that turn runs in the scratch folder with a fresh session. It also gets none of the agent's saved memory and can't save any, because earlier turns may have stored what they read in your folder. From b28e35e63fbbc381970c1ed4b5ba8c491dcaf8c5 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:23:47 -0700 Subject: [PATCH 27/33] fix(cli): read-only git refuses --full-diff, which shows .env past the excludes (TASK-294) With --full-diff, git log's pathspecs pick commits but no longer limit their diffs, so a commit that changed a .env alongside an ordinary file printed the .env patch (and its blob ids under --raw) despite the excludes. CodeRabbit r4372882967; measured with the guarded argv. The test runs the guarded `log -p` argv as is (no .env) and with --full-diff spliced in (the .env patch shows), then checks log and show refuse it, abbreviations included. A sweep of 41 other log, show, diff, status and blame forms found no other way past the excludes. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/git-readonly.test.mjs | 26 ++++++++++++++++++++++++++ cli/src/lib/git-readonly.js | 7 +++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/cli/__tests__/git-readonly.test.mjs b/cli/__tests__/git-readonly.test.mjs index f8c90cfe33..e8689e7c52 100644 --- a/cli/__tests__/git-readonly.test.mjs +++ b/cli/__tests__/git-readonly.test.mjs @@ -284,6 +284,32 @@ describe('read-only git keeps committed .env files out of reach', () => { } }); + test('--full-diff is refused, since it shows every file a picked commit changed (control: the guarded argv plus it shows .env)', async () => { + const own = mkdtempSync(join(tmpdir(), 'git-ro-full-diff-')); + try { + const inOwn = (args) => bareGit(args, { cwd: own }); + await inOwn(['init', '-q']); + writeFileSync(join(own, '.env'), `TOKEN=${SECRET}\n`); + writeFileSync(join(own, 'a.txt'), 'a\n'); + await inOwn(['add', '-f', '.env', 'a.txt']); + await inOwn(['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'both']); + // The guarded `log -p` argv, run as is and with --full-diff spliced in. + const { argv } = readonlyGitArgv({ subcommand: 'log', args: ['-p'], root: own }); + const at = argv.indexOf('log') + 1; + const env = readonlyGitEnv({ root: own }); + expect((await execFileAsync(GIT, argv, { cwd: own, env })).stdout).not.toContain(SECRET); + const spliced = [...argv.slice(0, at), '--full-diff', ...argv.slice(at)]; + expect((await execFileAsync(GIT, spliced, { cwd: own, env })).stdout).toContain(SECRET); + for (const [subcommand, args] of [['log', ['-p', '--full-diff']], ['log', ['--raw', '--full-diff']], + ['log', ['--full-d']], ['show', ['--full-diff']]]) { + expect(refusal({ subcommand, args })).toMatch(/--full-diff/); + } + expect((await run('log', ['-p', '--full-history'])).ok).toBe(true); + } finally { + rmSync(own, { recursive: true, force: true }); + } + }); + test('a patch, a glob, a pickaxe and a raw listing all leave it out (control: bare log -p shows it)', async () => { expect((await bareGit(['log', '-p'])).stdout).toContain(SECRET); for (const [subcommand, args] of [['log', ['-p']], ['log', ['-p', '--', '.en*', 'sub/*']], diff --git a/cli/src/lib/git-readonly.js b/cli/src/lib/git-readonly.js index 43c532c214..b82ce49f6f 100644 --- a/cli/src/lib/git-readonly.js +++ b/cli/src/lib/git-readonly.js @@ -48,8 +48,10 @@ * (`.env`, `HEAD:.env`, `:0:sub/.env.local`, `-L1,5:.env`) is refused, and * status, log, diff and show run with pathspecs excluding them, so no patch, * pickaxe match or blob id comes from one whatever path or glob the caller - * named. `--follow` takes exactly one path, so it is refused. What this - * cannot stop is `show ` with an id learned somewhere else. + * named. `--follow` takes exactly one path, so it is refused, and so is + * `--full-diff`, which lets pathspecs pick commits and then shows every + * file each one changed. What this cannot stop is `show ` with an + * id learned somewhere else. */ import { execFile } from 'child_process'; import { realpathSync } from 'fs'; @@ -74,6 +76,7 @@ export const DENIED_LONG_OPTIONS = Object.freeze({ '--paginate': 'runs a pager', '--show-signature': 'runs gpg to check a signature', '--follow': 'needs exactly one path, and every log here also carries the .env excludes', + '--full-diff': 'shows every file a picked commit changed, .env files included', }); // Real options that are also prefixes of a denied one. Git takes an exact name From 488c64a142a185adf7bb6a66966703125ec43e84 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:29:06 -0700 Subject: [PATCH 28/33] test(cli): test git inherits no GIT_* from the shell (TASK-294) Under a git hook, GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE point git at the outer repository. The read-only git tests and the server test ran their own git with the whole process environment, so inside a hook they would have used the outer repository instead of their temp repos. Test git now drops every inherited GIT_* variable and keeps system and global config off. The controls stay bare git on purpose, so they keep their own environment rather than the guarded one. Measured with GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE set to a scratch repository: before, 49 of 49 tests in the two files failed; after, 49 of 49 pass. CLI: 1,771 passed, 18 skipped; lint clean. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/git-readonly-server.test.mjs | 8 ++++++- cli/__tests__/git-readonly.test.mjs | 25 +++++++++++++--------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/cli/__tests__/git-readonly-server.test.mjs b/cli/__tests__/git-readonly-server.test.mjs index 61045b6a19..c611adf286 100644 --- a/cli/__tests__/git-readonly-server.test.mjs +++ b/cli/__tests__/git-readonly-server.test.mjs @@ -25,7 +25,13 @@ let repo; beforeAll(async () => { repo = mkdtempSync(join(tmpdir(), 'git-ro-srv-')); - const env = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }; + // No GIT_* from the shell: under a git hook, GIT_DIR and GIT_WORK_TREE + // would point this git at the outer repository. + const env = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))), + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + }; await execFileAsync('git', ['init', '-q', '-b', 'main'], { cwd: repo, env }); await execFileAsync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo, env }); await execFileAsync('git', ['config', 'user.name', 'Test'], { cwd: repo, env }); diff --git a/cli/__tests__/git-readonly.test.mjs b/cli/__tests__/git-readonly.test.mjs index e8689e7c52..66fdc130c1 100644 --- a/cli/__tests__/git-readonly.test.mjs +++ b/cli/__tests__/git-readonly.test.mjs @@ -23,6 +23,15 @@ import { const execFileAsync = promisify(execFile); const GIT = 'git'; +// Test git inherits no GIT_* from the shell: under a git hook, GIT_DIR, +// GIT_WORK_TREE and GIT_INDEX_FILE would point it at the outer repository. +// System and global config are off, so a developer's own settings can't +// change what a control shows. +const testGitEnv = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))), + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', +}; // A repo with one commit, a tracked file, and a second directory beside it to // reach for. Every test runs real git here, so refusals are measured, not @@ -32,7 +41,7 @@ let outside; const bareGit = (args, opts = {}) => execFileAsync(GIT, args, { cwd: repo, - env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, + env: testGitEnv, ...opts, }); @@ -235,10 +244,10 @@ describe('read-only git runs the five subcommands', () => { // walks up and finds the parent; ours stops at the ceiling. const parent = mkdtempSync(join(tmpdir(), 'git-ro-parent-')); try { - await execFileAsync(GIT, ['init', '-q', parent]); + await bareGit(['init', '-q', parent]); const nested = join(parent, 'nested'); mkdirSync(nested); - const bare = await execFileAsync(GIT, ['status'], { cwd: nested }).then(() => 'found', () => 'not-found'); + const bare = await bareGit(['status'], { cwd: nested }).then(() => 'found', () => 'not-found'); expect(bare).toBe('found'); // control: bare git walks up to the parent repo const confined = await runReadonlyGit({ subcommand: 'status', args: [], root: nested, git: GIT }); expect(confined.ok).toBe(false); @@ -343,9 +352,7 @@ describe('read-only git runs no gpg and no textconv', () => { let signed; const cfgGit = (args, opts = {}) => execFileAsync(GIT, args, { cwd: cfgRepo, - env: { - ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', PATH: `${bin}:${process.env.PATH}`, - }, + env: { ...testGitEnv, PATH: `${bin}:${process.env.PATH}` }, ...opts, }); const run = (subcommand, args) => runReadonlyGit({ @@ -378,7 +385,7 @@ describe('read-only git runs no gpg and no textconv', () => { const { stdout: body } = await cfgGit(['cat-file', 'commit', 'HEAD']); const split = body.indexOf('\n\n'); const header = ['gpgsig -----BEGIN PGP SIGNATURE-----', ' ', ' aGVsbG8=', ' -----END PGP SIGNATURE-----'].join('\n'); - const child = execFile(GIT, ['hash-object', '-t', 'commit', '-w', '--stdin'], { cwd: cfgRepo }); + const child = execFile(GIT, ['hash-object', '-t', 'commit', '-w', '--stdin'], { cwd: cfgRepo, env: testGitEnv }); const out = new Promise((done) => { let text = ''; child.stdout.on('data', (d) => { text += d; }); child.on('close', () => done(text)); }); child.stdin.end(`${body.slice(0, split)}\n${header}${body.slice(split)}`); signed = (await out).trim(); @@ -542,9 +549,7 @@ describe('read-only git never contacts a remote', () => { let clone; const present = (oid) => bareGit(['cat-file', '-e', oid], { cwd: clone, - env: { - ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_NO_LAZY_FETCH: '1', - }, + env: { ...testGitEnv, GIT_NO_LAZY_FETCH: '1' }, }).then(() => true, () => false); const missingBlob = async (rev) => { const oid = (await bareGit(['rev-parse', `${rev}:f.txt`], { cwd: clone })).stdout.trim(); From 97f5393e676a08029ff975d7a44161b127bc2687 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:29:38 -0700 Subject: [PATCH 29/33] fix(frontend): the Connect page states every condition for folder access (TASK-294) The listener note named macOS and a private, invite-only pod, but not the other two conditions that decide it: every pod the seat is in must also be unlisted in Community, and a seat started from the home folder keeps its scratch folder. A reader who met only the old line could expect folder access that the seat would not get. The note now says that on macOS, when every pod it's in is private, invite-only and not listed in Community, the seat reads only the folder it was run from, and that otherwise, or from the home folder, it works in its own scratch folder. Same change in en and zh-CN; the snippet test asserts the new sentence. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- frontend/src/i18n/locales/en.json | 2 +- frontend/src/i18n/locales/zh-CN.json | 2 +- frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 2889d2fe50..41de6e9fc2 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1587,7 +1587,7 @@ "listen": { "title": "Run this in Terminal", "note": "MCP alone won't wake {{name}} for @mentions. Keep this Terminal open.", - "reachClaude": "By default, on macOS and in a private, invite-only pod, {{name}} reads only the folder you run this from. To let it edit files and run commands, add --allow write to the last line. Those commands can read beyond the folder. To let it fetch web pages, add --allow web. Either one lets it send what it reads off this computer, so grant them only in a pod you trust.", + "reachClaude": "By default, on macOS, when every pod it's in is private, invite-only and not listed in Community, {{name}} reads only the folder you run this from. Otherwise, or when you run it from your home folder, it works in its own scratch folder. To let it edit files and run commands, add --allow write to the last line. Those commands can read beyond the folder. To let it fetch web pages, add --allow web. Either one lets it send what it reads off this computer, so grant them only in a pod you trust.", "waiting": "Waiting for {{name}} to check in… this turns green the moment the command connects.", "verified": "{{name}} is listening — mentions will wake it.", "stillWaiting": "Still no check-in from {{name}}. Run the command above in Terminal on your computer, and this page will confirm it.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 4b6442bb31..92a1beff90 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1647,7 +1647,7 @@ "listen": { "title": "在终端里运行这段命令", "note": "仅连接 MCP 不会唤醒 {{name}} 回复 @提及。请保持终端窗口开启。", - "reachClaude": "默认情况下,在 macOS 上,若 pod 私密且仅限邀请,{{name}} 只能读取你运行此命令的文件夹。若要让它编辑文件并运行命令,请在最后一行加上 --allow write。这些命令能读取文件夹以外的内容。若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容传出这台电脑,所以只在你信任的 pod 中授予。", + "reachClaude": "默认情况下,在 macOS 上,若它所在的每个 pod 都私密、仅限邀请且未列入「社区」,{{name}} 只能读取你运行此命令的文件夹。否则,或者你从主目录运行它时,它会在自己的临时文件夹中工作。若要让它编辑文件并运行命令,请在最后一行加上 --allow write。这些命令能读取文件夹以外的内容。若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容传出这台电脑,所以只在你信任的 pod 中授予。", "waiting": "正在等待 {{name}} 接入…命令一接通,这里就会变绿。", "verified": "{{name}} 已在监听 —— @提及 会唤醒它。", "stillWaiting": "{{name}} 还没接入。请在你电脑的终端里运行上面的命令,这里会自动确认。", diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx index 601b6c534e..0997017e59 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx @@ -158,7 +158,7 @@ describe('BYO listener snippet', () => { test('a Claude Code listener says what it can reach and how --allow widens it; Codex says nothing of folders', async () => { await issueToken(); const reach = screen.getByTestId('byo-listen-reach'); - expect(reach).toHaveTextContent(/By default, on macOS and in a private, invite-only pod, .+ reads only the folder you run this from\./); + expect(reach).toHaveTextContent(/By default, on macOS, when every pod it's in is private, invite-only and not listed in Community, .+ reads only the folder you run this from\. Otherwise, or when you run it from your home folder, it works in its own scratch folder\./); // The folder bound is the default only: write's commands read past it. expect(reach).toHaveTextContent(/add --allow write to the last line\. Those commands can read beyond the folder\./); expect(reach).toHaveTextContent(/To let it fetch web pages, add --allow web\./); From a0c37204745b135c4f5b1817a16e491651f24639 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:35:17 -0700 Subject: [PATCH 30/33] docs(byo): the scratch-folder cases include a pod listed in Community (TASK-294) The section defines a private pod as invite-only, not publicly readable and not listed in Community, and both the first-run check and the per-turn check use all three (allPodsConfirmedPrivate). The list of cases that fall back to the scratch folder named only the first two, at first run and before each turn. Both lists now name all three. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs-site/bring-your-own/work-in-your-repo.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/bring-your-own/work-in-your-repo.mdx b/docs-site/bring-your-own/work-in-your-repo.mdx index 846df08e23..7e3cc55970 100644 --- a/docs-site/bring-your-own/work-in-your-repo.mdx +++ b/docs-site/bring-your-own/work-in-your-repo.mdx @@ -31,7 +31,7 @@ The [Claude Code listener](/bring-your-own/claude-code) checks the pods its key - Read-only git never contacts a remote. In a partial clone, an object that was never downloaded stays missing, and git reports it as missing. - Anyone who can @mention it in those pods can ask what the files there say, so run it from a folder you would show them. -Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when it runs anywhere but macOS, when a pod is publicly readable or open to join, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable or open to join, or the check fails, that turn runs in the scratch folder with a fresh session. It also gets none of the agent's saved memory and can't save any, because earlier turns may have stored what they read in your folder. +Otherwise it reads only its own scratch folder, and the lines it prints when it starts say why. That happens when it runs anywhere but macOS, when a pod is publicly readable, listed in Community or open to join, or when you run it from your home directory, `/`, or a folder like `~/.ssh`. It checks its pods again before every turn. If one has become publicly readable, listed in Community or open to join, or the check fails, that turn runs in the scratch folder with a fresh session. It also gets none of the agent's saved memory and can't save any, because earlier turns may have stored what they read in your folder. ### Give it more with `--allow` From bb7c6caa4fd6625e83291bdc3a72d7c33d02b7ca Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:14:23 -0700 Subject: [PATCH 31/33] fix(cli): read-only git takes -L only where git is sure to read it as a line range (TASK-294) A line range takes no pathspec, so a call with one runs without the .env excludes. The validator turned them off for any token starting with -L, but after another argument git can read that token as the option's value, and a bare -L takes the next argument as its range. Now -L is taken only for log and show, as the first argument, with its range in the same argument; status and diff refuse it, since git takes no -L there. blame is unchanged: its -L names no file and blame takes no excludes. Answers CodeRabbit r4373012618. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- cli/__tests__/git-readonly.test.mjs | 18 ++++++++++++++- cli/src/lib/git-readonly.js | 34 ++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/cli/__tests__/git-readonly.test.mjs b/cli/__tests__/git-readonly.test.mjs index 66fdc130c1..fe62bd76c4 100644 --- a/cli/__tests__/git-readonly.test.mjs +++ b/cli/__tests__/git-readonly.test.mjs @@ -282,7 +282,7 @@ describe('read-only git keeps committed .env files out of reach', () => { } expect(refusal({ subcommand: 'blame', args: ['sub/.env.local'] })).toMatch(/names a \.env file/); expect(refusal({ subcommand: 'log', args: ['-L1,1:.env'] })).toMatch(/names a \.env file/); - expect(refusal({ subcommand: 'log', args: ['-L', '1,1:sub/.env.local'] })).toMatch(/names a \.env file/); + expect(refusal({ subcommand: 'log', args: ['-L1,1:sub/.env.local'] })).toMatch(/names a \.env file/); }); test('--follow is refused, since it takes one path and the excludes add more (control: git rejects it with them)', async () => { @@ -319,6 +319,22 @@ describe('read-only git keeps committed .env files out of reach', () => { } }); + test('-L turns the excludes off only where git is sure to read it as a line range: log or show, first, in one piece', async () => { + // CodeRabbit r4373012618: after another argument, git can read -L… as that option's value. + expect(refusal({ subcommand: 'diff', args: ['--src-prefix', '-Lx', 'HEAD~1', 'HEAD'] })).toMatch(/-L is refused for diff/); + expect(refusal({ subcommand: 'status', args: ['-L1,1:tracked.txt'] })).toMatch(/-L is refused for status/); + for (const subcommand of ['log', 'show']) { + for (const args of [['-p', '-L1,1:tracked.txt'], ['-L', '1,1:tracked.txt'], ['-L1,1:tracked.txt', '-L1,1:tracked.txt']]) { + expect(refusal({ subcommand, args })).toMatch(/as the first argument and in one piece/); + } + expect(readonlyGitArgv({ subcommand, args: ['-L1,1:tracked.txt', '-p'], root: repo }).argv.slice(-2)) + .toEqual(['-L1,1:tracked.txt', '-p']); + expect((await run(subcommand, ['-L1,1:tracked.txt'])).text).toContain('first line'); + } + // blame's -L names no file and blame takes no excludes, so it keeps its usual form. + expect((await run('blame', ['-L', '1,1', 'tracked.txt'])).text).toContain('first line'); + }); + test('a patch, a glob, a pickaxe and a raw listing all leave it out (control: bare log -p shows it)', async () => { expect((await bareGit(['log', '-p'])).stdout).toContain(SECRET); for (const [subcommand, args] of [['log', ['-p']], ['log', ['-p', '--', '.en*', 'sub/*']], diff --git a/cli/src/lib/git-readonly.js b/cli/src/lib/git-readonly.js index b82ce49f6f..6cdc9825e4 100644 --- a/cli/src/lib/git-readonly.js +++ b/cli/src/lib/git-readonly.js @@ -50,8 +50,11 @@ * pickaxe match or blob id comes from one whatever path or glob the caller * named. `--follow` takes exactly one path, so it is refused, and so is * `--full-diff`, which lets pathspecs pick commits and then shows every - * file each one changed. What this cannot stop is `show ` with an - * id learned somewhere else. + * file each one changed. A line range (log and show `-L:`) + * takes no pathspec, so it is taken only as the first argument, in one + * piece, where git cannot read it as another option's value, and its file + * is name-checked. What this cannot stop is `show ` with an id + * learned somewhere else. */ import { execFile } from 'child_process'; import { realpathSync } from 'fs'; @@ -149,10 +152,25 @@ const ENV_EXCLUDES = Object.freeze([ ]); // blame takes one literal file, so the name check above covers it. A // blob-to-blob diff (`diff A:f B:f`) takes no pathspec and is refused by git -// here; `diff A B -- f` is the same diff. log -L also takes none, so its file -// is name-checked instead. +// here; `diff A B -- f` is the same diff. A line range takes none either (see +// checkLineRange), so its file is name-checked instead. const PATHSPEC_SUBCOMMANDS = new Set(['status', 'log', 'diff', 'show']); +// log and show trace a line range (-L:), and git refuses a +// pathspec with one, so that call runs without the .env excludes. Only a token +// git is sure to read as -L may turn them off: the first argument, with its +// range in it. After another argument, git can read it as that option's value; +// a bare -L takes the next argument as its range, which the name check may +// read as an option instead. status and diff take no -L at all. +const LINE_RANGE_SUBCOMMANDS = new Set(['log', 'show']); +const checkLineRange = (arg, index, subcommand) => { + if (!LINE_RANGE_SUBCOMMANDS.has(subcommand)) return `-L is refused for ${subcommand}: only log and show trace a line range`; + if (index > 0 || arg.length === 2) { + return `${arg} is refused: give one line range, as the first argument and in one piece (-L,:), so git cannot read it as another option's value`; + } + return null; +}; + const MAX_ARGS = 64; const MAX_ARG_LENGTH = 4096; export const MAX_OUTPUT_CHARS = 50 * 1024; @@ -220,7 +238,7 @@ export const readonlyGitArgv = ({ subcommand, args = [], root }) => { if (typeof root !== 'string' || !isAbsolute(root)) return refuse('the workspace is not an absolute path'); let pathsOnly = false; let lineRange = false; - for (const arg of args) { + for (const [index, arg] of args.entries()) { if (arg.length > MAX_ARG_LENGTH || arg.includes('\0')) return refuse('an argument is too long or contains NUL'); if (pathsOnly || arg === '-' || !arg.startsWith('-')) { if (escapesRoot(root, arg)) return refuse(`${arg} is refused: it is outside the workspace`); @@ -231,8 +249,12 @@ export const readonlyGitArgv = ({ subcommand, args = [], root }) => { const reason = arg.startsWith('--') ? checkLongOption(arg) || checkFormat(arg) : checkShortOption(arg, subcommand); if (reason) return refuse(reason); if (arg.startsWith('-L')) { - lineRange = true; if (namesEnvFile(arg.slice(2))) return refuse(`${arg} is refused: ${NAMES_ENV_FILE}`); + if (PATHSPEC_SUBCOMMANDS.has(subcommand)) { + const lineRangeReason = checkLineRange(arg, index, subcommand); + if (lineRangeReason) return refuse(lineRangeReason); + lineRange = true; + } } } } From 885e8bf00ed08cfa47c8ded7c1a162d49d3ceda3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:14:23 -0700 Subject: [PATCH 32/33] test(byo): a Linux Claude Code setup shows no folder guidance (TASK-294) The Linux test now checks that the reach note is absent when the listener is unavailable, so removing the listenerAvailable guard fails it (CodeRabbit nitpick on review 5646054733). Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx index 0997017e59..d80394ffbc 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOListenSnippet.test.tsx @@ -258,6 +258,7 @@ describe('BYO listener snippet', () => { await issueToken(); expect(screen.getByTestId('byo-setup-os-linux')).toHaveAttribute('aria-pressed', 'true'); expect(listener()).toBeUndefined(); + expect(screen.queryByTestId('byo-listen-reach')).toBeNull(); expect(screen.getByTestId('byo-listen-unavailable')).toHaveTextContent(/from Linux yet/); expect(mcpBox()).toMatch(/-- npx -y @commonlyai\/mcp$/); expect(screen.getByTestId('byo-mcp-claude')).not.toHaveTextContent(/command above|Optional/); From ae1030363dafc382efe9b2f3e356b81667a6ff64 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:01:16 -0700 Subject: [PATCH 33/33] fix(connect): the zh-CN folder-access line follows the zh-CN glossary (TASK-294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agentByo.listen.reachClaude went through the zh-CN two-pass review. Pod follows the glossary. macOS now sits inside the condition, so "otherwise" also covers other systems, which is the CLI's rule. The home folder is 个人文件夹, and the string now says either flag alone is enough. 仅限邀请 stays so the string names the label the setting shows. The change table is in the PR body. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- frontend/src/i18n/locales/zh-CN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 92a1beff90..f621a214a7 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1647,7 +1647,7 @@ "listen": { "title": "在终端里运行这段命令", "note": "仅连接 MCP 不会唤醒 {{name}} 回复 @提及。请保持终端窗口开启。", - "reachClaude": "默认情况下,在 macOS 上,若它所在的每个 pod 都私密、仅限邀请且未列入「社区」,{{name}} 只能读取你运行此命令的文件夹。否则,或者你从主目录运行它时,它会在自己的临时文件夹中工作。若要让它编辑文件并运行命令,请在最后一行加上 --allow write。这些命令能读取文件夹以外的内容。若要让它抓取网页,请加上 --allow web。两者都能让它把读到的内容传出这台电脑,所以只在你信任的 pod 中授予。", + "reachClaude": "默认情况下,如果你用的是 macOS,并且 {{name}} 所在的每个 Pod 都是私密、仅限邀请且未列入「社区」的,它只能读取你运行这段命令时所在的文件夹。不满足这些条件,或者你在个人文件夹里运行这段命令,它都会在自己的临时文件夹里工作。想让它编辑文件、运行命令,就在最后一行加上 --allow write。它运行的命令能读取文件夹以外的内容。想让它抓取网页,就加上 --allow web。无论加哪一个,它都能把读到的内容传出这台电脑,所以只在你信任的 Pod 里授予这两项权限。", "waiting": "正在等待 {{name}} 接入…命令一接通,这里就会变绿。", "verified": "{{name}} 已在监听 —— @提及 会唤醒它。", "stillWaiting": "{{name}} 还没接入。请在你电脑的终端里运行上面的命令,这里会自动确认。",