Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions cli/__tests__/adapters.claude.environment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ await jest.unstable_mockModule('child_process', () => ({
}));

const claude = (await import('../src/lib/adapters/claude.js')).default;
const { spawnSync } = await import('child_process');

const fakeChild = ({ stdout = '', code = 0 } = {}) => {
const proc = new EventEmitter();
Expand Down Expand Up @@ -177,6 +178,93 @@ describe('claude adapter — ctx.environment', () => {
expect(fs.existsSync(path.join(cwd, '.claude'))).toBe(false);
});

test('public workspace mode wraps Claude in deny-default Seatbelt with isolated state and env', async () => {
const originalPlatform = process.platform;
const publicState = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-public-state-'));
const { impl, calls } = makeSpawnImpl();
try {
Object.defineProperty(process, 'platform', { value: 'darwin' });
spawnSync.mockImplementation((cmd) => (
cmd === 'which'
? { status: 0, stdout: '/usr/bin/true\n' }
: { status: 0, stdout: '' }
));

await claude.spawn('hi', {
agentName: 'public-test',
sessionId: null,
cwd,
env: {
PATH: process.env.PATH,
USER: 'safe-user',
LOGNAME: 'safe-user',
COMMONLY_HOST_SECRET: 'must-not-inherit',
cm_agent_probe: 'must-not-inherit',
},
environment: {
sandbox: { mode: 'workspace', trust: 'public' },
mcp: [{
name: 'capture',
transport: 'stdio',
command: [process.execPath, path.join(cwd, 'server.mjs')],
}],
},
_publicClaudeState: publicState,
_spawnImpl: impl,
});

expect(calls).toHaveLength(1);
const call = calls[0];
expect(call.cmd).toBe('/usr/bin/sandbox-exec');
expect(call.args[0]).toBe('-p');
const profile = call.args[1];
expect(profile).toContain('(deny default)');
expect(profile).toContain(`(subpath "${fs.realpathSync(cwd)}")`);
expect(profile).toContain(
`(deny file-read* file-write* (subpath "${path.join(fs.realpathSync(cwd), '.commonly')}"))`,
);
expect(call.args).toContain('--setting-sources');
expect(call.args).toContain('--strict-mcp-config');
expect(call.args).toContain('--permission-mode');
expect(call.args).toContain('Read(./**)');
expect(call.args).toContain('mcp__capture__*');
expect(call.args).toContain('Bash');
expect(call.opts.env.HOME).toBe(publicState);
expect(call.opts.env.TMPDIR).toBe(path.join(publicState, 'tmp'));
expect(call.opts.env.USER).toBe('safe-user');
expect(call.opts.env.COMMONLY_HOST_SECRET).toBeUndefined();
expect(call.opts.env.cm_agent_probe).toBeUndefined();

const sanitized = JSON.parse(
fs.readFileSync(path.join(publicState, '.claude.json'), 'utf8'),
);
expect(sanitized.hasCompletedOnboarding).toBe(true);
expect(Object.keys(sanitized).every((key) => [
'hasCompletedOnboarding',
'installMethod',
'userID',
'machineID',
'oauthAccount',
].includes(key))).toBe(true);
expect(fs.statSync(path.join(publicState, '.claude.json')).mode & 0o777).toBe(0o600);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform });
spawnSync.mockReset();
fs.rmSync(publicState, { recursive: true, force: true });
}
});

test('public trust with sandbox.mode=none refuses to spawn', async () => {
const { impl, calls } = makeSpawnImpl();
await expect(claude.spawn('hi', {
sessionId: null,
cwd,
environment: { sandbox: { mode: 'none', trust: 'public' } },
_spawnImpl: impl,
})).rejects.toThrow(/require an enforced sandbox mode/);
expect(calls).toHaveLength(0);
});

// ── ${COMMONLY_*} placeholder substitution ────────────────────────────────
// Lets users keep their checked-in env files free of secrets — the
// wrapper substitutes the runtime token + instance URL at spawn time
Expand Down
111 changes: 111 additions & 0 deletions cli/__tests__/seatbelt.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';

