use crate::ai_agents::AiAgentPermissionMode; use crate::claude_cli::ChatStreamRequest; use crate::cli_agent_runtime::AgentStreamRequest; const CLAUDE_SAFE_AGENT_TOOLS: &str = "Read,Edit,MultiEdit,Write,Glob,Grep,LS"; const CLAUDE_POWER_USER_AGENT_TOOLS: &str = "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"; const CLAUDE_CHAT_DISALLOWED_TOOLS_COMPAT: &str = "Bash,Glob,Grep,Read,Edit,Write,NotebookEdit,WebFetch,WebSearch,TodoWrite,Task,MultiEdit,LS"; const CLAUDE_SAFE_DISALLOWED_TOOLS_COMPAT: &str = "Bash,NotebookEdit,WebFetch,WebSearch,TodoWrite,Task"; const CLAUDE_POWER_USER_DISALLOWED_TOOLS_COMPAT: &str = "NotebookEdit,WebFetch,WebSearch,TodoWrite,Task"; const WINDOWS_COMMAND_LINE_STDIN_THRESHOLD: usize = 24 * 1024; #[derive(Debug)] pub(crate) struct ClaudeInvocation { pub(crate) args: Vec, pub(crate) fallback_args: Vec>, pub(crate) stdin_text: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PromptSource { Argument, Stdin, } pub(crate) fn chat(req: &ChatStreamRequest) -> ClaudeInvocation { chat_with_windows_limit(req, cfg!(windows)) } pub(crate) fn agent(req: &AgentStreamRequest) -> Result { agent_with_windows_limit(req, cfg!(windows)) } fn chat_with_windows_limit( req: &ChatStreamRequest, enforce_windows_limit: bool, ) -> ClaudeInvocation { let args = chat_args(req); let fallback_args = vec![chat_args_compat(req)]; if should_pipe_prompt_for_windows(enforce_windows_limit, &args, &fallback_args) { return ClaudeInvocation { args: chat_args_with_tool_policy(req, "--tools", String::new(), PromptSource::Stdin), fallback_args: vec![chat_args_with_tool_policy( req, "--disallowedTools", CLAUDE_CHAT_DISALLOWED_TOOLS_COMPAT.into(), PromptSource::Stdin, )], stdin_text: Some(stdin_prompt(&req.message, req.system_prompt.as_deref())), }; } ClaudeInvocation { args, fallback_args, stdin_text: None, } } fn agent_with_windows_limit( req: &AgentStreamRequest, enforce_windows_limit: bool, ) -> Result { let args = agent_args(req)?; let fallback_args = vec![ agent_args_without_session_persistence(req)?, agent_args_compat(req)?, ]; if should_pipe_prompt_for_windows(enforce_windows_limit, &args, &fallback_args) { return Ok(ClaudeInvocation { args: agent_args_with_tool_policy( req, AgentToolPolicy::strict(req.permission_mode), PromptSource::Stdin, )?, fallback_args: vec![ agent_args_with_tool_policy( req, AgentToolPolicy::strict_without_session_persistence(req.permission_mode), PromptSource::Stdin, )?, agent_args_with_tool_policy( req, AgentToolPolicy::compat(req.permission_mode), PromptSource::Stdin, )?, ], stdin_text: Some(stdin_prompt(&req.message, req.system_prompt.as_deref())), }); } Ok(ClaudeInvocation { args, fallback_args, stdin_text: None, }) } fn chat_args(req: &ChatStreamRequest) -> Vec { chat_args_with_tool_policy(req, "--tools", String::new(), PromptSource::Argument) } fn chat_args_compat(req: &ChatStreamRequest) -> Vec { chat_args_with_tool_policy( req, "--disallowedTools", CLAUDE_CHAT_DISALLOWED_TOOLS_COMPAT.into(), PromptSource::Argument, ) } fn chat_args_with_tool_policy( req: &ChatStreamRequest, tool_flag: &str, tool_value: String, prompt_source: PromptSource, ) -> Vec { let mut args: Vec = vec!["-p".into()]; if prompt_source == PromptSource::Argument { args.push(req.message.clone()); } args.extend([ "--output-format".into(), "stream-json".into(), "--verbose".into(), "--include-partial-messages".into(), tool_flag.into(), tool_value, ]); if prompt_source == PromptSource::Argument { append_non_empty_arg_pair(&mut args, "--system-prompt", req.system_prompt.as_deref()); } if let Some(ref session_id) = req.session_id { args.push("--resume".into()); args.push(session_id.clone()); } args } fn agent_args(req: &AgentStreamRequest) -> Result, String> { agent_args_with_tool_policy( req, AgentToolPolicy::strict(req.permission_mode), PromptSource::Argument, ) } fn agent_args_without_session_persistence(req: &AgentStreamRequest) -> Result, String> { agent_args_with_tool_policy( req, AgentToolPolicy::strict_without_session_persistence(req.permission_mode), PromptSource::Argument, ) } fn agent_args_compat(req: &AgentStreamRequest) -> Result, String> { agent_args_with_tool_policy( req, AgentToolPolicy::compat(req.permission_mode), PromptSource::Argument, ) } fn agent_args_with_tool_policy( req: &AgentStreamRequest, policy: AgentToolPolicy, prompt_source: PromptSource, ) -> Result, String> { let mut args: Vec = vec!["-p".into()]; if prompt_source == PromptSource::Argument { args.push(req.message.clone()); } args.extend([ "--output-format".into(), "stream-json".into(), "--verbose".into(), "--include-partial-messages".into(), "--mcp-config".into(), mcp_config(&req.vault_path, &req.vault_paths)?, "--strict-mcp-config".into(), "--permission-mode".into(), "acceptEdits".into(), policy.tool_flag.into(), policy.tool_value.into(), ]); if policy.include_session_persistence_flag { args.push("--no-session-persistence".into()); } if let Some(allowed_tools) = policy.preapproved_tools { args.push("--allowedTools".into()); args.push(allowed_tools.into()); } if let Some(disallowed_tools) = policy.disallowed_tools { args.push("--disallowedTools".into()); args.push(disallowed_tools.into()); } if prompt_source == PromptSource::Argument { append_non_empty_arg_pair( &mut args, "--append-system-prompt", req.system_prompt.as_deref(), ); } Ok(args) } fn append_non_empty_arg_pair(args: &mut Vec, flag: &str, value: Option<&str>) { if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { args.push(flag.into()); args.push(value.into()); } } fn stdin_prompt(message: &str, system_prompt: Option<&str>) -> String { crate::cli_agent_runtime::build_prompt(message, system_prompt) } fn mcp_config(vault_path: &str, vault_paths: &[String]) -> Result { let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(vault_path, vault_paths); let config = serde_json::json!({ "mcpServers": { "tolaria": { "command": "node", "args": [mcp_server_path], "env": { "VAULT_PATH": vault_path, "VAULT_PATHS": vault_paths } } } }); serde_json::to_string(&config).map_err(|e| format!("Failed to serialise MCP config: {e}")) } fn should_pipe_prompt_for_windows( enforce_windows_limit: bool, args: &[String], fallback_args: &[Vec], ) -> bool { enforce_windows_limit && std::iter::once(args) .chain(fallback_args.iter().map(Vec::as_slice)) .any(args_exceed_windows_stdin_threshold) } fn args_exceed_windows_stdin_threshold(args: &[String]) -> bool { windows_command_line_utf16_units(args) >= WINDOWS_COMMAND_LINE_STDIN_THRESHOLD } fn windows_command_line_utf16_units(args: &[String]) -> usize { args.iter().map(|arg| arg.encode_utf16().count() + 3).sum() } struct AgentToolPolicy { tool_flag: &'static str, tool_value: &'static str, include_session_persistence_flag: bool, preapproved_tools: Option<&'static str>, disallowed_tools: Option<&'static str>, } impl AgentToolPolicy { fn strict(permission_mode: AiAgentPermissionMode) -> Self { Self { tool_flag: "--tools", tool_value: agent_tools(permission_mode), include_session_persistence_flag: true, preapproved_tools: preapproved_agent_tools(permission_mode), disallowed_tools: None, } } fn strict_without_session_persistence(permission_mode: AiAgentPermissionMode) -> Self { Self { include_session_persistence_flag: false, ..Self::strict(permission_mode) } } fn compat(permission_mode: AiAgentPermissionMode) -> Self { Self { tool_flag: "--allowedTools", tool_value: agent_tools(permission_mode), include_session_persistence_flag: false, preapproved_tools: None, disallowed_tools: Some(disallowed_agent_tools_compat(permission_mode)), } } } fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str { match permission_mode { AiAgentPermissionMode::Safe => CLAUDE_SAFE_AGENT_TOOLS, AiAgentPermissionMode::PowerUser => CLAUDE_POWER_USER_AGENT_TOOLS, } } fn preapproved_agent_tools(permission_mode: AiAgentPermissionMode) -> Option<&'static str> { match permission_mode { AiAgentPermissionMode::Safe => None, AiAgentPermissionMode::PowerUser => Some("Bash"), } } fn disallowed_agent_tools_compat(permission_mode: AiAgentPermissionMode) -> &'static str { match permission_mode { AiAgentPermissionMode::Safe => CLAUDE_SAFE_DISALLOWED_TOOLS_COMPAT, AiAgentPermissionMode::PowerUser => CLAUDE_POWER_USER_DISALLOWED_TOOLS_COMPAT, } } #[cfg(test)] mod tests { use super::*; macro_rules! assert_args_contain { ($args:expr, [$($value:expr),+ $(,)?] $(,)?) => { $( assert!($args.contains(&$value.to_string()), "missing {}", $value); )+ }; } macro_rules! assert_args_lack { ($args:expr, [$($value:expr),+ $(,)?] $(,)?) => { $( assert!(!$args.iter().any(|arg| arg == &$value.to_string()), "unexpected {}", $value); )+ }; } macro_rules! assert_no_arg_contains { ($args:expr, $fragment:expr $(,)?) => { assert!(!$args.iter().any(|arg| arg.contains($fragment))); }; } macro_rules! chat_request { ($message:expr, None, None $(,)?) => { ChatStreamRequest { message: $message.into(), system_prompt: None, session_id: None, } }; ($message:expr, Some($system_prompt:expr), None $(,)?) => { ChatStreamRequest { message: $message.into(), system_prompt: Some($system_prompt.to_string()), session_id: None, } }; ($message:expr, None, Some($session_id:expr) $(,)?) => { ChatStreamRequest { message: $message.into(), system_prompt: None, session_id: Some($session_id.to_string()), } }; ($message:expr, Some($system_prompt:expr), Some($session_id:expr) $(,)?) => { ChatStreamRequest { message: $message.into(), system_prompt: Some($system_prompt.to_string()), session_id: Some($session_id.to_string()), } }; } fn agent_request( message: &str, system_prompt: Option<&str>, permission_mode: AiAgentPermissionMode, ) -> AgentStreamRequest { AgentStreamRequest { message: message.into(), system_prompt: system_prompt.map(str::to_string), vault_path: "/tmp/vault".into(), vault_paths: Vec::new(), permission_mode, } } fn arg_value_after<'a>(args: &'a [String], name: &str) -> Option<&'a str> { let index = args.iter().position(|arg| arg == name)?; args.get(index + 1).map(String::as_str) } #[test] fn chat_args_basic() { let args = chat_args(&chat_request!("hello", None, None)); assert_args_contain!(args, ["-p", "hello", "stream-json"]); assert_args_lack!(args, ["--system-prompt", "--resume"]); } #[test] fn chat_args_with_system_prompt() { let args = chat_args(&chat_request!("hi", Some("You are helpful."), None)); assert_args_contain!(args, ["--system-prompt", "You are helpful."]); } #[test] fn chat_args_empty_system_prompt_is_skipped() { let args = chat_args(&chat_request!("hi", Some(""), None)); assert!(!args.contains(&"--system-prompt".to_string())); } #[test] fn chat_args_with_session_id() { let args = chat_args(&chat_request!("continue", None, Some("sess-abc"))); assert_args_contain!(args, ["--resume", "sess-abc"]); } #[test] fn chat_args_compat_disallows_builtin_tools_when_tools_flag_is_unavailable() { let args = chat_args_compat(&chat_request!("hello", None, None)); assert_args_contain!(args, ["--disallowedTools"]); assert_args_lack!(args, ["--tools"]); assert!(arg_value_after(&args, "--disallowedTools") .is_some_and(|tools| tools.contains("NotebookEdit"))); } #[test] fn oversized_windows_invocations_pipe_prompt_to_stdin() { let long_message = "x".repeat(WINDOWS_COMMAND_LINE_STDIN_THRESHOLD); let chat_req = chat_request!(&long_message, Some("Use context."), Some("sess-abc")); let chat_invocation = chat_with_windows_limit(&chat_req, true); assert_eq!(chat_invocation.args.first().map(String::as_str), Some("-p")); assert_args_lack!( chat_invocation.args, [long_message.as_str(), "--system-prompt", "Use context."] ); assert_args_contain!(chat_invocation.args, ["--resume", "sess-abc"]); assert!(chat_invocation .fallback_args .iter() .flatten() .all(|arg| arg != &long_message)); let chat_stdin = chat_invocation.stdin_text.as_deref().unwrap(); assert!(chat_stdin.contains("System instructions:\nUse context.")); assert!(chat_stdin.contains("User request:\n")); assert!(chat_stdin.contains(&long_message)); let agent_req = agent_request( &long_message, Some("Read the active vault first."), AiAgentPermissionMode::Safe, ); let agent_invocation = agent_with_windows_limit(&agent_req, true).unwrap(); assert_eq!( agent_invocation.args.first().map(String::as_str), Some("-p") ); assert_args_lack!( agent_invocation.args, [ long_message.as_str(), "--append-system-prompt", "Read the active vault first." ] ); assert_args_contain!( agent_invocation.args, ["--mcp-config", "--strict-mcp-config"] ); assert!(agent_invocation .fallback_args .iter() .flatten() .all(|arg| arg != &long_message)); let agent_stdin = agent_invocation.stdin_text.as_deref().unwrap(); assert!(agent_stdin.contains("System instructions:\nRead the active vault first.")); assert!(agent_stdin.contains("User request:\n")); assert!(agent_stdin.contains(&long_message)); } #[test] fn agent_args_with_system_prompt() { if let Ok(args) = agent_args(&agent_request( "do it", Some("Act as expert."), AiAgentPermissionMode::Safe, )) { assert_args_contain!(args, ["--append-system-prompt", "Act as expert."]); } } #[test] fn agent_args_empty_system_prompt_is_skipped() { if let Ok(args) = agent_args(&agent_request("x", Some(""), AiAgentPermissionMode::Safe)) { assert!(!args.contains(&"--append-system-prompt".to_string())); } } #[test] fn agent_args_without_session_persistence_keeps_tools_allowlist() { let args = agent_args_without_session_persistence(&agent_request( "Rename the note", None, AiAgentPermissionMode::Safe, )) .unwrap(); assert_args_contain!(args, ["--tools", "Read,Edit,MultiEdit,Write,Glob,Grep,LS"]); assert_args_lack!(args, ["--no-session-persistence"]); } #[test] fn agent_args_compat_uses_allowed_and_disallowed_tools_without_removed_flags() { let args = agent_args_compat(&agent_request( "Rename the note", None, AiAgentPermissionMode::Safe, )) .unwrap(); assert_eq!( arg_value_after(&args, "--allowedTools"), Some("Read,Edit,MultiEdit,Write,Glob,Grep,LS") ); assert_args_contain!(args, ["--disallowedTools"]); assert_args_lack!(args, ["--tools"]); assert_args_lack!(args, ["--no-session-persistence"]); } #[test] fn agent_args_use_safe_mode_without_bash_by_default() { let args = agent_args(&agent_request( "Rename the note", None, AiAgentPermissionMode::Safe, )) .unwrap(); assert_args_contain!( args, ["--strict-mcp-config", "--permission-mode", "acceptEdits"] ); assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]); assert_no_arg_contains!(args, "Bash"); assert_args_lack!(args, ["--allowedTools"]); assert_args_lack!(args, ["--dangerously-skip-permissions"]); } #[test] fn agent_args_allow_bash_in_power_user_mode_without_dangerous_bypass() { let args = agent_args(&agent_request( "Rename the note", None, AiAgentPermissionMode::PowerUser, )) .unwrap(); assert_args_contain!(args, ["--strict-mcp-config"]); assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"]); assert_args_lack!(args, ["--dangerously-skip-permissions"]); } #[test] fn agent_args_preapprove_bash_for_power_user_runs() { let args = agent_args(&agent_request( "Run a local script", None, AiAgentPermissionMode::PowerUser, )) .unwrap(); assert_eq!(arg_value_after(&args, "--allowedTools"), Some("Bash")); } #[test] fn agent_invocation_keeps_short_windows_prompt_on_args() { let req = agent_request( "summarize the inbox", Some("Read the active vault first."), AiAgentPermissionMode::Safe, ); let invocation = agent_with_windows_limit(&req, true).unwrap(); assert_args_contain!( invocation.args, ["-p", "summarize the inbox", "--append-system-prompt"] ); assert!(invocation.stdin_text.is_none()); } #[test] fn mcp_config_is_valid_json() { let extra_vaults = vec!["/tmp/secondary-vault".to_string()]; if let Ok(config_str) = mcp_config("/tmp/test-vault", &extra_vaults) { let parsed: serde_json::Value = serde_json::from_str(&config_str).unwrap(); assert!(parsed["mcpServers"]["tolaria"]["command"].is_string()); assert_eq!( parsed["mcpServers"]["tolaria"]["env"]["VAULT_PATH"], "/tmp/test-vault" ); assert_eq!( parsed["mcpServers"]["tolaria"]["env"]["VAULT_PATHS"], "[\"/tmp/test-vault\",\"/tmp/secondary-vault\"]" ); } } }