clash 0.5.4

Command Line Agent Safety Harness — permission policies for coding agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Pre-built hook handlers for Claude Code integration.
//!
//! These handlers wire together permission evaluation, notifications, and
//! session validation into ready-to-use functions that process Claude Code
//! hook events.

use tracing::{Level, info, instrument, warn};

use crate::hooks::{
    HookOutput, HookSpecificOutput, SessionStartHookInput, ToolUseHookInput, is_interactive_tool,
};
use crate::notifications;
use crate::permissions::check_permission;
use crate::settings::ClashSettings;

use claude_settings::PermissionRule;

/// Handle a permission request — decide whether to approve or deny on behalf of user.
///
/// When the policy evaluates to "ask" and a Zulip bot is configured, the request
/// is forwarded to Zulip and we poll for a human response. If no Zulip config is
/// present or the poll times out, we fall through to let the terminal user decide.
#[instrument(level = Level::TRACE, skip(input, settings))]
pub fn handle_permission_request(
    input: &ToolUseHookInput,
    settings: &ClashSettings,
) -> anyhow::Result<HookOutput> {
    // Interactive tools (AskUserQuestion, EnterPlanMode, ExitPlanMode) must be
    // handled by Claude Code's native UI. If the policy doesn't deny them,
    // pass through so the user sees the native prompt / plan review screen.
    if is_interactive_tool(&input.tool_name) {
        let pre_tool_result = check_permission(input, settings)?;
        let is_deny = matches!(
            pre_tool_result.hook_specific_output,
            Some(HookSpecificOutput::PreToolUse(ref pre))
                if matches!(pre.permission_decision, Some(PermissionRule::Deny))
        );
        if is_deny {
            let reason = match &pre_tool_result.hook_specific_output {
                Some(HookSpecificOutput::PreToolUse(pre)) => pre
                    .permission_decision_reason
                    .clone()
                    .unwrap_or_else(|| "denied by policy".into()),
                _ => "denied by policy".into(),
            };
            return Ok(HookOutput::deny_permission(reason, false));
        }
        info!(tool = %input.tool_name, "Passthrough: interactive tool deferred to Claude Code");
        return Ok(HookOutput::continue_execution());
    }

    let pre_tool_result = check_permission(input, settings)?;

    // Convert PreToolUse decision to PermissionRequest format.
    // Claude Code validates that hookEventName matches the event type.
    Ok(match pre_tool_result.hook_specific_output {
        Some(HookSpecificOutput::PreToolUse(ref pre)) => match pre.permission_decision {
            Some(PermissionRule::Allow) => HookOutput::approve_permission(None),
            Some(PermissionRule::Deny) => {
                let reason = pre
                    .permission_decision_reason
                    .clone()
                    .unwrap_or_else(|| "denied by policy".into());
                HookOutput::deny_permission(reason, false)
            }
            // Ask or no decision: try interactive desktop prompt first,
            // then fall through to Zulip / terminal.
            _ => resolve_via_desktop_or_zulip(input, settings),
        },
        _ => pre_tool_result,
    })
}

/// Build a human-readable summary of the permission request for notifications.
fn permission_summary(input: &ToolUseHookInput) -> String {
    match input.tool_name.as_str() {
        "Bash" => {
            let cmd = input.tool_input["command"].as_str().unwrap_or("(unknown)");
            format!("Bash: {}", cmd)
        }
        _ => input.tool_name.to_string(),
    }
}

/// Try to resolve a permission ask via desktop notification and/or Zulip.
#[instrument(level = Level::TRACE, skip(input, settings))]
pub fn resolve_via_desktop_or_zulip(
    input: &ToolUseHookInput,
    settings: &ClashSettings,
) -> HookOutput {
    let has_desktop = settings.notifications.desktop;
    let has_zulip = settings.notifications.zulip.is_some();

    if has_zulip && has_desktop {
        start_zulip_background(input, settings);
        return resolve_via_desktop_then_continue(input, settings);
    }

    if has_desktop {
        return resolve_via_desktop_then_continue(input, settings);
    }

    if has_zulip {
        return resolve_via_zulip_or_continue(input, settings);
    }

    HookOutput::continue_execution()
}

fn resolve_via_desktop_then_continue(
    input: &ToolUseHookInput,
    settings: &ClashSettings,
) -> HookOutput {
    let summary = permission_summary(input);
    let timeout = std::time::Duration::from_secs(settings.notifications.desktop_timeout_secs);
    let response = clash_notify::prompt("Clash: Permission Request", &summary, timeout);

    match response {
        clash_notify::PromptResponse::Approved => {
            info!("Permission approved via desktop notification");
            HookOutput::approve_permission(None)
        }
        clash_notify::PromptResponse::Denied => {
            info!("Permission denied via desktop notification");
            HookOutput::deny_permission("denied via desktop notification".into(), false)
        }
        clash_notify::PromptResponse::TimedOut => {
            info!("Desktop notification timed out, falling through to terminal");
            HookOutput::continue_execution()
        }
        clash_notify::PromptResponse::Unavailable => {
            info!("Interactive desktop notifications unavailable, falling through to terminal");
            HookOutput::continue_execution()
        }
    }
}

