package main import ( "io" "os" "path/filepath" "sort" "strings" "testing" "github.com/runbear-io/beardrive/internal/config" "github.com/runbear-io/beardrive/internal/store" "github.com/runbear-io/beardrive/internal/syncer" ) // Round 11 — `bdrive scope`, the surface round 10 judged "already covered by // rounds 4/6" and skipped, and then flagged its own judgement as an // overstatement risk. // // It writes into the same SYNCED .bdriveignore that `bdrive forget` writes // into, through a different code path: a bdrive-managed block delimited by two // marker comments (cmd/bdrive/scopefile.go). Round 10 closed forget's escaping // hole by routing its argument through syncer.EscapeIgnore. scopeLines never // got that treatment, and the block's markers are themselves lines in a file // every teammate can write. // // Threat model, unchanged from round 10's forget work: .bdriveignore syncs, so // its bytes are chosen by any project member, and its rules decide what leaves // each teammate's machine. // // Helpers are prefixed sec11. // sec11Project builds an isolated BDRIVE_HOME with one enrolled project folder // and a file:// remote — the same fixture shape sec10Project uses. func sec11Project(t *testing.T) string { t.Helper() t.Setenv("BDRIVE_HOME", t.TempDir()) folder := t.TempDir() if _, err := config.SaveProject(folder, config.Project{ Volume: "notes", Remote: "file://" + t.TempDir(), }); err != nil { t.Fatal(err) } if _, _, err := config.EnrollMount(folder); err != nil { t.Fatal(err) } return folder } // sec11Scope writes a scope exactly as `bdrive init --only` and `bdrive scope // add` do: clean the names, mkdir them, render the managed block. func sec11Scope(t *testing.T, folder string, names ...string) []string { t.Helper() dirs, err := cleanScopeDirs(names) if err != nil { t.Skipf("cleanScopeDirs refused %q at the door: %v", names, err) } for _, d := range dirs { if err := os.MkdirAll(filepath.Join(folder, filepath.FromSlash(d)), 0o755); err != nil { t.Fatal(err) } } if err := writeScopeDirs(folder, dirs); err != nil { t.Fatal(err) } return dirs } // sec11Filter loads the shared rules exactly as a sync cycle does. func sec11Filter(t *testing.T, folder string) *syncer.Filter { t.Helper() f, err := syncer.LoadFilter(folder, nil) if err != nil { t.Fatal(err) } return f } // sec11Ignore returns the synced rules file's lines, markers included. func sec11Ignore(t *testing.T, folder string) []string { t.Helper() data, err := os.ReadFile(filepath.Join(folder, syncer.IgnoreFile)) if err != nil { t.Fatal(err) } var out []string for _, l := range strings.Split(string(data), "\n") { if t := strings.TrimSpace(l); t != "" { out = append(out, t) } } return out } // --------------------------------------------------------------------------- // 1. A folder name is a NAME. scopeLines renders it as a PATTERN. // --------------------------------------------------------------------------- // TestSec_Scope_AFolderNameCannotWidenTheScopeIntoAGlob // // scopeLines (scopefile.go:28) is: // // lines = append(lines, "!/"+d+"/") // // d verbatim, into a gitignore-dialect rule file. That is the identical defect // round 10 found in `bdrive forget` and fixed with syncer.EscapeIgnore — whose // own doc comment says a caller that spells the escaping itself is "a second // definition of the dialect". scope IS that second definition, and it was left // unescaped. // // The direction of the damage is the opposite of forget's, and worse. forget // writes an EXCLUSION, so an over-broad rule deletes too much. scope writes a // NEGATION (`!//`) under a blanket `/*`, so an over-broad rule INCLUDES // too much: every sibling folder the pattern happens to match starts syncing // to the hub and out to the whole team, from a command whose entire purpose is // to keep the rest of the folder off the network. `bdrive scope --explain` // exists because users are told to "verify what leaves this machine instead of // taking it on trust". // // A `*` or `?` in a directory name is legal on every unix filesystem, `scope // add` mkdirs whatever name it is handed, and `chooseScope` (init.go:609) takes // the names as free text off a survey prompt and splits them on whitespace — // no validation between the user's keystrokes and the rule. // // The delta that makes this the code's decision, not the fixture's: the folder // that WAS named syncs either way. What changes is the siblings. func TestSec_Scope_AFolderNameCannotWidenTheScopeIntoAGlob(t *testing.T) { for _, tc := range []struct { name string // the folder the user scopes bystander string // a sibling the user did NOT scope }{ {"a*", "alpha"}, {"a?c", "abc"}, } { t.Run(tc.name, func(t *testing.T) { folder := sec11Project(t) for _, d := range []string{tc.name, tc.bystander} { if err := os.MkdirAll(filepath.Join(folder, d), 0o755); err != nil { t.Fatal(err) } } sec11Scope(t, folder, tc.name) f := sec11Filter(t, folder) // Control: the named folder syncs. If this fails the fixture is // wrong, not the code. if f.Skip(tc.name + "/notes.md") { t.Fatalf("fixture: scoping %q does not even sync %q", tc.name, tc.name) } if !f.Skip(tc.bystander + "/secret.md") { t.Errorf("`bdrive scope add %q` wrote %q, which also un-ignores %s/ — "+ "a folder the user never named now syncs to the hub and to every "+ "teammate, from the command that exists to keep it off the network", tc.name, "!/"+tc.name+"/", tc.bystander) } }) } } // TestSec_Scope_AFolderNameCannotBecomeAnEscapeSequence // // The other half of the same missing escape. `\` is gitignore's escape // character (see compile() in internal/syncer/ignore.go, which consumes it and // takes the next byte literally) and it is a legal byte in a unix directory // name. // // A folder named `a\b` scoped as `!/a\b/` compiles to the pattern for `ab`. // Both ends are wrong at once and in opposite directions: // // - the folder the user asked to sync does NOT sync — silently, with the // command reporting "syncing only: ./a\b"; // - a DIFFERENT folder, `ab/`, starts syncing to the whole team. // // The second is the exfiltration; the first is why nobody notices. func TestSec_Scope_AFolderNameCannotBecomeAnEscapeSequence(t *testing.T) { folder := sec11Project(t) for _, d := range []string{`a\b`, "ab"} { if err := os.MkdirAll(filepath.Join(folder, d), 0o755); err != nil { t.Fatal(err) } } sec11Scope(t, folder, `a\b`) f := sec11Filter(t, folder) if f.Skip(`a\b/notes.md`) { t.Errorf(`scoping "a\\b" wrote the rule "!/a\\b/", which compiles to the pattern for "ab" — ` + `the folder the user named does not sync at all, and the command reported success`) } if !f.Skip("ab/secret.md") { t.Errorf(`scoping "a\\b" un-ignored the unrelated folder "ab/", which now syncs to the hub ` + `and to every teammate`) } } // --------------------------------------------------------------------------- // 2. The block's markers are lines in a file the whole team writes. // --------------------------------------------------------------------------- // TestSec_Scope_AMarkerInTheSharedRulesCannotSwallowTheRulesBelowIt // // writeScopeDirs (scopefile.go:62) rebuilds .bdriveignore by walking it and // dropping every line between scopeStart and scopeEnd. It looks for those // markers ANYWHERE in the file and treats every occurrence as authoritative. // // .bdriveignore syncs. Its bytes are chosen by any project member. A second // scopeStart line with no matching end — one line, indistinguishable from the // ordinary comment it looks like — makes writeScopeDirs treat everything from // there to EOF as block content and drop it. // // So the next teammate who runs `bdrive scope add `, `bdrive scope // rm ` or `bdrive init . --only ` silently deletes every // exclusion rule below that line from the SHARED rules file, and pushes the // deletion to everyone. The rules that vanish are the ones people write to // keep material off the hub — `secrets/`, `*.pem`, `.env`. On the next cycle // those paths sync, for the whole team, and nothing reported anything. // // Secure behavior asserted: editing the scope changes the managed block and // nothing else. Every rule the user did not write stays in the file. func TestSec_Scope_AMarkerInTheSharedRulesCannotSwallowTheRulesBelowIt(t *testing.T) { folder := sec11Project(t) sec11Scope(t, folder, "docs") // What a teammate's synced .bdriveignore looks like after they append one // comment line that happens to be the marker. The exclusions below it are // ordinary keep-this-off-the-hub rules, written where they still bite // inside the scoped folder (the block's own `/*` masks anything outside // it, so only rules that narrow WITHIN the scope are observable at all). body := strings.Join(sec11Ignore(t, folder), "\n") + "\n" + "\nnode_modules/\n" + scopeStart + "\n" + // <- the injected line, arriving over sync "secrets/\n" + "*.pem\n" path := filepath.Join(folder, syncer.IgnoreFile) if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } private := []string{"docs/secrets/keys.txt", "docs/id.pem"} before := sec11Filter(t, folder) for _, p := range private { if !before.Skip(p) { t.Fatalf("fixture: %s was not excluded before the scope edit", p) } } kept := sec11Ignore(t, folder) // One ordinary scope edit — `bdrive scope add wiki`. dirs, _, err := readScopeDirs(folder) if err != nil { t.Fatal(err) } if err := writeScopeDirs(folder, append(dirs, "wiki")); err != nil { t.Fatal(err) } now := strings.Join(sec11Ignore(t, folder), "\n") for _, line := range kept { if line == scopeStart || line == scopeEnd || line == "/*" || strings.HasPrefix(line, "!/") { continue // the managed block is what a scope edit is allowed to rewrite } if !strings.Contains(now, line) { t.Errorf("`bdrive scope add wiki` deleted the rule %q from the shared %s — "+ "one comment-shaped line in a file every teammate can write turns the next "+ "scope edit into a rule wipe\nfile now:\n%s", line, syncer.IgnoreFile, now) } } after := sec11Filter(t, folder) for _, p := range private { if !after.Skip(p) { t.Errorf("after `bdrive scope add wiki`, %s is no longer excluded — the rule that "+ "kept it off the hub was swallowed, and the next cycle pushes it to the whole "+ "team\nfile now:\n%s", p, now) } } } // TestSec_Scope_AStrayEndMarkerCannotEmptyTheScopeInForce // // readScopeDirs (scopefile.go:37) returns at the FIRST scopeEnd it sees, // whether or not a start marker opened anything. A lone end marker above the // real block therefore reports "scoped, with zero folders": the caller is told // a scope is in force and that it names nothing. // // `bdrive scope` prints that as "syncing only:" followed by an empty list — // the audit surface reporting that nothing leaves this machine while the real // block below still scopes docs/ and wiki/. And `bdrive scope add pub` then // writes a block containing only pub/, dropping docs/ and wiki/ out of the // team's scope without a word. // // Secure behavior asserted: what readScopeDirs reports is the scope that is // actually in force. func TestSec_Scope_AStrayEndMarkerCannotEmptyTheScopeInForce(t *testing.T) { folder := sec11Project(t) sec11Scope(t, folder, "docs", "wiki") path := filepath.Join(folder, syncer.IgnoreFile) body, err := os.ReadFile(path) if err != nil { t.Fatal(err) } // One line, arriving over sync, above the real block. if err := os.WriteFile(path, append([]byte(scopeEnd+"\n"), body...), 0o644); err != nil { t.Fatal(err) } // The rules in force are unchanged: docs/ and wiki/ still sync. f := sec11Filter(t, folder) for _, d := range []string{"docs", "wiki"} { if f.Skip(d + "/x.md") { t.Fatalf("fixture: %s/ stopped syncing, so there is no scope to misreport", d) } } dirs, scoped, err := readScopeDirs(folder) if err != nil { t.Fatal(err) } sort.Strings(dirs) if !scoped || len(dirs) != 2 || dirs[0] != "docs" || dirs[1] != "wiki" { t.Errorf("readScopeDirs reported scoped=%v dirs=%q while docs/ and wiki/ are the scope "+ "actually in force — `bdrive scope` prints that list as the answer to \"what leaves "+ "this machine\", and `bdrive scope add` rewrites the block from it, so the next edit "+ "silently drops every folder the report left out", scoped, dirs) } } // --------------------------------------------------------------------------- // 3. scope add creates the folders it scopes. // --------------------------------------------------------------------------- // TestSec_Scope_AddCannotCreateADirectoryOutsideTheProject // // All three scope-writing doors (`scope add`, `init --only`, `init` on a new // folder) mkdir the names they are handed. cleanScopeDirs rejects `..`, // absolute paths and control characters, so the STRING is inside the project. // MkdirAll follows symlinks, so the BYTES need not be: a symlinked directory // already sitting in the folder takes the creation wherever it points. Same // lexical-vs-on-disk gap round 4 closed in the syncer and the file:// backend, // and round 6 closed in templates.WriteTo with store.UnderRoot. // // A symlink gets into the folder the way a folder gets anything: it arrives // with the folder, or it is pulled from the hub, or an agent session made it. // // REWRITTEN BY THE CISO IN ROUND 11, with the coordinator's grant, and // disclosed in .claude/security-goal.md. The original called cleanScopeDirs, // then called os.MkdirAll ITSELF, then asserted store.UnderRoot on the result — // no production code sat between the setup and the assertion, so it measured // os.MkdirAll and filepath.EvalSymlinks and no change in this repo could move // it. It now drives mkdirScopeDirs, the single door round 11 routed all three // call sites through, which is the thing the original prose describes. Verified // to go RED when the store.UnderRoot call in mkdirScopeDirs is removed. func TestSec_Scope_AddCannotCreateADirectoryOutsideTheProject(t *testing.T) { folder := sec11Project(t) outside := t.TempDir() if err := os.Symlink(outside, filepath.Join(folder, "shared")); err != nil { t.Skipf("symlinks unavailable: %v", err) } // Control: an ordinary subfolder is still created, so a refusal below is // about the symlink and not about the door refusing everything. if err := mkdirScopeDirs(folder, []string{"docs"}); err != nil { t.Fatalf("control: a plain subfolder was refused: %v", err) } if fi, err := os.Stat(filepath.Join(folder, "docs")); err != nil || !fi.IsDir() { t.Fatalf("control: docs/ was not created: %v", err) } // The attack: the name is spelled inside the project and resolves outside. dirs, err := cleanScopeDirs([]string{"shared/loot"}) if err != nil { t.Fatalf("cleanScopeDirs refused a plain relative name: %v", err) } mkErr := mkdirScopeDirs(folder, dirs) abs := filepath.Join(folder, filepath.FromSlash(dirs[0])) landed := filepath.Join(outside, "loot") if mkErr == nil { t.Errorf("`bdrive scope add shared/loot` was accepted; %s resolves to %s — outside the "+ "project root %s. cleanScopeDirs judges the name's SPELLING; MkdirAll follows the "+ "symlink already in the folder, so a scope edit writes into a directory the project "+ "boundary does not cover", abs, landed, folder) } // The refusal has to be a refusal, not a message: nothing may exist outside. if _, err := os.Lstat(landed); err == nil { t.Errorf("the door reported %v but created %s anyway", mkErr, landed) } if store.UnderRoot(folder, abs) { t.Fatalf("fixture: %s was expected to resolve outside %s", abs, folder) } } // --------------------------------------------------------------------------- // 4. The legacy include list and the managed block, both in force. // --------------------------------------------------------------------------- // TestSec_Scope_ReportsEveryScopeMechanismInForce // // Two scope mechanisms exist: the managed .bdriveignore block, and the legacy // Include list in .bdrive/config.json ("still honored, never written"). // syncer.LoadFilter applies BOTH — Skip() is `ignored OR not-included`. // // printScope (scope.go:229) returns as soon as it finds an Include list, // before it ever looks at the block. `bdrive scope` on a project carrying both // therefore names one of the two sets of rules that decide what leaves the // machine and never mentions the other. It is the command documented as the // way to "verify what leaves this machine instead of taking it on trust". func TestSec_Scope_ReportsEveryScopeMechanismInForce(t *testing.T) { folder := sec11Project(t) sec11Scope(t, folder, "docs") proj, _, err := config.LoadProject(folder) if err != nil { t.Fatal(err) } proj.Include = []string{"wiki"} if _, err := config.SaveProject(folder, proj); err != nil { t.Fatal(err) } out := secloginCapture(t, func() { if err := printScope(folder, proj); err != nil { t.Fatal(err) } }) if !strings.Contains(out, "wiki") { t.Fatalf("fixture: the legacy include list was not reported at all\n%s", out) } if !strings.Contains(out, "docs") { t.Errorf("`bdrive scope` on a project with BOTH a legacy include list and a managed "+ "block reported only the include list — the block scoping ./docs is in force in "+ "every sync cycle and is absent from the audit output:\n%s", out) } } // --------------------------------------------------------------------------- // 5. `bdrive hooks install --agent` — the CLI layer above agenthooks.Install. // // Round 10 pinned Uninstall's refusal of an unknown agent. Install's is // unpinned, and hooksCmd is the layer neither round has driven: it turns one // flag string into the []string agenthooks.Install loops over // (strings.Split(agentsFlag, ","), hooks.go:62). // // What Install writes is not a preference file. It is an inline shell command // in ~/.claude/settings.json and friends that the platform runs on EVERY turn // of EVERY session on the machine, in every folder — the repo's own invariant // list calls it out ("the agent hook guard stays pure shell ... it runs on // every session and every tool call on the machine"). Registering one the user // did not ask for is a machine-wide change. // --------------------------------------------------------------------------- // sec11Hooks isolates $HOME and runs `bdrive hooks install` with the given // --agent value, exactly as the CLI parses it. func sec11Hooks(t *testing.T, args ...string) (string, error) { t.Helper() home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) folder := t.TempDir() c := hooksCmd() c.SetOut(io.Discard) c.SetErr(io.Discard) c.SilenceUsage, c.SilenceErrors = true, true c.SetArgs(append(append([]string{"install"}, args...), folder)) var err error secloginCapture(t, func() { err = c.Execute() }) return home, err } // sec11Registrations lists every file under home that names bdrive — i.e. // every platform config this machine will now run bdrive from. func sec11Registrations(t *testing.T, home string) []string { t.Helper() var out []string _ = filepath.Walk(home, func(p string, fi os.FileInfo, err error) error { if err != nil || fi.IsDir() { return nil } if b, err := os.ReadFile(p); err == nil && strings.Contains(string(b), "bdrive") { rel, _ := filepath.Rel(home, p) out = append(out, rel) } return nil }) sort.Strings(out) return out } // TestSec_HooksInstall_ARefusedAgentListRegistersNothing // // agenthooks.Install loops the names in order and returns on the first unknown // one — after installing every name before it, and after returning `out`, the // partial result. hooksCmd propagates the error, so the command exits non-zero // and prints a failure, while ~/.claude/settings.json has already been // rewritten with a hook that fires on every turn of every Claude session on // the machine. // // The same command tree already establishes the opposite posture one file over // — `bdrive forget` resolves every argument first, because "an argument // outside the project is an error that writes nothing at all" (forget.go:57). // // The delta that makes this the code's decision: put the bad name FIRST and // nothing is written; put it second and the machine is changed by a command // that reported failure. func TestSec_HooksInstall_ARefusedAgentListRegistersNothing(t *testing.T) { // Control: unknown name first — refused before any write. home, err := sec11Hooks(t, "--agent", "bogus,claude") if err == nil { t.Fatal("`hooks install --agent bogus,claude` was accepted") } if got := sec11Registrations(t, home); len(got) != 0 { t.Fatalf("fixture: the control case already wrote %q", got) } // Same list, order swapped. home, err = sec11Hooks(t, "--agent", "claude,bogus") if err == nil { t.Fatal("`hooks install --agent claude,bogus` was accepted") } if got := sec11Registrations(t, home); len(got) != 0 { t.Errorf("`bdrive hooks install --agent claude,bogus` failed with %q and still left %q "+ "registered under $HOME — the same list with the names in the other order writes "+ "nothing, so whether a failed command changes the machine depends on argument order. "+ "What it wrote is an inline shell command the platform runs on every turn of every "+ "session in every folder", err, got) } } // TestSec_HooksInstall_AnEmptyAgentValueRegistersNothing // // An empty --agent is not "auto": it is a user (or a script, or an agent // building the command line) naming an empty set. hooks.go:59 tests // `agentsFlag != ""`, so an empty value falls through to the same branch as // the default and agenthooks.Install turns a nil list into Detect(folder) — // every platform with a config directory in the project or in $HOME. // // So an unexpanded shell variable registers hooks machine-wide for every // platform the user has ever installed, silently and with a zero exit code. // The flag that names which platforms to touch is the only thing standing // between "one" and "all of them". func TestSec_HooksInstall_AnEmptyAgentValueRegistersNothing(t *testing.T) { // A machine that has Claude Code and Gemini CLI set up: the ordinary case. home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) for _, d := range []string{".claude", ".gemini"} { if err := os.MkdirAll(filepath.Join(home, d), 0o755); err != nil { t.Fatal(err) } } folder := t.TempDir() c := hooksCmd() c.SetOut(io.Discard) c.SetErr(io.Discard) c.SilenceUsage, c.SilenceErrors = true, true c.SetArgs([]string{"install", "--agent", "", folder}) var err error secloginCapture(t, func() { err = c.Execute() }) got := sec11Registrations(t, home) if err == nil && len(got) > 0 { t.Errorf("`bdrive hooks install --agent ''` succeeded and registered %q — an empty "+ "value names no platform, and it reached agenthooks.Install as the same nil list "+ "the default \"auto\" produces, so every detected platform on the machine now runs "+ "bdrive on every turn of every session", got) } } // TestSec_HooksInstall_OnlyTheFourKnownPlatformsCanNameAConfigPath // // The refusals that DO hold, pinned so they keep holding. agenthooks.ConfigPath // returns "" for an unknown agent, which is a relative path — a write to the // process's working directory, which for `bdrive hooks install` is a user's // project folder, and therefore a SYNCED one. func TestSec_HooksInstall_OnlyTheFourKnownPlatformsCanNameAConfigPath(t *testing.T) { for _, name := range []string{ "../../etc", "..", "/etc/claude", "CLAUDE", "claude/../codex", "claude ", " claude", "claude\x00", "auto,claude", ".claude", } { t.Run(strings.ReplaceAll(name, "\x00", "\\x00"), func(t *testing.T) { home, err := sec11Hooks(t, "--agent", name) if err == nil { t.Errorf("`hooks install --agent %q` was accepted", name) } if got := sec11Registrations(t, home); len(got) != 0 { t.Errorf("`hooks install --agent %q` wrote %q under $HOME", name, got) } }) } } // --------------------------------------------------------------------------- // 6. The other client this device sends its credential with. // --------------------------------------------------------------------------- // TestSec_CLI_TheInitClientCarriesAWholeRequestDeadline // // internal/remote's sync backend is not the only http.Client the device uses: // initClient (init.go:636) is the one `bdrive init`, `bdrive login` and // `bdrive logout` talk to the hub with, carrying the device's bearer token. // A zero Timeout there is a hub that can hang `bdrive init` forever — and // `bdrive init` is the command the onboarding runbook tells every agent to run // first. Round 10 found this client had no CheckRedirect because nobody had // read its construction; this pins both fields, alongside // internal/remote/sec_slow_test.go for the sync backend's. func TestSec_CLI_TheInitClientCarriesAWholeRequestDeadline(t *testing.T) { if initClient.Timeout <= 0 { t.Errorf("initClient.Timeout = %v — a hub that accepts the request and never answers "+ "hangs `bdrive init` and `bdrive login` indefinitely", initClient.Timeout) } if initClient.CheckRedirect == nil { t.Error("initClient has no CheckRedirect — a hub's 3xx carries this device's bearer token") } }