From a298e6d99c1b525369057ac61ac44c5b818a51ce Mon Sep 17 00:00:00 2001 From: Serge Alex Date: Sat, 7 Nov 2026 07:15:18 +0300 Subject: [PATCH] Bash policy: cross-platform safe-list, skip git/pkg global flags, single confirm pass --- bridge/server.js | 23 ++++++++++----------- bridge/tools.js | 63 ++++++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/bridge/server.js b/bridge/server.js index 1b47a5f..ed84df4 100644 --- a/bridge/server.js +++ b/bridge/server.js @@ -178,24 +178,23 @@ async function handle(req, res, pathname, body) { var r = await runTool(tool, args, ctx, handoff, mcp); await new Promise(function(ok) { setTimeout(ok, 3000); }); return r; }; let data; - try { - data = await call(false); - } catch (err) { - if (err instanceof ToolError && err.code === 'E_NEEDS_CONFIRM') { - if (confirmToken && pendingConfirms.has(confirmToken)) { - const pending = pendingConfirms.get(confirmToken); - pendingConfirms.delete(confirmToken); - if (pending.exp < Date.now()) throw new ToolError('E_NEEDS_CONFIRM', '确认令牌已过期,请重新发起'); - data = await call(true); - } else { + // Если пришёл confirmToken — сразу выполняем с confirmed:true, без лишнего прохода call(false). + if (confirmToken && pendingConfirms.has(confirmToken)) { + const pending = pendingConfirms.get(confirmToken); + pendingConfirms.delete(confirmToken); + if (pending.exp < Date.now()) throw new ToolError('E_NEEDS_CONFIRM', '确认令牌已过期,请重新发起'); + data = await call(true); + } else { + try { + data = await call(false); + } catch (err) { + if (err instanceof ToolError && err.code === 'E_NEEDS_CONFIRM') { const token = crypto.randomBytes(16).toString('hex'); pendingConfirms.set(token, { exp: Date.now() + 10 * 60 * 1000, tool, args, workspace }); - // 清理过期项 for (const [k, v] of pendingConfirms) if (v.exp < Date.now()) pendingConfirms.delete(k); err.extra = { ...(err.extra || {}), confirmToken: token }; throw err; } - } else { throw err; } } diff --git a/bridge/tools.js b/bridge/tools.js index 7921bb7..f307b4b 100644 --- a/bridge/tools.js +++ b/bridge/tools.js @@ -76,14 +76,38 @@ 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']); +// Safe-list: кроссплатформенный (Windows + Unix). Только команды, которые +// НЕ читают содержимое файлов (иначе обход validatePath и доступ к .env/ключам). +// cat, head, tail, type, more, less — сознательно НЕ включены: они читают файлы. +// Любые policy-флаги (shell, destructive, absolute-path, parent-path) отменяют allow. +const SAFE_PLAIN_HEADS = new Set([ + 'pwd', 'dir', 'ls', 'tree', 'where', 'which', 'whoami', 'hostname', + 'uname', 'ver', 'cd', 'echo', 'date', 'time' +]); +const SAFE_GIT_READONLY = new Set(['status', 'diff', 'log', 'show', 'branch', 'rev-parse', 'ls-files', 'describe', 'remote', 'config']); 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_PKG_SECOND = new Set(['test', 'run', 'build', 'lint', 'typecheck', 'check', '--version', '-v', 'ls', 'list', 'why', 'outdated', 'info', 'view']); const SAFE_NPX_TOOLS = new Set(['tsc', 'eslint', 'prettier', 'vitest', 'jest', 'mocha']); +// Глобальные флаги, которые стоят ПЕРЕД подкомандой и не меняют её смысла. +// git -C status -> подкоманда status, safe. +// npm --prefix test -> подкоманда test, safe. +const GIT_GLOBAL_WITH_ARG = new Set(['-C', '-c']); +const GIT_GLOBAL_NO_ARG = new Set(['--no-pager', '--bare', '--literal-pathspecs', '--no-optional-locks']); +const PKG_GLOBAL_WITH_ARG = new Set(['--prefix', '--workspace', '-w']); + +function skipGlobalFlags(tokens, start, withArg, noArg) { + let i = start; + while (i < tokens.length) { + const t = tokens[i]; + if (noArg.has(t)) { i++; continue; } + if (withArg.has(t)) { i += 2; continue; } + if (t.startsWith('--') && t.includes('=')) { i++; continue; } + break; + } + return i; +} + function computePolicyFlags(raw) { const flags = []; if (BASH_MULTILINE.test(raw)) flags.push('multiline'); @@ -95,14 +119,31 @@ function computePolicyFlags(raw) { } function isSafeListed(normalized) { - const tokens = normalized.split(' '); + const tokens = normalized.split(' ').filter(Boolean); + if (tokens.length === 0) return false; 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 === 'git') { + const idx = skipGlobalFlags(tokens, 1, GIT_GLOBAL_WITH_ARG, GIT_GLOBAL_NO_ARG); + const sub = tokens[idx] || ''; + return SAFE_GIT_READONLY.has(sub); + } + if (SAFE_PKG_HEADS.has(head)) { + const idx = skipGlobalFlags(tokens, 1, PKG_GLOBAL_WITH_ARG, GIT_GLOBAL_NO_ARG); + const sub = tokens[idx] || ''; + if (SAFE_PKG_SECOND.has(sub)) return true; + if (sub === '--version' || sub === '-v') return true; + return false; + } + if (head === 'node' || head === 'npx') { + const second = tokens[1] || ''; + if (second === '--version' || second === '-v') return true; + if (head === 'npx') { + const idx = skipGlobalFlags(tokens, 1, new Set(['-p', '--package']), new Set(['--yes', '-y', '--no-install'])); + return SAFE_NPX_TOOLS.has(tokens[idx] || ''); + } + return false; + } if (head === 'tsc' || head === 'eslint') return true; return false; }