/// Build a [`notifications::PermissionRequest`] from a tool use hook input.
fn build_permission_request(input: &ToolUseHookInput) -> notifications::PermissionRequest {
    notifications::PermissionRequest {
        tool_name: input.tool_name.clone(),
        tool_input: input.tool_input.clone(),
        session_id: input.session_id.clone(),
        cwd: input.cwd.clone(),
    }
}

/// Map a Zulip resolution result to a [`HookOutput`].
///
/// Returns `Some(output)` for a definitive approve/deny, or `None` when the
/// resolution timed out or failed (caller should fall through to the next strategy).
fn zulip_result_to_output(
    result: anyhow::Result<Option<notifications::PermissionResponse>>,
) -> Option<HookOutput> {
    match result {
        Ok(Some(notifications::PermissionResponse::Approve)) => {
            Some(HookOutput::approve_permission(None))
        }
        Ok(Some(notifications::PermissionResponse::Deny(reason))) => {
            Some(HookOutput::deny_permission(reason, false))
        }
        Ok(None) => None,
        Err(_) => None,
    }
}

fn start_zulip_background(input: &ToolUseHookInput, settings: &ClashSettings) {
    let Some(ref zulip_config) = settings.notifications.zulip else {
        return;
    };

    let request = build_permission_request(input);
    let config = zulip_config.clone();

    std::thread::spawn(move || {
        let client = notifications::ZulipClient::new(&config);
        let result = client.resolve_permission(&request);
        match &result {
            Ok(Some(notifications::PermissionResponse::Approve)) => {
                info!("Permission approved via Zulip (background), exiting hook");
            }
            Ok(Some(notifications::PermissionResponse::Deny(_))) => {
                info!("Permission denied via Zulip (background), exiting hook");
            }
            Ok(None) => info!("Zulip resolution timed out (background)"),
            Err(e) => warn!(error = %e, "Zulip resolution failed (background)"),
        }
        if let Some(output) = zulip_result_to_output(result)
            && output.write_stdout().is_ok()
        {
            std::process::exit(0);
        }
    });
}

#[instrument(level = Level::TRACE, skip(input, settings))]
fn resolve_via_zulip_or_continue(input: &ToolUseHookInput, settings: &ClashSettings) -> HookOutput {
    let Some(ref zulip_config) = settings.notifications.zulip else {
        return HookOutput::continue_execution();
    };

    let client = notifications::ZulipClient::new(zulip_config);
    let result = client.resolve_permission(&build_permission_request(input));

    if result.is_err() || matches!(result, Ok(None)) {
        info!("Zulip resolution timed out or failed, falling through to terminal");
    }

    zulip_result_to_output(result).unwrap_or_else(HookOutput::continue_execution)
}

/// Handle a session start event — validate policy/settings and report status to Claude.
#[instrument(level = Level::TRACE, skip(input))]
pub fn handle_session_start(input: &SessionStartHookInput) -> anyhow::Result<HookOutput> {
    // Ensure the user has a policy file — create one with safe defaults if not.
    let created_policy = ClashSettings::ensure_user_policy_exists()?;

    let hook_ctx = crate::settings::HookContext::from_transcript_path(&input.transcript_path);
    let _settings =
        ClashSettings::load_or_create_with_session(Some(&input.session_id), Some(&hook_ctx))?;

    let mut lines = Vec::new();

    if let Some(path) = created_policy {
        lines.push(format!(
            "Welcome to Clash! A default policy has been created at {}. \
             It starts with deny-all and allows reading files in your project. \
             Run `clash status` to see what's allowed, or edit the policy file to customize.",
            path.display()
        ));
    }

    // Inject clash usage context so Claude understands how to use skills and policies.
    lines.push(clash_session_context().into());

    // Check if user is running without skip-permissions (default mode).
    let is_skip_permissions = input
        .permission_mode
        .as_deref()
        .is_some_and(|m| m == "dangerously-skip-permissions");

    if is_skip_permissions {
        lines.push(
            "NOTE: policy enforcement is DISABLED (--dangerously-skip-permissions). \
             Filesystem sandboxing is still active for exec rules."
                .into(),
        );
    } else {
        lines.push(
            "NOTE: Clash is managing permissions. For full enforcement, run with \
             --dangerously-skip-permissions so Clash is the sole decision-maker."
                .into(),
        );
    }

    check_sandbox_and_session(&mut lines, input);

    finish_session_start(lines)
}

