fix: harden Cursor hook enforcement

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 00:15:21 -04:00
1 parent bf777a2ff0
commit 415a884efe
12 files changed
+1980 -340

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
- Forward official hook JSON from STDIN without reshaping provider fields. - Forward official hook JSON from STDIN without reshaping provider fields.
- Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard. - Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard.
- Report installation as healthy only when a complete hook contract references both generated runtime scripts and both scripts exist. - Report installation as healthy only when a complete hook contract references both generated runtime scripts and both scripts exist.
- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction.
- Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance. - Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance.
- Select Kimi Code and legacy `kimi-cli` contracts from runtime evidence. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; create no cross-runtime migration marker. - Select Kimi Code and legacy `kimi-cli` contracts from runtime evidence. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; create no cross-runtime migration marker.
- Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it. - Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it.
+2
View File
@@ -81,6 +81,8 @@ Credential options may be omitted in an interactive terminal; the CLI then reads
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
Cursor installation writes native global `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json`. Elydora preserves unrelated user hooks, leaves project and enterprise sources unchanged, emits valid JSON hook responses, fails closed when enforcement or audit execution cannot complete, and commits its 10-second hook contract with all managed runtime files as one rollback-capable transaction.
GitHub Copilot CLI installation writes native user hooks to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves user hooks, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, quotes the resolved Node.js runtime for each shell, and propagates the freeze guard's exit code. Installation requires `disableAllHooks` to be false. GitHub Copilot CLI installation writes native user hooks to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves user hooks, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, quotes the resolved Node.js runtime for each shell, and propagates the freeze guard's exit code. Installation requires `disableAllHooks` to be false.
Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The wrappers preserve Cline's official payload and translate a frozen guard into JSON stdout cancellation control. Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The wrappers preserve Cline's official payload and translate a frozen guard into JSON stdout cancellation control.
+28 -19
View File
@@ -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) fmt.Fprintf(os.Stderr, "Error: unsupported agent %q. Run 'elydora agents' to see supported agents.\n", *agent)
os.Exit(1) 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) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -115,25 +116,7 @@ func cmdInstall(args []string) {
os.Exit(1) 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") 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{ config := plugins.InstallConfig{
AgentName: *agent, AgentName: *agent,
OrgID: *orgID, OrgID: *orgID,
@@ -144,11 +127,37 @@ func cmdInstall(args []string) {
BaseURL: *baseURL, BaseURL: *baseURL,
GuardScriptPath: guardScriptPath, 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 { if err := plugin.Install(config); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf(" Guard script: %s\n", guardScriptPath)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+15 -5
View File
@@ -15,12 +15,12 @@ type InstallConfig struct {
// PluginStatus describes the current state of a plugin installation. // PluginStatus describes the current state of a plugin installation.
type PluginStatus struct { type PluginStatus struct {
Installed bool Installed bool
AgentName string AgentName string
DisplayName string DisplayName string
HookConfigured bool HookConfigured bool
HookScriptExists bool HookScriptExists bool
ConfigPath string ConfigPath string
} }
// AgentPlugin is the interface that every agent integration must implement. // AgentPlugin is the interface that every agent integration must implement.
@@ -29,3 +29,13 @@ type AgentPlugin interface {
Uninstall(agentID string) error Uninstall(agentID string) error
Status() (PluginStatus, 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
View File
@@ -2,236 +2,172 @@ package plugins
import ( import (
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings"
) )
// CursorPlugin manages the Elydora audit hook for Cursor. // CursorPlugin manages Cursor's native global user hooks.
// It writes/merges into ~/.cursor/hooks.json using nested settings.hooks.postToolUse[]. type CursorPlugin struct {
type CursorPlugin struct{} rename renameFunc
}
func (p *CursorPlugin) configPath() (string, error) { // ManagesGuardRuntime reports that Cursor commits its provider-specific guard
home, err := os.UserHomeDir() // in the same transaction as the rest of the installation.
if err != nil { func (p *CursorPlugin) ManagesGuardRuntime() bool {
return "", fmt.Errorf("resolve home directory: %w", err) 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 { func (p *CursorPlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath(config.AgentID) document, err := readCursorDocument()
if err != nil { if err != nil {
return err return err
} }
if config.HookScript != "" { runtimeRoot, agentDirectory, guardPath, auditPath, err := cursorAgentPaths(config)
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()
if err != nil { if err != nil {
return err return err
} }
if err := preflightCursorRuntime(agentDirectory, config.AgentID); err != nil {
settings, err := readJSONFile(configPath) return err
}
nodePath, err := resolveNodeRuntime()
if err != nil { if err != nil {
return err return err
} }
hooks := removeManagedCursorHooks(document.hooks, runtimeRoot, "")
// Ensure hooks object exists hooks["preToolUse"] = append(
hooks, _ := settings["hooks"].(map[string]interface{}) hooks["preToolUse"],
if hooks == nil { buildCursorHandler(nodePath, guardPath),
hooks = make(map[string]interface{}) )
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)
} }
changes, err := prepareCursorInstallationChanges(config, agentDirectory, rendered)
// --- preToolUse (guard — freeze enforcement) --- if err != nil {
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 {
return err 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 return nil
} }
func (p *CursorPlugin) Uninstall(agentID string) error { func (p *CursorPlugin) Uninstall(agentID string) error {
configPath, err := p.configPath() document, err := readCursorDocument()
if err != nil { if err != nil {
return err return err
} }
runtimeRoot, err := AgentRuntimeRoot()
settings, err := readJSONFile(configPath)
if err != nil { if err != nil {
return err return err
} }
hooks := removeManagedCursorHooks(document.hooks, runtimeRoot, agentID)
hooks, _ := settings["hooks"].(map[string]interface{}) rendered, err := renderCursorDocument(document, hooks, runtimeRoot)
if hooks == nil { if err != nil {
fmt.Println("No Cursor hooks found.") return fmt.Errorf("render Cursor user hooks: %w", err)
return nil
} }
change, err := prepareRenderedCursorChange(rendered)
// Remove preToolUse Elydora entries if err != nil {
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 {
return err return err
} }
return writeCursorChanges(
if agentID != "" { []*fileChange{change},
scriptPath, _ := hookScriptPath(agentID) "Uninstall Cursor hooks",
if scriptPath != "" { p.rename,
os.Remove(scriptPath) "",
} "",
gPath, _ := guardScriptPath(agentID) )
if gPath != "" {
os.Remove(gPath)
}
}
fmt.Println("Uninstalled Elydora hook for Cursor.")
return nil
} }
func (p *CursorPlugin) Status() (PluginStatus, error) { func (p *CursorPlugin) Status() (PluginStatus, error) {
configPath, err := p.configPath() configPath, pathErr := cursorConfigPath()
if err != nil { entry := SupportedAgents[cursorAgentKey]
return PluginStatus{}, err
}
status := PluginStatus{ status := PluginStatus{
AgentName: "cursor", AgentName: cursorAgentKey, DisplayName: entry.Name, ConfigPath: configPath,
DisplayName: "Cursor",
ConfigPath: configPath,
} }
if pathErr != nil {
settings, err := readJSONFile(configPath) return status, pathErr
}
document, err := readCursorDocument()
if err != nil { 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 return status, nil
} }
status.HookScriptExists, err = cursorRuntimeFilesExist(contracts)
hooks, _ := settings["hooks"].(map[string]interface{}) if err != nil {
if hooks != nil { return status, err
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.Installed = status.HookScriptExists
status.Installed = status.HookConfigured && status.HookScriptExists
return status, nil 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
}
+419
View File
@@ -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
}
+324
View File
@@ -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
}
+291
View File
@@ -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)
}
}
}
+376
View File
@@ -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)
}
}
+139
View File
@@ -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)
}
+59 -127
View File
@@ -1,118 +1,15 @@
package plugins package plugins
import ( import "fmt"
"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)
}
func buildHookScript(agentName string, agentId string) string { 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 return fmt.Sprintf(`#!/usr/bin/env node
'use strict'; 'use strict';
@@ -128,6 +25,9 @@ const os = require('node:os');
const AGENT_NAME = %q; const AGENT_NAME = %q;
const AGENT_ID = %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 ELYDORA_DIR = path.join(os.homedir(), '.elydora');
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
const KEY_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'private.key'); 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 raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
const state = JSON.parse(raw); const state = JSON.parse(raw);
return state.prev_chain_hash || ZERO_CHAIN_HASH; 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; return ZERO_CHAIN_HASH;
} }
} }
@@ -282,8 +190,10 @@ function logError(err) {
const ts = new Date().toISOString(); const ts = new Date().toISOString();
const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\n'; const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\n';
fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8'); fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8');
} catch { } catch (logFailure) {
// Silently ignore logging failures process.stderr.write(
'[Elydora audit] Failed to write error log: ' + logFailure.message + '\n',
);
} }
} }
@@ -299,13 +209,15 @@ async function main() {
chunks.push(chunk); chunks.push(chunk);
} }
const rawInput = Buffer.concat(chunks).toString('utf-8').trim(); const rawInput = Buffer.concat(chunks).toString('utf-8').trim();
if (!rawInput) return; if (!rawInput) {
throw new Error('Hook input is empty');
}
let hookData; let hookData;
try { try {
hookData = JSON.parse(rawInput); hookData = JSON.parse(rawInput);
} catch { } catch (err) {
return; throw new Error('Hook input is invalid JSON: ' + err.message);
} }
// Extract provider-native tool fields without reshaping the hook input. // 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 || hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters ||
clineToolEvent.input || {}; clineToolEvent.input || {};
const sessionId = 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 // Read agent config and private key
let config; let config;
@@ -334,7 +247,11 @@ async function main() {
).toString('utf-8')); ).toString('utf-8'));
privateKey = parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key')); privateKey = parsePrivateKey(readProtectedFile(KEY_PATH, 'Private key'));
} catch (err) { } 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; return;
} }
@@ -343,6 +260,9 @@ async function main() {
const kid = config.kid; const kid = config.kid;
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, ''); const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\/+$/, '');
const token = config.token || ''; const token = config.token || '';
if (!orgId || !agentId || !kid) {
throw new Error('Agent config is missing org_id, agent_id, or kid');
}
// Read chain state // Read chain state
const prevChainHash = readChainState(); const prevChainHash = readChainState();
@@ -352,7 +272,7 @@ async function main() {
const issuedAt = Date.now(); const issuedAt = Date.now();
const nonce = generateNonce(); const nonce = generateNonce();
const payload = { const payload = NATIVE_PAYLOAD ? hookData : {
tool_name: toolName, tool_name: toolName,
tool_input: toolInput, tool_input: toolInput,
session_id: sessionId, session_id: sessionId,
@@ -406,9 +326,10 @@ async function main() {
}); });
if (res.ok) { if (res.ok) {
// Server accepted — safe to advance local chain state
writeChainState(chainHash); writeChainState(chainHash);
} else if (res.status === 400) { return;
}
if (res.status === 400) {
// Possible chain hash mismatch — try to resync from server response // Possible chain hash mismatch — try to resync from server response
try { try {
const errBody = await res.json(); const errBody = await res.json();
@@ -420,18 +341,29 @@ async function main() {
logError(new Error('Chain hash resynced to server: ' + match[1])); 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 { throw new Error('Audit API returned HTTP ' + res.status);
// Network error — don't advance chain state, will retry with same prev_chain_hash
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
} }
} catch (err) { } catch (err) {
logError(err); logError(err);
if (FAIL_CLOSED) {
throw err;
}
} }
} }
main(); main().then(() => {
`, agentName, agentName, agentId, protectedRuntimeFileReader) 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)
} }