From 7bf84a35e0e176c2bf84f73557584bd28d7391f4 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:26:00 -0400 Subject: [PATCH] fix(cli): confine agent runtime paths --- AGENTS.md | 1 + README.md | 2 + cmd/elydora/main.go | 104 +++++++++++++----- cmd/elydora/main_test.go | 80 ++++++++++++++ cmd/elydora/plugins/droid.go | 9 +- cmd/elydora/plugins/hooktemplate.go | 10 +- cmd/elydora/plugins/jsonutil.go | 12 +- cmd/elydora/plugins/qwen.go | 9 +- cmd/elydora/plugins/runtime_paths.go | 128 ++++++++++++++++++++++ cmd/elydora/plugins/runtime_paths_test.go | 95 ++++++++++++++++ 10 files changed, 399 insertions(+), 51 deletions(-) create mode 100644 cmd/elydora/main_test.go create mode 100644 cmd/elydora/plugins/runtime_paths.go create mode 100644 cmd/elydora/plugins/runtime_paths_test.go diff --git a/AGENTS.md b/AGENTS.md index 811d57d..fd7930e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +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. ## Verification diff --git a/README.md b/README.md index 6cf0853..1cee30a 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ 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. + ```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 3ea90f5..fc665bf 100644 --- a/cmd/elydora/main.go +++ b/cmd/elydora/main.go @@ -92,16 +92,13 @@ func cmdInstall(args []string) { } // Generate guard script (PreToolUse — freeze enforcement) - guardScriptPath, err := guardScriptPathForAgent(*agentID) + agentDirectory, err := plugins.PrepareAgentRuntimeDirectory(*agentID) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + guardScriptPath := filepath.Join(agentDirectory, "guard.js") guardScript := plugins.GenerateGuardScript(*agent, *agentID) - if err := os.MkdirAll(filepath.Dir(guardScriptPath), 0755); err != nil { - fmt.Fprintf(os.Stderr, "Error creating guard script directory: %v\n", err) - os.Exit(1) - } if err := os.WriteFile(guardScriptPath, []byte(guardScript), 0755); err != nil { fmt.Fprintf(os.Stderr, "Error writing guard script: %v\n", err) os.Exit(1) @@ -149,13 +146,38 @@ func cmdUninstall(args []string) { os.Exit(1) } + runtimeRoot, err := plugins.AgentRuntimeRoot() + 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 = findAgentIDByName(*agent) - if resolvedAgentID == "" { - fmt.Fprintf(os.Stderr, "Warning: could not find agent ID for %q in ~/.elydora/*/config.json\n", *agent) + 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) + os.Exit(1) } if err := plugin.Uninstall(resolvedAgentID); err != nil { @@ -164,49 +186,71 @@ func cmdUninstall(args []string) { } // Remove the per-agent directory - if resolvedAgentID != "" { - home, err := os.UserHomeDir() - if err == nil { - agentDir := filepath.Join(home, ".elydora", resolvedAgentID) - if err := os.RemoveAll(agentDir); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to remove agent directory %s: %v\n", agentDir, err) - } else { - fmt.Printf(" Removed agent directory: %s\n", agentDir) - } + if agentDirectoryExists { + if err := os.RemoveAll(agentDirectory); err != nil { + fmt.Fprintf(os.Stderr, "Error removing agent directory %s: %v\n", agentDirectory, err) + os.Exit(1) } + fmt.Printf(" Removed agent directory: %s\n", agentDirectory) } } // 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 { - home, err := os.UserHomeDir() +func findAgentIDByName(agentName string) (string, error) { + elydoraDir, err := plugins.AgentRuntimeRoot() if err != nil { - return "" + return "", err + } + exists, err := plugins.RequirePhysicalDirectory(elydoraDir) + if err != nil { + return "", err + } + if !exists { + return "", nil } - elydoraDir := filepath.Join(home, ".elydora") entries, err := os.ReadDir(elydoraDir) if err != nil { - return "" + return "", fmt.Errorf("read Elydora runtime root %s: %w", elydoraDir, err) } + foundAgentID := "" for _, entry := range entries { - if !entry.IsDir() { + if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() { continue } configPath := filepath.Join(elydoraDir, entry.Name(), "config.json") data, err := os.ReadFile(configPath) - if err != nil { + 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 { - continue + return "", fmt.Errorf("parse agent config %s: %w", configPath, err) } if name, _ := config["agent_name"].(string); name == agentName { - return entry.Name() + 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 "" + return foundAgentID, nil } // --------------------------------------------------------------------------- @@ -285,9 +329,9 @@ func sortedAgentNames() []string { // guardScriptPathForAgent returns the path to ~/.elydora//guard.js. func guardScriptPathForAgent(agentId string) (string, error) { - home, err := os.UserHomeDir() + agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(agentId) if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) + return "", err } - return filepath.Join(home, ".elydora", agentId, "guard.js"), nil + return filepath.Join(agentDirectory, "guard.js"), nil } diff --git a/cmd/elydora/main_test.go b/cmd/elydora/main_test.go new file mode 100644 index 0000000..af8a14c --- /dev/null +++ b/cmd/elydora/main_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func setMainTestHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + return home +} + +func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) { + setMainTestHome(t) + _, err := guardScriptPathForAgent("../escape") + if err == nil || !strings.Contains(err.Error(), "invalid agent ID") { + t.Fatalf("guardScriptPathForAgent() error = %v", err) + } +} + +func TestFindAgentIDByNameReturnsValidatedDirectoryIdentity(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":"opencode","agent_id":"agent-1"}`) + if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + agentID, err := findAgentIDByName("opencode") + if err != nil { + t.Fatalf("findAgentIDByName() error = %v", err) + } + if agentID != "agent-1" { + t.Fatalf("agent ID = %q, want agent-1", agentID) + } +} + +func TestFindAgentIDByNameRejectsConfigDirectoryMismatch(t *testing.T) { + home := setMainTestHome(t) + runtimeDirectory := filepath.Join(home, ".elydora", "stored-directory") + if err := os.MkdirAll(runtimeDirectory, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + config := []byte(`{"agent_name":"opencode","agent_id":"different-agent"}`) + if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + _, err := findAgentIDByName("opencode") + if err == nil || !strings.Contains(err.Error(), "crosses its runtime directory") { + t.Fatalf("findAgentIDByName() error = %v", err) + } +} + +func TestFindAgentIDByNameRejectsAmbiguousRuntimes(t *testing.T) { + home := setMainTestHome(t) + for _, agentID := range []string{"agent-1", "agent-2"} { + runtimeDirectory := filepath.Join(home, ".elydora", agentID) + if err := os.MkdirAll(runtimeDirectory, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + config := []byte(`{"agent_name":"opencode","agent_id":"` + agentID + `"}`) + if err := os.WriteFile(filepath.Join(runtimeDirectory, "config.json"), config, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + } + + _, err := findAgentIDByName("opencode") + if err == nil || !strings.Contains(err.Error(), "multiple") { + t.Fatalf("findAgentIDByName() error = %v", err) + } +} diff --git a/cmd/elydora/plugins/droid.go b/cmd/elydora/plugins/droid.go index 1cab914..8bf8172 100644 --- a/cmd/elydora/plugins/droid.go +++ b/cmd/elydora/plugins/droid.go @@ -153,14 +153,15 @@ func (p *DroidPlugin) Status() (PluginStatus, error) { } func droidAgentDirectory(agentID string) (string, string, error) { - if agentID == "" || agentID == "." || agentID == ".." || filepath.IsAbs(agentID) || filepath.Base(agentID) != agentID { - return "", "", fmt.Errorf("agent_id must be a single non-empty path segment") - } runtimeRoot, err := droidRuntimeRoot() if err != nil { return "", "", err } - return runtimeRoot, filepath.Join(runtimeRoot, agentID), nil + agentDirectory, err := ResolveAgentRuntimeDirectory(agentID) + if err != nil { + return "", "", err + } + return runtimeRoot, agentDirectory, nil } func droidSettingsDocument(settings *droidDocument) *droidDocument { diff --git a/cmd/elydora/plugins/hooktemplate.go b/cmd/elydora/plugins/hooktemplate.go index bf3be2d..f89303c 100644 --- a/cmd/elydora/plugins/hooktemplate.go +++ b/cmd/elydora/plugins/hooktemplate.go @@ -12,7 +12,7 @@ import ( // It always exits 0 so it never blocks the host agent. func GenerateHookScript(destPath string, config InstallConfig) error { dir := filepath.Dir(destPath) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := EnsurePrivateDirectory(dir); err != nil { return fmt.Errorf("create hook script directory: %w", err) } @@ -32,13 +32,9 @@ func GenerateHookScript(destPath string, config InstallConfig) error { // ~/.elydora//config.json and ~/.elydora//private.key so that // the generated hook script can read them at runtime without embedding secrets. func writeAgentConfig(config InstallConfig) error { - home, err := os.UserHomeDir() + agentDir, err := PrepareAgentRuntimeDirectory(config.AgentID) if err != nil { - return fmt.Errorf("resolve home directory: %w", err) - } - agentDir := filepath.Join(home, ".elydora", config.AgentID) - if err := os.MkdirAll(agentDir, 0755); err != nil { - return fmt.Errorf("create agent directory: %w", err) + return err } baseURL := config.BaseURL diff --git a/cmd/elydora/plugins/jsonutil.go b/cmd/elydora/plugins/jsonutil.go index 5d017a9..729b391 100644 --- a/cmd/elydora/plugins/jsonutil.go +++ b/cmd/elydora/plugins/jsonutil.go @@ -55,18 +55,18 @@ func writeJSONFile(path string, data map[string]interface{}) error { // hookScriptPath returns the default path for the hook script inside ~/.elydora//. func hookScriptPath(agentId string) (string, error) { - home, err := os.UserHomeDir() + agentDirectory, err := ResolveAgentRuntimeDirectory(agentId) if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) + return "", err } - return filepath.Join(home, ".elydora", agentId, "hook.js"), nil + return filepath.Join(agentDirectory, "hook.js"), nil } // guardScriptPath returns the default path for the guard script inside ~/.elydora//. func guardScriptPath(agentId string) (string, error) { - home, err := os.UserHomeDir() + agentDirectory, err := ResolveAgentRuntimeDirectory(agentId) if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) + return "", err } - return filepath.Join(home, ".elydora", agentId, "guard.js"), nil + return filepath.Join(agentDirectory, "guard.js"), nil } diff --git a/cmd/elydora/plugins/qwen.go b/cmd/elydora/plugins/qwen.go index 697c2aa..ab81eb8 100644 --- a/cmd/elydora/plugins/qwen.go +++ b/cmd/elydora/plugins/qwen.go @@ -119,14 +119,15 @@ func (p *QwenPlugin) Status() (PluginStatus, error) { } func qwenAgentDirectory(agentID string) (string, string, error) { - if agentID == "" || agentID == "." || agentID == ".." || filepath.IsAbs(agentID) || filepath.Base(agentID) != agentID { - return "", "", fmt.Errorf("agent_id must be a single non-empty path segment") - } runtimeRoot, err := qwenRuntimeRoot() if err != nil { return "", "", err } - return runtimeRoot, filepath.Join(runtimeRoot, agentID), nil + agentDirectory, err := ResolveAgentRuntimeDirectory(agentID) + if err != nil { + return "", "", err + } + return runtimeRoot, agentDirectory, nil } func prepareQwenInstallationChanges( diff --git a/cmd/elydora/plugins/runtime_paths.go b/cmd/elydora/plugins/runtime_paths.go new file mode 100644 index 0000000..aa5fc4d --- /dev/null +++ b/cmd/elydora/plugins/runtime_paths.go @@ -0,0 +1,128 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +var windowsDeviceName = regexp.MustCompile( + `(?i)^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)`, +) + +// AgentRuntimeRoot returns the local root for all Elydora agent runtimes. +func AgentRuntimeRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + root, err := filepath.Abs(filepath.Join(home, ".elydora")) + if err != nil { + return "", fmt.Errorf("resolve Elydora runtime root: %w", err) + } + return root, nil +} + +// ResolveAgentRuntimeDirectory confines an agent ID to one portable path segment. +func ResolveAgentRuntimeDirectory(agentID string) (string, error) { + if invalidAgentDirectoryName(agentID) { + return "", fmt.Errorf( + "invalid agent ID for local storage: %q must be a single non-empty path segment with a portable file name", + agentID, + ) + } + root, err := AgentRuntimeRoot() + if err != nil { + return "", err + } + candidate := filepath.Join(root, agentID) + relative, err := filepath.Rel(root, candidate) + if err != nil { + return "", fmt.Errorf("resolve agent runtime path for %q: %w", agentID, err) + } + if relative == "." || relative == ".." || filepath.IsAbs(relative) || + strings.HasPrefix(relative, ".."+string(os.PathSeparator)) || + strings.ContainsRune(relative, os.PathSeparator) { + return "", fmt.Errorf("agent ID escapes the local storage directory: %q", agentID) + } + if _, err := RequirePhysicalDirectory(root); err != nil { + return "", err + } + if _, err := RequirePhysicalDirectory(candidate); err != nil { + return "", err + } + return candidate, nil +} + +func invalidAgentDirectoryName(agentID string) bool { + if agentID == "" || agentID == "." || agentID == ".." || + strings.ContainsAny(agentID, `<>:"/\|?*`) || + strings.HasPrefix(agentID, " ") || strings.HasSuffix(agentID, ".") || + strings.HasSuffix(agentID, " ") || + windowsDeviceName.MatchString(agentID) { + return true + } + for _, character := range agentID { + if character <= 31 { + return true + } + } + return false +} + +// EnsurePrivateDirectory creates an owner-only physical directory. +func EnsurePrivateDirectory(path string) error { + if err := os.MkdirAll(path, 0700); err != nil { + return fmt.Errorf("create private directory %s: %w", path, err) + } + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect private directory %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("private directory path is not a physical directory: %s", path) + } + if runtime.GOOS != "windows" { + if err := os.Chmod(path, 0700); err != nil { + return fmt.Errorf("restrict private directory %s: %w", path, err) + } + } + return nil +} + +// PrepareAgentRuntimeDirectory validates and creates the private runtime path. +func PrepareAgentRuntimeDirectory(agentID string) (string, error) { + root, err := AgentRuntimeRoot() + if err != nil { + return "", err + } + agentDirectory, err := ResolveAgentRuntimeDirectory(agentID) + if err != nil { + return "", err + } + if err := EnsurePrivateDirectory(root); err != nil { + return "", err + } + if err := EnsurePrivateDirectory(agentDirectory); err != nil { + return "", err + } + return agentDirectory, nil +} + +// RequirePhysicalDirectory reports whether a path is an existing physical directory. +func RequirePhysicalDirectory(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 directory %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return false, fmt.Errorf("agent runtime path is not a physical directory: %s", path) + } + return true, nil +} diff --git a/cmd/elydora/plugins/runtime_paths_test.go b/cmd/elydora/plugins/runtime_paths_test.go new file mode 100644 index 0000000..908db7f --- /dev/null +++ b/cmd/elydora/plugins/runtime_paths_test.go @@ -0,0 +1,95 @@ +package plugins + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func setRuntimeTestHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + return home +} + +func TestResolveAgentRuntimeDirectoryRejectsUnsafePortableNames(t *testing.T) { + setRuntimeTestHome(t) + invalid := []string{ + "../escape", + `..\escape`, + "C:escape", + "agent.", + "agent ", + " agent", + "CON", + "COM¹.log", + ".", + "..", + } + for _, agentID := range invalid { + t.Run(agentID, func(t *testing.T) { + _, err := ResolveAgentRuntimeDirectory(agentID) + if err == nil || !strings.Contains(err.Error(), "invalid agent ID") { + t.Fatalf("ResolveAgentRuntimeDirectory(%q) error = %v", agentID, err) + } + }) + } +} + +func TestPrepareAgentRuntimeDirectoryCreatesPrivatePhysicalChild(t *testing.T) { + home := setRuntimeTestHome(t) + agentDirectory, err := PrepareAgentRuntimeDirectory("agent-1") + if err != nil { + t.Fatalf("PrepareAgentRuntimeDirectory() error = %v", err) + } + want := filepath.Join(home, ".elydora", "agent-1") + if agentDirectory != want { + t.Fatalf("agent directory = %q, want %q", agentDirectory, want) + } + for _, directory := range []string{filepath.Dir(agentDirectory), agentDirectory} { + info, err := os.Lstat(directory) + if err != nil { + t.Fatalf("Lstat(%q) error = %v", directory, err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("%q is not a physical directory", directory) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0700 { + t.Fatalf("%q mode = %o, want 700", directory, info.Mode().Perm()) + } + } +} + +func TestPrepareAgentRuntimeDirectoryRejectsSymlinkRoot(t *testing.T) { + home := setRuntimeTestHome(t) + target := t.TempDir() + root := filepath.Join(home, ".elydora") + if err := os.Symlink(target, root); err != nil { + t.Skipf("directory symbolic links unavailable: %v", err) + } + _, err := PrepareAgentRuntimeDirectory("agent-1") + if err == nil || !strings.Contains(err.Error(), "physical directory") { + t.Fatalf("PrepareAgentRuntimeDirectory() error = %v", err) + } +} + +func TestResolveAgentRuntimeDirectoryRejectsSymlinkChild(t *testing.T) { + home := setRuntimeTestHome(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 := ResolveAgentRuntimeDirectory("agent-1") + if err == nil || !strings.Contains(err.Error(), "physical directory") { + t.Fatalf("ResolveAgentRuntimeDirectory() error = %v", err) + } +}