openvibers/openvibe.codes Commits
Hard rules enforced in the adapters, and a 40 KB read cap (R1 item 6) (#379)
Changes
@@@ -7,10 +7,12 @@** list_dir, read_file, grep every permission level* write_file, edit_file edit and full-- * run_command full only (bash -lc in the working directory, two minutes, output clipped)++ * run_command full only (bash -lc in the working directory, two minutes, output clipped;++ * push, reset, recursive rm and .env paths refused before anything spawns)** Every path is resolved inside the working directory: `..` out of it, an absolute path elsewhere, `.git/` writes and-- * `.env` files are refused. The conversation is kept in the session store so `resume` continues it.++ * `.env` files are refused. One read answers at most 40 KB. The conversation is kept in the session store so `resume`++ * continues it.*/const fs = require('fs');const path = require('path');@@@ -34,6 +36,45 @@ const LEVELS = { read: 1, edit: 2, full: 3 };const SHOWN_AS = { list_dir: 'LS', read_file: 'Read', grep: 'Grep', write_file: 'Write', edit_file: 'Edit', run_command: 'Bash' };const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.cache']);++/** What one read_file answer may hold: 40 KB, enforced here, not in the prompt. */++const MAX_READ = 40_000;++++/**++ * The shell a job may not run (the portal's deny list, enforced where the command would spawn).++ *++ * Every `;`, `&&`, `||`, `|` and `&` segment is checked from its first word (leading `sudo`/`command` skipped),++ * so chaining a refusal after a harmless command does not dodge it; the same shapes are then matched anywhere in++ * the whole command, so `bash -c "git push"` and `timeout 5 git push` do not dodge it either. A refusal names++ * its rule, in the tool's answer, before anything spawns.++ *++ * @param {string} command the shell the model asked for++ * @returns {string|null} why it is refused, or null when it may run++ */++function refusedCommand(command) {++ const text = String(command == null ? '' : command);++ const segments = text.split(/\s*(?:&&|\|\||[;|&\n])\s*/);++ const head = (seg) => String(seg).replace(/^(?:sudo|command)\s+/i, '').trimStart();++ const push = /^git\s+push\b/;++ const reset = /^git\s+reset\b/;++ const recRm = /^rm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(?:\s|$)/;++ const dotenv = /(?:^|[\s"'=([{\/-])(?:\S*\/)?\.env(?:\.[^\s"'/]*)?(?=[\s"'/)\]}]|$)/;++ const whole = [++ [/\bgit\s+push\b/, 'git push is refused: publishing is the person\'s step'],++ [/\bgit\s+reset\b/, 'git reset is refused: it throws work away'],++ [/\brm\b[^;|&\n]*?(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(?:\s|$)/, 'a recursive rm is refused'],++ [dotenv, '.env files are not read or written'],++ ];++ for (const seg of segments) {++ const h = head(seg);++ if (push.test(h)) return 'git push is refused: publishing is the person\'s step';++ if (reset.test(h)) return 'git reset is refused: it throws work away';++ if (recRm.test(h)) return 'a recursive rm is refused';++ if (dotenv.test(seg)) return '.env files are not read or written';++ }++ for (const [re, why] of whole) if (re.test(text)) return why;++ return null;++}++/*** The real path of abs, with any part that does not exist yet kept as written; null when a component is* not a directory. Every existing component is realpath'd, so a symlink cannot hide where the path goes.@@@ -84,7 +125,7 @@ function runTool(name, args, cwd) {const from = Math.max(1, Number(a.offset) || 1);const to = Math.min(lines.length, from - 1 + Math.max(1, Number(a.limit) || 2000));const body = lines.slice(from - 1, to).map((l, i) => `${from + i}\t${l}`).join('\n');-- return [body.length > 64_000 ? `${body.slice(0, 64_000)}\n… (cut: read with offset/limit)` : body, false];++ return [body.length > MAX_READ ? `${body.slice(0, MAX_READ)}\n… (cut: read with offset/limit)` : body, false];}if (name === 'grep') {const base = inside(cwd, a.path);@@@ -273,6 +314,11 @@ function apiAgent(spec) {if (!def) out = [`unknown tool ${name}`, true];else if (LEVELS[def.level] > level) out = [`${name} needs the ${def.level} permission level`, true];else if (name === 'run_command') {++ // The hard rules run before the shell does: a refusal is the tool's answer, and++ // nothing spawns.++ const refused = refusedCommand(args.command);++ if (refused) out = [refused, true];++ else {// The command's output reaches the stream as it comes (batched every 150 ms), so a// reader sees a long test run move instead of waiting for its end.const chunks = []; let wake = null; let finished = null;@@@ -285,6 +331,7 @@ function apiAgent(spec) {}if (chunks.length) yield ev.toolProgress(call.id, chunks.splice(0).join(''));out = finished;++ }}else { try { out = runTool(name, args, t.cwd); } catch (err) { out = [err.message, true]; } }yield ev.toolResult(call.id, out[0], out[1]);@@@ -310,4 +357,4 @@ function apiAgent(spec) {return adapter;}--module.exports = { apiAgent, refusalOf, extraHeaders, runTool, inside, TOOL_DEFS, SYSTEM };++module.exports = { apiAgent, refusalOf, extraHeaders, runTool, inside, refusedCommand, TOOL_DEFS, SYSTEM };@@@ -18,6 +18,7 @@ const { execFileSync } = require('child_process');const ev = require('../events');const { spawnAgent, exitReason } = require('../process');const { effortFor } = require('../../engine/adapters/base');++const { DENIED: CLAUDE_DENIED } = require('../../engine/adapters/claude-code');/** A parser that reads JSON lines, hands each object to onEvent, and ends the run with exactly one result. */function jsonLines(agent, onEvent, onEnd) {@@@ -71,8 +72,9 @@ const claudeCode = {if (o.mcpConfig) args.push('--mcp-config', o.mcpConfig);if (Array.isArray(o.allowedTools) && o.allowedTools.length) args.push('--allowedTools', o.allowedTools.join(' '));if (o.permissionPromptTool) args.push('--permission-prompt-tool', o.permissionPromptTool);-- // Even unattended, a run never pushes: publishing is the person's step.-- args.push('--disallowedTools', 'Bash(git push:*)');++ // The portal's deny list rides along (no commits, resets, stashes, sudo, recursive rm, .env reads); even++ // unattended, a run never pushes: publishing is the person's step, the one rule full keeps.++ args.push('--disallowedTools', t.permission === 'full' ? 'Bash(git push:*)' : CLAUDE_DENIED.join(' '));const parser = jsonLines(claudeCode, (o, emit, st) => {if (o.type === 'system' && o.subtype === 'init') { st.sessionId = o.session_id; st.model = o.model; emit(ev.init(o.session_id, 'claude-code', o.model)); return; }if (o.type === 'result') { emit({ ...o, result: ev.scrub(o.result || '') }); return; }@@@ -356,8 +358,9 @@ const claudeOther = {'--permission-mode', mode, '--exclude-dynamic-system-prompt-sections'];if (t.maxTurns) args.push('--max-turns', String(t.maxTurns));if (t.resume) args.push('--resume', String(t.resume));-- // Even unattended, a run never pushes: publishing is the person's step.-- args.push('--disallowedTools', 'Bash(git push:*)');++ // The portal's deny list rides along (no commits, resets, stashes, sudo, recursive rm, .env reads); even++ // unattended, a run never pushes: publishing is the person's step, the one rule full keeps.++ args.push('--disallowedTools', t.permission === 'full' ? 'Bash(git push:*)' : CLAUDE_DENIED.join(' '));// The harness's environment (PATH finds the CLI), with the connection's own names on top: the endpoint, the// token and the model id. The key travels in the environment, never in argv: the process list of this machine// is readable.@@@ -12,6 +12,7 @@ const path = require('path');const http = require('http');const { createHarness } = require('../harness');const { createStore } = require('../harness/sessions');++const { refusedCommand, runTool } = require('../harness/adapters/api');const { check, done } = require('./helpers/boot');/** A chat-completions server that answers from a script: each request gets the next scripted message. */@@@ -190,6 +191,52 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(assert.ok(!fs.existsSync(path.join(dir, 'outside-dir', 'planted.txt')), 'no write through the link');});++ await check('run_command refuses a push, a reset, a recursive rm and a .env path before anything spawns', async () => {++ api.set([{ calls: [++ ['run_command', { command: 'git push --force origin main; touch escaped.txt' }],++ ['run_command', { command: 'npm test && git reset --hard HEAD' }],++ ['run_command', { command: 'echo tidying; rm -rf src' }],++ ['run_command', { command: 'cat .env' }],++ ['run_command', { command: 'git --version' }],++ ] }, { content: 'ok' }]);++ const events = await collect(h.run({ prompt: 'push it', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));++ const res = toolResults(events);++ assert.strictEqual(res.length, 5);++ assert.ok(res.slice(0, 4).every((c) => c.is_error), JSON.stringify(res.map((c) => c.content)));++ assert.ok(/publishing is the person's step/.test(res[0].content), res[0].content);++ assert.ok(/git reset is refused/.test(res[1].content), res[1].content);++ assert.ok(/recursive rm is refused/.test(res[2].content), res[2].content);++ assert.ok(/\.env files are not read/.test(res[3].content), res[3].content);++ assert.ok(!res[4].is_error && /git version/.test(res[4].content), 'a plain git read still runs');++ assert.ok(!fs.existsSync(path.join(work, 'escaped.txt')), 'the refused chain never spawned');++ assert.ok(fs.existsSync(path.join(work, 'src', 'a.js')), 'the refused rm never ran');++ });++++ await check('the refused shapes: chained, nested and prefixed commands, and what still runs', () => {++ for (const c of ['git push', 'git push -f', 'echo ok;git reset', 'git stash list | git push', 'sudo git push',++ 'bash -c "git push origin x"', 'timeout 5 git push', 'rm -fr build', 'rm --recursive old',++ 'rm --force -r x', 'cat a/.env.local', 'grep SECRET .env', 'echo x > .env', 'cp a .env']) {++ assert.ok(refusedCommand(c), `refused: ${c}`);++ }++ for (const c of ['pwd', 'npm test', 'git status', 'git log --oneline', 'rm note.txt', 'rm -f note.txt',++ 'git commit -m "push the button"', 'echo NODE_ENV=production', 'git stash list | grep reset', 'cat config.env']) {++ assert.strictEqual(refusedCommand(c), null, `runs: ${c}`);++ }++ assert.strictEqual(refusedCommand(''), null);++ assert.strictEqual(refusedCommand(null), null);++ });++++ await check('one read answers at most 40 KB, and says where to continue', () => {++ // At the tool itself: the loop's events clip to 4 KB, so the cap is only visible here.++ fs.writeFileSync(path.join(work, 'big.txt'), `${'x'.repeat(999)}\n`.repeat(60));++ const [body, isError] = runTool('read_file', { path: 'big.txt' }, work);++ assert.strictEqual(isError, false);++ assert.ok(body.length <= 40_100, `cut at 40 KB, was ${body.length}`);++ assert.ok(/… \(cut: read with offset\/limit\)$/.test(body), 'the cut says where to continue');++ const [small] = runTool('read_file', { path: 'src/a.js' }, work);++ assert.ok(!/cut: read with/.test(small), 'a small file comes back whole');++ });++await check('run_command exists only at the full level, and runs in the working directory', async () => {api.set([{ calls: [['run_command', { command: 'pwd' }]] }, { content: 'ok' }]);const events = await collect(h.run({ prompt: 'where', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));@@@ -70,6 +70,17 @@ const collect = async (run) => { const out = []; for await (const e of run) out.assert.ok(inBox.candidates.filter((c) => /^(opencode|command-code):/.test(c.id)).some((c) => c.included !== false), 'inside a sandbox they may be picked');});++ await check('Claude Code carries the deny list, and full keeps only the push', async () => {++ const after = (argv, flag) => argv[argv.indexOf(flag) + 1];++ for (const adapter of [cli.claudeCode, cli.claudeOther]) {++ const edit = after(await argvOf(adapter, { permission: 'edit' }), '--disallowedTools');++ assert.ok(edit.includes('Bash(git push:*)'), 'a run never pushes');++ assert.ok(edit.includes('Bash(git reset:*)') && edit.includes('Bash(rm -rf:*)'), 'reset and recursive rm ride along');++ assert.ok(edit.includes('Read(.env*)'), 'no .env reads');++ assert.strictEqual(after(await argvOf(adapter, { permission: 'full' }), '--disallowedTools'), 'Bash(git push:*)', 'full keeps only the push');++ }++ });++await check('a run that names one for "edit" outside a sandbox is refused in words, and nothing starts', async () => {const events = await collect(harness().run({ prompt: 'tidy it', agent: 'opencode', permission: 'edit', cwd: dir, handoff: false }));const result = events.find((e) => e.type === 'result');
@@ -7,10 +7,12 @@
@@ -7,10 +7,12 @@
*
*
* list_dir, read_file, grep every permission level
* list_dir, read_file, grep every permission level
* write_file, edit_file edit and full
* write_file, edit_file edit and full
- * run_command full only (bash -lc in the working directory, two minutes, output clipped)
+ * run_command full only (bash -lc in the working directory, two minutes, output clipped;
+ * push, reset, recursive rm and .env paths refused before anything spawns)
*
*
* Every path is resolved inside the working directory: `..` out of it, an absolute path elsewhere, `.git/` writes and
* Every path is resolved inside the working directory: `..` out of it, an absolute path elsewhere, `.git/` writes and
- * `.env` files are refused. The conversation is kept in the session store so `resume` continues it.
+ * `.env` files are refused. One read answers at most 40 KB. The conversation is kept in the session store so `resume`
+ * continues it.
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -34,6 +36,45 @@ const LEVELS = { read: 1, edit: 2, full: 3 };
@@ -34,6 +36,45 @@ const LEVELS = { read: 1, edit: 2, full: 3 };
const SHOWN_AS = { list_dir: 'LS', read_file: 'Read', grep: 'Grep', write_file: 'Write', edit_file: 'Edit', run_command: 'Bash' };
const SHOWN_AS = { list_dir: 'LS', read_file: 'Read', grep: 'Grep', write_file: 'Write', edit_file: 'Edit', run_command: 'Bash' };
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.cache']);
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.cache']);
+/** What one read_file answer may hold: 40 KB, enforced here, not in the prompt. */
+const MAX_READ = 40_000;
+
+/**
+ * The shell a job may not run (the portal's deny list, enforced where the command would spawn).
+ *
+ * Every `;`, `&&`, `||`, `|` and `&` segment is checked from its first word (leading `sudo`/`command` skipped),
+ * so chaining a refusal after a harmless command does not dodge it; the same shapes are then matched anywhere in
+ * the whole command, so `bash -c "git push"` and `timeout 5 git push` do not dodge it either. A refusal names
+ * its rule, in the tool's answer, before anything spawns.
+ *
+ * @param {string} command the shell the model asked for
+ * @returns {string|null} why it is refused, or null when it may run
+ */
+function refusedCommand(command) {
+ const text = String(command == null ? '' : command);
+ const segments = text.split(/\s*(?:&&|\|\||[;|&\n])\s*/);
+ const head = (seg) => String(seg).replace(/^(?:sudo|command)\s+/i, '').trimStart();
+ const push = /^git\s+push\b/;
+ const reset = /^git\s+reset\b/;
+ const recRm = /^rm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(?:\s|$)/;
+ const dotenv = /(?:^|[\s"'=([{\/-])(?:\S*\/)?\.env(?:\.[^\s"'/]*)?(?=[\s"'/)\]}]|$)/;
+ const whole = [
+ [/\bgit\s+push\b/, 'git push is refused: publishing is the person\'s step'],
+ [/\bgit\s+reset\b/, 'git reset is refused: it throws work away'],
+ [/\brm\b[^;|&\n]*?(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(?:\s|$)/, 'a recursive rm is refused'],
+ [dotenv, '.env files are not read or written'],
+ ];
+ for (const seg of segments) {
+ const h = head(seg);
+ if (push.test(h)) return 'git push is refused: publishing is the person\'s step';
+ if (reset.test(h)) return 'git reset is refused: it throws work away';
+ if (recRm.test(h)) return 'a recursive rm is refused';
+ if (dotenv.test(seg)) return '.env files are not read or written';
+ }
+ for (const [re, why] of whole) if (re.test(text)) return why;
+ return null;
+}
+
/**
/**
* The real path of abs, with any part that does not exist yet kept as written; null when a component is
* The real path of abs, with any part that does not exist yet kept as written; null when a component is
* not a directory. Every existing component is realpath'd, so a symlink cannot hide where the path goes.
* not a directory. Every existing component is realpath'd, so a symlink cannot hide where the path goes.
@@ -84,7 +125,7 @@ function runTool(name, args, cwd) {
@@ -84,7 +125,7 @@ function runTool(name, args, cwd) {
const from = Math.max(1, Number(a.offset) || 1);
const from = Math.max(1, Number(a.offset) || 1);
const to = Math.min(lines.length, from - 1 + Math.max(1, Number(a.limit) || 2000));
const to = Math.min(lines.length, from - 1 + Math.max(1, Number(a.limit) || 2000));
const body = lines.slice(from - 1, to).map((l, i) => `${from + i}\t${l}`).join('\n');
const body = lines.slice(from - 1, to).map((l, i) => `${from + i}\t${l}`).join('\n');
- return [body.length > 64_000 ? `${body.slice(0, 64_000)}\n… (cut: read with offset/limit)` : body, false];
+ return [body.length > MAX_READ ? `${body.slice(0, MAX_READ)}\n… (cut: read with offset/limit)` : body, false];
}
}
if (name === 'grep') {
if (name === 'grep') {
const base = inside(cwd, a.path);
const base = inside(cwd, a.path);
@@ -273,6 +314,11 @@ function apiAgent(spec) {
@@ -273,6 +314,11 @@ function apiAgent(spec) {
if (!def) out = [`unknown tool ${name}`, true];
if (!def) out = [`unknown tool ${name}`, true];
else if (LEVELS[def.level] > level) out = [`${name} needs the ${def.level} permission level`, true];
else if (LEVELS[def.level] > level) out = [`${name} needs the ${def.level} permission level`, true];
else if (name === 'run_command') {
else if (name === 'run_command') {
+ // The hard rules run before the shell does: a refusal is the tool's answer, and
+ // nothing spawns.
+ const refused = refusedCommand(args.command);
+ if (refused) out = [refused, true];
+ else {
// The command's output reaches the stream as it comes (batched every 150 ms), so a
// The command's output reaches the stream as it comes (batched every 150 ms), so a
// reader sees a long test run move instead of waiting for its end.
// reader sees a long test run move instead of waiting for its end.
const chunks = []; let wake = null; let finished = null;
const chunks = []; let wake = null; let finished = null;
@@ -285,6 +331,7 @@ function apiAgent(spec) {
@@ -285,6 +331,7 @@ function apiAgent(spec) {
}
}
if (chunks.length) yield ev.toolProgress(call.id, chunks.splice(0).join(''));
if (chunks.length) yield ev.toolProgress(call.id, chunks.splice(0).join(''));
out = finished;
out = finished;
+ }
}
}
else { try { out = runTool(name, args, t.cwd); } catch (err) { out = [err.message, true]; } }
else { try { out = runTool(name, args, t.cwd); } catch (err) { out = [err.message, true]; } }
yield ev.toolResult(call.id, out[0], out[1]);
yield ev.toolResult(call.id, out[0], out[1]);
@@ -310,4 +357,4 @@ function apiAgent(spec) {
@@ -310,4 +357,4 @@ function apiAgent(spec) {
return adapter;
return adapter;
}
}
-module.exports = { apiAgent, refusalOf, extraHeaders, runTool, inside, TOOL_DEFS, SYSTEM };
+module.exports = { apiAgent, refusalOf, extraHeaders, runTool, inside, refusedCommand, TOOL_DEFS, SYSTEM };
@@ -18,6 +18,7 @@ const { execFileSync } = require('child_process');
@@ -18,6 +18,7 @@ const { execFileSync } = require('child_process');
const ev = require('../events');
const ev = require('../events');
const { spawnAgent, exitReason } = require('../process');
const { spawnAgent, exitReason } = require('../process');
const { effortFor } = require('../../engine/adapters/base');
const { effortFor } = require('../../engine/adapters/base');
+const { DENIED: CLAUDE_DENIED } = require('../../engine/adapters/claude-code');
/** A parser that reads JSON lines, hands each object to onEvent, and ends the run with exactly one result. */
/** A parser that reads JSON lines, hands each object to onEvent, and ends the run with exactly one result. */
function jsonLines(agent, onEvent, onEnd) {
function jsonLines(agent, onEvent, onEnd) {
@@ -71,8 +72,9 @@ const claudeCode = {
@@ -71,8 +72,9 @@ const claudeCode = {
if (o.mcpConfig) args.push('--mcp-config', o.mcpConfig);
if (o.mcpConfig) args.push('--mcp-config', o.mcpConfig);
if (Array.isArray(o.allowedTools) && o.allowedTools.length) args.push('--allowedTools', o.allowedTools.join(' '));
if (Array.isArray(o.allowedTools) && o.allowedTools.length) args.push('--allowedTools', o.allowedTools.join(' '));
if (o.permissionPromptTool) args.push('--permission-prompt-tool', o.permissionPromptTool);
if (o.permissionPromptTool) args.push('--permission-prompt-tool', o.permissionPromptTool);
- // Even unattended, a run never pushes: publishing is the person's step.
- args.push('--disallowedTools', 'Bash(git push:*)');
+ // The portal's deny list rides along (no commits, resets, stashes, sudo, recursive rm, .env reads); even
+ // unattended, a run never pushes: publishing is the person's step, the one rule full keeps.
+ args.push('--disallowedTools', t.permission === 'full' ? 'Bash(git push:*)' : CLAUDE_DENIED.join(' '));
const parser = jsonLines(claudeCode, (o, emit, st) => {
const parser = jsonLines(claudeCode, (o, emit, st) => {
if (o.type === 'system' && o.subtype === 'init') { st.sessionId = o.session_id; st.model = o.model; emit(ev.init(o.session_id, 'claude-code', o.model)); return; }
if (o.type === 'system' && o.subtype === 'init') { st.sessionId = o.session_id; st.model = o.model; emit(ev.init(o.session_id, 'claude-code', o.model)); return; }
if (o.type === 'result') { emit({ ...o, result: ev.scrub(o.result || '') }); return; }
if (o.type === 'result') { emit({ ...o, result: ev.scrub(o.result || '') }); return; }
@@ -356,8 +358,9 @@ const claudeOther = {
@@ -356,8 +358,9 @@ const claudeOther = {
'--permission-mode', mode, '--exclude-dynamic-system-prompt-sections'];
'--permission-mode', mode, '--exclude-dynamic-system-prompt-sections'];
if (t.maxTurns) args.push('--max-turns', String(t.maxTurns));
if (t.maxTurns) args.push('--max-turns', String(t.maxTurns));
if (t.resume) args.push('--resume', String(t.resume));
if (t.resume) args.push('--resume', String(t.resume));
- // Even unattended, a run never pushes: publishing is the person's step.
- args.push('--disallowedTools', 'Bash(git push:*)');
+ // The portal's deny list rides along (no commits, resets, stashes, sudo, recursive rm, .env reads); even
+ // unattended, a run never pushes: publishing is the person's step, the one rule full keeps.
+ args.push('--disallowedTools', t.permission === 'full' ? 'Bash(git push:*)' : CLAUDE_DENIED.join(' '));
// The harness's environment (PATH finds the CLI), with the connection's own names on top: the endpoint, the
// The harness's environment (PATH finds the CLI), with the connection's own names on top: the endpoint, the
// token and the model id. The key travels in the environment, never in argv: the process list of this machine
// token and the model id. The key travels in the environment, never in argv: the process list of this machine
// is readable.
// is readable.
@@ -12,6 +12,7 @@ const path = require('path');
@@ -12,6 +12,7 @@ const path = require('path');
const http = require('http');
const http = require('http');
const { createHarness } = require('../harness');
const { createHarness } = require('../harness');
const { createStore } = require('../harness/sessions');
const { createStore } = require('../harness/sessions');
+const { refusedCommand, runTool } = require('../harness/adapters/api');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
/** A chat-completions server that answers from a script: each request gets the next scripted message. */
/** A chat-completions server that answers from a script: each request gets the next scripted message. */
@@ -190,6 +191,52 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
@@ -190,6 +191,52 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
assert.ok(!fs.existsSync(path.join(dir, 'outside-dir', 'planted.txt')), 'no write through the link');
assert.ok(!fs.existsSync(path.join(dir, 'outside-dir', 'planted.txt')), 'no write through the link');
});
});
+ await check('run_command refuses a push, a reset, a recursive rm and a .env path before anything spawns', async () => {
+ api.set([{ calls: [
+ ['run_command', { command: 'git push --force origin main; touch escaped.txt' }],
+ ['run_command', { command: 'npm test && git reset --hard HEAD' }],
+ ['run_command', { command: 'echo tidying; rm -rf src' }],
+ ['run_command', { command: 'cat .env' }],
+ ['run_command', { command: 'git --version' }],
+ ] }, { content: 'ok' }]);
+ const events = await collect(h.run({ prompt: 'push it', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));
+ const res = toolResults(events);
+ assert.strictEqual(res.length, 5);
+ assert.ok(res.slice(0, 4).every((c) => c.is_error), JSON.stringify(res.map((c) => c.content)));
+ assert.ok(/publishing is the person's step/.test(res[0].content), res[0].content);
+ assert.ok(/git reset is refused/.test(res[1].content), res[1].content);
+ assert.ok(/recursive rm is refused/.test(res[2].content), res[2].content);
+ assert.ok(/\.env files are not read/.test(res[3].content), res[3].content);
+ assert.ok(!res[4].is_error && /git version/.test(res[4].content), 'a plain git read still runs');
+ assert.ok(!fs.existsSync(path.join(work, 'escaped.txt')), 'the refused chain never spawned');
+ assert.ok(fs.existsSync(path.join(work, 'src', 'a.js')), 'the refused rm never ran');
+ });
+
+ await check('the refused shapes: chained, nested and prefixed commands, and what still runs', () => {
+ for (const c of ['git push', 'git push -f', 'echo ok;git reset', 'git stash list | git push', 'sudo git push',
+ 'bash -c "git push origin x"', 'timeout 5 git push', 'rm -fr build', 'rm --recursive old',
+ 'rm --force -r x', 'cat a/.env.local', 'grep SECRET .env', 'echo x > .env', 'cp a .env']) {
+ assert.ok(refusedCommand(c), `refused: ${c}`);
+ }
+ for (const c of ['pwd', 'npm test', 'git status', 'git log --oneline', 'rm note.txt', 'rm -f note.txt',
+ 'git commit -m "push the button"', 'echo NODE_ENV=production', 'git stash list | grep reset', 'cat config.env']) {
+ assert.strictEqual(refusedCommand(c), null, `runs: ${c}`);
+ }
+ assert.strictEqual(refusedCommand(''), null);
+ assert.strictEqual(refusedCommand(null), null);
+ });
+
+ await check('one read answers at most 40 KB, and says where to continue', () => {
+ // At the tool itself: the loop's events clip to 4 KB, so the cap is only visible here.
+ fs.writeFileSync(path.join(work, 'big.txt'), `${'x'.repeat(999)}\n`.repeat(60));
+ const [body, isError] = runTool('read_file', { path: 'big.txt' }, work);
+ assert.strictEqual(isError, false);
+ assert.ok(body.length <= 40_100, `cut at 40 KB, was ${body.length}`);
+ assert.ok(/… \(cut: read with offset\/limit\)$/.test(body), 'the cut says where to continue');
+ const [small] = runTool('read_file', { path: 'src/a.js' }, work);
+ assert.ok(!/cut: read with/.test(small), 'a small file comes back whole');
+ });
+
await check('run_command exists only at the full level, and runs in the working directory', async () => {
await check('run_command exists only at the full level, and runs in the working directory', async () => {
api.set([{ calls: [['run_command', { command: 'pwd' }]] }, { content: 'ok' }]);
api.set([{ calls: [['run_command', { command: 'pwd' }]] }, { content: 'ok' }]);
const events = await collect(h.run({ prompt: 'where', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));
const events = await collect(h.run({ prompt: 'where', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));
@@ -70,6 +70,17 @@ const collect = async (run) => { const out = []; for await (const e of run) out.
@@ -70,6 +70,17 @@ const collect = async (run) => { const out = []; for await (const e of run) out.
assert.ok(inBox.candidates.filter((c) => /^(opencode|command-code):/.test(c.id)).some((c) => c.included !== false), 'inside a sandbox they may be picked');
assert.ok(inBox.candidates.filter((c) => /^(opencode|command-code):/.test(c.id)).some((c) => c.included !== false), 'inside a sandbox they may be picked');
});
});
+ await check('Claude Code carries the deny list, and full keeps only the push', async () => {
+ const after = (argv, flag) => argv[argv.indexOf(flag) + 1];
+ for (const adapter of [cli.claudeCode, cli.claudeOther]) {
+ const edit = after(await argvOf(adapter, { permission: 'edit' }), '--disallowedTools');
+ assert.ok(edit.includes('Bash(git push:*)'), 'a run never pushes');
+ assert.ok(edit.includes('Bash(git reset:*)') && edit.includes('Bash(rm -rf:*)'), 'reset and recursive rm ride along');
+ assert.ok(edit.includes('Read(.env*)'), 'no .env reads');
+ assert.strictEqual(after(await argvOf(adapter, { permission: 'full' }), '--disallowedTools'), 'Bash(git push:*)', 'full keeps only the push');
+ }
+ });
+
await check('a run that names one for "edit" outside a sandbox is refused in words, and nothing starts', async () => {
await check('a run that names one for "edit" outside a sandbox is refused in words, and nothing starts', async () => {
const events = await collect(harness().run({ prompt: 'tidy it', agent: 'opencode', permission: 'edit', cwd: dir, handoff: false }));
const events = await collect(harness().run({ prompt: 'tidy it', agent: 'opencode', permission: 'edit', cwd: dir, handoff: false }));
const result = events.find((e) => e.type === 'result');
const result = events.find((e) => e.type === 'result');