Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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`.
155 changes: 118 additions & 37 deletions internal/account/account.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -10,18 +10,21 @@ 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"`
Training map[string]*bool `json:"training,omitempty"` // feature key -> value
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 {
Expand All @@ -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)
}
Comment thread
ygpark80 marked this conversation as resolved.

// CodexTrainingUnknown reports whether a Codex CLI account was found without
// successfully reading its ChatGPT account-level Codex training setting.
func (a *Account) CodexTrainingUnknown() bool {
Expand Down Expand Up @@ -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
Expand All @@ -77,38 +93,63 @@ 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
a.APIDataSharing = &v
}

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
Expand All @@ -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{}
Expand All @@ -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 {
Expand Down
Loading
Loading