fix: harden Cursor hook enforcement
This commit is contained in:
1 parent
bf777a2ff0
commit
415a884efe
12 files changed
+1980
-340
No files matched your search
+28
-19
@@ -94,7 +94,8 @@ func cmdInstall(args []string) {
|
||||
fmt.Fprintf(os.Stderr, "Error: unsupported agent %q. Run 'elydora agents' to see supported agents.\n", *agent)
|
||||
os.Exit(1)
|
||||
}
|
||||
if _, err := plugins.ResolveAgentRuntimeDirectory(*agentID); err != nil {
|
||||
agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(*agentID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -115,25 +116,7 @@ func cmdInstall(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Generate guard script (PreToolUse — freeze enforcement)
|
||||
agentDirectory, err := plugins.PrepareAgentRuntimeDirectory(*agentID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
guardScriptPath := filepath.Join(agentDirectory, "guard.js")
|
||||
guardScript := plugins.GenerateGuardScript(*agent, *agentID)
|
||||
if err := plugins.WriteRuntimeFileAtomic(
|
||||
guardScriptPath,
|
||||
"Elydora guard runtime",
|
||||
[]byte(guardScript),
|
||||
0700,
|
||||
); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing guard script: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" Guard script: %s\n", guardScriptPath)
|
||||
|
||||
config := plugins.InstallConfig{
|
||||
AgentName: *agent,
|
||||
OrgID: *orgID,
|
||||
@@ -144,11 +127,37 @@ func cmdInstall(args []string) {
|
||||
BaseURL: *baseURL,
|
||||
GuardScriptPath: guardScriptPath,
|
||||
}
|
||||
if preflighter, ok := plugin.(plugins.InstallPreflighter); ok {
|
||||
if err := preflighter.PreflightInstall(config); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
manager, managesGuard := plugin.(plugins.GuardRuntimeManager)
|
||||
if !managesGuard || !manager.ManagesGuardRuntime() {
|
||||
agentDirectory, err = plugins.PrepareAgentRuntimeDirectory(*agentID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
guardScript := plugins.GenerateGuardScript(*agent, *agentID)
|
||||
if err := plugins.WriteRuntimeFileAtomic(
|
||||
guardScriptPath,
|
||||
"Elydora guard runtime",
|
||||
[]byte(guardScript),
|
||||
0700,
|
||||
); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing guard script: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if err := plugin.Install(config); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" Guard script: %s\n", guardScriptPath)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,12 +15,12 @@ type InstallConfig struct {
|
||||
|
||||
// PluginStatus describes the current state of a plugin installation.
|
||||
type PluginStatus struct {
|
||||
Installed bool
|
||||
AgentName string
|
||||
DisplayName string
|
||||
HookConfigured bool
|
||||
Installed bool
|
||||
AgentName string
|
||||
DisplayName string
|
||||
HookConfigured bool
|
||||
HookScriptExists bool
|
||||
ConfigPath string
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// AgentPlugin is the interface that every agent integration must implement.
|
||||
@@ -29,3 +29,13 @@ type AgentPlugin interface {
|
||||
Uninstall(agentID string) error
|
||||
Status() (PluginStatus, error)
|
||||
}
|
||||
|
||||
// InstallPreflighter validates provider and runtime sources before CLI writes.
|
||||
type InstallPreflighter interface {
|
||||
PreflightInstall(config InstallConfig) error
|
||||
}
|
||||
|
||||
// GuardRuntimeManager owns provider-specific guard generation transactionally.
|
||||
type GuardRuntimeManager interface {
|
||||
ManagesGuardRuntime() bool
|
||||
}
|
||||
+125
-189
@@ -2,236 +2,172 @@ package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CursorPlugin manages the Elydora audit hook for Cursor.
|
||||
// It writes/merges into ~/.cursor/hooks.json using nested settings.hooks.postToolUse[].
|
||||
type CursorPlugin struct{}
|
||||
// CursorPlugin manages Cursor's native global user hooks.
|
||||
type CursorPlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
func (p *CursorPlugin) configPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
// ManagesGuardRuntime reports that Cursor commits its provider-specific guard
|
||||
// in the same transaction as the rest of the installation.
|
||||
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")
|
||||
}
|
||||
return filepath.Join(home, ".cursor", "hooks.json"), nil
|
||||
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
|
||||
}
|
||||
|
||||
// PreflightInstall validates every existing source before the CLI creates a
|
||||
// runtime directory or guard file.
|
||||
func (p *CursorPlugin) PreflightInstall(config InstallConfig) error {
|
||||
if _, err := readCursorDocument(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, agentDirectory, _, _, err := cursorAgentPaths(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = resolveNodeRuntime()
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *CursorPlugin) Install(config InstallConfig) error {
|
||||
scriptPath, err := hookScriptPath(config.AgentID)
|
||||
document, err := readCursorDocument()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.HookScript != "" {
|
||||
scriptPath = config.HookScript
|
||||
}
|
||||
|
||||
if err := GenerateHookScript(scriptPath, config); err != nil {
|
||||
return fmt.Errorf("generate hook script: %w", err)
|
||||
}
|
||||
|
||||
guardPath := config.GuardScriptPath
|
||||
if guardPath == "" {
|
||||
guardPath, err = guardScriptPath(config.AgentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
configPath, err := p.configPath()
|
||||
runtimeRoot, agentDirectory, guardPath, auditPath, err := cursorAgentPaths(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil {
|
||||
return err
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure hooks object exists
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
hooks = make(map[string]interface{})
|
||||
hooks := removeManagedCursorHooks(document.hooks, runtimeRoot, "")
|
||||
hooks["preToolUse"] = append(
|
||||
hooks["preToolUse"],
|
||||
buildCursorHandler(nodePath, guardPath),
|
||||
)
|
||||
hooks["postToolUse"] = append(
|
||||
hooks["postToolUse"],
|
||||
buildCursorHandler(nodePath, auditPath),
|
||||
)
|
||||
hooks["postToolUseFailure"] = append(
|
||||
hooks["postToolUseFailure"],
|
||||
buildCursorHandler(nodePath, auditPath),
|
||||
)
|
||||
rendered, err := renderCursorDocument(document, hooks, runtimeRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render Cursor user hooks: %w", err)
|
||||
}
|
||||
|
||||
// --- preToolUse (guard — freeze enforcement) ---
|
||||
preToolUse, _ := hooks["preToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
guardEntry := map[string]interface{}{
|
||||
"command": "node " + guardPath,
|
||||
}
|
||||
preFiltered = append(preFiltered, guardEntry)
|
||||
hooks["preToolUse"] = preFiltered
|
||||
|
||||
// --- postToolUse (audit logging) ---
|
||||
postToolUse, _ := hooks["postToolUse"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range postToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
hookEntry := map[string]interface{}{
|
||||
"command": "node " + scriptPath,
|
||||
}
|
||||
postFiltered = append(postFiltered, hookEntry)
|
||||
hooks["postToolUse"] = postFiltered
|
||||
|
||||
settings["hooks"] = hooks
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
changes, err := prepareCursorInstallationChanges(config, agentDirectory, rendered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Installed Elydora hook for Cursor at %s\n", configPath)
|
||||
if err := writeCursorChanges(
|
||||
changes,
|
||||
"Install Cursor hooks",
|
||||
p.rename,
|
||||
runtimeRoot,
|
||||
agentDirectory,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" Cursor hooks: %s\n", document.filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CursorPlugin) Uninstall(agentID string) error {
|
||||
configPath, err := p.configPath()
|
||||
document, err := readCursorDocument()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks == nil {
|
||||
fmt.Println("No Cursor hooks found.")
|
||||
return nil
|
||||
hooks := removeManagedCursorHooks(document.hooks, runtimeRoot, agentID)
|
||||
rendered, err := renderCursorDocument(document, hooks, runtimeRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render Cursor user hooks: %w", err)
|
||||
}
|
||||
|
||||
// Remove preToolUse Elydora entries
|
||||
preToolUse, _ := hooks["preToolUse"].([]interface{})
|
||||
var preFiltered []interface{}
|
||||
for _, entry := range preToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
}
|
||||
if len(preFiltered) == 0 {
|
||||
delete(hooks, "preToolUse")
|
||||
} else {
|
||||
hooks["preToolUse"] = preFiltered
|
||||
}
|
||||
|
||||
// Remove postToolUse Elydora entries
|
||||
postToolUse, _ := hooks["postToolUse"].([]interface{})
|
||||
var postFiltered []interface{}
|
||||
for _, entry := range postToolUse {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
postFiltered = append(postFiltered, entry)
|
||||
}
|
||||
if len(postFiltered) == 0 {
|
||||
delete(hooks, "postToolUse")
|
||||
} else {
|
||||
hooks["postToolUse"] = postFiltered
|
||||
}
|
||||
|
||||
if len(hooks) == 0 {
|
||||
delete(settings, "hooks")
|
||||
} else {
|
||||
settings["hooks"] = hooks
|
||||
}
|
||||
|
||||
if err := writeJSONFile(configPath, settings); err != nil {
|
||||
change, err := prepareRenderedCursorChange(rendered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if agentID != "" {
|
||||
scriptPath, _ := hookScriptPath(agentID)
|
||||
if scriptPath != "" {
|
||||
os.Remove(scriptPath)
|
||||
}
|
||||
gPath, _ := guardScriptPath(agentID)
|
||||
if gPath != "" {
|
||||
os.Remove(gPath)
|
||||
}
|
||||
}
|
||||
fmt.Println("Uninstalled Elydora hook for Cursor.")
|
||||
return nil
|
||||
return writeCursorChanges(
|
||||
[]*fileChange{change},
|
||||
"Uninstall Cursor hooks",
|
||||
p.rename,
|
||||
"",
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
func (p *CursorPlugin) Status() (PluginStatus, error) {
|
||||
configPath, err := p.configPath()
|
||||
if err != nil {
|
||||
return PluginStatus{}, err
|
||||
}
|
||||
|
||||
configPath, pathErr := cursorConfigPath()
|
||||
entry := SupportedAgents[cursorAgentKey]
|
||||
status := PluginStatus{
|
||||
AgentName: "cursor",
|
||||
DisplayName: "Cursor",
|
||||
ConfigPath: configPath,
|
||||
AgentName: cursorAgentKey, DisplayName: entry.Name, ConfigPath: configPath,
|
||||
}
|
||||
|
||||
settings, err := readJSONFile(configPath)
|
||||
if pathErr != nil {
|
||||
return status, pathErr
|
||||
}
|
||||
document, err := readCursorDocument()
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
contracts := cursorRuntimeContracts(document.hooks, runtimeRoot)
|
||||
status.HookConfigured = len(contracts) > 0
|
||||
if !status.HookConfigured {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
||||
if hooks != nil {
|
||||
preConfigured := hasCursorElydoraEntry(hooks["preToolUse"])
|
||||
postConfigured := hasCursorElydoraEntry(hooks["postToolUse"])
|
||||
status.HookConfigured = preConfigured && postConfigured
|
||||
|
||||
// Extract hook script path from the configured command
|
||||
scriptPath := extractCursorElydoraScriptPath(hooks["postToolUse"])
|
||||
if scriptPath != "" {
|
||||
if _, err := os.Stat(scriptPath); err == nil {
|
||||
status.HookScriptExists = true
|
||||
}
|
||||
}
|
||||
status.HookScriptExists, err = cursorRuntimeFilesExist(contracts)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||
status.Installed = status.HookScriptExists
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// extractCursorElydoraScriptPath extracts the script path from a Cursor hook array's Elydora command entry.
|
||||
func extractCursorElydoraScriptPath(hookArray interface{}) string {
|
||||
arr, _ := hookArray.([]interface{})
|
||||
for _, entry := range arr {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
return extractPathFromNodeCommand(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hasCursorElydoraEntry checks if a Cursor hook array (flat format) contains an Elydora entry.
|
||||
func hasCursorElydoraEntry(hookArray interface{}) bool {
|
||||
arr, _ := hookArray.([]interface{})
|
||||
for _, entry := range arr {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
cursorAgentKey = "cursor"
|
||||
cursorConfigFile = "hooks.json"
|
||||
cursorGuardScript = "guard.js"
|
||||
cursorAuditScript = "hook.js"
|
||||
cursorHookTimeout = float64(10)
|
||||
cursorPOSIXApostrophe = `'"'"'`
|
||||
)
|
||||
|
||||
type cursorHooks map[string][]map[string]any
|
||||
|
||||
type cursorDocument struct {
|
||||
exists bool
|
||||
filePath string
|
||||
root map[string]any
|
||||
hooks cursorHooks
|
||||
raw []byte
|
||||
}
|
||||
|
||||
type cursorRenderedDocument struct {
|
||||
document *cursorDocument
|
||||
changed bool
|
||||
next []byte
|
||||
remove bool
|
||||
}
|
||||
|
||||
type cursorRuntimeContract struct {
|
||||
agentID string
|
||||
guardPath string
|
||||
auditPath string
|
||||
}
|
||||
|
||||
func cloneCursorHooks(source cursorHooks) cursorHooks {
|
||||
clone := make(cursorHooks, len(source))
|
||||
for event, handlers := range source {
|
||||
clone[event] = append([]map[string]any(nil), handlers...)
|
||||
}
|
||||
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)
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "& " + quoteCursorPowerShell(nodePath) + " " +
|
||||
quoteCursorPowerShell(scriptPath) + "; exit $LASTEXITCODE"
|
||||
}
|
||||
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 {
|
||||
return "", false
|
||||
}
|
||||
executable := command[:separator]
|
||||
if !strings.EqualFold(executable, "node") && !strings.EqualFold(executable, "node.exe") {
|
||||
return "", false
|
||||
}
|
||||
script := command[separator+1:]
|
||||
if len(script) >= 2 && script[0] == '"' && script[len(script)-1] == '"' {
|
||||
script = script[1 : len(script)-1]
|
||||
}
|
||||
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)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
var nodePath, scriptPath string
|
||||
var parsed bool
|
||||
if runtime.GOOS == "windows" {
|
||||
nodePath, scriptPath, parsed = parseCursorPowerShellCommand(command)
|
||||
} else {
|
||||
nodePath, scriptPath, parsed = parseCursorPOSIXCommand(command)
|
||||
}
|
||||
if parsed && filepath.IsAbs(nodePath) && filepath.IsAbs(scriptPath) &&
|
||||
isCursorNodeExecutable(nodePath) {
|
||||
return scriptPath, true
|
||||
}
|
||||
}
|
||||
if len(handler) != 1 {
|
||||
return "", false
|
||||
}
|
||||
command, ok := handler["command"].(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return parseCursorLegacyCommand(command)
|
||||
}
|
||||
|
||||
func cursorManagedAgentID(
|
||||
handler map[string]any,
|
||||
scriptName string,
|
||||
runtimeRoot string,
|
||||
) (string, bool) {
|
||||
scriptPath, managed := cursorManagedScriptPath(handler)
|
||||
if !managed || !filepath.IsAbs(scriptPath) || filepath.Base(scriptPath) != scriptName {
|
||||
return "", false
|
||||
}
|
||||
agentDirectory := filepath.Dir(scriptPath)
|
||||
if !sameCursorPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
return "", false
|
||||
}
|
||||
agentID := filepath.Base(agentDirectory)
|
||||
return agentID, agentID != "" && agentID != "." && agentID != ".."
|
||||
}
|
||||
|
||||
func readCursorHooks(value any, label string) (cursorHooks, error) {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || object == nil {
|
||||
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
|
||||
}
|
||||
hooks := make(cursorHooks, len(object))
|
||||
for event, handlerValue := range object {
|
||||
values, ok := handlerValue.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event)
|
||||
}
|
||||
handlers := make([]map[string]any, 0, len(values))
|
||||
for index, value := range values {
|
||||
handler, ok := value.(map[string]any)
|
||||
if !ok || handler == nil {
|
||||
return nil, fmt.Errorf(`%s handler hooks.%s[%d] must be an object`, label, event, index)
|
||||
}
|
||||
handlers = append(handlers, handler)
|
||||
}
|
||||
hooks[event] = handlers
|
||||
}
|
||||
return hooks, nil
|
||||
}
|
||||
|
||||
func cursorContainsManagedHook(hooks cursorHooks, runtimeRoot string) bool {
|
||||
for _, contract := range []struct{ event, script string }{
|
||||
{"preToolUse", cursorGuardScript},
|
||||
{"postToolUse", cursorAuditScript},
|
||||
{"postToolUseFailure", cursorAuditScript},
|
||||
} {
|
||||
for _, handler := range hooks[contract.event] {
|
||||
if _, managed := cursorManagedAgentID(handler, contract.script, runtimeRoot); managed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseCursorDocument(filePath string, raw []byte, runtimeRoot string) (*cursorDocument, error) {
|
||||
label := fmt.Sprintf("Cursor user hooks at %s", filePath)
|
||||
if !json.Valid(raw) {
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", label, err)
|
||||
}
|
||||
}
|
||||
root, err := decodeJSONCObject(raw, label, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hooks := cursorHooks{}
|
||||
if value, exists := root["hooks"]; exists {
|
||||
hooks, err = readCursorHooks(value, label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
version, hasVersion := root["version"]
|
||||
current := hasVersion && version == float64(1)
|
||||
if !current && (hasVersion || !cursorContainsManagedHook(hooks, runtimeRoot)) {
|
||||
return nil, fmt.Errorf("%s must declare version 1", label)
|
||||
}
|
||||
return &cursorDocument{
|
||||
exists: true, filePath: filePath, root: root,
|
||||
hooks: hooks, raw: append([]byte(nil), raw...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createCursorDocument(filePath string) *cursorDocument {
|
||||
return &cursorDocument{filePath: filePath, root: map[string]any{}, hooks: cursorHooks{}}
|
||||
}
|
||||
|
||||
func removeManagedCursorHooks(hooks cursorHooks, runtimeRoot, agentID string) cursorHooks {
|
||||
result := cloneCursorHooks(hooks)
|
||||
for _, contract := range []struct{ event, script string }{
|
||||
{"preToolUse", cursorGuardScript},
|
||||
{"postToolUse", cursorAuditScript},
|
||||
{"postToolUseFailure", cursorAuditScript},
|
||||
} {
|
||||
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)) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, handler)
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(result, contract.event)
|
||||
} else {
|
||||
result[contract.event] = kept
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func entirelyManagedCursorDocument(document *cursorDocument, runtimeRoot string) bool {
|
||||
if !document.exists || len(document.hooks) == 0 {
|
||||
return false
|
||||
}
|
||||
for key := range document.root {
|
||||
if key != "version" && key != "hooks" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
count := 0
|
||||
for event, handlers := range document.hooks {
|
||||
script := map[string]string{
|
||||
"preToolUse": cursorGuardScript,
|
||||
"postToolUse": cursorAuditScript,
|
||||
"postToolUseFailure": cursorAuditScript,
|
||||
}[event]
|
||||
if script == "" || len(handlers) == 0 {
|
||||
return false
|
||||
}
|
||||
count += len(handlers)
|
||||
for _, handler := range handlers {
|
||||
if _, managed := cursorManagedAgentID(handler, script, runtimeRoot); !managed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func renderCursorDocument(
|
||||
document *cursorDocument,
|
||||
hooks cursorHooks,
|
||||
runtimeRoot string,
|
||||
) (*cursorRenderedDocument, error) {
|
||||
if !document.exists && len(hooks) == 0 {
|
||||
return &cursorRenderedDocument{document: document}, nil
|
||||
}
|
||||
if document.exists && reflect.DeepEqual(hooks, document.hooks) {
|
||||
return &cursorRenderedDocument{document: document}, nil
|
||||
}
|
||||
if len(hooks) == 0 && entirelyManagedCursorDocument(document, runtimeRoot) {
|
||||
return &cursorRenderedDocument{document: document, changed: true, remove: true}, nil
|
||||
}
|
||||
root := make(map[string]any, len(document.root)+1)
|
||||
for key, value := range document.root {
|
||||
root[key] = value
|
||||
}
|
||||
root["version"] = float64(1)
|
||||
if len(hooks) == 0 {
|
||||
delete(root, "hooks")
|
||||
} else {
|
||||
root["hooks"] = hooks
|
||||
}
|
||||
next, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next = append(next, '\n')
|
||||
return &cursorRenderedDocument{
|
||||
document: document, changed: !document.exists || string(next) != string(document.raw), next: next,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func managedCursorIDs(
|
||||
handlers []map[string]any,
|
||||
scriptName, runtimeRoot string,
|
||||
) map[string]string {
|
||||
result := map[string]string{}
|
||||
for _, handler := range handlers {
|
||||
agentID, managed := cursorManagedAgentID(handler, scriptName, runtimeRoot)
|
||||
if !managed {
|
||||
continue
|
||||
}
|
||||
key := agentID
|
||||
if runtime.GOOS == "windows" {
|
||||
key = strings.ToLower(agentID)
|
||||
}
|
||||
result[key] = agentID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cursorRuntimeContracts(hooks cursorHooks, runtimeRoot string) []cursorRuntimeContract {
|
||||
guards := managedCursorIDs(hooks["preToolUse"], cursorGuardScript, runtimeRoot)
|
||||
audits := managedCursorIDs(hooks["postToolUse"], cursorAuditScript, runtimeRoot)
|
||||
failures := managedCursorIDs(hooks["postToolUseFailure"], cursorAuditScript, runtimeRoot)
|
||||
keys := make([]string, 0, len(guards))
|
||||
for key := range guards {
|
||||
_, hasAudit := audits[key]
|
||||
_, hasFailureAudit := failures[key]
|
||||
if hasAudit && hasFailureAudit {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
contracts := make([]cursorRuntimeContract, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
agentID := guards[key]
|
||||
directory := filepath.Join(runtimeRoot, agentID)
|
||||
contracts = append(contracts, cursorRuntimeContract{
|
||||
agentID: agentID, guardPath: filepath.Join(directory, cursorGuardScript),
|
||||
auditPath: filepath.Join(directory, cursorAuditScript),
|
||||
})
|
||||
}
|
||||
return contracts
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func cursorConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".cursor", cursorConfigFile), nil
|
||||
}
|
||||
|
||||
func readCursorDocument() (*cursorDocument, error) {
|
||||
filePath, err := cursorConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Lstat(filePath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
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)
|
||||
}
|
||||
|
||||
func prepareRenderedCursorChange(rendered *cursorRenderedDocument) (*fileChange, error) {
|
||||
if rendered == nil || !rendered.changed {
|
||||
return nil, nil
|
||||
}
|
||||
return prepareSourceChange(
|
||||
rendered.document.filePath,
|
||||
"Cursor user hooks",
|
||||
rendered.document.raw,
|
||||
rendered.document.exists,
|
||||
rendered.next,
|
||||
0600,
|
||||
rendered.remove,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
rendered *cursorRenderedDocument,
|
||||
) ([]*fileChange, error) {
|
||||
baseURL := config.BaseURL
|
||||
if baseURL == "" {
|
||||
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,
|
||||
}, "", " ")
|
||||
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,
|
||||
)), 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)
|
||||
}
|
||||
documentChange, err := prepareRenderedCursorChange(rendered)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCursorInstallRejectsSymlinkRuntimeTargets(t *testing.T) {
|
||||
for _, name := range []string{"config", "private key", "guard", "audit"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
path := map[string]string{
|
||||
"config": fixture.runtimeConfig, "private key": fixture.privateKey,
|
||||
"guard": fixture.guardPath, "audit": fixture.hookPath,
|
||||
}[name]
|
||||
target := filepath.Join(fixture.homeDir, strings.ReplaceAll(name, " ", "-")+"-target")
|
||||
if err := os.WriteFile(target, []byte("target\n"), 0600); err != nil {
|
||||
t.Fatalf("write %s target: %v", name, err)
|
||||
}
|
||||
if err := os.Symlink(target, path); err != nil {
|
||||
t.Skipf("create %s symlink: %v", name, err)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err == nil ||
|
||||
!strings.Contains(err.Error(), "physical file") {
|
||||
t.Fatalf("symlink %s install error = %v", name, err)
|
||||
}
|
||||
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Cursor config exists after rejection: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil || string(content) != "target\n" {
|
||||
t.Fatalf("%s target = %q, %v", name, content, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorInstallRejectsSymlinkUserConfig(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
target := filepath.Join(fixture.homeDir, "cursor-hooks-target.json")
|
||||
source := `{"version":1,"hooks":{}}` + "\n"
|
||||
if err := os.WriteFile(target, []byte(source), 0600); err != nil {
|
||||
t.Fatalf("write Cursor config target: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil {
|
||||
t.Fatalf("create Cursor config directory: %v", err)
|
||||
}
|
||||
if err := os.Symlink(target, fixture.configPath); err != nil {
|
||||
t.Skipf("create Cursor config symlink: %v", err)
|
||||
}
|
||||
|
||||
if err := fixture.plugin.Install(fixture.config); err == nil ||
|
||||
!strings.Contains(err.Error(), "physical file") {
|
||||
t.Fatalf("symlink config install error = %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil || string(content) != source {
|
||||
t.Fatalf("Cursor config target = %q, %v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorInstallRejectsSymlinkUserConfigDirectory(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
target := t.TempDir()
|
||||
if err := os.Symlink(target, filepath.Dir(fixture.configPath)); err != nil {
|
||||
t.Skipf("create Cursor config directory symlink: %v", err)
|
||||
}
|
||||
|
||||
if err := fixture.plugin.Install(fixture.config); err == nil ||
|
||||
!strings.Contains(err.Error(), "physical directory") {
|
||||
t.Fatalf("symlink config directory install error = %v", err)
|
||||
}
|
||||
assertCursorRuntimeAbsent(t, fixture)
|
||||
entries, err := os.ReadDir(target)
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("Cursor config target entries = %#v, %v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorInstallRejectsRuntimeIdentityBeforeWrites(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, true)
|
||||
writeCursorObject(t, fixture.runtimeConfig, map[string]any{
|
||||
"agent_name": "codex", "agent_id": cursorTestAgentID,
|
||||
})
|
||||
guardBefore, err := os.ReadFile(fixture.guardPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read guard before install: %v", err)
|
||||
}
|
||||
|
||||
err = fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "identity does not match") {
|
||||
t.Fatalf("runtime identity install error = %v", err)
|
||||
}
|
||||
guardAfter, readErr := os.ReadFile(fixture.guardPath)
|
||||
if readErr != nil || string(guardAfter) != string(guardBefore) {
|
||||
t.Fatalf("guard changed to %q, %v", guardAfter, readErr)
|
||||
}
|
||||
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Cursor config exists after identity rejection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorInstallRejectsOrphanRuntimeBeforeWrites(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, true)
|
||||
guardBefore, err := os.ReadFile(fixture.guardPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read orphan guard: %v", err)
|
||||
}
|
||||
|
||||
err = fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "identity cannot be verified") {
|
||||
t.Fatalf("orphan runtime install error = %v", err)
|
||||
}
|
||||
guardAfter, readErr := os.ReadFile(fixture.guardPath)
|
||||
if readErr != nil || string(guardAfter) != string(guardBefore) {
|
||||
t.Fatalf("orphan guard changed to %q, %v", guardAfter, readErr)
|
||||
}
|
||||
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Cursor config exists after orphan rejection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorStatusRejectsSymlinkRuntimeFiles(t *testing.T) {
|
||||
for _, name := range []string{"config", "private key", "guard", "audit"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
installCursorFixture(t, fixture)
|
||||
path := map[string]string{
|
||||
"config": fixture.runtimeConfig, "private key": fixture.privateKey,
|
||||
"guard": fixture.guardPath, "audit": fixture.hookPath,
|
||||
}[name]
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s runtime: %v", name, err)
|
||||
}
|
||||
target := filepath.Join(fixture.homeDir, "status-"+strings.ReplaceAll(name, " ", "-"))
|
||||
if err := os.WriteFile(target, content, 0600); err != nil {
|
||||
t.Fatalf("write %s target: %v", name, err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("remove %s runtime: %v", name, err)
|
||||
}
|
||||
if err := os.Symlink(target, path); err != nil {
|
||||
t.Skipf("create %s runtime symlink: %v", name, err)
|
||||
}
|
||||
|
||||
status, err := fixture.plugin.Status()
|
||||
if err == nil || status.Installed {
|
||||
t.Fatalf("Cursor status = %#v, %v", status, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorInstallRequiresNodeBeforeWrites(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
t.Setenv("PATH", "")
|
||||
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "Node.js") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
assertCursorRuntimeAbsent(t, fixture)
|
||||
if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Cursor config exists after Node.js rejection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorInstallCreatesRuntimeDirectoryTransactionally(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
if err := os.Remove(fixture.agentDir); err != nil {
|
||||
t.Fatalf("remove empty agent directory: %v", err)
|
||||
}
|
||||
|
||||
installCursorFixture(t, fixture)
|
||||
for _, path := range []string{
|
||||
fixture.runtimeConfig,
|
||||
fixture.privateKey,
|
||||
fixture.guardPath,
|
||||
fixture.hookPath,
|
||||
} {
|
||||
if exists, err := cursorPhysicalFileExists(path, "Cursor runtime"); err != nil || !exists {
|
||||
t.Fatalf("runtime file %s exists = %v, %v", path, exists, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorInstallRollsBackEveryFileOnConfigCommitFailure(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, cursorJSON(map[string]any{
|
||||
"version": float64(1),
|
||||
"hooks": map[string]any{"sessionStart": []any{map[string]any{"command": "keep"}}},
|
||||
}), false)
|
||||
before, err := os.ReadFile(fixture.configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read Cursor config: %v", err)
|
||||
}
|
||||
failed := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !failed && sameCursorPath(destination, fixture.configPath) {
|
||||
failed = true
|
||||
return errors.New("injected Cursor config commit failure")
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
|
||||
err = fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "injected Cursor config commit failure") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
assertCursorFileBytes(t, fixture.configPath, before)
|
||||
assertCursorRuntimeAbsent(t, fixture)
|
||||
assertNoCursorTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestCursorInstallDetectsConcurrentSourceChangeAndRollsBack(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, cursorJSON(map[string]any{
|
||||
"version": float64(1), "hooks": map[string]any{},
|
||||
}), false)
|
||||
concurrent := []byte(`{"version":1,"hooks":{"sessionStart":[{"command":"concurrent"}]}}` + "\n")
|
||||
mutated := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if !mutated && strings.HasSuffix(source, ".tmp") {
|
||||
mutated = true
|
||||
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
assertCursorFileBytes(t, fixture.configPath, concurrent)
|
||||
assertCursorRuntimeAbsent(t, fixture)
|
||||
assertNoCursorTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func TestCursorAtomicConfigIsPrivateAndLeavesNoStagingFiles(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
installCursorFixture(t, fixture)
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(fixture.configPath)
|
||||
if err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("Cursor config mode = %v, %v", info.Mode().Perm(), err)
|
||||
}
|
||||
}
|
||||
assertNoCursorTransactionArtifacts(t, fixture.homeDir)
|
||||
}
|
||||
|
||||
func assertCursorRuntimeAbsent(t *testing.T, fixture *cursorFixture) {
|
||||
t.Helper()
|
||||
for _, path := range []string{
|
||||
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath,
|
||||
} {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("runtime file exists at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertCursorFileBytes(t *testing.T, path string, expected []byte) {
|
||||
t.Helper()
|
||||
actual, err := os.ReadFile(path)
|
||||
if err != nil || string(actual) != string(expected) {
|
||||
t.Fatalf("file %s = %q, %v; want %q", path, actual, err, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoCursorTransactionArtifacts(t *testing.T, roots ...string) {
|
||||
t.Helper()
|
||||
for _, root := range roots {
|
||||
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback") {
|
||||
t.Errorf("transaction artifact remains at %s", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk transaction root %s: %v", root, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCursorRegistryUsesNativeUserHooks(t *testing.T) {
|
||||
want := AgentRegistryEntry{Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"}
|
||||
if got := SupportedAgents["cursor"]; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Cursor registry = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
writeCursorObject(t, fixture.configPath, map[string]any{
|
||||
"description": "user-owned",
|
||||
"hooks": map[string]any{
|
||||
"sessionStart": []any{map[string]any{"command": "user-session"}},
|
||||
"preToolUse": []any{
|
||||
map[string]any{"command": "user-pre"},
|
||||
map[string]any{"command": "node " + fixture.guardPath},
|
||||
},
|
||||
"postToolUse": []any{map[string]any{"command": "node " + fixture.hookPath}},
|
||||
},
|
||||
})
|
||||
|
||||
installCursorFixture(t, fixture)
|
||||
installCursorFixture(t, fixture)
|
||||
|
||||
settings := readCursorObject(t, fixture.configPath)
|
||||
if settings["version"] != float64(1) || settings["description"] != "user-owned" {
|
||||
t.Fatalf("Cursor settings = %#v", settings)
|
||||
}
|
||||
hooks := cursorObject(t, settings["hooks"])
|
||||
if len(cursorArray(t, hooks["sessionStart"])) != 1 ||
|
||||
len(cursorArray(t, hooks["preToolUse"])) != 2 ||
|
||||
len(cursorArray(t, hooks["postToolUse"])) != 1 ||
|
||||
len(cursorArray(t, hooks["postToolUseFailure"])) != 1 {
|
||||
t.Fatalf("Cursor hooks = %#v", hooks)
|
||||
}
|
||||
if cursorObject(t, cursorArray(t, hooks["preToolUse"])[0])["command"] != "user-pre" {
|
||||
t.Fatalf("first preToolUse handler = %#v", cursorArray(t, hooks["preToolUse"])[0])
|
||||
}
|
||||
assertNativeCursorHandler(t, managedCursorHandler(t, settings, "preToolUse", "guard.js"))
|
||||
assertNativeCursorHandler(t, managedCursorHandler(t, settings, "postToolUse", "hook.js"))
|
||||
assertNativeCursorHandler(t, managedCursorHandler(t, settings, "postToolUseFailure", "hook.js"))
|
||||
runtimeConfig := readCursorObject(t, fixture.runtimeConfig)
|
||||
if runtimeConfig["agent_name"] != "cursor" || runtimeConfig["agent_id"] != cursorTestAgentID {
|
||||
t.Fatalf("runtime config = %#v", runtimeConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorCommandsBlockAndForwardOfficialPayloadByteForByte(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
installCursorFixture(t, fixture)
|
||||
settings := readCursorObject(t, fixture.configPath)
|
||||
guard := managedCursorHandler(t, settings, "preToolUse", "guard.js")
|
||||
writeCursorObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
|
||||
"status": "active", "cached_at": time.Now().UnixMilli(),
|
||||
})
|
||||
active := runCursorHandler(t, guard, "{}\n")
|
||||
var allow map[string]any
|
||||
if err := json.Unmarshal([]byte(active.stdout), &allow); err != nil || allow["permission"] != "allow" {
|
||||
t.Fatalf("active guard output = %q, %v", active.stdout, err)
|
||||
}
|
||||
writeCursorObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
|
||||
"status": "frozen", "cached_at": time.Now().UnixMilli(),
|
||||
})
|
||||
prePayload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"preToolUse","tool_name":"Shell","tool_input":{"command":"Get-ChildItem"},"tool_use_id":"call-1","cwd":"project"}` + "\n"
|
||||
frozen := runCursorHandler(t, guard, prePayload)
|
||||
if frozen.exitCode != 2 || !strings.Contains(frozen.stderr, "frozen by Elydora") {
|
||||
t.Fatalf("frozen guard result = %#v", frozen)
|
||||
}
|
||||
writeCursorObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
|
||||
"status": "revoked", "cached_at": time.Now().UnixMilli(),
|
||||
})
|
||||
revoked := runCursorHandler(t, guard, prePayload)
|
||||
if revoked.exitCode != 2 || !strings.Contains(revoked.stderr, "revoked in Elydora") {
|
||||
t.Fatalf("revoked guard result = %#v", revoked)
|
||||
}
|
||||
|
||||
capturePath := filepath.Join(t.TempDir(), "captured.json")
|
||||
captureScript := "const fs = require('node:fs');\n" +
|
||||
"fs.writeFileSync(process.env.ELYDORA_CAPTURE, fs.readFileSync(0));\n" +
|
||||
"process.stdout.write('{}\\n');\n"
|
||||
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
|
||||
t.Fatalf("write capture runtime: %v", err)
|
||||
}
|
||||
postPayload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"postToolUse","tool_name":"Shell","tool_input":{"command":"Get-ChildItem"},"tool_output":"{\"exitCode\":0,\"stdout\":\"ok\"}","tool_use_id":"call-1","cwd":"project","duration":42}` + "\n"
|
||||
audit := managedCursorHandler(t, settings, "postToolUse", "hook.js")
|
||||
result := runCursorHandler(t, audit, postPayload, "ELYDORA_CAPTURE="+capturePath)
|
||||
captured, err := os.ReadFile(capturePath)
|
||||
if err != nil || result.exitCode != 0 || string(captured) != postPayload {
|
||||
t.Fatalf("audit result = %#v, captured = %q, %v", result, captured, err)
|
||||
}
|
||||
failurePayload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"postToolUseFailure","tool_name":"Shell","tool_input":{"command":"exit 1"},"tool_use_id":"call-2","cwd":"project","error_message":"command failed","failure_type":"error","duration":21,"is_interrupt":false}` + "\n"
|
||||
failureAudit := managedCursorHandler(t, settings, "postToolUseFailure", "hook.js")
|
||||
failureResult := runCursorHandler(t, failureAudit, failurePayload, "ELYDORA_CAPTURE="+capturePath)
|
||||
captured, err = os.ReadFile(capturePath)
|
||||
if err != nil || failureResult.exitCode != 0 || string(captured) != failurePayload {
|
||||
t.Fatalf("failure audit result = %#v, captured = %q, %v", failureResult, captured, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorAuditPreservesNativeFailurePayload(t *testing.T) {
|
||||
received := make(chan map[string]any, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
defer request.Body.Close()
|
||||
var operation map[string]any
|
||||
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
|
||||
t.Errorf("decode operation: %v", err)
|
||||
}
|
||||
received <- operation
|
||||
response.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
fixture.config.BaseURL = server.URL
|
||||
fixture.config.PrivateKey = base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
installCursorFixture(t, fixture)
|
||||
payload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"postToolUseFailure","tool_name":"Shell","tool_input":{"command":"exit 1"},"tool_use_id":"call-1","cwd":"project","error_message":"command failed","failure_type":"error","duration":42,"is_interrupt":false}` + "\n"
|
||||
handler := managedCursorHandler(
|
||||
t,
|
||||
readCursorObject(t, fixture.configPath),
|
||||
"postToolUseFailure",
|
||||
cursorAuditScript,
|
||||
)
|
||||
result := runCursorHandler(t, handler, payload)
|
||||
if result.exitCode != 0 || result.stdout != "{}\n" || result.stderr != "" {
|
||||
t.Fatalf("Cursor audit result = %#v", result)
|
||||
}
|
||||
operation := <-received
|
||||
var expected map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &expected); err != nil {
|
||||
t.Fatalf("decode expected payload: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(operation["payload"], expected) {
|
||||
t.Fatalf("operation payload = %#v, want %#v", operation["payload"], expected)
|
||||
}
|
||||
subject := cursorObject(t, operation["subject"])
|
||||
if subject["session_id"] != "conversation-1" {
|
||||
t.Fatalf("operation subject = %#v", subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorGeneratedRuntimesFailClosed(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
installCursorFixture(t, fixture)
|
||||
settings := readCursorObject(t, fixture.configPath)
|
||||
if err := os.Remove(fixture.runtimeConfig); err != nil {
|
||||
t.Fatalf("remove runtime config: %v", err)
|
||||
}
|
||||
guard := runCursorHandler(
|
||||
t,
|
||||
managedCursorHandler(t, settings, "preToolUse", cursorGuardScript),
|
||||
"{}\n",
|
||||
)
|
||||
if guard.exitCode != 1 || guard.stdout != "" || !strings.Contains(guard.stderr, "Failed to read agent config") {
|
||||
t.Fatalf("guard failure = %#v", guard)
|
||||
}
|
||||
|
||||
auditFixture := prepareCursorFixture(t, nil, false)
|
||||
installCursorFixture(t, auditFixture)
|
||||
auditSettings := readCursorObject(t, auditFixture.configPath)
|
||||
if err := os.Remove(auditFixture.privateKey); err != nil {
|
||||
t.Fatalf("remove private key: %v", err)
|
||||
}
|
||||
audit := runCursorHandler(
|
||||
t,
|
||||
managedCursorHandler(t, auditSettings, "postToolUse", cursorAuditScript),
|
||||
`{"conversation_id":"conversation-1","tool_name":"Shell","tool_input":{}}`+"\n",
|
||||
)
|
||||
if audit.exitCode != 1 || audit.stdout != "" || !strings.Contains(audit.stderr, "Failed to read agent config/key") {
|
||||
t.Fatalf("audit failure = %#v", audit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorStatusRequiresExactPairIdentityAndRuntimeFiles(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
installCursorFixture(t, fixture)
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
||||
t.Fatalf("installed status = %#v, %v", status, err)
|
||||
}
|
||||
|
||||
settings := readCursorObject(t, fixture.configPath)
|
||||
managedCursorHandler(t, settings, "preToolUse", "guard.js")["failClosed"] = false
|
||||
writeCursorObject(t, fixture.configPath, settings)
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.HookConfigured || status.Installed {
|
||||
t.Fatalf("modified handler status = %#v, %v", status, err)
|
||||
}
|
||||
|
||||
installCursorFixture(t, fixture)
|
||||
settings = readCursorObject(t, fixture.configPath)
|
||||
delete(cursorObject(t, settings["hooks"]), "postToolUseFailure")
|
||||
writeCursorObject(t, fixture.configPath, settings)
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.HookConfigured || status.Installed {
|
||||
t.Fatalf("missing failure audit status = %#v, %v", status, err)
|
||||
}
|
||||
|
||||
installCursorFixture(t, fixture)
|
||||
if err := os.Remove(fixture.hookPath); err != nil {
|
||||
t.Fatalf("remove audit runtime: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || !status.HookConfigured || status.HookScriptExists || status.Installed {
|
||||
t.Fatalf("missing runtime status = %#v, %v", status, err)
|
||||
}
|
||||
|
||||
installCursorFixture(t, fixture)
|
||||
runtimeConfig := readCursorObject(t, fixture.runtimeConfig)
|
||||
runtimeConfig["agent_id"] = "another-agent"
|
||||
writeCursorObject(t, fixture.runtimeConfig, runtimeConfig)
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.HookScriptExists || status.Installed {
|
||||
t.Fatalf("identity mismatch status = %#v, %v", status, err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||
t.Fatalf("write malformed runtime config: %v", err)
|
||||
}
|
||||
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
|
||||
t.Fatalf("malformed runtime status error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorUninstallRemovesExactOwnershipAndPreservesUserEntries(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, cursorJSON(map[string]any{
|
||||
"version": float64(1),
|
||||
"hooks": map[string]any{"sessionStart": []any{map[string]any{"command": "keep"}}},
|
||||
}), false)
|
||||
installCursorFixture(t, fixture)
|
||||
settings := readCursorObject(t, fixture.configPath)
|
||||
hooks := cursorObject(t, settings["hooks"])
|
||||
pre := managedCursorHandler(t, settings, "preToolUse", "guard.js")
|
||||
post := managedCursorHandler(t, settings, "postToolUse", "hook.js")
|
||||
otherPre := map[string]any{}
|
||||
otherPost := map[string]any{}
|
||||
otherFailure := map[string]any{}
|
||||
for key, value := range pre {
|
||||
otherPre[key] = value
|
||||
}
|
||||
for key, value := range post {
|
||||
otherPost[key] = value
|
||||
otherFailure[key] = value
|
||||
}
|
||||
otherPre["command"] = strings.ReplaceAll(cursorStringValue(otherPre["command"]), "agent-1", "agent-10")
|
||||
otherPost["command"] = strings.ReplaceAll(cursorStringValue(otherPost["command"]), "agent-1", "agent-10")
|
||||
hooks["preToolUse"] = append(cursorArray(t, hooks["preToolUse"]), otherPre, map[string]any{
|
||||
"command": "echo elydora agent-1 guard.js", "timeout": float64(10), "failClosed": true,
|
||||
})
|
||||
hooks["postToolUse"] = append(cursorArray(t, hooks["postToolUse"]), otherPost)
|
||||
otherFailure["command"] = strings.ReplaceAll(
|
||||
cursorStringValue(otherFailure["command"]),
|
||||
"agent-1",
|
||||
"agent-10",
|
||||
)
|
||||
hooks["postToolUseFailure"] = append(
|
||||
cursorArray(t, hooks["postToolUseFailure"]),
|
||||
otherFailure,
|
||||
)
|
||||
writeCursorObject(t, fixture.configPath, settings)
|
||||
uninstallID := cursorTestAgentID
|
||||
if runtime.GOOS == "windows" {
|
||||
uninstallID = strings.ToUpper(uninstallID)
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(uninstallID); err != nil {
|
||||
t.Fatalf("uninstall Cursor hooks: %v", err)
|
||||
}
|
||||
remaining := readCursorObject(t, fixture.configPath)
|
||||
remainingHooks := cursorObject(t, remaining["hooks"])
|
||||
if len(cursorArray(t, remainingHooks["preToolUse"])) != 2 ||
|
||||
len(cursorArray(t, remainingHooks["postToolUse"])) != 1 ||
|
||||
len(cursorArray(t, remainingHooks["postToolUseFailure"])) != 1 ||
|
||||
len(cursorArray(t, remainingHooks["sessionStart"])) != 1 {
|
||||
t.Fatalf("remaining hooks = %#v", remainingHooks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorRejectsInvalidConfigBeforeRuntimeWrites(t *testing.T) {
|
||||
cases := []string{
|
||||
"{ malformed", "[]\n", `{"hooks":{}}`, `{"version":2,"hooks":{}}`,
|
||||
`{"version":1,"hooks":null}`, `{"version":1,"hooks":{"preToolUse":null}}`,
|
||||
`{"version":1,"hooks":{"preToolUse":[null]}}`,
|
||||
`{"version":1,"version":1,"hooks":{}}`, `{"version":1,"hooks":{},}`,
|
||||
`{"version":1,"hooks":{/* comments are outside Cursor's JSON contract */}}`,
|
||||
}
|
||||
for index, source := range cases {
|
||||
t.Run(strings.ReplaceAll(source, "/", "_"), func(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, cursorString(source), true)
|
||||
originalGuard, readErr := os.ReadFile(fixture.guardPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read original guard: %v", readErr)
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err == nil {
|
||||
t.Fatalf("invalid config %d was accepted: %s", index, source)
|
||||
}
|
||||
raw, err := os.ReadFile(fixture.configPath)
|
||||
if err != nil || string(raw) != source {
|
||||
t.Fatalf("config changed to %q, %v", raw, err)
|
||||
}
|
||||
guard, err := os.ReadFile(fixture.guardPath)
|
||||
if err != nil || string(guard) != string(originalGuard) {
|
||||
t.Fatalf("guard changed, %v", err)
|
||||
}
|
||||
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("runtime write exists at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorCreatesMissingGuardAndRejectsUnmanagedRuntimePaths(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
installCursorFixture(t, fixture)
|
||||
if exists, err := regularFileExists(fixture.guardPath, "Cursor guard"); err != nil || !exists {
|
||||
t.Fatalf("guard exists = %v, %v", exists, err)
|
||||
}
|
||||
|
||||
for _, field := range []string{"guard", "audit"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
unmanaged := filepath.Join(fixture.homeDir, "unmanaged-"+field+".js")
|
||||
if field == "guard" {
|
||||
fixture.config.GuardScriptPath = unmanaged
|
||||
} else {
|
||||
fixture.config.HookScript = unmanaged
|
||||
}
|
||||
if err := fixture.plugin.Install(fixture.config); err == nil ||
|
||||
!strings.Contains(err.Error(), "managed agent directory") {
|
||||
t.Fatalf("unmanaged %s error = %v", field, err)
|
||||
}
|
||||
if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("Cursor config exists after rejected path: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorUninstallRemovesOwnedConfigAndPreservesUserEmptyConfig(t *testing.T) {
|
||||
fixture := prepareCursorFixture(t, nil, false)
|
||||
installCursorFixture(t, fixture)
|
||||
if err := fixture.plugin.Uninstall(cursorTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Cursor hooks: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("owned Cursor config remains: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(cursorTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall absent Cursor hooks: %v", err)
|
||||
}
|
||||
userSource := `{"version":1,"hooks":{}}` + "\n"
|
||||
writeOptionalCursorFile(t, fixture.configPath, cursorString(userSource))
|
||||
if err := fixture.plugin.Uninstall(cursorTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall user Cursor config: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(fixture.configPath)
|
||||
if err != nil || string(raw) != userSource {
|
||||
t.Fatalf("user config = %q, %v", raw, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const cursorTestAgentID = "agent-1"
|
||||
|
||||
type cursorFixture struct {
|
||||
plugin *CursorPlugin
|
||||
config InstallConfig
|
||||
homeDir string
|
||||
agentDir string
|
||||
configPath string
|
||||
guardPath string
|
||||
hookPath string
|
||||
runtimeConfig string
|
||||
privateKey string
|
||||
}
|
||||
|
||||
type cursorCommandResult struct {
|
||||
exitCode int
|
||||
stdout string
|
||||
stderr string
|
||||
}
|
||||
|
||||
func cursorString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func cursorJSON(value any) *string {
|
||||
encoded, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return cursorString(string(encoded) + "\n")
|
||||
}
|
||||
|
||||
func prepareCursorFixture(t *testing.T, existingRaw *string, createGuard bool) *cursorFixture {
|
||||
t.Helper()
|
||||
rootDir := t.TempDir()
|
||||
homeDir := filepath.Join(rootDir, "home with spaces and 'quote")
|
||||
agentDir := filepath.Join(homeDir, ".elydora", cursorTestAgentID)
|
||||
configPath := filepath.Join(homeDir, ".cursor", "hooks.json")
|
||||
guardPath := filepath.Join(agentDir, "guard.js")
|
||||
hookPath := filepath.Join(agentDir, "hook.js")
|
||||
if err := os.MkdirAll(agentDir, 0700); err != nil {
|
||||
t.Fatalf("create Cursor fixture: %v", err)
|
||||
}
|
||||
if createGuard {
|
||||
if err := os.WriteFile(
|
||||
guardPath,
|
||||
[]byte(GenerateGuardScript("cursor", cursorTestAgentID)),
|
||||
0700,
|
||||
); err != nil {
|
||||
t.Fatalf("write guard runtime: %v", err)
|
||||
}
|
||||
}
|
||||
writeOptionalCursorFile(t, configPath, existingRaw)
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
return &cursorFixture{
|
||||
plugin: &CursorPlugin{},
|
||||
homeDir: homeDir,
|
||||
agentDir: agentDir,
|
||||
configPath: configPath,
|
||||
guardPath: guardPath,
|
||||
hookPath: hookPath,
|
||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||
privateKey: filepath.Join(agentDir, "private.key"),
|
||||
config: InstallConfig{
|
||||
AgentName: "cursor", OrgID: "org-1", AgentID: cursorTestAgentID,
|
||||
PrivateKey: "test-key", KID: "kid-1", Token: "token-1",
|
||||
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeOptionalCursorFile(t *testing.T, path string, source *string) {
|
||||
t.Helper()
|
||||
if source == nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatalf("create directory for %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(*source), 0600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCursorObject(t *testing.T, path string, value map[string]any) {
|
||||
t.Helper()
|
||||
writeOptionalCursorFile(t, path, cursorJSON(value))
|
||||
}
|
||||
|
||||
func readCursorObject(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func cursorObject(t *testing.T, value any) map[string]any {
|
||||
t.Helper()
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("value is not an object: %#v", value)
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
func cursorArray(t *testing.T, value any) []any {
|
||||
t.Helper()
|
||||
array, ok := value.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("value is not an array: %#v", value)
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
func managedCursorHandler(t *testing.T, settings map[string]any, event, script string) map[string]any {
|
||||
t.Helper()
|
||||
hooks := cursorObject(t, settings["hooks"])
|
||||
for _, value := range cursorArray(t, hooks[event]) {
|
||||
handler := cursorObject(t, value)
|
||||
if strings.Contains(cursorStringValue(handler["command"]), script) {
|
||||
return handler
|
||||
}
|
||||
}
|
||||
t.Fatalf("managed Cursor handler for %s was not found", event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cursorStringValue(value any) string {
|
||||
result, _ := value.(string)
|
||||
return result
|
||||
}
|
||||
|
||||
func assertNativeCursorHandler(t *testing.T, handler map[string]any) {
|
||||
t.Helper()
|
||||
if len(handler) != 3 || handler["timeout"] != float64(10) || handler["failClosed"] != true {
|
||||
t.Fatalf("Cursor handler = %#v", handler)
|
||||
}
|
||||
command := cursorStringValue(handler["command"])
|
||||
if !strings.Contains(strings.ToLower(command), "node") {
|
||||
t.Fatalf("Cursor command = %q", command)
|
||||
}
|
||||
if runtime.GOOS == "windows" &&
|
||||
(!strings.HasPrefix(command, "& '") || !strings.HasSuffix(command, "; exit $LASTEXITCODE")) {
|
||||
t.Fatalf("Cursor PowerShell command = %q", command)
|
||||
}
|
||||
if runtime.GOOS != "windows" && !strings.HasPrefix(command, "'") {
|
||||
t.Fatalf("Cursor POSIX command = %q", command)
|
||||
}
|
||||
}
|
||||
|
||||
func runCursorHandler(t *testing.T, handler map[string]any, payload string, environment ...string) cursorCommandResult {
|
||||
t.Helper()
|
||||
var process *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
process = exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", cursorStringValue(handler["command"]))
|
||||
} else {
|
||||
process = exec.Command("sh", "-c", cursorStringValue(handler["command"]))
|
||||
}
|
||||
process.Env = append(os.Environ(), environment...)
|
||||
process.Stdin = strings.NewReader(payload)
|
||||
var stdout, stderr bytes.Buffer
|
||||
process.Stdout, process.Stderr = &stdout, &stderr
|
||||
err := process.Run()
|
||||
if err == nil {
|
||||
return cursorCommandResult{stdout: stdout.String(), stderr: stderr.String()}
|
||||
}
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
return cursorCommandResult{exitCode: exitError.ExitCode(), stdout: stdout.String(), stderr: stderr.String()}
|
||||
}
|
||||
t.Fatalf("run Cursor hook: %v", err)
|
||||
return cursorCommandResult{exitCode: -1}
|
||||
}
|
||||
|
||||
func installCursorFixture(t *testing.T, fixture *cursorFixture) {
|
||||
t.Helper()
|
||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||
t.Fatalf("install Cursor hooks: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package plugins
|
||||
|
||||
import "fmt"
|
||||
|
||||
// GenerateGuardScript returns a self-contained Node.js guard script that checks
|
||||
// whether the agent is frozen and blocks tool execution if so.
|
||||
// It caches the agent status locally (60s TTL) to avoid hitting the API on every
|
||||
// tool call, and fails open if the API is unreachable or config is missing.
|
||||
func GenerateGuardScript(agentName string, agentID string) string {
|
||||
return generateGuardScript(agentName, agentID, "", false)
|
||||
}
|
||||
|
||||
func generateGuardScript(agentName, agentID, successOutput string, failClosed bool) string {
|
||||
return fmt.Sprintf(`#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elydora agent guard (PreToolUse) for: %s
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const AGENT_NAME = %q;
|
||||
const AGENT_ID = %q;
|
||||
const SUCCESS_OUTPUT = %q;
|
||||
const FAIL_CLOSED = %t;
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json');
|
||||
const CACHE_TTL_MS = 60000;
|
||||
|
||||
%s
|
||||
|
||||
process.stdin.resume();
|
||||
process.stdin.on('data', () => {});
|
||||
|
||||
function blockAgent(status) {
|
||||
const state = status === 'frozen' ? 'is frozen by Elydora' : 'is revoked in Elydora';
|
||||
process.stderr.write(
|
||||
'Agent "' + AGENT_NAME + '" ' + state + '. Tool execution blocked.\n',
|
||||
);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(readProtectedFile(
|
||||
CONFIG_PATH,
|
||||
'Agent config',
|
||||
MAX_PROTECTED_CONFIG_BYTES,
|
||||
).toString('utf-8'));
|
||||
} catch (err) {
|
||||
if (FAIL_CLOSED) {
|
||||
throw new Error('Failed to read agent config: ' + err.message);
|
||||
}
|
||||
process.stderr.write('[Elydora guard] Failed to read agent config: ' + err.message + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8');
|
||||
const cache = JSON.parse(raw);
|
||||
const age = Date.now() - (cache.cached_at || 0);
|
||||
if (age < CACHE_TTL_MS) {
|
||||
if (!['active', 'frozen', 'revoked'].includes(cache.status)) {
|
||||
throw new Error('Status cache contains an invalid status');
|
||||
}
|
||||
if (cache.status !== 'active') {
|
||||
blockAgent(cache.status);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
if (FAIL_CLOSED) {
|
||||
throw new Error('Failed to read status cache: ' + err.message);
|
||||
}
|
||||
process.stderr.write('[Elydora guard] Failed to read status cache: ' + err.message + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
const baseURL = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, '');
|
||||
const token = config.token || '';
|
||||
const agentID = config.agent_id;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
try {
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
const response = await fetch(baseURL + '/v1/agents/' + encodeURIComponent(agentID), {
|
||||
headers: headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Agent status API returned HTTP ' + response.status);
|
||||
}
|
||||
const data = await response.json();
|
||||
const status = data.agent && data.agent.status;
|
||||
if (!['active', 'frozen', 'revoked'].includes(status)) {
|
||||
throw new Error('Agent status API returned an invalid status');
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({
|
||||
status: status,
|
||||
cached_at: Date.now(),
|
||||
}), 'utf-8');
|
||||
} catch (err) {
|
||||
process.stderr.write('[Elydora guard] Failed to update status cache: ' + err.message + '\n');
|
||||
}
|
||||
if (status !== 'active') {
|
||||
blockAgent(status);
|
||||
}
|
||||
} catch (err) {
|
||||
if (FAIL_CLOSED) {
|
||||
throw new Error('Failed to resolve agent status: ' + err.message);
|
||||
}
|
||||
process.stderr.write('[Elydora guard] Failed to resolve agent status: ' + err.message + '\n');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
main().then(() => {
|
||||
if ((!process.exitCode || process.exitCode === 0) && SUCCESS_OUTPUT) {
|
||||
process.stdout.write(SUCCESS_OUTPUT);
|
||||
}
|
||||
}).catch((err) => {
|
||||
process.stderr.write('[Elydora guard] ' + (err.stack || err.message || String(err)) + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
`, agentName, agentName, agentID, successOutput, failClosed, protectedRuntimeFileReader)
|
||||
}
|
||||
@@ -1,118 +1,15 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GenerateGuardScript returns a self-contained Node.js guard script that checks
|
||||
// whether the agent is frozen and blocks tool execution if so.
|
||||
// It caches the agent status locally (60s TTL) to avoid hitting the API on every
|
||||
// tool call, and fails open if the API is unreachable or config is missing.
|
||||
func GenerateGuardScript(agentName string, agentId string) string {
|
||||
return fmt.Sprintf(`#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elydora agent guard (PreToolUse) for: %s
|
||||
// Generated by @elydora/sdk — DO NOT EDIT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const AGENT_NAME = %q;
|
||||
const AGENT_ID = %q;
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json');
|
||||
const CACHE_TTL_MS = 60000; // 60 seconds
|
||||
|
||||
%s
|
||||
|
||||
// Consume stdin so the parent process doesn't block on a full pipe
|
||||
process.stdin.resume();
|
||||
process.stdin.on('data', () => {});
|
||||
|
||||
async function main() {
|
||||
// Read agent config
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(readProtectedFile(
|
||||
CONFIG_PATH,
|
||||
'Agent config',
|
||||
MAX_PROTECTED_CONFIG_BYTES,
|
||||
).toString('utf-8'));
|
||||
} catch {
|
||||
return; // Can't read config — fail-open (allow)
|
||||
}
|
||||
|
||||
// Check cached status
|
||||
try {
|
||||
const raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8');
|
||||
const cache = JSON.parse(raw);
|
||||
const age = Date.now() - (cache.cached_at || 0);
|
||||
|
||||
if (age < CACHE_TTL_MS) {
|
||||
if (cache.status === 'frozen') {
|
||||
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\n');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
return; // Cache is fresh — use cached result
|
||||
}
|
||||
} catch {
|
||||
// No cache or invalid — need to check API
|
||||
}
|
||||
|
||||
// Fetch agent status from API
|
||||
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, '');
|
||||
const token = config.token || '';
|
||||
const agentId = config.agent_id;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
const res = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(agentId), {
|
||||
headers: headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const agentStatus = (data.agent && data.agent.status) || 'active';
|
||||
|
||||
// Update cache
|
||||
try {
|
||||
fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({
|
||||
status: agentStatus,
|
||||
cached_at: Date.now(),
|
||||
}), 'utf-8');
|
||||
} catch {
|
||||
// Ignore cache write failures
|
||||
}
|
||||
|
||||
if (agentStatus === 'frozen') {
|
||||
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\n');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// API unreachable — fail-open (allow)
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
`, agentName, agentName, agentId, protectedRuntimeFileReader)
|
||||
}
|
||||
import "fmt"
|
||||
|
||||
func buildHookScript(agentName string, agentId string) string {
|
||||
return buildHookScriptWithOutput(agentName, agentId, "", false, false)
|
||||
}
|
||||
|
||||
func buildHookScriptWithOutput(
|
||||
agentName, agentId, successOutput string,
|
||||
failClosed, nativePayload bool,
|
||||
) string {
|
||||
return fmt.Sprintf(`#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
@@ -128,6 +25,9 @@ const os = require('node:os');
|
||||
|
||||
const AGENT_NAME = %q;
|
||||
const AGENT_ID = %q;
|
||||
const SUCCESS_OUTPUT = %q;
|
||||
const FAIL_CLOSED = %t;
|
||||
const NATIVE_PAYLOAD = %t;
|
||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||
const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key');
|
||||
@@ -254,7 +154,15 @@ function readChainState() {
|
||||
const raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
|
||||
const state = JSON.parse(raw);
|
||||
return state.prev_chain_hash || ZERO_CHAIN_HASH;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
return ZERO_CHAIN_HASH;
|
||||
}
|
||||
const failure = new Error('Failed to read chain state: ' + err.message);
|
||||
if (FAIL_CLOSED) {
|
||||
throw failure;
|
||||
}
|
||||
logError(failure);
|
||||
return ZERO_CHAIN_HASH;
|
||||
}
|
||||
}
|
||||
@@ -282,8 +190,10 @@ function logError(err) {
|
||||
const ts = new Date().toISOString();
|
||||
const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\n';
|
||||
fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8');
|
||||
} catch {
|
||||
// Silently ignore logging failures
|
||||
} catch (logFailure) {
|
||||
process.stderr.write(
|
||||
'[Elydora audit] Failed to write error log: ' + logFailure.message + '\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,13 +209,15 @@ async function main() {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const rawInput = Buffer.concat(chunks).toString('utf-8').trim();
|
||||
if (!rawInput) return;
|
||||
if (!rawInput) {
|
||||
throw new Error('Hook input is empty');
|
||||
}
|
||||
|
||||
let hookData;
|
||||
try {
|
||||
hookData = JSON.parse(rawInput);
|
||||
} catch {
|
||||
return;
|
||||
} catch (err) {
|
||||
throw new Error('Hook input is invalid JSON: ' + err.message);
|
||||
}
|
||||
|
||||
// Extract provider-native tool fields without reshaping the hook input.
|
||||
@@ -321,7 +233,8 @@ async function main() {
|
||||
hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters ||
|
||||
clineToolEvent.input || {};
|
||||
const sessionId =
|
||||
hookData.session_id || hookData.sessionId || hookData.session || hookData.taskId || 'unknown';
|
||||
hookData.conversation_id || hookData.session_id || hookData.sessionId ||
|
||||
hookData.session || hookData.taskId || 'unknown';
|
||||
|
||||
// Read agent config and private key
|
||||
let config;
|
||||
@@ -334,7 +247,11 @@ async function main() {
|
||||
).toString('utf-8'));
|
||||
privateKey = parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key'));
|
||||
} catch (err) {
|
||||
logError(new Error('Failed to read agent config/key: ' + err.message));
|
||||
const failure = new Error('Failed to read agent config/key: ' + err.message);
|
||||
if (FAIL_CLOSED) {
|
||||
throw failure;
|
||||
}
|
||||
logError(failure);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -343,6 +260,9 @@ async function main() {
|
||||
const kid = config.kid;
|
||||
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, '');
|
||||
const token = config.token || '';
|
||||
if (!orgId || !agentId || !kid) {
|
||||
throw new Error('Agent config is missing org_id, agent_id, or kid');
|
||||
}
|
||||
|
||||
// Read chain state
|
||||
const prevChainHash = readChainState();
|
||||
@@ -352,7 +272,7 @@ async function main() {
|
||||
const issuedAt = Date.now();
|
||||
const nonce = generateNonce();
|
||||
|
||||
const payload = {
|
||||
const payload = NATIVE_PAYLOAD ? hookData : {
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
session_id: sessionId,
|
||||
@@ -406,9 +326,10 @@ async function main() {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Server accepted — safe to advance local chain state
|
||||
writeChainState(chainHash);
|
||||
} else if (res.status === 400) {
|
||||
return;
|
||||
}
|
||||
if (res.status === 400) {
|
||||
// Possible chain hash mismatch — try to resync from server response
|
||||
try {
|
||||
const errBody = await res.json();
|
||||
@@ -420,18 +341,29 @@ async function main() {
|
||||
logError(new Error('Chain hash resynced to server: ' + match[1]));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
} catch (err) {
|
||||
logError(new Error('Failed to parse audit API error: ' + err.message));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Network error — don't advance chain state, will retry with same prev_chain_hash
|
||||
throw new Error('Audit API returned HTTP ' + res.status);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
if (FAIL_CLOSED) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
`, agentName, agentName, agentId, protectedRuntimeFileReader)
|
||||
main().then(() => {
|
||||
if (SUCCESS_OUTPUT) {
|
||||
process.stdout.write(SUCCESS_OUTPUT);
|
||||
}
|
||||
}).catch((err) => {
|
||||
process.stderr.write('[Elydora audit] ' + (err.stack || err.message || String(err)) + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
`, agentName, agentName, agentId, successOutput, failClosed, nativePayload, protectedRuntimeFileReader)
|
||||
}
|
||||
Reference in new issue
Block a user