Skip to content
Open
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
55 changes: 55 additions & 0 deletions internal/clauderig/account/identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package account

import (
"os"
"path/filepath"
"testing"
)

// LiveIdentity must return the three identity fields and nothing else — the
// same block holds the plan, the rate-limit tier and the org name, none of
// which are safe to write into a synced repo.
func TestIdentityFromFile_ReadsOnlyIdentity(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, ".claude.json")
if err := os.WriteFile(p, []byte(`{
"userID": "deadbeef",
"oauthAccount": {
"accountUuid": "456fc32e-7579-49c7-bb2a-099657892c6a",
"organizationUuid": "f1eab509-9590-47cf-a4e8-33e5f45a5747",
"emailAddress": "john@example.com",
"seatTier": "max",
"organizationName": "Example Org",
"billingType": "stripe_subscription"
},
"mcpServers": {"ctx": {"headers": {"API_KEY": "super-secret-value"}}}
}`), 0o600); err != nil {
t.Fatal(err)
}

a, o, e, err := identityFromFile(p)
if err != nil {
t.Fatal(err)
}
if a != "456fc32e-7579-49c7-bb2a-099657892c6a" {
t.Errorf("accountUUID = %q", a)
}
if o != "f1eab509-9590-47cf-a4e8-33e5f45a5747" {
t.Errorf("orgUUID = %q", o)
}
if e != "john@example.com" {
t.Errorf("email = %q", e)
}
}

// A machine that has never logged in has no identity — that is a normal state,
// not an error, and must not fail a sync.
func TestIdentityFromFile_AbsentFileIsNotAnError(t *testing.T) {
a, o, e, err := identityFromFile(filepath.Join(t.TempDir(), "nope.json"))
if err != nil {
t.Fatalf("absent file should not error: %v", err)
}
if a != "" || o != "" || e != "" {
t.Errorf("want all empty, got %q %q %q", a, o, e)
}
}
33 changes: 32 additions & 1 deletion internal/clauderig/account/livestore_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"fmt"
"os/exec"
"os/user"
"regexp"
"strings"
)

Expand Down Expand Up @@ -46,7 +47,7 @@ func readKeychain(service string) (raw []byte, found bool, err error) {
out, err := exec.Command(securityBin, "find-generic-password",
"-a", accountName(), "-w", "-s", service).Output()
if err == nil {
return bytes.TrimRight(out, "\n"), true, nil
return decodeSecurityOutput(bytes.TrimRight(out, "\n")), true, nil
}
var ee *exec.ExitError
if errors.As(err, &ee) && ee.ExitCode() == errSecItemNotFound {
Expand All @@ -55,6 +56,36 @@ func readKeychain(service string) (raw []byte, found bool, err error) {
return nil, false, fmt.Errorf("read keychain: %w", err)
}

// hexOnly matches security(1)'s hex fallback output — nothing but hex digits.
var hexOnly = regexp.MustCompile(`^[0-9a-fA-F]+$`)

// decodeSecurityOutput undoes security(1)'s hex fallback.
//
// `find-generic-password -w` normally prints the password as text, but when the
// stored blob holds a byte it will not print inline — a newline is enough — it
// prints the WHOLE blob as hex digits instead, with no flag to say which form
// you got. Claude Code writes the credential as pretty-printed JSON, so the live
// item is multi-line and comes back hex; clauderig writes compact JSON, so its
// own per-profile items come back as text. Handing the hex form straight to a
// JSON parser fails on "{" (0x7b) read as the number 7 followed by 'b' — which
// is exactly the "credential unreadable" a machine sees once Claude Code has
// rewritten the credential.
//
// The two forms cannot be confused: a credential is JSON and always starts with
// '{', which is not a hex digit, so all-hex output is unambiguously the encoded
// form. Anything that does not decode is returned untouched — a reader that
// guessed wrong must not corrupt a blob that was fine.
func decodeSecurityOutput(out []byte) []byte {
if len(out) == 0 || len(out)%2 != 0 || !hexOnly.Match(out) {
return out
}
dec := make([]byte, hex.DecodedLen(len(out)))
if _, err := hex.Decode(dec, out); err != nil {
return out
}
return dec
}

// writeKeychain creates or updates one generic-password service. The secret is
// passed as hex via `-X` (no escaping needed) through `security -i` stdin, so
// it never appears in process argv. Only a payload large enough to overflow
Expand Down
50 changes: 50 additions & 0 deletions internal/clauderig/account/livestore_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package account

import (
"encoding/hex"
"encoding/json"
"testing"
)

Expand Down Expand Up @@ -43,3 +44,52 @@ func TestSessionKeychainService(t *testing.T) {
t.Fatalf("sessionKeychainService = %q, want Claude Code-credentials-c890e741", got)
}
}

// security(1) prints the whole blob as hex whenever it holds a byte it won't
// print inline — a newline is enough. Claude Code writes the credential as
// pretty-printed JSON, so the live item comes back hex and the JSON parser
// failed on '{' (0x7b) read as 7 then 'b': "invalid character 'b' after
// top-level value". Observed live on 2026-08-25, Claude Code 2.1.237.
func TestDecodeSecurityOutput(t *testing.T) {
pretty := "{\n \"claudeAiOauth\": {\n \"accessToken\": \"tok\"\n },\n \"organizationUuid\": \"f1eab509\"\n}"
compact := `{"claudeAiOauth":{"accessToken":"tok"},"organizationUuid":"f1eab509"}`

cases := []struct {
name string
in string
want string
}{
{"hex fallback decodes", hex.EncodeToString([]byte(pretty)), pretty},
{"compact json passes through", compact, compact},
{"pretty json passes through", pretty, pretty},
// Not the hex form: leave it exactly as it came, never half-decode it.
{"odd length is not hex", "abc", "abc"},
{"non-hex letters pass through", "zzzz", "zzzz"},
{"empty passes through", "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := string(decodeSecurityOutput([]byte(c.in))); got != c.want {
t.Errorf("decodeSecurityOutput() = %q, want %q", got, c.want)
}
})
}
}

