Merge remote-tracking branch 'origin/main' into fix-dispatch-local · Entire

Merge remote-tracking branch 'origin/main' into fix-dispatch-local

bfc0e34→main·

alishakawaguchi·yesterday·2 files·+246 added/-3 removed

Changes

2

1 unmodified line

2
3
4
5
6
7
8
9
10
11
12
46 unmodified lines

59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189

1 unmodified line

import (
    "context"
    "encoding/json"
    "errors"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
    "testing"

46 unmodified lines

}

func flagValue(args []string, name string) (string, bool) {
    for i, a := range args {
        if a == name && i+1 < len(args) {
            return args[i+1], true
        }
    }
    return "", false
}

func TestBuildGenerateArgs_IsolatesSettingSources(t *testing.T) {
    t.Parallel()
    // Isolation is the security-critical invariant: --setting-sources must be
    // empty so user-level hooks and tool permissions (e.g. bypassPermissions)
    // are never loaded for this internal, injection-exposed call.
    args := buildGenerateArgs("haiku", "")
    got, ok := flagValue(args, "--setting-sources")
    if !ok {
        t.Fatalf("--setting-sources flag missing from args: %v", args)
    }
    if got != "" {
        t.Fatalf("--setting-sources = %q, want %q (must load no sources)", got, "")
    }
    // With no settings path, we inject nothing extra.
    if _, ok := flagValue(args, "--settings"); ok {
        t.Fatalf("--settings must be absent when there is no settings path: %v", args)
    }
}

func TestBuildGenerateArgs_PassesSettingsAsPath(t *testing.T) {
    t.Parallel()
    // The injected settings must be passed as a file path, not inline JSON, so a
    // key-bearing apiKeyHelper never lands in argv (ps / /proc/<pid>/cmdline).
    path := "/tmp/entire-claude-auth-123.json"
    args := buildGenerateArgs("haiku", path)

if got, _ := flagValue(args, "--setting-sources"); got != "" {
        t.Fatalf("--setting-sources = %q, want empty", got)
    }
    got, ok := flagValue(args, "--settings")
    if !ok {
        t.Fatalf("--settings flag missing: %v", args)
    }
    if got != path {
        t.Fatalf("--settings = %q, want the file path %q", got, path)
    }
    // Guard against regressing to inline JSON in argv.
    if strings.Contains(got, "{") {
        t.Fatalf("--settings must be a path, not inline JSON: %q", got)
    }
}

func TestWriteAuthSettingsFile_WritesOnlyAPIKeyHelper0600(t *testing.T) {
    t.Parallel()
    helper := `echo "sk-ant-secret"` // could embed a literal key
    path, cleanup, err := writeAuthSettingsFile(helper)
    if err != nil {
        t.Fatalf("writeAuthSettingsFile: %v", err)
    }
    if cleanup == nil {
        t.Fatal("cleanup func is nil")
    }
    defer cleanup()

info, err := os.Stat(path)
    if err != nil {
        t.Fatalf("stat settings file: %v", err)
    }
    if perm := info.Mode().Perm(); perm != 0o600 {
        t.Fatalf("settings file perm = %o, want 0600", perm)
    }

data, err := os.ReadFile(path)
    if err != nil {
        t.Fatalf("read settings file: %v", err)
    }
    var settings map[string]any
    if err := json.Unmarshal(data, &settings); err != nil {
        t.Fatalf("settings file is not valid JSON: %v (%s)", err, data)
    }
    if settings["apiKeyHelper"] != helper {
        t.Fatalf("apiKeyHelper = %v, want %q", settings["apiKeyHelper"], helper)
    }
    if len(settings) != 1 {
        t.Fatalf("settings file must contain only apiKeyHelper, got %v", settings)
    }

cleanup()
    if _, err := os.Stat(path); !os.IsNotExist(err) {
        t.Fatalf("cleanup did not remove settings file (stat err=%v)", err)
    }
}

func TestWriteAuthSettingsFile_EmptyHelperNoFile(t *testing.T) {
    t.Parallel()
    path, cleanup, err := writeAuthSettingsFile("")
    if err != nil {
        t.Fatalf("writeAuthSettingsFile(\""): %v", err)
    }
    if path != "" {
        t.Fatalf("path = %q, want empty for no apiKeyHelper", path)
    }
    if cleanup != nil {
        t.Fatal("cleanup should be nil when no file is written")
    }
}

func TestReadUserAPIKeyHelper_FromClaudeConfigDir(t *testing.T) {
    dir := t.TempDir()
     t.Setenv("CLAUDE_CONFIG_DIR", dir)
    if err := os.WriteFile(filepath.Join(dir, "settings.json"),
        []byte(`{"apiKeyHelper":"echo secret-cmd","permissions":{"defaultMode":"bypassPermissions"}}`), 0o600); err != nil {
        t.Fatal(err)
    }
    if got := readUserAPIKeyHelper(); got != "echo secret-cmd" {
        t.Fatalf("readUserAPIKeyHelper() = %q, want %q", got, "echo secret-cmd")
    }
}

func TestReadUserAPIKeyHelper_MissingFileReturnsEmpty(t *testing.T) {
    t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) // no settings.json inside
    if got := readUserAPIKeyHelper(); got != "" {
        t.Fatalf("readUserAPIKeyHelper() = %q, want empty for missing file", got)
    }
}

