Files
beardrive/cmd/bdrive/sec_fixes13_test.go
4031495c81 feat(cli,docs): say that agent skills sync, and refuse ~/.claude as a mount root (BEA-117) (#138)
`.claude/skills/**` has always synced — deliberately, per the reservation
rule's own comment — but the only sentence saying so sits under the heading
"What beardrive does not sync". Nobody knows.

Track B, the one real bug: `bdrive init ~/.claude` was accepted. The
reserved-path rule matches ".claude/settings.json" on its directory segment,
so at that mount root the file is bare "settings.json" — reserved by nothing —
along with .credentials.json and every saved session under projects/. New
exported config.AgentConfigDir folds the keys of agentHookConfigs the way
ReservedDir folds (case, trailing dots), and init refuses before any network
call or file write. Only that direction leaks: a mount CONTAINING ~/.claude
still sees .claude/settings.json, reserved at any depth.

Track A, the content job: a README Features bullet stating the positive claim,
a 7th use-case page (plus its astro.config.mjs sidebar entry, without which it
is invisible), and a `skills` template appended last to the registry so `docs`
keeps the RECOMMENDED badge. The embed directive becomes `//go:embed all:files`
— a plain pattern drops dot-prefixed paths silently, so the template whose
whole payload is .claude/skills/<name>/SKILL.md would have shipped empty.

templates_test.go's every-directory-holds-a-file rule now marks ancestors, not
just the direct parent: skills is the first template more than one level deep,
and the rule was stricter than its own stated reason (an intermediate
directory on the way to a file is not empty).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 05:04:12 +09:00

147 lines
5.8 KiB
Go

package main
// Round 13 — the twin of round 7's $BDRIVE_HOME guard, one directory over.
//
// Round 7 closed `bdrive init $BDRIVE_HOME`. The identical shape exists for an
// AGENT's config directory: the reserved-path rule matches
// ".claude/settings.json" on its directory segment, and a mount whose ROOT is
// ~/.claude leaves that file as bare "settings.json" — reserved by nothing.
// Same for .credentials.json and everything under projects/, which is the
// transcript of every session ever run on the machine.
//
// Every test asserts the SECURE behaviour. Helpers are prefixed secfx13; the
// hub fixture and the real-binary runner are reused from sec_init_test.go.
import (
"os"
"path/filepath"
"strings"
"testing"
)
// secfx13Claude builds a realistic ~/.claude: the hook config the reservation
// rule exists to block, the credential file beside it, and one saved session
// transcript. Each carries its own sentinel so a leak names itself.
func secfx13Claude(t *testing.T, parent string) string {
t.Helper()
dir := filepath.Join(parent, ".claude")
if err := os.MkdirAll(filepath.Join(dir, "projects", "some-project"), 0o755); err != nil {
t.Fatal(err)
}
write := func(p, body string) {
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
write("settings.json", `{"hooks":{"PreToolUse":[{"command":"SECFX13-HOOK-COMMAND"}]}}`)
write(".credentials.json", `{"token":"SECFX13-CREDENTIAL"}`)
write("projects/some-project/session.jsonl", `{"text":"SECFX13-TRANSCRIPT"}`)
// The one thing in here that SHOULD be shareable, so the test can tell
// "refused the directory" apart from "refused everything".
if err := os.MkdirAll(filepath.Join(dir, "skills", "example"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "skills", "example", "SKILL.md"),
[]byte("# example skill\n"), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// `bdrive init ~/.claude` must refuse before it writes or calls anything.
//
// The assertion that matters is the last one: whatever init printed, no
// sentinel from the agent's config directory may reach the hub.
func TestSec_Init_RefusesAnAgentConfigDirectoryAsAMountRoot(t *testing.T) {
e := secinitNewEnv(t, secinitHubOpts{})
claude := secfx13Claude(t, t.TempDir())
defer e.run(claude, "stop", claude)
out, err := e.run(claude, "init", "--name", "claude-config", "--server", e.url, "--yes")
if err == nil {
t.Errorf("init mounted an agent's configuration directory:\n%s\n"+
"the reserved-path rule only covers segments BELOW a mount root, so at this root "+
"settings.json, .credentials.json and every saved session transcript are ordinary "+
"top-level files", out)
} else if !strings.Contains(out, ".claude") {
t.Errorf("the refusal does not name the directory, so a user cannot act on it:\n%s", out)
}
if _, serr := os.Stat(filepath.Join(claude, ".bdrive", "config.json")); serr == nil {
t.Error("init wrote .bdrive/config.json before refusing; the guard must land before any file write")
}
if len(e.sentAuth()) != 0 {
t.Error("init reached the hub before refusing; the guard must land before any network call")
}
e.run(claude, "sync")
for key, body := range e.pushed() {
for _, sentinel := range []string{"SECFX13-HOOK-COMMAND", "SECFX13-CREDENTIAL", "SECFX13-TRANSCRIPT"} {
if strings.Contains(string(body), sentinel) {
t.Fatalf("%s from the agent's config directory was pushed to the hub as object %s:\n%s",
sentinel, key, body)
}
}
}
}
// Every directory that keys a reserved hook config, under the spellings the
// filesystem folds onto it. A guard that only knows the exact string
// ".claude" is bypassed by the name the same directory also answers to.
func TestSec_Init_RefusesEveryAgentConfigDirectorySpelling(t *testing.T) {
e := secinitNewEnv(t, secinitHubOpts{})
for _, name := range []string{".claude", ".codex", ".gemini", ".hermes", ".CLAUDE"} {
dir := filepath.Join(t.TempDir(), name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "settings.json"),
[]byte(`{"hooks":"SECFX13-`+name+`"}`), 0o600); err != nil {
t.Fatal(err)
}
out, err := e.run(dir, "init", "--name", "cfg"+name, "--server", e.url, "--yes")
if err == nil {
e.run(dir, "stop", dir)
t.Errorf("init mounted %s: its hook config is a top-level file at this root\n%s", name, out)
}
}
}
// The positive case, which is the whole point of the feature this guard ships
// with: ~/.claude/skills is what every doc tells people to sync, and
// filepath.Base of it is "skills".
func TestSec_Init_StillMountsTheSkillsDirectory(t *testing.T) {
e := secinitNewEnv(t, secinitHubOpts{})
claude := secfx13Claude(t, t.TempDir())
skills := filepath.Join(claude, "skills")
defer e.run(skills, "stop", skills)
out, err := e.run(skills, "init", "--name", "team-skills", "--server", e.url, "--yes")
if err != nil {
t.Fatalf("init refused %s, the directory the docs tell users to sync: %v\n%s", skills, err, out)
}
if _, serr := os.Stat(filepath.Join(skills, ".bdrive", "config.json")); serr != nil {
t.Fatalf("init reported success but wrote no project config: %v", serr)
}
// And mounting it carries the skill without carrying anything from the
// agent config directory above it.
e.run(skills, "sync")
var sawSkill bool
for key, body := range e.pushed() {
if strings.Contains(string(body), "# example skill") {
sawSkill = true
}
for _, sentinel := range []string{"SECFX13-HOOK-COMMAND", "SECFX13-CREDENTIAL", "SECFX13-TRANSCRIPT"} {
if strings.Contains(string(body), sentinel) {
t.Fatalf("mounting %s pushed %s from its parent as object %s", skills, sentinel, key)
}
}
}
if !sawSkill {
t.Error("the skill file never reached the hub — sharing what an agent reads is the product")
}
}