@@ -29,9 +29,10 @@ type Result struct {
2929
3030// ValidAgents lists the supported agent names.
3131var ValidAgents = map [string ]bool {
32- "claude" : true ,
33- "codex" : true ,
34- "gemini" : true ,
32+ "claude" : true ,
33+ "codex" : true ,
34+ "copilot" : true ,
35+ "gemini" : true ,
3536}
3637
3738// GenerateFunc is the signature for insight generation,
@@ -87,65 +88,28 @@ func GenerateStream(
8788 switch agent {
8889 case "codex" :
8990 return generateCodex (ctx , path , prompt , onLog )
91+ case "copilot" :
92+ return generateCopilot (ctx , path , prompt , onLog )
9093 case "gemini" :
9194 return generateGemini (ctx , path , prompt , onLog )
9295 default :
9396 return generateClaude (ctx , path , prompt , onLog )
9497 }
9598}
9699
97- // allowedKeyPrefixes lists uppercase key prefixes that are
98- // safe to pass to agent CLI subprocesses. Matched
99- // case-insensitively so Windows-style casing (Path, ComSpec)
100- // is handled correctly. Using an allowlist prevents leaking
101- // secrets to child processes.
102- var allowedKeyPrefixes = []string {
103- "PATH" ,
104- "HOME" , "USERPROFILE" ,
105- "USER" , "USERNAME" , "LOGNAME" ,
106- "LANG" , "LC_" ,
107- "TERM" , "COLORTERM" ,
108- "TMPDIR" , "TEMP" , "TMP" ,
109- "XDG_" ,
110- "SHELL" ,
111- "SSL_CERT_" , "CURL_CA_BUNDLE" ,
112- "HTTP_PROXY" , "HTTPS_PROXY" , "NO_PROXY" ,
113- "SYSTEMROOT" , "COMSPEC" , "PATHEXT" , "WINDIR" ,
114- "HOMEDRIVE" , "HOMEPATH" ,
115- "APPDATA" , "LOCALAPPDATA" , "PROGRAMDATA" ,
116- }
117-
118- // envKeyAllowed reports whether key (case-insensitive) is
119- // on the allowlist. Prefix entries ending with _ (LC_,
120- // XDG_, SSL_CERT_) match any key starting with that prefix;
121- // all others require an exact match.
122- func envKeyAllowed (key string ) bool {
123- upper := strings .ToUpper (key )
124- for _ , p := range allowedKeyPrefixes {
125- if strings .HasSuffix (p , "_" ) {
126- if strings .HasPrefix (upper , p ) {
127- return true
128- }
129- } else if upper == p {
130- return true
131- }
132- }
133- return false
134- }
135-
136- // cleanEnv returns an allowlisted subset of the current
137- // environment for agent CLI subprocesses, plus
138- // CLAUDE_NO_SOUND=1.
139- func cleanEnv () []string {
140- env := os .Environ ()
141- filtered := make ([]string , 0 , len (env ))
142- for _ , e := range env {
143- k , _ , _ := strings .Cut (e , "=" )
144- if envKeyAllowed (k ) {
145- filtered = append (filtered , e )
146- }
147- }
148- return append (filtered , "CLAUDE_NO_SOUND=1" )
100+ // agentEnv returns the current environment with
101+ // CLAUDE_NO_SOUND=1 appended.
102+ //
103+ // The full environment is passed through intentionally.
104+ // Agent CLIs need provider auth (API keys, tokens, config
105+ // paths) that vary across providers, users, and deployment
106+ // methods (env vars, desktop.env, persisted login). An
107+ // allowlist is brittle here: every new provider or config
108+ // var requires a code change, and missing one breaks auth.
109+ // Sandboxing is handled by CLI flags (--tools, --sandbox,
110+ // --config-dir, etc.), not by env filtering.
111+ func agentEnv () []string {
112+ return append (os .Environ (), "CLAUDE_NO_SOUND=1" )
149113}
150114
151115func emitLog (onLog LogFunc , stream , line string ) {
@@ -213,8 +177,10 @@ func generateClaude(
213177 cmd := exec .CommandContext (
214178 ctx , path ,
215179 "-p" , "--output-format" , "json" ,
180+ "--no-session-persistence" ,
181+ "--tools" , "" ,
216182 )
217- cmd .Env = append ( os . Environ (), "CLAUDE_NO_SOUND=1" )
183+ cmd .Env = agentEnv ( )
218184 cmd .Stdin = strings .NewReader (prompt )
219185
220186 stdoutPipe , err := cmd .StdoutPipe ()
@@ -299,8 +265,12 @@ func generateCodex(
299265 cmd := exec .CommandContext (
300266 ctx , path ,
301267 "exec" , "--json" ,
302- "--sandbox" , "read-only" , "-" ,
268+ "--sandbox" , "read-only" ,
269+ "--skip-git-repo-check" ,
270+ "--ephemeral" ,
271+ "-" ,
303272 )
273+ cmd .Env = agentEnv ()
304274 cmd .Stdin = strings .NewReader (prompt )
305275
306276 stdoutPipe , err := cmd .StdoutPipe ()
@@ -433,6 +403,83 @@ func parseCodexStream(
433403 return strings .Join (messages , "\n " ), nil
434404}
435405
406+ // generateCopilot invokes `copilot -p <prompt> --silent`.
407+ // The prompt is passed as the -p argument (copilot does not
408+ // read prompts from stdin). Output is plain text on stdout.
409+ func generateCopilot (
410+ ctx context.Context , path , prompt string , onLog LogFunc ,
411+ ) (Result , error ) {
412+ cmd := exec .CommandContext (
413+ ctx , path ,
414+ "-p" , prompt ,
415+ "--silent" ,
416+ "--no-custom-instructions" ,
417+ "--no-ask-user" ,
418+ "--disable-builtin-mcps" ,
419+ )
420+ cmd .Env = agentEnv ()
421+
422+ stdoutPipe , err := cmd .StdoutPipe ()
423+ if err != nil {
424+ return Result {}, fmt .Errorf (
425+ "create stdout pipe: %w" , err ,
426+ )
427+ }
428+ stderrPipe , err := cmd .StderrPipe ()
429+ if err != nil {
430+ return Result {}, fmt .Errorf (
431+ "create stderr pipe: %w" , err ,
432+ )
433+ }
434+
435+ if err := cmd .Start (); err != nil {
436+ return Result {}, fmt .Errorf (
437+ "start copilot: %w" , err ,
438+ )
439+ }
440+
441+ stderrDone := collectStreamLines (
442+ stderrPipe , "stderr" , onLog ,
443+ )
444+ // Read stdout raw to preserve blank lines in plain
445+ // text output (collectStreamLines drops empty lines).
446+ stdoutBytes , readErr := io .ReadAll (stdoutPipe )
447+ stderrText := <- stderrDone
448+ runErr := cmd .Wait ()
449+
450+ if readErr != nil {
451+ return Result {}, fmt .Errorf (
452+ "read copilot stdout: %w" , readErr ,
453+ )
454+ }
455+
456+ emitLog (onLog , "stdout" , string (stdoutBytes ))
457+
458+ if runErr != nil && ctx .Err () != nil {
459+ return Result {}, fmt .Errorf (
460+ "copilot CLI cancelled: %w" , ctx .Err (),
461+ )
462+ }
463+ if runErr != nil {
464+ return Result {}, fmt .Errorf (
465+ "copilot CLI failed: %w\n stderr: %s" ,
466+ runErr , stderrText ,
467+ )
468+ }
469+
470+ content := strings .TrimSpace (string (stdoutBytes ))
471+ if content == "" {
472+ return Result {}, fmt .Errorf (
473+ "copilot returned empty result" ,
474+ )
475+ }
476+
477+ return Result {
478+ Content : content ,
479+ Agent : "copilot" ,
480+ }, nil
481+ }
482+
436483// generateGemini invokes `gemini --output-format stream-json`
437484// and parses the JSONL stream for result/assistant messages.
438485func generateGemini (
@@ -442,7 +489,9 @@ func generateGemini(
442489 ctx , path ,
443490 "--model" , geminiInsightModel ,
444491 "--output-format" , "stream-json" ,
492+ "--sandbox" ,
445493 )
494+ cmd .Env = agentEnv ()
446495 cmd .Stdin = strings .NewReader (prompt )
447496
448497 stdoutPipe , err := cmd .StdoutPipe ()
0 commit comments