func TestGenerateText_ArrayResponse(t *testing.T) {
    t.Parallel()
    ag := &ClaudeCodeAgent{

Mcmd/entire/cli/agent/claudecode/claude\_test.go+128

2 unmodified lines

3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 17 unmodified lines

139 140 141 38 39 40 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158

2 unmodified lines

import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings"

"github.com/entireio/cli/cmd/entire/cli/agent" )

// buildGenerateArgs assembles the claude CLI argv for a --print text-generation // call. // // The subprocess must stay isolated from the user's project/local AND user // settings: loading them would fire user-level hooks and, worse, honor // user-level tool permissions (e.g. permissions.defaultMode=bypassPermissions), // which would let prompt-injection in the untrusted dispatch data drive tool // execution. So we pass --setting-sources "" (load nothing). // // The one thing we genuinely need from the user settings is auth. Users on API // billing configure it with apiKeyHelper (a command that prints the key), // which lives in user settings and is therefore dropped by --setting-sources "". // Rather than load the whole settings file back (and re-inherit hooks and // permissions), we extract only apiKeyHelper and re-inject it via a --settings // file (settingsPath), so auth works while nothing else from the user's settings // is loaded. // // The injected settings are passed as a file path, not an inline JSON string: // apiKeyHelper can embed a literal key, and an inline value would land in the // process argv (visible via ps / /proc//cmdline / EDR tooling). The file is // written 0600 (see writeAuthSettingsFile), matching settings.json's protection. // // Auth methods that do not live in user settings — an exported ANTHROPIC_API_KEY // (survives StripGitEnv) and keychain/subscription credentials — keep working // without any injection (settingsPath == ""). func buildGenerateArgs(model, settingsPath string) []string { args := []string{ "--print", "--output-format", "json", "--model", model, "--setting-sources", "", } if settingsPath != "" { args = append(args, "--settings", settingsPath) } return args }

// writeAuthSettingsFile writes a minimal claude settings file containing only // the given apiKeyHelper and returns its path plus a cleanup func. The file is // created 0600 so the (possibly key-bearing) helper is no more exposed than the // user's own settings.json. Returns ("", nil, nil) when apiKeyHelper is empty. func writeAuthSettingsFile(apiKeyHelper string) (string, func(), error) { if strings.TrimSpace(apiKeyHelper) == "" { return "", nil, nil } data, err := json.Marshal(map[string]string{"apiKeyHelper": apiKeyHelper}) if err != nil { return "", nil, fmt.Errorf("marshal auth settings: %w", err) } f, err := os.CreateTemp("", "entire-claude-auth-*.json") // 0600 by default if err != nil { return "", nil, fmt.Errorf("create auth settings file: %w", err) } path := f.Name() cleanup := func() { _ = os.Remove(path) } if _, err := f.Write(data); err != nil { _ = f.Close() cleanup() return "", nil, fmt.Errorf("write auth settings file: %w", err) } if err := f.Close(); err != nil { cleanup() return "", nil, fmt.Errorf("close auth settings file: %w", err) } return path, cleanup, nil }

// userClaudeSettingsPath resolves the user's claude settings.json the same way // the claude CLI does: $CLAUDE_CONFIG_DIR/settings.json when set, otherwise // ~/.claude/settings.json. func userClaudeSettingsPath() (string, error) { if dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR")); dir != "" { return filepath.Join(dir, "settings.json"), nil } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) } return filepath.Join(home, ".claude", "settings.json"), nil }

// readUserAPIKeyHelper returns the apiKeyHelper field from the user's claude // settings, or "" if absent. Best-effort: a missing file or malformed JSON // yields "" so we fall back to env/keychain auth rather than failing. func readUserAPIKeyHelper() string { path, err := userClaudeSettingsPath() if err != nil { return "" } data, err := os.ReadFile(path) //nolint:gosec // path is the user's own claude config, not attacker-controlled if err != nil { return "" } var settings struct { APIKeyHelper string json:"apiKeyHelper" } if err := json.Unmarshal(data, &settings); err != nil { return "" } return strings.TrimSpace(settings.APIKeyHelper) }

// GenerateText sends a prompt to the Claude CLI and returns the raw text response. // Implements the agent.TextGenerator interface. // The model parameter hints which model to use (e.g., "haiku", "sonnet"). 17 unmodified lines

commandRunner = exec.CommandContext }

cmd := commandRunner(ctx, claudePath, "--print", "--output-format", "json", "--model", model, "--setting-sources", "") // Run isolated from all setting sources (see buildGenerateArgs), re-injecting // only the user's apiKeyHelper (via a 0600 file, never argv) so API-billing // auth keeps working without re-inheriting user hooks or tool permissions. // Best-effort: if extracting/writing the helper fails, fall back to running // without it (env/keychain auth still work) rather than failing the call. settingsPath, cleanup, err := writeAuthSettingsFile(readUserAPIKeyHelper()) if err != nil { settingsPath = "" } if cleanup != nil { defer cleanup() }

cmd := commandRunner(ctx, claudePath, buildGenerateArgs(model, settingsPath)...)

// Isolate from the user's git repo to prevent recursive hook triggers // and index pollution (matches agent.RunIsolatedTextGeneratorCLI behavior).