From a7fc75bf6b3993ba032634a19ccd5120639279db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 3 Sep 2026 01:42:27 -0400 Subject: [PATCH] chore(go): audit providers claudecode codex cursor gemini Add managed_runtime.go (paths, config validation, identity, runtime file changes, status checks) and shell_command.go (quoting, PowerShell encoding, command parsing) and move the four providers onto them. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq --- cmd/elydora/plugins/augment_io.go | 2 +- cmd/elydora/plugins/claudecode.go | 9 +- cmd/elydora/plugins/claudecode_contract.go | 6 +- .../plugins/claudecode_installation.go | 172 +----- cmd/elydora/plugins/claudecode_io.go | 35 -- cmd/elydora/plugins/claudecode_ownership.go | 81 +-- cmd/elydora/plugins/claudecode_status.go | 230 +------- .../plugins/claudecode_transaction_test.go | 7 +- cmd/elydora/plugins/cline_installation.go | 2 +- cmd/elydora/plugins/codex.go | 12 +- cmd/elydora/plugins/codex_command.go | 192 +------ cmd/elydora/plugins/codex_contract.go | 49 +- cmd/elydora/plugins/codex_installation.go | 147 +----- cmd/elydora/plugins/codex_io.go | 150 +----- cmd/elydora/plugins/codex_recovery_test.go | 2 +- cmd/elydora/plugins/codex_test.go | 6 +- cmd/elydora/plugins/cursor.go | 80 +-- cmd/elydora/plugins/cursor_contract.go | 130 +---- cmd/elydora/plugins/cursor_io.go | 287 +--------- cmd/elydora/plugins/cursor_recovery_test.go | 4 +- cmd/elydora/plugins/cursor_test.go | 2 +- cmd/elydora/plugins/gemini.go | 9 +- cmd/elydora/plugins/gemini_command.go | 89 +--- cmd/elydora/plugins/gemini_contract.go | 28 +- cmd/elydora/plugins/gemini_installation.go | 180 +------ cmd/elydora/plugins/gemini_io.go | 35 -- cmd/elydora/plugins/gemini_status.go | 230 +------- cmd/elydora/plugins/gemini_test.go | 8 +- .../plugins/gemini_test_helpers_test.go | 2 +- .../plugins/gemini_transaction_test.go | 9 +- cmd/elydora/plugins/grok_command.go | 6 +- cmd/elydora/plugins/jsonc.go | 10 + cmd/elydora/plugins/kimi_command.go | 6 +- cmd/elydora/plugins/kirocli_io.go | 17 - cmd/elydora/plugins/kiroide_command.go | 8 +- cmd/elydora/plugins/kiroide_test.go | 2 +- cmd/elydora/plugins/letta.go | 1 - cmd/elydora/plugins/managed_runtime.go | 489 ++++++++++++++++++ cmd/elydora/plugins/shell_command.go | 198 +++++++ 39 files changed, 914 insertions(+), 2018 deletions(-) create mode 100644 cmd/elydora/plugins/managed_runtime.go create mode 100644 cmd/elydora/plugins/shell_command.go diff --git a/cmd/elydora/plugins/augment_io.go b/cmd/elydora/plugins/augment_io.go index 40aeff9..0d0b90e 100644 --- a/cmd/elydora/plugins/augment_io.go +++ b/cmd/elydora/plugins/augment_io.go @@ -211,7 +211,7 @@ func resolveAugmentWrapperPaths(agentDirectory string) augmentWrapperPaths { } func requireAugmentAbsoluteNode(nodePath string) error { - if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) { + if !filepath.IsAbs(nodePath) || !isNodeExecutable(nodePath) { return fmt.Errorf("Auggie hooks require an absolute Node.js executable path") } return nil diff --git a/cmd/elydora/plugins/claudecode.go b/cmd/elydora/plugins/claudecode.go index cbbe5a2..4ef9ad1 100644 --- a/cmd/elydora/plugins/claudecode.go +++ b/cmd/elydora/plugins/claudecode.go @@ -10,8 +10,7 @@ type ClaudeCodePlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Claude Code commits its provider guard with -// the audit runtime and user settings document. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool { return true } @@ -73,13 +72,14 @@ func (p *ClaudeCodePlugin) Install(config InstallConfig) error { if err != nil { return err } - if err := writeClaudeChanges( + if err := writeManagedChanges( changes, "Install Claude Code hooks", p.rename, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.filePath), + "Claude Code configuration directory", ); err != nil { return err } @@ -105,13 +105,14 @@ func (p *ClaudeCodePlugin) Uninstall(agentID string) error { if err != nil { return err } - return writeClaudeChanges( + return writeManagedChanges( []*fileChange{change}, "Uninstall Claude Code hooks", p.rename, "", "", filepath.Dir(document.filePath), + "Claude Code configuration directory", ) } diff --git a/cmd/elydora/plugins/claudecode_contract.go b/cmd/elydora/plugins/claudecode_contract.go index 4994cd3..687d68c 100644 --- a/cmd/elydora/plugins/claudecode_contract.go +++ b/cmd/elydora/plugins/claudecode_contract.go @@ -77,11 +77,7 @@ type claudeRenderedDocument struct { remove bool } -type claudeRuntimeContract struct { - agentID string - guardPath string - auditPath string -} +type claudeRuntimeContract = managedRuntimeContract func cloneClaudeObject(value map[string]any) map[string]any { clone := make(map[string]any, len(value)) diff --git a/cmd/elydora/plugins/claudecode_installation.go b/cmd/elydora/plugins/claudecode_installation.go index 27f5a5e..7a3ee21 100644 --- a/cmd/elydora/plugins/claudecode_installation.go +++ b/cmd/elydora/plugins/claudecode_installation.go @@ -1,182 +1,48 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) - -type claudeRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string -} - -func validateClaudeInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != claudeAgentKey { - return fmt.Errorf( - "Claude Code installation requires agent name %s", - claudeAgentKey, - ) - } - if strings.TrimSpace(config.OrgID) == "" { - return fmt.Errorf("organization ID is required") - } - if strings.TrimSpace(config.KID) == "" { - return fmt.Errorf("key ID is required") - } - if config.Token != "" && strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("token must contain a non-whitespace value when provided") - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func claudeAgentPaths(config InstallConfig) (*claudeRuntimePaths, error) { - if err := validateClaudeInstallConfig(config); err != nil { - return nil, err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &claudeRuntimePaths{ - runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, claudeGuardScript), - auditPath: filepath.Join(agentDirectory, claudeAuditScript), - } - if !filepath.IsAbs(config.GuardScriptPath) || - !sameClaudePath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "guard runtime must use the managed Elydora agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || - !sameClaudePath(config.HookScript, paths.auditPath)) { - return nil, fmt.Errorf( - "audit runtime must use the managed Elydora agent directory: %s", - paths.auditPath, - ) - } - return paths, nil -} +import "fmt" func preflightClaudeInstallation( config InstallConfig, document *claudeDocument, -) (*claudeRuntimePaths, string, error) { +) (*managedRuntimePaths, string, error) { if document == nil || document.filePath == "" { - return nil, "", fmt.Errorf( - "Claude Code installation requires a user settings document", - ) + return nil, "", fmt.Errorf("Claude Code installation requires a user settings document") } - paths, err := claudeAgentPaths(config) + if err := validateManagedInstallConfig(config, claudeAgentKey, "Claude Code"); err != nil { + return nil, "", err + } + paths, err := resolveManagedRuntimePaths(config, claudeGuardScript, claudeAuditScript) if err != nil { return nil, "", err } - if err := validateClaudeRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, claudeAgentKey, "Claude Code", + ); err != nil { return nil, "", err } - nodePath, err := resolveNodeRuntime() + nodePath, err := resolveAbsoluteNodeRuntime("Claude Code") if err != nil { return nil, "", err } - if err := requireClaudeAbsoluteNode(nodePath); err != nil { - return nil, "", err - } return paths, nodePath, nil } -func buildClaudeRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, - "base_url": config.BaseURL, "agent_name": claudeAgentKey, - } - if config.Token != "" { - value["token"] = config.Token - } - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil -} - func prepareClaudeInstallationChanges( config InstallConfig, - paths *claudeRuntimePaths, + paths *managedRuntimePaths, rendered *claudeRenderedDocument, ) ([]*fileChange, error) { - runtimeConfig, err := buildClaudeRuntimeConfig(config) + changes, err := managedRuntimeFileChanges( + config, + paths, + claudeAgentKey, + generateGuardScript(claudeAgentKey, config.AgentID, "", false, ""), + buildHookScriptWithOutput(claudeAgentKey, config.AgentID, "", false, true), + ) if err != nil { return nil, err } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript(claudeAgentKey, config.AgentID, "", false, "")), - 0700, - }, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - claudeAgentKey, - config.AgentID, - "", - false, - true, - )), - 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+1) - for _, item := range items { - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) - if err != nil { - return nil, err - } - changes = append(changes, change) - } documentChange, err := prepareRenderedClaudeChange(rendered) if err != nil { return nil, err diff --git a/cmd/elydora/plugins/claudecode_io.go b/cmd/elydora/plugins/claudecode_io.go index 996ec83..596fe36 100644 --- a/cmd/elydora/plugins/claudecode_io.go +++ b/cmd/elydora/plugins/claudecode_io.go @@ -71,38 +71,3 @@ func prepareRenderedClaudeChange( rendered.remove, ) } - -func writeClaudeChanges( - changes []*fileChange, - label string, - rename renameFunc, - runtimeRoot string, - agentDirectory string, - settingsDirectory string, -) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { - return nil - } - if agentDirectory != "" { - if err := EnsurePrivateDirectory(runtimeRoot); err != nil { - return err - } - if err := EnsurePrivateDirectory(agentDirectory); err != nil { - return err - } - } - if err := ensureManagedDirectory( - settingsDirectory, - "Claude Code configuration directory", - ); err != nil { - return err - } - return writeChanges(changes, label, rename) -} diff --git a/cmd/elydora/plugins/claudecode_ownership.go b/cmd/elydora/plugins/claudecode_ownership.go index f3c6aa4..3680cb7 100644 --- a/cmd/elydora/plugins/claudecode_ownership.go +++ b/cmd/elydora/plugins/claudecode_ownership.go @@ -1,73 +1,18 @@ package plugins import ( - "fmt" "path/filepath" - "runtime" "sort" "strings" ) -type claudeRuntimeReference struct { - agentID string - scriptPath string -} - -func sameClaudePath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameClaudeAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func isClaudeNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} - -func sameClaudeFileName(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} +type claudeRuntimeReference = managedScriptReference func claudeRuntimeReferenceForScript( scriptPath string, scriptName string, ) (*claudeRuntimeReference, error) { - if !filepath.IsAbs(scriptPath) || - !sameClaudeFileName(filepath.Base(scriptPath), scriptName) { - return nil, nil - } - agentDirectory := filepath.Dir(scriptPath) - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) { - return nil, nil - } - agentID := filepath.Base(agentDirectory) - if agentID == "" || agentID == "." || agentID == ".." { - return nil, nil - } - return &claudeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil + return resolveManagedScript(scriptPath, scriptName) } func claudeLegacyReference( @@ -95,7 +40,7 @@ func managedClaudeReference( handler["statusMessage"] == statusMessage { nodePath, nodeOK := handler["command"].(string) args, argsOK := handler["args"].([]any) - if nodeOK && filepath.IsAbs(nodePath) && isClaudeNodeExecutable(nodePath) && + if nodeOK && filepath.IsAbs(nodePath) && isNodeExecutable(nodePath) && argsOK && len(args) == 1 { if scriptPath, ok := args[0].(string); ok { return claudeRuntimeReferenceForScript(scriptPath, scriptName) @@ -146,7 +91,7 @@ func removeManagedClaudeGroups( return nil, false, err } owned := reference != nil && - (agentID == "" || sameClaudeAgentID(reference.agentID, agentID)) + (agentID == "" || sameManagedAgentID(reference.agentID, agentID)) if owned { removed = true continue @@ -235,13 +180,6 @@ func entirelyManagedClaudeDocument(document *claudeDocument) bool { return handlerCount > 0 } -func claudeReferenceKey(agentID string) string { - if runtime.GOOS == "windows" { - return strings.ToLower(agentID) - } - return agentID -} - func claudeReferencesForEvent( groups []claudeGroup, scriptName string, @@ -263,7 +201,7 @@ func claudeReferencesForEvent( return nil, err } if reference != nil { - key := claudeReferenceKey(reference.agentID) + key := managedReferenceKey(reference.agentID) result[key] = append(result[key], *reference) } } @@ -307,7 +245,7 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error) success := successes[key] failure := failures[key] if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || - !sameClaudePath(success[0].scriptPath, failure[0].scriptPath) { + !sameManagedPath(success[0].scriptPath, failure[0].scriptPath) { continue } contracts = append(contracts, claudeRuntimeContract{ @@ -317,10 +255,3 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error) } return contracts, nil } - -func requireClaudeAbsoluteNode(nodePath string) error { - if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) { - return fmt.Errorf("Claude Code hooks require an absolute Node.js executable path") - } - return nil -} diff --git a/cmd/elydora/plugins/claudecode_status.go b/cmd/elydora/plugins/claudecode_status.go index 781cb21..7c8ea88 100644 --- a/cmd/elydora/plugins/claudecode_status.go +++ b/cmd/elydora/plugins/claudecode_status.go @@ -1,231 +1,5 @@ package plugins -import ( - "fmt" - "path/filepath" - "sort" - "strings" -) - -func readClaudeRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - config, err := decodeStrictJSONObject(snapshot.contents, label) - return config, true, err -} - -func requireClaudeRuntimeString( - config map[string]any, - field string, - configPath string, -) (string, error) { - value, ok := config[field].(string) - if !ok || strings.TrimSpace(value) == "" { - return "", fmt.Errorf( - "elydora runtime config %s is invalid: %s", - field, - configPath, - ) - } - return value, nil -} - -func validateClaudeRuntimeConfig( - config map[string]any, - expectedAgentID string, - configPath string, -) error { - supported := stringSet( - "org_id", "agent_id", "kid", "base_url", "token", "agent_name", - ) - fields := make([]string, 0, len(config)) - for field := range config { - fields = append(fields, field) - } - sort.Strings(fields) - for _, field := range fields { - if _, ok := supported[field]; !ok { - return fmt.Errorf( - `elydora runtime config has unsupported field %q: %s`, - field, - configPath, - ) - } - } - if _, err := requireClaudeRuntimeString(config, "org_id", configPath); err != nil { - return err - } - if _, err := requireClaudeRuntimeString(config, "kid", configPath); err != nil { - return err - } - agentID, err := requireClaudeRuntimeString(config, "agent_id", configPath) - if err != nil { - return err - } - if !sameClaudeAgentID(agentID, expectedAgentID) || - config["agent_name"] != claudeAgentKey { - return fmt.Errorf( - "elydora runtime identity does not match Claude Code hooks: %s", - configPath, - ) - } - if _, exists := config["token"]; exists { - if _, err := requireClaudeRuntimeString(config, "token", configPath); err != nil { - return err - } - } - baseURL, err := requireClaudeRuntimeString(config, "base_url", configPath) - if err != nil { - return err - } - if err := validateManagedBaseURL(baseURL); err != nil { - return fmt.Errorf( - "elydora runtime config base URL is invalid at %s: %w", - configPath, - err, - ) - } - return nil -} - -func validateClaudeRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readClaudeRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false - for _, item := range []struct { - path string - label string - limit int64 - }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, claudeGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, claudeAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return inspectErr - } - artifactExists = artifactExists || exists - } - if !configExists { - if artifactExists { - return fmt.Errorf( - "elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != claudeAgentKey || - !sameClaudeAgentID(configuredID, agentID) { - return fmt.Errorf( - "elydora runtime config identity does not match Claude Code agent %s: %s", - agentID, - configPath, - ) - } - return nil -} - -func claudeRuntimeContractExists(contract claudeRuntimeContract) (bool, error) { - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return false, err - } - agentDirectory := filepath.Dir(contract.guardPath) - if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) || - !sameClaudePath( - contract.auditPath, - filepath.Join(agentDirectory, claudeAuditScript), - ) { - return false, nil - } - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return false, err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return false, err - } - configPath := filepath.Join(agentDirectory, "config.json") - keyPath := filepath.Join(agentDirectory, "private.key") - config, configExists, err := readClaudeRuntimeConfig(configPath) - if err != nil { - return false, err - } - key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) - if err != nil { - return false, err - } - guard, err := readManagedFile( - contract.guardPath, - "Elydora guard runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - audit, err := readManagedFile( - contract.auditPath, - "Elydora audit runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - if !configExists || key == nil || guard == nil || audit == nil { - return false, nil - } - if err := validateClaudeRuntimeConfig(config, contract.agentID, configPath); err != nil { - return false, err - } - if err := validateManagedPrivateKey(string(key.contents)); err != nil { - return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err) - } - return len(guard.contents) > 0 && len(audit.contents) > 0, nil -} - -func claudeRuntimeFilesExist(contracts []claudeRuntimeContract) (bool, error) { - for _, contract := range contracts { - exists, err := claudeRuntimeContractExists(contract) - if err != nil { - return false, err - } - if exists { - return true, nil - } - } - return false, nil +func claudeRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesExist(contracts, claudeAgentKey, "Claude Code", nil) } diff --git a/cmd/elydora/plugins/claudecode_transaction_test.go b/cmd/elydora/plugins/claudecode_transaction_test.go index b46991b..8ec3073 100644 --- a/cmd/elydora/plugins/claudecode_transaction_test.go +++ b/cmd/elydora/plugins/claudecode_transaction_test.go @@ -60,7 +60,7 @@ func TestClaudeInstallRollsBackAllRuntimesAfterSettingsFailure(t *testing.T) { } before := snapshotClaudeFiles(t, paths...) fixture.plugin.rename = func(source, destination string) error { - if sameClaudePath(destination, fixture.configPath) && + if sameManagedPath(destination, fixture.configPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Claude settings failure") } @@ -114,13 +114,14 @@ func TestPreparedClaudeInstallRejectsConcurrentSettingsChange(t *testing.T) { if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil { t.Fatalf("write concurrent settings: %v", err) } - err = writeClaudeChanges( + err = writeManagedChanges( changes, "Install Claude Code hooks", nil, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.filePath), + "Claude Code configuration directory", ) if err == nil || !strings.Contains(err.Error(), "changed during installation") { t.Fatalf("prepared install error = %v", err) @@ -176,7 +177,7 @@ func TestClaudeUninstallPreservesSettingsAfterCommitFailure(t *testing.T) { t.Fatalf("read installed settings: %v", err) } fixture.plugin.rename = func(source, destination string) error { - if sameClaudePath(destination, fixture.configPath) && + if sameManagedPath(destination, fixture.configPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Claude uninstall failure") } diff --git a/cmd/elydora/plugins/cline_installation.go b/cmd/elydora/plugins/cline_installation.go index dff29e8..354dff0 100644 --- a/cmd/elydora/plugins/cline_installation.go +++ b/cmd/elydora/plugins/cline_installation.go @@ -109,7 +109,7 @@ func preflightClineInstallation( if err != nil { return nil, err } - if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) { + if !filepath.IsAbs(nodePath) || !isNodeExecutable(nodePath) { return nil, fmt.Errorf("Cline hooks require an absolute Node.js executable path") } return paths, nil diff --git a/cmd/elydora/plugins/codex.go b/cmd/elydora/plugins/codex.go index 2471184..686c862 100644 --- a/cmd/elydora/plugins/codex.go +++ b/cmd/elydora/plugins/codex.go @@ -10,14 +10,12 @@ type CodexPlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Codex commits its provider guard with the -// audit runtime and user hook document. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *CodexPlugin) ManagesGuardRuntime() bool { return true } -// PreflightInstall validates every existing source before the CLI creates -// runtime state. +// PreflightInstall validates every source before the CLI creates runtime state. func (p *CodexPlugin) PreflightInstall(config InstallConfig) error { document, err := readCodexDocument() if err != nil { @@ -61,13 +59,14 @@ func (p *CodexPlugin) Install(config InstallConfig) error { if err != nil { return err } - if err := writeCodexChanges( + if err := writeManagedChanges( changes, "Install Codex hooks", p.rename, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.filePath), + "Codex hooks directory", ); err != nil { return err } @@ -90,13 +89,14 @@ func (p *CodexPlugin) Uninstall(agentID string) error { if err != nil { return err } - return writeCodexChanges( + return writeManagedChanges( []*fileChange{change}, "Uninstall Codex hooks", p.rename, "", "", filepath.Dir(document.filePath), + "Codex hooks directory", ) } diff --git a/cmd/elydora/plugins/codex_command.go b/cmd/elydora/plugins/codex_command.go index dd42168..95b086e 100644 --- a/cmd/elydora/plugins/codex_command.go +++ b/cmd/elydora/plugins/codex_command.go @@ -1,53 +1,12 @@ package plugins -import ( - "encoding/base64" - "encoding/binary" - "os" - "path/filepath" - "runtime" - "strings" - "unicode/utf16" -) - -func quoteCodexPowerShell(value string) string { - return "'" + strings.ReplaceAll(value, "'", "''") + "'" -} - -func codexPowerShellPath() string { - root := "C:\\Windows" - if runtime.GOOS == "windows" { - configured := os.Getenv("SystemRoot") - if filepath.IsAbs(configured) && !strings.ContainsAny(configured, "\"%\r\n") { - root = configured - } - } - return strings.TrimRight(root, `\/`) + - `\System32\WindowsPowerShell\v1.0\powershell.exe` -} - -func encodeCodexPowerShell(source string) string { - runes := utf16.Encode([]rune(source)) - raw := make([]byte, len(runes)*2) - for index, value := range runes { - binary.LittleEndian.PutUint16(raw[index*2:], value) - } - return base64.StdEncoding.EncodeToString(raw) -} - -func codexWindowsCommand(runtimePath, scriptPath string) string { - source := "& " + quoteCodexPowerShell(runtimePath) + " " + - quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE" - return `"` + codexPowerShellPath() + - `" -NoLogo -NoProfile -NonInteractive -EncodedCommand ` + - encodeCodexPowerShell(source) -} +import "path/filepath" func codexHandler(runtimePath, scriptPath, statusMessage string) map[string]any { return map[string]any{ "type": "command", - "command": quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), - "commandWindows": codexWindowsCommand(runtimePath, scriptPath), + "command": posixSource(runtimePath, scriptPath), + "commandWindows": encodedWindowsCommand(runtimePath, scriptPath), "timeout": codexHookTimeout, "statusMessage": statusMessage, } @@ -57,124 +16,6 @@ func codexMatcherGroup(handler map[string]any) map[string]any { return map[string]any{"matcher": "*", "hooks": []any{handler}} } -func readCodexPOSIXArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); { - if strings.HasPrefix(command[index:], codexPOSIXApostrophe) { - value.WriteByte('\'') - index += len(codexPOSIXApostrophe) - continue - } - if command[index] == '\'' { - return value.String(), index + 1, true - } - value.WriteByte(command[index]) - index++ - } - return "", start, false -} - -func parseCodexPOSIXCommand(value any) (string, string, bool) { - command, ok := value.(string) - if !ok { - return "", "", false - } - runtimePath, next, ok := readCodexPOSIXArgument(command, 0) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - scriptPath, end, ok := readCodexPOSIXArgument(command, next+1) - return runtimePath, scriptPath, ok && end == len(command) -} - -func readCodexPowerShellArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); index++ { - if command[index] != '\'' { - value.WriteByte(command[index]) - continue - } - if index+1 < len(command) && command[index+1] == '\'' { - value.WriteByte('\'') - index++ - continue - } - return value.String(), index + 1, true - } - return "", start, false -} - -func parseCodexPowerShellSource(source string) (string, string, bool) { - if !strings.HasPrefix(source, "& ") { - return "", "", false - } - runtimePath, next, ok := readCodexPowerShellArgument(source, 2) - if !ok || next >= len(source) || source[next] != ' ' { - return "", "", false - } - scriptPath, end, ok := readCodexPowerShellArgument(source, next+1) - return runtimePath, scriptPath, ok && source[end:] == "; exit $LASTEXITCODE" -} - -func windowsPathBase(value string) string { - index := strings.LastIndexAny(value, `\/`) - return value[index+1:] -} - -func isAbsoluteWindowsPath(value string) bool { - return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || - (value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' && - (value[2] == '\\' || value[2] == '/') || strings.HasPrefix(value, `\\`) -} - -func decodeCodexPowerShell(encoded string) (string, bool) { - raw, err := base64.StdEncoding.DecodeString(encoded) - if err != nil || base64.StdEncoding.EncodeToString(raw) != encoded || len(raw)%2 != 0 { - return "", false - } - units := make([]uint16, len(raw)/2) - for index := range units { - units[index] = uint16(raw[index*2]) | uint16(raw[index*2+1])<<8 - } - return string(utf16.Decode(units)), true -} - -func parseCodexWindowsCommand(value any) (string, string, bool) { - command, ok := value.(string) - if !ok || !strings.HasPrefix(command, `"`) { - return "", "", false - } - closing := strings.IndexByte(command[1:], '"') - if closing < 0 { - return "", "", false - } - closing++ - powerShell := command[1:closing] - prefix := `" -NoLogo -NoProfile -NonInteractive -EncodedCommand ` - if !isAbsoluteWindowsPath(powerShell) || - !strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") || - !strings.HasPrefix(command[closing:], prefix) { - return "", "", false - } - encoded := command[closing+len(prefix):] - source, ok := decodeCodexPowerShell(encoded) - if !ok { - return "", "", false - } - return parseCodexPowerShellSource(source) -} - -func isCodexNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} - func exactCodexHandlerKeys(handler map[string]any) bool { if len(handler) != 5 { return false @@ -192,20 +33,21 @@ func codexManagedScriptPath(handler map[string]any, status string) (string, bool handler["timeout"] != codexHookTimeout || handler["statusMessage"] != status { return "", false } - posixRuntime, posixScript, posixOK := parseCodexPOSIXCommand(handler["command"]) - windowsRuntime, windowsScript, windowsOK := parseCodexWindowsCommand(handler["commandWindows"]) + command, _ := handler["command"].(string) + commandWindows, _ := handler["commandWindows"].(string) + posixRuntime, posixScript, posixOK := parsePOSIXCommand(command) + windowsRuntime, windowsScript, windowsOK := parseEncodedWindowsCommand(commandWindows) if !windowsOK && posixOK { - legacy, ok := handler["commandWindows"].(string) expected := quoteWindowsArgument(posixRuntime) + " " + quoteWindowsArgument(posixScript) - if ok && legacy == expected { + if commandWindows == expected { windowsRuntime, windowsScript, windowsOK = posixRuntime, posixScript, true } } if !posixOK || !windowsOK || !filepath.IsAbs(posixRuntime) || - !filepath.IsAbs(posixScript) || !isCodexNodeExecutable(posixRuntime) || - !isCodexNodeExecutable(windowsRuntime) || - !sameCodexPath(posixRuntime, windowsRuntime) || - !sameCodexPath(posixScript, windowsScript) { + !filepath.IsAbs(posixScript) || !isNodeExecutable(posixRuntime) || + !isNodeExecutable(windowsRuntime) || + !sameManagedPath(posixRuntime, windowsRuntime) || + !sameManagedPath(posixScript, windowsScript) { return "", false } return posixScript, true @@ -217,14 +59,12 @@ func codexManagedAgentID( status string, ) (string, bool) { scriptPath, managed := codexManagedScriptPath(handler, status) - if !managed || filepath.Base(scriptPath) != scriptName { + if !managed { return "", false } - agentDirectory := filepath.Dir(scriptPath) - runtimeRoot, err := AgentRuntimeRoot() - if err != nil || !sameCodexPath(filepath.Dir(agentDirectory), runtimeRoot) { + reference, err := resolveManagedScript(scriptPath, scriptName) + if err != nil || reference == nil { return "", false } - agentID := filepath.Base(agentDirectory) - return agentID, agentID != "" && agentID != "." && agentID != ".." + return reference.agentID, true } diff --git a/cmd/elydora/plugins/codex_contract.go b/cmd/elydora/plugins/codex_contract.go index b8534bf..25983eb 100644 --- a/cmd/elydora/plugins/codex_contract.go +++ b/cmd/elydora/plugins/codex_contract.go @@ -6,9 +6,7 @@ import ( "fmt" "path/filepath" "reflect" - "runtime" "sort" - "strings" ) const ( @@ -20,7 +18,6 @@ const ( codexOwnedDescription = "Elydora audit and freeze enforcement" codexGuardStatusMessage = "Checking Elydora agent state" codexAuditStatusMessage = "Recording Elydora tool use" - codexPOSIXApostrophe = `'"'"'` ) type codexHooks map[string][]map[string]any @@ -40,11 +37,7 @@ type codexRenderedDocument struct { remove bool } -type codexRuntimeContract struct { - agentID string - guardPath string - auditPath string -} +type codexRuntimeContract = managedRuntimeContract func cloneCodexObject(value map[string]any) map[string]any { clone := make(map[string]any, len(value)) @@ -62,28 +55,6 @@ func cloneCodexHooks(source codexHooks) codexHooks { return clone } -func sameCodexPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameCodexAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - func readCodexHooks(value any, label string) (codexHooks, error) { object, ok := value.(map[string]any) if !ok { @@ -126,16 +97,6 @@ func readCodexHooks(value any, label string) (codexHooks, error) { return hooks, nil } -func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) { - if !json.Valid(source) { - var value any - if err := json.Unmarshal(source, &value); err != nil { - return nil, fmt.Errorf("parse %s: %w", label, err) - } - } - return decodeJSONCObject(source, label, false) -} - func parseCodexDocument(filePath string, raw []byte) (*codexDocument, error) { label := fmt.Sprintf("Codex user hooks at %s", filePath) root, err := decodeStrictJSONObject(raw, label) @@ -180,7 +141,7 @@ func removeCodexGroups( for _, value := range handlers { handler := value.(map[string]any) managedID, managed := codexManagedAgentID(handler, scriptName, status) - if managed && (agentID == "" || sameCodexAgentID(managedID, agentID)) { + if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) { continue } kept = append(kept, handler) @@ -295,11 +256,7 @@ func managedCodexIDs( if !managed { continue } - key := agentID - if runtime.GOOS == "windows" { - key = strings.ToLower(agentID) - } - result[key] = agentID + result[managedReferenceKey(agentID)] = agentID } } return result diff --git a/cmd/elydora/plugins/codex_installation.go b/cmd/elydora/plugins/codex_installation.go index 31ea6f0..f74a5f9 100644 --- a/cmd/elydora/plugins/codex_installation.go +++ b/cmd/elydora/plugins/codex_installation.go @@ -1,94 +1,26 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) - -type codexRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string -} - -func validateCodexInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != codexAgentKey { - return fmt.Errorf("codex installation requires agent name %s", codexAgentKey) - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) { - if err := validateCodexInstallConfig(config); err != nil { - return nil, err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &codexRuntimePaths{ - runtimeRoot: runtimeRoot, - agentDirectory: agentDirectory, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, codexGuardScript), - auditPath: filepath.Join(agentDirectory, codexAuditScript), - } - if !sameCodexPath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "guard runtime must use the managed Elydora agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && !sameCodexPath(config.HookScript, paths.auditPath) { - return nil, fmt.Errorf( - "audit runtime must use the managed Elydora agent directory: %s", - paths.auditPath, - ) - } - return paths, nil -} +import "path/filepath" func preflightCodexInstallation( config InstallConfig, hooksPath string, -) (*codexRuntimePaths, string, error) { - paths, err := codexAgentPaths(config) +) (*managedRuntimePaths, string, error) { + if err := validateManagedInstallConfig(config, codexAgentKey, "Codex"); err != nil { + return nil, "", err + } + paths, err := resolveManagedRuntimePaths(config, codexGuardScript, codexAuditScript) if err != nil { return nil, "", err } if _, err := managedPhysicalDirectoryExists( - filepath.Dir(hooksPath), - "Codex hooks directory", + filepath.Dir(hooksPath), "Codex hooks directory", ); err != nil { return nil, "", err } - if err := validateCodexRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, codexAgentKey, "Codex", + ); err != nil { return nil, "", err } nodePath, err := resolveNodeRuntime() @@ -98,64 +30,21 @@ func preflightCodexInstallation( return paths, nodePath, nil } -func buildCodexRuntimeConfig(config InstallConfig) ([]byte, error) { - encoded, err := json.MarshalIndent(agentRuntimeConfig{ - OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID, - BaseURL: config.BaseURL, Token: config.Token, AgentName: codexAgentKey, - }, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil -} - func prepareCodexInstallationChanges( config InstallConfig, - paths *codexRuntimePaths, + paths *managedRuntimePaths, rendered *codexRenderedDocument, ) ([]*fileChange, error) { - runtimeConfig, err := buildCodexRuntimeConfig(config) + changes, err := managedRuntimeFileChanges( + config, + paths, + codexAgentKey, + generateGuardScript(codexAgentKey, config.AgentID, "", false, ""), + buildHookScriptWithOutput(codexAgentKey, config.AgentID, "", false, true), + ) if err != nil { return nil, err } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript(codexAgentKey, config.AgentID, "", false, "")), 0700, - }, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - codexAgentKey, config.AgentID, "", false, true, - )), 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+1) - for _, item := range items { - change, err := prepareFileChange( - item.path, - item.label, - item.content, - item.mode, - ) - if err != nil { - return nil, err - } - changes = append(changes, change) - } documentChange, err := prepareRenderedCodexChange(rendered) if err != nil { return nil, err diff --git a/cmd/elydora/plugins/codex_io.go b/cmd/elydora/plugins/codex_io.go index ff63921..f388aa6 100644 --- a/cmd/elydora/plugins/codex_io.go +++ b/cmd/elydora/plugins/codex_io.go @@ -82,152 +82,6 @@ func prepareRenderedCodexChange(rendered *codexRenderedDocument) (*fileChange, e ) } -func writeCodexChanges( - changes []*fileChange, - label string, - rename renameFunc, - runtimeRoot string, - agentDirectory string, - hooksDirectory string, -) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { - return nil - } - if agentDirectory != "" { - if err := EnsurePrivateDirectory(runtimeRoot); err != nil { - return err - } - if err := EnsurePrivateDirectory(agentDirectory); err != nil { - return err - } - } - if err := ensureManagedDirectory(hooksDirectory, "Codex hooks directory"); err != nil { - return err - } - return writeChanges(changes, label, rename) -} - -func readCodexRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - config, err := decodeStrictJSONObject(snapshot.contents, label) - return config, true, err -} - -func validateCodexRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readCodexRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false - for _, item := range []struct { - path string - label string - limit int64 - }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, codexGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, codexAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return inspectErr - } - artifactExists = artifactExists || exists - } - if !configExists { - if artifactExists { - return fmt.Errorf( - "elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != codexAgentKey || - !sameCodexAgentID(configuredID, agentID) { - return fmt.Errorf( - "elydora runtime config identity does not match Codex agent %s: %s", - agentID, - configPath, - ) - } - return nil -} - -func codexRuntimeFilesExist(contracts []codexRuntimeContract) (bool, error) { - for _, contract := range contracts { - agentDirectory := filepath.Dir(contract.guardPath) - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil { - return false, err - } - if !directoryExists { - continue - } - config, exists, err := readCodexRuntimeConfig( - filepath.Join(agentDirectory, "config.json"), - ) - if err != nil { - return false, err - } - configuredID, idOK := config["agent_id"].(string) - if !exists || !idOK || config["agent_name"] != codexAgentKey || - !sameCodexAgentID(configuredID, contract.agentID) { - continue - } - complete := true - for _, item := range []struct { - path string - label string - limit int64 - }{ - {contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes}, - {contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return false, inspectErr - } - complete = complete && exists - } - if complete { - return true, nil - } - } - return false, nil +func codexRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesPresent(contracts, codexAgentKey) } diff --git a/cmd/elydora/plugins/codex_recovery_test.go b/cmd/elydora/plugins/codex_recovery_test.go index d7ea358..83794f0 100644 --- a/cmd/elydora/plugins/codex_recovery_test.go +++ b/cmd/elydora/plugins/codex_recovery_test.go @@ -249,7 +249,7 @@ func TestCodexInstallRollsBackAllFilesOnHooksCommitFailure(t *testing.T) { source := []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep"}]}]}}`) fixture := prepareCodexFixture(t, string(source)) fixture.plugin.rename = func(source, destination string) error { - if sameCodexPath(destination, fixture.configPath) { + if sameManagedPath(destination, fixture.configPath) { return errors.New("injected Codex hooks commit failure") } return os.Rename(source, destination) diff --git a/cmd/elydora/plugins/codex_test.go b/cmd/elydora/plugins/codex_test.go index cb8381b..9fa3004 100644 --- a/cmd/elydora/plugins/codex_test.go +++ b/cmd/elydora/plugins/codex_test.go @@ -355,10 +355,10 @@ func TestCodexUninstallPreservesUnrelatedHooksAndExactAgent(t *testing.T) { if len(requireArray(t, hooks["PreToolUse"])) != 2 || len(requireArray(t, hooks["PostToolUse"])) != 1 { t.Fatalf("unexpected remaining hooks: %#v", hooks) } - _, remainingScript, parsed := parseCodexPOSIXCommand( - requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"], + _, remainingScript, parsed := parsePOSIXCommand( + requireCodexHandler(t, remaining, "PostToolUse", codexAuditStatus)["command"].(string), ) - if !parsed || !sameCodexPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) { + if !parsed || !sameManagedPath(remainingScript, filepath.Join(otherDir, codexAuditScript)) { t.Fatalf("other agent handler was removed: %#v", hooks) } } diff --git a/cmd/elydora/plugins/cursor.go b/cmd/elydora/plugins/cursor.go index f709ea8..bb403e3 100644 --- a/cmd/elydora/plugins/cursor.go +++ b/cmd/elydora/plugins/cursor.go @@ -10,55 +10,27 @@ type CursorPlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Cursor commits its provider-specific guard -// in the same transaction as the rest of the installation. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *CursorPlugin) ManagesGuardRuntime() bool { return true } -func cursorAgentPaths(config InstallConfig) ( - runtimeRoot, agentDirectory, guardPath, auditPath string, - err error, -) { - if config.AgentID == "" { - return "", "", "", "", fmt.Errorf("agent ID is required") - } - runtimeRoot, err = AgentRuntimeRoot() - if err != nil { - return "", "", "", "", err - } - agentDirectory, err = ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return "", "", "", "", err - } - guardPath = filepath.Join(agentDirectory, cursorGuardScript) - if !sameCursorPath(config.GuardScriptPath, guardPath) { - return "", "", "", "", fmt.Errorf( - "Elydora guard runtime must use the managed agent directory: %s", - guardPath, - ) - } - auditPath = filepath.Join(agentDirectory, cursorAuditScript) - if config.HookScript != "" && !sameCursorPath(config.HookScript, auditPath) { - return "", "", "", "", fmt.Errorf( - "Elydora audit runtime must use the managed agent directory: %s", - auditPath, - ) - } - return runtimeRoot, agentDirectory, guardPath, auditPath, nil +func cursorAgentPaths(config InstallConfig) (*managedRuntimePaths, error) { + return resolveManagedRuntimePaths(config, cursorGuardScript, cursorAuditScript) } -// PreflightInstall validates every existing source before the CLI creates a -// runtime directory or guard file. +// PreflightInstall validates every source before the CLI creates runtime state. func (p *CursorPlugin) PreflightInstall(config InstallConfig) error { if _, err := readCursorDocument(); err != nil { return err } - _, agentDirectory, _, _, err := cursorAgentPaths(config) + paths, err := cursorAgentPaths(config) if err != nil { return err } - if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor", + ); err != nil { return err } _, err = resolveNodeRuntime() @@ -70,44 +42,42 @@ func (p *CursorPlugin) Install(config InstallConfig) error { if err != nil { return err } - runtimeRoot, agentDirectory, guardPath, auditPath, err := cursorAgentPaths(config) + paths, err := cursorAgentPaths(config) if err != nil { return err } - if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, cursorAgentKey, "Cursor", + ); err != nil { return err } nodePath, err := resolveNodeRuntime() if err != nil { return err } - hooks := removeManagedCursorHooks(document.hooks, runtimeRoot, "") - hooks["preToolUse"] = append( - hooks["preToolUse"], - buildCursorHandler(nodePath, guardPath), - ) - hooks["postToolUse"] = append( - hooks["postToolUse"], - buildCursorHandler(nodePath, auditPath), - ) + hooks := removeManagedCursorHooks(document.hooks, paths.runtimeRoot, "") + hooks["preToolUse"] = append(hooks["preToolUse"], buildCursorHandler(nodePath, paths.guardPath)) + hooks["postToolUse"] = append(hooks["postToolUse"], buildCursorHandler(nodePath, paths.auditPath)) hooks["postToolUseFailure"] = append( hooks["postToolUseFailure"], - buildCursorHandler(nodePath, auditPath), + buildCursorHandler(nodePath, paths.auditPath), ) - rendered, err := renderCursorDocument(document, hooks, runtimeRoot) + rendered, err := renderCursorDocument(document, hooks, paths.runtimeRoot) if err != nil { return fmt.Errorf("render Cursor user hooks: %w", err) } - changes, err := prepareCursorInstallationChanges(config, agentDirectory, rendered) + changes, err := prepareCursorInstallationChanges(config, paths, rendered) if err != nil { return err } - if err := writeCursorChanges( + if err := writeManagedChanges( changes, "Install Cursor hooks", p.rename, - runtimeRoot, - agentDirectory, + paths.runtimeRoot, + paths.agentDirectory, + filepath.Dir(document.filePath), + "Cursor hooks directory", ); err != nil { return err } @@ -133,12 +103,14 @@ func (p *CursorPlugin) Uninstall(agentID string) error { if err != nil { return err } - return writeCursorChanges( + return writeManagedChanges( []*fileChange{change}, "Uninstall Cursor hooks", p.rename, "", "", + filepath.Dir(document.filePath), + "Cursor hooks directory", ) } diff --git a/cmd/elydora/plugins/cursor_contract.go b/cmd/elydora/plugins/cursor_contract.go index 6b02783..c9ade4a 100644 --- a/cmd/elydora/plugins/cursor_contract.go +++ b/cmd/elydora/plugins/cursor_contract.go @@ -11,12 +11,11 @@ import ( ) const ( - cursorAgentKey = "cursor" - cursorConfigFile = "hooks.json" - cursorGuardScript = "guard.js" - cursorAuditScript = "hook.js" - cursorHookTimeout = float64(10) - cursorPOSIXApostrophe = `'"'"'` + cursorAgentKey = "cursor" + cursorConfigFile = "hooks.json" + cursorGuardScript = "guard.js" + cursorAuditScript = "hook.js" + cursorHookTimeout = float64(10) ) type cursorHooks map[string][]map[string]any @@ -36,11 +35,7 @@ type cursorRenderedDocument struct { remove bool } -type cursorRuntimeContract struct { - agentID string - guardPath string - auditPath string -} +type cursorRuntimeContract = managedRuntimeContract func cloneCursorHooks(source cursorHooks) cursorHooks { clone := make(cursorHooks, len(source)) @@ -50,104 +45,16 @@ func cloneCursorHooks(source cursorHooks) cursorHooks { return clone } -func sameCursorPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameCursorAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func quoteCursorPowerShell(value string) string { - return "'" + strings.ReplaceAll(value, "'", "''") + "'" -} - func buildCursorHandler(nodePath, scriptPath string) map[string]any { - command := quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) + command := posixSource(nodePath, scriptPath) if runtime.GOOS == "windows" { - command = "& " + quoteCursorPowerShell(nodePath) + " " + - quoteCursorPowerShell(scriptPath) + "; exit $LASTEXITCODE" + command = powerShellSource(nodePath, scriptPath) } return map[string]any{ "command": command, "timeout": cursorHookTimeout, "failClosed": true, } } -func readCursorPOSIXArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); { - if strings.HasPrefix(command[index:], cursorPOSIXApostrophe) { - value.WriteByte('\'') - index += len(cursorPOSIXApostrophe) - continue - } - if command[index] == '\'' { - return value.String(), index + 1, true - } - value.WriteByte(command[index]) - index++ - } - return "", start, false -} - -func readCursorPowerShellArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '\'' { - return "", start, false - } - var value strings.Builder - for index := start + 1; index < len(command); index++ { - if command[index] != '\'' { - value.WriteByte(command[index]) - continue - } - if index+1 < len(command) && command[index+1] == '\'' { - value.WriteByte('\'') - index++ - continue - } - return value.String(), index + 1, true - } - return "", start, false -} - -func parseCursorPOSIXCommand(command string) (string, string, bool) { - executable, next, ok := readCursorPOSIXArgument(command, 0) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - script, end, ok := readCursorPOSIXArgument(command, next+1) - return executable, script, ok && end == len(command) -} - -func parseCursorPowerShellCommand(command string) (string, string, bool) { - if !strings.HasPrefix(command, "& ") { - return "", "", false - } - executable, next, ok := readCursorPowerShellArgument(command, 2) - if !ok || next >= len(command) || command[next] != ' ' { - return "", "", false - } - script, end, ok := readCursorPowerShellArgument(command, next+1) - return executable, script, ok && command[end:] == "; exit $LASTEXITCODE" -} - func parseCursorLegacyCommand(command string) (string, bool) { separator := strings.IndexByte(command, ' ') if separator < 0 { @@ -164,11 +71,6 @@ func parseCursorLegacyCommand(command string) (string, bool) { return script, script != "" && !strings.ContainsAny(script, "\r\n") } -func isCursorNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} - func cursorManagedScriptPath(handler map[string]any) (string, bool) { if len(handler) == 3 && handler["timeout"] == cursorHookTimeout && handler["failClosed"] == true { command, ok := handler["command"].(string) @@ -178,12 +80,12 @@ func cursorManagedScriptPath(handler map[string]any) (string, bool) { var nodePath, scriptPath string var parsed bool if runtime.GOOS == "windows" { - nodePath, scriptPath, parsed = parseCursorPowerShellCommand(command) + nodePath, scriptPath, parsed = parsePowerShellSource(command) } else { - nodePath, scriptPath, parsed = parseCursorPOSIXCommand(command) + nodePath, scriptPath, parsed = parsePOSIXCommand(command) } if parsed && filepath.IsAbs(nodePath) && filepath.IsAbs(scriptPath) && - isCursorNodeExecutable(nodePath) { + isNodeExecutable(nodePath) { return scriptPath, true } } @@ -207,7 +109,7 @@ func cursorManagedAgentID( return "", false } agentDirectory := filepath.Dir(scriptPath) - if !sameCursorPath(filepath.Dir(agentDirectory), runtimeRoot) { + if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) { return "", false } agentID := filepath.Base(agentDirectory) @@ -297,7 +199,7 @@ func removeManagedCursorHooks(hooks cursorHooks, runtimeRoot, agentID string) cu kept := make([]map[string]any, 0, len(result[contract.event])) for _, handler := range result[contract.event] { managedID, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot) - if managed && (agentID == "" || sameCursorAgentID(managedID, agentID)) { + if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) { continue } kept = append(kept, handler) @@ -384,11 +286,7 @@ func managedCursorIDs( if !managed { continue } - key := agentID - if runtime.GOOS == "windows" { - key = strings.ToLower(agentID) - } - result[key] = agentID + result[managedReferenceKey(agentID)] = agentID } return result } diff --git a/cmd/elydora/plugins/cursor_io.go b/cmd/elydora/plugins/cursor_io.go index a943573..d2f57d5 100644 --- a/cmd/elydora/plugins/cursor_io.go +++ b/cmd/elydora/plugins/cursor_io.go @@ -1,13 +1,9 @@ package plugins import ( - "encoding/json" - "errors" "fmt" "os" "path/filepath" - "runtime" - "strings" ) func cursorConfigPath() (string, error) { @@ -27,21 +23,14 @@ func readCursorDocument() (*cursorDocument, error) { if err != nil { return nil, err } - info, err := os.Lstat(filePath) - if errors.Is(err, os.ErrNotExist) { + snapshot, err := readManagedFile(filePath, "Cursor user hooks", maxManagedSourceBytes) + if err != nil { + return nil, err + } + if snapshot == nil { return createCursorDocument(filePath), nil } - if err != nil { - return nil, fmt.Errorf("inspect Cursor user hooks at %s: %w", filePath, err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return nil, fmt.Errorf("Cursor user hooks path is not a physical file: %s", filePath) - } - raw, err := os.ReadFile(filePath) // #nosec G304 -- filePath is the fixed Cursor user configuration path. - if err != nil { - return nil, fmt.Errorf("read Cursor user hooks at %s: %w", filePath, err) - } - return parseCursorDocument(filePath, raw, runtimeRoot) + return parseCursorDocument(filePath, snapshot.contents, runtimeRoot) } func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, error) { @@ -59,167 +48,25 @@ func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, ) } -func cursorPhysicalFileExists(path, label string) (bool, error) { - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("inspect %s at %s: %w", label, path, err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return false, fmt.Errorf("%s path is not a physical file: %s", label, path) - } - return true, nil -} - -func cursorPhysicalDirectoryExists(path string) (bool, error) { - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("inspect Elydora runtime directory at %s: %w", path, err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return false, fmt.Errorf("Elydora runtime path is not a physical directory: %s", path) - } - return true, nil -} - -func readCursorRuntimeConfig(path string) (map[string]any, bool, error) { - exists, err := cursorPhysicalFileExists(path, "Elydora runtime config") - if err != nil || !exists { - return nil, exists, err - } - raw, err := os.ReadFile(path) // #nosec G304 -- path is confined to one validated Elydora agent directory. - if err != nil { - return nil, true, fmt.Errorf("read Elydora runtime config at %s: %w", path, err) - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - if !json.Valid(raw) { - var value any - if err := json.Unmarshal(raw, &value); err != nil { - return nil, true, fmt.Errorf("parse %s: %w", label, err) - } - } - config, err := decodeJSONCObject(raw, label, false) - if err != nil { - return nil, true, err - } - return config, true, nil -} - -func validateCursorRuntimeIdentity(path, agentID string) (bool, error) { - config, exists, err := readCursorRuntimeConfig(path) - if err != nil || !exists { - return exists, err - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != cursorAgentKey || - !sameCursorAgentID(configuredID, agentID) { - return true, fmt.Errorf( - "Elydora runtime config identity does not match Cursor agent %s: %s", - agentID, - path, - ) - } - return true, nil -} - -func preflightCursorRuntime(agentDirectory, agentID string) error { - exists, err := cursorPhysicalDirectoryExists(agentDirectory) - if err != nil || !exists { - return err - } - identityExists, err := validateCursorRuntimeIdentity( - filepath.Join(agentDirectory, "config.json"), agentID, - ) - if err != nil { - return err - } - runtimeExists := false - for _, item := range []struct{ path, label string }{ - {filepath.Join(agentDirectory, cursorGuardScript), "Elydora guard runtime"}, - {filepath.Join(agentDirectory, cursorAuditScript), "Elydora audit runtime"}, - {filepath.Join(agentDirectory, "private.key"), "Elydora private key"}, - } { - exists, err := cursorPhysicalFileExists(item.path, item.label) - if err != nil { - return err - } - runtimeExists = runtimeExists || exists - } - if runtimeExists && !identityExists { - return fmt.Errorf( - "Elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil -} - func prepareCursorInstallationChanges( config InstallConfig, - agentDirectory string, + paths *managedRuntimePaths, rendered *cursorRenderedDocument, ) ([]*fileChange, error) { - baseURL := config.BaseURL - if baseURL == "" { - baseURL = "https://api.elydora.com" + if config.BaseURL == "" { + config.BaseURL = "https://api.elydora.com" } - runtimeConfig, err := json.MarshalIndent(agentRuntimeConfig{ - OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID, - BaseURL: baseURL, Token: config.Token, AgentName: cursorAgentKey, - }, "", " ") + changes, err := managedRuntimeFileChanges( + config, + paths, + cursorAgentKey, + generateGuardScript( + cursorAgentKey, config.AgentID, `{"permission":"allow"}`+"\n", true, "cursor", + ), + buildHookScriptWithOutput(cursorAgentKey, config.AgentID, "{}\n", true, true), + ) if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - runtimeConfig = append(runtimeConfig, '\n') - if len(runtimeConfig) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "Elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - filepath.Join(agentDirectory, cursorGuardScript), "Elydora guard runtime", - []byte(generateGuardScript( - cursorAgentKey, - config.AgentID, - `{"permission":"allow"}`+"\n", - true, - "cursor", - )), 0700, - }, - {filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600}, - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600}, - { - filepath.Join(agentDirectory, cursorAuditScript), "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - cursorAgentKey, - config.AgentID, - "{}\n", - true, - true, - )), 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+1) - for _, item := range items { - if err := validateRuntimeFileTarget(item.path, item.label); err != nil { - return nil, err - } - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) - if err != nil { - return nil, err - } - changes = append(changes, change) + return nil, err } documentChange, err := prepareRenderedCursorChange(rendered) if err != nil { @@ -228,98 +75,6 @@ func prepareCursorInstallationChanges( return append(changes, documentChange), nil } -func ensureCursorDirectory(path, label string) error { - if err := os.MkdirAll(path, 0700); err != nil { - return fmt.Errorf("create %s directory at %s: %w", label, path, err) - } - info, err := os.Lstat(path) - if err != nil { - return fmt.Errorf("inspect %s directory at %s: %w", label, path, err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return fmt.Errorf("%s directory is not a physical directory: %s", label, path) - } - return nil -} - -func writeCursorChanges( - changes []*fileChange, - label string, - rename renameFunc, - runtimeRoot, agentDirectory string, -) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { - return nil - } - if agentDirectory != "" { - if err := EnsurePrivateDirectory(runtimeRoot); err != nil { - return err - } - if err := EnsurePrivateDirectory(agentDirectory); err != nil { - return err - } - } - directories := map[string]string{} - for _, change := range changes { - if change == nil { - continue - } - directory := filepath.Dir(change.filePath) - key := filepath.Clean(directory) - if runtime.GOOS == "windows" { - key = strings.ToLower(key) - } - directories[key] = directory - } - for _, directory := range directories { - if err := ensureCursorDirectory(directory, label); err != nil { - return err - } - } - return writeChanges(changes, label, rename) -} - -func cursorRuntimeFilesExist(contracts []cursorRuntimeContract) (bool, error) { - for _, contract := range contracts { - agentDirectory := filepath.Dir(contract.guardPath) - directoryExists, err := cursorPhysicalDirectoryExists(agentDirectory) - if err != nil { - return false, err - } - if !directoryExists { - continue - } - config, exists, err := readCursorRuntimeConfig(filepath.Join(agentDirectory, "config.json")) - if err != nil { - return false, err - } - configuredID, idOK := config["agent_id"].(string) - if !exists || !idOK || config["agent_name"] != cursorAgentKey || - !sameCursorAgentID(configuredID, contract.agentID) { - continue - } - complete := true - for _, item := range []struct{ path, label string }{ - {contract.guardPath, "Elydora guard runtime"}, - {contract.auditPath, "Elydora audit runtime"}, - {filepath.Join(agentDirectory, "private.key"), "Elydora private key"}, - } { - exists, err := cursorPhysicalFileExists(item.path, item.label) - if err != nil { - return false, err - } - complete = complete && exists - } - if complete { - return true, nil - } - } - return false, nil +func cursorRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesPresent(contracts, cursorAgentKey) } diff --git a/cmd/elydora/plugins/cursor_recovery_test.go b/cmd/elydora/plugins/cursor_recovery_test.go index a267bb5..1067dec 100644 --- a/cmd/elydora/plugins/cursor_recovery_test.go +++ b/cmd/elydora/plugins/cursor_recovery_test.go @@ -183,7 +183,7 @@ func TestCursorInstallCreatesRuntimeDirectoryTransactionally(t *testing.T) { fixture.guardPath, fixture.hookPath, } { - if exists, err := cursorPhysicalFileExists(path, "Cursor runtime"); err != nil || !exists { + if exists, err := managedPhysicalFileExists(path, "Cursor runtime", maxManagedSourceBytes); err != nil || !exists { t.Fatalf("runtime file %s exists = %v, %v", path, exists, err) } } @@ -200,7 +200,7 @@ func TestCursorInstallRollsBackEveryFileOnConfigCommitFailure(t *testing.T) { } failed := false fixture.plugin.rename = func(source, destination string) error { - if !failed && sameCursorPath(destination, fixture.configPath) { + if !failed && sameManagedPath(destination, fixture.configPath) { failed = true return errors.New("injected Cursor config commit failure") } diff --git a/cmd/elydora/plugins/cursor_test.go b/cmd/elydora/plugins/cursor_test.go index 18840fd..464b42a 100644 --- a/cmd/elydora/plugins/cursor_test.go +++ b/cmd/elydora/plugins/cursor_test.go @@ -342,7 +342,7 @@ func TestCursorCreatesMissingGuardAndRejectsUnmanagedRuntimePaths(t *testing.T) fixture.config.HookScript = unmanaged } if err := fixture.plugin.Install(fixture.config); err == nil || - !strings.Contains(err.Error(), "managed agent directory") { + !strings.Contains(err.Error(), "managed Elydora agent directory") { t.Fatalf("unmanaged %s error = %v", field, err) } if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) { diff --git a/cmd/elydora/plugins/gemini.go b/cmd/elydora/plugins/gemini.go index 860a1da..e4e100d 100644 --- a/cmd/elydora/plugins/gemini.go +++ b/cmd/elydora/plugins/gemini.go @@ -10,8 +10,7 @@ type GeminiPlugin struct { rename renameFunc } -// ManagesGuardRuntime reports that Gemini commits both generated runtimes and -// the user settings document through one transaction. +// ManagesGuardRuntime reports that the guard is committed with the audit runtime. func (p *GeminiPlugin) ManagesGuardRuntime() bool { return true } @@ -92,13 +91,14 @@ func (p *GeminiPlugin) Install(config InstallConfig) error { if err != nil { return err } - if err := writeGeminiChanges( + if err := writeManagedChanges( changes, "Install Gemini CLI hooks", p.rename, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.filePath), + "Gemini CLI configuration directory", ); err != nil { return err } @@ -120,13 +120,14 @@ func (p *GeminiPlugin) Uninstall(agentID string) error { if err != nil { return err } - return writeGeminiChanges( + return writeManagedChanges( []*fileChange{change}, "Uninstall Gemini CLI hooks", p.rename, "", "", filepath.Dir(document.filePath), + "Gemini CLI configuration directory", ) } diff --git a/cmd/elydora/plugins/gemini_command.go b/cmd/elydora/plugins/gemini_command.go index d7f4b62..2d86af5 100644 --- a/cmd/elydora/plugins/gemini_command.go +++ b/cmd/elydora/plugins/gemini_command.go @@ -7,83 +7,37 @@ import ( "strings" ) -type geminiRuntimeReference struct { - agentID string - scriptPath string -} - -func sameGeminiPath(left, right string) bool { - left = filepath.Clean(left) - right = filepath.Clean(right) - if absolute, err := filepath.Abs(left); err == nil { - left = absolute - } - if absolute, err := filepath.Abs(right); err == nil { - right = absolute - } - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameGeminiAgentID(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func sameGeminiFileName(left, right string) bool { - if runtime.GOOS == "windows" { - return strings.EqualFold(left, right) - } - return left == right -} - -func isGeminiNodeExecutable(path string) bool { - name := filepath.Base(path) - return name == "node" || strings.EqualFold(name, "node.exe") -} +type geminiRuntimeReference = managedScriptReference func buildGeminiCommand(runtimePath, scriptPath string) (string, error) { if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { - return "", fmt.Errorf( - "Gemini CLI hook commands require absolute runtime and script paths", - ) + return "", fmt.Errorf("Gemini CLI hook commands require absolute runtime and script paths") } if runtime.GOOS != "windows" { - return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil + return posixSource(runtimePath, scriptPath), nil } - source := "& " + quoteCodexPowerShell(runtimePath) + " " + - quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE" - return "& " + quoteCodexPowerShell(codexPowerShellPath()) + - " -NoLogo -NoProfile -NonInteractive -EncodedCommand " + - encodeCodexPowerShell(source), nil + return "& " + quotePowerShellArgument(windowsPowerShellPath()) + powerShellEncodedFlags + + encodePowerShellSource(powerShellSource(runtimePath, scriptPath)), nil } func parseGeminiWindowsCommand(command string) (string, string, bool) { if !strings.HasPrefix(command, "& ") { return "", "", false } - powerShell, next, ok := readCodexPowerShellArgument(command, 2) - if !ok || !isAbsoluteWindowsPath(powerShell) || - !strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") { + powerShell, next, ok := readPowerShellArgument(command, 2) + if !ok || !isPowerShellExecutable(powerShell) || + !strings.HasPrefix(command[next:], powerShellEncodedFlags) { return "", "", false } - prefix := " -NoLogo -NoProfile -NonInteractive -EncodedCommand " - if !strings.HasPrefix(command[next:], prefix) { - return "", "", false - } - source, ok := decodeCodexPowerShell(command[next+len(prefix):]) + source, ok := decodePowerShellSource(command[next+len(powerShellEncodedFlags):]) if !ok { return "", "", false } - return parseCodexPowerShellSource(source) + return parsePowerShellSource(source) } func parseGeminiCommand(command string) (string, string, bool) { - if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { + if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok { return runtimePath, scriptPath, true } return parseGeminiWindowsCommand(command) @@ -110,23 +64,10 @@ func geminiRuntimeReferenceForCommand( if !current && includeLegacy { runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command) } - validRuntime := (current && filepath.IsAbs(runtimePath) && - isGeminiNodeExecutable(runtimePath)) || (legacy && runtimePath == "node") - if !validRuntime || !filepath.IsAbs(scriptPath) || - !sameGeminiFileName(filepath.Base(scriptPath), scriptName) { + validRuntime := (current && filepath.IsAbs(runtimePath) && isNodeExecutable(runtimePath)) || + (legacy && runtimePath == "node") + if !validRuntime { return nil, nil } - agentDirectory := filepath.Dir(scriptPath) - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return nil, err - } - if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) { - return nil, nil - } - agentID := filepath.Base(agentDirectory) - if agentID == "" || agentID == "." || agentID == ".." { - return nil, nil - } - return &geminiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil + return resolveManagedScript(scriptPath, scriptName) } diff --git a/cmd/elydora/plugins/gemini_contract.go b/cmd/elydora/plugins/gemini_contract.go index 74e9da7..62a4c0d 100644 --- a/cmd/elydora/plugins/gemini_contract.go +++ b/cmd/elydora/plugins/gemini_contract.go @@ -1,10 +1,7 @@ package plugins import ( - "path/filepath" - "runtime" "sort" - "strings" ) const ( @@ -27,11 +24,7 @@ type geminiManagedRemoval struct { removeGroup bool } -type geminiRuntimeContract struct { - agentID string - guardPath string - auditPath string -} +type geminiRuntimeContract = managedRuntimeContract func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) { command, err := buildGeminiCommand(runtimePath, scriptPath) @@ -151,7 +144,7 @@ func managedGeminiRemovals( return nil, err } if reference != nil && - (agentID == "" || sameGeminiAgentID(reference.agentID, agentID)) { + (agentID == "" || sameManagedAgentID(reference.agentID, agentID)) { indexes = append(indexes, handlerIndex) } } @@ -168,13 +161,6 @@ func managedGeminiRemovals( return removals, nil } -func geminiReferenceKey(agentID string) string { - if runtime.GOOS == "windows" { - return strings.ToLower(agentID) - } - return agentID -} - func geminiReferencesForEvent( groups []any, scriptName string, @@ -196,7 +182,7 @@ func geminiReferencesForEvent( return nil, err } if reference != nil { - key := geminiReferenceKey(reference.agentID) + key := managedReferenceKey(reference.agentID) result[key] = append(result[key], *reference) } } @@ -243,11 +229,3 @@ func geminiRuntimeContracts( } return contracts, nil } - -func geminiRuntimeRoot() (string, error) { - root, err := AgentRuntimeRoot() - if err != nil { - return "", err - } - return filepath.Clean(root), nil -} diff --git a/cmd/elydora/plugins/gemini_installation.go b/cmd/elydora/plugins/gemini_installation.go index 2b99479..f519740 100644 --- a/cmd/elydora/plugins/gemini_installation.go +++ b/cmd/elydora/plugins/gemini_installation.go @@ -1,190 +1,48 @@ package plugins -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) - -type geminiRuntimePaths struct { - runtimeRoot string - agentDirectory string - configPath string - keyPath string - guardPath string - auditPath string -} - -func validateGeminiInstallConfig(config InstallConfig) error { - for _, field := range []struct{ name, value string }{ - {"agent name", config.AgentName}, - {"organization ID", config.OrgID}, - {"agent ID", config.AgentID}, - {"key ID", config.KID}, - {"private key", config.PrivateKey}, - {"base URL", config.BaseURL}, - {"guard script path", config.GuardScriptPath}, - } { - if field.value == "" { - return fmt.Errorf("%s is required", field.name) - } - } - if config.AgentName != geminiAgentKey { - return fmt.Errorf( - "Gemini CLI installation requires agent name %s", - geminiAgentKey, - ) - } - if strings.TrimSpace(config.OrgID) == "" { - return fmt.Errorf("organization ID is required") - } - if strings.TrimSpace(config.KID) == "" { - return fmt.Errorf("key ID is required") - } - if config.Token != "" && strings.TrimSpace(config.Token) == "" { - return fmt.Errorf("token must contain a non-whitespace value when provided") - } - if err := validateManagedPrivateKey(config.PrivateKey); err != nil { - return err - } - return validateManagedBaseURL(config.BaseURL) -} - -func geminiAgentPaths(config InstallConfig) (*geminiRuntimePaths, error) { - if err := validateGeminiInstallConfig(config); err != nil { - return nil, err - } - runtimeRoot, err := geminiRuntimeRoot() - if err != nil { - return nil, err - } - agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) - if err != nil { - return nil, err - } - paths := &geminiRuntimePaths{ - runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, - configPath: filepath.Join(agentDirectory, "config.json"), - keyPath: filepath.Join(agentDirectory, "private.key"), - guardPath: filepath.Join(agentDirectory, geminiGuardScript), - auditPath: filepath.Join(agentDirectory, geminiAuditScript), - } - if !filepath.IsAbs(config.GuardScriptPath) || - !sameGeminiPath(config.GuardScriptPath, paths.guardPath) { - return nil, fmt.Errorf( - "guard runtime must use the managed Elydora agent directory: %s", - paths.guardPath, - ) - } - if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || - !sameGeminiPath(config.HookScript, paths.auditPath)) { - return nil, fmt.Errorf( - "audit runtime must use the managed Elydora agent directory: %s", - paths.auditPath, - ) - } - return paths, nil -} +import "fmt" func preflightGeminiInstallation( config InstallConfig, document *geminiDocument, -) (*geminiRuntimePaths, string, error) { +) (*managedRuntimePaths, string, error) { if document == nil || document.filePath == "" { - return nil, "", fmt.Errorf( - "Gemini CLI installation requires a user settings document", - ) + return nil, "", fmt.Errorf("Gemini CLI installation requires a user settings document") } - paths, err := geminiAgentPaths(config) + if err := validateManagedInstallConfig(config, geminiAgentKey, "Gemini CLI"); err != nil { + return nil, "", err + } + paths, err := resolveManagedRuntimePaths(config, geminiGuardScript, geminiAuditScript) if err != nil { return nil, "", err } - if err := validateGeminiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + if err := validateManagedRuntimeIdentity( + paths.agentDirectory, config.AgentID, geminiAgentKey, "Gemini CLI", + ); err != nil { return nil, "", err } - nodePath, err := resolveNodeRuntime() + nodePath, err := resolveAbsoluteNodeRuntime("Gemini CLI") if err != nil { return nil, "", err } - if !filepath.IsAbs(nodePath) || !isGeminiNodeExecutable(nodePath) { - return nil, "", fmt.Errorf( - "Gemini CLI hooks require an absolute Node.js executable path", - ) - } return paths, nodePath, nil } -func buildGeminiRuntimeConfig(config InstallConfig) ([]byte, error) { - value := map[string]any{ - "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, - "base_url": config.BaseURL, "agent_name": geminiAgentKey, - } - if config.Token != "" { - value["token"] = config.Token - } - encoded, err := json.MarshalIndent(value, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - encoded = append(encoded, '\n') - if len(encoded) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - return encoded, nil -} - func prepareGeminiInstallationChanges( config InstallConfig, - paths *geminiRuntimePaths, + paths *managedRuntimePaths, rendered *geminiRenderedDocument, ) ([]*fileChange, error) { - runtimeConfig, err := buildGeminiRuntimeConfig(config) + changes, err := managedRuntimeFileChanges( + config, + paths, + geminiAgentKey, + generateGuardScript(geminiAgentKey, config.AgentID, "{}\n", false, ""), + buildHookScriptWithOutput(geminiAgentKey, config.AgentID, "{}\n", false, true), + ) if err != nil { return nil, err } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - { - paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript( - geminiAgentKey, - config.AgentID, - "{}\n", - false, - "", - )), - 0700, - }, - {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, - {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, - { - paths.auditPath, "Elydora audit runtime", - []byte(buildHookScriptWithOutput( - geminiAgentKey, - config.AgentID, - "{}\n", - false, - true, - )), - 0700, - }, - } - changes := make([]*fileChange, 0, len(items)+1) - for _, item := range items { - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) - if err != nil { - return nil, err - } - changes = append(changes, change) - } documentChange, err := prepareRenderedGeminiChange(rendered) if err != nil { return nil, err diff --git a/cmd/elydora/plugins/gemini_io.go b/cmd/elydora/plugins/gemini_io.go index 422d514..b5a70d8 100644 --- a/cmd/elydora/plugins/gemini_io.go +++ b/cmd/elydora/plugins/gemini_io.go @@ -67,38 +67,3 @@ func prepareRenderedGeminiChange( rendered.remove, ) } - -func writeGeminiChanges( - changes []*fileChange, - label string, - rename renameFunc, - runtimeRoot string, - agentDirectory string, - settingsDirectory string, -) error { - hasChanges := false - for _, change := range changes { - if change != nil { - hasChanges = true - break - } - } - if !hasChanges { - return nil - } - if agentDirectory != "" { - if err := EnsurePrivateDirectory(runtimeRoot); err != nil { - return err - } - if err := EnsurePrivateDirectory(agentDirectory); err != nil { - return err - } - } - if err := ensureManagedDirectory( - settingsDirectory, - "Gemini CLI configuration directory", - ); err != nil { - return err - } - return writeChanges(changes, label, rename) -} diff --git a/cmd/elydora/plugins/gemini_status.go b/cmd/elydora/plugins/gemini_status.go index 1650cf6..db5808b 100644 --- a/cmd/elydora/plugins/gemini_status.go +++ b/cmd/elydora/plugins/gemini_status.go @@ -1,231 +1,5 @@ package plugins -import ( - "fmt" - "path/filepath" - "sort" - "strings" -) - -func readGeminiRuntimeConfig(path string) (map[string]any, bool, error) { - snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) - if err != nil || snapshot == nil { - return nil, snapshot != nil, err - } - label := fmt.Sprintf("Elydora runtime config at %s", path) - config, err := decodeStrictJSONObject(snapshot.contents, label) - return config, true, err -} - -func requireGeminiRuntimeString( - config map[string]any, - field string, - configPath string, -) (string, error) { - value, ok := config[field].(string) - if !ok || strings.TrimSpace(value) == "" { - return "", fmt.Errorf( - "elydora runtime config %s is invalid: %s", - field, - configPath, - ) - } - return value, nil -} - -func validateGeminiRuntimeConfig( - config map[string]any, - expectedAgentID string, - configPath string, -) error { - supported := stringSet( - "org_id", "agent_id", "kid", "base_url", "token", "agent_name", - ) - fields := make([]string, 0, len(config)) - for field := range config { - fields = append(fields, field) - } - sort.Strings(fields) - for _, field := range fields { - if _, ok := supported[field]; !ok { - return fmt.Errorf( - `elydora runtime config has unsupported field %q: %s`, - field, - configPath, - ) - } - } - if _, err := requireGeminiRuntimeString(config, "org_id", configPath); err != nil { - return err - } - if _, err := requireGeminiRuntimeString(config, "kid", configPath); err != nil { - return err - } - agentID, err := requireGeminiRuntimeString(config, "agent_id", configPath) - if err != nil { - return err - } - if !sameGeminiAgentID(agentID, expectedAgentID) || - config["agent_name"] != geminiAgentKey { - return fmt.Errorf( - "elydora runtime identity does not match Gemini CLI hooks: %s", - configPath, - ) - } - if _, exists := config["token"]; exists { - if _, err := requireGeminiRuntimeString(config, "token", configPath); err != nil { - return err - } - } - baseURL, err := requireGeminiRuntimeString(config, "base_url", configPath) - if err != nil { - return err - } - if err := validateManagedBaseURL(baseURL); err != nil { - return fmt.Errorf( - "elydora runtime config base URL is invalid at %s: %w", - configPath, - err, - ) - } - return nil -} - -func validateGeminiRuntimeIdentity(agentDirectory, agentID string) error { - runtimeRoot := filepath.Dir(agentDirectory) - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return err - } - configPath := filepath.Join(agentDirectory, "config.json") - config, configExists, err := readGeminiRuntimeConfig(configPath) - if err != nil { - return err - } - artifactExists := false - for _, item := range []struct { - path string - label string - limit int64 - }{ - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, - {filepath.Join(agentDirectory, geminiGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, geminiAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, - {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, - {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, - } { - exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) - if inspectErr != nil { - return inspectErr - } - artifactExists = artifactExists || exists - } - if !configExists { - if artifactExists { - return fmt.Errorf( - "elydora runtime identity cannot be verified without config.json: %s", - agentDirectory, - ) - } - return nil - } - configuredID, ok := config["agent_id"].(string) - if !ok || config["agent_name"] != geminiAgentKey || - !sameGeminiAgentID(configuredID, agentID) { - return fmt.Errorf( - "elydora runtime config identity does not match Gemini CLI agent %s: %s", - agentID, - configPath, - ) - } - return nil -} - -func geminiRuntimeContractExists(contract geminiRuntimeContract) (bool, error) { - runtimeRoot, err := geminiRuntimeRoot() - if err != nil { - return false, err - } - agentDirectory := filepath.Dir(contract.guardPath) - if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) || - !sameGeminiPath( - contract.auditPath, - filepath.Join(agentDirectory, geminiAuditScript), - ) { - return false, nil - } - rootExists, err := managedPhysicalDirectoryExists( - runtimeRoot, - "Elydora runtime directory", - ) - if err != nil || !rootExists { - return false, err - } - directoryExists, err := managedPhysicalDirectoryExists( - agentDirectory, - "Elydora agent runtime directory", - ) - if err != nil || !directoryExists { - return false, err - } - configPath := filepath.Join(agentDirectory, "config.json") - keyPath := filepath.Join(agentDirectory, "private.key") - config, configExists, err := readGeminiRuntimeConfig(configPath) - if err != nil { - return false, err - } - key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) - if err != nil { - return false, err - } - guard, err := readManagedFile( - contract.guardPath, - "Elydora guard runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - audit, err := readManagedFile( - contract.auditPath, - "Elydora audit runtime", - maxManagedSourceBytes, - ) - if err != nil { - return false, err - } - if !configExists || key == nil || guard == nil || audit == nil { - return false, nil - } - if err := validateGeminiRuntimeConfig(config, contract.agentID, configPath); err != nil { - return false, err - } - if err := validateManagedPrivateKey(string(key.contents)); err != nil { - return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err) - } - return len(guard.contents) > 0 && len(audit.contents) > 0, nil -} - -func geminiRuntimeFilesExist(contracts []geminiRuntimeContract) (bool, error) { - for _, contract := range contracts { - exists, err := geminiRuntimeContractExists(contract) - if err != nil { - return false, err - } - if exists { - return true, nil - } - } - return false, nil +func geminiRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) { + return managedRuntimeFilesExist(contracts, geminiAgentKey, "Gemini CLI", nil) } diff --git a/cmd/elydora/plugins/gemini_test.go b/cmd/elydora/plugins/gemini_test.go index 919e48b..addedf1 100644 --- a/cmd/elydora/plugins/gemini_test.go +++ b/cmd/elydora/plugins/gemini_test.go @@ -33,8 +33,8 @@ func TestGeminiCommandRoundTripAndRejectsInjection(t *testing.T) { t.Fatalf("build Gemini command: %v", err) } executable, script, ok := parseGeminiCommand(command) - if !ok || !sameGeminiPath(executable, nodePath) || - !sameGeminiPath(script, fixture.guardPath) { + if !ok || !sameManagedPath(executable, nodePath) || + !sameManagedPath(script, fixture.guardPath) { t.Fatalf("parsed Gemini command = %q, %q, %v", executable, script, ok) } if runtime.GOOS == "windows" && (strings.Contains(command, "%GEMINI_CWD%") || @@ -425,12 +425,12 @@ func TestGeminiRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) { func TestGeminiAgentIDMatchingIsPlatformCorrect(t *testing.T) { if runtime.GOOS == "windows" { - if !sameGeminiAgentID("AGENT-1", "agent-1") { + if !sameManagedAgentID("AGENT-1", "agent-1") { t.Fatal("Windows Gemini agent IDs should compare case-insensitively") } return } - if sameGeminiAgentID("AGENT-1", "agent-1") { + if sameManagedAgentID("AGENT-1", "agent-1") { t.Fatal("POSIX Gemini agent IDs should preserve case") } } diff --git a/cmd/elydora/plugins/gemini_test_helpers_test.go b/cmd/elydora/plugins/gemini_test_helpers_test.go index e0dd1ee..c525bb3 100644 --- a/cmd/elydora/plugins/gemini_test_helpers_test.go +++ b/cmd/elydora/plugins/gemini_test_helpers_test.go @@ -251,7 +251,7 @@ func runGeminiRawCommand( var process *exec.Cmd if runtime.GOOS == "windows" { process = exec.Command( - codexPowerShellPath(), + windowsPowerShellPath(), "-NoLogo", "-NoProfile", "-NonInteractive", diff --git a/cmd/elydora/plugins/gemini_transaction_test.go b/cmd/elydora/plugins/gemini_transaction_test.go index 362f654..9bb55b2 100644 --- a/cmd/elydora/plugins/gemini_transaction_test.go +++ b/cmd/elydora/plugins/gemini_transaction_test.go @@ -35,7 +35,7 @@ func prepareGeminiTestChanges( t *testing.T, fixture *geminiFixture, document *geminiDocument, -) ([]*fileChange, *geminiRuntimePaths) { +) ([]*fileChange, *managedRuntimePaths) { t.Helper() paths, nodePath, err := preflightGeminiInstallation(fixture.config, document) if err != nil { @@ -110,7 +110,7 @@ func TestGeminiInstallRollsBackAllFiveFilesAfterSettingsFailure(t *testing.T) { fixture.config.OrgID = "org-updated" fixture.config.Token = "token-updated" fixture.plugin.rename = func(source, destination string) error { - if sameGeminiPath(destination, fixture.settingsPath) && + if sameManagedPath(destination, fixture.settingsPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Gemini settings failure") } @@ -139,13 +139,14 @@ func TestPreparedGeminiInstallRejectsConcurrentSettingsChange(t *testing.T) { if err := os.WriteFile(fixture.settingsPath, concurrent, 0600); err != nil { t.Fatalf("write concurrent settings: %v", err) } - err = writeGeminiChanges( + err = writeManagedChanges( changes, "Install Gemini CLI hooks", nil, paths.runtimeRoot, paths.agentDirectory, filepath.Dir(document.filePath), + "Gemini CLI configuration directory", ) if err == nil || !strings.Contains(err.Error(), "changed during installation") { t.Fatalf("prepared install error = %v", err) @@ -204,7 +205,7 @@ func TestGeminiUninstallPreservesSettingsAfterCommitFailure(t *testing.T) { t.Fatalf("read installed Gemini settings: %v", err) } fixture.plugin.rename = func(source, destination string) error { - if sameGeminiPath(destination, fixture.settingsPath) && + if sameManagedPath(destination, fixture.settingsPath) && strings.HasSuffix(source, ".tmp") { return errors.New("injected Gemini uninstall failure") } diff --git a/cmd/elydora/plugins/grok_command.go b/cmd/elydora/plugins/grok_command.go index 80f8a70..14c7579 100644 --- a/cmd/elydora/plugins/grok_command.go +++ b/cmd/elydora/plugins/grok_command.go @@ -41,7 +41,7 @@ func buildGrokCommand(runtimePath, scriptPath string) (string, error) { ) } if runtime.GOOS == "windows" { - return codexWindowsCommand(runtimePath, scriptPath), nil + return encodedWindowsCommand(runtimePath, scriptPath), nil } return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil } @@ -85,10 +85,10 @@ func parseGrokLegacyWindowsCommand(command string) (string, string, bool) { } func parseGrokCommand(command string) (string, string, bool) { - if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { + if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok { return runtimePath, scriptPath, true } - if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok { + if runtimePath, scriptPath, ok := parseEncodedWindowsCommand(command); ok { return runtimePath, scriptPath, true } return parseGrokLegacyWindowsCommand(command) diff --git a/cmd/elydora/plugins/jsonc.go b/cmd/elydora/plugins/jsonc.go index 199b7fc..9dee1d2 100644 --- a/cmd/elydora/plugins/jsonc.go +++ b/cmd/elydora/plugins/jsonc.go @@ -88,6 +88,16 @@ func rejectDuplicateJSONCKeys(value *hujson.Value, label, location string) error return nil } +func decodeStrictJSONObject(source []byte, label string) (map[string]any, error) { + if !json.Valid(source) { + var value any + if err := json.Unmarshal(source, &value); err != nil { + return nil, fmt.Errorf("parse %s: %w", label, err) + } + } + return decodeJSONCObject(source, label, false) +} + func decodeJSONCObject(source []byte, label string, allowTrailingCommas bool) (map[string]any, error) { value, err := parseJSONC(source, label, allowTrailingCommas) if err != nil { diff --git a/cmd/elydora/plugins/kimi_command.go b/cmd/elydora/plugins/kimi_command.go index f894d59..3a1771e 100644 --- a/cmd/elydora/plugins/kimi_command.go +++ b/cmd/elydora/plugins/kimi_command.go @@ -39,7 +39,7 @@ func buildKimiCommand(runtimePath, scriptPath string) (string, error) { return "", fmt.Errorf("kimi hook commands require absolute runtime and script paths") } if runtime.GOOS == "windows" { - return codexWindowsCommand(runtimePath, scriptPath), nil + return encodedWindowsCommand(runtimePath, scriptPath), nil } return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil } @@ -80,10 +80,10 @@ func parseKimiLegacyWindowsCommand(command string) (string, string, bool) { } func parseKimiCommand(command string) (string, string, bool) { - if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { + if runtimePath, scriptPath, ok := parsePOSIXCommand(command); ok { return runtimePath, scriptPath, true } - if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok { + if runtimePath, scriptPath, ok := parseEncodedWindowsCommand(command); ok { return runtimePath, scriptPath, true } return parseKimiLegacyWindowsCommand(command) diff --git a/cmd/elydora/plugins/kirocli_io.go b/cmd/elydora/plugins/kirocli_io.go index 4133857..01422c3 100644 --- a/cmd/elydora/plugins/kirocli_io.go +++ b/cmd/elydora/plugins/kirocli_io.go @@ -3,7 +3,6 @@ package plugins import ( "fmt" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -18,18 +17,6 @@ func kiroConfigPaths() (string, string, error) { filepath.Join(home, ".kiro", "hooks", "elydora-audit.json"), nil } -func resolveNodeRuntime() (string, error) { - nodePath, err := exec.LookPath("node") - if err != nil { - return "", fmt.Errorf("resolve Node.js runtime: %w", err) - } - absolute, err := filepath.Abs(nodePath) - if err != nil { - return "", fmt.Errorf("resolve Node.js runtime path: %w", err) - } - return absolute, nil -} - func buildKiroCommand(runtimePath, scriptPath string) string { if runtime.GOOS == "windows" { return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath) @@ -37,10 +24,6 @@ func buildKiroCommand(runtimePath, scriptPath string) string { return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath) } -func quotePOSIXArgument(value string) string { - return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" -} - func quoteWindowsArgument(value string) string { if value != "" && !strings.ContainsAny(value, " \t\n\v\"") { return value diff --git a/cmd/elydora/plugins/kiroide_command.go b/cmd/elydora/plugins/kiroide_command.go index b19dc64..e980395 100644 --- a/cmd/elydora/plugins/kiroide_command.go +++ b/cmd/elydora/plugins/kiroide_command.go @@ -48,16 +48,16 @@ func buildKiroIdeCommand(runtimePath, scriptPath string) (string, error) { ) } if runtime.GOOS == "windows" { - return codexWindowsCommand(runtimePath, scriptPath), nil + return encodedWindowsCommand(runtimePath, scriptPath), nil } return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil } func parseKiroIdeCommand(command string) (string, string, bool) { if runtime.GOOS == "windows" { - return parseCodexWindowsCommand(command) + return parseEncodedWindowsCommand(command) } - return parseCodexPOSIXCommand(command) + return parsePOSIXCommand(command) } func kiroIdeRuntimeReferenceForCommand( @@ -66,7 +66,7 @@ func kiroIdeRuntimeReferenceForCommand( ) (*kiroIdeRuntimeReference, error) { runtimePath, scriptPath, ok := parseKiroIdeCommand(command) if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || - !isCodexNodeExecutable(runtimePath) || + !isNodeExecutable(runtimePath) || !sameKiroIdeFileName(filepath.Base(scriptPath), scriptName) { return nil, nil } diff --git a/cmd/elydora/plugins/kiroide_test.go b/cmd/elydora/plugins/kiroide_test.go index 4b6fd99..a3c06e0 100644 --- a/cmd/elydora/plugins/kiroide_test.go +++ b/cmd/elydora/plugins/kiroide_test.go @@ -331,7 +331,7 @@ func TestKiroIdeStatusRejectsStaleWindowsPowerShellLauncher(t *testing.T) { command := action["command"].(string) action["command"] = strings.Replace( command, - codexPowerShellPath(), + windowsPowerShellPath(), `C:\StaleWindows\System32\WindowsPowerShell\v1.0\powershell.exe`, 1, ) diff --git a/cmd/elydora/plugins/letta.go b/cmd/elydora/plugins/letta.go index d113d98..29cb1ec 100644 --- a/cmd/elydora/plugins/letta.go +++ b/cmd/elydora/plugins/letta.go @@ -219,4 +219,3 @@ func (p *LettaPlugin) Status() (PluginStatus, error) { status.Installed = status.HookConfigured && status.HookScriptExists return status, nil } - diff --git a/cmd/elydora/plugins/managed_runtime.go b/cmd/elydora/plugins/managed_runtime.go new file mode 100644 index 0000000..e242070 --- /dev/null +++ b/cmd/elydora/plugins/managed_runtime.go @@ -0,0 +1,489 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +type managedRuntimeContract struct { + agentID string + guardPath string + auditPath string +} + +type managedRuntimePaths struct { + runtimeRoot string + agentDirectory string + configPath string + keyPath string + guardPath string + auditPath string +} + +type managedScriptReference struct { + agentID string + scriptPath string +} + +var managedRuntimeConfigFields = stringSet( + "org_id", "agent_id", "kid", "base_url", "token", "agent_name", +) + +func sameManagedPath(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if absolute, err := filepath.Abs(left); err == nil { + left = absolute + } + if absolute, err := filepath.Abs(right); err == nil { + right = absolute + } + return sameManagedName(left, right) +} + +// sameManagedName compares identifiers case-insensitively on Windows. +func sameManagedName(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func sameManagedAgentID(left, right string) bool { + return sameManagedName(left, right) +} + +func managedReferenceKey(agentID string) string { + if runtime.GOOS == "windows" { + return strings.ToLower(agentID) + } + return agentID +} + +// resolveManagedScript accepts absolute ~/.elydora// paths. +func resolveManagedScript(scriptPath, scriptName string) (*managedScriptReference, error) { + if !filepath.IsAbs(scriptPath) || !sameManagedName(filepath.Base(scriptPath), scriptName) { + return nil, nil + } + agentDirectory := filepath.Dir(scriptPath) + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) { + return nil, nil + } + agentID := filepath.Base(agentDirectory) + if agentID == "" || agentID == "." || agentID == ".." { + return nil, nil + } + return &managedScriptReference{agentID: agentID, scriptPath: scriptPath}, nil +} + +func validateManagedInstallConfig(config InstallConfig, agentKey, product string) error { + for _, field := range []struct{ name, value string }{ + {"agent name", config.AgentName}, + {"organization ID", config.OrgID}, + {"agent ID", config.AgentID}, + {"key ID", config.KID}, + {"private key", config.PrivateKey}, + {"base URL", config.BaseURL}, + {"guard script path", config.GuardScriptPath}, + } { + if strings.TrimSpace(field.value) == "" { + return fmt.Errorf("%s is required", field.name) + } + } + if config.AgentName != agentKey { + return fmt.Errorf("%s installation requires agent name %s", product, agentKey) + } + if config.Token != "" && strings.TrimSpace(config.Token) == "" { + return fmt.Errorf("token must contain a non-whitespace value when provided") + } + if err := validateManagedPrivateKey(config.PrivateKey); err != nil { + return err + } + return validateManagedBaseURL(config.BaseURL) +} + +func resolveManagedRuntimePaths( + config InstallConfig, + guardScript, auditScript string, +) (*managedRuntimePaths, error) { + if config.AgentID == "" { + return nil, fmt.Errorf("agent ID is required") + } + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) + if err != nil { + return nil, err + } + paths := &managedRuntimePaths{ + runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, + configPath: filepath.Join(agentDirectory, "config.json"), + keyPath: filepath.Join(agentDirectory, "private.key"), + guardPath: filepath.Join(agentDirectory, guardScript), + auditPath: filepath.Join(agentDirectory, auditScript), + } + if !filepath.IsAbs(config.GuardScriptPath) || + !sameManagedPath(config.GuardScriptPath, paths.guardPath) { + return nil, fmt.Errorf( + "guard runtime must use the managed Elydora agent directory: %s", + paths.guardPath, + ) + } + if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || + !sameManagedPath(config.HookScript, paths.auditPath)) { + return nil, fmt.Errorf( + "audit runtime must use the managed Elydora agent directory: %s", + paths.auditPath, + ) + } + return paths, nil +} + +func readManagedRuntimeConfig(path string) (map[string]any, bool, error) { + snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) + if err != nil || snapshot == nil { + return nil, snapshot != nil, err + } + label := fmt.Sprintf("Elydora runtime config at %s", path) + config, err := decodeStrictJSONObject(snapshot.contents, label) + return config, true, err +} + +func requireManagedRuntimeString(config map[string]any, field, configPath string) (string, error) { + value, ok := config[field].(string) + if !ok || strings.TrimSpace(value) == "" { + return "", fmt.Errorf("elydora runtime config %s is invalid: %s", field, configPath) + } + return value, nil +} + +func validateManagedRuntimeConfig( + config map[string]any, + expectedAgentID, configPath, agentKey, product string, +) error { + fields := make([]string, 0, len(config)) + for field := range config { + fields = append(fields, field) + } + sort.Strings(fields) + for _, field := range fields { + if _, ok := managedRuntimeConfigFields[field]; !ok { + return fmt.Errorf( + `elydora runtime config has unsupported field %q: %s`, + field, + configPath, + ) + } + } + for _, field := range []string{"org_id", "kid"} { + if _, err := requireManagedRuntimeString(config, field, configPath); err != nil { + return err + } + } + agentID, err := requireManagedRuntimeString(config, "agent_id", configPath) + if err != nil { + return err + } + if !sameManagedAgentID(agentID, expectedAgentID) || config["agent_name"] != agentKey { + return fmt.Errorf( + "elydora runtime identity does not match %s hooks: %s", + product, + configPath, + ) + } + if _, exists := config["token"]; exists { + if _, err := requireManagedRuntimeString(config, "token", configPath); err != nil { + return err + } + } + baseURL, err := requireManagedRuntimeString(config, "base_url", configPath) + if err != nil { + return err + } + if err := validateManagedBaseURL(baseURL); err != nil { + return fmt.Errorf("elydora runtime config base URL is invalid at %s: %w", configPath, err) + } + return nil +} + +type managedArtifact struct { + path string + label string + limit int64 +} + +func managedRuntimeArtifacts(agentDirectory, guardScript, auditScript string) []managedArtifact { + return []managedArtifact{ + {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, + {filepath.Join(agentDirectory, guardScript), "Elydora guard runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, auditScript), "Elydora audit runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, + {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, + {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, + } +} + +// validateManagedRuntimeIdentity rejects an agent directory owned by another identity. +func validateManagedRuntimeIdentity( + agentDirectory, agentID, agentKey, product string, + extraArtifacts ...managedArtifact, +) error { + rootExists, err := managedPhysicalDirectoryExists( + filepath.Dir(agentDirectory), "Elydora runtime directory", + ) + if err != nil || !rootExists { + return err + } + directoryExists, err := managedPhysicalDirectoryExists( + agentDirectory, "Elydora agent runtime directory", + ) + if err != nil || !directoryExists { + return err + } + configPath := filepath.Join(agentDirectory, "config.json") + config, configExists, err := readManagedRuntimeConfig(configPath) + if err != nil { + return err + } + artifactExists := false + artifacts := append(managedRuntimeArtifacts(agentDirectory, "guard.js", "hook.js"), extraArtifacts...) + for _, item := range artifacts { + exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) + if inspectErr != nil { + return inspectErr + } + artifactExists = artifactExists || exists + } + if !configExists { + if artifactExists { + return fmt.Errorf( + "elydora runtime identity cannot be verified without config.json: %s", + agentDirectory, + ) + } + return nil + } + configuredID, ok := config["agent_id"].(string) + if !ok || config["agent_name"] != agentKey || !sameManagedAgentID(configuredID, agentID) { + return fmt.Errorf( + "elydora runtime config identity does not match %s agent %s: %s", + product, + agentID, + configPath, + ) + } + return nil +} + +type expectedRuntimeScripts func(agentID string, config map[string]any) (guard, audit []byte) + +// managedRuntimeContractExists is the strict status check; nil expected accepts non-empty scripts. +func managedRuntimeContractExists( + contract managedRuntimeContract, + agentKey, product string, + expected expectedRuntimeScripts, +) (bool, error) { + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return false, err + } + agentDirectory := filepath.Dir(contract.guardPath) + if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) || + !sameManagedPath(contract.auditPath, filepath.Join(agentDirectory, "hook.js")) { + return false, nil + } + for _, directory := range []struct{ path, label string }{ + {runtimeRoot, "Elydora runtime directory"}, + {agentDirectory, "Elydora agent runtime directory"}, + } { + exists, err := managedPhysicalDirectoryExists(directory.path, directory.label) + if err != nil || !exists { + return false, err + } + } + configPath := filepath.Join(agentDirectory, "config.json") + keyPath := filepath.Join(agentDirectory, "private.key") + config, configExists, err := readManagedRuntimeConfig(configPath) + if err != nil { + return false, err + } + key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) + if err != nil { + return false, err + } + guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes) + if err != nil { + return false, err + } + audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes) + if err != nil { + return false, err + } + if !configExists || key == nil || guard == nil || audit == nil { + return false, nil + } + if err := validateManagedRuntimeConfig(config, contract.agentID, configPath, agentKey, product); err != nil { + return false, err + } + if err := validateManagedPrivateKey(string(key.contents)); err != nil { + return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err) + } + if expected == nil { + return len(guard.contents) > 0 && len(audit.contents) > 0, nil + } + expectedGuard, expectedAudit := expected(contract.agentID, config) + return string(guard.contents) == string(expectedGuard) && + string(audit.contents) == string(expectedAudit), nil +} + +func managedRuntimeFilesExist( + contracts []managedRuntimeContract, + agentKey, product string, + expected expectedRuntimeScripts, +) (bool, error) { + for _, contract := range contracts { + exists, err := managedRuntimeContractExists(contract, agentKey, product, expected) + if err != nil || exists { + return exists, err + } + } + return false, nil +} + +// managedRuntimePresent is the presence-only status check; identity mismatch reports false. +func managedRuntimePresent(contract managedRuntimeContract, agentKey string) (bool, error) { + agentDirectory := filepath.Dir(contract.guardPath) + directoryExists, err := managedPhysicalDirectoryExists( + agentDirectory, "Elydora agent runtime directory", + ) + if err != nil || !directoryExists { + return false, err + } + config, exists, err := readManagedRuntimeConfig(filepath.Join(agentDirectory, "config.json")) + if err != nil { + return false, err + } + configuredID, idOK := config["agent_id"].(string) + if !exists || !idOK || config["agent_name"] != agentKey || + !sameManagedAgentID(configuredID, contract.agentID) { + return false, nil + } + for _, item := range []managedArtifact{ + {contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes}, + {contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, + } { + exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) + if inspectErr != nil || !exists { + return false, inspectErr + } + } + return true, nil +} + +func managedRuntimeFilesPresent(contracts []managedRuntimeContract, agentKey string) (bool, error) { + for _, contract := range contracts { + present, err := managedRuntimePresent(contract, agentKey) + if err != nil || present { + return present, err + } + } + return false, nil +} + +func buildManagedRuntimeConfig(config InstallConfig, agentKey string) ([]byte, error) { + value := map[string]any{ + "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, + "base_url": config.BaseURL, "agent_name": agentKey, + } + if config.Token != "" { + value["token"] = config.Token + } + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Elydora runtime config: %w", err) + } + encoded = append(encoded, '\n') + if len(encoded) > maxRuntimeConfigBytes { + return nil, fmt.Errorf( + "elydora runtime config exceeds %d bytes after JSON encoding", + maxRuntimeConfigBytes, + ) + } + return encoded, nil +} + +// managedRuntimeFileChanges prepares the guard, config, key, and audit runtime changes. +func managedRuntimeFileChanges( + config InstallConfig, + paths *managedRuntimePaths, + agentKey, guardScript, auditScript string, +) ([]*fileChange, error) { + runtimeConfig, err := buildManagedRuntimeConfig(config, agentKey) + if err != nil { + return nil, err + } + items := []struct { + path, label string + content []byte + mode os.FileMode + }{ + {paths.guardPath, "Elydora guard runtime", []byte(guardScript), 0700}, + {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, + {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, + {paths.auditPath, "Elydora audit runtime", []byte(auditScript), 0700}, + } + changes := make([]*fileChange, 0, len(items)+1) + for _, item := range items { + change, err := prepareFileChange(item.path, item.label, item.content, item.mode) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + return changes, nil +} + +func hasFileChanges(changes []*fileChange) bool { + for _, change := range changes { + if change != nil { + return true + } + } + return false +} + +// writeManagedChanges creates the runtime and settings directories, then commits. +func writeManagedChanges( + changes []*fileChange, + label string, + rename renameFunc, + runtimeRoot, agentDirectory, settingsDirectory, settingsLabel string, +) error { + if !hasFileChanges(changes) { + return nil + } + if agentDirectory != "" { + if err := EnsurePrivateDirectory(runtimeRoot); err != nil { + return err + } + if err := EnsurePrivateDirectory(agentDirectory); err != nil { + return err + } + } + if err := ensureManagedDirectory(settingsDirectory, settingsLabel); err != nil { + return err + } + return writeChanges(changes, label, rename) +} diff --git a/cmd/elydora/plugins/shell_command.go b/cmd/elydora/plugins/shell_command.go new file mode 100644 index 0000000..9690ce6 --- /dev/null +++ b/cmd/elydora/plugins/shell_command.go @@ -0,0 +1,198 @@ +package plugins + +import ( + "encoding/base64" + "encoding/binary" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "unicode/utf16" +) + +const ( + posixApostrophe = `'"'"'` + powerShellExitSuffix = "; exit $LASTEXITCODE" + powerShellEncodedFlags = " -NoLogo -NoProfile -NonInteractive -EncodedCommand " +) + +func resolveNodeRuntime() (string, error) { + nodePath, err := exec.LookPath("node") + if err != nil { + return "", fmt.Errorf("resolve Node.js runtime: %w", err) + } + absolute, err := filepath.Abs(nodePath) + if err != nil { + return "", fmt.Errorf("resolve Node.js runtime path: %w", err) + } + return absolute, nil +} + +func resolveAbsoluteNodeRuntime(product string) (string, error) { + nodePath, err := resolveNodeRuntime() + if err != nil { + return "", err + } + if !filepath.IsAbs(nodePath) || !isNodeExecutable(nodePath) { + return "", fmt.Errorf("%s hooks require an absolute Node.js executable path", product) + } + return nodePath, nil +} + +func isNodeExecutable(path string) bool { + name := filepath.Base(path) + return name == "node" || strings.EqualFold(name, "node.exe") +} + +func quotePOSIXArgument(value string) string { + return "'" + strings.ReplaceAll(value, "'", posixApostrophe) + "'" +} + +func quotePowerShellArgument(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +func posixSource(runtimePath, scriptPath string) string { + return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath) +} + +func powerShellSource(runtimePath, scriptPath string) string { + return "& " + quotePowerShellArgument(runtimePath) + " " + + quotePowerShellArgument(scriptPath) + powerShellExitSuffix +} + +func windowsPowerShellPath() string { + root := "C:\\Windows" + if runtime.GOOS == "windows" { + configured := os.Getenv("SystemRoot") + if filepath.IsAbs(configured) && !strings.ContainsAny(configured, "\"%\r\n") { + root = configured + } + } + return strings.TrimRight(root, `\/`) + `\System32\WindowsPowerShell\v1.0\powershell.exe` +} + +func encodePowerShellSource(source string) string { + units := utf16.Encode([]rune(source)) + raw := make([]byte, len(units)*2) + for index, value := range units { + binary.LittleEndian.PutUint16(raw[index*2:], value) + } + return base64.StdEncoding.EncodeToString(raw) +} + +func decodePowerShellSource(encoded string) (string, bool) { + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || base64.StdEncoding.EncodeToString(raw) != encoded || len(raw)%2 != 0 { + return "", false + } + units := make([]uint16, len(raw)/2) + for index := range units { + units[index] = uint16(raw[index*2]) | uint16(raw[index*2+1])<<8 + } + return string(utf16.Decode(units)), true +} + +func encodedWindowsCommand(runtimePath, scriptPath string) string { + return `"` + windowsPowerShellPath() + `"` + powerShellEncodedFlags + + encodePowerShellSource(powerShellSource(runtimePath, scriptPath)) +} + +func readPOSIXArgument(command string, start int) (string, int, bool) { + if start >= len(command) || command[start] != '\'' { + return "", start, false + } + var value strings.Builder + for index := start + 1; index < len(command); { + if strings.HasPrefix(command[index:], posixApostrophe) { + value.WriteByte('\'') + index += len(posixApostrophe) + continue + } + if command[index] == '\'' { + return value.String(), index + 1, true + } + value.WriteByte(command[index]) + index++ + } + return "", start, false +} + +func parsePOSIXCommand(command string) (string, string, bool) { + runtimePath, next, ok := readPOSIXArgument(command, 0) + if !ok || next >= len(command) || command[next] != ' ' { + return "", "", false + } + scriptPath, end, ok := readPOSIXArgument(command, next+1) + return runtimePath, scriptPath, ok && end == len(command) +} + +func readPowerShellArgument(command string, start int) (string, int, bool) { + if start >= len(command) || command[start] != '\'' { + return "", start, false + } + var value strings.Builder + for index := start + 1; index < len(command); index++ { + if command[index] != '\'' { + value.WriteByte(command[index]) + continue + } + if index+1 < len(command) && command[index+1] == '\'' { + value.WriteByte('\'') + index++ + continue + } + return value.String(), index + 1, true + } + return "", start, false +} + +func parsePowerShellSource(source string) (string, string, bool) { + if !strings.HasPrefix(source, "& ") { + return "", "", false + } + runtimePath, next, ok := readPowerShellArgument(source, 2) + if !ok || next >= len(source) || source[next] != ' ' { + return "", "", false + } + scriptPath, end, ok := readPowerShellArgument(source, next+1) + return runtimePath, scriptPath, ok && source[end:] == powerShellExitSuffix +} + +func windowsPathBase(value string) string { + index := strings.LastIndexAny(value, `\/`) + return value[index+1:] +} + +func isAbsoluteWindowsPath(value string) bool { + return len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || + (value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' && + (value[2] == '\\' || value[2] == '/') || strings.HasPrefix(value, `\\`) +} + +func isPowerShellExecutable(value string) bool { + return isAbsoluteWindowsPath(value) && + strings.EqualFold(windowsPathBase(value), "powershell.exe") +} + +func parseEncodedWindowsCommand(command string) (string, string, bool) { + if !strings.HasPrefix(command, `"`) { + return "", "", false + } + closing := strings.IndexByte(command[1:], '"') + if closing < 0 { + return "", "", false + } + closing++ + prefix := `"` + powerShellEncodedFlags + if !isPowerShellExecutable(command[1:closing]) || !strings.HasPrefix(command[closing:], prefix) { + return "", "", false + } + source, ok := decodePowerShellSource(command[closing+len(prefix):]) + if !ok { + return "", "", false + } + return parsePowerShellSource(source) +}