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..e34f39b 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,19 @@ 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 || a.GitHubCopilot.PartnerAgents["claude"] == nil || + a.GitHubCopilot.PartnerAgents["codex"] == 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 +82,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 +93,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 +149,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 +167,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 +185,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..02ee7ec 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,88 @@ 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") + } + 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) { + 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), githubprivacy.Result{OK: true, Login: "octocat"}, "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{"claude": &off, "codex": &off}, + }, + }}, + 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..9b7e108 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 { @@ -87,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 } @@ -97,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 new file mode 100644 index 0000000..83dd1fb --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,397 @@ +// 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 != "" && completePartnerAgents(r.Settings.PartnerAgents) +} + +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 { + 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 +} + +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"}, + } + 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()} + } + + 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 { + 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} +} + +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" && + attr(node, "action") == "/settings/copilot" && page.AuthenticityToken == "" { + if telemetry := descendantInput(node, "telemetry"); telemetry != nil { + 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") + } + } + } + } + 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 + 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"` + Enabled bool `json:"enabled"` + } `json:"agents"` + ModeChangedCallbackPath string `json:"modeChangedCallbackPath"` + } `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 { + if page.PartnerAgents == nil { + page.PartnerAgents = map[string]*bool{} + } + } + for _, agent := range payload.Props.Agents { + name := agentName(agent.Name) + 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 !completePartnerAgents(page.PartnerAgents) { + 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 string) string { + switch name { + case "anthropic-code-agent": + return "claude" + case "openai-code-agent": + return "codex" + } + return "" +} + +func completePartnerAgents(agents map[string]*bool) bool { + return agents != nil && agents["claude"] != nil && agents["codex"] != nil +} + +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..18f290d --- /dev/null +++ b/internal/github/github_test.go @@ -0,0 +1,239 @@ +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 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)) + 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 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 + 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 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) { + 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, "could not be read") { + 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 { + 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, + "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": agents, + }} + repositoryData, _ := json.Marshal(repositoryPayload) + agentData, _ := json.Marshal(agentPayload) + return `` + + `` +} diff --git a/main.go b/main.go index f5b92ef..82d03bc 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,9 @@ package main import ( "bufio" "encoding/json" + "errors" "fmt" + "io" "os" "strings" @@ -17,6 +19,7 @@ import ( "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 +44,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 +89,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 +105,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 +121,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 +135,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 +266,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 +305,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 +374,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 +428,60 @@ 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 { + outcome.indeterminate = true + fmt.Println(c(" ! "+browser.Name+" (GitHub): session could not be read", yel)) + continue + } + if jar == nil { + continue + } + checked := githubprivacy.CheckWith(jar) + if !checked.OK { + outcome.indeterminate = true + 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 + } + 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 +579,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 +610,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 +630,8 @@ func printStatus(rep account.Report) { fmt.Print(" ") printSharedChat(shared) } + case "github": + printGitHubCopilot(a.GitHubCopilot) } fmt.Println() } @@ -539,6 +641,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 +658,42 @@ 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 + } + 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") + } +} + 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()) + } + } +}