Browse Source

Bash policy: cross-platform safe-list, skip git/pkg global flags, single confirm pass

master
Serge Shutov 1 week ago
parent
commit
a298e6d99c
  1. 23
      bridge/server.js
  2. 63
      bridge/tools.js

23
bridge/server.js

@ -178,24 +178,23 @@ async function handle(req, res, pathname, body) { @@ -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;
}
}

63
bridge/tools.js

@ -76,14 +76,38 @@ const BASH_BLACKLIST_RE = new RegExp( @@ -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 <path> status -> подкоманда status, safe.
// npm --prefix <path> 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) { @@ -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;
}

Loading…
Cancel
Save