From da0f7ea895f408e9788e7373da96a767b4d3a4a6 Mon Sep 17 00:00:00 2001 From: Hayato Kawai Date: Fri, 20 Feb 2026 23:59:33 +0900 Subject: [PATCH] adapter: improve deny feedback with matched rule, reason, and suggestion ExecAdapter's deny output did not show the matched rule pattern, making it hard for LLM agents to understand why a command was rejected and how to fix it. HookAdapter's permissionDecisionReason only included the message but omitted the matched rule and fix_suggestion. ExecAdapter now outputs a structured format: runok: denied: reason: suggestion: HookAdapter now builds a combined reason string that includes the matched rule, message, and fix_suggestion in permissionDecisionReason. Co-Authored-By: Claude Opus 4.6 --- src/adapter/exec_adapter.rs | 12 ++++++------ src/adapter/hook_adapter.rs | 33 +++++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/adapter/exec_adapter.rs b/src/adapter/exec_adapter.rs index 2477d3d..74d705b 100644 --- a/src/adapter/exec_adapter.rs +++ b/src/adapter/exec_adapter.rs @@ -74,12 +74,12 @@ impl Endpoint for ExecAdapter { Ok(exit_code) } Action::Deny(deny_response) => { - let msg = deny_response - .message - .unwrap_or_else(|| format!("command denied: {}", deny_response.matched_rule)); - eprintln!("runok: {}", msg); - if let Some(suggestion) = deny_response.fix_suggestion { - eprintln!("runok: suggestion: {}", suggestion); + eprintln!("runok: denied: {}", deny_response.matched_rule); + if let Some(ref message) = deny_response.message { + eprintln!(" reason: {}", message); + } + if let Some(ref suggestion) = deny_response.fix_suggestion { + eprintln!(" suggestion: {}", suggestion); } Ok(3) } diff --git a/src/adapter/hook_adapter.rs b/src/adapter/hook_adapter.rs index 66e6738..46521aa 100644 --- a/src/adapter/hook_adapter.rs +++ b/src/adapter/hook_adapter.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::adapter::{ActionResult, Endpoint, SandboxInfo}; use crate::config::{ActionKind, Defaults}; -use crate::rules::rule_engine::Action; +use crate::rules::rule_engine::{Action, DenyResponse}; /// Claude Code PreToolUse Hook input (stdin JSON). #[derive(Debug, Deserialize)] @@ -58,6 +58,19 @@ pub struct ClaudeCodeHookAdapter { input: HookInput, } +/// Build a combined reason string from a `DenyResponse`, including +/// the matched rule, optional message, and optional fix suggestion. +fn build_deny_reason(deny: &DenyResponse) -> String { + let mut reason = format!("denied: {}", deny.matched_rule); + if let Some(ref message) = deny.message { + reason.push_str(&format!(" ({})", message)); + } + if let Some(ref suggestion) = deny.fix_suggestion { + reason.push_str(&format!(" [suggestion: {}]", suggestion)); + } + reason +} + impl ClaudeCodeHookAdapter { pub fn new(input: HookInput) -> Self { Self { input } @@ -77,7 +90,10 @@ impl ClaudeCodeHookAdapter { let updated = Self::sandbox_updated_input(&result.sandbox, &bash_input.command)?; ("allow", None, updated) } - Action::Deny(deny_response) => ("deny", deny_response.message.clone(), None), + Action::Deny(deny_response) => { + let reason = build_deny_reason(deny_response); + ("deny", Some(reason), None) + } Action::Ask(message) => ("ask", message.clone(), None), Action::Default => { // run() dispatches Default to handle_no_match, but handle safely. @@ -286,7 +302,7 @@ mod tests { matched_rule: "rm -rf /".to_string(), }), SandboxInfo::Preset(None), - make_output("deny", Some("not allowed"), None), + make_output("deny", Some("denied: rm -rf / (not allowed)"), None), )] #[case::deny_without_message( Action::Deny(DenyResponse { @@ -295,7 +311,16 @@ mod tests { matched_rule: "rm *".to_string(), }), SandboxInfo::Preset(None), - make_output("deny", None, None), + make_output("deny", Some("denied: rm *"), None), + )] + #[case::deny_with_message_and_suggestion( + Action::Deny(DenyResponse { + message: Some("force push is not allowed".to_string()), + fix_suggestion: Some("git push --force-with-lease".to_string()), + matched_rule: "git push -f *".to_string(), + }), + SandboxInfo::Preset(None), + make_output("deny", Some("denied: git push -f * (force push is not allowed) [suggestion: git push --force-with-lease]"), None), )] #[case::ask_with_message( Action::Ask(Some("please confirm".to_string())),