// The end-to-end shape of the bug: what security(1) hands back must parse.
func TestDecodeSecurityOutput_HexBlobParsesAsCredential(t *testing.T) {
pretty := "{\n \"claudeAiOauth\": {\n \"accessToken\": \"tok\",\n \"refreshToken\": \"ref\"\n },\n \"organizationUuid\": \"f1eab509\"\n}"
raw := decodeSecurityOutput([]byte(hex.EncodeToString([]byte(pretty))))
var v struct {
ClaudeAiOauth struct {
AccessToken string `json:"accessToken"`
} `json:"claudeAiOauth"`
OrganizationUUID string `json:"organizationUuid"`
}
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("decoded credential should parse: %v", err)
}
if v.ClaudeAiOauth.AccessToken != "tok" || v.OrganizationUUID != "f1eab509" {
t.Errorf("round trip lost fields: %+v", v)
}
}
30 changes: 30 additions & 0 deletions internal/clauderig/account/oauthaccount.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,33 @@ func resolveLinkTarget(path string) string {
}
return path // cycle: write where we ended up rather than loop
}

// GlobalConfigPath is ~/.claude.json — where Claude Code keeps the oauthAccount
// block. Exported for callers that must act on the file itself (the pre-restore
// backup), not just its contents.
func GlobalConfigPath() (string, error) { return globalConfigPath() }

// LiveIdentity reports which account ~/.claude.json currently names: the account
// uuid, its organization uuid, and the email address.
//
// Identity ONLY. Never the credential, the plan, the rate-limit tier, or
// anything else in the block — this is the slice that is safe to write into the
// synced repo, where it becomes the sole record of which account a machine's
// sessions were captured under. All three come back empty when Claude Code has
// never logged in here (file or key absent), which is not an error.
func LiveIdentity() (accountUUID, orgUUID, email string, err error) {
p, err := globalConfigPath()
if err != nil {
return "", "", "", err
}
return identityFromFile(p)
}

func identityFromFile(path string) (accountUUID, orgUUID, email string, err error) {
raw, err := readOAuthAccountFrom(path)
if err != nil || len(raw) == 0 {
return "", "", "", err
}
m := parseOAuthMeta(raw)
return m.AccountUUID, m.OrganizationUUID, m.EmailAddress, nil
}
7 changes: 7 additions & 0 deletions internal/clauderig/allowlist/allowlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ func Walk(root string, l List) ([]string, []Link, error) {
l.decide(rel) == Include && l.decide(target) == Include {
links = append(links, Link{Rel: rel, Target: target})
}
// Either way this is a directory, so it is never a file to sync.
// This return is load-bearing, not a formality: without it the
// link path falls through to Match and is offered as a regular
// file, which reading can only ever fail on (EISDIR). Staging
// trees written before this guard existed still carry the 0-byte
// placeholders that came of exactly that, which is what
// reconcileStagedRoot retires.
return nil
}
}
Expand Down
165 changes: 165 additions & 0 deletions internal/clauderig/commands/backup_links_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package commands

import (
"os"
"path/filepath"
"runtime"
"testing"
)

