Browse Source

Bash policy: safe-list + flags instead of hard blocks

master
Serge Shutov 1 week ago
parent
commit
c2cf4198cf
  1. 95
      bridge/tools.js

95
bridge/tools.js

@ -57,9 +57,12 @@ const TOOL_DEFS = [ @@ -57,9 +57,12 @@ const TOOL_DEFS = [
];
// ---------------------------------------------------------------------------
// bash 安全层:白名单前缀 + 无条件黑名单 + 元字符封禁
// bash 安全层: safe-list (без подтверждения) + policy flags (audit) + confirm.
// Жёстко блокируются только: пустая команда, >2000 символов.
// Метасимволы и blacklist-паттерны -> confirm с флагами, не block.
// ---------------------------------------------------------------------------
const BASH_META_CHARS = /[|&<>;`\n\r$]/;
const BASH_META_CHARS = /[|&<>;`$]/;
const BASH_MULTILINE = /[\r\n]/;
const BASH_BLACKLIST_RE = new RegExp(
[
@ -73,40 +76,60 @@ const BASH_BLACKLIST_RE = new RegExp( @@ -73,40 +76,60 @@ const BASH_BLACKLIST_RE = new RegExp(
'i'
);
// Safe-list: только эти команды не требуют подтверждения. `type` убран — читает
// файлы в обход validatePath (включая .env). Любые policy-флаги отменяют allow.
const SAFE_PLAIN_HEADS = new Set(['pwd', 'dir', 'ls', 'tree', 'where', 'whoami']);
const SAFE_GIT_READONLY = new Set(['status', 'diff', 'log', 'show', 'branch', 'rev-parse', 'ls-files', 'describe', 'remote']);
const SAFE_PKG_HEADS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
const SAFE_PKG_SECOND = new Set(['test', 'run', 'build', 'lint', 'typecheck', 'check', '--version', '-v', 'ls', 'list', 'why', 'outdated']);
const SAFE_NPX_TOOLS = new Set(['tsc', 'eslint', 'prettier', 'vitest', 'jest', 'mocha']);
function computePolicyFlags(raw) {
const flags = [];
if (BASH_MULTILINE.test(raw)) flags.push('multiline');
if (BASH_META_CHARS.test(raw)) flags.push('shell');
if (BASH_BLACKLIST_RE.test(raw)) flags.push('destructive');
if (/[a-z]:[\\/]|\/{2}/i.test(raw)) flags.push('absolute-path');
if (/(^|\s)\.\.([\\/]|$)/.test(raw)) flags.push('parent-path');
return flags;
}
function isSafeListed(normalized) {
const tokens = normalized.split(' ');
const head = tokens[0];
const second = tokens[1] || '';
if (SAFE_PLAIN_HEADS.has(head)) return true;
if (head === 'git' && SAFE_GIT_READONLY.has(second)) return true;
if (SAFE_PKG_HEADS.has(head) && SAFE_PKG_SECOND.has(second)) return true;
if ((head === 'node' || head === 'npm' || head === 'npx' || head === 'pnpm' || head === 'yarn' || head === 'bun') && (second === '--version' || second === '-v')) return true;
if (head === 'npx' && SAFE_NPX_TOOLS.has(second)) return true;
if (head === 'tsc' || head === 'eslint') return true;
return false;
}
function policyReason(flags, normalized) {
const parts = [];
if (flags.includes('multiline')) parts.push('multi-line command');
if (flags.includes('shell')) parts.push('shell operators (| & > < ; ` $)');
if (flags.includes('destructive')) parts.push('matches destructive/network/system pattern');
if (flags.includes('absolute-path')) parts.push('absolute path outside workspace');
if (flags.includes('parent-path')) parts.push('parent path (..) escapes workspace');
const detail = parts.length ? parts.join('; ') : 'not in safe-list';
return 'policy: ' + detail + '. command: ' + normalized.slice(0, 500);
}
function checkBashCommand(command) {
if (typeof command !== 'string' || command.trim() === '') {
throw new ToolError('E_ARGS_INVALID', 'command must be a non-empty string');
}
if (command.length > 2000) throw new ToolError('E_ARGS_INVALID', 'command too long (>2000 chars)');
if (BASH_META_CHARS.test(command)) {
throw new ToolError('E_BASH_BLOCKED', 'command contains forbidden metacharacters(| & > < ; ` $ newline)');
}
if (BASH_BLACKLIST_RE.test(command)) {
throw new ToolError('E_BASH_BLOCKED', `command matched blacklist: ${command.slice(0, 120)}`);
const raw = command.trim();
if (raw.length > 2000) throw new ToolError('E_ARGS_INVALID', 'command too long (>2000 chars)');
const normalized = raw.replace(/\s+/g, ' ');
const flags = computePolicyFlags(raw);
if (flags.length === 0 && isSafeListed(normalized.toLowerCase())) {
return { verdict: 'allow', flags: [] };
}
const trimmed = command.trim().replace(/\s+/g, ' ');
const lower = trimmed.toLowerCase();
const tokens = lower.split(' ');
const head = tokens[0];
const second = tokens[1] || '';
const plainHeads = new Set(['pwd', 'dir', 'ls', 'tree', 'type', 'where', 'whoami']);
const gitReadOnly = new Set(['status', 'diff', 'log', 'show', 'branch', 'rev-parse', 'ls-files', 'describe', 'remote']);
const pkgHeads = new Set(['npm', 'pnpm', 'yarn', 'bun']);
const pkgSecondOk = new Set(['test', 'run', 'build', 'lint', 'typecheck', 'check', '--version', '-v', 'ls', 'list', 'why', 'outdated', 'exec']);
const versionOnly = new Set(['--version', '-v']);
const npxTools = new Set(['tsc', 'eslint', 'prettier', 'vitest', 'jest', 'mocha']);
let allowed = false;
if (plainHeads.has(head)) allowed = true;
else if (head === 'git' && gitReadOnly.has(second)) allowed = true;
else if (pkgHeads.has(head) && pkgSecondOk.has(second)) allowed = true;
else if ((head === 'node' || head === 'npm' || head === 'npx') && versionOnly.has(second)) allowed = true;
else if (head === 'npx' && npxTools.has(second)) allowed = true;
else if (head === 'tsc' || head === 'eslint') allowed = true;
if (allowed) return { verdict: 'allow' };
return { verdict: 'confirm', reason: `command not in whitelist (outside blacklist, needs confirmation): ${trimmed.slice(0, 120)}` };
return { verdict: 'confirm', flags, reason: policyReason(flags, normalized) };
}
// ---------------------------------------------------------------------------
@ -365,13 +388,16 @@ function runCaptured(cmd, cmdArgs, cwd, timeoutMs, maxOut, opts) { @@ -365,13 +388,16 @@ function runCaptured(cmd, cmdArgs, cwd, timeoutMs, maxOut, opts) {
async function toolBash(ctx, args) {
const verdict = checkBashCommand(args.command);
if (verdict.verdict === 'blocked') throw new ToolError('E_BASH_BLOCKED', verdict.reason);
const flags = verdict.flags || [];
if (flags.length) console.warn('[bash] policy flags: ' + flags.join(',') + ' :: ' + String(args.command).slice(0, 200));
if (verdict.verdict === 'confirm' && !ctx.confirmed) {
throw new ToolError('E_NEEDS_CONFIRM', verdict.reason);
const err = new ToolError('E_NEEDS_CONFIRM', verdict.reason);
err.extra = { policyFlags: flags };
throw err;
}
const root = resolveWorkspace(ctx.config, ctx.workspace);
const timeoutMs = Math.min(Math.max(1000, Number(args.timeoutMs) || ctx.config.bashTimeoutMs), ctx.config.bashTimeoutMaxMs);
// shell:true 以支持 dir/type 等 cmd 内建;命令已过元字符封禁
// shell:true нужен для cmd-встроенных (dir/type) и для shell-композиции (| & > ;)
const r = await runCaptured(args.command.trim(), [], root, timeoutMs, BASH_OUT_MAX, { shell: true });
if (r.timedOut) throw new ToolError('E_TIMEOUT', `Command timed out (${timeoutMs}ms) and was killed. Captured stdout so far: ${r.stdout.slice(0, 500)}`);
return {
@ -379,6 +405,7 @@ async function toolBash(ctx, args) { @@ -379,6 +405,7 @@ async function toolBash(ctx, args) {
stderr: r.stderr.slice(0, 8192),
exitCode: r.exitCode,
truncated: r.truncated,
policyFlags: flags,
};
}

Loading…
Cancel
Save