mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
`.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>
147 lines
5.8 KiB
Go
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")
|
|
}
|
|
}
|