Skip to content
Open
5 changes: 5 additions & 0 deletions clash/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
//! Writes JSON Lines entries to `~/.clash/audit.jsonl` (configurable via settings).
//! Each entry records the tool invocation and the policy decision.

// `init_session` and `update_session_stats` are on the `disallowed_methods`
// list for handler code (use `Env::session` instead). In-module tests
// legitimately exercise them directly.
#![cfg_attr(test, allow(clippy::disallowed_methods))]

use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
Expand Down
2 changes: 2 additions & 0 deletions clash/src/cmd/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,8 @@ fn check_file_permissions() -> CheckResult {

/// Check 6: Does the platform support sandboxing?
fn check_sandbox_support() -> CheckResult {
// Diagnostic command, not on the env-injected handler path.
#[allow(clippy::disallowed_methods)]
match sandbox::check_support() {
sandbox::SupportLevel::Full => {
let backend = if cfg!(target_os = "macos") {
Expand Down
49 changes: 15 additions & 34 deletions clash/src/cmd/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,37 +69,29 @@ impl HookCmd {
tool = %input.tool_name,
"CLASH_PASSTHROUGH: deferring to native permissions"
);
if let Err(e) = trace::sync_trace(&input.session_id, None) {
let env = crate::env::Env::prod();
if let Err(e) = env.session.sync_trace(&input.session_id, None) {
tracing::warn!(error = %e, "Failed to sync trace (PreToolUse/passthrough)");
}
HookOutput::continue_execution()
} else {
let env = crate::env::Env::prod();
let mut hook_ctx = HookContext::from_transcript_path(&input.transcript_path);
if let Some(agent) = input.agent {
hook_ctx = hook_ctx.with_agent(agent);
}
let settings = ClashSettings::load_or_create_with_session(
Some(&input.session_id),
Some(&hook_ctx),
)?;
let settings = env.policy.load_settings(&input.session_id, &hook_ctx)?;
let output = check_permission(&input, &settings)?;

// Interactive tools (e.g., AskUserQuestion) require user input
// via Claude Code's native UI. When the policy says "ask", pass
// through to CC's native prompt. When the policy explicitly allows
// or denies, enforce it — this enables mode-aware automation
// (e.g., allow ExitPlanMode in plan mode).
if is_interactive_tool(&input.tool_name)
&& !is_deny_decision(&output)
&& is_ask_decision(&output)
{
info!(tool = %input.tool_name, "Passthrough: interactive tool deferred to Claude Code");
HookOutput::continue_execution()
} else {
// Update session stats for the status line (only here, not in
// log_decision, to avoid double-counting PermissionRequest).
if let Some(effect) = hook_effect_to_policy(output.effect()) {
crate::audit::update_session_stats(
env.session.update_session_stats(
&input.session_id,
&input.tool_name,
&input.tool_input,
Expand All @@ -108,12 +100,10 @@ impl HookCmd {
);
}

// If the decision is Ask, record it so PostToolUse can detect
// user approval and suggest a session policy rule.
if is_ask_decision(&output)
&& let Some(ref tool_use_id) = input.tool_use_id
{
session_policy::record_pending_ask(
env.session.record_pending_ask(
&input.session_id,
tool_use_id,
&input.tool_name,
Expand All @@ -122,7 +112,6 @@ impl HookCmd {
);
}

// Sync trace with the policy decision for this tool use.
let decision = input.tool_use_id.as_ref().and_then(|id| {
let effect = hook_effect_to_policy(output.effect())?;
Some(trace::PolicyDecision {
Expand All @@ -132,7 +121,7 @@ impl HookCmd {
reason: None,
})
});
if let Err(e) = trace::sync_trace(&input.session_id, decision) {
if let Err(e) = env.session.sync_trace(&input.session_id, decision) {
tracing::warn!(error = %e, "Failed to sync trace (PreToolUse)");
}

Expand All @@ -144,12 +133,10 @@ impl HookCmd {
let input = self
.parse_tool_use_input()
.context("parsing PostToolUse hook input from stdin")?;
let env = crate::env::Env::prod();

// Check if this tool use was previously "ask"ed and the user
// accepted. If so, return advisory context suggesting a session
// rule for Claude to offer the user.
let session_context = input.tool_use_id.as_deref().and_then(|tool_use_id| {
let advice = session_policy::process_post_tool_use(
let advice = env.session.consume_pending_ask(
tool_use_id,
&input.session_id,
&input.tool_name,
Expand All @@ -163,18 +150,12 @@ impl HookCmd {
Some(advice.as_context())
});

// Check if a sandboxed Bash command failed with network or
// filesystem errors, and provide hints about sandbox restrictions.
let (network_context, fs_context) = {
let mut hook_ctx = HookContext::from_transcript_path(&input.transcript_path);
if let Some(agent) = input.agent {
hook_ctx = hook_ctx.with_agent(agent);
}
let settings = ClashSettings::load_or_create_with_session(
Some(&input.session_id),
Some(&hook_ctx),
)
.ok();
let settings = env.policy.load_settings(&input.session_id, &hook_ctx).ok();
let net = settings.as_ref().and_then(|s| {
crate::network_hints::check_for_sandbox_network_hint(&input, s)
});
Expand All @@ -184,7 +165,6 @@ impl HookCmd {
(net, fs)
};

// Combine contexts (session policy advice + sandbox hints).
let context = [session_context, network_context, fs_context]
.into_iter()
.flatten()
Expand All @@ -195,8 +175,7 @@ impl HookCmd {
Some(context.join("\n\n"))
};

// Sync trace to pick up tool responses.
if let Err(e) = trace::sync_trace(&input.session_id, None) {
if let Err(e) = env.session.sync_trace(&input.session_id, None) {
tracing::warn!(error = %e, "Failed to sync trace (PostToolUse)");
}

Expand Down Expand Up @@ -232,7 +211,8 @@ impl HookCmd {
input.session_id = fallback_session_id(self.agent);
info!(session_id = %input.session_id, "Agent did not provide session_id, using fallback");
}
crate::handlers::handle_session_start(&input, Some(self.agent))?
let env = crate::env::Env::prod();
crate::handlers::handle_session_start(&env, &input, Some(self.agent))?
}
HookSubcommand::Stop => {
let mut input = self
Expand All @@ -244,7 +224,8 @@ impl HookCmd {
}

// Final catch-up sync for non-tool conversation turns.
if let Err(e) = trace::sync_trace(&input.session_id, None) {
let env = crate::env::Env::prod();
if let Err(e) = env.session.sync_trace(&input.session_id, None) {
tracing::warn!(error = %e, "Failed to sync trace (Stop)");
}

Expand Down
2 changes: 2 additions & 0 deletions clash/src/cmd/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,8 @@ fn run_show(session: Option<String>, json: bool) -> Result<()> {
let stats = audit::read_session_stats(&session_id).ok();

// Sync trace so we pick up recent conversation entries.
// CLI command, not on the env-injected handler path.
#[allow(clippy::disallowed_methods)]
let _ = crate::trace::sync_trace(&session_id, None);
let last_message = crate::trace::last_user_message(&session_id);

Expand Down
6 changes: 5 additions & 1 deletion clash/src/cmd/trace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ fn run_export(session: Option<String>) -> Result<()> {
};

// Sync before export to pick up any new conversation entries.
crate::trace::sync_trace(&session_id, None).context("syncing trace before export")?;
// CLI command, not on the env-injected handler path.
#[allow(clippy::disallowed_methods)]
{
crate::trace::sync_trace(&session_id, None).context("syncing trace before export")?;
}

let doc = crate::trace::export_trace(&session_id)?;
let json = doc.to_json().context("serializing trace")?;
Expand Down
Loading
Loading