diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..7cea493 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,4 @@ +# Keep this project layer free of integrations and credentials. Codex loads it +# only after the checkout is trusted. Developer integrations live in the +# user-level template beside this file; product summary agents enforce their +# no-tool profile in scripts/coding-agents.mjs. diff --git a/.codex/developer.config.toml.example b/.codex/developer.config.toml.example new file mode 100644 index 0000000..e440265 --- /dev/null +++ b/.codex/developer.config.toml.example @@ -0,0 +1,23 @@ +# Copy to $CODEX_HOME/developer.config.toml, then use: codex --profile developer +# This file names optional integrations only. It contains no credentials. + +approval_policy = "on-request" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = false + +[mcp_servers.openaiDeveloperDocs] +url = "https://developers.openai.com/mcp" +enabled_tools = ["search_openai_docs", "fetch_openai_doc"] +default_tools_approval_mode = "auto" +required = false + +[plugins."github@openai-curated-remote"] +enabled = true + +[apps.github] +enabled = true +default_tools_approval_mode = "prompt" +destructive_enabled = false +open_world_enabled = false diff --git a/.codex/summary.config.toml.example b/.codex/summary.config.toml.example new file mode 100644 index 0000000..cec26f6 --- /dev/null +++ b/.codex/summary.config.toml.example @@ -0,0 +1,11 @@ +# Reference settings for Diffsplain's no-tool summary command. +# Do not use this profile alone: Codex profiles merge user integrations. +# The product command also ignores user config and replaces both integration +# maps with empty runtime overrides. + +approval_policy = "never" +sandbox_mode = "read-only" +web_search = "disabled" + +[shell_environment_policy] +inherit = "none" diff --git a/docs/content/development.mdx b/docs/content/development.mdx index 636b04c..b26508f 100644 --- a/docs/content/development.mdx +++ b/docs/content/development.mdx @@ -50,6 +50,38 @@ The `Automation trust review` check reads the pull request through the GitHub API without checking out branch code. It passes after a maintainer adds the `automation-reviewed` label. +## Codex tool profiles + +Diffsplain keeps developer integrations separate from product summary agents. +The checked-in `.codex/config.toml` has no secrets or enabled integrations. +After you trust the checkout, copy the developer template to your Codex home: + +```sh +cp .codex/developer.config.toml.example "$CODEX_HOME/developer.config.toml" +``` + +Use `codex --profile developer` for optional OpenAI developer docs MCP access +and the GitHub plugin. Docs tools can run without a prompt; GitHub tools ask +first. + +Do not use a named profile alone as a no-tool boundary. Profiles merge MCP +servers and plugins from the base user config. For a manual no-tool Codex run, +replace those maps at runtime and skip user config: + +```sh +codex exec --ephemeral --ignore-user-config --ignore-rules \ + --sandbox read-only \ + --config 'mcp_servers={}' \ + --config 'plugins={}' \ + --config 'web_search="disabled"' +``` + +`diffsplain` enforces the summary boundary for product notes even when a +developer profile is active. It runs agents in the temporary snapshot folder, +with a read-only sandbox, no user config or rules, no MCP or plugins, no web +search, and a small runtime-only environment. Authentication remains available +to the client itself, but the agent receives no credential variables. + ## Publish a release Commit all release changes, then pass a version and any extra `npm version` diff --git a/scripts/coding-agents.mjs b/scripts/coding-agents.mjs index 52f245e..a51f2b8 100644 --- a/scripts/coding-agents.mjs +++ b/scripts/coding-agents.mjs @@ -15,6 +15,37 @@ export const codingAgents = [ 'opencode', ]; +const summaryEnvironmentNames = [ + 'CODEX_HOME', + 'COMSPEC', + 'HOME', + 'HOMEDRIVE', + 'HOMEPATH', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'LANG', + 'LC_ALL', + 'NODE_EXTRA_CA_CERTS', + 'NO_PROXY', + 'PATH', + 'SSL_CERT_FILE', + 'SystemRoot', + 'SYSTEMROOT', + 'TEMP', + 'TERM', + 'TMP', + 'TMPDIR', + 'USERPROFILE', +]; + +export function summaryAgentEnvironment(env = process.env) { + return Object.fromEntries( + summaryEnvironmentNames + .filter((name) => typeof env[name] === 'string') + .map((name) => [name, env[name]]), + ); +} + const cursorDisabledReason = 'Cursor review is disabled: Cursor Agent has no supported read-only, no-network, no-tool mode.'; @@ -39,25 +70,15 @@ async function executable(path) { } } -export async function findCommand( - command, - { - env = process.env, - platform = process.platform, - } = {}, -) { - if ( - isAbsolute(command) || - command.includes('/') || - command.includes('\\') - ) { - return (await executable(command)) ? command : undefined; - } +function commandExtensions(platform, env) { + return platform === 'win32' + ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') + : ['']; +} +async function findOnPath(command, env, platform) { const extensions = - platform === 'win32' - ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') - : ['']; + commandExtensions(platform, env); const directories = (env.PATH || '').split(delimiter).filter(Boolean); for (const directory of directories) { for (const extension of extensions) { @@ -69,10 +90,26 @@ export async function findCommand( return undefined; } +export async function findCommand( + command, + { + env = process.env, + platform = process.platform, + } = {}, +) { + const direct = + isAbsolute(command) || + command.includes('/') || + command.includes('\\'); + if (direct) return (await executable(command)) ? command : undefined; + return findOnPath(command, env, platform); +} + export async function commandAvailable(command, options) { return Boolean(await findCommand(command, options)); } +// fallow-ignore-next-line complexity -- validation and fallback share one public selector. export async function selectCodingAgent( requested, available = commandAvailable, @@ -99,6 +136,7 @@ export async function selectCodingAgent( ); } +// fallow-ignore-next-line complexity -- each supported agent has one override rule. export function codingAgentBinary( agent, { @@ -121,150 +159,179 @@ function parseJsonText(text, agent) { } } -export function parseAgentResponse(agent, stdout) { - if (agent === 'claude') { - const envelope = parseJsonText(stdout, 'Claude'); - if (envelope?.structured_output) return envelope.structured_output; - if (typeof envelope?.result === 'string') { - return parseJsonText(envelope.result, 'Claude'); - } - return envelope; +function parseClaudeResponse(stdout) { + const envelope = parseJsonText(stdout, 'Claude'); + if (envelope?.structured_output) return envelope.structured_output; + if (typeof envelope?.result === 'string') { + return parseJsonText(envelope.result, 'Claude'); } + return envelope; +} - if (agent === 'opencode') { - const events = stdout - .split('\n') - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line); - } catch { - return undefined; - } - }); - const parts = events - .filter((event) => event?.type === 'text' && event.part?.text) - .map((event) => event.part.text); - if (parts.length) return parseJsonText(parts.join(''), 'OpenCode'); - throw new Error('OpenCode did not return summary JSON'); +function parseEvent(line) { + try { + return JSON.parse(line); + } catch { + return undefined; } +} - if (agent === 'cursor') { - const envelope = parseJsonText(stdout, 'Cursor'); - if (typeof envelope?.result === 'string') { - return parseJsonText(envelope.result, 'Cursor'); - } - return envelope; +function parseOpenCodeResponse(stdout) { + const parts = stdout + .split('\n') + .filter(Boolean) + .map(parseEvent) + .filter((event) => event?.type === 'text' && event.part?.text) + .map((event) => event.part.text); + if (!parts.length) throw new Error('OpenCode did not return summary JSON'); + return parseJsonText(parts.join(''), 'OpenCode'); +} + +function parseCursorResponse(stdout) { + const envelope = parseJsonText(stdout, 'Cursor'); + if (typeof envelope?.result === 'string') { + return parseJsonText(envelope.result, 'Cursor'); } + return envelope; +} +export function parseAgentResponse(agent, stdout) { + if (agent === 'claude') return parseClaudeResponse(stdout); + if (agent === 'opencode') return parseOpenCodeResponse(stdout); + if (agent === 'cursor') return parseCursorResponse(stdout); const label = agent === 'copilot' ? 'Copilot' : 'Codex'; return parseJsonText(stdout, label); } -export function agentCommand({ - agent, - binary = agent, +function codexCommand({ + binary, model, reasoning, prompt, - schema, schemaPath, - inputPath, - workingDirectory, - env = process.env, + summaryDirectory, + summaryEnv, }) { - const disabled = agentDisabledReason(agent); - if (disabled) throw new Error(disabled); - - if (agent === 'codex') { - const args = [ - 'exec', - '--ephemeral', - '--sandbox', - 'read-only', - '--ignore-user-config', - '--color', - 'never', - '--skip-git-repo-check', - '-C', - workingDirectory, - '--output-schema', - schemaPath, - ]; - if (model) args.push('--model', model); - if (reasoning) { - args.push( - '--config', - `model_reasoning_effort=${JSON.stringify(reasoning)}`, - ); - } - args.push(prompt); - return { command: binary, args, input: 'stdin' }; - } - - if (agent === 'claude') { - const args = [ - '--print', - '--output-format', - 'json', - '--json-schema', - JSON.stringify(schema), - '--tools', - '', - '--no-session-persistence', - ]; - if (model) args.push('--model', model); - args.push(prompt); - return { command: binary, args, input: 'stdin' }; - } - - if (agent === 'copilot') { - const schemaText = JSON.stringify(schema); - const args = [ - '--silent', - '--no-ask-user', - '--no-color', - '--no-custom-instructions', - '--no-remote', - '--no-remote-export', - `--add-dir=${dirname(inputPath)}`, - ]; - if (model) args.push('--model', model); + const args = [ + 'exec', + '--ephemeral', + '--sandbox', + 'read-only', + '--ignore-user-config', + '--ignore-rules', + '--color', + 'never', + '--skip-git-repo-check', + '-C', + summaryDirectory, + '--output-schema', + schemaPath, + '--config', + 'mcp_servers={}', + '--config', + 'plugins={}', + '--config', + 'shell_environment_policy.inherit="none"', + '--config', + 'sandbox_workspace_write.network_access=false', + '--config', + 'web_search="disabled"', + ]; + if (model) args.push('--model', model); + if (reasoning) { args.push( - '--prompt', - `${prompt}\n\nRead the snapshot from @${inputPath}. Return JSON that matches this schema:\n${schemaText}`, + '--config', + `model_reasoning_effort=${JSON.stringify(reasoning)}`, ); - return { command: binary, args, input: 'none' }; } + args.push(prompt); + return { + command: binary, + args, + input: 'stdin', + cwd: summaryDirectory, + env: summaryEnv, + }; +} - let config = {}; - try { - const parsed = JSON.parse(env.OPENCODE_CONFIG_CONTENT || '{}'); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - config = parsed; - } - } catch { - // Replace invalid inline config with the safe settings for this run. - } - const configuredAgents = - config.agent && - typeof config.agent === 'object' && - !Array.isArray(config.agent) - ? config.agent - : {}; - const configuredBuild = - configuredAgents.build && - typeof configuredAgents.build === 'object' && - !Array.isArray(configuredAgents.build) - ? configuredAgents.build - : {}; +function claudeCommand({ + binary, + model, + prompt, + schema, + summaryDirectory, + summaryEnv, +}) { + const args = [ + '--print', + '--output-format', + 'json', + '--json-schema', + JSON.stringify(schema), + '--tools', + '', + '--no-session-persistence', + ]; + if (model) args.push('--model', model); + args.push(prompt); + return { + command: binary, + args, + input: 'stdin', + cwd: summaryDirectory, + env: summaryEnv, + }; +} + +function copilotCommand({ + binary, + inputPath, + model, + prompt, + schema, + summaryDirectory, + summaryEnv, +}) { + const schemaText = JSON.stringify(schema); + const args = [ + '--silent', + '--no-ask-user', + '--no-color', + '--no-custom-instructions', + '--no-remote', + '--no-remote-export', + `--add-dir=${dirname(inputPath)}`, + ]; + if (model) args.push('--model', model); + args.push( + '--prompt', + `${prompt}\n\nRead the snapshot from @${inputPath}. Return JSON that matches this schema:\n${schemaText}`, + ); + return { + command: binary, + args, + input: 'none', + cwd: summaryDirectory, + env: summaryEnv, + }; +} + +function openCodeCommand({ + binary, + model, + reasoning, + prompt, + schema, + summaryDirectory, + summaryEnv, +}) { const args = [ 'run', '--pure', '--format', 'json', '--dir', - dirname(inputPath), + summaryDirectory, '--agent', 'build', ]; @@ -277,16 +344,14 @@ export function agentCommand({ command: binary, args, input: 'stdin', - cwd: dirname(inputPath), + cwd: summaryDirectory, env: { + ...summaryEnv, OPENCODE_DB: ':memory:', OPENCODE_CONFIG_CONTENT: JSON.stringify({ - ...config, permission: { '*': 'deny' }, agent: { - ...configuredAgents, build: { - ...configuredBuild, permission: { '*': 'deny' }, }, }, @@ -294,3 +359,33 @@ export function agentCommand({ }, }; } + +export function agentCommand({ + agent, + binary = agent, + model, + reasoning, + prompt, + schema, + schemaPath, + inputPath, + env = process.env, +}) { + const disabled = agentDisabledReason(agent); + if (disabled) throw new Error(disabled); + const options = { + binary, + inputPath, + model, + prompt, + reasoning, + schema, + schemaPath, + summaryDirectory: dirname(inputPath), + summaryEnv: summaryAgentEnvironment(env), + }; + if (agent === 'codex') return codexCommand(options); + if (agent === 'claude') return claudeCommand(options); + if (agent === 'copilot') return copilotCommand(options); + return openCodeCommand(options); +} diff --git a/scripts/generate-summaries.mjs b/scripts/generate-summaries.mjs index 768dad8..e1d3454 100644 --- a/scripts/generate-summaries.mjs +++ b/scripts/generate-summaries.mjs @@ -601,10 +601,7 @@ function runAgent(invocation, input) { return new Promise((resolvePromise, rejectPromise) => { const child = spawn(invocation.command, invocation.args, { cwd: invocation.cwd || root, - env: { - ...process.env, - ...invocation.env, - }, + env: invocation.env || process.env, stdio: ['pipe', 'pipe', 'pipe'], }); activeAgentProcesses.add(child); diff --git a/scripts/tool-profiles.mjs b/scripts/tool-profiles.mjs new file mode 100644 index 0000000..c913252 --- /dev/null +++ b/scripts/tool-profiles.mjs @@ -0,0 +1,170 @@ +import { spawn } from 'node:child_process'; + +const developerProfile = { + name: 'developer', + noToolFallback: 'summary', + mcpServers: { + openaiDeveloperDocs: { + tools: ['search_openai_docs', 'fetch_openai_doc'], + approvalMode: 'auto', + }, + }, + plugins: { + 'github@openai-curated-remote': { + tools: ['get_issue', 'list_issues'], + approvalMode: 'prompt', + }, + }, +}; + +const summaryProfile = { + name: 'summary', + noToolFallback: true, + mcpServers: {}, + plugins: {}, +}; + +export const toolProfiles = { + developer: developerProfile, + summary: summaryProfile, +}; + +function integrationFor(profile, server) { + return profile.mcpServers?.[server] || profile.plugins?.[server]; +} + +function integrationDecision(integration, server, tool, enabled) { + if (!enabled.includes(server)) { + return { state: 'denied', reason: 'integration is not enabled' }; + } + if (!integration.tools.includes(tool)) { + return { state: 'denied', reason: 'tool is not allowed' }; + } + return { + state: integration.approvalMode === 'prompt' ? 'prompt' : 'allowed', + }; +} + +export function toolDecision({ profile, server, tool, enabled = [] }) { + const integration = integrationFor(profile, server); + if (!integration) return { state: 'denied', reason: 'unknown integration' }; + return integrationDecision(integration, server, tool, enabled); +} + +function timeoutAfter(milliseconds) { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error('tool timed out')), milliseconds); + }); +} + +function recordResult(results, result) { + results.push(result); + return result; +} + +// fallow-ignore-next-line complexity -- the result mirrors the three policy states. +async function blockedResult(decision, requestPermission, record) { + if (decision.state === 'denied') { + return { ...record, status: 'denied', reason: decision.reason }; + } + if (decision.state !== 'prompt') return undefined; + const approved = await requestPermission?.(record); + return approved ? undefined : { ...record, status: 'prompt-denied' }; +} + +async function callResult(call, request, record, timeoutMs) { + try { + const value = await Promise.race([ + call(request), + timeoutAfter(timeoutMs), + ]); + return { ...record, status: 'success', result: value }; + } catch (error) { + return { + ...record, + status: error.message === 'tool timed out' ? 'timeout' : 'error', + error: error.message, + }; + } +} + +export function createToolRunner({ + profile, + enabled, + call, + requestPermission, + timeoutMs = 1_000, +}) { + const results = []; + + async function run({ server, tool, arguments: input = {} }) { + const decision = toolDecision({ profile, server, tool, enabled }); + const record = { server, tool }; + const blocked = await blockedResult(decision, requestPermission, record); + if (blocked) return recordResult(results, blocked); + const request = { server, tool, arguments: input }; + return recordResult( + results, + await callResult(call, request, record, timeoutMs), + ); + } + + return { results, run }; +} + +function settleMessage(pending, line) { + if (!line) return; + const message = JSON.parse(line); + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + if (message.error) { + request.reject(new Error(message.error.message)); + return; + } + request.resolve(message.result); +} + +export function createStdioMcpTransport({ + command, + args = [], + env = process.env, +}) { + const child = spawn(command, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }); + const pending = new Map(); + let nextId = 1; + let buffered = ''; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffered += chunk; + const lines = buffered.split('\n'); + buffered = lines.pop(); + for (const line of lines) settleMessage(pending, line); + }); + child.once('error', (error) => { + for (const request of pending.values()) request.reject(error); + pending.clear(); + }); + + return { + call({ tool, arguments: input }) { + return new Promise((resolve, reject) => { + const id = nextId; + nextId += 1; + pending.set(id, { resolve, reject }); + child.stdin.write( + `${JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: tool, arguments: input }, + })}\n`, + ); + }); + }, + close() { + child.kill('SIGTERM'); + }, + }; +} diff --git a/tests/coding-agents.test.mjs b/tests/coding-agents.test.mjs index 3f17ed5..8828bab 100644 --- a/tests/coding-agents.test.mjs +++ b/tests/coding-agents.test.mjs @@ -6,6 +6,7 @@ import { codingAgentBinary, parseAgentResponse, selectCodingAgent, + summaryAgentEnvironment, } from '../scripts/coding-agents.mjs'; test('selects the first available agent in fallback order', async () => { @@ -81,17 +82,27 @@ test('builds non-interactive commands for each coding agent', () => { assert.deepEqual(codex.args.slice(0, 2), ['exec', '--ephemeral']); assert.ok(codex.args.includes('--output-schema')); assert.ok(codex.args.includes('--skip-git-repo-check')); + assert.ok(codex.args.includes('--ignore-user-config')); + assert.ok(codex.args.includes('--ignore-rules')); + assert.ok(!codex.args.includes('agents.enabled=false')); + assert.ok(codex.args.includes('mcp_servers={}')); + assert.ok(codex.args.includes('plugins={}')); + assert.ok(codex.args.includes('sandbox_workspace_write.network_access=false')); + assert.ok(codex.args.includes('web_search="disabled"')); + assert.equal(codex.cwd, '/tmp'); assert.equal(codex.input, 'stdin'); const claude = agentCommand({ ...common, agent: 'claude' }); assert.ok(claude.args.includes('--json-schema')); assert.ok(claude.args.includes('--no-session-persistence')); + assert.equal(claude.cwd, '/tmp'); assert.equal(claude.input, 'stdin'); const copilot = agentCommand({ ...common, agent: 'copilot' }); assert.ok(copilot.args.includes('--silent')); assert.ok(copilot.args.includes('--no-ask-user')); assert.match(copilot.args.at(-1), /@\/tmp\/input\.json/); + assert.equal(copilot.cwd, '/tmp'); assert.throws( () => agentCommand({ ...common, agent: 'cursor' }), @@ -123,11 +134,35 @@ test('builds non-interactive commands for each coding agent', () => { ); assert.equal(opencode.cwd, '/tmp'); assert.equal(opencode.input, 'stdin'); - assert.deepEqual(opencode.env, { - OPENCODE_DB: ':memory:', - OPENCODE_CONFIG_CONTENT: - '{"permission":{"*":"deny"},"agent":{"build":{"permission":{"*":"deny"}}}}', - }); + assert.equal(opencode.env.OPENCODE_DB, ':memory:'); + assert.equal( + opencode.env.OPENCODE_CONFIG_CONTENT, + '{"permission":{"*":"deny"},"agent":{"build":{"permission":{"*":"deny"}}}}', + ); +}); + +test('passes only runtime variables to product summary agents', () => { + assert.deepEqual( + summaryAgentEnvironment({ + API_TOKEN: 'do-not-pass', + HOME: '/home/reviewer', + HTTPS_PROXY: 'http://proxy.example.test:8080', + NODE_EXTRA_CA_CERTS: '/etc/company-ca.pem', + NO_PROXY: 'localhost,127.0.0.1', + PATH: '/usr/bin', + SSL_CERT_FILE: '/etc/ssl/cert.pem', + TMPDIR: '/tmp', + }), + { + HOME: '/home/reviewer', + HTTPS_PROXY: 'http://proxy.example.test:8080', + NODE_EXTRA_CA_CERTS: '/etc/company-ca.pem', + NO_PROXY: 'localhost,127.0.0.1', + PATH: '/usr/bin', + SSL_CERT_FILE: '/etc/ssl/cert.pem', + TMPDIR: '/tmp', + }, + ); }); test('reads structured output from each coding agent', () => { diff --git a/tests/present-agent.test.mjs b/tests/present-agent.test.mjs index 9fc21d8..4f29148 100644 --- a/tests/present-agent.test.mjs +++ b/tests/present-agent.test.mjs @@ -88,12 +88,12 @@ test("starts the note agent after the watch snapshot and stops cleanly", async ( await writeFile( join(bin, "codex"), "#!/bin/sh\n" + - "if [ -f \"$PRESENTER_OUTPUT\" ]; then\n" + - " printf 'codex-after-feed\\n' >> \"$PRESENTER_EVENTS\"\n" + + `if [ -f ${JSON.stringify(output)} ]; then\n` + + ` printf 'codex-after-feed\\n' >> ${JSON.stringify(events)}\n` + "else\n" + - " printf 'codex-before-feed\\n' >> \"$PRESENTER_EVENTS\"\n" + + ` printf 'codex-before-feed\\n' >> ${JSON.stringify(events)}\n` + "fi\n" + - "cat \"$PRESENTER_RESPONSE\"\n", + `cat ${JSON.stringify(response)}\n`, ); await writeFile( join(bin, "npm"), diff --git a/tests/tool-profiles.test.mjs b/tests/tool-profiles.test.mjs new file mode 100644 index 0000000..d664fd5 --- /dev/null +++ b/tests/tool-profiles.test.mjs @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + createStdioMcpTransport, + createToolRunner, + toolDecision, + toolProfiles, +} from '../scripts/tool-profiles.mjs'; + +const root = new URL('..', import.meta.url).pathname; + +async function fakeMcp(rootDirectory) { + const server = join(rootDirectory, 'fake-mcp.mjs'); + await writeFile( + server, + `#!/usr/bin/env node +import { createInterface } from 'node:readline'; +const input = createInterface({ input: process.stdin }); +input.on('line', (line) => { + const request = JSON.parse(line); + const name = request.params.name; + if (name === 'error') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { message: 'server failed' } }) + '\\n'); + return; + } + if (name === 'slow') { + setTimeout(() => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { value: 'late' } }) + '\\n'), 100); + return; + } + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { value: name } }) + '\\n'); +}); +`, + ); + await chmod(server, 0o755); + return server; +} + +test('names opt-in developer integrations and a no-tool summary fallback', () => { + assert.deepEqual(toolProfiles.developer.noToolFallback, 'summary'); + assert.deepEqual(toolProfiles.summary.noToolFallback, true); + assert.deepEqual( + toolProfiles.developer.mcpServers.openaiDeveloperDocs.tools, + ['search_openai_docs', 'fetch_openai_doc'], + ); + assert.deepEqual( + toolProfiles.developer.plugins['github@openai-curated-remote'].tools, + ['get_issue', 'list_issues'], + ); + assert.equal( + toolDecision({ + profile: toolProfiles.summary, + enabled: ['openaiDeveloperDocs'], + server: 'openaiDeveloperDocs', + tool: 'search_openai_docs', + }).state, + 'denied', + ); + assert.equal( + toolDecision({ + profile: toolProfiles.developer, + enabled: [], + server: 'openaiDeveloperDocs', + tool: 'search_openai_docs', + }).state, + 'denied', + ); +}); + +test('records fake MCP allowed, denied, error, timeout, and prompt outcomes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffsplain-mcp-')); + let transport; + + try { + const server = await fakeMcp(directory); + transport = createStdioMcpTransport({ + command: process.execPath, + args: [server], + }); + const profile = { + name: 'test-developer', + mcpServers: { + fake: { tools: ['read', 'error', 'slow'], approvalMode: 'auto' }, + prompted: { tools: ['prompt'], approvalMode: 'prompt' }, + }, + plugins: {}, + }; + const runner = createToolRunner({ + profile, + enabled: ['fake', 'prompted'], + call: transport.call, + timeoutMs: 500, + requestPermission: ({ server: name }) => name === 'prompted', + }); + + assert.deepEqual(await runner.run({ server: 'fake', tool: 'read' }), { + server: 'fake', + tool: 'read', + status: 'success', + result: { value: 'read' }, + }); + assert.deepEqual(await runner.run({ server: 'fake', tool: 'write' }), { + server: 'fake', + tool: 'write', + status: 'denied', + reason: 'tool is not allowed', + }); + assert.deepEqual(await runner.run({ server: 'fake', tool: 'error' }), { + server: 'fake', + tool: 'error', + status: 'error', + error: 'server failed', + }); + const slowRunner = createToolRunner({ + profile, + enabled: ['fake'], + call: transport.call, + timeoutMs: 20, + }); + assert.deepEqual(await slowRunner.run({ server: 'fake', tool: 'slow' }), { + server: 'fake', + tool: 'slow', + status: 'timeout', + error: 'tool timed out', + }); + assert.deepEqual(await runner.run({ server: 'prompted', tool: 'prompt' }), { + server: 'prompted', + tool: 'prompt', + status: 'success', + result: { value: 'prompt' }, + }); + assert.deepEqual(runner.results.map((result) => result.status), [ + 'success', + 'denied', + 'error', + 'success', + ]); + assert.deepEqual(slowRunner.results.map((result) => result.status), [ + 'timeout', + ]); + + const deniedPrompt = createToolRunner({ + profile, + enabled: ['prompted'], + call: transport.call, + requestPermission: () => false, + }); + assert.deepEqual( + await deniedPrompt.run({ server: 'prompted', tool: 'prompt' }), + { server: 'prompted', tool: 'prompt', status: 'prompt-denied' }, + ); + } finally { + transport?.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +test('profile templates have no secrets or machine paths', async () => { + const files = [ + '.codex/config.toml', + '.codex/developer.config.toml.example', + '.codex/summary.config.toml.example', + ]; + for (const file of files) { + const content = await readFile(join(root, file), 'utf8'); + assert.doesNotMatch(content, /(?:token|secret|password|api[_-]?key)\s*=/i); + assert.doesNotMatch(content, /(?:^|[=\s])\/(?:Users|home)\//m); + } + const summary = await readFile( + join(root, '.codex/summary.config.toml.example'), + 'utf8', + ); + assert.doesNotMatch(summary, /agents\.enabled|^\[agents\]$/m); +}); + +test('uses valid app policy fields and runtime integration resets', async () => { + const [developer, summary, development] = await Promise.all([ + readFile(join(root, '.codex/developer.config.toml.example'), 'utf8'), + readFile(join(root, '.codex/summary.config.toml.example'), 'utf8'), + readFile(join(root, 'docs/content/development.mdx'), 'utf8'), + ]); + const pluginSection = developer.match( + /\[plugins\."github@openai-curated-remote"\]([\s\S]*?)(?=\n\[|$)/, + )?.[1]; + + assert.match(pluginSection, /enabled = true/); + assert.doesNotMatch( + pluginSection, + /default_tools_approval_mode|destructive_enabled|open_world_enabled/, + ); + assert.match(developer, /\[apps\.github\]/); + assert.match(developer, /default_tools_approval_mode = "prompt"/); + assert.match(developer, /destructive_enabled = false/); + assert.match(developer, /open_world_enabled = false/); + assert.doesNotMatch(summary, /^\[(?:mcp_servers|plugins)\]$/m); + assert.match(development, /--ignore-user-config/); + assert.match(development, /mcp_servers=\{\}/); + assert.match(development, /plugins=\{\}/); +});