1use serde::{Deserialize, Serialize};
25use serde_json::{json, Value};
26
27use crate::domain::gate::DenyShape;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum RewriteShape {
38 ClaudeNested,
42 CrushFlat,
45 OpencodeShim,
49 CursorPermission,
53}
54
55impl RewriteShape {
56 pub fn as_str(self) -> &'static str {
58 match self {
59 RewriteShape::ClaudeNested => "claude-nested",
60 RewriteShape::CrushFlat => "crush-flat",
61 RewriteShape::OpencodeShim => "opencode-shim",
62 RewriteShape::CursorPermission => "cursor-permission",
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum MockDelivery {
74 SettingsFlag { flag: &'static str },
79 ProjectHooks { extra_args: &'static [&'static str] },
85}
86
87pub fn hook_command(exe: &str, id: &str, rules: Option<&str>, spy: Option<&str>) -> String {
92 let mut command = format!("{exe} mock {id}");
93 if let Some(rules) = rules {
94 command.push_str(" --rules ");
95 command.push_str(rules);
96 }
97 if let Some(spy) = spy {
98 command.push_str(" --spy-file ");
99 command.push_str(spy);
100 }
101 command
102}
103
104pub fn settings_hooks_json(command: &str) -> String {
109 json!({
110 "hooks": {
111 "PreToolUse": [
112 { "hooks": [ { "type": "command", "command": command } ] }
113 ]
114 }
115 })
116 .to_string()
117}
118
119pub fn stub_input(output: &str, exit_code: i32) -> Value {
124 let quoted = format!("'{}'", output.replace('\'', "'\\''"));
125 let mut command = format!("printf '%s\\n' {quoted}");
126 if exit_code != 0 {
127 command.push_str(&format!("; exit {exit_code}"));
128 }
129 json!({ "command": command })
130}
131
132#[derive(Debug, Clone, PartialEq, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct MockRules {
139 pub rules: Vec<MockRule>,
140}
141
142#[derive(Debug, Clone, PartialEq, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct MockRule {
146 #[serde(rename = "match")]
147 pub matcher: MatchSpec,
148 pub action: Action,
149}
150
151#[derive(Debug, Clone, PartialEq, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct MatchSpec {
158 #[serde(default)]
163 pub tool: Option<String>,
164 #[serde(default)]
167 pub tool_regex: Option<String>,
168 #[serde(default)]
172 pub event_contains: Option<String>,
173 #[serde(default)]
175 pub event_regex: Option<String>,
176 #[serde(default)]
183 pub input: std::collections::BTreeMap<String, StringPredicate>,
184}
185
186#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct StringPredicate {
195 #[serde(default)]
196 pub equals: Option<String>,
197 #[serde(default)]
198 pub contains: Option<String>,
199 #[serde(default)]
200 pub regex: Option<String>,
201}
202
203impl StringPredicate {
204 fn matches(&self, value: &str) -> bool {
208 if let Some(want) = &self.equals {
209 if value != want {
210 return false;
211 }
212 }
213 if let Some(needle) = &self.contains {
214 if !value.contains(needle.as_str()) {
215 return false;
216 }
217 }
218 if let Some(pattern) = &self.regex {
219 match regex::Regex::new(pattern) {
220 Ok(re) if re.is_match(value) => {}
221 _ => return false,
222 }
223 }
224 true
225 }
226
227 fn forms(&self) -> (bool, bool) {
229 let any = self.equals.is_some() || self.contains.is_some() || self.regex.is_some();
230 let empty_needle =
231 self.contains.as_deref() == Some("") || self.regex.as_deref() == Some("");
232 (any, empty_needle)
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Deserialize)]
238#[serde(rename_all = "snake_case", deny_unknown_fields)]
239pub enum Action {
240 Deny { message: String },
243 Rewrite {
248 input: Value,
249 #[serde(default)]
251 message: Option<String>,
252 },
253 Stub {
263 output: String,
264 #[serde(default)]
266 exit_code: i32,
267 },
268}
269
270impl Action {
271 pub fn kind(&self) -> &'static str {
273 match self {
274 Action::Deny { .. } => "deny",
275 Action::Rewrite { .. } => "rewrite",
276 Action::Stub { .. } => "stub",
277 }
278 }
279}
280
281pub fn parse_rules(text: &str) -> Result<MockRules, String> {
284 let rules: MockRules = serde_json::from_str(text).map_err(|e| e.to_string())?;
285 for (i, rule) in rules.rules.iter().enumerate() {
286 let m = &rule.matcher;
287 for (field, value) in [
289 ("tool", &m.tool),
290 ("tool_regex", &m.tool_regex),
291 ("event_contains", &m.event_contains),
292 ("event_regex", &m.event_regex),
293 ] {
294 if value.as_deref() == Some("") {
295 return Err(format!(
296 "rule {i}: empty `match.{field}` is not allowed (it would match everything)"
297 ));
298 }
299 }
300 for (field, pattern) in [
303 ("tool_regex", &m.tool_regex),
304 ("event_regex", &m.event_regex),
305 ] {
306 if let Some(pattern) = pattern {
307 regex::Regex::new(pattern)
308 .map_err(|e| format!("rule {i}: invalid `match.{field}` regex: {e}"))?;
309 }
310 }
311 for (key, pred) in &m.input {
314 let (any, empty_needle) = pred.forms();
315 if !any {
316 return Err(format!(
317 "rule {i}: `match.input.{key}` needs one of `equals`/`contains`/`regex`"
318 ));
319 }
320 if empty_needle {
321 return Err(format!(
322 "rule {i}: empty `contains`/`regex` in `match.input.{key}` is not allowed (it would match everything)"
323 ));
324 }
325 if let Some(pattern) = &pred.regex {
326 regex::Regex::new(pattern).map_err(|e| {
327 format!("rule {i}: invalid `match.input.{key}.regex` regex: {e}")
328 })?;
329 }
330 }
331 let no_criteria = m.tool.is_none()
333 && m.tool_regex.is_none()
334 && m.event_contains.is_none()
335 && m.event_regex.is_none()
336 && m.input.is_empty();
337 if no_criteria {
338 return Err(format!(
339 "rule {i}: `match` needs at least one of `tool`, `tool_regex`, `event_contains`, `event_regex`, or `input`"
340 ));
341 }
342 if let Action::Rewrite { input, .. } = &rule.action {
343 if !input.is_object() {
344 return Err(format!(
345 "rule {i}: `rewrite.input` must be a JSON object (the substituted tool arguments)"
346 ));
347 }
348 }
349 }
350 Ok(rules)
351}
352
353pub fn unsupported_action(
357 rules: &MockRules,
358 gate_deny: Option<DenyShape>,
359 rewrite: Option<RewriteShape>,
360) -> Option<&'static str> {
361 for rule in &rules.rules {
362 match rule.action {
363 Action::Deny { .. } if gate_deny.is_none() => return Some("deny"),
364 Action::Rewrite { .. } if rewrite.is_none() => return Some("rewrite"),
365 Action::Stub { .. } if rewrite.is_none() => return Some("stub"),
367 _ => {}
368 }
369 }
370 None
371}
372
373pub fn decide<'r>(event: &str, rules: &'r MockRules) -> Option<(usize, &'r Action)> {
377 let parsed: Option<Value> = serde_json::from_str(event.trim()).ok();
380 let tool = parsed.as_ref().and_then(tool_name_of);
381 rules
382 .rules
383 .iter()
384 .enumerate()
385 .find(|(_, rule)| rule_matches(rule, event, tool.as_deref(), parsed.as_ref()))
386 .map(|(i, rule)| (i, &rule.action))
387}
388
389fn rule_matches(rule: &MockRule, event: &str, tool: Option<&str>, parsed: Option<&Value>) -> bool {
390 let m = &rule.matcher;
391 if let Some(want) = m.tool.as_deref() {
392 match tool {
395 Some(name) if !want.is_empty() && name.eq_ignore_ascii_case(want) => {}
396 _ => return false,
397 }
398 }
399 if let Some(pattern) = m.tool_regex.as_deref() {
400 match (tool, regex::Regex::new(pattern)) {
401 (Some(name), Ok(re)) if re.is_match(name) => {}
402 _ => return false,
403 }
404 }
405 if let Some(needle) = m.event_contains.as_deref() {
406 if needle.is_empty() || !event.contains(needle) {
407 return false;
408 }
409 }
410 if let Some(pattern) = m.event_regex.as_deref() {
411 match regex::Regex::new(pattern) {
412 Ok(re) if re.is_match(event) => {}
413 _ => return false,
414 }
415 }
416 if !m.input.is_empty() {
417 let args = parsed.and_then(tool_input_of);
418 for (key, pred) in &m.input {
419 let Some(value) = args.and_then(|a| a.get(key)) else {
421 return false;
422 };
423 let owned;
426 let text = match value.as_str() {
427 Some(s) => s,
428 None => {
429 owned = serde_json::to_string(value).unwrap_or_default();
430 &owned
431 }
432 };
433 if !pred.matches(text) {
434 return false;
435 }
436 }
437 }
438 true
439}
440
441pub fn extract_tool_name(event: &str) -> Option<String> {
446 let value: Value = serde_json::from_str(event.trim()).ok()?;
447 tool_name_of(&value)
448}
449
450fn tool_name_of(value: &Value) -> Option<String> {
452 for key in ["tool_name", "toolName", "tool"] {
453 if let Some(name) = value.get(key).and_then(Value::as_str) {
454 if !name.is_empty() {
455 return Some(name.to_string());
456 }
457 }
458 }
459 None
460}
461
462fn tool_input_of(value: &Value) -> Option<&serde_json::Map<String, Value>> {
465 for key in ["tool_input", "toolArgs"] {
466 if let Some(obj) = value.get(key).and_then(Value::as_object) {
467 return Some(obj);
468 }
469 }
470 None
471}
472
473pub fn render_rewrite(shape: RewriteShape, input: &Value, reason: &str) -> String {
478 let value = match shape {
479 RewriteShape::ClaudeNested => json!({
480 "hookSpecificOutput": {
481 "hookEventName": "PreToolUse",
482 "permissionDecision": "allow",
483 "permissionDecisionReason": reason,
484 "updatedInput": input,
485 }
486 }),
487 RewriteShape::CrushFlat => json!({
488 "version": 1,
489 "decision": "allow",
490 "reason": reason,
491 "updated_input": input,
492 }),
493 RewriteShape::OpencodeShim => json!({
494 "decision": "allow",
495 "reason": reason,
496 "updated_input": input,
497 }),
498 RewriteShape::CursorPermission => json!({
501 "permission": "allow",
502 "updated_input": input,
503 }),
504 };
505 value.to_string()
506}
507
508#[derive(Debug, Serialize)]
514pub struct SpyRecord<'a> {
515 pub harness: &'a str,
516 pub event: Value,
519 pub action: &'static str,
521 pub rule: Option<usize>,
523}
524
525pub fn spy_line(harness: &str, event: &str, decision: Option<(usize, &Action)>) -> String {
527 let record = SpyRecord {
528 harness,
529 event: serde_json::from_str(event.trim())
530 .unwrap_or_else(|_| Value::String(event.to_string())),
531 action: decision.map_or("allow", |(_, action)| action.kind()),
532 rule: decision.map(|(i, _)| i),
533 };
534 serde_json::to_string(&record).expect("SpyRecord serialization cannot fail")
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 fn rules(json: &str) -> MockRules {
542 parse_rules(json).expect("test ruleset must parse")
543 }
544
545 const REWRITE_RULES: &str = r#"{
546 "rules": [
547 {
548 "match": {"tool": "Bash", "event_contains": "git push"},
549 "action": {"deny": {"message": "pushes are mocked"}}
550 },
551 {
552 "match": {"event_contains": "git status"},
553 "action": {"rewrite": {"input": {"command": "printf clean"}}}
554 }
555 ]
556 }"#;
557
558 #[test]
559 fn parse_accepts_valid_rules_and_rejects_faults_loudly() {
560 assert_eq!(rules(REWRITE_RULES).rules.len(), 2);
561 assert!(parse_rules(r#"{"rules":[],"extra":1}"#).is_err());
563 assert!(parse_rules(
564 r#"{"rules":[{"match":{"typo_contains":"x"},"action":{"deny":{"message":"m"}}}]}"#
565 )
566 .is_err());
567 let err = parse_rules(r#"{"rules":[{"match":{},"action":{"deny":{"message":"m"}}}]}"#)
569 .unwrap_err();
570 assert!(err.contains("at least one of"), "{err}");
571 for m in [
573 r#"{"tool": ""}"#,
574 r#"{"event_contains": ""}"#,
575 r#"{"tool": "", "event_contains": "x"}"#,
576 ] {
577 let text =
578 format!(r#"{{"rules":[{{"match":{m},"action":{{"deny":{{"message":"m"}}}}}}]}}"#);
579 assert!(parse_rules(&text).is_err(), "{m} must be rejected");
580 }
581 let err = parse_rules(
583 r#"{"rules":[{"match":{"tool":"Bash"},"action":{"rewrite":{"input":"echo"}}}]}"#,
584 )
585 .unwrap_err();
586 assert!(err.contains("must be a JSON object"), "{err}");
587 assert!(parse_rules("not json").is_err());
589 }
590
591 #[test]
592 fn decide_first_match_wins_and_falls_through() {
593 let r = rules(REWRITE_RULES);
594 let event = r#"{"tool_name":"Bash","tool_input":{"command":"git push origin"}}"#;
596 let (i, action) = decide(event, &r).expect("must match");
597 assert_eq!(i, 0);
598 assert_eq!(action.kind(), "deny");
599 let event = r#"{"tool_name":"shell","tool_input":{"command":"git status"}}"#;
601 let (i, action) = decide(event, &r).expect("must match");
602 assert_eq!(i, 1);
603 assert_eq!(action.kind(), "rewrite");
604 assert!(decide(r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#, &r).is_none());
606 assert!(decide(r#"{"tool_name":"Bash","tool_input":{"command":"rm"}}"#, &r).is_none());
609 assert!(decide(
612 r#"{"tool_name":"Edit","tool_input":{"command":"git push"}}"#,
613 &r
614 )
615 .is_none());
616 }
617
618 #[test]
619 fn tool_regex_and_event_regex_match() {
620 let r = rules(
622 r#"{"rules":[{"match":{"tool_regex":"^(?i)bash$","event_regex":"git\\s+push"},"action":{"deny":{"message":"m"}}}]}"#,
623 );
624 assert!(decide(
625 r#"{"tool_name":"Bash","tool_input":{"command":"git push"}}"#,
626 &r
627 )
628 .is_some());
629 assert!(decide(
630 r#"{"tool_name":"bash","tool_input":{"command":"git push"}}"#,
631 &r
632 )
633 .is_some());
634 assert!(decide(
636 r#"{"tool_name":"Edit","tool_input":{"command":"git push"}}"#,
637 &r
638 )
639 .is_none());
640 assert!(decide(
641 r#"{"tool_name":"bash","tool_input":{"command":"git status"}}"#,
642 &r
643 )
644 .is_none());
645 assert!(decide(r#"{"tool_input":{"command":"git push"}}"#, &r).is_none());
646 }
647
648 #[test]
649 fn input_field_predicates_match_and_coerce() {
650 let r = rules(
652 r#"{"rules":[{"match":{"input":{"command":{"regex":"rm\\s+-rf"}}},"action":{"deny":{"message":"m"}}}]}"#,
653 );
654 assert!(decide(
655 r#"{"tool_name":"bash","tool_input":{"command":"rm -rf /tmp/x"}}"#,
656 &r
657 )
658 .is_some());
659 assert!(decide(r#"{"tool_name":"bash","tool_input":{"command":"ls"}}"#, &r).is_none());
660 assert!(decide(
662 r#"{"tool_name":"bash","tool_input":{"other":"rm -rf"}}"#,
663 &r
664 )
665 .is_none());
666
667 let r = rules(
669 r#"{"rules":[{"match":{"input":{"file_path":{"equals":"/etc/passwd"}}},"action":{"deny":{"message":"m"}}}]}"#,
670 );
671 assert!(decide(
672 r#"{"tool_name":"Read","tool_input":{"file_path":"/etc/passwd"}}"#,
673 &r
674 )
675 .is_some());
676 assert!(decide(
677 r#"{"tool_name":"Read","tool_input":{"file_path":"/etc/passwd.bak"}}"#,
678 &r
679 )
680 .is_none());
681
682 let r = rules(
685 r#"{"rules":[{"match":{"input":{"args":{"contains":"--force"}}},"action":{"deny":{"message":"m"}}}]}"#,
686 );
687 assert!(decide(
688 r#"{"tool_name":"exec","tool_input":{"args":["git","push","--force"]}}"#,
689 &r
690 )
691 .is_some());
692 assert!(decide(
693 r#"{"tool_name":"exec","tool_input":{"args":["git","status"]}}"#,
694 &r
695 )
696 .is_none());
697
698 let r = rules(
700 r#"{"rules":[{"match":{"input":{"command":{"contains":"push"}}},"action":{"deny":{"message":"m"}}}]}"#,
701 );
702 assert!(decide(
703 r#"{"toolName":"shell","toolArgs":{"command":"git push"}}"#,
704 &r
705 )
706 .is_some());
707 }
708
709 #[test]
710 fn input_and_tool_criteria_are_anded() {
711 let r = rules(
713 r#"{"rules":[{"match":{"tool":"Bash","input":{"command":{"contains":"push"}}},"action":{"deny":{"message":"m"}}}]}"#,
714 );
715 assert!(decide(
716 r#"{"tool_name":"Bash","tool_input":{"command":"git push"}}"#,
717 &r
718 )
719 .is_some());
720 assert!(decide(
722 r#"{"tool_name":"Edit","tool_input":{"command":"git push"}}"#,
723 &r
724 )
725 .is_none());
726 }
727
728 #[test]
729 fn regex_and_input_validation_is_loud() {
730 for m in [
732 r#"{"tool_regex":"("}"#,
733 r#"{"event_regex":"["}"#,
734 r#"{"input":{"command":{"regex":"("}}}"#,
735 ] {
736 let text =
737 format!(r#"{{"rules":[{{"match":{m},"action":{{"deny":{{"message":"m"}}}}}}]}}"#);
738 let err = parse_rules(&text).unwrap_err();
739 assert!(
740 err.contains("invalid") && err.contains("regex"),
741 "{m}: {err}"
742 );
743 }
744 let err = parse_rules(
746 r#"{"rules":[{"match":{"input":{"command":{}}},"action":{"deny":{"message":"m"}}}]}"#,
747 )
748 .unwrap_err();
749 assert!(err.contains("needs one of"), "{err}");
750 assert!(parse_rules(
753 r#"{"rules":[{"match":{"input":{"c":{"contains":""}}},"action":{"deny":{"message":"m"}}}]}"#
754 )
755 .is_err());
756 let ok = parse_rules(
757 r#"{"rules":[{"match":{"input":{"c":{"equals":""}}},"action":{"deny":{"message":"m"}}}]}"#,
758 )
759 .unwrap();
760 assert!(decide(r#"{"tool_name":"x","tool_input":{"c":""}}"#, &ok).is_some());
761 assert!(decide(r#"{"tool_name":"x","tool_input":{"c":"nonempty"}}"#, &ok).is_none());
762 for f in ["tool_regex", "event_regex"] {
764 let text = format!(
765 r#"{{"rules":[{{"match":{{"{f}":""}},"action":{{"deny":{{"message":"m"}}}}}}]}}"#
766 );
767 assert!(parse_rules(&text).is_err(), "{f}");
768 }
769 }
770
771 #[test]
772 fn tool_matching_is_case_insensitive_and_needs_a_named_tool() {
773 let r = rules(r#"{"rules":[{"match":{"tool":"bash"},"action":{"deny":{"message":"m"}}}]}"#);
774 assert!(decide(r#"{"tool_name":"Bash"}"#, &r).is_some());
775 assert!(decide(r#"{"toolName":"BASH"}"#, &r).is_some());
776 assert!(decide(r#"{"tool":"bash"}"#, &r).is_some());
777 assert!(decide(r#"{"command":"bash stuff"}"#, &r).is_none());
780 assert!(decide("not json", &r).is_none());
781 }
782
783 #[test]
784 fn extract_tool_name_reads_known_fields_only() {
785 assert_eq!(
786 extract_tool_name(r#"{"tool_name":"Bash"}"#).as_deref(),
787 Some("Bash")
788 );
789 assert_eq!(
790 extract_tool_name(r#"{"toolName":"shell"}"#).as_deref(),
791 Some("shell")
792 );
793 assert_eq!(
794 extract_tool_name(r#"{"tool":"bash"}"#).as_deref(),
795 Some("bash")
796 );
797 assert_eq!(
799 extract_tool_name(r#"{"tool":"b","tool_name":"a"}"#).as_deref(),
800 Some("a")
801 );
802 assert!(extract_tool_name(r#"{"tool_name":""}"#).is_none());
803 assert!(extract_tool_name(r#"{"tool_name":42}"#).is_none());
804 assert!(extract_tool_name("nope").is_none());
805 }
806
807 #[test]
808 fn unsupported_action_reports_the_first_unrenderable_verb() {
809 let r = rules(REWRITE_RULES);
810 assert!(unsupported_action(
812 &r,
813 Some(DenyShape::ClaudeNested),
814 Some(RewriteShape::ClaudeNested)
815 )
816 .is_none());
817 assert_eq!(
819 unsupported_action(&r, Some(DenyShape::ClaudeNested), None),
820 Some("rewrite")
821 );
822 assert_eq!(unsupported_action(&r, None, None), Some("deny"));
824 let deny_only =
826 rules(r#"{"rules":[{"match":{"tool":"Bash"},"action":{"deny":{"message":"m"}}}]}"#);
827 assert!(unsupported_action(&deny_only, Some(DenyShape::CopilotFlat), None).is_none());
828 }
829
830 #[test]
831 fn render_rewrite_matches_each_protocol() {
832 let input = json!({"command": "printf mocked"});
833 let claude: Value =
834 serde_json::from_str(&render_rewrite(RewriteShape::ClaudeNested, &input, "r")).unwrap();
835 assert_eq!(claude["hookSpecificOutput"]["hookEventName"], "PreToolUse");
836 assert_eq!(claude["hookSpecificOutput"]["permissionDecision"], "allow");
837 assert_eq!(
838 claude["hookSpecificOutput"]["permissionDecisionReason"],
839 "r"
840 );
841 assert_eq!(
842 claude["hookSpecificOutput"]["updatedInput"]["command"],
843 "printf mocked"
844 );
845 let crush: Value =
846 serde_json::from_str(&render_rewrite(RewriteShape::CrushFlat, &input, "r")).unwrap();
847 assert_eq!(crush["version"], 1);
848 assert_eq!(crush["decision"], "allow");
849 assert_eq!(crush["updated_input"]["command"], "printf mocked");
850 let oc: Value =
851 serde_json::from_str(&render_rewrite(RewriteShape::OpencodeShim, &input, "r")).unwrap();
852 assert_eq!(oc["decision"], "allow");
853 assert_eq!(oc["updated_input"]["command"], "printf mocked");
854 assert!(oc.get("hookSpecificOutput").is_none());
855 let cursor: Value =
858 serde_json::from_str(&render_rewrite(RewriteShape::CursorPermission, &input, "r"))
859 .unwrap();
860 assert_eq!(
861 cursor,
862 json!({"permission": "allow", "updated_input": {"command": "printf mocked"}})
863 );
864 }
865
866 #[test]
867 fn hook_command_wires_rules_and_spy() {
868 assert_eq!(
869 hook_command("/bin/oh", "crush", Some("/w/r.json"), Some("/w/s.jsonl")),
870 "/bin/oh mock crush --rules /w/r.json --spy-file /w/s.jsonl"
871 );
872 assert_eq!(
874 hook_command("/bin/oh", "goose", None, Some("/w/s.jsonl")),
875 "/bin/oh mock goose --spy-file /w/s.jsonl"
876 );
877 assert_eq!(
878 hook_command("oh", "claude-code", Some("r.json"), None),
879 "oh mock claude-code --rules r.json"
880 );
881 }
882
883 #[test]
884 fn settings_hooks_json_matches_the_probe_verified_shape() {
885 let v: Value = serde_json::from_str(&settings_hooks_json("oh mock claude-code")).unwrap();
886 assert_eq!(
887 v,
888 json!({
889 "hooks": {
890 "PreToolUse": [
891 { "hooks": [ { "type": "command", "command": "oh mock claude-code" } ] }
892 ]
893 }
894 })
895 );
896 }
897
898 #[test]
899 fn stub_input_quotes_any_output_safely() {
900 assert_eq!(
903 stub_input("nothing to commit", 0)["command"],
904 "printf '%s\\n' 'nothing to commit'"
905 );
906 assert_eq!(
909 stub_input("it's `x` a $HOME\nline2", 0)["command"],
910 "printf '%s\\n' 'it'\\''s `x` a $HOME\nline2'"
911 );
912 assert_eq!(
914 stub_input("boom", 3)["command"],
915 "printf '%s\\n' 'boom'; exit 3"
916 );
917 }
918
919 #[test]
920 fn stub_action_parses_and_requires_the_rewrite_shape() {
921 let rules = parse_rules(
922 r#"{"rules":[{"match":{"event_contains":"git status"},"action":{"stub":{"output":"clean"}}}]}"#,
923 )
924 .unwrap();
925 let (_, action) = decide(r#"{"tool_input":{"command":"git status"}}"#, &rules).unwrap();
926 assert_eq!(action.kind(), "stub");
927 assert_eq!(
928 *action,
929 Action::Stub {
930 output: "clean".into(),
931 exit_code: 0
932 }
933 );
934 let rules = parse_rules(
936 r#"{"rules":[{"match":{"tool":"Bash"},"action":{"stub":{"output":"e","exit_code":2}}}]}"#,
937 )
938 .unwrap();
939 assert!(matches!(
940 rules.rules[0].action,
941 Action::Stub { exit_code: 2, .. }
942 ));
943 assert_eq!(
945 unsupported_action(&rules, Some(DenyShape::Decision("block")), None),
946 Some("stub")
947 );
948 assert!(unsupported_action(
949 &rules,
950 Some(DenyShape::Decision("block")),
951 Some(RewriteShape::CrushFlat)
952 )
953 .is_none());
954 }
955
956 #[test]
957 fn rewrite_shape_tokens_are_stable() {
958 assert_eq!(RewriteShape::ClaudeNested.as_str(), "claude-nested");
959 assert_eq!(RewriteShape::CrushFlat.as_str(), "crush-flat");
960 assert_eq!(RewriteShape::OpencodeShim.as_str(), "opencode-shim");
961 assert_eq!(RewriteShape::CursorPermission.as_str(), "cursor-permission");
962 }
963
964 #[test]
965 fn spy_line_records_event_action_and_rule() {
966 let r = rules(REWRITE_RULES);
967 let event = r#"{"tool_name":"Bash","tool_input":{"command":"git push"}}"#;
968 let decision = decide(event, &r);
969 let line: Value = serde_json::from_str(&spy_line("claude-code", event, decision)).unwrap();
970 assert_eq!(line["harness"], "claude-code");
971 assert_eq!(line["action"], "deny");
972 assert_eq!(line["rule"], 0);
973 assert_eq!(line["event"]["tool_input"]["command"], "git push");
975 let line: Value = serde_json::from_str(&spy_line("goose", "raw text", None)).unwrap();
978 assert_eq!(line["action"], "allow");
979 assert!(line["rule"].is_null());
980 assert_eq!(line["event"], "raw text");
981 }
982}