From 05e6e019b292885a0421aa1e766e48b202be074c Mon Sep 17 00:00:00 2001 From: abel533 Date: Sun, 5 Apr 2026 09:16:12 +0800 Subject: [PATCH] feat: BashTool sandbox, ConfigTool persistence, ToolSearchTool - BashTool: command blacklist (fork bombs, rm -rf /, etc) + risky command warning log. Blocked commands return clear error message. - ConfigTool: JSON file persistence (~/.claude-code/config.json), new 'list' action, loads from file on first access. - ToolSearchTool: search ToolRegistry by name/description keywords, returns matching tools with descriptions (total: 23 tools). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/com/claudecode/config/AppConfig.java | 6 +- .../com/claudecode/tool/impl/BashTool.java | 48 +++ .../com/claudecode/tool/impl/ConfigTool.java | 303 +++++++++++------- .../claudecode/tool/impl/ToolSearchTool.java | 115 +++++++ 4 files changed, 358 insertions(+), 114 deletions(-) create mode 100644 src/main/java/com/claudecode/tool/impl/ToolSearchTool.java diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index deea100..f15a7f3 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -109,7 +109,8 @@ public class AppConfig { // P2: 配置工具 new ConfigTool(), // P2: 实用工具 - new SleepTool() + new SleepTool(), + new ToolSearchTool() ); // P2: 注册 MCP 工具桥接(将远程 MCP 工具映射为本地工具) @@ -119,6 +120,9 @@ public class AppConfig { } } + // 将 ToolRegistry 注入 ToolContext,供 ToolSearchTool 使用 + toolContext.set("TOOL_REGISTRY", registry); + return registry; } diff --git a/src/main/java/com/claudecode/tool/impl/BashTool.java b/src/main/java/com/claudecode/tool/impl/BashTool.java index 6fb5895..4323c9f 100644 --- a/src/main/java/com/claudecode/tool/impl/BashTool.java +++ b/src/main/java/com/claudecode/tool/impl/BashTool.java @@ -9,6 +9,7 @@ import java.io.BufferedReader; import java.io.InputStreamReader; import java.nio.file.Path; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; /** @@ -28,6 +29,35 @@ public class BashTool implements Tool { /** 默认超时(秒) */ private static final int DEFAULT_TIMEOUT = 120; + /** + * 危险命令黑名单 —— 这些命令前缀需要特别警告。 + * 对应 TS 版 shouldUseSandbox.ts 的 containsExcludedCommand。 + * 注意:这不是安全边界,而是用户友好的提示机制。 + */ + private static final Set DANGEROUS_COMMANDS = Set.of( + "rm -rf /", "rm -rf /*", "rm -rf ~", + "mkfs", "dd if=", + ":(){:|:&};:", // fork bomb + "chmod -R 777 /", + "git push --force", "git push -f", + "git reset --hard", + "shutdown", "reboot", "halt", + "format c:", "del /f /s /q c:\\" + ); + + /** + * 需要用户确认的命令前缀 —— 风险较高但不是完全禁止的操作。 + */ + private static final Set WARN_COMMAND_PREFIXES = Set.of( + "rm -rf", "rm -r", + "git push --force", "git push -f", + "git reset --hard", + "git rebase", + "drop database", "drop table", + "truncate table", + "sudo rm", "sudo dd" + ); + /** 检测到的 shell 类型 */ public enum ShellType { POWERSHELL("PowerShell", "pwsh", "-NoProfile", "-Command"), @@ -126,6 +156,24 @@ public class BashTool implements Tool { : DEFAULT_TIMEOUT; Path workDir = context.getWorkDir(); + // Sandbox check: block absolutely dangerous commands + String cmdLower = command.toLowerCase().trim(); + for (String dangerous : DANGEROUS_COMMANDS) { + if (cmdLower.equals(dangerous) || cmdLower.startsWith(dangerous)) { + return "⛔ BLOCKED: This command is potentially destructive and has been blocked.\n" + + "Command: " + command + "\n" + + "If you really need to run this, please ask the user to execute it manually."; + } + } + + // Sandbox warning: flag risky commands + for (String prefix : WARN_COMMAND_PREFIXES) { + if (cmdLower.startsWith(prefix)) { + log.warn("⚠️ Risky command detected: {}", command); + break; + } + } + try { ProcessBuilder pb = buildProcess(command); pb.directory(workDir.toFile()); diff --git a/src/main/java/com/claudecode/tool/impl/ConfigTool.java b/src/main/java/com/claudecode/tool/impl/ConfigTool.java index c14b13e..bc5c2ea 100644 --- a/src/main/java/com/claudecode/tool/impl/ConfigTool.java +++ b/src/main/java/com/claudecode/tool/impl/ConfigTool.java @@ -2,35 +2,46 @@ package com.claudecode.tool.impl; import com.claudecode.tool.Tool; import com.claudecode.tool.ToolContext; - +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** - * Config 工具 —— 获取或设置配置值。 + * Config 工具 —— 获取或设置配置值,持久化到 JSON 文件。 *

- * 属于 P2 优先级的辅助工具。支持两种存储后端: + * 属于 P2 优先级的辅助工具。存储后端优先级: *

*
    - *
  1. 首选从 ToolContext 中的配置映射(键 "CONFIG_STORE")读写
  2. - *
  3. 回退到 {@link System#getProperty} / {@link System#setProperty}
  4. + *
  5. ToolContext 中的内存缓存(CONFIG_STORE)—— 快速读写
  6. + *
  7. JSON 文件持久化(~/.claude-code/config.json)—— 跨会话保持
  8. + *
  9. 回退到 {@link System#getProperty} —— 兜底读取
  10. *
* *

参数

*
    - *
  • action(必填)—— "get" 或 "set"
  • - *
  • key(必填)—— 配置键名
  • - *
  • value(可选,set 时必填)—— 配置值
  • + *
  • action(必填)—— "get"、"set" 或 "list"
  • + *
  • key(get/set 时必填)—— 配置键名
  • + *
  • value(set 时必填)—— 配置值
  • *
- * - *

返回

- *

JSON 格式:get 返回当前值,set 返回确认信息。

*/ public class ConfigTool implements Tool { + private static final Logger log = LoggerFactory.getLogger(ConfigTool.class); + /** ToolContext 中配置存储的键名 */ private static final String CONFIG_STORE_KEY = "CONFIG_STORE"; + /** 配置文件路径 */ + private static final Path CONFIG_FILE = Path.of( + System.getProperty("user.home"), ".claude-code", "config.json"); + @Override public String name() { return "Config"; @@ -39,8 +50,8 @@ public class ConfigTool implements Tool { @Override public String description() { return """ - Get or set configuration values. Use when the user requests configuration changes, \ - asks about current settings, or when adjusting a setting would benefit them. + Get, set, or list configuration values. Configuration persists across sessions \ + in ~/.claude-code/config.json. Available settings include: - language: Preferred response language (e.g., "zh-CN", "en") @@ -50,7 +61,7 @@ public class ConfigTool implements Tool { - timeout: Default command timeout in seconds - permissions: Permission mode (ask/auto/deny) - Use action 'get' to read a setting value, 'set' to change it."""; + Use action 'get' to read, 'set' to change, 'list' to see all settings."""; } @Override @@ -61,26 +72,22 @@ public class ConfigTool implements Tool { "properties": { "action": { "type": "string", - "description": "Action type: get or set", - "enum": ["get", "set"] + "description": "Action type: get, set, or list", + "enum": ["get", "set", "list"] }, "key": { "type": "string", - "description": "Configuration key name" + "description": "Configuration key name (required for get/set)" }, "value": { "type": "string", "description": "Configuration value (required for set operation)" } }, - "required": ["action", "key"] + "required": ["action"] }"""; } - /** - * Config 工具不是纯只读的(set 操作会修改状态), - * 但出于安全考虑仍标记为 false。 - */ @Override public boolean isReadOnly() { return false; @@ -88,33 +95,32 @@ public class ConfigTool implements Tool { @Override public String execute(Map input, ToolContext context) { - // 解析必填参数: action String action = (String) input.get("action"); if (action == null || action.isBlank()) { - return errorJson("Parameter 'action' is required, valid values: get, set"); + return errorJson("Parameter 'action' is required, valid values: get, set, list"); } action = action.trim().toLowerCase(); - // 解析必填参数: key - String key = (String) input.get("key"); - if (key == null || key.isBlank()) { - return errorJson("Parameter 'key' is required and cannot be empty"); - } - - // 获取或初始化配置存储 - @SuppressWarnings("unchecked") - ConcurrentHashMap configStore = - context.getOrDefault(CONFIG_STORE_KEY, null); - - if (configStore == null) { - configStore = new ConcurrentHashMap<>(); - context.set(CONFIG_STORE_KEY, configStore); - } + // 获取或初始化配置存储(从文件加载) + ConcurrentHashMap configStore = getOrInitStore(context); return switch (action) { - case "get" -> executeGet(key, configStore); - case "set" -> executeSet(key, input, configStore); - default -> errorJson("Invalid action value: '" + action + "'. Valid values: get, set"); + case "get" -> { + String key = (String) input.get("key"); + if (key == null || key.isBlank()) { + yield errorJson("'get' action requires 'key' parameter"); + } + yield executeGet(key, configStore); + } + case "set" -> { + String key = (String) input.get("key"); + if (key == null || key.isBlank()) { + yield errorJson("'set' action requires 'key' parameter"); + } + yield executeSet(key, input, configStore); + } + case "list" -> executeList(configStore); + default -> errorJson("Invalid action: '" + action + "'. Valid values: get, set, list"); }; } @@ -122,6 +128,9 @@ public class ConfigTool implements Tool { public String activityDescription(Map input) { String action = (String) input.getOrDefault("action", "?"); String key = (String) input.getOrDefault("key", "?"); + if ("list".equalsIgnoreCase(action)) { + return "⚙️ Listing all config"; + } if ("set".equalsIgnoreCase(action)) { return "⚙️ Setting config: " + key; } @@ -129,119 +138,187 @@ public class ConfigTool implements Tool { } /* ------------------------------------------------------------------ */ - /* get / set 具体实现 */ + /* get / set / list 具体实现 */ /* ------------------------------------------------------------------ */ - /** - * 执行 get 操作:优先从配置映射读取,回退到系统属性。 - * - * @param key 配置键 - * @param configStore 配置映射 - * @return JSON 格式的结果 - */ private String executeGet(String key, ConcurrentHashMap configStore) { - // 优先从上下文配置映射获取 String value = configStore.get(key); - - // 回退到系统属性 if (value == null) { value = System.getProperty(key); } if (value == null) { return """ - { - "action": "get", - "key": "%s", - "value": null, - "found": false, - "message": "Config key '%s' not found" - }""".formatted(escapeJson(key), escapeJson(key)); + {"action": "get", "key": "%s", "value": null, "found": false, \ + "message": "Config key '%s' not found"}""" + .formatted(escapeJson(key), escapeJson(key)); } return """ - { - "action": "get", - "key": "%s", - "value": "%s", - "found": true - }""".formatted(escapeJson(key), escapeJson(value)); + {"action": "get", "key": "%s", "value": "%s", "found": true}""" + .formatted(escapeJson(key), escapeJson(value)); } - /** - * 执行 set 操作:同时写入配置映射和系统属性。 - * - * @param key 配置键 - * @param input 输入参数映射 - * @param configStore 配置映射 - * @return JSON 格式的确认 - */ private String executeSet(String key, Map input, ConcurrentHashMap configStore) { String value = (String) input.get("value"); if (value == null) { - return errorJson("set operation requires 'value' parameter"); + return errorJson("'set' action requires 'value' parameter"); } - // 获取旧值(用于返回信息) String oldValue = configStore.get(key); - if (oldValue == null) { - oldValue = System.getProperty(key); - } - - // 写入配置映射 configStore.put(key, value); - // 同步写入系统属性(简单实现,生产环境应使用专门的配置管理) - try { - System.setProperty(key, value); - } catch (SecurityException e) { - // 如果没有权限设置系统属性,只使用配置映射即可 - } + // 持久化到文件 + persistToFile(configStore); StringBuilder sb = new StringBuilder(); - sb.append("{\n"); - sb.append(" \"action\": \"set\",\n"); - sb.append(" \"key\": \"").append(escapeJson(key)).append("\",\n"); - sb.append(" \"value\": \"").append(escapeJson(value)).append("\",\n"); - + sb.append("{\"action\": \"set\", \"key\": \"").append(escapeJson(key)); + sb.append("\", \"value\": \"").append(escapeJson(value)).append("\""); if (oldValue != null) { - sb.append(" \"previous_value\": \"").append(escapeJson(oldValue)).append("\",\n"); - } else { - sb.append(" \"previous_value\": null,\n"); + sb.append(", \"previous_value\": \"").append(escapeJson(oldValue)).append("\""); } + sb.append(", \"success\": true}"); + return sb.toString(); + } - sb.append(" \"success\": true,\n"); - sb.append(" \"message\": \"Config key '").append(escapeJson(key)).append("' has been set\"\n"); - sb.append("}"); + private String executeList(ConcurrentHashMap configStore) { + if (configStore.isEmpty()) { + return "{\"action\": \"list\", \"count\": 0, \"message\": \"No configuration set\"}"; + } + StringBuilder sb = new StringBuilder(); + sb.append("{\"action\": \"list\", \"count\": ").append(configStore.size()); + sb.append(", \"settings\": {"); + boolean first = true; + for (var entry : new java.util.TreeMap<>(configStore).entrySet()) { + if (!first) sb.append(", "); + sb.append("\"").append(escapeJson(entry.getKey())).append("\": \"") + .append(escapeJson(entry.getValue())).append("\""); + first = false; + } + sb.append("}}"); return sb.toString(); } /* ------------------------------------------------------------------ */ - /* 辅助方法 */ + /* 持久化 — JSON 文件读写 */ /* ------------------------------------------------------------------ */ + @SuppressWarnings("unchecked") + private ConcurrentHashMap getOrInitStore(ToolContext context) { + ConcurrentHashMap store = context.getOrDefault(CONFIG_STORE_KEY, null); + if (store != null) { + return store; + } + + // 从文件加载 + store = loadFromFile(); + context.set(CONFIG_STORE_KEY, store); + return store; + } + /** - * 转义 JSON 特殊字符。 + * 从 JSON 文件加载配置。 + * 使用简单的手动 JSON 解析,避免引入额外依赖。 */ - private String escapeJson(String s) { - if (s == null) return ""; - return s.replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t"); + private ConcurrentHashMap loadFromFile() { + ConcurrentHashMap store = new ConcurrentHashMap<>(); + + if (!Files.exists(CONFIG_FILE)) { + return store; + } + + try { + String json = Files.readString(CONFIG_FILE, StandardCharsets.UTF_8).trim(); + if (json.startsWith("{") && json.endsWith("}")) { + // Simple JSON parsing for flat key-value pairs + String inner = json.substring(1, json.length() - 1).trim(); + if (!inner.isEmpty()) { + parseJsonPairs(inner, store); + } + } + log.debug("Loaded {} config entries from {}", store.size(), CONFIG_FILE); + } catch (IOException e) { + log.warn("Failed to load config from {}: {}", CONFIG_FILE, e.getMessage()); + } + + return store; + } + + /** + * 持久化配置到 JSON 文件。 + */ + private void persistToFile(ConcurrentHashMap store) { + try { + Files.createDirectories(CONFIG_FILE.getParent()); + + StringBuilder json = new StringBuilder("{\n"); + boolean first = true; + for (var entry : new java.util.TreeMap<>(store).entrySet()) { + if (!first) json.append(",\n"); + json.append(" \"").append(escapeJson(entry.getKey())) + .append("\": \"").append(escapeJson(entry.getValue())).append("\""); + first = false; + } + json.append("\n}"); + + Files.writeString(CONFIG_FILE, json.toString(), StandardCharsets.UTF_8); + log.debug("Persisted {} config entries to {}", store.size(), CONFIG_FILE); + } catch (IOException e) { + log.warn("Failed to persist config to {}: {}", CONFIG_FILE, e.getMessage()); + } } /** - * 构建错误 JSON 响应。 + * 简单解析 JSON 键值对(仅支持字符串值的扁平对象)。 */ + private void parseJsonPairs(String inner, Map store) { + int i = 0; + while (i < inner.length()) { + // Find key + int keyStart = inner.indexOf('"', i); + if (keyStart < 0) break; + int keyEnd = findClosingQuote(inner, keyStart + 1); + if (keyEnd < 0) break; + String key = unescapeJson(inner.substring(keyStart + 1, keyEnd)); + + // Find colon + int colon = inner.indexOf(':', keyEnd + 1); + if (colon < 0) break; + + // Find value + int valStart = inner.indexOf('"', colon + 1); + if (valStart < 0) break; + int valEnd = findClosingQuote(inner, valStart + 1); + if (valEnd < 0) break; + String value = unescapeJson(inner.substring(valStart + 1, valEnd)); + + store.put(key, value); + i = valEnd + 1; + } + } + + private int findClosingQuote(String s, int from) { + for (int i = from; i < s.length(); i++) { + if (s.charAt(i) == '\\') { i++; continue; } + if (s.charAt(i) == '"') return i; + } + return -1; + } + + private String unescapeJson(String s) { + return s.replace("\\\"", "\"").replace("\\\\", "\\") + .replace("\\n", "\n").replace("\\t", "\t"); + } + + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"); + } + private String errorJson(String message) { - return """ - { - "error": true, - "message": "%s" - }""".formatted(escapeJson(message)); + return "{\"error\": true, \"message\": \"%s\"}".formatted(escapeJson(message)); } } diff --git a/src/main/java/com/claudecode/tool/impl/ToolSearchTool.java b/src/main/java/com/claudecode/tool/impl/ToolSearchTool.java new file mode 100644 index 0000000..cc45cca --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/ToolSearchTool.java @@ -0,0 +1,115 @@ +package com.claudecode.tool.impl; + +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; +import com.claudecode.tool.ToolRegistry; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 工具搜索工具 —— 对应 claude-code 中 /tools 命令功能。 + *

+ * 在 ToolRegistry 中搜索已注册的工具,按名称或描述关键字匹配。 + * 用于帮助 LLM 发现可用工具。 + */ +public class ToolSearchTool implements Tool { + + private static final String TOOL_REGISTRY_KEY = "TOOL_REGISTRY"; + + @Override + public String name() { + return "ToolSearch"; + } + + @Override + public String description() { + return """ + Search for available tools by name or keyword. Returns matching tool names and \ + their descriptions. Use this when you need to find which tools are available for \ + a specific task, or when the user asks about available capabilities."""; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query to match against tool names and descriptions. Empty string lists all tools." + } + }, + "required": ["query"] + }"""; + } + + @Override + public boolean isReadOnly() { + return true; + } + + @Override + public String execute(Map input, ToolContext context) { + String query = (String) input.get("query"); + if (query == null) query = ""; + String queryLower = query.toLowerCase().trim(); + + ToolRegistry registry = context.getOrDefault(TOOL_REGISTRY_KEY, null); + if (registry == null) { + return "Error: ToolRegistry is not available"; + } + + List allTools = registry.getTools(); + List matches; + + if (queryLower.isEmpty()) { + matches = allTools; + } else { + matches = allTools.stream() + .filter(t -> t.name().toLowerCase().contains(queryLower) + || t.description().toLowerCase().contains(queryLower)) + .collect(Collectors.toList()); + } + + if (matches.isEmpty()) { + return String.format(""" + {"query": "%s", "total_tools": %d, "matches": 0, \ + "message": "No tools matched the query."}""", + escapeJson(query), allTools.size()); + } + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("Found %d tool(s)", matches.size())); + if (!queryLower.isEmpty()) { + sb.append(" matching \"").append(query).append("\""); + } + sb.append(" (").append(allTools.size()).append(" total):\n\n"); + + for (Tool t : matches) { + sb.append("• **").append(t.name()).append("**"); + if (t.isReadOnly()) sb.append(" [read-only]"); + sb.append("\n"); + // Truncate description to first 120 chars for overview + String desc = t.description().strip(); + if (desc.length() > 120) { + desc = desc.substring(0, 117) + "..."; + } + sb.append(" ").append(desc).append("\n\n"); + } + + return sb.toString().stripTrailing(); + } + + @Override + public String activityDescription(Map input) { + return "🔍 Searching tools: " + input.getOrDefault("query", "*"); + } + + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } +}