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
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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
@@ -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