// ~/.claude is full of shared-memory symlinks (a worktree slug pointing memory/
// at its main project). The pre-restore backup must recreate them as links —
// following one either duplicates the whole linked tree into the .bak or fails
// outright with EISDIR when it points at a directory.
func TestCopyTree_PreservesSymlinksInsteadOfFollowingThem(t *testing.T) {
src := t.TempDir()
mem := filepath.Join(src, "projects", "-main", "memory")
if err := os.MkdirAll(mem, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mem, "MEMORY.md"), []byte("facts"), 0o644); err != nil {
t.Fatal(err)
}
wt := filepath.Join(src, "projects", "-wt")
if err := os.MkdirAll(wt, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(mem, filepath.Join(wt, "memory")); err != nil {
t.Skipf("symlink unsupported: %v", err)
}

dst := filepath.Join(t.TempDir(), "claude.bak")
if err := copyTree(src, dst); err != nil {
t.Fatalf("backup failed on a shared-memory link: %v", err)
}

backedUp := filepath.Join(dst, "projects", "-wt", "memory")
info, err := os.Lstat(backedUp)
if err != nil {
t.Fatalf("link missing from backup: %v", err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("link was followed into the backup, not recreated as a link")
}
if got, _ := os.Readlink(backedUp); got != mem {
t.Errorf("link text = %q, want %q", got, mem)
}
// the real directory still travels under its own path
if b, _ := os.ReadFile(filepath.Join(dst, "projects", "-main", "memory", "MEMORY.md")); string(b) != "facts" {
t.Errorf("memory content = %q, want %q", b, "facts")
}
}

// ~/.claude holds 0600 transcripts, and the identity file beside it is 0600
// too. os.Create would widen them to 0644 in the .bak, so the act of protecting
// the data would be what exposed it.
func TestCopyOne_PreservesSourcePermissions(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "private.jsonl")
if err := os.WriteFile(src, []byte("secret transcript"), 0o600); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, "bak", "private.jsonl")
if err := copyOne(src, dst); err != nil {
t.Fatal(err)
}
si, err := os.Stat(src)
if err != nil {
t.Fatal(err)
}
fi, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
// Assert the PROPERTY — the backup carries the source's mode — rather than a
// literal 0600. Windows has no Unix permission bits: Go reports 0666 and
// Chmod only toggles read-only, so a literal would fail there for a reason
// that has nothing to do with this code.
if got := fi.Mode().Perm(); got != si.Mode().Perm() {
t.Errorf("backup mode = %04o, want %04o (source mode)", got, si.Mode().Perm())
}
// Where the bits are real, pin the case that matters: ~/.claude is full of
// 0600 transcripts, and os.Create would have widened them to 0644.
if runtime.GOOS != "windows" && fi.Mode().Perm() != 0o600 {
t.Errorf("backup mode = %04o, want 0600", fi.Mode().Perm())
}
if b, _ := os.ReadFile(dst); string(b) != "secret transcript" {
t.Errorf("content = %q", b)
}
}

// A DANGLING symlink at the backup path passes an os.Stat check as "not
// present". The copy would then follow it and write through the link — for
// ~/.claude.json that means an identity file, which can carry MCP credentials,
// landing wherever the link points.
func TestBackupPathIsFree_RejectsADanglingSymlink(t *testing.T) {
dir := t.TempDir()
elsewhere := filepath.Join(dir, "elsewhere.json")
bak := filepath.Join(dir, "claude.json.bak")
if err := os.Symlink(elsewhere, bak); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
// Stat agrees the target is absent — that is exactly the trap.
if _, err := os.Stat(bak); !os.IsNotExist(err) {
t.Fatalf("test setup: want a dangling link, Stat gave %v", err)
}
if err := backupPathIsFree(bak); err == nil {
t.Fatal("a dangling symlink must be refused, not treated as free")
}
if _, err := os.Stat(elsewhere); err == nil {
t.Error("nothing should have been written through the link")
}
}

func TestBackupPathIsFree_AllowsAnAbsentPath(t *testing.T) {
if err := backupPathIsFree(filepath.Join(t.TempDir(), "nope.bak")); err != nil {
t.Errorf("an absent backup path is free: %v", err)
}
}

// backupPathIsFree and the write are two moments. A symlink created in between
// would be followed by an O_CREATE|O_TRUNC open and its target overwritten, so
// the open itself must refuse an existing entry rather than trusting the
// earlier check.
func TestCopyOne_RefusesADestinationThatAppearedAfterTheCheck(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "identity.json")
if err := os.WriteFile(src, []byte(`{"secret":"value"}`), 0o600); err != nil {
t.Fatal(err)
}
victim := filepath.Join(dir, "victim.txt")
if err := os.WriteFile(victim, []byte("must survive"), 0o644); err != nil {
t.Fatal(err)
}

// The race: something plants a symlink at dst after the path was checked.
dst := filepath.Join(dir, "identity.json.bak")
if err := os.Symlink(victim, dst); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
if err := copyOne(src, dst); err == nil {
t.Error("copyOne must refuse a destination that already exists")
}
if b, _ := os.ReadFile(victim); string(b) != "must survive" {
t.Errorf("wrote through the planted symlink: victim = %q", b)
}
}

// A plain existing file is refused too — never silently overwritten.
func TestCopyOne_RefusesAnExistingRegularFile(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "a")
dst := filepath.Join(dir, "b")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := copyOne(src, dst); err == nil {
t.Error("want a refusal")
}
if b, _ := os.ReadFile(dst); string(b) != "old" {
t.Errorf("destination was overwritten: %q", b)
}
}
Loading