use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::domain::gate::DenyShape;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RewriteShape {
ClaudeNested,
CrushFlat,
OpencodeShim,
CursorPermission,
}
impl RewriteShape {
pub fn as_str(self) -> &'static str {
match self {
RewriteShape::ClaudeNested => "claude-nested",
RewriteShape::CrushFlat => "crush-flat",
RewriteShape::OpencodeShim => "opencode-shim",
RewriteShape::CursorPermission => "cursor-permission",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MockDelivery {
SettingsFlag { flag: &'static str },
ProjectHooks { extra_args: &'static [&'static str] },
}
pub fn hook_command(exe: &str, id: &str, rules: Option<&str>, spy: Option<&str>) -> String {
let mut command = format!("{exe} mock {id}");
if let Some(rules) = rules {
command.push_str(" --rules ");
command.push_str(rules);
}
if let Some(spy) = spy {
command.push_str(" --spy-file ");
command.push_str(spy);
}
command
}
pub fn settings_hooks_json(command: &str) -> String {
json!({
"hooks": {
"PreToolUse": [
{ "hooks": [ { "type": "command", "command": command } ] }
]
}
})
.to_string()
}
pub fn stub_input(output: &str, exit_code: i32) -> Value {
let quoted = format!("'{}'", output.replace('\'', "'\\''"));
let mut command = format!("printf '%s\\n' {quoted}");
if exit_code != 0 {
command.push_str(&format!("; exit {exit_code}"));
}
json!({ "command": command })
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MockRules {
pub rules: Vec<MockRule>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MockRule {
#[serde(rename = "match")]
pub matcher: MatchSpec,
pub action: Action,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MatchSpec {
#[serde(default)]
pub tool: Option<String>,
#[serde(default)]
pub tool_regex: Option<String>,
#[serde(default)]
pub event_contains: Option<String>,
#[serde(default)]
pub event_regex: Option<String>,
#[serde(default)]
pub input: std::collections::BTreeMap<String, StringPredicate>,
}
#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StringPredicate {
#[serde(default)]
pub equals: Option<String>,
#[serde(default)]
pub contains: Option<String>,
#[serde(default)]
pub regex: Option<String>,
}
impl StringPredicate {
fn matches(&self, value: &str) -> bool {
if let Some(want) = &self.equals {
if value != want {
return false;
}
}
if let Some(needle) = &self.contains {
if !value.contains(needle.as_str()) {
return false;
}
}
if let Some(pattern) = &self.regex {
match regex::Regex::new(pattern) {
Ok(re) if re.is_match(value) => {}
_ => return false,
}
}
true
}
fn forms(&self) -> (bool, bool) {
let any = self.equals.is_some() || self.contains.is_some() || self.regex.is_some();
let empty_needle =
self.contains.as_deref() == Some("") || self.regex.as_deref() == Some("");
(any, empty_needle)
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Action {
Deny { message: String },
Rewrite {
input: Value,
#[serde(default)]
message: Option<String>,
},
Stub {
output: String,
#[serde(default)]
exit_code: i32,
},
}
impl Action {
pub fn kind(&self) -> &'static str {
match self {
Action::Deny { .. } => "deny",
Action::Rewrite { .. } => "rewrite",
Action::Stub { .. } => "stub",
}
}
}
pub fn parse_rules(text: &str) -> Result<MockRules, String> {
let rules: MockRules = serde_json::from_str(text).map_err(|e| e.to_string())?;
for (i, rule) in rules.rules.iter().enumerate() {
let m = &rule.matcher;
for (field, value) in [
("tool", &m.tool),
("tool_regex", &m.tool_regex),
("event_contains", &m.event_contains),
("event_regex", &m.event_regex),
] {
if value.as_deref() == Some("") {
return Err(format!(
"rule {i}: empty `match.{field}` is not allowed (it would match everything)"
));
}
}
for (field, pattern) in [
("tool_regex", &m.tool_regex),
("event_regex", &m.event_regex),
] {
if let Some(pattern) = pattern {
regex::Regex::new(pattern)
.map_err(|e| format!("rule {i}: invalid `match.{field}` regex: {e}"))?;
}
}
for (key, pred) in &m.input {
let (any, empty_needle) = pred.forms();
if !any {
return Err(format!(
"rule {i}: `match.input.{key}` needs one of `equals`/`contains`/`regex`"
));
}
if empty_needle {
return Err(format!(
"rule {i}: empty `contains`/`regex` in `match.input.{key}` is not allowed (it would match everything)"
));
}
if let Some(pattern) = &pred.regex {
regex::Regex::new(pattern).map_err(|e| {
format!("rule {i}: invalid `match.input.{key}.regex` regex: {e}")
})?;
}
}
let no_criteria = m.tool.is_none()
&& m.tool_regex.is_none()
&& m.event_contains.is_none()
&& m.event_regex.is_none()
&& m.input.is_empty();
if no_criteria {
return Err(format!(
"rule {i}: `match` needs at least one of `tool`, `tool_regex`, `event_contains`, `event_regex`, or `input`"
));
}
if let Action::Rewrite { input, .. } = &rule.action {
if !input.is_object() {
return Err(format!(
"rule {i}: `rewrite.input` must be a JSON object (the substituted tool arguments)"
));
}
}
}
Ok(rules)
}
pub fn unsupported_action(
rules: &MockRules,
gate_deny: Option<DenyShape>,
rewrite: Option<RewriteShape>,
) -> Option<&'static str> {
for rule in &rules.rules {
match rule.action {
Action::Deny { .. } if gate_deny.is_none() => return Some("deny"),
Action::Rewrite { .. } if rewrite.is_none() => return Some("rewrite"),
Action::Stub { .. } if rewrite.is_none() => return Some("stub"),
_ => {}
}
}
None
}
pub fn decide<'r>(event: &str, rules: &'r MockRules) -> Option<(usize, &'r Action)> {
let parsed: Option<Value> = serde_json::from_str(event.trim()).ok();
let tool = parsed.as_ref().and_then(tool_name_of);
rules
.rules
.iter()
.enumerate()
.find(|(_, rule)| rule_matches(rule, event, tool.as_deref(), parsed.as_ref()))
.map(|(i, rule)| (i, &rule.action))
}
fn rule_matches(rule: &MockRule, event: &str, tool: Option<&str>, parsed: Option<&Value>) -> bool {
let m = &rule.matcher;
if let Some(want) = m.tool.as_deref() {
match tool {
Some(name) if !want.is_empty() && name.eq_ignore_ascii_case(want) => {}
_ => return false,
}
}
if let Some(pattern) = m.tool_regex.as_deref() {
match (tool, regex::Regex::new(pattern)) {
(Some(name), Ok(re)) if re.is_match(name) => {}
_ => return false,
}
}
if let Some(needle) = m.event_contains.as_deref() {
if needle.is_empty() || !event.contains(needle) {
return false;
}
}
if let Some(pattern) = m.event_regex.as_deref() {
match regex::Regex::new(pattern) {
Ok(re) if re.is_match(event) => {}
_ => return false,
}
}
if !m.input.is_empty() {
let args = parsed.and_then(tool_input_of);
for (key, pred) in &m.input {
let Some(value) = args.and_then(|a| a.get(key)) else {
return false;
};
let owned;
let text = match value.as_str() {
Some(s) => s,
None => {
owned = serde_json::to_string(value).unwrap_or_default();
&owned
}
};
if !pred.matches(text) {
return false;
}
}
}
true
}
pub fn extract_tool_name(event: &str) -> Option<String> {
let value: Value = serde_json::from_str(event.trim()).ok()?;
tool_name_of(&value)
}
fn tool_name_of(value: &Value) -> Option<String> {
for key in ["tool_name", "toolName", "tool"] {
if let Some(name) = value.get(key).and_then(Value::as_str) {
if !name.is_empty() {
return Some(name.to_string());
}
}
}
None
}
fn tool_input_of(value: &Value) -> Option<&serde_json::Map<String, Value>> {
for key in ["tool_input", "toolArgs"] {
if let Some(obj) = value.get(key).and_then(Value::as_object) {
return Some(obj);
}
}
None
}
pub fn render_rewrite(shape: RewriteShape, input: &Value, reason: &str) -> String {
let value = match shape {
RewriteShape::ClaudeNested => json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": reason,
"updatedInput": input,
}
}),
RewriteShape::CrushFlat => json!({
"version": 1,
"decision": "allow",
"reason": reason,
"updated_input": input,
}),
RewriteShape::OpencodeShim => json!({
"decision": "allow",
"reason": reason,
"updated_input": input,
}),
RewriteShape::CursorPermission => json!({
"permission": "allow",
"updated_input": input,
}),
};
value.to_string()
}
#[derive(Debug, Serialize)]
pub struct SpyRecord<'a> {
pub harness: &'a str,
pub event: Value,
pub action: &'static str,
pub rule: Option<usize>,
}
pub fn spy_line(harness: &str, event: &str, decision: Option<(usize, &Action)>) -> String {
let record = SpyRecord {
harness,
event: serde_json::from_str(event.trim())
.unwrap_or_else(|_| Value::String(event.to_string())),
action: decision.map_or("allow", |(_, action)| action.kind()),
rule: decision.map(|(i, _)| i),
};
serde_json::to_string(&record).expect("SpyRecord serialization cannot fail")
}
#[cfg(test)]
mod tests {
use super::*;
fn rules(json: &str) -> MockRules {
parse_rules(json).expect("test ruleset must parse")
}
const REWRITE_RULES: &str = r#"{
"rules": [
{
"match": {"tool": "Bash", "event_contains": "git push"},
"action": {"deny": {"message": "pushes are mocked"}}
},
{
"match": {"event_contains": "git status"},
"action": {"rewrite": {"input": {"command": "printf clean"}}}
}
]
}"#;
#[test]
fn parse_accepts_valid_rules_and_rejects_faults_loudly() {
assert_eq!(rules(REWRITE_RULES).rules.len(), 2);
assert!(parse_rules(r#"{"rules":[],"extra":1}"#).is_err());
assert!(parse_rules(
r#"{"rules":[{"match":{"typo_contains":"x"},"action":{"deny":{"message":"m"}}}]}"#
)
.is_err());
let err = parse_rules(r#"{"rules":[{"match":{},"action":{"deny":{"message":"m"}}}]}"#)
.unwrap_err();
assert!(err.contains("at least one of"), "{err}");
for m in [
r#"{"tool": ""}"#,
r#"{"event_contains": ""}"#,
r#"{"tool": "", "event_contains": "x"}"#,
] {
let text =
format!(r#"{{"rules":[{{"match":{m},"action":{{"deny":{{"message":"m"}}}}}}]}}"#);
assert!(parse_rules(&text).is_err(), "{m} must be rejected");
}
let err = parse_rules(
r#"{"rules":[{"match":{"tool":"Bash"},"action":{"rewrite":{"input":"echo"}}}]}"#,
)
.unwrap_err();
assert!(err.contains("must be a JSON object"), "{err}");
assert!(parse_rules("not json").is_err());
}
#[test]
fn decide_first_match_wins_and_falls_through() {
let r = rules(REWRITE_RULES);
let event = r#"{"tool_name":"Bash","tool_input":{"command":"git push origin"}}"#;
let (i, action) = decide(event, &r).expect("must match");
assert_eq!(i, 0);
assert_eq!(action.kind(), "deny");
let event = r#"{"tool_name":"shell","tool_input":{"command":"git status"}}"#;
let (i, action) = decide(event, &r).expect("must match");
assert_eq!(i, 1);
assert_eq!(action.kind(), "rewrite");
assert!(decide(r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#, &r).is_none());
assert!(decide(r#"{"tool_name":"Bash","tool_input":{"command":"rm"}}"#, &r).is_none());
assert!(decide(
r#"{"tool_name":"Edit","tool_input":{"command":"git push"}}"#,
&r
)
.is_none());
}
#[test]
fn tool_regex_and_event_regex_match() {
let r = rules(
r#"{"rules":[{"match":{"tool_regex":"^(?i)bash$","event_regex":"git\\s+push"},"action":{"deny":{"message":"m"}}}]}"#,
);
assert!(decide(
r#"{"tool_name":"Bash","tool_input":{"command":"git push"}}"#,
&r
)
.is_some());
assert!(decide(
r#"{"tool_name":"bash","tool_input":{"command":"git push"}}"#,
&r
)
.is_some());
assert!(decide(
r#"{"tool_name":"Edit","tool_input":{"command":"git push"}}"#,
&r
)
.is_none());
assert!(decide(
r#"{"tool_name":"bash","tool_input":{"command":"git status"}}"#,
&r
)
.is_none());
assert!(decide(r#"{"tool_input":{"command":"git push"}}"#, &r).is_none());
}
#[test]
fn input_field_predicates_match_and_coerce() {
let r = rules(
r#"{"rules":[{"match":{"input":{"command":{"regex":"rm\\s+-rf"}}},"action":{"deny":{"message":"m"}}}]}"#,
);
assert!(decide(
r#"{"tool_name":"bash","tool_input":{"command":"rm -rf /tmp/x"}}"#,
&r
)
.is_some());
assert!(decide(r#"{"tool_name":"bash","tool_input":{"command":"ls"}}"#, &r).is_none());
assert!(decide(
r#"{"tool_name":"bash","tool_input":{"other":"rm -rf"}}"#,
&r
)
.is_none());
let r = rules(
r#"{"rules":[{"match":{"input":{"file_path":{"equals":"/etc/passwd"}}},"action":{"deny":{"message":"m"}}}]}"#,
);
assert!(decide(
r#"{"tool_name":"Read","tool_input":{"file_path":"/etc/passwd"}}"#,
&r
)
.is_some());
assert!(decide(
r#"{"tool_name":"Read","tool_input":{"file_path":"/etc/passwd.bak"}}"#,
&r
)
.is_none());
let r = rules(
r#"{"rules":[{"match":{"input":{"args":{"contains":"--force"}}},"action":{"deny":{"message":"m"}}}]}"#,
);
assert!(decide(
r#"{"tool_name":"exec","tool_input":{"args":["git","push","--force"]}}"#,
&r
)
.is_some());
assert!(decide(
r#"{"tool_name":"exec","tool_input":{"args":["git","status"]}}"#,
&r
)
.is_none());
let r = rules(
r#"{"rules":[{"match":{"input":{"command":{"contains":"push"}}},"action":{"deny":{"message":"m"}}}]}"#,
);
assert!(decide(
r#"{"toolName":"shell","toolArgs":{"command":"git push"}}"#,
&r
)
.is_some());
}
#[test]
fn input_and_tool_criteria_are_anded() {
let r = rules(
r#"{"rules":[{"match":{"tool":"Bash","input":{"command":{"contains":"push"}}},"action":{"deny":{"message":"m"}}}]}"#,
);
assert!(decide(
r#"{"tool_name":"Bash","tool_input":{"command":"git push"}}"#,
&r
)
.is_some());
assert!(decide(
r#"{"tool_name":"Edit","tool_input":{"command":"git push"}}"#,
&r
)
.is_none());
}
#[test]
fn regex_and_input_validation_is_loud() {
for m in [
r#"{"tool_regex":"("}"#,
r#"{"event_regex":"["}"#,
r#"{"input":{"command":{"regex":"("}}}"#,
] {
let text =
format!(r#"{{"rules":[{{"match":{m},"action":{{"deny":{{"message":"m"}}}}}}]}}"#);
let err = parse_rules(&text).unwrap_err();
assert!(
err.contains("invalid") && err.contains("regex"),
"{m}: {err}"
);
}
let err = parse_rules(
r#"{"rules":[{"match":{"input":{"command":{}}},"action":{"deny":{"message":"m"}}}]}"#,
)
.unwrap_err();
assert!(err.contains("needs one of"), "{err}");
assert!(parse_rules(
r#"{"rules":[{"match":{"input":{"c":{"contains":""}}},"action":{"deny":{"message":"m"}}}]}"#
)
.is_err());
let ok = parse_rules(
r#"{"rules":[{"match":{"input":{"c":{"equals":""}}},"action":{"deny":{"message":"m"}}}]}"#,
)
.unwrap();
assert!(decide(r#"{"tool_name":"x","tool_input":{"c":""}}"#, &ok).is_some());
assert!(decide(r#"{"tool_name":"x","tool_input":{"c":"nonempty"}}"#, &ok).is_none());
for f in ["tool_regex", "event_regex"] {
let text = format!(
r#"{{"rules":[{{"match":{{"{f}":""}},"action":{{"deny":{{"message":"m"}}}}}}]}}"#
);
assert!(parse_rules(&text).is_err(), "{f}");
}
}
#[test]
fn tool_matching_is_case_insensitive_and_needs_a_named_tool() {
let r = rules(r#"{"rules":[{"match":{"tool":"bash"},"action":{"deny":{"message":"m"}}}]}"#);
assert!(decide(r#"{"tool_name":"Bash"}"#, &r).is_some());
assert!(decide(r#"{"toolName":"BASH"}"#, &r).is_some());
assert!(decide(r#"{"tool":"bash"}"#, &r).is_some());
assert!(decide(r#"{"command":"bash stuff"}"#, &r).is_none());
assert!(decide("not json", &r).is_none());
}
#[test]
fn extract_tool_name_reads_known_fields_only() {
assert_eq!(
extract_tool_name(r#"{"tool_name":"Bash"}"#).as_deref(),
Some("Bash")
);
assert_eq!(
extract_tool_name(r#"{"toolName":"shell"}"#).as_deref(),
Some("shell")
);
assert_eq!(
extract_tool_name(r#"{"tool":"bash"}"#).as_deref(),
Some("bash")
);
assert_eq!(
extract_tool_name(r#"{"tool":"b","tool_name":"a"}"#).as_deref(),
Some("a")
);
assert!(extract_tool_name(r#"{"tool_name":""}"#).is_none());
assert!(extract_tool_name(r#"{"tool_name":42}"#).is_none());
assert!(extract_tool_name("nope").is_none());
}
#[test]
fn unsupported_action_reports_the_first_unrenderable_verb() {
let r = rules(REWRITE_RULES);
assert!(unsupported_action(
&r,
Some(DenyShape::ClaudeNested),
Some(RewriteShape::ClaudeNested)
)
.is_none());
assert_eq!(
unsupported_action(&r, Some(DenyShape::ClaudeNested), None),
Some("rewrite")
);
assert_eq!(unsupported_action(&r, None, None), Some("deny"));
let deny_only =
rules(r#"{"rules":[{"match":{"tool":"Bash"},"action":{"deny":{"message":"m"}}}]}"#);
assert!(unsupported_action(&deny_only, Some(DenyShape::CopilotFlat), None).is_none());
}
#[test]
fn render_rewrite_matches_each_protocol() {
let input = json!({"command": "printf mocked"});
let claude: Value =
serde_json::from_str(&render_rewrite(RewriteShape::ClaudeNested, &input, "r")).unwrap();
assert_eq!(claude["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(claude["hookSpecificOutput"]["permissionDecision"], "allow");
assert_eq!(
claude["hookSpecificOutput"]["permissionDecisionReason"],
"r"
);
assert_eq!(
claude["hookSpecificOutput"]["updatedInput"]["command"],
"printf mocked"
);
let crush: Value =
serde_json::from_str(&render_rewrite(RewriteShape::CrushFlat, &input, "r")).unwrap();
assert_eq!(crush["version"], 1);
assert_eq!(crush["decision"], "allow");
assert_eq!(crush["updated_input"]["command"], "printf mocked");
let oc: Value =
serde_json::from_str(&render_rewrite(RewriteShape::OpencodeShim, &input, "r")).unwrap();
assert_eq!(oc["decision"], "allow");
assert_eq!(oc["updated_input"]["command"], "printf mocked");
assert!(oc.get("hookSpecificOutput").is_none());
let cursor: Value =
serde_json::from_str(&render_rewrite(RewriteShape::CursorPermission, &input, "r"))
.unwrap();
assert_eq!(
cursor,
json!({"permission": "allow", "updated_input": {"command": "printf mocked"}})
);
}
#[test]
fn hook_command_wires_rules_and_spy() {
assert_eq!(
hook_command("/bin/oh", "crush", Some("/w/r.json"), Some("/w/s.jsonl")),
"/bin/oh mock crush --rules /w/r.json --spy-file /w/s.jsonl"
);
assert_eq!(
hook_command("/bin/oh", "goose", None, Some("/w/s.jsonl")),
"/bin/oh mock goose --spy-file /w/s.jsonl"
);
assert_eq!(
hook_command("oh", "claude-code", Some("r.json"), None),
"oh mock claude-code --rules r.json"
);
}
#[test]
fn settings_hooks_json_matches_the_probe_verified_shape() {
let v: Value = serde_json::from_str(&settings_hooks_json("oh mock claude-code")).unwrap();
assert_eq!(
v,
json!({
"hooks": {
"PreToolUse": [
{ "hooks": [ { "type": "command", "command": "oh mock claude-code" } ] }
]
}
})
);
}
#[test]
fn stub_input_quotes_any_output_safely() {
assert_eq!(
stub_input("nothing to commit", 0)["command"],
"printf '%s\\n' 'nothing to commit'"
);
assert_eq!(
stub_input("it's `x` a $HOME\nline2", 0)["command"],
"printf '%s\\n' 'it'\\''s `x` a $HOME\nline2'"
);
assert_eq!(
stub_input("boom", 3)["command"],
"printf '%s\\n' 'boom'; exit 3"
);
}
#[test]
fn stub_action_parses_and_requires_the_rewrite_shape() {
let rules = parse_rules(
r#"{"rules":[{"match":{"event_contains":"git status"},"action":{"stub":{"output":"clean"}}}]}"#,
)
.unwrap();
let (_, action) = decide(r#"{"tool_input":{"command":"git status"}}"#, &rules).unwrap();
assert_eq!(action.kind(), "stub");
assert_eq!(
*action,
Action::Stub {
output: "clean".into(),
exit_code: 0
}
);
let rules = parse_rules(
r#"{"rules":[{"match":{"tool":"Bash"},"action":{"stub":{"output":"e","exit_code":2}}}]}"#,
)
.unwrap();
assert!(matches!(
rules.rules[0].action,
Action::Stub { exit_code: 2, .. }
));
assert_eq!(
unsupported_action(&rules, Some(DenyShape::Decision("block")), None),
Some("stub")
);
assert!(unsupported_action(
&rules,
Some(DenyShape::Decision("block")),
Some(RewriteShape::CrushFlat)
)
.is_none());
}
#[test]
fn rewrite_shape_tokens_are_stable() {
assert_eq!(RewriteShape::ClaudeNested.as_str(), "claude-nested");
assert_eq!(RewriteShape::CrushFlat.as_str(), "crush-flat");
assert_eq!(RewriteShape::OpencodeShim.as_str(), "opencode-shim");
assert_eq!(RewriteShape::CursorPermission.as_str(), "cursor-permission");
}
#[test]
fn spy_line_records_event_action_and_rule() {
let r = rules(REWRITE_RULES);
let event = r#"{"tool_name":"Bash","tool_input":{"command":"git push"}}"#;
let decision = decide(event, &r);
let line: Value = serde_json::from_str(&spy_line("claude-code", event, decision)).unwrap();
assert_eq!(line["harness"], "claude-code");
assert_eq!(line["action"], "deny");
assert_eq!(line["rule"], 0);
assert_eq!(line["event"]["tool_input"]["command"], "git push");
let line: Value = serde_json::from_str(&spy_line("goose", "raw text", None)).unwrap();
assert_eq!(line["action"], "allow");
assert!(line["rule"].is_null());
assert_eq!(line["event"], "raw text");
}
}