import {
buildSeatbeltProfile,
detectSeatbelt,
wrapArgvWithSeatbelt,
} from '../src/lib/sandbox/seatbelt.js';

describe('macOS Seatbelt profile', () => {
let root;
let workspace;
let state;
let mcp;

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'commonly-seatbelt-test-'));
workspace = join(root, 'workspace');
state = join(root, 'state');
mcp = join(root, 'mcp');
for (const path of [workspace, state, mcp]) {
mkdirSync(path, { recursive: true });
}
});

afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

test('builds a deny-default write profile with only explicit dynamic roots', () => {
const profile = buildSeatbeltProfile({
workspacePath: workspace,
workspaceAccess: 'write',
claudePath: '/usr/bin/true',
statePath: state,
mcpConfigDir: mcp,
executablePaths: [process.execPath],
});
const resolvedWorkspace = realpathSync(workspace);
const resolvedState = realpathSync(state);
const resolvedMcp = realpathSync(mcp);

expect(profile).toContain('(deny default)');
expect(profile).not.toContain('(allow default)');
expect(profile).toContain(
`(allow file-read* file-test-existence file-write* (subpath "${resolvedWorkspace}"))`,
);
expect(profile).toContain(
`(allow file-read* file-test-existence file-write* (subpath "${resolvedState}"))`,
);
expect(profile).toContain(
`(allow file-read* file-test-existence (subpath "${resolvedMcp}"))`,
);
expect(profile).toContain(
`(deny file-read* file-write* (subpath "${join(resolvedWorkspace, '.commonly')}"))`,
);
expect(profile).toContain(
`(deny file-read* file-write* (subpath "${join(resolvedWorkspace, '.codex')}"))`,
);
expect(profile).not.toContain(`(subpath "${process.env.HOME}")`);
});

test('read-only mode never grants workspace writes', () => {
const profile = buildSeatbeltProfile({
workspacePath: workspace,
workspaceAccess: 'read',
claudePath: '/usr/bin/true',
statePath: state,
});
const resolvedWorkspace = realpathSync(workspace);

expect(profile).toContain(
`(allow file-read* file-test-existence (subpath "${resolvedWorkspace}"))`,
);
expect(profile).not.toContain(
`(allow file-read* file-test-existence file-write* (subpath "${resolvedWorkspace}"))`,
);
});

test('rejects relative dynamic paths', () => {
expect(() => buildSeatbeltProfile({
workspacePath: 'relative',
claudePath: '/usr/bin/true',
statePath: state,
})).toThrow(/workspacePath must be an absolute path/);
});

test('wrapper is fail-closed off macOS and invokes sandbox-exec on macOS', () => {
if (process.platform !== 'darwin') {
expect(() => wrapArgvWithSeatbelt(['/usr/bin/true'], {
workspacePath: workspace,
claudePath: '/usr/bin/true',
statePath: state,
})).toThrow(/available only on macOS/);
expect(detectSeatbelt()).toMatchObject({ available: false });
return;
}

const detected = detectSeatbelt();
expect(detected).toMatchObject({ available: true, path: '/usr/bin/sandbox-exec' });
const argv = wrapArgvWithSeatbelt(['/usr/bin/true'], {
workspacePath: workspace,
claudePath: '/usr/bin/true',
statePath: state,
});
expect(argv[0]).toBe('/usr/bin/sandbox-exec');
expect(argv[1]).toBe('-p');
expect(argv.at(-1)).toBe('/usr/bin/true');
});
});
175 changes: 175 additions & 0 deletions cli/scripts/security/mock-claude-sandbox-mcp.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env node

/**
* Live-attack MCP server for verify-public-claude-seatbelt.mjs.
*
* Unlike the Codex capture server, this one exposes direct filesystem and
* process attacks. It runs as a child of Claude and therefore inherits the
* outer Seatbelt policy; a denied result proves the kernel boundary applies
* to declared MCP children as well as Claude's built-in tools.
*/

