From c2cf4198cfc8bc47b1be74590272960fcc43b9df Mon Sep 17 00:00:00 2001 From: Serge Alex Date: Sat, 7 Nov 2026 06:44:07 +0300 Subject: [PATCH] Bash policy: safe-list + flags instead of hard blocks --- bridge/tools.js | 95 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/bridge/tools.js b/bridge/tools.js index 6e7b064..7921bb7 100644 --- a/bridge/tools.js +++ b/bridge/tools.js @@ -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( '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) { 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) { stderr: r.stderr.slice(0, 8192), exitCode: r.exitCode, truncated: r.truncated, + policyFlags: flags, }; }