136 lines
4.8 KiB
TypeScript
136 lines
4.8 KiB
TypeScript
import path from 'path';
|
|
import { getConfig } from '../config/config.js';
|
|
import { ToolResult } from '../types.js';
|
|
import { log } from '../security/log-scrubber.js';
|
|
|
|
export interface ShellToolArgs {
|
|
command: string;
|
|
cwd?: string;
|
|
}
|
|
|
|
// ── Path confinement helper ───────────────────────────────────────────────────
|
|
// Uses proper path.resolve + path.relative — immune to case, trailing-slash,
|
|
// and "../" traversal bypasses that defeat simple startsWith() checks.
|
|
function isPathInsideDir(base: string, target: string): boolean {
|
|
const resolvedBase = path.resolve(base);
|
|
const resolvedTarget = path.resolve(target);
|
|
if (resolvedBase === resolvedTarget) return true;
|
|
const rel = path.relative(resolvedBase, resolvedTarget);
|
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
}
|
|
|
|
// Compiled once at module load — never recreated per execution
|
|
const DANGEROUS_COMMANDS: Array<[RegExp, string]> = [
|
|
[/rm\s+-rf\s+\//, 'rm -rf /'],
|
|
[/mkfs/, 'filesystem format'],
|
|
[/dd\s+if=/, 'disk write'],
|
|
[/>\s*\/dev\//, 'device write'],
|
|
[/\bsudo\b/, 'privilege escalation'],
|
|
[/\bsu\s/, 'user switch'],
|
|
[/chmod\s+777/, 'world-writable permission'],
|
|
[/\bcurl\b.*\|.*\bbash\b/, 'curl-pipe-bash'],
|
|
[/\bwget\b.*-O.*\s*-\s*\|/, 'wget-pipe'],
|
|
];
|
|
|
|
// ── Absolute-path detector ────────────────────────────────────────────────────
|
|
// Catches commands that contain absolute paths outside the workspace even when
|
|
// cwd is inside it — e.g. `type C:\Windows\System32\config\SAM`
|
|
function containsOutOfScopeAbsPath(command: string, workspacePath: string): boolean {
|
|
// Match Windows and POSIX absolute paths embedded in command strings
|
|
const absPathRe = process.platform === 'win32'
|
|
? /[A-Za-z]:[/\\][^\s"']+/g
|
|
: /\/[^\s"']{3,}/g;
|
|
|
|
const matches = command.match(absPathRe) || [];
|
|
for (const match of matches) {
|
|
try {
|
|
if (!isPathInsideDir(workspacePath, match)) return true;
|
|
} catch {
|
|
// If we can't resolve it, treat as suspicious
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function executeShell(args: ShellToolArgs): Promise<ToolResult> {
|
|
const config = getConfig().getConfig();
|
|
const permissions = config.tools.permissions.shell;
|
|
const workspacePath = path.resolve(config.workspace.path);
|
|
|
|
// Determine and resolve working directory
|
|
const cwd = path.resolve(args.cwd ? args.cwd : workspacePath);
|
|
|
|
// ── FIX HIGH-05: use proper path confinement (not startsWith) ──────────────
|
|
if (permissions.workspace_only) {
|
|
if (!isPathInsideDir(workspacePath, cwd)) {
|
|
log.warn('[shell] Blocked: cwd outside workspace:', cwd);
|
|
return {
|
|
success: false,
|
|
error: `Security: Command execution outside workspace is not allowed. Workspace: ${workspacePath}, Requested: ${cwd}`
|
|
};
|
|
}
|
|
|
|
// Also block commands that reference absolute paths outside workspace
|
|
if (containsOutOfScopeAbsPath(args.command, workspacePath)) {
|
|
log.warn('[shell] Blocked: command references path outside workspace:', args.command.slice(0, 120));
|
|
return {
|
|
success: false,
|
|
error: `Security: Command references a path outside the workspace directory.`
|
|
};
|
|
}
|
|
}
|
|
|
|
// Check config-defined blocked patterns
|
|
for (const pattern of permissions.blocked_patterns) {
|
|
if (args.command.includes(pattern)) {
|
|
log.warn('[shell] Blocked pattern match:', pattern);
|
|
return {
|
|
success: false,
|
|
error: `Security: Command blocked due to dangerous pattern: "${pattern}"`
|
|
};
|
|
}
|
|
}
|
|
|
|
for (const [pattern, label] of DANGEROUS_COMMANDS) {
|
|
if (pattern.test(args.command)) {
|
|
log.warn('[shell] Blocked dangerous command:', label);
|
|
return {
|
|
success: false,
|
|
error: `Security: Potentially destructive command detected (${label}): ${args.command.slice(0, 80)}`
|
|
};
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Lazy-load node-pty (63MB native module) only when shell is actually invoked
|
|
const { default: PTYManager } = await import('../gateway/pty-manager.js');
|
|
const pty = PTYManager.getInstance();
|
|
const output = await pty.runCommand(args.command);
|
|
return {
|
|
success: true,
|
|
stdout: output.trim(),
|
|
stderr: '',
|
|
exitCode: 0
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
error: error.message,
|
|
stdout: '',
|
|
stderr: '',
|
|
exitCode: 1
|
|
};
|
|
}
|
|
}
|
|
|
|
export const shellTool = {
|
|
name: 'shell',
|
|
description: 'Execute terminal commands in the workspace',
|
|
execute: executeShell,
|
|
schema: {
|
|
command: 'string (required) - The command to execute',
|
|
cwd: 'string (optional) - Working directory, defaults to workspace'
|
|
}
|
|
};
|