openvibers/openvibe.codes Commits

Hard rules enforced in the adapters, and a 40 KB read cap (R1 item 6) (#379)

OP OpenVibers committed 3 days ago · 67d857cd5e · 4 files, +116 −8

The API agent loop (DeepSeek, OpenVibe.AI, your own model) refuses a push,
a reset, a recursive rm and any .env path before the shell spawns: every
chained segment is checked from its first word, and the same shapes match
anywhere in the command so sh -c nesting does not dodge them. One read
answers 40 KB now, enforced where it is read. Claude Code runs carry the
portal's whole deny list below full; full keeps only the never-push rule.

Codex and MiniMax Code have no deny mechanism to set: the installed CLIs
offer only the sandbox (Codex) and the permission policy (mcode), both
already mapped, so there is deliberately no settings-shaped list for them.

Changes

ddiff --git a/harness/adapters/api.js b/harness/adapters/api.js
iindex ed1839c..d9abc37 100644
---- a/harness/adapters/api.js
++++ b/harness/adapters/api.js
@@@ -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 };
ddiff --git a/harness/adapters/cli.js b/harness/adapters/cli.js
iindex bd14e3a..d6d1e02 100644
---- a/harness/adapters/cli.js
++++ b/harness/adapters/cli.js
@@@ -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.
ddiff --git a/test/harness-api.test.js b/test/harness-api.test.js
iindex 5e9c43b..955c291 100644
---- a/test/harness-api.test.js
++++ b/test/harness-api.test.js
@@@ -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 }));
ddiff --git a/test/harness-sandbox.test.js b/test/harness-sandbox.test.js
iindex 5d175a8..56c8647 100644
---- a/test/harness-sandbox.test.js
++++ b/test/harness-sandbox.test.js
@@@ -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');