|
|
|
|
@ -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, |
|
|
|
|
}; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|