From b028029f42eeaff08f3cb997dae45dc923ef2a20 Mon Sep 17 00:00:00 2001 From: Banon Date: Mon, 15 Jun 2026 11:02:06 -0700 Subject: [PATCH] fix(validator): make decision-event matrix harness-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decision-event compatibility matrix only contained Claude Code events, so `cupcake validate` flagged every decision verb used on a Cursor-native event (beforeShellExecution, afterFileEdit, beforeReadFile, ...) as incompatible with an empty "Supported:" list. This produced false errors for the Cursor builtins generated by `cupcake init` — and swapping the verb (halt -> deny) did not help, because no verb is registered for those events. Changes: - Add Cursor's actionable native events to the matrix, with verb sets derived from the Cursor response handlers: - beforeShellExecution / beforeMCPExecution: Halt, Deny, Block, Ask - beforeReadFile: Halt, Deny, Block (ask is coerced to deny) - beforeSubmitPrompt: Halt, Deny, Block (ask coerced; context dropped) - stop: Halt, Block Cursor's fire-and-forget after* events (always return {}) are intentionally omitted. - Add DecisionEventMatrix::knows_event() to distinguish an unknown event from a known event with an empty verb set (e.g. SessionEnd). - Skip events the matrix has no spec for in the compatibility rule, so the Claude-centric matrix no longer flags other harnesses' events while still validating known-but-empty events. - Add regression tests for Cursor before-events, fire-and-forget after* events, and the unknown-vs-empty distinction. Co-Authored-By: Claude Opus 4.8 --- .../src/validator/decision_event_matrix.rs | 117 ++++++++++++++++++ cupcake-core/src/validator/rules.rs | 9 ++ 2 files changed, 126 insertions(+) diff --git a/cupcake-core/src/validator/decision_event_matrix.rs b/cupcake-core/src/validator/decision_event_matrix.rs index a31d3912..f381c3e6 100644 --- a/cupcake-core/src/validator/decision_event_matrix.rs +++ b/cupcake-core/src/validator/decision_event_matrix.rs @@ -152,9 +152,64 @@ impl DecisionEventMatrix { vec![DecisionVerb::Halt, DecisionVerb::Block], ); + // --------------------------------------------------------------- + // Cursor native events (cursor.com hooks) + // --------------------------------------------------------------- + // Cursor uses its own event namespace (camelCase) distinct from + // Claude Code's. Verb support is derived from Cursor's response + // schemas (see harness::response::cursor): before* permission events + // accept allow/deny/ask; after* events are fire-and-forget (return + // {} for any decision) and are therefore intentionally NOT listed + // here so the validator skips them rather than flagging every verb. + + // beforeShellExecution / beforeMCPExecution: full permission gate + // {permission: allow|deny|ask}. Halt/Deny/Block reject, Ask confirms. + // (Modify is ignored by Cursor -> treated as allow; add_context has no + // field in these schemas, so neither is supported.) + for event in ["beforeShellExecution", "beforeMCPExecution"] { + compatibility.insert( + event, + vec![ + DecisionVerb::Halt, + DecisionVerb::Deny, + DecisionVerb::Block, + DecisionVerb::Ask, + ], + ); + } + + // beforeReadFile: {permission: allow|deny} only. Ask is coerced to + // deny by Cursor, so it is not offered as a distinct supported verb. + compatibility.insert( + "beforeReadFile", + vec![DecisionVerb::Halt, DecisionVerb::Deny, DecisionVerb::Block], + ); + + // beforeSubmitPrompt: {continue: true|false, user_message?}. Block/Deny + // prevent submission; Ask is coerced to block; context injection is + // explicitly unsupported (dropped by Cursor). + compatibility.insert( + "beforeSubmitPrompt", + vec![DecisionVerb::Halt, DecisionVerb::Deny, DecisionVerb::Block], + ); + + // stop: {followup_message?}. Only Block (-> followup) is actionable. + compatibility.insert("stop", vec![DecisionVerb::Halt, DecisionVerb::Block]); + Self { compatibility } } + /// Whether this matrix has an entry for the given event. + /// + /// Distinguishes a genuinely unknown event (e.g. another harness's native + /// event, or a fire-and-forget event with no decision schema) from a known + /// event that legitimately supports no verbs (e.g. SessionEnd -> []). The + /// validator uses this to skip events it has no authority over instead of + /// flagging every verb as incompatible. + pub fn knows_event(&self, event: &str) -> bool { + self.compatibility.contains_key(event) + } + /// Check if a decision verb is compatible with an event pub fn is_compatible(&self, event: &str, verb: DecisionVerb) -> bool { self.compatibility @@ -370,6 +425,68 @@ mod tests { ); } + #[test] + fn test_cursor_before_events_support_permission_verbs() { + let matrix = DecisionEventMatrix::new(); + + // beforeShellExecution / beforeMCPExecution: allow/deny/ask gate + for event in ["beforeShellExecution", "beforeMCPExecution"] { + assert!(matrix.knows_event(event), "{event} should be known"); + for verb in [ + DecisionVerb::Halt, + DecisionVerb::Deny, + DecisionVerb::Block, + DecisionVerb::Ask, + ] { + assert!( + matrix.is_compatible(event, verb), + "{event} should support {verb:?}" + ); + } + // Modify is ignored by Cursor; add_context has no schema field. + assert!(!matrix.is_compatible(event, DecisionVerb::Modify)); + assert!(!matrix.is_compatible(event, DecisionVerb::AddContext)); + } + + // beforeReadFile: allow/deny only (ask is coerced to deny). + assert!(matrix.is_compatible("beforeReadFile", DecisionVerb::Deny)); + assert!(matrix.is_compatible("beforeReadFile", DecisionVerb::Halt)); + assert!(!matrix.is_compatible("beforeReadFile", DecisionVerb::Ask)); + } + + #[test] + fn test_cursor_fire_and_forget_events_are_unknown() { + let matrix = DecisionEventMatrix::new(); + + // after* events return {} for any decision, so they are intentionally + // absent from the matrix and the validator skips them. + for event in [ + "afterFileEdit", + "afterShellExecution", + "afterMCPExecution", + "afterAgentResponse", + "afterAgentThought", + ] { + assert!( + !matrix.knows_event(event), + "fire-and-forget {event} should be unknown (skipped by validator)" + ); + } + } + + #[test] + fn test_knows_event_distinguishes_unknown_from_empty() { + let matrix = DecisionEventMatrix::new(); + + // Known events (even with an empty verb set) are still validated. + assert!(matrix.knows_event("SessionEnd")); + assert!(matrix.compatible_verbs("SessionEnd").is_empty()); + + // Truly unknown events are not validated (skipped). + assert!(!matrix.knows_event("totallyMadeUpEvent")); + assert!(!matrix.knows_event("beforeReadFileX")); + } + #[test] fn test_permission_request_compatibility() { let matrix = DecisionEventMatrix::new(); diff --git a/cupcake-core/src/validator/rules.rs b/cupcake-core/src/validator/rules.rs index 621a8c27..5756e80b 100644 --- a/cupcake-core/src/validator/rules.rs +++ b/cupcake-core/src/validator/rules.rs @@ -360,6 +360,15 @@ impl ValidationRule for DecisionEventCompatibilityRule { // Check each verb against each required event for event in &routing.required_events { + // Skip events this matrix has no spec for (e.g. another harness's + // native events, or fire-and-forget events with no decision schema). + // Without this, the Claude-Code-centric matrix flags every verb on + // such events as incompatible. Known-but-empty events (SessionEnd) + // are still validated because knows_event() returns true for them. + if !matrix.knows_event(event) { + continue; + } + for (verb, line_numbers) in &found_verbs { let line = line_numbers.first().copied();