import { execFile } from 'child_process';
import { appendFile, readFile, writeFile } from 'fs/promises';
import readline from 'readline';

const captureFile = process.env.CAPTURE_FILE;
const debugFile = process.env.DEBUG_FILE;
if (!captureFile) {
process.stderr.write('CAPTURE_FILE is required\n');
process.exit(2);
}

const debug = async (event) => {
if (!debugFile) return;
await appendFile(debugFile, `${JSON.stringify(event)}\n`, 'utf8');
};

const reply = (id, result) => {
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
};

const toolResult = (text) => ({
content: [{ type: 'text', text }],
});

const safeError = (err) => `OS_ERROR ${err?.code || 'UNKNOWN'}: ${err?.message || String(err)}`;

const exec = (file, args) => new Promise((resolve) => {
execFile(file, args, { encoding: 'utf8' }, (err, stdout, stderr) => {
resolve(err ? safeError(err) : `${stdout}${stderr}`.trim());
});
});

const tools = [
{
name: 'attempt_file_read',
description: 'Attempt to read an exact path and return content or the raw OS error.',
inputSchema: {
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
additionalProperties: false,
},
},
{
name: 'attempt_file_write',
description: 'Attempt to write content to an exact path and return success or the raw OS error.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string' },
content: { type: 'string' },
},
required: ['path', 'content'],
additionalProperties: false,
},
},
{
name: 'attempt_shell_read',
description: 'Attempt an exact /bin/sh cat command and return output or the raw OS error.',
inputSchema: {
type: 'object',
properties: { path: { type: 'string' } },
required: ['path'],
additionalProperties: false,
},
},
{
name: 'inspect_environment',
description: 'Return only environment variable names that look like Commonly credentials.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: 'record_observation',
description: 'Record an access-control observation supplied by the caller.',
inputSchema: {
type: 'object',
properties: {
source: { type: 'string' },
content: { type: 'string' },
},
required: ['source', 'content'],
additionalProperties: false,
},
},
];

await debug({ event: 'started', pid: process.pid });

const input = readline.createInterface({
input: process.stdin,
crlfDelay: Infinity,
});

input.on('line', async (line) => {
let message;
try {
message = JSON.parse(line);
} catch (err) {
await debug({ event: 'parse-error', line, error: err.message });
return;
}
await debug({ event: 'message', method: message.method, id: message.id });

if (message.method === 'initialize') {
reply(message.id, {
protocolVersion: message.params?.protocolVersion || '2025-06-18',
capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'commonly-claude-sandbox-attack', version: '1.0.0' },
});
return;
}
if (message.method === 'tools/list') {
reply(message.id, { tools });
return;
}
if (message.method !== 'tools/call') return;

const args = message.params?.arguments || {};
let output;
if (message.params?.name === 'attempt_file_read') {
try {
output = await readFile(args.path, 'utf8');
} catch (err) {
output = safeError(err);
}
} else if (message.params?.name === 'attempt_file_write') {
try {
await writeFile(args.path, args.content, 'utf8');
output = 'WRITE_OK';
} catch (err) {
output = safeError(err);
}
} else if (message.params?.name === 'attempt_shell_read') {
output = await exec('/bin/sh', ['-c', `cat -- "$1"`, 'sandbox-attack', args.path]);
} else if (message.params?.name === 'inspect_environment') {
output = JSON.stringify(Object.keys(process.env)
.filter((key) => /^(?:COMMONLY_|cm_agent_)/i.test(key))
.sort());
} else if (message.params?.name === 'record_observation') {
output = 'OBSERVATION_RECORDED';
} else {
reply(message.id, {
content: [{ type: 'text', text: 'unknown tool' }],
isError: true,
});
return;
}

await appendFile(
captureFile,
`${JSON.stringify({
tool: message.params.name,
arguments: args,
output,
})}\n`,
'utf8',
);
reply(message.id, toolResult(output));
});

Loading