From 2a95f78323e74d96a3f37fd5b02348e8413fa0a1 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:27:09 +0200 Subject: [PATCH] fix(runtime): Isolate linked-worktree state Route MCP and blast-radius analysis through the selected project while keeping mutable Codemap state inside the linked worktree. Co-Authored-By: GPT-5.6 Sol --- blast_radius.go | 5 + mcp/main.go | 104 ++++++----------- mcp/main_test.go | 208 ++++++++++++++++++++++++++++++++++ mcp/surface_hygiene_test.go | 12 +- root_options_e2e_test.go | 220 ++++++++++++++++++++++++++++++++++++ 5 files changed, 473 insertions(+), 76 deletions(-) create mode 100644 root_options_e2e_test.go diff --git a/blast_radius.go b/blast_radius.go index 40e0d28..3237da8 100644 --- a/blast_radius.go +++ b/blast_radius.go @@ -18,6 +18,7 @@ import ( "unicode/utf8" "codemap/analysis" + "codemap/cmd" "codemap/config" "codemap/render" "codemap/scanner" @@ -231,6 +232,10 @@ func executeBlastRadiusSubcommand(args []string) int { return 1 } defer cleanup() + if _, err := cmd.ValidateProjectPath(absRoot); err != nil { + fmt.Fprintf(os.Stderr, "Error preparing root: %v\n", err) + return 1 + } bundle, err := buildBlastRadiusBundle(absRoot, *ref, limits) if err != nil { diff --git a/mcp/main.go b/mcp/main.go index fdb39cb..d529ba2 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -20,6 +20,7 @@ import ( "codemap/config" "codemap/handoff" "codemap/internal/buildinfo" + "codemap/internal/projectpath" "codemap/limits" "codemap/render" "codemap/scanner" @@ -329,19 +330,18 @@ func mustSchemaFor[T any]() *jsonschema.Schema { return schema } -func traversalRoot(path string) (string, *mcp.CallToolResult) { +func validateProjectPath(path string) (string, *mcp.CallToolResult) { if strings.HasPrefix(path, "~/") { path = filepath.Join(os.Getenv("HOME"), path[2:]) } - absRoot, err := filepath.Abs(path) + absPath, err := filepath.Abs(path) if err != nil { return "", errorResult("Invalid path: " + err.Error()) } - info, err := os.Stat(absRoot) - if err != nil || !info.IsDir() { - return "", errorResult("Invalid project path: path is not an accessible directory") + if _, err := projectpath.Select(absPath); err != nil { + return "", errorResult("Invalid project path: " + err.Error()) } - return absRoot, nil + return absPath, nil } func cancellationResult(ctx context.Context, operation string) *mcp.CallToolResult { @@ -355,7 +355,7 @@ func handleGetStructure(ctx context.Context, req *mcp.CallToolRequest, input Str if cancelled := cancellationResult(ctx, "Structure scan"); cancelled != nil { return cancelled, nil, nil } - absRoot, invalid := traversalRoot(input.Path) + absRoot, invalid := validateProjectPath(input.Path) if invalid != nil { return invalid, nil, nil } @@ -441,7 +441,7 @@ func handleGetDependencies(ctx context.Context, req *mcp.CallToolRequest, input if cancelled := cancellationResult(ctx, "Dependency scan"); cancelled != nil { return cancelled, nil, nil } - absRoot, invalid := traversalRoot(input.Path) + absRoot, invalid := validateProjectPath(input.Path) if invalid != nil { return invalid, nil, nil } @@ -488,7 +488,7 @@ func handleGetDiff(ctx context.Context, req *mcp.CallToolRequest, input DiffInpu ref = "main" } - absRoot, invalid := traversalRoot(input.Path) + absRoot, invalid := validateProjectPath(input.Path) if invalid != nil { return invalid, nil, nil } @@ -542,7 +542,7 @@ func handleFindFile(ctx context.Context, req *mcp.CallToolRequest, input FindInp if cancelled := cancellationResult(ctx, "File search"); cancelled != nil { return cancelled, nil, nil } - absRoot, invalid := traversalRoot(input.Path) + absRoot, invalid := validateProjectPath(input.Path) if invalid != nil { return invalid, nil, nil } @@ -625,7 +625,7 @@ func handleListProjects(ctx context.Context, req *mcp.CallToolRequest, input Lis path = filepath.Join(home, path[2:]) } - absPath, invalid := traversalRoot(path) + absPath, invalid := validateProjectPath(path) if invalid != nil { return invalid, nil, nil } @@ -817,7 +817,7 @@ func handleGetImporters(ctx context.Context, req *mcp.CallToolRequest, input Imp if cancelled := cancellationResult(ctx, "Importer analysis"); cancelled != nil { return cancelled, nil, nil } - absRoot, invalid := traversalRoot(input.Path) + absRoot, invalid := validateProjectPath(input.Path) if invalid != nil { return invalid, nil, nil } @@ -874,13 +874,13 @@ func handleGetHandoff(ctx context.Context, req *mcp.CallToolRequest, input Hando return errorResult("prefix and delta options are mutually exclusive"), nil, nil } - absRoot, invalid := traversalRoot(input.Path) + absRoot, invalid := validateProjectPath(input.Path) if invalid != nil { return invalid, nil, nil } - var artifact *handoff.Artifact var err error + var artifact *handoff.Artifact if input.Latest { artifact, err = handoff.ReadLatest(absRoot) if err != nil { @@ -996,15 +996,9 @@ func stripANSI(s string) string { // === WATCH HANDLERS === func handleStartWatch(ctx context.Context, req *mcp.CallToolRequest, input WatchInput) (*mcp.CallToolResult, any, error) { - path := input.Path - if strings.HasPrefix(path, "~/") { - home := os.Getenv("HOME") - path = filepath.Join(home, path[2:]) - } - - absPath, err := filepath.Abs(path) - if err != nil { - return errorResult("Invalid path: " + err.Error()), nil, nil + absPath, invalid := validateProjectPath(input.Path) + if invalid != nil { + return invalid, nil, nil } watchersMu.Lock() @@ -1040,15 +1034,9 @@ Use get_activity to see what you've been working on.`, absPath, daemon.FileCount } func handleStopWatch(ctx context.Context, req *mcp.CallToolRequest, input WatchInput) (*mcp.CallToolResult, any, error) { - path := input.Path - if strings.HasPrefix(path, "~/") { - home := os.Getenv("HOME") - path = filepath.Join(home, path[2:]) - } - - absPath, err := filepath.Abs(path) - if err != nil { - return errorResult("Invalid path: " + err.Error()), nil, nil + absPath, invalid := validateProjectPath(input.Path) + if invalid != nil { + return invalid, nil, nil } watchersMu.Lock() @@ -1068,15 +1056,9 @@ func handleStopWatch(ctx context.Context, req *mcp.CallToolRequest, input WatchI } func handleGetActivity(ctx context.Context, req *mcp.CallToolRequest, input WatchActivityInput) (*mcp.CallToolResult, any, error) { - path := input.Path - if strings.HasPrefix(path, "~/") { - home := os.Getenv("HOME") - path = filepath.Join(home, path[2:]) - } - - absPath, err := filepath.Abs(path) - if err != nil { - return errorResult("Invalid path: " + err.Error()), nil, nil + absPath, invalid := validateProjectPath(input.Path) + if invalid != nil { + return invalid, nil, nil } watchersMu.RLock() @@ -1248,7 +1230,7 @@ func handleGetHubs(ctx context.Context, req *mcp.CallToolRequest, input PathInpu if cancelled := cancellationResult(ctx, "Hub analysis"); cancelled != nil { return cancelled, nil, nil } - absRoot, invalid := traversalRoot(input.Path) + absRoot, invalid := validateProjectPath(input.Path) if invalid != nil { return invalid, nil, nil } @@ -1298,7 +1280,7 @@ func handleGetFileContext(ctx context.Context, req *mcp.CallToolRequest, input I if cancelled := cancellationResult(ctx, "File context analysis"); cancelled != nil { return cancelled, nil, nil } - absRoot, invalid := traversalRoot(input.Path) + absRoot, invalid := validateProjectPath(input.Path) if invalid != nil { return invalid, nil, nil } @@ -1359,15 +1341,9 @@ func handleGetFileContext(ctx context.Context, req *mcp.CallToolRequest, input I // handleGetWorkingSet returns the current session's working set func handleGetWorkingSet(ctx context.Context, req *mcp.CallToolRequest, input WatchInput) (*mcp.CallToolResult, any, error) { - path := input.Path - if strings.HasPrefix(path, "~/") { - home := os.Getenv("HOME") - path = filepath.Join(home, path[2:]) - } - - absPath, err := filepath.Abs(path) - if err != nil { - return errorResult("Invalid path: " + err.Error()), nil, nil + absPath, invalid := validateProjectPath(input.Path) + if invalid != nil { + return invalid, nil, nil } // Try daemon state first (includes working set) @@ -1399,15 +1375,9 @@ func handleGetWorkingSet(ctx context.Context, req *mcp.CallToolRequest, input Wa // handleListSkills returns metadata for all available skills func handleListSkills(ctx context.Context, req *mcp.CallToolRequest, input PathInput) (*mcp.CallToolResult, any, error) { - path := input.Path - if strings.HasPrefix(path, "~/") { - home := os.Getenv("HOME") - path = filepath.Join(home, path[2:]) - } - - absPath, err := filepath.Abs(path) - if err != nil { - return errorResult("Invalid path: " + err.Error()), nil, nil + absPath, invalid := validateProjectPath(input.Path) + if invalid != nil { + return invalid, nil, nil } idx, err := skills.LoadSkills(absPath) @@ -1435,15 +1405,9 @@ func handleListSkills(ctx context.Context, req *mcp.CallToolRequest, input PathI // handleGetSkill returns the full body of a specific skill func handleGetSkill(ctx context.Context, req *mcp.CallToolRequest, input SkillInput) (*mcp.CallToolResult, any, error) { - path := input.Path - if strings.HasPrefix(path, "~/") { - home := os.Getenv("HOME") - path = filepath.Join(home, path[2:]) - } - - absPath, err := filepath.Abs(path) - if err != nil { - return errorResult("Invalid path: " + err.Error()), nil, nil + absPath, invalid := validateProjectPath(input.Path) + if invalid != nil { + return invalid, nil, nil } idx, err := skills.LoadSkills(absPath) diff --git a/mcp/main_test.go b/mcp/main_test.go index a98c99e..f696555 100644 --- a/mcp/main_test.go +++ b/mcp/main_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -281,6 +282,188 @@ func TestFormatOnlyFilterHintOffersDirectConfigActionForExtensionlessMatches(t * } } +func TestHandleListSkillsRejectsMalformedWorktreeSetup(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".git"), []byte("not a gitdir\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, _, err := handleListSkills(context.Background(), nil, PathInput{Path: root}) + if err != nil { + t.Fatalf("handleListSkills() error: %v", err) + } + if !res.IsError || !strings.Contains(resultText(t, res), "resolve linked worktree setup") { + t.Fatalf("expected bounded root-resolution error, got:\n%s", resultText(t, res)) + } +} + +func TestProjectToolsRejectInvalidWorktreeMetadataBeforeAccess(t *testing.T) { + type toolCall func(string) (*mcp.CallToolResult, error) + tools := map[string]toolCall{ + "analysis": func(path string) (*mcp.CallToolResult, error) { + result, _, err := handleGetStructure(context.Background(), nil, StructureInput{Path: path}) + return result, err + }, + "file context": func(path string) (*mcp.CallToolResult, error) { + result, _, err := handleGetFileContext(context.Background(), nil, ImportersInput{Path: path, File: "main.go"}) + return result, err + }, + "handoff read": func(path string) (*mcp.CallToolResult, error) { + result, _, err := handleGetHandoff(context.Background(), nil, HandoffInput{Path: path, Latest: true}) + return result, err + }, + "handoff save": func(path string) (*mcp.CallToolResult, error) { + result, _, err := handleGetHandoff(context.Background(), nil, HandoffInput{Path: path, Save: true}) + return result, err + }, + "watch state": func(path string) (*mcp.CallToolResult, error) { + result, _, err := handleGetWorkingSet(context.Background(), nil, WatchInput{Path: path}) + return result, err + }, + } + + fixtures := map[string]func(string) error{ + "malformed gitfile": func(root string) error { + return os.WriteFile(filepath.Join(root, ".git"), []byte("not a gitdir\n"), 0o644) + }, + "inaccessible gitdir": func(root string) error { + return os.WriteFile(filepath.Join(root, ".git"), []byte("gitdir: missing-admin-dir\n"), 0o644) + }, + } + + for fixtureName, makeFixture := range fixtures { + for toolName, call := range tools { + t.Run(fixtureName+"/"+toolName, func(t *testing.T) { + root := t.TempDir() + if err := makeFixture(root); err != nil { + t.Fatal(err) + } + + result, err := call(root) + if err != nil { + t.Fatalf("tool returned transport error: %v", err) + } + if !result.IsError || !strings.Contains(resultText(t, result), "resolve linked worktree setup") { + t.Fatalf("expected bounded root-resolution error, got:\n%s", resultText(t, result)) + } + if _, err := os.Lstat(filepath.Join(root, ".codemap")); !os.IsNotExist(err) { + t.Fatalf("tool accessed mutable storage before root validation: %v", err) + } + }) + } + } +} + +func TestHandoffSaveRejectsSymlinkedLinkedRuntimeBeforeWrite(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks may require elevated privileges") + } + for name, withPrimarySetup := range map[string]bool{ + "with primary setup": true, + "without primary setup": false, + } { + t.Run(name, func(t *testing.T) { + assertHandoffSaveRejectsSymlinkedLinkedRuntime(t, withPrimarySetup) + }) + } +} + +func assertHandoffSaveRejectsSymlinkedLinkedRuntime(t *testing.T, withPrimarySetup bool) { + t.Helper() + primary := t.TempDir() + gitDir := filepath.Join(primary, ".git", "worktrees", "linked") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatal(err) + } + if withPrimarySetup { + if err := os.MkdirAll(filepath.Join(primary, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("../..\n"), 0o644); err != nil { + t.Fatal(err) + } + linked := t.TempDir() + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + target := t.TempDir() + sentinel := filepath.Join(target, "sentinel") + if err := os.WriteFile(sentinel, []byte("unchanged"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(linked, ".codemap")); err != nil { + t.Fatal(err) + } + + result, _, err := handleGetHandoff(context.Background(), nil, HandoffInput{Path: linked, Save: true}) + if err != nil { + t.Fatalf("handleGetHandoff() error: %v", err) + } + if !result.IsError || !strings.Contains(resultText(t, result), "unsafe Codemap storage") { + t.Fatalf("expected unsafe runtime-storage error, got:\n%s", resultText(t, result)) + } + data, err := os.ReadFile(sentinel) + if err != nil { + t.Fatal(err) + } + if string(data) != "unchanged" { + t.Fatalf("sentinel changed through runtime symlink: %q", data) + } + entries, err := os.ReadDir(target) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "sentinel" { + t.Fatalf("runtime symlink target was modified: %#v", entries) + } +} + +func TestHandleListSkillsResolvesWorktreeSetupPerInputPath(t *testing.T) { + makeFixture := func(name string) string { + primary := filepath.Join(t.TempDir(), "primary") + gitDir := filepath.Join(primary, ".git", "worktrees", name) + skillsDir := filepath.Join(primary, ".codemap", "skills") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(skillsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("../..\n"), 0o644); err != nil { + t.Fatal(err) + } + skill := "---\nname: " + name + "\ndescription: per-path fixture\n---\n\n# Fixture\n" + if err := os.WriteFile(filepath.Join(skillsDir, name+".md"), []byte(skill), 0o644); err != nil { + t.Fatal(err) + } + linked := filepath.Join(t.TempDir(), "linked") + if err := os.MkdirAll(linked, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + return linked + } + + linkedA := makeFixture("primary-a") + linkedB := makeFixture("primary-b") + resA, _, err := handleListSkills(context.Background(), nil, PathInput{Path: linkedA}) + if err != nil { + t.Fatalf("handleListSkills(A) error: %v", err) + } + resB, _, err := handleListSkills(context.Background(), nil, PathInput{Path: linkedB}) + if err != nil { + t.Fatalf("handleListSkills(B) error: %v", err) + } + outA := resultText(t, resA) + outB := resultText(t, resB) + if !strings.Contains(outA, "primary-a") || strings.Contains(outA, "primary-b") || !strings.Contains(outB, "primary-b") || strings.Contains(outB, "primary-a") { + t.Fatalf("per-path skill selection failed:\nA:\n%s\nB:\n%s", outA, outB) + } +} + func TestHandleGetStructureUsesStateHubs(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { @@ -395,3 +578,28 @@ func resultText(t *testing.T, res *mcp.CallToolResult) string { } return text.Text } + +func TestValidateProjectPathExpandsHomeAndRejectsInvalidRoots(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + proj := filepath.Join(home, "proj") + if err := os.MkdirAll(filepath.Join(proj, ".git"), 0o755); err != nil { + t.Fatal(err) + } + abs, invalid := validateProjectPath("~/proj") + if invalid != nil || abs != proj { + t.Fatalf("validateProjectPath(~/) = %q, %v; want %q", abs, invalid, proj) + } + // A malformed linked-worktree marker is rejected via projectpath.Select. + bad := filepath.Join(t.TempDir(), "bad") + if err := os.MkdirAll(bad, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bad, ".git"), []byte("not a gitdir\n"), 0o644); err != nil { + t.Fatal(err) + } + _, invalid = validateProjectPath(bad) + if invalid == nil || !invalid.IsError { + t.Fatalf("validateProjectPath(malformed) not rejected: %v", invalid) + } +} diff --git a/mcp/surface_hygiene_test.go b/mcp/surface_hygiene_test.go index 6ab2c99..bf9fbac 100644 --- a/mcp/surface_hygiene_test.go +++ b/mcp/surface_hygiene_test.go @@ -457,13 +457,13 @@ func assertPathsOrdered(t *testing.T, text string, paths []string) { } } -func TestTraversalRootRejectsInaccessiblePath(t *testing.T) { +func TestValidateProjectPathRejectsInaccessiblePath(t *testing.T) { missing := filepath.Join(t.TempDir(), "does-not-exist") - root, result := traversalRoot(missing) + root, result := validateProjectPath(missing) if root != "" || result == nil || !result.IsError { - t.Fatalf("traversalRoot(%q) = %q, %v; want error", missing, root, result) + t.Fatalf("validateProjectPath(%q) = %q, %v; want error", missing, root, result) } - if !strings.Contains(resultText(t, result), "not an accessible directory") { + if !strings.Contains(resultText(t, result), "Invalid project path") { t.Fatalf("unexpected rejection: %q", resultText(t, result)) } home := t.TempDir() @@ -471,8 +471,8 @@ func TestTraversalRootRejectsInaccessiblePath(t *testing.T) { if err := os.MkdirAll(filepath.Join(home, "proj"), 0o755); err != nil { t.Fatal(err) } - expanded, result := traversalRoot("~/proj") + expanded, result := validateProjectPath("~/proj") if result != nil || expanded != filepath.Join(home, "proj") { - t.Fatalf("traversalRoot(~/) = %q, %v; want %q", expanded, result, filepath.Join(home, "proj")) + t.Fatalf("validateProjectPath(~/) = %q, %v; want %q", expanded, result, filepath.Join(home, "proj")) } } diff --git a/root_options_e2e_test.go b/root_options_e2e_test.go new file mode 100644 index 0000000..7019c87 --- /dev/null +++ b/root_options_e2e_test.go @@ -0,0 +1,220 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestGlobalRootOptionsEndToEnd(t *testing.T) { + root := t.TempDir() + projectRoot := filepath.Join(root, "worktree") + setupRoot := filepath.Join(root, "original") + for _, repo := range []string{projectRoot, setupRoot} { + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatal(err) + } + } + projectNested := filepath.Join(projectRoot, "pkg", "feature") + setupNested := filepath.Join(setupRoot, "cmd") + if err := os.MkdirAll(projectNested, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(setupNested, 0o755); err != nil { + t.Fatal(err) + } + writeTestSkill(t, setupRoot, "setup-only") + + t.Run("linked worktree inherits primary skill without setup flag", func(t *testing.T) { + gitDir := filepath.Join(setupRoot, ".git", "worktrees", "automatic") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("../..\n"), 0o644); err != nil { + t.Fatal(err) + } + linked := filepath.Join(root, "automatic-linked") + nested := filepath.Join(linked, "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + out, err := runRootOptionsBinary(nested, "-C", ".", "skill", "list") + if err != nil { + t.Fatalf("codemap failed: %v\n%s", err, out) + } + if !strings.Contains(out, "setup-only") { + t.Fatalf("primary skill missing from automatic linked worktree:\n%s", out) + } + }) + + t.Run("independent repository does not inherit another setup", func(t *testing.T) { + out, err := runRootOptionsBinary(projectNested, "-C", ".", "skill", "list") + if err != nil { + t.Fatalf("codemap failed: %v\n%s", err, out) + } + if strings.Contains(out, "setup-only") { + t.Fatalf("independent repository inherited unrelated setup:\n%s", out) + } + }) + + t.Run("relative setup root reuses original storage", func(t *testing.T) { + relSetup, err := filepath.Rel(projectRoot, setupNested) + if err != nil { + t.Fatal(err) + } + out, err := runRootOptionsBinary(projectNested, + "-C", ".", + "--setup-root", relSetup, + "skill", "list", + ) + if err != nil { + t.Fatalf("codemap failed: %v\n%s", err, out) + } + if !strings.Contains(out, "setup-only") { + t.Fatalf("setup-root skill missing from output:\n%s", out) + } + }) + + t.Run("inherited setup root environment is ignored", func(t *testing.T) { + hostileRoot := filepath.Join(root, "hostile") + if err := os.MkdirAll(filepath.Join(hostileRoot, ".git"), 0o755); err != nil { + t.Fatal(err) + } + writeTestSkill(t, hostileRoot, "hostile-only") + + command := exec.Command(codemapTestBinaryPath, + "--project-root", projectNested, + "skill", "list", + ) + command.Env = append(os.Environ(), "CODEMAP_SETUP_ROOT="+hostileRoot) + out, err := command.CombinedOutput() + if err != nil { + t.Fatalf("codemap failed: %v\n%s", err, out) + } + if strings.Contains(string(out), "hostile-only") { + t.Fatalf("inherited environment redirected setup storage:\n%s", out) + } + }) + + t.Run("project commands reject malformed per-command roots", func(t *testing.T) { + malformedRoot := filepath.Join(root, "malformed-command-root") + if err := os.MkdirAll(malformedRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(malformedRoot, ".git"), []byte("not a gitdir\n"), 0o644); err != nil { + t.Fatal(err) + } + + commands := map[string][]string{ + "analysis": {malformedRoot}, + "blast radius": {"blast-radius", malformedRoot}, + "config": {"config", "show", malformedRoot}, + "context": {"context", malformedRoot}, + "handoff": {"handoff", "--latest", malformedRoot}, + "hook": {"hook", "pre-edit", malformedRoot}, + "watch": {"watch", "status", malformedRoot}, + } + for name, args := range commands { + t.Run(name, func(t *testing.T) { + out, err := runRootOptionsBinary(projectNested, args...) + if err == nil { + t.Fatalf("codemap unexpectedly accepted malformed project metadata:\n%s", out) + } + if !strings.Contains(out, "resolve linked worktree setup") && !strings.Contains(out, "invalid Git marker") { + t.Fatalf("unexpected rejection output:\n%s", out) + } + }) + } + }) + + t.Run("symlinked codemap storage is rejected", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks may require elevated privileges") + } + unsafeRoot := filepath.Join(root, "unsafe") + if err := os.MkdirAll(filepath.Join(unsafeRoot, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), filepath.Join(unsafeRoot, ".codemap")); err != nil { + t.Fatal(err) + } + + out, err := runRootOptionsBinary(projectNested, + "--setup-root", unsafeRoot, + "skill", "list", + ) + if err == nil { + t.Fatalf("codemap unexpectedly accepted symlinked storage:\n%s", out) + } + if !strings.Contains(out, "unsafe Codemap storage") { + t.Fatalf("unexpected rejection output:\n%s", out) + } + }) +} + +func TestStandardSubmoduleUsesProjectLocalSetup(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source") + super := filepath.Join(root, "super") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(super, 0o755); err != nil { + t.Fatal(err) + } + runGitFixtureCommand(t, source, "init", "-q") + runGitFixtureCommand(t, source, "-c", "user.name=Codemap Test", "-c", "user.email=codemap@example.invalid", "commit", "--allow-empty", "-q", "-m", "initial") + runGitFixtureCommand(t, super, "init", "-q") + runGitFixtureCommand(t, super, "-c", "user.name=Codemap Test", "-c", "user.email=codemap@example.invalid", "commit", "--allow-empty", "-q", "-m", "initial") + runGitFixtureCommand(t, super, "-c", "protocol.file.allow=always", "submodule", "add", "-q", source, "child") + + submodule := filepath.Join(super, "child") + writeTestSkill(t, submodule, "submodule-local") + out, err := runRootOptionsBinary(super, "-C", submodule, "skill", "list") + if err != nil { + t.Fatalf("codemap rejected standard submodule gitfile: %v\n%s", err, out) + } + if !strings.Contains(out, "submodule-local") { + t.Fatalf("submodule-local skill missing from output:\n%s", out) + } +} + +func writeTestSkill(t *testing.T, root, name string) { + t.Helper() + dir := filepath.Join(root, ".codemap", "skills") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + content := "---\nname: " + name + "\ndescription: root option fixture\n---\n\n# Fixture\n" + if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func runRootOptionsBinary(dir string, args ...string) (string, error) { + command := exec.Command(codemapTestBinaryPath, args...) + command.Dir = dir + out, err := command.CombinedOutput() + return string(out), err +} + +func runGitFixtureCommand(t *testing.T, dir string, args ...string) { + t.Helper() + command := exec.Command("git", args...) + command.Dir = dir + command.Env = append(os.Environ(), + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=commit.gpgsign", + "GIT_CONFIG_VALUE_0=false", + ) + if out, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) + } +}