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
1 change: 0 additions & 1 deletion api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
)

const (
accept = "Accept"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was unused.

apiVersion = "X-GitHub-Api-Version"
apiVersionValue = "2022-11-28"
authorization = "Authorization"
Expand Down
8 changes: 7 additions & 1 deletion api/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type tokenGetter interface {

type HTTPClientOptions struct {
AppVersion string
InvokingAgent string
CacheTTL time.Duration
Config tokenGetter
EnableCache bool
Expand Down Expand Up @@ -48,8 +49,13 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
clientOpts.LogVerboseHTTP = opts.LogVerboseHTTP
}

ua := fmt.Sprintf("GitHub CLI %s", opts.AppVersion)
if opts.InvokingAgent != "" {
ua = fmt.Sprintf("%s Agent/%s", ua, opts.InvokingAgent)
}
Comment on lines +52 to +55

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opts.InvokingAgent is appended to the User-Agent verbatim. If a future caller passes an untrusted/unsanitized value (e.g. containing \r/\n or other invalid header characters), requests can fail with "invalid header field value" and this undermines the stated header-injection hardening. Consider validating/sanitizing opts.InvokingAgent inside NewHTTPClient (e.g. allow only [A-Za-z0-9_-]+ and otherwise omit the agent suffix), and add a small test for the invalid-value case.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah we sanitize at the agent layer. I don't want to create a gh.AgentName just to deal with this.


headers := map[string]string{
userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
userAgent: ua,
apiVersion: apiVersionValue,
}
clientOpts.Headers = headers
Expand Down
14 changes: 14 additions & 0 deletions api/http_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func TestNewHTTPClient(t *testing.T) {
type args struct {
config tokenGetter
appVersion string
invokingAgent string
logVerboseHTTP bool
skipDefaultHeaders bool
}
Expand Down Expand Up @@ -155,6 +156,18 @@ func TestNewHTTPClient(t *testing.T) {
* Request took <duration>
`),
},
{
name: "includes invoking agent in user-agent header",
args: args{
appVersion: "v1.2.3",
invokingAgent: "copilot-cli",
},
host: "github.com",
wantHeader: map[string][]string{
"user-agent": {"GitHub CLI v1.2.3 Agent/copilot-cli"},
},
wantStderr: "",
},
}

var gotReq *http.Request
Expand All @@ -169,6 +182,7 @@ func TestNewHTTPClient(t *testing.T) {
ios, _, _, stderr := iostreams.Test()
client, err := NewHTTPClient(HTTPClientOptions{
AppVersion: tt.args.appVersion,
InvokingAgent: tt.args.invokingAgent,
Config: tt.args.config,
Log: ios.ErrOut,
LogVerboseHTTP: tt.args.logVerboseHTTP,
Expand Down
99 changes: 99 additions & 0 deletions internal/agents/detect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package agents

import (
"fmt"
"os"
"regexp"
)

// AgentName is a validated agent identifier safe for use in HTTP headers.
type AgentName string

const (
agentAmp AgentName = "amp"
agentClaudeCode AgentName = "claude-code"
agentCodex AgentName = "codex"
agentCopilotCLI AgentName = "copilot-cli"
agentGeminiCLI AgentName = "gemini-cli"
agentOpencode AgentName = "opencode"
)

var validAgentName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe also dots, slashes (/ and ), and brackets? As some agent may decide to add versions (in brackets).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Purely based on what we've seen so far out in the wild, I'm inclined to leave this alone until we see something otherwise.


// parseAgentName validates and returns an AgentName from a raw string.
// Only alphanumeric characters, hyphens, and underscores are allowed.
func parseAgentName(s string) (AgentName, error) {
if !validAgentName.MatchString(s) {
return "", fmt.Errorf("invalid agent name %q: must match [a-zA-Z0-9_-]+", s)
}
return AgentName(s), nil
}

// Detect returns the name of the AI coding agent driving the CLI,
// or an empty AgentName if none is detected.
func Detect() AgentName {
return detectWith(os.LookupEnv)
}

func detectWith(lookup func(string) (string, bool)) AgentName {
isSet := func(key string) bool {
v, ok := lookup(key)
return ok && v != ""
}

valueOf := func(key string) string {
v, _ := lookup(key)
return v
}

// Generic agent identifiers — checked first because they are the most specific signal.
if v, ok := lookup("AI_AGENT"); ok && v != "" {
if name, err := parseAgentName(v); err == nil {
return name
}
}

// Tool-specific variables.

// Check AGENT=amp before the more generic CLAUDECODE=1 since Amp sets both.
if valueOf("AGENT") == "amp" {
return agentAmp
}

// OpenAI Codex CLI — https://github.com/openai/codex
// CODEX_SANDBOX: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/spawn.rs#L25
// CODEX_THREAD_ID: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/exec_env.rs#L8
// CODEX_CI: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/unified_exec/process_manager.rs#L64
if isSet("CODEX_SANDBOX") || isSet("CODEX_CI") || isSet("CODEX_THREAD_ID") {
return agentCodex
}

// Google Gemini CLI — https://github.com/google-gemini/gemini-cli
// GEMINI_CLI: https://github.com/google-gemini/gemini-cli/blob/46fd7b4864111032a1c7dfa1821b2000fc7531da/docs/tools/shell.md#L96-L97
if isSet("GEMINI_CLI") {
return agentGeminiCLI
}

// GitHub Copilot CLI
// No first-party docs
if isSet("COPILOT_CLI") {
return agentCopilotCLI
}

// OpenCode — https://github.com/anomalyco/opencode
// OPENCODE: https://github.com/anomalyco/opencode/blob/fde201c286a83ff32dda9b41d61d734a4449fe70/packages/opencode/src/index.ts#L78-L80
if isSet("OPENCODE") {
return agentOpencode
}

// Anthropic Claude Code — https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview
// CLAUDECODE: https://code.claude.com/docs/en/env-vars (CLAUDECODE section)
// Checked last because other agents (e.g. Amp) set CLAUDECODE=1 alongside their own vars.
if isSet("CLAUDECODE") {
// There is a CLAUDE_CODE_ENTRYPOINT env var that is set to `cli` or `desktop` etc, but it's not documented
// so we don't want to rely on it too heavily. We'll just return a generic claude-code agent name.
return agentClaudeCode
}

return ""
}
149 changes: 149 additions & 0 deletions internal/agents/detect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package agents

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func lookup(vars map[string]string) func(string) (string, bool) {
return func(key string) (string, bool) {
v, ok := vars[key]
return v, ok
}
}

func TestParseAgentName(t *testing.T) {
tests := []struct {
name string
input string
want AgentName
wantErr bool
}{
{name: "valid lowercase", input: "my-agent", want: "my-agent"},
{name: "valid with underscore", input: "my_agent_v2", want: "my_agent_v2"},
{name: "valid uppercase", input: "MyAgent", want: "MyAgent"},
{name: "valid numbers", input: "agent123", want: "agent123"},
{name: "spaces rejected", input: "my agent", wantErr: true},
{name: "newline rejected", input: "my\nagent", wantErr: true},
{name: "carriage return rejected", input: "my\ragent", wantErr: true},
{name: "null byte rejected", input: "my\x00agent", wantErr: true},
{name: "dot rejected", input: "my.agent", wantErr: true},
{name: "slash rejected", input: "my/agent", wantErr: true},
{name: "empty rejected", input: "", wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseAgentName(tt.input)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.want, got)
}
})
}
}

func TestDetectWith(t *testing.T) {
tests := []struct {
name string
env map[string]string
wantAgent AgentName
}{
{
name: "clean environment",
env: map[string]string{},
wantAgent: "",
},
{
name: "empty var is not detected",
env: map[string]string{"GEMINI_CLI": ""},
wantAgent: "",
},
{
name: "AGENT=amp detected as amp",
env: map[string]string{"AGENT": "amp"},
wantAgent: "amp",
},
{
name: "AGENT with non-amp value is ignored",
env: map[string]string{"AGENT": "other"},
wantAgent: "",
},
{
name: "AI_AGENT returns value as agent name",
env: map[string]string{"AI_AGENT": "some-agent"},
wantAgent: "some-agent",
},
{
name: "AI_AGENT with invalid characters is ignored",
env: map[string]string{"AI_AGENT": "bad\nagent"},
wantAgent: "",
},
{
name: "AI_AGENT with spaces is ignored",
env: map[string]string{"AI_AGENT": "bad agent"},
wantAgent: "",
},
{
name: "AI_AGENT takes priority over AGENT",
env: map[string]string{"AGENT": "amp", "AI_AGENT": "other"},
wantAgent: "other",
},
{
name: "CODEX_SANDBOX",
env: map[string]string{"CODEX_SANDBOX": "seatbelt"},
wantAgent: "codex",
},
{
name: "CODEX_CI",
env: map[string]string{"CODEX_CI": "1"},
wantAgent: "codex",
},
{
name: "CODEX_THREAD_ID",
env: map[string]string{"CODEX_THREAD_ID": "abc"},
wantAgent: "codex",
},
{
name: "GEMINI_CLI",
env: map[string]string{"GEMINI_CLI": "1"},
wantAgent: "gemini-cli",
},
{
name: "COPILOT_CLI",
env: map[string]string{"COPILOT_CLI": "1"},
wantAgent: "copilot-cli",
},
{
name: "OPENCODE",
env: map[string]string{"OPENCODE": "1"},
wantAgent: "opencode",
},
{
name: "CLAUDECODE",
env: map[string]string{"CLAUDECODE": "1"},
wantAgent: "claude-code",
},
{
name: "AGENT=amp takes priority over CLAUDECODE",
env: map[string]string{"AGENT": "amp", "CLAUDECODE": "1"},
wantAgent: "amp",
},
{
name: "invalid AI_AGENT falls through to tool-specific detection",
env: map[string]string{"AI_AGENT": "bad agent", "GEMINI_CLI": "1"},
wantAgent: "gemini-cli",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectWith(lookup(tt.env))
assert.Equal(t, tt.wantAgent, got)
})
}
}
3 changes: 2 additions & 1 deletion internal/ghcmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
surveyCore "github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/agents"
"github.com/cli/cli/v2/internal/build"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/config/migration"
Expand Down Expand Up @@ -44,7 +45,7 @@ func Main() exitCode {
buildVersion := build.Version
hasDebug, _ := utils.IsDebugEnabled()

cmdFactory := factory.New(buildVersion)
cmdFactory := factory.New(buildVersion, string(agents.Detect()))
stderr := cmdFactory.IOStreams.ErrOut

ctx := context.Background()
Expand Down
8 changes: 4 additions & 4 deletions pkg/cmd/attestation/verify/verify_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestVerifyIntegration(t *testing.T) {
TUFMetadataDir: o.Some(t.TempDir()),
}

cmdFactory := factory.New("test")
cmdFactory := factory.New("test", "")

hc, err := cmdFactory.HttpClient()
if err != nil {
Expand Down Expand Up @@ -143,7 +143,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
TUFMetadataDir: o.Some(t.TempDir()),
}

cmdFactory := factory.New("test")
cmdFactory := factory.New("test", "")

hc, err := cmdFactory.HttpClient()
if err != nil {
Expand Down Expand Up @@ -217,7 +217,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
TUFMetadataDir: o.Some(t.TempDir()),
}

cmdFactory := factory.New("test")
cmdFactory := factory.New("test", "")

hc, err := cmdFactory.HttpClient()
if err != nil {
Expand Down Expand Up @@ -310,7 +310,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
TUFMetadataDir: o.Some(t.TempDir()),
}

cmdFactory := factory.New("test")
cmdFactory := factory.New("test", "")

hc, err := cmdFactory.HttpClient()
if err != nil {
Expand Down
Loading
Loading