fix: harden Cursor hook enforcement

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

No files matched your search

+376
View File
@@ -0,0 +1,376 @@
package plugins
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
)
func TestCursorRegistryUsesNativeUserHooks(t *testing.T) {
want := AgentRegistryEntry{Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"}
if got := SupportedAgents["cursor"]; !reflect.DeepEqual(got, want) {
t.Fatalf("Cursor registry = %#v, want %#v", got, want)
}
}
func TestCursorInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
writeCursorObject(t, fixture.configPath, map[string]any{
"description": "user-owned",
"hooks": map[string]any{
"sessionStart": []any{map[string]any{"command": "user-session"}},
"preToolUse": []any{
map[string]any{"command": "user-pre"},
map[string]any{"command": "node " + fixture.guardPath},
},
"postToolUse": []any{map[string]any{"command": "node " + fixture.hookPath}},
},
})
installCursorFixture(t, fixture)
installCursorFixture(t, fixture)
settings := readCursorObject(t, fixture.configPath)
if settings["version"] != float64(1) || settings["description"] != "user-owned" {
t.Fatalf("Cursor settings = %#v", settings)
}
hooks := cursorObject(t, settings["hooks"])
if len(cursorArray(t, hooks["sessionStart"])) != 1 ||
len(cursorArray(t, hooks["preToolUse"])) != 2 ||
len(cursorArray(t, hooks["postToolUse"])) != 1 ||
len(cursorArray(t, hooks["postToolUseFailure"])) != 1 {
t.Fatalf("Cursor hooks = %#v", hooks)
}
if cursorObject(t, cursorArray(t, hooks["preToolUse"])[0])["command"] != "user-pre" {
t.Fatalf("first preToolUse handler = %#v", cursorArray(t, hooks["preToolUse"])[0])
}
assertNativeCursorHandler(t, managedCursorHandler(t, settings, "preToolUse", "guard.js"))
assertNativeCursorHandler(t, managedCursorHandler(t, settings, "postToolUse", "hook.js"))
assertNativeCursorHandler(t, managedCursorHandler(t, settings, "postToolUseFailure", "hook.js"))
runtimeConfig := readCursorObject(t, fixture.runtimeConfig)
if runtimeConfig["agent_name"] != "cursor" || runtimeConfig["agent_id"] != cursorTestAgentID {
t.Fatalf("runtime config = %#v", runtimeConfig)
}
}
func TestCursorCommandsBlockAndForwardOfficialPayloadByteForByte(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
settings := readCursorObject(t, fixture.configPath)
guard := managedCursorHandler(t, settings, "preToolUse", "guard.js")
writeCursorObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
"status": "active", "cached_at": time.Now().UnixMilli(),
})
active := runCursorHandler(t, guard, "{}\n")
var allow map[string]any
if err := json.Unmarshal([]byte(active.stdout), &allow); err != nil || allow["permission"] != "allow" {
t.Fatalf("active guard output = %q, %v", active.stdout, err)
}
writeCursorObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
"status": "frozen", "cached_at": time.Now().UnixMilli(),
})
prePayload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"preToolUse","tool_name":"Shell","tool_input":{"command":"Get-ChildItem"},"tool_use_id":"call-1","cwd":"project"}` + "\n"
frozen := runCursorHandler(t, guard, prePayload)
if frozen.exitCode != 2 || !strings.Contains(frozen.stderr, "frozen by Elydora") {
t.Fatalf("frozen guard result = %#v", frozen)
}
writeCursorObject(t, filepath.Join(fixture.agentDir, "status-cache.json"), map[string]any{
"status": "revoked", "cached_at": time.Now().UnixMilli(),
})
revoked := runCursorHandler(t, guard, prePayload)
if revoked.exitCode != 2 || !strings.Contains(revoked.stderr, "revoked in Elydora") {
t.Fatalf("revoked guard result = %#v", revoked)
}
capturePath := filepath.Join(t.TempDir(), "captured.json")
captureScript := "const fs = require('node:fs');\n" +
"fs.writeFileSync(process.env.ELYDORA_CAPTURE, fs.readFileSync(0));\n" +
"process.stdout.write('{}\\n');\n"
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
t.Fatalf("write capture runtime: %v", err)
}
postPayload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"postToolUse","tool_name":"Shell","tool_input":{"command":"Get-ChildItem"},"tool_output":"{\"exitCode\":0,\"stdout\":\"ok\"}","tool_use_id":"call-1","cwd":"project","duration":42}` + "\n"
audit := managedCursorHandler(t, settings, "postToolUse", "hook.js")
result := runCursorHandler(t, audit, postPayload, "ELYDORA_CAPTURE="+capturePath)
captured, err := os.ReadFile(capturePath)
if err != nil || result.exitCode != 0 || string(captured) != postPayload {
t.Fatalf("audit result = %#v, captured = %q, %v", result, captured, err)
}
failurePayload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"postToolUseFailure","tool_name":"Shell","tool_input":{"command":"exit 1"},"tool_use_id":"call-2","cwd":"project","error_message":"command failed","failure_type":"error","duration":21,"is_interrupt":false}` + "\n"
failureAudit := managedCursorHandler(t, settings, "postToolUseFailure", "hook.js")
failureResult := runCursorHandler(t, failureAudit, failurePayload, "ELYDORA_CAPTURE="+capturePath)
captured, err = os.ReadFile(capturePath)
if err != nil || failureResult.exitCode != 0 || string(captured) != failurePayload {
t.Fatalf("failure audit result = %#v, captured = %q, %v", failureResult, captured, err)
}
}
func TestCursorAuditPreservesNativeFailurePayload(t *testing.T) {
received := make(chan map[string]any, 1)
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
defer request.Body.Close()
var operation map[string]any
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
t.Errorf("decode operation: %v", err)
}
received <- operation
response.WriteHeader(http.StatusCreated)
}))
defer server.Close()
fixture := prepareCursorFixture(t, nil, false)
fixture.config.BaseURL = server.URL
fixture.config.PrivateKey = base64.RawURLEncoding.EncodeToString(make([]byte, 32))
installCursorFixture(t, fixture)
payload := `{"conversation_id":"conversation-1","generation_id":"generation-1","hook_event_name":"postToolUseFailure","tool_name":"Shell","tool_input":{"command":"exit 1"},"tool_use_id":"call-1","cwd":"project","error_message":"command failed","failure_type":"error","duration":42,"is_interrupt":false}` + "\n"
handler := managedCursorHandler(
t,
readCursorObject(t, fixture.configPath),
"postToolUseFailure",
cursorAuditScript,
)
result := runCursorHandler(t, handler, payload)
if result.exitCode != 0 || result.stdout != "{}\n" || result.stderr != "" {
t.Fatalf("Cursor audit result = %#v", result)
}
operation := <-received
var expected map[string]any
if err := json.Unmarshal([]byte(payload), &expected); err != nil {
t.Fatalf("decode expected payload: %v", err)
}
if !reflect.DeepEqual(operation["payload"], expected) {
t.Fatalf("operation payload = %#v, want %#v", operation["payload"], expected)
}
subject := cursorObject(t, operation["subject"])
if subject["session_id"] != "conversation-1" {
t.Fatalf("operation subject = %#v", subject)
}
}
func TestCursorGeneratedRuntimesFailClosed(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
settings := readCursorObject(t, fixture.configPath)
if err := os.Remove(fixture.runtimeConfig); err != nil {
t.Fatalf("remove runtime config: %v", err)
}
guard := runCursorHandler(
t,
managedCursorHandler(t, settings, "preToolUse", cursorGuardScript),
"{}\n",
)
if guard.exitCode != 1 || guard.stdout != "" || !strings.Contains(guard.stderr, "Failed to read agent config") {
t.Fatalf("guard failure = %#v", guard)
}
auditFixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, auditFixture)
auditSettings := readCursorObject(t, auditFixture.configPath)
if err := os.Remove(auditFixture.privateKey); err != nil {
t.Fatalf("remove private key: %v", err)
}
audit := runCursorHandler(
t,
managedCursorHandler(t, auditSettings, "postToolUse", cursorAuditScript),
`{"conversation_id":"conversation-1","tool_name":"Shell","tool_input":{}}`+"\n",
)
if audit.exitCode != 1 || audit.stdout != "" || !strings.Contains(audit.stderr, "Failed to read agent config/key") {
t.Fatalf("audit failure = %#v", audit)
}
}
func TestCursorStatusRequiresExactPairIdentityAndRuntimeFiles(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
status, err := fixture.plugin.Status()
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
t.Fatalf("installed status = %#v, %v", status, err)
}
settings := readCursorObject(t, fixture.configPath)
managedCursorHandler(t, settings, "preToolUse", "guard.js")["failClosed"] = false
writeCursorObject(t, fixture.configPath, settings)
status, err = fixture.plugin.Status()
if err != nil || status.HookConfigured || status.Installed {
t.Fatalf("modified handler status = %#v, %v", status, err)
}
installCursorFixture(t, fixture)
settings = readCursorObject(t, fixture.configPath)
delete(cursorObject(t, settings["hooks"]), "postToolUseFailure")
writeCursorObject(t, fixture.configPath, settings)
status, err = fixture.plugin.Status()
if err != nil || status.HookConfigured || status.Installed {
t.Fatalf("missing failure audit status = %#v, %v", status, err)
}
installCursorFixture(t, fixture)
if err := os.Remove(fixture.hookPath); err != nil {
t.Fatalf("remove audit runtime: %v", err)
}
status, err = fixture.plugin.Status()
if err != nil || !status.HookConfigured || status.HookScriptExists || status.Installed {
t.Fatalf("missing runtime status = %#v, %v", status, err)
}
installCursorFixture(t, fixture)
runtimeConfig := readCursorObject(t, fixture.runtimeConfig)
runtimeConfig["agent_id"] = "another-agent"
writeCursorObject(t, fixture.runtimeConfig, runtimeConfig)
status, err = fixture.plugin.Status()
if err != nil || status.HookScriptExists || status.Installed {
t.Fatalf("identity mismatch status = %#v, %v", status, err)
}
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("write malformed runtime config: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
t.Fatalf("malformed runtime status error = %v", err)
}
}
func TestCursorUninstallRemovesExactOwnershipAndPreservesUserEntries(t *testing.T) {
fixture := prepareCursorFixture(t, cursorJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{"sessionStart": []any{map[string]any{"command": "keep"}}},
}), false)
installCursorFixture(t, fixture)
settings := readCursorObject(t, fixture.configPath)
hooks := cursorObject(t, settings["hooks"])
pre := managedCursorHandler(t, settings, "preToolUse", "guard.js")
post := managedCursorHandler(t, settings, "postToolUse", "hook.js")
otherPre := map[string]any{}
otherPost := map[string]any{}
otherFailure := map[string]any{}
for key, value := range pre {
otherPre[key] = value
}
for key, value := range post {
otherPost[key] = value
otherFailure[key] = value
}
otherPre["command"] = strings.ReplaceAll(cursorStringValue(otherPre["command"]), "agent-1", "agent-10")
otherPost["command"] = strings.ReplaceAll(cursorStringValue(otherPost["command"]), "agent-1", "agent-10")
hooks["preToolUse"] = append(cursorArray(t, hooks["preToolUse"]), otherPre, map[string]any{
"command": "echo elydora agent-1 guard.js", "timeout": float64(10), "failClosed": true,
})
hooks["postToolUse"] = append(cursorArray(t, hooks["postToolUse"]), otherPost)
otherFailure["command"] = strings.ReplaceAll(
cursorStringValue(otherFailure["command"]),
"agent-1",
"agent-10",
)
hooks["postToolUseFailure"] = append(
cursorArray(t, hooks["postToolUseFailure"]),
otherFailure,
)
writeCursorObject(t, fixture.configPath, settings)
uninstallID := cursorTestAgentID
if runtime.GOOS == "windows" {
uninstallID = strings.ToUpper(uninstallID)
}
if err := fixture.plugin.Uninstall(uninstallID); err != nil {
t.Fatalf("uninstall Cursor hooks: %v", err)
}
remaining := readCursorObject(t, fixture.configPath)
remainingHooks := cursorObject(t, remaining["hooks"])
if len(cursorArray(t, remainingHooks["preToolUse"])) != 2 ||
len(cursorArray(t, remainingHooks["postToolUse"])) != 1 ||
len(cursorArray(t, remainingHooks["postToolUseFailure"])) != 1 ||
len(cursorArray(t, remainingHooks["sessionStart"])) != 1 {
t.Fatalf("remaining hooks = %#v", remainingHooks)
}
}
func TestCursorRejectsInvalidConfigBeforeRuntimeWrites(t *testing.T) {
cases := []string{
"{ malformed", "[]\n", `{"hooks":{}}`, `{"version":2,"hooks":{}}`,
`{"version":1,"hooks":null}`, `{"version":1,"hooks":{"preToolUse":null}}`,
`{"version":1,"hooks":{"preToolUse":[null]}}`,
`{"version":1,"version":1,"hooks":{}}`, `{"version":1,"hooks":{},}`,
`{"version":1,"hooks":{/* comments are outside Cursor's JSON contract */}}`,
}
for index, source := range cases {
t.Run(strings.ReplaceAll(source, "/", "_"), func(t *testing.T) {
fixture := prepareCursorFixture(t, cursorString(source), true)
originalGuard, readErr := os.ReadFile(fixture.guardPath)
if readErr != nil {
t.Fatalf("read original guard: %v", readErr)
}
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatalf("invalid config %d was accepted: %s", index, source)
}
raw, err := os.ReadFile(fixture.configPath)
if err != nil || string(raw) != source {
t.Fatalf("config changed to %q, %v", raw, err)
}
guard, err := os.ReadFile(fixture.guardPath)
if err != nil || string(guard) != string(originalGuard) {
t.Fatalf("guard changed, %v", err)
}
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("runtime write exists at %s: %v", path, err)
}
}
})
}
}
func TestCursorCreatesMissingGuardAndRejectsUnmanagedRuntimePaths(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
if exists, err := regularFileExists(fixture.guardPath, "Cursor guard"); err != nil || !exists {
t.Fatalf("guard exists = %v, %v", exists, err)
}
for _, field := range []string{"guard", "audit"} {
t.Run(field, func(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
unmanaged := filepath.Join(fixture.homeDir, "unmanaged-"+field+".js")
if field == "guard" {
fixture.config.GuardScriptPath = unmanaged
} else {
fixture.config.HookScript = unmanaged
}
if err := fixture.plugin.Install(fixture.config); err == nil ||
!strings.Contains(err.Error(), "managed agent directory") {
t.Fatalf("unmanaged %s error = %v", field, err)
}
if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) {
t.Fatalf("Cursor config exists after rejected path: %v", err)
}
})
}
}
func TestCursorUninstallRemovesOwnedConfigAndPreservesUserEmptyConfig(t *testing.T) {
fixture := prepareCursorFixture(t, nil, false)
installCursorFixture(t, fixture)
if err := fixture.plugin.Uninstall(cursorTestAgentID); err != nil {
t.Fatalf("uninstall Cursor hooks: %v", err)
}
if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) {
t.Fatalf("owned Cursor config remains: %v", err)
}
if err := fixture.plugin.Uninstall(cursorTestAgentID); err != nil {
t.Fatalf("uninstall absent Cursor hooks: %v", err)
}
userSource := `{"version":1,"hooks":{}}` + "\n"
writeOptionalCursorFile(t, fixture.configPath, cursorString(userSource))
if err := fixture.plugin.Uninstall(cursorTestAgentID); err != nil {
t.Fatalf("uninstall user Cursor config: %v", err)
}
raw, err := os.ReadFile(fixture.configPath)
if err != nil || string(raw) != userSource {
t.Fatalf("user config = %q, %v", raw, err)
}
}