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
39 changes: 23 additions & 16 deletions cli/__tests__/adapters.claude.environment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,11 @@ describe('claude adapter — ctx.environment', () => {
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
// from values it already has on hand (the saved token record).
// ── ${COMMONLY_*} native MCP environment expansion ────────────────────────
// Values exist only in Claude's per-spawn environment. The JSON retains
// placeholders so a cm_agent_* bearer token never exists on disk.

test('${COMMONLY_AGENT_TOKEN} in MCP env values is substituted with ctx.runtimeToken', async () => {
test('${COMMONLY_AGENT_TOKEN} stays literal on disk and is supplied only in child env', async () => {
const { impl, calls } = makeSpawnImpl();
const environment = {
mcp: [
Expand All @@ -295,15 +294,18 @@ describe('claude adapter — ctx.environment', () => {
_spawnImpl: impl,
});
const cfg = calls[0].config;
expect(cfg.mcpServers.commonly.env.COMMONLY_AGENT_TOKEN).toBe('cm_agent_real_token_12345');
expect(cfg.mcpServers.commonly.env.COMMONLY_API_URL).toBe('https://api-dev.commonly.me');
// Substitution is literal — interpolation works inside larger strings.
expect(cfg.mcpServers.commonly.env.COMMONLY_AGENT_TOKEN).toBe('${COMMONLY_AGENT_TOKEN}');
expect(cfg.mcpServers.commonly.env.COMMONLY_API_URL).toBe('${COMMONLY_API_URL}');
expect(cfg.mcpServers.commonly.env.CUSTOM).toBe(
'literal-value-cm_agent_real_token_12345-suffix',
'literal-value-${COMMONLY_AGENT_TOKEN}-suffix',
);
expect(JSON.stringify(cfg)).not.toContain('cm_agent_real_token_12345');
expect(JSON.stringify(calls[0].args)).not.toContain('cm_agent_real_token_12345');
expect(calls[0].opts.env.COMMONLY_AGENT_TOKEN).toBe('cm_agent_real_token_12345');
expect(calls[0].opts.env.COMMONLY_API_URL).toBe('https://api-dev.commonly.me');
});

test('${COMMONLY_INSTANCE_URL} alias substitutes to the same value as ${COMMONLY_API_URL}', async () => {
test('${COMMONLY_INSTANCE_URL} alias is supplied to native expansion', async () => {
const { impl, calls } = makeSpawnImpl();
await claude.spawn('hi', {
sessionId: null,
Expand All @@ -314,10 +316,11 @@ describe('claude adapter — ctx.environment', () => {
_spawnImpl: impl,
});
const cfg = calls[0].config;
expect(cfg.mcpServers.x.env.U).toBe('http://localhost:5000');
expect(cfg.mcpServers.x.env.U).toBe('${COMMONLY_INSTANCE_URL}');
expect(calls[0].opts.env.COMMONLY_INSTANCE_URL).toBe('http://localhost:5000');
});

test('placeholders in command args + url are also substituted', async () => {
test('placeholders in command args + url stay token-free and receive expansion env', async () => {
const { impl, calls } = makeSpawnImpl();
await claude.spawn('hi', {
sessionId: null,
Expand All @@ -341,8 +344,10 @@ describe('claude adapter — ctx.environment', () => {
_spawnImpl: impl,
});
const cfg = calls[0].config;
expect(cfg.mcpServers['sse-server'].url).toBe('https://api-dev.commonly.me/mcp/sse');
expect(cfg.mcpServers['arg-server'].args).toEqual(['--token', 'cm_agent_x']);
expect(cfg.mcpServers['sse-server'].url).toBe('${COMMONLY_API_URL}/mcp/sse');
expect(cfg.mcpServers['arg-server'].args).toEqual(['--token', '${COMMONLY_AGENT_TOKEN}']);
expect(calls[0].opts.env.COMMONLY_API_URL).toBe('https://api-dev.commonly.me');
expect(calls[0].opts.env.COMMONLY_AGENT_TOKEN).toBe('cm_agent_x');
});

test('unknown ${COMMONLY_*} placeholders are left intact (so misspellings surface as MCP errors, not silent empties)', async () => {
Expand All @@ -364,13 +369,15 @@ describe('claude adapter — ctx.environment', () => {
});
const cfg = calls[0].config;
expect(cfg.mcpServers.x.env.TYPO).toBe('${COMMONLY_AGNT_TOKEN}');
expect(calls[0].opts.env.COMMONLY_AGNT_TOKEN).toBeUndefined();
});

test('substitution is a no-op when ctx.runtimeToken / instanceUrl are absent (literal env values pass through)', async () => {
test('missing runtime context never creates a token environment entry', async () => {
const { impl, calls } = makeSpawnImpl();
await claude.spawn('hi', {
sessionId: null,
cwd,
env: { PATH: process.env.PATH },
environment: {
mcp: [{
name: 'x',
Expand All @@ -384,7 +391,7 @@ describe('claude adapter — ctx.environment', () => {
});
const cfg = calls[0].config;
expect(cfg.mcpServers.x.env.LITERAL).toBe('plain-string');
// Empty token → placeholder left intact (not substituted with empty string).
expect(cfg.mcpServers.x.env.PLACEHOLDER).toBe('${COMMONLY_AGENT_TOKEN}');
expect(calls[0].opts.env.COMMONLY_AGENT_TOKEN).toBeUndefined();
});
});
10 changes: 7 additions & 3 deletions cli/scripts/security/mock-claude-sandbox-mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,14 @@ input.on('line', async (line) => {
} 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)
const credentialKeys = Object.keys(process.env)
.filter((key) => /^(?:COMMONLY_|cm_agent_)/i.test(key))
.sort());
.sort();
output = JSON.stringify({
keys: credentialKeys,
runtimeTokenResolved: /^cm_agent_[A-Za-z0-9]+$/
.test(process.env.COMMONLY_AGENT_TOKEN || ''),
});
} else if (message.params?.name === 'record_observation') {
output = 'OBSERVATION_RECORDED';
} else {
Expand All @@ -172,4 +177,3 @@ input.on('line', async (line) => {
);
reply(message.id, toolResult(output));
});

35 changes: 31 additions & 4 deletions cli/scripts/security/verify-public-claude-seatbelt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
* It never reads or prints real credentials.
*
* Scope note: the declared MCP server is a trusted capability and receives
* its configured environment by design. Moving the Commonly token out of the
* transient MCP config entirely is phase IV; this phase proves that Claude
* and declared MCP children cannot read unrelated host/workspace secrets.
* its configured environment by design. The Commonly token exists only in
* Claude's one-run environment and is expanded natively; the probe fails if
* it appears in the transient MCP JSON or argv.
*/

import { randomUUID } from 'crypto';
import { spawn } from 'child_process';
import {
access,
appendFile,
Expand All @@ -29,7 +30,7 @@ import {
unlink,
writeFile,
} from 'fs/promises';
import { constants } from 'fs';
import { constants, readFileSync } from 'fs';
import { homedir, tmpdir } from 'os';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
Expand Down Expand Up @@ -82,6 +83,16 @@ const everyCanary = [
workspaceTokenCanary,
inheritedEnvCanary,
];
let materializedMcpConfig = '';
let spawnedArgv = '';
const spawnImpl = (cmd, args, opts) => {
spawnedArgv = JSON.stringify([cmd, args]);
const configIndex = args.indexOf('--mcp-config');
if (configIndex !== -1) {
materializedMcpConfig = readFileSync(args[configIndex + 1], 'utf8');
}
return spawn(cmd, args, opts);
};

const parseCapture = (text) => text
.split('\n')
Expand Down Expand Up @@ -143,6 +154,7 @@ try {
},
runtimeToken,
timeoutMs: 5 * 60 * 1000,
_spawnImpl: spawnImpl,
});

const capture = await readFile(captureFile, 'utf8');
Expand Down Expand Up @@ -190,6 +202,12 @@ try {
fail('SANDBOX FAILURE: a protected canary crossed into model/MCP output');
}
}
if (!materializedMcpConfig.includes('${COMMONLY_AGENT_TOKEN}')) {
fail('MCP config did not preserve the native environment placeholder');
}
if (materializedMcpConfig.includes(runtimeToken) || spawnedArgv.includes(runtimeToken)) {
fail('TOKEN MATERIALIZATION FAILURE: runtime token entered MCP config or argv');
}

if ((await readFile(allowedWrite, 'utf8')) !== 'ALLOWED') {
fail('SANDBOX FAILURE: workspace write did not succeed');
Expand Down Expand Up @@ -218,6 +236,15 @@ try {
.test(environmentAttempt?.output || '')) {
fail('SANDBOX FAILURE: host credential-shaped environment reached the MCP child');
}
let inspectedEnvironment;
try {
inspectedEnvironment = JSON.parse(environmentAttempt?.output || '{}');
} catch {
fail('Attack was incomplete: MCP child environment result was not JSON');
}
if (inspectedEnvironment.runtimeTokenResolved !== true) {
fail('MCP env expansion failed: server did not receive a resolved runtime token');
}

const redactedReport = everyCanary.reduce(
(text, canary) => text.replaceAll(canary, '[REDACTED_CANARY]'),
Expand Down
7 changes: 4 additions & 3 deletions cli/src/commands/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -527,9 +527,10 @@ export const performRun = ({
env: process.env,
memoryLongTerm,
environment,
// Runtime context the adapter uses to substitute ${COMMONLY_AGENT_TOKEN}
// / ${COMMONLY_API_URL} placeholders in MCP env values, command args,
// and URLs. Lets users keep tokens out of their checked-in env files.
// Runtime context the Claude/Codex adapters expose only to their
// per-spawn MCP environment. Claude keeps ${COMMONLY_*} placeholders
// literal on disk and lets its native MCP parser expand them, so the
// bearer token never enters the generated config file.
runtimeToken: token,
instanceUrl,
agentName,
Expand Down
Loading