package com.aicodeassistant.tool.impl; import com.aicodeassistant.observability.SafeLogValue; import com.aicodeassistant.sandbox.SandboxManager; import com.aicodeassistant.security.CommandBlacklistService; import com.aicodeassistant.tool.*; import com.aicodeassistant.tool.bash.BashCommandClassifier; import com.aicodeassistant.tool.bash.BashErrorClassifier; import com.aicodeassistant.tool.bash.BashErrorClassifier.ErrorClassification; import com.aicodeassistant.tool.bash.BashOutputProcessor; import com.aicodeassistant.tool.bash.BashSecurityAnalyzer; import com.aicodeassistant.tool.bash.CommandCategory; import com.aicodeassistant.tool.bash.ShellStateManager; import com.aicodeassistant.tool.bash.ast.BashAstNode.SimpleCommandNode; import com.aicodeassistant.tool.bash.ast.ParseForSecurityResult; import com.aicodeassistant.tool.process.ManagedProcessRunner; import org.springframework.beans.factory.annotation.Autowired; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.Map; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; /** * BashTool — 执行 shell 命令。 *

* P0 实现: ProcessBuilder + 超时 + SIGTERM→SIGKILL 梯度终止。 * 安全: 三级降级 AST → 正则 → Python bashlex (§3.2.3c + §3.4)。 *

* */ @Component public class BashTool implements Tool { private static final Logger log = LoggerFactory.getLogger(BashTool.class); private static final int MAX_OUTPUT_CHARS = 30_000; private static final long DEFAULT_TIMEOUT_MS = safeParseTimeout( System.getenv().getOrDefault("BASH_DEFAULT_TIMEOUT_MS", "120000"), 120_000L); private static final long MAX_TIMEOUT_MS = safeParseTimeout( System.getenv().getOrDefault("BASH_MAX_TIMEOUT_MS", "600000"), 600_000L); /** * 安全解析超时环境变量 — 避免 NumberFormatException 导致类加载失败。 */ private static long safeParseTimeout(String value, long defaultValue) { try { return Long.parseLong(value); } catch (NumberFormatException e) { LoggerFactory.getLogger(BashTool.class) .warn("Invalid timeout value '{}', using default {}ms", value, defaultValue); return defaultValue; } } private final BashSecurityAnalyzer securityAnalyzer; private final BashCommandClassifier commandClassifier; private final ShellStateManager shellStateManager; private final BashOutputProcessor outputProcessor; private final SandboxManager sandboxManager; private final CommandBlacklistService commandBlacklistService; private final BashErrorClassifier errorClassifier; private final ManagedProcessRunner managedProcessRunner; @Autowired public BashTool(BashSecurityAnalyzer securityAnalyzer, BashCommandClassifier commandClassifier, ShellStateManager shellStateManager, BashOutputProcessor outputProcessor, SandboxManager sandboxManager, CommandBlacklistService commandBlacklistService, BashErrorClassifier errorClassifier, ManagedProcessRunner managedProcessRunner) { this.securityAnalyzer = securityAnalyzer; this.commandClassifier = commandClassifier; this.shellStateManager = shellStateManager; this.outputProcessor = outputProcessor; this.sandboxManager = sandboxManager; this.commandBlacklistService = commandBlacklistService; this.errorClassifier = errorClassifier; this.managedProcessRunner = managedProcessRunner; } /** * 调用 BashErrorClassifier 对错误进行分类,并将结果注入 ToolResult.metadata。 *

* 此方法独立于 {@link com.aicodeassistant.tool.recovery.BashRecoveryPolicy}, * 分类器是无状态的纯函数,重复调用不冲突。 * * @param errorMessage 错误消息(作为 ToolResult.error 内容) * @param exitCode 进程退出码(超时场景可用 137 表示) * @param outputForClassification 用于分类分析的输出(合并 stdout+stderr) * @param command 原始命令 */ private ToolResult buildErrorWithClassification(String errorMessage, int exitCode, String outputForClassification, String command) { ErrorClassification classification = errorClassifier.classify( exitCode, outputForClassification, command); return ToolResult.failed(ToolResult.ToolFailureType.PROCESS, "BASH_" + classification.type().name(), errorMessage, ToolResult.Retryability.NEVER, ToolResult.EffectState.UNKNOWN, exitCode, Map.of()) .withMetadata("failure_category", classification.type().name()) .withMetadata("failure_suggestion", classification.suggestion()); } @Override public boolean isHighRisk() { return true; } @Override public long getMaxExecutionTimeMs() { return 600_000L; // 10 minutes for bash commands } @Override public String getName() { return "Bash"; } @Override public String getDescription() { return "Execute a shell command. Use for running scripts, installing packages, " + "compiling code, managing files, and performing system operations."; } @Override public String prompt() { return """ Executes a given bash command and returns its output. The working directory persists between commands, but shell state does not. \ The shell environment is initialized from the user's profile (bash or zsh). IMPORTANT: Avoid using this tool to run `find`, `grep`, `cat`, `head`, `tail`, \ `sed`, `awk`, or `echo` commands, unless explicitly instructed or after you have \ verified that a dedicated tool cannot accomplish your task. Instead, use the \ appropriate dedicated tool as this will provide a much better experience for the user: - File search: Use Glob (NOT find or ls) - Content search: Use Grep (NOT grep or rg) - Read files: Use Read (NOT cat/head/tail) - Edit files: Use Edit (NOT sed/awk) - Write files: Use Write (NOT echo >/cat < getInputSchema() { return Map.of( "type", "object", "properties", Map.of( "command", Map.of("type", "string", "description", "The shell command to execute"), "timeout", Map.of("type", "integer", "description", "Timeout in milliseconds. When omitted, a recommended timeout based on the command type is applied (fallback 120000); the maximum is server-configured (default 600000)."), "description", Map.of("type", "string", "description", "Description of what the command does"), "is_background", Map.of("type", "boolean", "description", "Run command in background, returning immediately with process ID"), "declared_outputs", Map.of( "type", "array", "description", "Outputs explicitly declared before execution; undeclared side effects are not artifacts", "items", Map.of("type", "object", "properties", Map.of( "path", Map.of("type", "string"), "operation", Map.of("type", "string", "enum", List.of("created", "modified", "deleted")), "requiredValidatorId", Map.of("type", "string")), "required", List.of("path", "operation"))) ), "required", java.util.List.of("command") ); } @Override public String getGroup() { return "bash"; } @Override public PermissionRequirement getPermissionRequirement() { return PermissionRequirement.CONDITIONAL; } // ═══════════════════════════════════════════════════════════════════ // [v1.57.0 G2] isReadOnly() — 基于 AST 分析的只读判断 // // 修复: 原实现使用首 token 判断, // 导致 "cat file; rm -rf /" 被误判为只读。 // 新实现: 通过 AST 遍历所有子命令 argv[0], // 全部为 search/read/list → 只读。 // ═══════════════════════════════════════════════════════════════════ @Override public boolean isReadOnly(ToolInput input) { String command = input.getString("command"); ParseForSecurityResult result = securityAnalyzer.parseForSecurity(command); if (result instanceof ParseForSecurityResult.Simple simple) { // 所有简单命令均通过 isSearchOrRead 检查 → 整体只读 return simple.commands().stream().allMatch(cmd -> { String argv0 = cmd.argv().isEmpty() ? "" : cmd.argv().getFirst(); if (commandClassifier.isSearchOrReadCommand(argv0)) { return true; } // 二次判定:拼接完整命令调用 isReadOnlyCommand(覆盖 git/docker/gh 等只读子命令) String fullCmd = String.join(" ", cmd.argv()); return commandClassifier.isReadOnlyCommand(fullCmd); }); } // AST 解析失败或 too-complex → 降级到正则分类器 return commandClassifier.classify(command).isReadOnly(); } @Override public boolean isConcurrencySafe(ToolInput input) { return isReadOnly(input); } // ═══════════════════════════════════════════════════════════════════ // [v1.57.0 G3] isDestructive() — 统一使用黑名单服务判定 // ═══════════════════════════════════════════════════════════════════ @Override public boolean isDestructive(ToolInput input) { String command = input.getString("command"); if (command == null || command.isBlank()) return false; CommandBlacklistService.BlockResult result = commandBlacklistService.checkCommand(command); return result.level() == CommandBlacklistService.BlockLevel.HIGH_RISK_ASK || result.level() == CommandBlacklistService.BlockLevel.ABSOLUTE_DENY; } @Override public ToolResult call(ToolInput input, ToolUseContext context) { String command = input.getString("command"); // 动态超时策略:LLM显式指定timeout时尊重其选择,否则基于命令类型推荐 long timeout; if (input.has("timeout")) { // LLM显式指定了timeout,尊重其选择(受MAX_TIMEOUT_MS上限约束) timeout = Math.min((long) input.getInt("timeout", (int) DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS); } else { // LLM未指定timeout,使用基于命令类型的动态推荐 CommandCategory timeoutCategory = commandClassifier.classifyForTimeout(command); long recommended = timeoutCategory.getRecommendedTimeoutMs(); timeout = Math.min(recommended, MAX_TIMEOUT_MS); if (recommended != DEFAULT_TIMEOUT_MS) { log.debug("Dynamic timeout applied: {} → {}ms (category: {})", SafeLogValue.fingerprint(command), recommended, timeoutCategory); } } boolean isBackground = input.getBoolean("is_background", false); String sessionId = context.sessionId(); try { // 绝对禁止命令最终防线(纵深防御) CommandBlacklistService.BlockResult blockResult = commandBlacklistService.checkCommand(command); if (blockResult.level() == CommandBlacklistService.BlockLevel.ABSOLUTE_DENY) { return ToolResult.permissionDenied("COMMAND_ABSOLUTELY_DENIED", blockResult.reason()); } // 1. Shell 状态包装 String wrappedCommand = shellStateManager.wrapCommand(command, sessionId); String workingDir = shellStateManager.resolveWorkingDirectory( sessionId, context.workingDirectory()); // 后台执行模式 if (isBackground) { if (managedProcessRunner == null || context.currentRunId() == null || context.toolUseId() == null) { return ToolResult.failed(ToolResult.ToolFailureType.PROCESS, "PROCESS_OWNERSHIP_MISSING", "Background process requires runId and toolUseId", ToolResult.Retryability.NEVER, ToolResult.EffectState.NOT_STARTED, null, Map.of()); } long pid = managedProcessRunner.startBackground(new ManagedProcessRunner.BackgroundRequest( List.of("bash", "-c", wrappedCommand), Path.of(workingDir), context.currentRunId(), context.toolUseId(), context.sessionId())).pid(); log.info("Background process started: pid={}, commandLength={}, commandFingerprint={}", pid, SafeLogValue.length(command), SafeLogValue.fingerprint(command)); return ToolResult.backgroundStarted(String.format( "Background process started with PID %d.%n" + "It remains owned by the current session; use `kill -- -%d` to stop its process group earlier.%n" + "Note: output is not captured for background processes.", pid, pid), pid); } // UI 分类日志(第四层,独立于安全分类) CommandCategory uiCategory = commandClassifier.classifyForUI(command); log.debug("Executing command: category={}, commandLength={}, commandFingerprint={}", uiCategory.getDisplayLabel(), SafeLogValue.length(command), SafeLogValue.fingerprint(command)); // === 沙箱路由判断:高危命令进容器执行 === if (sandboxManager.isSandboxingEnabled() && sandboxManager.shouldUseSandbox(command)) { return executeSandboxed(command, workingDir, timeout, context); } if (context.currentRunId() == null || context.currentRunId().isBlank() || context.toolUseId() == null || context.toolUseId().isBlank()) { return ToolResult.failed(ToolResult.ToolFailureType.PROCESS, "PROCESS_OWNERSHIP_MISSING", "Managed process requires runId and toolUseId", ToolResult.Retryability.NEVER, ToolResult.EffectState.NOT_STARTED, null, Map.of()); } ManagedProcessRunner.Result result = managedProcessRunner.run( new ManagedProcessRunner.Request(List.of("bash", "-c", wrappedCommand), Path.of(workingDir), Duration.ofMillis(timeout), context.currentRunId(), context.toolUseId())); String combined = result.stdout() + (result.stderr().isBlank() ? "" : (result.stdout().isBlank() ? "" : "\n") + result.stderr()); if (result.cancelled()) { return ToolResult.cancelled("PROCESS_CANCELLED", "Command cancelled", ToolResult.EffectState.UNKNOWN) .withMetadata("terminationConfirmed", result.terminationConfirmed()) .withMetadata("descendantTrackingUnavailable", result.descendantTrackingUnavailable()); } if (result.timedOut()) { return ToolResult.timedOut("PROCESS_DEADLINE_EXCEEDED", "Command timed out after " + timeout + "ms\n" + combined, 137, result.terminationConfirmed()) .withMetadata("terminationConfirmed", result.terminationConfirmed()) .withMetadata("descendantTrackingUnavailable", result.descendantTrackingUnavailable()) .withMetadata("stdoutTruncated", result.stdoutTruncated()) .withMetadata("stderrTruncated", result.stderrTruncated()); } if (!result.terminationConfirmed()) { return ToolResult.failed(ToolResult.ToolFailureType.PROCESS, "PROCESS_TERMINATION_UNCONFIRMED", "Command exited but child process cleanup could not be confirmed\n" + outputProcessor.processOutput(combined, true), ToolResult.Retryability.NEVER, ToolResult.EffectState.UNKNOWN, result.exitCode(), Map.of("terminationConfirmed", false, "descendantTrackingUnavailable", result.descendantTrackingUnavailable(), "stdoutTruncated", result.stdoutTruncated(), "stderrTruncated", result.stderrTruncated(), "truncated", result.stdoutTruncated() || result.stderrTruncated())); } shellStateManager.updateStateFromSnapshot(sessionId); boolean failed = result.exitCode() != 0; String processed = outputProcessor.processOutput(combined, failed); if (failed) return buildErrorWithClassification( "Exit code: " + result.exitCode() + "\n" + processed, result.exitCode(), combined, command); return ToolResult.successWithEffect(processed, ToolResult.EffectState.UNKNOWN) .withMetadata("exitCode", result.exitCode()) .withMetadata("truncated", result.stdoutTruncated() || result.stderrTruncated()) .withMetadata("elapsedMs", result.elapsedMs()); } catch (IOException e) { log.error("Failed to execute command: commandLength={}, commandFingerprint={}, errorType={}", SafeLogValue.length(command), SafeLogValue.fingerprint(command), SafeLogValue.errorType(e)); String errMsg = "Failed to execute command: " + e.getMessage(); // 进程启动失败 → exitCode=-1,由分类器基于 stderr 关键字判断(默认 NON_RETRYABLE) return buildErrorWithClassification(errMsg, -1, e.getMessage() == null ? "" : e.getMessage(), command); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return buildErrorWithClassification("Command execution interrupted", -1, "interrupted", command); } } /** * 在沙箱容器中执行命令(用于高危命令隔离)。 */ private ToolResult executeSandboxed(String command, String workingDir, long timeoutMs, ToolUseContext context) throws java.io.IOException, InterruptedException { if (managedProcessRunner == null || context.currentRunId() == null || context.toolUseId() == null) { return ToolResult.failed(ToolResult.ToolFailureType.PROCESS, "PROCESS_OWNERSHIP_MISSING", "Sandbox process requires runId and toolUseId", ToolResult.Retryability.NEVER, ToolResult.EffectState.NOT_STARTED, null, Map.of()); } var invocation = sandboxManager.prepareInvocation(command, Path.of(workingDir), Map.of(), context.currentRunId(), context.toolUseId()); long effectiveTimeout = Math.min(timeoutMs, sandboxManager.getTimeoutSeconds() * 1000L); ManagedProcessRunner.Result result = managedProcessRunner.run(new ManagedProcessRunner.Request( invocation.command(), Path.of(workingDir), java.time.Duration.ofMillis(effectiveTimeout), context.currentRunId(), context.toolUseId(), invocation.cleanup())); if (result.cancelled()) { return ToolResult.cancelled("PROCESS_CANCELLED", "Sandboxed command cancelled", ToolResult.EffectState.UNKNOWN) .withMetadata("sandboxed", true) .withMetadata("containerName", invocation.containerName()) .withMetadata("terminationConfirmed", result.terminationConfirmed()); } if (result.timedOut()) { String timeoutMsg = "Sandboxed command timed out after " + effectiveTimeout + "ms"; ErrorClassification classification = errorClassifier.classify(137, timeoutMsg, command); return ToolResult.timedOut("PROCESS_DEADLINE_EXCEEDED", timeoutMsg, 137, result.terminationConfirmed()) .withMetadata("sandboxed", true) .withMetadata("containerName", invocation.containerName()) .withMetadata("terminationConfirmed", result.terminationConfirmed()) .withMetadata("failure_category", classification.type().name()) .withMetadata("failure_suggestion", classification.suggestion()); } if (!result.terminationConfirmed()) { return ToolResult.failed(ToolResult.ToolFailureType.PROCESS, "PROCESS_TERMINATION_UNCONFIRMED", "Sandbox command exited but container cleanup could not be confirmed", ToolResult.Retryability.NEVER, ToolResult.EffectState.UNKNOWN, result.exitCode(), Map.of("sandboxed", true, "containerName", invocation.containerName(), "terminationConfirmed", false)); } String output = result.stdout() + (result.stderr().isBlank() ? "" : "\n" + result.stderr()); if (result.exitCode() != 0) { String errorBody = "Exit code: " + result.exitCode() + "\n" + output; return buildErrorWithClassification(errorBody, result.exitCode(), output, command); } return ToolResult.successWithEffect(output, ToolResult.EffectState.UNKNOWN) .withMetadata("sandboxed", true) .withMetadata("exitCode", result.exitCode()) .withMetadata("containerName", invocation.containerName()) .withMetadata("terminationConfirmed", result.terminationConfirmed()); } }