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
This commit is contained in:
1 parent
c1caed3d42
commit
a7fc75bf6b
39 files changed
+914
-2018
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ func runGeminiRawCommand(
|
||||
var process *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
process = exec.Command(
|
||||
codexPowerShellPath(),
|
||||
windowsPowerShellPath(),
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -219,4 +219,3 @@ func (p *LettaPlugin) Status() (PluginStatus, error) {
|
||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
|
||||
@@ -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/<agent>/<scriptName> 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user