/// Generate comprehensive context about clash for injection into Claude's session.
///
/// This text is returned as `additional_context` in the SessionStart hook response,
/// giving Claude the knowledge it needs to use clash skills and manage policies.
fn clash_session_context() -> &'static str {
    include_str!("../docs/session-context.md")
}

/// Check sandbox support, init session, and symlink — shared by both paths.
fn check_sandbox_and_session(lines: &mut Vec<String>, input: &SessionStartHookInput) {
    // 3. Check sandbox support
    let support = crate::sandbox::check_support();
    match support {
        crate::sandbox::SupportLevel::Full => {
            lines.push("sandbox: fully supported".into());
        }
        crate::sandbox::SupportLevel::Partial { ref missing } => {
            lines.push(format!(
                "sandbox: partial (missing: {})",
                missing.join(", ")
            ));
        }
        crate::sandbox::SupportLevel::Unsupported { ref reason } => {
            lines.push(format!("sandbox: unsupported ({})", reason));
        }
    }

    // 4. Initialize per-session history directory
    match crate::audit::init_session(
        &input.session_id,
        &input.cwd,
        input.source.as_deref(),
        input.model.as_deref(),
    ) {
        Ok(session_dir) => {
            lines.push(format!("session history: {}", session_dir.display()));
        }
        Err(e) => {
            warn!(error = %e, "Failed to create session history directory");
        }
    }

    // 4b. Write active session marker so CLI commands can find this session.
    if let Err(e) = ClashSettings::set_active_session(&input.session_id) {
        warn!(error = %e, "Failed to write active session marker");
    }

    // 4c. Initialize toolpath tracing for this session.
    if let Err(e) = crate::trace::init_trace(
        &input.session_id,
        &input.transcript_path,
        &input.cwd,
        input.model.as_deref(),
        input.source.as_deref(),
    ) {
        warn!(error = %e, "Failed to initialize session trace");
    }

    // 5. Session metadata
    if let Some(ref source) = input.source {
        lines.push(format!("session source: {}", source));
    }
    if let Some(ref model) = input.model {
        lines.push(format!("model: {}", model));
    }
}

fn finish_session_start(lines: Vec<String>) -> anyhow::Result<HookOutput> {
    info!(context = %lines.join("; "), "SessionStart validation");

    let context = if lines.is_empty() {
        None
    } else {
        Some(lines.join("\n"))
    };

    Ok(HookOutput::session_start(context))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn session_start_context(input: &SessionStartHookInput) -> String {
        let output = handle_session_start(input).expect("session start should succeed");
        match &output.hook_specific_output {
            Some(HookSpecificOutput::SessionStart(s)) => {
                s.additional_context.clone().expect("should have context")
            }
            _ => panic!("expected SessionStart output"),
        }
    }

    fn default_session_start_input() -> SessionStartHookInput {
        SessionStartHookInput {
            session_id: "test-session".into(),
            transcript_path: "/tmp/transcript.jsonl".into(),
            cwd: "/tmp".into(),
            permission_mode: Some("default".into()),
            hook_event_name: "SessionStart".into(),
            source: Some("startup".into()),
            model: Some("claude-sonnet-4-20250514".into()),
        }
    }

    #[test]
    fn test_session_start_reports_sandbox_support() {
        let ctx = session_start_context(&default_session_start_input());
        assert!(
            ctx.contains("sandbox:"),
            "should report sandbox status, got: {ctx}"
        );
    }

    #[test]
    fn test_session_start_reports_session_metadata() {
        let ctx = session_start_context(&default_session_start_input());
        assert!(ctx.contains("session source: startup"), "got: {ctx}");
        assert!(
            ctx.contains("model: claude-sonnet-4-20250514"),
            "got: {ctx}"
        );
    }

    #[test]
    fn test_session_start_recommends_skip_permissions_in_default_mode() {
        let ctx = session_start_context(&default_session_start_input());
        assert!(
            ctx.contains("--dangerously-skip-permissions"),
            "should recommend --dangerously-skip-permissions when not in skip mode, got: {ctx}"
        );
    }

    #[test]
    fn test_session_start_no_recommendation_when_skip_permissions() {
        let mut input = default_session_start_input();
        input.permission_mode = Some("dangerously-skip-permissions".into());
        let ctx = session_start_context(&input);
        assert!(
            !ctx.contains("NOTE: Clash is managing permissions"),
            "should NOT recommend when already in skip mode, got: {ctx}"
        );
    }

    #[test]
    fn test_session_start_injects_instructions_when_skip_permissions() {
        let mut input = default_session_start_input();
        input.permission_mode = Some("dangerously-skip-permissions".into());
        let ctx = session_start_context(&input);
        assert!(ctx.contains("policy enforcement is DISABLED"), "got: {ctx}");
        assert!(ctx.contains("Filesystem sandboxing"), "got: {ctx}");
    }
}