Files
homeclaw/src/tools/shell.ts
T
kimandClaude Sonnet 4.6 245606252e v2.7.1 — image edit improvements, search config, bug fixes
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>
2026-05-18 22:03:32 +09:00

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'
}
};