image_edit: - speech_bubble operation with Korean font, rounded rect, tail - sketch quality: CLAHE pre-processing for better line contrast - output filename with timestamp to prevent overwrites - anime/painting stylize timeout 30s→600s (AnimeGANv2 model download) - remove_bg PNG output fix server-v2.ts: - resolveToolImageContent: embed edited image as base64 in tool results so vision models can see the output (3 execution paths covered) - _activeModelName: fix vision support detection for Ollama-hosted models (kimi/gemini run via Ollama — provider='ollama' was masking vision capability) - Synthetic tool call ID generation to prevent Gemini function_response empty name error - image_edit path hint format changed to English to prevent Gemini hallucination - userRequestedImageEdit: expanded keyword list (풍선, 달아, 붙여 등) - image_read OCR failure returns success:true with visual description hint - TOOL_BLOCKS photo: image_edit workflow documentation updated web-ui/index.html: - renderFileDownloads: skip download buttons for image file extensions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
138 lines
4.9 KiB
TypeScript
138 lines
4.9 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;
|
|
_workspacePath?: string;
|
|
_workspace?: 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(args._workspacePath || args._workspace || 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'
|
|
}
|
|
};
|