From 3488ab778c6dd74eadb19b99f76269714d70acb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 16:40:05 -0400 Subject: [PATCH] fix(cli): validate runtimes before uninstall --- AGENTS.md | 2 +- README.md | 2 +- cmd/elydora/main.go | 87 +----------- cmd/elydora/main_test.go | 56 ++++++++ cmd/elydora/plugins/runtime_paths.go | 15 ++ cmd/elydora/plugins/runtime_paths_test.go | 16 +++ cmd/elydora/runtime_discovery.go | 159 ++++++++++++++++++++++ 7 files changed, 250 insertions(+), 87 deletions(-) create mode 100644 cmd/elydora/runtime_discovery.go diff --git a/AGENTS.md b/AGENTS.md index fd7930e..7805870 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh - Return unexpected errors to the CLI boundary with operation and path context. - Use documented defaults only for genuinely optional configuration. - Avoid compatibility shims without a named public or user configuration contract. -- Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, and symbolic-link directories before writes or recursive removal. +- Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous. ## Verification diff --git a/README.md b/README.md index 1cee30a..18255a0 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ func main() { The SDK includes a CLI for installing audit hooks into AI coding agents. -Agent IDs map to one physical directory directly under `~/.elydora`; path separators, traversal segments, Windows reserved names, and symbolic-link directories are rejected. +Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical directory/config checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID. ```bash go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest diff --git a/cmd/elydora/main.go b/cmd/elydora/main.go index fc665bf..55e8ff5 100644 --- a/cmd/elydora/main.go +++ b/cmd/elydora/main.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "flag" "fmt" "os" @@ -146,37 +145,13 @@ func cmdUninstall(args []string) { os.Exit(1) } - runtimeRoot, err := plugins.AgentRuntimeRoot() + resolvedAgentID, agentDirectory, agentDirectoryExists, err := resolveAgentRuntimeForUninstall(*agent, *agentID) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - if _, err := plugins.RequirePhysicalDirectory(runtimeRoot); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - // Resolve agentID: if not given, scan ~/.elydora/*/config.json for matching agent_name - resolvedAgentID := *agentID if resolvedAgentID == "" { - resolvedAgentID, err = findAgentIDByName(*agent) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - if resolvedAgentID == "" { - fmt.Fprintf(os.Stderr, "Error: could not find agent ID for %q in ~/.elydora/*/config.json; pass --agent-id explicitly\n", *agent) - os.Exit(1) - } - } - agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(resolvedAgentID) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - agentDirectoryExists, err := plugins.RequirePhysicalDirectory(agentDirectory) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: could not find agent ID for %q in ~/.elydora/*/config.json; pass --agent-id explicitly\n", *agent) os.Exit(1) } @@ -195,64 +170,6 @@ func cmdUninstall(args []string) { } } -// findAgentIDByName scans ~/.elydora/*/config.json for a config whose agent_name -// matches the given name and returns the directory name (agent ID). -func findAgentIDByName(agentName string) (string, error) { - elydoraDir, err := plugins.AgentRuntimeRoot() - if err != nil { - return "", err - } - exists, err := plugins.RequirePhysicalDirectory(elydoraDir) - if err != nil { - return "", err - } - if !exists { - return "", nil - } - entries, err := os.ReadDir(elydoraDir) - if err != nil { - return "", fmt.Errorf("read Elydora runtime root %s: %w", elydoraDir, err) - } - foundAgentID := "" - for _, entry := range entries { - if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() { - continue - } - configPath := filepath.Join(elydoraDir, entry.Name(), "config.json") - data, err := os.ReadFile(configPath) - if os.IsNotExist(err) { - continue - } - if err != nil { - return "", fmt.Errorf("read agent config %s: %w", configPath, err) - } - var config map[string]interface{} - if err := json.Unmarshal(data, &config); err != nil { - return "", fmt.Errorf("parse agent config %s: %w", configPath, err) - } - if name, _ := config["agent_name"].(string); name == agentName { - storedAgentID, ok := config["agent_id"].(string) - if !ok || storedAgentID == "" { - return "", fmt.Errorf("agent config %s has an invalid agent_id", configPath) - } - if storedAgentID != entry.Name() { - return "", fmt.Errorf("agent config %s crosses its runtime directory", configPath) - } - if _, err := plugins.ResolveAgentRuntimeDirectory(storedAgentID); err != nil { - return "", fmt.Errorf("validate agent config %s: %w", configPath, err) - } - if foundAgentID != "" { - return "", fmt.Errorf( - "multiple %q agent runtimes found; pass --agent-id explicitly", - agentName, - ) - } - foundAgentID = storedAgentID - } - } - return foundAgentID, nil -} - // --------------------------------------------------------------------------- // status // --------------------------------------------------------------------------- diff --git a/cmd/elydora/main_test.go b/cmd/elydora/main_test.go index af8a14c..00d290f 100644 --- a/cmd/elydora/main_test.go +++ b/cmd/elydora/main_test.go @@ -78,3 +78,59 @@ func TestFindAgentIDByNameRejectsAmbiguousRuntimes(t *testing.T) { t.Fatalf("findAgentIDByName() error = %v", err) } } + +func TestExplicitUninstallRejectsAgentOwnershipMismatch(t *testing.T) { + home := setMainTestHome(t) + runtimeDirectory := filepath.Join(home, ".elydora", "agent-1") + if err := os.MkdirAll(runtimeDirectory, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + config := []byte(`{"agent_name":"codex","agent_id":"agent-1"}`) + if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + _, _, _, err := resolveAgentRuntimeForUninstall("opencode", "agent-1") + if err == nil || !strings.Contains(err.Error(), "belongs to") { + t.Fatalf("resolveAgentRuntimeForUninstall() error = %v", err) + } +} + +func TestFindAgentIDByNameRejectsLinkedRuntime(t *testing.T) { + home := setMainTestHome(t) + root := filepath.Join(home, ".elydora") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + target := t.TempDir() + link := filepath.Join(root, "agent-1") + if err := os.Symlink(target, link); err != nil { + t.Skipf("directory symbolic links unavailable: %v", err) + } + + _, err := findAgentIDByName("opencode") + if err == nil || !strings.Contains(err.Error(), "physical directory") { + t.Fatalf("findAgentIDByName() error = %v", err) + } +} + +func TestFindAgentIDByNameRejectsLinkedConfig(t *testing.T) { + home := setMainTestHome(t) + runtimeDirectory := filepath.Join(home, ".elydora", "agent-1") + if err := os.MkdirAll(runtimeDirectory, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + target := filepath.Join(t.TempDir(), "config.json") + config := []byte(`{"agent_name":"opencode","agent_id":"agent-1"}`) + if err := os.WriteFile(target, config, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := os.Symlink(target, filepath.Join(runtimeDirectory, "config.json")); err != nil { + t.Skipf("file symbolic links unavailable: %v", err) + } + + _, err := findAgentIDByName("opencode") + if err == nil || !strings.Contains(err.Error(), "physical file") { + t.Fatalf("findAgentIDByName() error = %v", err) + } +} diff --git a/cmd/elydora/plugins/runtime_paths.go b/cmd/elydora/plugins/runtime_paths.go index aa5fc4d..9ec7ef2 100644 --- a/cmd/elydora/plugins/runtime_paths.go +++ b/cmd/elydora/plugins/runtime_paths.go @@ -126,3 +126,18 @@ func RequirePhysicalDirectory(path string) (bool, error) { } return true, nil } + +// RequirePhysicalFile reports whether a path is an existing physical regular file. +func RequirePhysicalFile(path string) (bool, error) { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("inspect agent runtime file %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return false, fmt.Errorf("agent runtime config is not a physical file: %s", path) + } + return true, nil +} diff --git a/cmd/elydora/plugins/runtime_paths_test.go b/cmd/elydora/plugins/runtime_paths_test.go index 908db7f..9c352e7 100644 --- a/cmd/elydora/plugins/runtime_paths_test.go +++ b/cmd/elydora/plugins/runtime_paths_test.go @@ -93,3 +93,19 @@ func TestResolveAgentRuntimeDirectoryRejectsSymlinkChild(t *testing.T) { t.Fatalf("ResolveAgentRuntimeDirectory() error = %v", err) } } + +func TestRequirePhysicalFileRejectsSymlink(t *testing.T) { + setRuntimeTestHome(t) + target := filepath.Join(t.TempDir(), "target.json") + if err := os.WriteFile(target, []byte("{}"), 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + link := filepath.Join(t.TempDir(), "config.json") + if err := os.Symlink(target, link); err != nil { + t.Skipf("file symbolic links unavailable: %v", err) + } + _, err := RequirePhysicalFile(link) + if err == nil || !strings.Contains(err.Error(), "physical file") { + t.Fatalf("RequirePhysicalFile() error = %v", err) + } +} diff --git a/cmd/elydora/runtime_discovery.go b/cmd/elydora/runtime_discovery.go new file mode 100644 index 0000000..f37b68b --- /dev/null +++ b/cmd/elydora/runtime_discovery.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora/plugins" +) + +type installedAgentRuntime struct { + agentID string + agentName string +} + +type runtimeIdentity struct { + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name"` +} + +func readInstalledAgentRuntime(directoryName string) (*installedAgentRuntime, error) { + agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(directoryName) + if err != nil { + return nil, err + } + exists, err := plugins.RequirePhysicalDirectory(agentDirectory) + if err != nil || !exists { + return nil, err + } + + configPath := filepath.Join(agentDirectory, "config.json") + configExists, err := plugins.RequirePhysicalFile(configPath) + if err != nil || !configExists { + return nil, err + } + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("read agent config %s: %w", configPath, err) + } + var identity runtimeIdentity + if err := json.Unmarshal(data, &identity); err != nil { + return nil, fmt.Errorf("parse agent config %s: %w", configPath, err) + } + if identity.AgentID == "" || identity.AgentName == "" { + return nil, fmt.Errorf("agent config %s has an invalid runtime identity", configPath) + } + if identity.AgentID != directoryName { + return nil, fmt.Errorf("agent config %s crosses its runtime directory", configPath) + } + if _, err := plugins.ResolveAgentRuntimeDirectory(identity.AgentID); err != nil { + return nil, fmt.Errorf("validate agent config %s: %w", configPath, err) + } + return &installedAgentRuntime{ + agentID: identity.AgentID, agentName: identity.AgentName, + }, nil +} + +func discoverInstalledAgentRuntimes() ([]installedAgentRuntime, error) { + runtimeRoot, err := plugins.AgentRuntimeRoot() + if err != nil { + return nil, err + } + exists, err := plugins.RequirePhysicalDirectory(runtimeRoot) + if err != nil || !exists { + return nil, err + } + entries, err := os.ReadDir(runtimeRoot) + if err != nil { + return nil, fmt.Errorf("read Elydora runtime root %s: %w", runtimeRoot, err) + } + + runtimes := make([]installedAgentRuntime, 0, len(entries)) + for _, entry := range entries { + if entry.Type()&os.ModeSymlink != 0 { + return nil, fmt.Errorf( + "agent runtime path is not a physical directory: %s", + filepath.Join(runtimeRoot, entry.Name()), + ) + } + if !entry.IsDir() { + continue + } + runtime, err := readInstalledAgentRuntime(entry.Name()) + if err != nil { + return nil, err + } + if runtime != nil { + runtimes = append(runtimes, *runtime) + } + } + return runtimes, nil +} + +// findAgentIDByName returns one validated runtime identity for an agent name. +func findAgentIDByName(agentName string) (string, error) { + runtimes, err := discoverInstalledAgentRuntimes() + if err != nil { + return "", err + } + foundAgentID := "" + for _, runtime := range runtimes { + if runtime.agentName != agentName { + continue + } + if foundAgentID != "" { + return "", fmt.Errorf( + "multiple %q agent runtimes found; pass --agent-id explicitly", + agentName, + ) + } + foundAgentID = runtime.agentID + } + return foundAgentID, nil +} + +func resolveAgentRuntimeForUninstall( + agentName, explicitAgentID string, +) (agentID, agentDirectory string, directoryExists bool, err error) { + runtimeRoot, err := plugins.AgentRuntimeRoot() + if err != nil { + return "", "", false, err + } + runtimeRootExists, err := plugins.RequirePhysicalDirectory(runtimeRoot) + if err != nil { + return "", "", false, err + } + + agentID = explicitAgentID + if agentID == "" { + agentID, err = findAgentIDByName(agentName) + if err != nil || agentID == "" { + return agentID, "", false, err + } + } + agentDirectory, err = plugins.ResolveAgentRuntimeDirectory(agentID) + if err != nil { + return "", "", false, err + } + if runtimeRootExists { + directoryExists, err = plugins.RequirePhysicalDirectory(agentDirectory) + if err != nil { + return "", "", false, err + } + } + if directoryExists { + runtime, err := readInstalledAgentRuntime(agentID) + if err != nil { + return "", "", false, err + } + if runtime != nil && runtime.agentName != agentName { + return "", "", false, fmt.Errorf( + "agent runtime %q belongs to %q, not %q", + agentID, runtime.agentName, agentName, + ) + } + } + return agentID, agentDirectory, directoryExists, nil +}