From 20d13986539ad8ef6c1f55e720aec348eb1607b1 Mon Sep 17 00:00:00 2001 From: YG Park Date: Thu, 30 Jul 2026 17:36:32 +0900 Subject: [PATCH 1/3] feat: add GitHub Copilot privacy checks (#8) --- README.md | 30 ++- internal/account/account.go | 154 ++++++++++--- internal/account/account_test.go | 80 +++++++ internal/cookies/cookies.go | 2 + internal/github/github.go | 383 +++++++++++++++++++++++++++++++ internal/github/github_test.go | 161 +++++++++++++ main.go | 154 ++++++++++++- main_test.go | 26 +++ 8 files changed, 939 insertions(+), 51 deletions(-) create mode 100644 internal/github/github.go create mode 100644 internal/github/github_test.go diff --git a/README.md b/README.md index 63f666b..0c78233 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,15 @@ nosnitch · AI account privacy check Model training OFF Codex training OFF + [GitHub Account] + Account you + Copilot license Copilot Pro + Discovered via Chrome + Model training OFF + Cloud agent repos SELECTED (2) review repository selection + Claude agent OFF + Codex agent OFF + ✓ no training or public-sharing exposure found ``` @@ -52,6 +61,7 @@ the command suitable for CI and local checks. | **OpenAI Account** | ChatGPT browser session | ChatGPT and Codex model-training settings | | **Claude Account** | Claude Code | Account, plan, and the account-wide “Help improve Claude” setting | | **Claude Account** | Claude Desktop or browser session | Publicly shared Claude conversations | +| **GitHub Account** | GitHub browser session | Copilot license, model-training preference, cloud-agent repository scope, and partner-agent states | The Claude model-improvement preference applies to consumer Claude chats and Claude Code coding sessions. Commercial Claude plans and API usage follow their @@ -67,6 +77,8 @@ organization's commercial data policy. Linux for a read-only request to `/api/oauth/account/settings`. - **Claude**: borrows the logged-in Claude Desktop or browser session and reads the same `/api/organizations/{id}/shares` endpoint used by claude.ai. +- **GitHub Copilot**: borrows the logged-in GitHub browser session and reads + `/settings/copilot/features` and `/settings/copilot/coding_agent`. Browser cookies are decrypted locally using macOS Keychain or Linux Secret Service. Tokens and cookies are sent only to the corresponding first-party @@ -110,16 +122,20 @@ nosnitch claude training # turn off Claude model improvement only nosnitch claude training --yes # turn off without prompting nosnitch claude unshare # remove only public Claude links nosnitch claude unshare --yes # remove links without prompting +nosnitch github training # turn off GitHub Copilot model training only +nosnitch github training --yes # turn off without prompting nosnitch openai --help # OpenAI-specific help nosnitch claude --help # Claude-specific help +nosnitch github --help # GitHub-specific help ``` `nosnitch off` disables supported OpenAI training settings, disables Claude's -account-wide model-improvement setting, and removes detected public Claude chat -links. Every command that changes account state asks for confirmation unless -`--yes` is provided. The provider-specific `training` commands change only -that provider's training settings. `nosnitch claude unshare` changes only -Claude's public links. +account-wide model-improvement setting, removes detected public Claude chat +links, and disables GitHub Copilot model training. It leaves GitHub Copilot +cloud-agent repository selection and partner-agent switches unchanged. Every +command that changes account state asks for confirmation unless `--yes` is +provided. The provider-specific `training` commands change only that provider's +training settings. `nosnitch claude unshare` changes only Claude's public links. ## Platform support @@ -134,4 +150,6 @@ Ubuntu); Homebrew installs this dependency automatically. `nosnitch` reads sensitive local credentials to inspect your settings. Requests are read-only unless you explicitly run `off`, a provider-specific `training` -command, or `claude unshare`. +command, or `claude unshare`. Credentials, browser cookies, CSRF values, and +tokens are held in memory only, sent solely to the matching first-party host, +and never printed or persisted by `nosnitch`. diff --git a/internal/account/account.go b/internal/account/account.go index 99e6596..7a17c26 100644 --- a/internal/account/account.go +++ b/internal/account/account.go @@ -1,5 +1,5 @@ // Package account aggregates CLI and browser sources into account-centric -// OpenAI Account and Claude Account views. +// OpenAI Account, Claude Account, and GitHub Account views. package account import ( @@ -10,11 +10,13 @@ import ( "github.com/circlesac/nosnitch-cli/internal/claude" "github.com/circlesac/nosnitch-cli/internal/codex" "github.com/circlesac/nosnitch-cli/internal/cookies" + githubprivacy "github.com/circlesac/nosnitch-cli/internal/github" ) type Account struct { Provider string `json:"provider"` - Email string `json:"email"` + Email string `json:"email,omitempty"` + Login string `json:"login,omitempty"` Plan string `json:"plan,omitempty"` Sources []string `json:"sources"` APIDataSharing *bool `json:"api_data_sharing,omitempty"` @@ -22,6 +24,7 @@ type Account struct { ModelImprovement *bool `json:"model_improvement,omitempty"` SharedConversations []claude.SharedConversation `json:"shared_conversations,omitempty"` SharedChatsChecked bool `json:"shared_chats_checked,omitempty"` + GitHubCopilot *githubprivacy.Settings `json:"github_copilot,omitempty"` } func (a *Account) Risk() bool { @@ -33,9 +36,18 @@ func (a *Account) Risk() bool { return true } } + if a.GitHubCopilot != nil && truthy(a.GitHubCopilot.ModelTraining) { + return true + } return truthy(a.ModelImprovement) || len(a.SharedConversations) > 0 } +func (a *Account) GitHubIncomplete() bool { + return a.Provider == "github" && (a.Plan == "" || a.GitHubCopilot == nil || + a.GitHubCopilot.ModelTraining == nil || a.GitHubCopilot.CloudAgentRepositories == "" || + a.GitHubCopilot.PartnerAgents == nil) +} + // CodexTrainingUnknown reports whether a Codex CLI account was found without // successfully reading its ChatGPT account-level Codex training setting. func (a *Account) CodexTrainingUnknown() bool { @@ -69,6 +81,9 @@ func (r Report) Indeterminate() bool { if len(r.Accounts) == 0 { return true } + if len(r.Blocked) > 0 || len(r.Skipped) > 0 { + return true + } for _, a := range r.Accounts { if a.CodexTrainingUnknown() { return true @@ -77,30 +92,55 @@ func (r Report) Indeterminate() bool { (a.ModelImprovement == nil || !a.SharedChatsChecked) { return true } + if a.GitHubIncomplete() { + return true + } } return false } +type accountIndex struct { + byIdentity map[string]*Account + order []string +} + +func newAccountIndex() *accountIndex { + return &accountIndex{byIdentity: map[string]*Account{}} +} + +func (i *accountIndex) get(provider, identity string) *Account { + key := provider + "\x00" + identity + if existing, ok := i.byIdentity[key]; ok { + return existing + } + created := &Account{Provider: provider} + if provider == "github" { + created.Login = identity + } else { + created.Email = identity + } + i.byIdentity[key] = created + i.order = append(i.order, key) + return created +} + +func (i *accountIndex) accounts() []*Account { + sort.Strings(i.order) + accounts := make([]*Account, 0, len(i.order)) + for _, key := range i.order { + accounts = append(accounts, i.byIdentity[key]) + } + return accounts +} + // Gather reads supported CLI and browser sessions and groups them by provider // plus email, so the same account discovered through multiple sources is merged. func Gather() Report { - byEmail := map[string]*Account{} - order := []string{} - get := func(provider, email string) *Account { - key := provider + "\x00" + email - if a, ok := byEmail[key]; ok { - return a - } - a := &Account{Provider: provider, Email: email} - byEmail[key] = a - order = append(order, key) - return a - } - + accounts := newAccountIndex() var rep Report if cx := codex.Check(); cx.OK { - a := get("openai", cx.Email) + a := accounts.get("openai", cx.Email) a.Plan = cx.Plan a.Sources = append(a.Sources, "Codex CLI") v := cx.APIDataSharing @@ -108,7 +148,7 @@ func Gather() Report { } if cc := claude.CheckCode(); cc.OK { - a := get("anthropic", cc.Email) + a := accounts.get("anthropic", cc.Email) a.Plan = cc.Plan a.Sources = append(a.Sources, "Claude Code") a.ModelImprovement = cc.ModelImprovement @@ -126,7 +166,7 @@ func Gather() Report { if !res.OK { rep.Skipped = append(rep.Skipped, b.Name+" (OpenAI): "+res.Reason) } else { - a := get("openai", res.Email) + a := accounts.get("openai", res.Email) a.Sources = appendUnique(a.Sources, b.Name) if a.Training == nil { a.Training = map[string]*bool{} @@ -144,32 +184,72 @@ func Gather() Report { if !hasBlocked(rep.Blocked, b.Name) { rep.Blocked = append(rep.Blocked, Blocked{b.Name, "needs Full Disk Access"}) } - continue - } - if err != nil || claudeJar == nil { - continue - } - claudeRes := claude.CheckWeb(claudeJar) - if !claudeRes.OK { - rep.Skipped = append(rep.Skipped, b.Name+" (Claude): "+claudeRes.Reason) - continue + } else if err != nil { + rep.Skipped = append(rep.Skipped, b.Name+" (Claude): "+err.Error()) + } else if claudeJar != nil { + claudeRes := claude.CheckWeb(claudeJar) + if !claudeRes.OK { + rep.Skipped = append(rep.Skipped, b.Name+" (Claude): "+claudeRes.Reason) + } else { + a := accounts.get("anthropic", claudeRes.Email) + a.Sources = appendUnique(a.Sources, b.Name) + if claudeRes.ModelImprovement != nil { + a.ModelImprovement = claudeRes.ModelImprovement + } + a.SharedConversations = appendSharedUnique(a.SharedConversations, claudeRes.SharedConversations) + a.SharedChatsChecked = true + } } - a := get("anthropic", claudeRes.Email) - a.Sources = appendUnique(a.Sources, b.Name) - if claudeRes.ModelImprovement != nil { - a.ModelImprovement = claudeRes.ModelImprovement + + githubJar, err := b.GitHub() + if errors.Is(err, cookies.ErrNeedFullDiskAccess) { + if !hasBlocked(rep.Blocked, b.Name) { + rep.Blocked = append(rep.Blocked, Blocked{b.Name, "needs Full Disk Access"}) + } + } else if err != nil { + rep.Skipped = append(rep.Skipped, b.Name+" (GitHub): "+err.Error()) + } else if githubJar != nil { + result := githubprivacy.CheckWith(githubJar) + if !result.OK { + rep.Skipped = append(rep.Skipped, b.Name+" (GitHub): "+result.Reason) + } else { + mergeGitHub(accounts.get("github", result.Login), result, b.Name) + if result.Reason != "" { + rep.Skipped = append(rep.Skipped, b.Name+" (GitHub): "+result.Reason) + } + } } - a.SharedConversations = appendSharedUnique(a.SharedConversations, claudeRes.SharedConversations) - a.SharedChatsChecked = true } - sort.Strings(order) - for _, e := range order { - rep.Accounts = append(rep.Accounts, byEmail[e]) - } + rep.Accounts = accounts.accounts() return rep } +func mergeGitHub(account *Account, result githubprivacy.Result, source string) { + account.Sources = appendUnique(account.Sources, source) + if result.License != "" { + account.Plan = result.License + } + if account.GitHubCopilot == nil { + account.GitHubCopilot = &githubprivacy.Settings{} + } + if result.Settings.ModelTraining != nil { + account.GitHubCopilot.ModelTraining = result.Settings.ModelTraining + } + if result.Settings.CloudAgentRepositories != "" { + account.GitHubCopilot.CloudAgentRepositories = result.Settings.CloudAgentRepositories + account.GitHubCopilot.SelectedRepositories = result.Settings.SelectedRepositories + } + if result.Settings.PartnerAgents != nil { + if account.GitHubCopilot.PartnerAgents == nil { + account.GitHubCopilot.PartnerAgents = map[string]*bool{} + } + for name, enabled := range result.Settings.PartnerAgents { + account.GitHubCopilot.PartnerAgents[name] = enabled + } + } +} + func truthy(b *bool) bool { return b != nil && *b } func contains(values []string, value string) bool { diff --git a/internal/account/account_test.go b/internal/account/account_test.go index 3a18ff8..b65c2d7 100644 --- a/internal/account/account_test.go +++ b/internal/account/account_test.go @@ -1,10 +1,13 @@ package account import ( + "encoding/json" + "strings" "testing" "github.com/circlesac/nosnitch-cli/internal/chatgpt" "github.com/circlesac/nosnitch-cli/internal/claude" + githubprivacy "github.com/circlesac/nosnitch-cli/internal/github" ) func TestClaudeAccountRisk(t *testing.T) { @@ -30,6 +33,83 @@ func TestClaudeAccountRisk(t *testing.T) { } } +func TestGitHubAccountRiskAndIncomplete(t *testing.T) { + on, off := true, false + clean := &Account{ + Provider: "github", + Login: "octocat", + Plan: "Copilot Pro", + GitHubCopilot: &githubprivacy.Settings{ + ModelTraining: &off, + CloudAgentRepositories: "all", + PartnerAgents: map[string]*bool{"claude": &on, "codex": &off}, + }, + } + if clean.Risk() { + t.Fatal("broad repository or partner-agent access must not be classified as training exposure") + } + if clean.GitHubIncomplete() { + t.Fatal("complete GitHub result reported incomplete") + } + clean.GitHubCopilot.ModelTraining = &on + if !clean.Risk() { + t.Fatal("enabled GitHub model training was not classified as exposure") + } + clean.GitHubCopilot.ModelTraining = nil + if !clean.GitHubIncomplete() { + t.Fatal("missing GitHub model-training setting was not incomplete") + } +} + +func TestGitHubBrowserSessionsMergeByLogin(t *testing.T) { + off := false + index := newAccountIndex() + result := githubprivacy.Result{ + OK: true, Login: "octocat", License: "Copilot Pro", + Settings: githubprivacy.Settings{ + ModelTraining: &off, + CloudAgentRepositories: "selected", + SelectedRepositories: 2, + PartnerAgents: map[string]*bool{"claude": &off, "codex": &off}, + }, + } + mergeGitHub(index.get("github", result.Login), result, "Chrome") + mergeGitHub(index.get("github", result.Login), result, "Safari") + + accounts := index.accounts() + if len(accounts) != 1 { + t.Fatalf("merged account count = %d, want 1", len(accounts)) + } + if strings.Join(accounts[0].Sources, ",") != "Chrome,Safari" { + t.Fatalf("merged sources = %#v", accounts[0].Sources) + } + encoded, _ := json.Marshal(Report{Accounts: accounts}) + for _, field := range []string{`"provider":"github"`, `"login":"octocat"`, + `"model_training":false`, `"cloud_agent_repositories":"selected"`, + `"partner_agents"`} { + if !strings.Contains(string(encoded), field) { + t.Fatalf("GitHub JSON missing %s: %s", field, encoded) + } + } +} + +func TestSkippedSessionMakesReportIndeterminate(t *testing.T) { + off := false + report := Report{ + Accounts: []*Account{{ + Provider: "github", Login: "octocat", Plan: "Copilot Pro", + GitHubCopilot: &githubprivacy.Settings{ + ModelTraining: &off, CloudAgentRepositories: "none", + PartnerAgents: map[string]*bool{}, + }, + }}, + Skipped: []string{"Chrome (GitHub): expired session"}, + } + if !report.Indeterminate() { + t.Fatal("skipped GitHub session did not make report incomplete") + } +} + func TestCodexTrainingUnknownMakesReportIndeterminate(t *testing.T) { off := false tests := []struct { diff --git a/internal/cookies/cookies.go b/internal/cookies/cookies.go index 135e85a..a86f8ae 100644 --- a/internal/cookies/cookies.go +++ b/internal/cookies/cookies.go @@ -77,6 +77,8 @@ func (b Browser) ChatGPT() (map[string]string, error) { return b.Cookies("chatgp func (b Browser) Claude() (map[string]string, error) { return b.Cookies("claude.ai") } +func (b Browser) GitHub() (map[string]string, error) { return b.Cookies("github.com") } + func chromiumCookies(b Browser, hostLike string) (map[string]string, error) { db, cleanup, err := openCopy(b.path()) if err != nil { diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 0000000..0455939 --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,383 @@ +// Package github reads GitHub Copilot privacy and coding-agent settings from +// a signed-in github.com browser session. +package github + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "golang.org/x/net/html" +) + +const ( + webBase = "https://github.com" + featuresPath = "/settings/copilot/features" + codingAgentPath = "/settings/copilot/coding_agent" + userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) + +type Settings struct { + ModelTraining *bool `json:"model_training,omitempty"` + CloudAgentRepositories string `json:"cloud_agent_repositories,omitempty"` + SelectedRepositories int `json:"selected_repositories,omitempty"` + PartnerAgents map[string]*bool `json:"partner_agents,omitempty"` +} + +type Result struct { + OK bool `json:"ok"` + Reason string `json:"reason,omitempty"` + Login string `json:"login,omitempty"` + License string `json:"license,omitempty"` + Settings Settings `json:"settings"` +} + +func (r Result) Complete() bool { + return r.OK && r.License != "" && r.Settings.ModelTraining != nil && + r.Settings.CloudAgentRepositories != "" && r.Settings.PartnerAgents != nil +} + +func (r Result) Risk() bool { + return r.Settings.ModelTraining != nil && *r.Settings.ModelTraining +} + +type OffResult struct { + OK bool `json:"ok"` + Reason string `json:"reason,omitempty"` + Login string `json:"login,omitempty"` + Changed bool `json:"changed"` +} + +type doer interface { + Do(*http.Request) (*http.Response, error) +} + +type featuresPage struct { + Login string + License string + ModelTraining *bool + FormAction string + AuthenticityToken string +} + +type codingAgentPage struct { + RepositoryScope string + SelectedCount int + PartnerAgents map[string]*bool +} + +func CheckWith(jar map[string]string) Result { + return checkWith(&http.Client{Timeout: 25 * time.Second}, webBase, jar) +} + +func checkWith(client doer, base string, jar map[string]string) Result { + cookie := cookieHeader(jar) + status, body, err := get(client, base+featuresPath, cookie) + if err != nil { + return Result{Reason: "GitHub Copilot settings read failed: " + err.Error()} + } + if status != http.StatusOK { + return Result{Reason: "GitHub Copilot settings read failed (HTTP " + strconv.Itoa(status) + ")"} + } + features, err := parseFeatures(body) + if err != nil || features.Login == "" { + reason := "signed-in GitHub account not found" + if err != nil { + reason = err.Error() + } + return Result{Reason: reason} + } + + result := Result{ + OK: true, + Login: features.Login, + License: features.License, + Settings: Settings{ + ModelTraining: features.ModelTraining, + }, + } + var reasons []string + if features.License == "" { + reasons = append(reasons, "Copilot license could not be read") + } + if features.ModelTraining == nil { + reasons = append(reasons, "Copilot model-training preference could not be read") + } + + status, body, err = get(client, base+codingAgentPath, cookie) + if err != nil { + reasons = append(reasons, "Copilot cloud-agent settings read failed: "+err.Error()) + } else if status != http.StatusOK { + reasons = append(reasons, "Copilot cloud-agent settings read failed (HTTP "+strconv.Itoa(status)+")") + } else if coding, parseErr := parseCodingAgent(body); parseErr != nil { + reasons = append(reasons, parseErr.Error()) + } else { + result.Settings.CloudAgentRepositories = coding.RepositoryScope + result.Settings.SelectedRepositories = coding.SelectedCount + result.Settings.PartnerAgents = coding.PartnerAgents + } + result.Reason = strings.Join(reasons, "; ") + return result +} + +func OffWith(jar map[string]string) OffResult { + return offWith(&http.Client{Timeout: 25 * time.Second}, webBase, jar) +} + +func offWith(client doer, base string, jar map[string]string) OffResult { + cookie := cookieHeader(jar) + status, body, err := get(client, base+featuresPath, cookie) + if err != nil { + return OffResult{Reason: "GitHub Copilot settings read failed: " + err.Error()} + } + if status != http.StatusOK { + return OffResult{Reason: "GitHub Copilot settings read failed (HTTP " + strconv.Itoa(status) + ")"} + } + features, err := parseFeatures(body) + if err != nil || features.Login == "" { + return OffResult{Reason: "signed-in GitHub account not found"} + } + if features.ModelTraining == nil { + return OffResult{Login: features.Login, Reason: "Copilot model-training preference could not be read"} + } + if !*features.ModelTraining { + return OffResult{OK: true, Login: features.Login} + } + if features.AuthenticityToken == "" || features.FormAction != "/settings/copilot" { + return OffResult{Login: features.Login, Reason: "Copilot model-training update form could not be read"} + } + + form := url.Values{ + "_method": {"put"}, + "authenticity_token": {features.AuthenticityToken}, + "telemetry": {"disabled"}, + } + status, _, err = postForm(client, strings.TrimRight(base, "/")+features.FormAction, base+featuresPath, cookie, form) + if err != nil { + return OffResult{Login: features.Login, Reason: "Copilot model-training update failed: " + err.Error()} + } + if status < 200 || status >= 400 { + return OffResult{Login: features.Login, Reason: "Copilot model-training update failed (HTTP " + strconv.Itoa(status) + ")"} + } + + status, body, err = get(client, base+featuresPath, cookie) + if err != nil || status != http.StatusOK { + return OffResult{Login: features.Login, Reason: "Copilot model-training verification failed"} + } + verified, err := parseFeatures(body) + if err != nil || verified.ModelTraining == nil || *verified.ModelTraining { + return OffResult{Login: features.Login, Reason: "Copilot model-training preference is still enabled"} + } + return OffResult{OK: true, Login: features.Login, Changed: true} +} + +func parseFeatures(body []byte) (featuresPage, error) { + doc, err := html.Parse(strings.NewReader(string(body))) + if err != nil { + return featuresPage{}, fmt.Errorf("GitHub Copilot settings parse error: %w", err) + } + var page featuresPage + planPattern := regexp.MustCompile(`^GitHub Copilot (.+) is active for your account$`) + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.ElementNode && node.Data == "meta" && attr(node, "name") == "user-login" { + page.Login = attr(node, "content") + } + if node.Type == html.TextNode { + text := strings.Join(strings.Fields(node.Data), " ") + if match := planPattern.FindStringSubmatch(text); len(match) == 2 { + page.License = "Copilot " + match[1] + } + } + if node.Type == html.ElementNode && node.Data == "form" { + if telemetry := descendantInput(node, "telemetry"); telemetry != nil { + page.ModelTraining = settingBool(attr(telemetry, "value")) + page.FormAction = attr(node, "action") + if token := descendantInput(node, "authenticity_token"); token != nil { + page.AuthenticityToken = attr(token, "value") + } + } + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(doc) + if page.Login == "" { + return page, fmt.Errorf("signed-in GitHub account not found") + } + return page, nil +} + +func parseCodingAgent(body []byte) (codingAgentPage, error) { + doc, err := html.Parse(strings.NewReader(string(body))) + if err != nil { + return codingAgentPage{}, fmt.Errorf("Copilot cloud-agent settings parse error: %w", err) + } + var page codingAgentPage + foundScope := false + foundAgents := false + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.ElementNode && node.Data == "script" && + attr(node, "type") == "application/json" && node.FirstChild != nil { + var payload struct { + Props struct { + Mode string `json:"mode"` + Selection []json.RawMessage `json:"selection"` + Agents []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Enabled bool `json:"enabled"` + } `json:"agents"` + ModeChangedCallbackPath string `json:"modeChangedCallbackPath"` + ThirdPartyAgentEnablementPath string `json:"third_party_agent_enablement_callback_path"` + } `json:"props"` + } + if json.Unmarshal([]byte(node.FirstChild.Data), &payload) == nil { + if payload.Props.Mode != "" || payload.Props.ModeChangedCallbackPath != "" { + page.RepositoryScope = repositoryScope(payload.Props.Mode) + page.SelectedCount = len(payload.Props.Selection) + foundScope = true + } + if payload.Props.Agents != nil || payload.Props.ThirdPartyAgentEnablementPath != "" { + foundAgents = true + if page.PartnerAgents == nil { + page.PartnerAgents = map[string]*bool{} + } + } + for _, agent := range payload.Props.Agents { + name := agentName(agent.Name, agent.DisplayName) + if name == "" { + continue + } + enabled := agent.Enabled + page.PartnerAgents[name] = &enabled + } + } + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(doc) + if !foundScope || page.RepositoryScope == "" { + return page, fmt.Errorf("Copilot cloud-agent settings could not be read") + } + if !foundAgents { + return page, fmt.Errorf("Copilot partner-agent settings could not be read") + } + return page, nil +} + +func repositoryScope(mode string) string { + switch strings.ToLower(mode) { + case "all", "all_repos", "all_repositories": + return "all" + case "selected", "selected_repos", "selected_repositories": + return "selected" + case "none", "no_repos", "no_repositories": + return "none" + default: + return "" + } +} + +func agentName(name, displayName string) string { + switch name { + case "anthropic-code-agent": + return "claude" + case "openai-code-agent": + return "codex" + } + return strings.ToLower(strings.TrimSpace(displayName)) +} + +func settingBool(value string) *bool { + var result bool + switch strings.ToLower(value) { + case "enabled", "true", "allowed": + result = true + case "disabled", "false", "blocked": + result = false + default: + return nil + } + return &result +} + +func descendantInput(node *html.Node, name string) *html.Node { + for child := node.FirstChild; child != nil; child = child.NextSibling { + if child.Type == html.ElementNode && child.Data == "input" && attr(child, "name") == name { + return child + } + if found := descendantInput(child, name); found != nil { + return found + } + } + return nil +} + +func attr(node *html.Node, name string) string { + for _, value := range node.Attr { + if value.Key == name { + return value.Val + } + } + return "" +} + +func get(client doer, target, cookie string) (int, []byte, error) { + req, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + return 0, nil, err + } + requestHeaders(req, cookie) + resp, err := client.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + return resp.StatusCode, body, err +} + +func postForm(client doer, target, referer, cookie string, form url.Values) (int, []byte, error) { + req, err := http.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) + if err != nil { + return 0, nil, err + } + requestHeaders(req, cookie) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + req.Header.Set("referer", referer) + resp, err := client.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + return resp.StatusCode, body, err +} + +func requestHeaders(req *http.Request, cookie string) { + req.Header.Set("accept", "text/html,application/xhtml+xml") + req.Header.Set("user-agent", userAgent) + req.Header.Set("cookie", cookie) +} + +func cookieHeader(jar map[string]string) string { + parts := make([]string, 0, len(jar)) + for name, value := range jar { + parts = append(parts, name+"="+value) + } + sort.Strings(parts) + return strings.Join(parts, "; ") +} diff --git a/internal/github/github_test.go b/internal/github/github_test.go new file mode 100644 index 0000000..b1e1a32 --- /dev/null +++ b/internal/github/github_test.go @@ -0,0 +1,161 @@ +package github + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestParseFeatures(t *testing.T) { + page, err := parseFeatures([]byte(` + + +

GitHub Copilot Pro is active for your account

+
+ + + +
`)) + if err != nil { + t.Fatal(err) + } + if page.Login != "octocat" || page.License != "Copilot Pro" { + t.Fatalf("parseFeatures() identity = %#v", page) + } + if page.ModelTraining == nil || !*page.ModelTraining { + t.Fatalf("parseFeatures() training = %#v", page.ModelTraining) + } + if page.FormAction != "/settings/copilot" || page.AuthenticityToken != "csrf-value" { + t.Fatalf("parseFeatures() form = %#v", page) + } +} + +func TestParseCodingAgent(t *testing.T) { + body := codingAgentHTML("all_repos", 0, true, false) + page, err := parseCodingAgent([]byte(body)) + if err != nil { + t.Fatal(err) + } + if page.RepositoryScope != "all" || page.SelectedCount != 0 { + t.Fatalf("parseCodingAgent() repositories = %#v", page) + } + if page.PartnerAgents["claude"] == nil || !*page.PartnerAgents["claude"] { + t.Fatalf("parseCodingAgent() Claude = %#v", page.PartnerAgents) + } + if page.PartnerAgents["codex"] == nil || *page.PartnerAgents["codex"] { + t.Fatalf("parseCodingAgent() Codex = %#v", page.PartnerAgents) + } +} + +func TestCheckWithReportsIncompleteSettings(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case featuresPath: + fmt.Fprint(w, ``) + case codingAgentPath: + fmt.Fprint(w, codingAgentHTML("all_repos", 0, false, false)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + result := checkWith(server.Client(), server.URL, map[string]string{"session": "not-printed"}) + if !result.OK || result.Complete() { + t.Fatalf("checkWith() = %#v", result) + } + if !strings.Contains(result.Reason, "license") || !strings.Contains(result.Reason, "model-training") { + t.Fatalf("checkWith() reason = %q", result.Reason) + } + encoded, _ := json.Marshal(result) + if strings.Contains(string(encoded), "not-printed") || strings.Contains(string(encoded), "csrf") { + t.Fatalf("result leaked credentials: %s", encoded) + } +} + +func TestOffWithChangesOnlyTrainingAndVerifies(t *testing.T) { + training := true + var posted bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != featuresPath && r.URL.Path != "/settings/copilot" { + http.NotFound(w, r) + return + } + if r.Method == http.MethodPost { + posted = true + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if len(r.PostForm) != 3 || r.PostForm.Get("_method") != "put" || + r.PostForm.Get("authenticity_token") != "csrf-value" || + r.PostForm.Get("telemetry") != "disabled" { + t.Fatalf("unexpected update form: %#v", r.PostForm) + } + training = false + w.WriteHeader(http.StatusNoContent) + return + } + value := "disabled" + if training { + value = "enabled" + } + fmt.Fprintf(w, ` +
`, value) + })) + defer server.Close() + + result := offWith(server.Client(), server.URL, map[string]string{"session": "secret"}) + if !result.OK || !result.Changed || !posted || result.Login != "octocat" { + t.Fatalf("offWith() = %#v, posted=%v", result, posted) + } +} + +func TestOffWithRejectsUnexpectedFormAction(t *testing.T) { + var posted bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + posted = true + } + fmt.Fprint(w, ` +
`) + })) + defer server.Close() + + result := offWith(server.Client(), server.URL, map[string]string{"session": "secret"}) + if result.OK || posted || !strings.Contains(result.Reason, "update form") { + t.Fatalf("offWith() = %#v, posted=%v", result, posted) + } +} + +func TestRepositoryScope(t *testing.T) { + for input, want := range map[string]string{ + "all_repos": "all", "selected_repos": "selected", "no_repos": "none", "unexpected": "", + } { + if got := repositoryScope(input); got != want { + t.Fatalf("repositoryScope(%q) = %q, want %q", input, got, want) + } + } +} + +func codingAgentHTML(mode string, selected int, claude, codex bool) string { + selection := make([]map[string]any, selected) + repositoryPayload := map[string]any{"props": map[string]any{ + "mode": mode, + "modeChangedCallbackPath": "/settings/copilot/coding_agent/repositories", + "selection": selection, + }} + agentPayload := map[string]any{"props": map[string]any{ + "third_party_agent_enablement_callback_path": "/settings/copilot/coding_agent/toggle_third_party_agents_enablement", + "agents": []map[string]any{ + {"name": "anthropic-code-agent", "displayName": "Claude", "enabled": claude}, + {"name": "openai-code-agent", "displayName": "Codex", "enabled": codex}, + }, + }} + repositoryData, _ := json.Marshal(repositoryPayload) + agentData, _ := json.Marshal(agentPayload) + return `` + + `` +} diff --git a/main.go b/main.go index f5b92ef..cfb16fe 100644 --- a/main.go +++ b/main.go @@ -9,14 +9,18 @@ package main import ( "bufio" "encoding/json" + "errors" "fmt" + "io" "os" + "sort" "strings" "github.com/circlesac/nosnitch-cli/internal/account" "github.com/circlesac/nosnitch-cli/internal/chatgpt" "github.com/circlesac/nosnitch-cli/internal/claude" "github.com/circlesac/nosnitch-cli/internal/cookies" + githubprivacy "github.com/circlesac/nosnitch-cli/internal/github" ) func main() { @@ -41,6 +45,8 @@ func main() { os.Exit(runProviderCommand("openai")) case "claude": os.Exit(runProviderCommand("anthropic")) + case "github": + os.Exit(runProviderCommand("github")) case "version", "-v", "--version": fmt.Println("nosnitch", Version) case "help", "-h", "--help": @@ -84,6 +90,9 @@ Usage: nosnitch claude unshare [--yes] Remove public Claude links without changing training settings. + nosnitch github training [--yes] + Turn off GitHub Copilot model training. + nosnitch version Check exit codes: @@ -97,6 +106,8 @@ func runProviderCommand(provider string) int { label := "OpenAI" if provider == "anthropic" { label = "Claude" + } else if provider == "github" { + label = "GitHub" } if len(os.Args) < 3 { fmt.Fprintf(os.Stderr, "missing %s command\n\n", label) @@ -111,6 +122,9 @@ func runProviderCommand(provider string) int { if provider == "openai" { return runOpenAIOff(hasFlag("--yes")) } + if provider == "github" { + return runGitHubTrainingOff(hasFlag("--yes")) + } return runClaudeTrainingOff(hasFlag("--yes")) } if os.Args[2] == "unshare" && provider == "anthropic" { @@ -122,6 +136,13 @@ func runProviderCommand(provider string) int { } func providerUsage(provider string, out *os.File) { + if provider == "github" { + fmt.Fprint(out, `Usage: + nosnitch github training [--yes] + Turn off GitHub Copilot model training. +`) + return + } if provider == "anthropic" { fmt.Fprint(out, `Usage: nosnitch claude training [--yes] @@ -246,7 +267,8 @@ func runOff(yes bool) int { return 0 } openAIOutcome := turnOffOpenAI("nosnitch off") - return finishOff(mergeOutcomes(claudeOutcome, openAIOutcome), + githubOutcome := turnOffGitHub("nosnitch off") + return finishOff(mergeOutcomes(claudeOutcome, openAIOutcome, githubOutcome), "training and public-sharing exposure turned off") } @@ -284,6 +306,16 @@ func runClaudeTrainingOff(yes bool) int { return 0 } +func runGitHubTrainingOff(yes bool) int { + if !yes && !confirm("Turn off GitHub Copilot model training?") { + return 0 + } + fmt.Println(c("nosnitch", bold), c("· turning off GitHub Copilot model training…", dim)) + fmt.Println() + return finishOff(turnOffGitHub("nosnitch github training"), + "GitHub Copilot model training turned off") +} + func turnOffClaude(yes bool) offOutcome { claudeSessions := discoverClaudeSessions() sharedCount := 0 @@ -343,13 +375,17 @@ func turnOffClaude(yes bool) offOutcome { } func confirm(prompt string) bool { - fmt.Printf("%s [y/N] ", prompt) - answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') + return confirmWith(prompt, os.Stdin, os.Stdout) +} + +func confirmWith(prompt string, in io.Reader, out io.Writer) bool { + fmt.Fprintf(out, "%s [y/N] ", prompt) + answer, _ := bufio.NewReader(in).ReadString('\n') answer = strings.TrimSpace(strings.ToLower(answer)) if answer == "y" || answer == "yes" { return true } - fmt.Println("Cancelled.") + fmt.Fprintln(out, "Cancelled.") return false } @@ -393,6 +429,51 @@ func turnOffOpenAI(retryCommand string) offOutcome { return outcome } +func turnOffGitHub(retryCommand string) offOutcome { + outcome := offOutcome{} + fdaBlocked := false + seen := map[string]bool{} + for _, browser := range cookies.Installed() { + jar, err := browser.GitHub() + if errors.Is(err, cookies.ErrNeedFullDiskAccess) { + fdaBlocked = true + continue + } + if err != nil || jar == nil { + continue + } + checked := githubprivacy.CheckWith(jar) + if !checked.OK { + outcome.indeterminate = true + fmt.Println(c(" ! "+browser.Name+" (GitHub): "+checked.Reason, yel)) + continue + } + if seen[checked.Login] { + continue + } + seen[checked.Login] = true + result := githubprivacy.OffWith(jar) + if !result.OK { + outcome.failed = true + fmt.Println(c(" ✗ GitHub Copilot model training: "+result.Reason, red)) + continue + } + outcome.acted = true + fmt.Println(" " + c("[GitHub Account]", bold)) + field("Account", result.Login, "", "") + field("Discovered via", browser.Name, "", "") + field("Model training", "OFF", grn, "") + fmt.Println() + } + if fdaBlocked { + fmt.Println(c(" ! Safari session found but couldn't be read — needs Full Disk Access.", yel)) + fmt.Println(c(" Opening the setting; add your terminal, then re-run `"+retryCommand+"`.", dim)) + openFullDiskAccessSettings() + outcome.indeterminate = true + } + return outcome +} + func mergeOutcomes(values ...offOutcome) offOutcome { var merged offOutcome for _, value := range values { @@ -490,18 +571,29 @@ func printStatus(rep account.Report) { label := "OpenAI Account" if a.Provider == "anthropic" { label = "Claude Account" + } else if a.Provider == "github" { + label = "GitHub Account" } fmt.Println(" " + c("["+label+"]", bold)) - field("Account", a.Email, "", "") + identity := a.Email + if a.Provider == "github" { + identity = a.Login + } + field("Account", identity, "", "") if a.Plan != "" { plan := capitalize(a.Plan) if a.Provider == "openai" { plan = "ChatGPT " + plan } - field("Plan", plan, "", "") + planLabel := "Plan" + if a.Provider == "github" { + planLabel = "Copilot license" + } + field(planLabel, plan, "", "") } field("Discovered via", strings.Join(a.Sources, ", "), "", "") - if a.Provider == "openai" { + switch a.Provider { + case "openai": flagRow("API data sharing", a.APIDataSharing, "API traffic used for training") for _, f := range chatgpt.TrainingFeatures { if f.Key == chatgpt.CodexTrainingFeatureKey && a.CodexTrainingUnknown() { @@ -510,7 +602,7 @@ func printStatus(rep account.Report) { } flagRow(f.Label, a.Training[f.Key], f.OnNote) } - } else { + case "anthropic": if a.ModelImprovement == nil { field("Model improvement", "UNKNOWN", yel, "account setting could not be read") } else { @@ -530,6 +622,8 @@ func printStatus(rep account.Report) { fmt.Print(" ") printSharedChat(shared) } + case "github": + printGitHubCopilot(a.GitHubCopilot) } fmt.Println() } @@ -539,6 +633,12 @@ func printStatus(rep account.Report) { fmt.Println(c(fullDiskAccessHint(), dim)) fmt.Println() } + for _, skipped := range rep.Skipped { + fmt.Println(c(" ! "+skipped, yel)) + } + if len(rep.Skipped) > 0 { + fmt.Println() + } switch { case rep.Risk(): @@ -550,6 +650,44 @@ func printStatus(rep account.Report) { } } +func printGitHubCopilot(settings *githubprivacy.Settings) { + if settings == nil { + field("Model training", "UNKNOWN", yel, "account setting could not be read") + field("Cloud agent repos", "UNKNOWN", yel, "repository scope could not be read") + field("Partner agents", "UNKNOWN", yel, "agent settings could not be read") + return + } + if settings.ModelTraining == nil { + field("Model training", "UNKNOWN", yel, "account setting could not be read") + } else { + flagRow("Model training", settings.ModelTraining, "inputs, outputs, and context used for training") + } + switch settings.CloudAgentRepositories { + case "none": + field("Cloud agent repos", "NONE", grn, "") + case "selected": + field("Cloud agent repos", fmt.Sprintf("SELECTED (%d)", settings.SelectedRepositories), yel, + "review repository selection") + case "all": + field("Cloud agent repos", "ALL REPOSITORIES", yel, "review account-wide access") + default: + field("Cloud agent repos", "UNKNOWN", yel, "repository scope could not be read") + } + if settings.PartnerAgents == nil { + field("Partner agents", "UNKNOWN", yel, "agent settings could not be read") + return + } + names := make([]string, 0, len(settings.PartnerAgents)) + for name := range settings.PartnerAgents { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + flagRow(capitalize(name)+" agent", settings.PartnerAgents[name], + "enabled wherever coding agents are allowed") + } +} + func sharedNote(count int) string { if count > 0 { return "publicly accessible links found" diff --git a/main_test.go b/main_test.go index d0a3f59..254c6ad 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,8 @@ package main import ( + "bytes" + "strings" "testing" "github.com/circlesac/nosnitch-cli/internal/account" @@ -18,3 +20,27 @@ func TestStatusCodeIsIncompleteWhenCodexTrainingIsUnknown(t *testing.T) { t.Fatalf("statusCode() = %d, want 2", got) } } + +func TestConfirmationDefaultsToNo(t *testing.T) { + for _, input := range []string{"\n", "n\n", "no\n"} { + var output bytes.Buffer + if confirmWith("Change setting?", strings.NewReader(input), &output) { + t.Fatalf("confirmWith(%q) = true", input) + } + if !strings.Contains(output.String(), "Cancelled.") { + t.Fatalf("confirmWith(%q) output = %q", input, output.String()) + } + } +} + +func TestConfirmationAcceptsYes(t *testing.T) { + for _, input := range []string{"y\n", "YES\n"} { + var output bytes.Buffer + if !confirmWith("Change setting?", strings.NewReader(input), &output) { + t.Fatalf("confirmWith(%q) = false", input) + } + if strings.Contains(output.String(), "Cancelled.") { + t.Fatalf("confirmWith(%q) output = %q", input, output.String()) + } + } +} From 34b40d177d5cc54cdb77875973f9d20a2962c52d Mon Sep 17 00:00:00 2001 From: YG Park Date: Thu, 30 Jul 2026 17:52:34 +0900 Subject: [PATCH 2/3] fix: verify GitHub training state after update (#8) --- internal/github/github.go | 19 ++++++++++++++----- internal/github/github_test.go | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/internal/github/github.go b/internal/github/github.go index 0455939..307c85b 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -160,20 +160,29 @@ func offWith(client doer, base string, jar map[string]string) OffResult { "authenticity_token": {features.AuthenticityToken}, "telemetry": {"disabled"}, } - status, _, err = postForm(client, strings.TrimRight(base, "/")+features.FormAction, base+featuresPath, cookie, form) + updateStatus, _, err := postForm(client, strings.TrimRight(base, "/")+features.FormAction, base+featuresPath, cookie, form) if err != nil { return OffResult{Login: features.Login, Reason: "Copilot model-training update failed: " + err.Error()} } - if status < 200 || status >= 400 { - return OffResult{Login: features.Login, Reason: "Copilot model-training update failed (HTTP " + strconv.Itoa(status) + ")"} - } status, body, err = get(client, base+featuresPath, cookie) if err != nil || status != http.StatusOK { + if updateStatus < 200 || updateStatus >= 400 { + return OffResult{Login: features.Login, Reason: "Copilot model-training update failed (HTTP " + strconv.Itoa(updateStatus) + "); verification failed"} + } return OffResult{Login: features.Login, Reason: "Copilot model-training verification failed"} } verified, err := parseFeatures(body) - if err != nil || verified.ModelTraining == nil || *verified.ModelTraining { + if err != nil || verified.ModelTraining == nil { + if updateStatus < 200 || updateStatus >= 400 { + return OffResult{Login: features.Login, Reason: "Copilot model-training update failed (HTTP " + strconv.Itoa(updateStatus) + "); verification failed"} + } + return OffResult{Login: features.Login, Reason: "Copilot model-training verification failed"} + } + if *verified.ModelTraining { + if updateStatus < 200 || updateStatus >= 400 { + return OffResult{Login: features.Login, Reason: "Copilot model-training update failed (HTTP " + strconv.Itoa(updateStatus) + ")"} + } return OffResult{Login: features.Login, Reason: "Copilot model-training preference is still enabled"} } return OffResult{OK: true, Login: features.Login, Changed: true} diff --git a/internal/github/github_test.go b/internal/github/github_test.go index b1e1a32..173c4dd 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -113,6 +113,29 @@ func TestOffWithChangesOnlyTrainingAndVerifies(t *testing.T) { } } +func TestOffWithAcceptsVerifiedChangeAfterErrorStatus(t *testing.T) { + training := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + training = false + w.WriteHeader(http.StatusNotAcceptable) + return + } + value := "disabled" + if training { + value = "enabled" + } + fmt.Fprintf(w, ` +
`, value) + })) + defer server.Close() + + result := offWith(server.Client(), server.URL, map[string]string{"session": "secret"}) + if !result.OK || !result.Changed || result.Login != "octocat" { + t.Fatalf("offWith() = %#v", result) + } +} + func TestOffWithRejectsUnexpectedFormAction(t *testing.T) { var posted bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 6b408342bebed1f861321a0c30b0291e3a8ba19d Mon Sep 17 00:00:00 2001 From: YG Park Date: Thu, 30 Jul 2026 18:13:43 +0900 Subject: [PATCH 3/3] fix: reject incomplete GitHub privacy results (#8) --- internal/account/account.go | 3 +- internal/account/account_test.go | 9 ++++- internal/cookies/cookies.go | 4 +- internal/cookies/cookies_test.go | 11 ++++++ internal/cookies/safari.go | 6 ++- internal/github/github.go | 45 ++++++++++++---------- internal/github/github_test.go | 65 +++++++++++++++++++++++++++++--- main.go | 26 ++++++++----- 8 files changed, 128 insertions(+), 41 deletions(-) diff --git a/internal/account/account.go b/internal/account/account.go index 7a17c26..e34f39b 100644 --- a/internal/account/account.go +++ b/internal/account/account.go @@ -45,7 +45,8 @@ func (a *Account) Risk() bool { func (a *Account) GitHubIncomplete() bool { return a.Provider == "github" && (a.Plan == "" || a.GitHubCopilot == nil || a.GitHubCopilot.ModelTraining == nil || a.GitHubCopilot.CloudAgentRepositories == "" || - a.GitHubCopilot.PartnerAgents == nil) + a.GitHubCopilot.PartnerAgents == nil || a.GitHubCopilot.PartnerAgents["claude"] == nil || + a.GitHubCopilot.PartnerAgents["codex"] == nil) } // CodexTrainingUnknown reports whether a Codex CLI account was found without diff --git a/internal/account/account_test.go b/internal/account/account_test.go index b65c2d7..02ee7ec 100644 --- a/internal/account/account_test.go +++ b/internal/account/account_test.go @@ -59,6 +59,11 @@ func TestGitHubAccountRiskAndIncomplete(t *testing.T) { if !clean.GitHubIncomplete() { t.Fatal("missing GitHub model-training setting was not incomplete") } + clean.GitHubCopilot.ModelTraining = &off + delete(clean.GitHubCopilot.PartnerAgents, "codex") + if !clean.GitHubIncomplete() { + t.Fatal("missing Codex partner-agent setting was not incomplete") + } } func TestGitHubBrowserSessionsMergeByLogin(t *testing.T) { @@ -74,7 +79,7 @@ func TestGitHubBrowserSessionsMergeByLogin(t *testing.T) { }, } mergeGitHub(index.get("github", result.Login), result, "Chrome") - mergeGitHub(index.get("github", result.Login), result, "Safari") + mergeGitHub(index.get("github", result.Login), githubprivacy.Result{OK: true, Login: "octocat"}, "Safari") accounts := index.accounts() if len(accounts) != 1 { @@ -100,7 +105,7 @@ func TestSkippedSessionMakesReportIndeterminate(t *testing.T) { Provider: "github", Login: "octocat", Plan: "Copilot Pro", GitHubCopilot: &githubprivacy.Settings{ ModelTraining: &off, CloudAgentRepositories: "none", - PartnerAgents: map[string]*bool{}, + PartnerAgents: map[string]*bool{"claude": &off, "codex": &off}, }, }}, Skipped: []string{"Chrome (GitHub): expired session"}, diff --git a/internal/cookies/cookies.go b/internal/cookies/cookies.go index a86f8ae..9b7e108 100644 --- a/internal/cookies/cookies.go +++ b/internal/cookies/cookies.go @@ -89,7 +89,7 @@ func chromiumCookies(b Browser, hostLike string) (map[string]string, error) { // Avoid a credential-store lookup when the browser has no matching session. var n int if err := db.QueryRow( - "SELECT COUNT(*) FROM cookies WHERE host_key LIKE ?", "%"+hostLike).Scan(&n); err != nil || n == 0 { + "SELECT COUNT(*) FROM cookies WHERE host_key = ? OR host_key = ?", hostLike, "."+hostLike).Scan(&n); err != nil || n == 0 { return nil, nil } @@ -99,7 +99,7 @@ func chromiumCookies(b Browser, hostLike string) (map[string]string, error) { cookieDBVersion, _ = strconv.Atoi(version) } rows, err := db.Query( - "SELECT host_key, name, value, encrypted_value FROM cookies WHERE host_key LIKE ?", "%"+hostLike) + "SELECT host_key, name, value, encrypted_value FROM cookies WHERE host_key = ? OR host_key = ?", hostLike, "."+hostLike) if err != nil { return nil, fmt.Errorf("query cookies: %w", err) } diff --git a/internal/cookies/cookies_test.go b/internal/cookies/cookies_test.go index 8e0512e..f7dddb6 100644 --- a/internal/cookies/cookies_test.go +++ b/internal/cookies/cookies_test.go @@ -28,6 +28,17 @@ func TestDecryptChromiumRejectsWrongHostDigest(t *testing.T) { } } +func TestCookieDomainMatches(t *testing.T) { + for domain, want := range map[string]bool{ + "github.com": true, ".github.com": true, "gist.github.com": false, + "evilgithub.com": false, ".evilgithub.com": false, + } { + if got := cookieDomainMatches(domain, "github.com"); got != want { + t.Fatalf("cookieDomainMatches(%q) = %v, want %v", domain, got, want) + } + } +} + func encryptCookie(scheme string, key, plaintext []byte) []byte { padding := aes.BlockSize - len(plaintext)%aes.BlockSize plaintext = append(plaintext, bytes.Repeat([]byte{byte(padding)}, padding)...) diff --git a/internal/cookies/safari.go b/internal/cookies/safari.go index eaf5e42..a830b8b 100644 --- a/internal/cookies/safari.go +++ b/internal/cookies/safari.go @@ -21,7 +21,7 @@ func safariCookies(path, hostLike string) (map[string]string, error) { } jar := map[string]string{} for _, ck := range parseBinaryCookies(data) { - if strings.Contains(ck.domain, hostLike) { + if cookieDomainMatches(ck.domain, hostLike) { jar[ck.name] = ck.value } } @@ -31,6 +31,10 @@ func safariCookies(path, hostLike string) (map[string]string, error) { return jar, nil } +func cookieDomainMatches(domain, host string) bool { + return domain == host || domain == "."+host +} + type bcCookie struct{ domain, name, value string } // parseBinaryCookies decodes Apple's Cookies.binarycookies format. diff --git a/internal/github/github.go b/internal/github/github.go index 307c85b..83dd1fb 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -42,7 +42,7 @@ type Result struct { func (r Result) Complete() bool { return r.OK && r.License != "" && r.Settings.ModelTraining != nil && - r.Settings.CloudAgentRepositories != "" && r.Settings.PartnerAgents != nil + r.Settings.CloudAgentRepositories != "" && completePartnerAgents(r.Settings.PartnerAgents) } func (r Result) Risk() bool { @@ -117,12 +117,14 @@ func checkWith(client doer, base string, jar map[string]string) Result { reasons = append(reasons, "Copilot cloud-agent settings read failed: "+err.Error()) } else if status != http.StatusOK { reasons = append(reasons, "Copilot cloud-agent settings read failed (HTTP "+strconv.Itoa(status)+")") - } else if coding, parseErr := parseCodingAgent(body); parseErr != nil { - reasons = append(reasons, parseErr.Error()) } else { + coding, parseErr := parseCodingAgent(body) result.Settings.CloudAgentRepositories = coding.RepositoryScope result.Settings.SelectedRepositories = coding.SelectedCount result.Settings.PartnerAgents = coding.PartnerAgents + if parseErr != nil { + reasons = append(reasons, parseErr.Error()) + } } result.Reason = strings.Join(reasons, "; ") return result @@ -206,12 +208,15 @@ func parseFeatures(body []byte) (featuresPage, error) { page.License = "Copilot " + match[1] } } - if node.Type == html.ElementNode && node.Data == "form" { + if node.Type == html.ElementNode && node.Data == "form" && + attr(node, "action") == "/settings/copilot" && page.AuthenticityToken == "" { if telemetry := descendantInput(node, "telemetry"); telemetry != nil { - page.ModelTraining = settingBool(attr(telemetry, "value")) - page.FormAction = attr(node, "action") - if token := descendantInput(node, "authenticity_token"); token != nil { - page.AuthenticityToken = attr(token, "value") + if setting := settingBool(attr(telemetry, "value")); setting != nil { + page.ModelTraining = setting + page.FormAction = attr(node, "action") + if token := descendantInput(node, "authenticity_token"); token != nil { + page.AuthenticityToken = attr(token, "value") + } } } } @@ -233,7 +238,6 @@ func parseCodingAgent(body []byte) (codingAgentPage, error) { } var page codingAgentPage foundScope := false - foundAgents := false var walk func(*html.Node) walk = func(node *html.Node) { if node.Type == html.ElementNode && node.Data == "script" && @@ -243,12 +247,10 @@ func parseCodingAgent(body []byte) (codingAgentPage, error) { Mode string `json:"mode"` Selection []json.RawMessage `json:"selection"` Agents []struct { - Name string `json:"name"` - DisplayName string `json:"displayName"` - Enabled bool `json:"enabled"` + Name string `json:"name"` + Enabled bool `json:"enabled"` } `json:"agents"` - ModeChangedCallbackPath string `json:"modeChangedCallbackPath"` - ThirdPartyAgentEnablementPath string `json:"third_party_agent_enablement_callback_path"` + ModeChangedCallbackPath string `json:"modeChangedCallbackPath"` } `json:"props"` } if json.Unmarshal([]byte(node.FirstChild.Data), &payload) == nil { @@ -257,14 +259,13 @@ func parseCodingAgent(body []byte) (codingAgentPage, error) { page.SelectedCount = len(payload.Props.Selection) foundScope = true } - if payload.Props.Agents != nil || payload.Props.ThirdPartyAgentEnablementPath != "" { - foundAgents = true + if payload.Props.Agents != nil { if page.PartnerAgents == nil { page.PartnerAgents = map[string]*bool{} } } for _, agent := range payload.Props.Agents { - name := agentName(agent.Name, agent.DisplayName) + name := agentName(agent.Name) if name == "" { continue } @@ -281,7 +282,7 @@ func parseCodingAgent(body []byte) (codingAgentPage, error) { if !foundScope || page.RepositoryScope == "" { return page, fmt.Errorf("Copilot cloud-agent settings could not be read") } - if !foundAgents { + if !completePartnerAgents(page.PartnerAgents) { return page, fmt.Errorf("Copilot partner-agent settings could not be read") } return page, nil @@ -300,14 +301,18 @@ func repositoryScope(mode string) string { } } -func agentName(name, displayName string) string { +func agentName(name string) string { switch name { case "anthropic-code-agent": return "claude" case "openai-code-agent": return "codex" } - return strings.ToLower(strings.TrimSpace(displayName)) + return "" +} + +func completePartnerAgents(agents map[string]*bool) bool { + return agents != nil && agents["claude"] != nil && agents["codex"] != nil } func settingBool(value string) *bool { diff --git a/internal/github/github_test.go b/internal/github/github_test.go index 173c4dd..18f290d 100644 --- a/internal/github/github_test.go +++ b/internal/github/github_test.go @@ -18,6 +18,10 @@ func TestParseFeatures(t *testing.T) { + +
+ +
`)) if err != nil { t.Fatal(err) @@ -33,6 +37,28 @@ func TestParseFeatures(t *testing.T) { } } +func TestParseCodingAgentRequiresExpectedAgents(t *testing.T) { + tests := []struct { + name string + agents []map[string]any + }{ + {"empty agents", []map[string]any{}}, + {"unknown agent", []map[string]any{{"name": "unknown-agent", "displayName": "Other", "enabled": true}}}, + {"only Claude", []map[string]any{{"name": "anthropic-code-agent", "displayName": "Claude", "enabled": false}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + page, err := parseCodingAgent([]byte(codingAgentHTMLWithAgents("all_repos", 0, tt.agents))) + if err == nil || !strings.Contains(err.Error(), "partner-agent") { + t.Fatalf("parseCodingAgent() = %#v, %v", page, err) + } + if page.RepositoryScope != "all" { + t.Fatalf("partial repository scope = %q", page.RepositoryScope) + } + }) + } +} + func TestParseCodingAgent(t *testing.T) { body := codingAgentHTML("all_repos", 0, true, false) page, err := parseCodingAgent([]byte(body)) @@ -76,6 +102,31 @@ func TestCheckWithReportsIncompleteSettings(t *testing.T) { } } +func TestCheckWithPreservesPartialCodingAgentSettings(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case featuresPath: + fmt.Fprint(w, ` +

GitHub Copilot Pro is active for your account

+
`) + case codingAgentPath: + fmt.Fprint(w, codingAgentHTMLWithAgents("all_repos", 0, []map[string]any{ + {"name": "anthropic-code-agent", "displayName": "Claude", "enabled": false}, + })) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + result := checkWith(server.Client(), server.URL, map[string]string{"session": "secret"}) + if !result.OK || result.Complete() || result.Settings.CloudAgentRepositories != "all" || + result.Settings.PartnerAgents["claude"] == nil || result.Settings.PartnerAgents["codex"] != nil || + !strings.Contains(result.Reason, "partner-agent") { + t.Fatalf("checkWith() = %#v", result) + } +} + func TestOffWithChangesOnlyTrainingAndVerifies(t *testing.T) { training := true var posted bool @@ -148,7 +199,7 @@ func TestOffWithRejectsUnexpectedFormAction(t *testing.T) { defer server.Close() result := offWith(server.Client(), server.URL, map[string]string{"session": "secret"}) - if result.OK || posted || !strings.Contains(result.Reason, "update form") { + if result.OK || posted || !strings.Contains(result.Reason, "could not be read") { t.Fatalf("offWith() = %#v, posted=%v", result, posted) } } @@ -164,6 +215,13 @@ func TestRepositoryScope(t *testing.T) { } func codingAgentHTML(mode string, selected int, claude, codex bool) string { + return codingAgentHTMLWithAgents(mode, selected, []map[string]any{ + {"name": "anthropic-code-agent", "displayName": "Claude", "enabled": claude}, + {"name": "openai-code-agent", "displayName": "Codex", "enabled": codex}, + }) +} + +func codingAgentHTMLWithAgents(mode string, selected int, agents []map[string]any) string { selection := make([]map[string]any, selected) repositoryPayload := map[string]any{"props": map[string]any{ "mode": mode, @@ -172,10 +230,7 @@ func codingAgentHTML(mode string, selected int, claude, codex bool) string { }} agentPayload := map[string]any{"props": map[string]any{ "third_party_agent_enablement_callback_path": "/settings/copilot/coding_agent/toggle_third_party_agents_enablement", - "agents": []map[string]any{ - {"name": "anthropic-code-agent", "displayName": "Claude", "enabled": claude}, - {"name": "openai-code-agent", "displayName": "Codex", "enabled": codex}, - }, + "agents": agents, }} repositoryData, _ := json.Marshal(repositoryPayload) agentData, _ := json.Marshal(agentPayload) diff --git a/main.go b/main.go index cfb16fe..82d03bc 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,6 @@ import ( "fmt" "io" "os" - "sort" "strings" "github.com/circlesac/nosnitch-cli/internal/account" @@ -439,7 +438,12 @@ func turnOffGitHub(retryCommand string) offOutcome { fdaBlocked = true continue } - if err != nil || jar == nil { + if err != nil { + outcome.indeterminate = true + fmt.Println(c(" ! "+browser.Name+" (GitHub): session could not be read", yel)) + continue + } + if jar == nil { continue } checked := githubprivacy.CheckWith(jar) @@ -448,6 +452,10 @@ func turnOffGitHub(retryCommand string) offOutcome { fmt.Println(c(" ! "+browser.Name+" (GitHub): "+checked.Reason, yel)) continue } + if checked.Reason != "" { + outcome.indeterminate = true + fmt.Println(c(" ! "+browser.Name+" (GitHub): "+checked.Reason, yel)) + } if seen[checked.Login] { continue } @@ -677,14 +685,12 @@ func printGitHubCopilot(settings *githubprivacy.Settings) { field("Partner agents", "UNKNOWN", yel, "agent settings could not be read") return } - names := make([]string, 0, len(settings.PartnerAgents)) - for name := range settings.PartnerAgents { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - flagRow(capitalize(name)+" agent", settings.PartnerAgents[name], - "enabled wherever coding agents are allowed") + for _, name := range []string{"claude", "codex"} { + if settings.PartnerAgents[name] == nil { + field(capitalize(name)+" agent", "UNKNOWN", yel, "agent setting could not be read") + continue + } + flagRow(capitalize(name)+" agent", settings.PartnerAgents[name], "enabled wherever coding agents are allowed") } }