diff --git a/agent-support/opencode/git-ai.ts b/agent-support/opencode/git-ai.ts index 0ca9060d9..3c8a5df2b 100644 --- a/agent-support/opencode/git-ai.ts +++ b/agent-support/opencode/git-ai.ts @@ -23,8 +23,8 @@ import { dirname } from "path" // Absolute path to git-ai binary, replaced at install time by `git-ai install-hooks` const GIT_AI_BIN = "__GIT_AI_BINARY_PATH__" -// Tools that modify files and should be tracked -const FILE_EDIT_TOOLS = ["edit", "write"] +// Bash/shell tool names that need special checkpoint handling +const BASH_TOOLS = ["bash", "shell"] export const GitAiPlugin: Plugin = async (ctx) => { const { $ } = ctx @@ -44,7 +44,7 @@ export const GitAiPlugin: Plugin = async (ctx) => { // Track pending edits by callID so we can reference them in the after hook // Stores { filePath, repoDir, sessionID } for each pending edit - const pendingEdits = new Map() + const pendingEdits = new Map() // Helper to find git repo root from a file path const findGitRepo = async (filePath: string): Promise => { @@ -61,51 +61,58 @@ export const GitAiPlugin: Plugin = async (ctx) => { return { "tool.execute.before": async (input, output) => { - // Only intercept file editing tools - if (!FILE_EDIT_TOOLS.includes(input.tool)) { - return - } - // Extract file path from tool arguments (args are in output, not input) const filePath = output.args?.filePath as string | undefined - if (!filePath) { - return + + // For bash/shell tools, extract the command for blacklist evaluation + const isBashTool = BASH_TOOLS.includes(input.tool) + const bashCommand = isBashTool + ? (output.args?.command as string | undefined) ?? (output.args?.input as string | undefined) + : undefined + + // Determine the working directory + let repoDir: string | null = null + if (filePath) { + repoDir = await findGitRepo(filePath) + } else if (output.args?.cwd) { + repoDir = await findGitRepo(output.args.cwd as string) + } else { + // Try process cwd as fallback + try { + const result = await $`git rev-parse --show-toplevel`.quiet() + repoDir = result.stdout.toString().trim() || null + } catch { + // Not in a git repo + } } - // Find the git repo for this file - const repoDir = await findGitRepo(filePath) if (!repoDir) { - // File is not in a git repo, skip silently return } - // Store filePath, repoDir, and sessionID for the after hook - pendingEdits.set(input.callID, { filePath, repoDir, sessionID: input.sessionID }) + // Store info for the after hook + pendingEdits.set(input.callID, { filePath: filePath ?? "", repoDir, sessionID: input.sessionID, bashCommand }) try { - // Create human checkpoint before AI edit - // This marks any changes since the last checkpoint as human-authored const hookInput = JSON.stringify({ hook_event_name: "PreToolUse", + tool_name: input.tool, session_id: input.sessionID, cwd: repoDir, - tool_input: { filePath }, + tool_input: { + ...(filePath ? { filePath } : {}), + ...(bashCommand ? { command: bashCommand } : {}), + }, }) await $`echo ${hookInput} | ${GIT_AI_BIN} checkpoint opencode --hook-input stdin`.quiet() } catch (error) { - // Log to stderr for debugging, but don't throw - git-ai errors shouldn't break the agent console.error("[git-ai] Failed to create human checkpoint:", String(error)) } }, "tool.execute.after": async (input, _output) => { - // Only intercept file editing tools - if (!FILE_EDIT_TOOLS.includes(input.tool)) { - return - } - - // Get the filePath and repoDir we stored in the before hook + // Get the info we stored in the before hook const editInfo = pendingEdits.get(input.callID) pendingEdits.delete(input.callID) @@ -113,22 +120,22 @@ export const GitAiPlugin: Plugin = async (ctx) => { return } - const { filePath, repoDir, sessionID } = editInfo + const { filePath, repoDir, sessionID, bashCommand } = editInfo try { - // Create AI checkpoint after edit - // This marks the changes made by this tool call as AI-authored - // Transcript is fetched from OpenCode's local storage by the preset const hookInput = JSON.stringify({ hook_event_name: "PostToolUse", + tool_name: input.tool, session_id: sessionID, cwd: repoDir, - tool_input: { filePath }, + tool_input: { + ...(filePath ? { filePath } : {}), + ...(bashCommand ? { command: bashCommand } : {}), + }, }) await $`echo ${hookInput} | ${GIT_AI_BIN} checkpoint opencode --hook-input stdin`.quiet() } catch (error) { - // Log to stderr for debugging, but don't throw - git-ai errors shouldn't break the agent console.error("[git-ai] Failed to create AI checkpoint:", String(error)) } }, diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index eb54b0475..af12c307d 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -3,6 +3,9 @@ use crate::{ transcript::{AiTranscript, Message}, working_log::{AgentId, CheckpointKind}, }, + commands::checkpoint_agent::bash_checkpoint::{ + ToolClassification, classify_tool, evaluate_bash_command, extract_bash_command, + }, error::GitAiError, observability::log_error, }; @@ -62,6 +65,19 @@ impl AgentCheckpointPreset for ClaudePreset { )); } + // Checkpoint-time tool filtering: classify the tool and skip non-relevant tools + let tool_name = hook_data + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let tool_class = classify_tool("claude", tool_name); + if tool_class == ToolClassification::Skip { + return Err(GitAiError::PresetError(format!( + "Skipping checkpoint for non-file-edit, non-bash tool: {}", + tool_name + ))); + } + // Extract transcript_path and cwd from the JSON let transcript_path = hook_data .get("transcript_path") @@ -127,8 +143,47 @@ impl AgentCheckpointPreset for ClaudePreset { // Check if this is a PreToolUse event (human checkpoint) let hook_event_name = hook_data.get("hook_event_name").and_then(|v| v.as_str()); + let is_pre_tool = hook_event_name == Some("PreToolUse"); + + // Bash/shell tools: use shared bash checkpoint logic + if tool_class == ToolClassification::Bash { + let tool_input = hook_data.get("tool_input").cloned().unwrap_or_default(); + let command = extract_bash_command(&tool_input).unwrap_or_default(); + let bash_result = evaluate_bash_command(&command, is_pre_tool)?; - if hook_event_name == Some("PreToolUse") { + if !bash_result.should_checkpoint { + return Err(GitAiError::PresetError(format!( + "Bash command blacklisted, skipping checkpoint: {}", + command.chars().take(100).collect::() + ))); + } + + return Ok(AgentRunResult { + agent_id, + agent_metadata: if is_pre_tool { + None + } else { + Some(agent_metadata) + }, + checkpoint_kind: bash_result.checkpoint_kind, + transcript: if is_pre_tool { None } else { Some(transcript) }, + repo_working_dir: None, + edited_filepaths: if is_pre_tool { + None + } else { + bash_result.scoped_paths.clone() + }, + will_edit_filepaths: if is_pre_tool { + bash_result.scoped_paths + } else { + None + }, + dirty_files: None, + }); + } + + // Standard file-edit tool checkpoint logic + if is_pre_tool { // Early return for human checkpoint return Ok(AgentRunResult { agent_id, @@ -402,6 +457,19 @@ impl AgentCheckpointPreset for GeminiPreset { let hook_data: serde_json::Value = serde_json::from_str(&stdin_json) .map_err(|e| GitAiError::PresetError(format!("Invalid JSON in hook_input: {}", e)))?; + // Checkpoint-time tool filtering: classify the tool and skip non-relevant tools + let gemini_tool_name = hook_data + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let tool_class = classify_tool("gemini", gemini_tool_name); + if tool_class == ToolClassification::Skip { + return Err(GitAiError::PresetError(format!( + "Skipping checkpoint for non-file-edit, non-bash tool: {}", + gemini_tool_name + ))); + } + let session_id = hook_data .get("session_id") .and_then(|v| v.as_str()) @@ -459,10 +527,49 @@ impl AgentCheckpointPreset for GeminiPreset { let agent_metadata = HashMap::from([("transcript_path".to_string(), transcript_path.to_string())]); - // Check if this is a PreToolUse event (human checkpoint) + // Check if this is a BeforeTool event (human checkpoint) let hook_event_name = hook_data.get("hook_event_name").and_then(|v| v.as_str()); + let is_pre_tool = hook_event_name == Some("BeforeTool"); + + // Bash/shell tools: use shared bash checkpoint logic + if tool_class == ToolClassification::Bash { + let tool_input = hook_data.get("tool_input").cloned().unwrap_or_default(); + let command = extract_bash_command(&tool_input).unwrap_or_default(); + let bash_result = evaluate_bash_command(&command, is_pre_tool)?; + + if !bash_result.should_checkpoint { + return Err(GitAiError::PresetError(format!( + "Bash command blacklisted, skipping checkpoint: {}", + command.chars().take(100).collect::() + ))); + } + + return Ok(AgentRunResult { + agent_id, + agent_metadata: if is_pre_tool { + None + } else { + Some(agent_metadata) + }, + checkpoint_kind: bash_result.checkpoint_kind, + transcript: if is_pre_tool { None } else { Some(transcript) }, + repo_working_dir: None, + edited_filepaths: if is_pre_tool { + None + } else { + bash_result.scoped_paths.clone() + }, + will_edit_filepaths: if is_pre_tool { + bash_result.scoped_paths + } else { + None + }, + dirty_files: None, + }); + } - if hook_event_name == Some("BeforeTool") { + // Standard file-edit tool checkpoint logic + if is_pre_tool { // Early return for human checkpoint return Ok(AgentRunResult { agent_id, @@ -601,6 +708,19 @@ impl AgentCheckpointPreset for ContinueCliPreset { let hook_data: serde_json::Value = serde_json::from_str(&stdin_json) .map_err(|e| GitAiError::PresetError(format!("Invalid JSON in hook_input: {}", e)))?; + // Checkpoint-time tool filtering: classify the tool and skip non-relevant tools + let continue_tool_name = hook_data + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let tool_class = classify_tool("continue", continue_tool_name); + if tool_class == ToolClassification::Skip { + return Err(GitAiError::PresetError(format!( + "Skipping checkpoint for non-file-edit, non-bash tool: {}", + continue_tool_name + ))); + } + let session_id = hook_data .get("session_id") .and_then(|v| v.as_str()) @@ -669,8 +789,47 @@ impl AgentCheckpointPreset for ContinueCliPreset { // Check if this is a PreToolUse event (human checkpoint) let hook_event_name = hook_data.get("hook_event_name").and_then(|v| v.as_str()); + let is_pre_tool = hook_event_name == Some("PreToolUse"); + + // Bash/shell tools: use shared bash checkpoint logic + if tool_class == ToolClassification::Bash { + let tool_input = hook_data.get("tool_input").cloned().unwrap_or_default(); + let command = extract_bash_command(&tool_input).unwrap_or_default(); + let bash_result = evaluate_bash_command(&command, is_pre_tool)?; - if hook_event_name == Some("PreToolUse") { + if !bash_result.should_checkpoint { + return Err(GitAiError::PresetError(format!( + "Bash command blacklisted, skipping checkpoint: {}", + command.chars().take(100).collect::() + ))); + } + + return Ok(AgentRunResult { + agent_id, + agent_metadata: if is_pre_tool { + None + } else { + Some(agent_metadata) + }, + checkpoint_kind: bash_result.checkpoint_kind, + transcript: if is_pre_tool { None } else { Some(transcript) }, + repo_working_dir: None, + edited_filepaths: if is_pre_tool { + None + } else { + bash_result.scoped_paths.clone() + }, + will_edit_filepaths: if is_pre_tool { + bash_result.scoped_paths + } else { + None + }, + dirty_files: None, + }); + } + + // Standard file-edit tool checkpoint logic + if is_pre_tool { // Early return for human checkpoint return Ok(AgentRunResult { agent_id, @@ -2463,6 +2622,15 @@ impl AgentCheckpointPreset for DroidPreset { .and_then(|v| v.as_str()) .or_else(|| hook_data.get("toolName").and_then(|v| v.as_str())); + // Checkpoint-time tool filtering: classify the tool and skip non-relevant tools + let droid_tool_class = classify_tool("droid", tool_name.unwrap_or("")); + if droid_tool_class == ToolClassification::Skip { + return Err(GitAiError::PresetError(format!( + "Skipping checkpoint for non-file-edit, non-bash tool: {}", + tool_name.unwrap_or("") + ))); + } + // Extract file_path from tool_input if present let tool_input = hook_data .get("tool_input") @@ -2581,7 +2749,51 @@ impl AgentCheckpointPreset for DroidPreset { } // Check if this is a PreToolUse event (human checkpoint) - if hook_event_name == "PreToolUse" { + let is_pre_tool = hook_event_name == "PreToolUse"; + + // Bash/shell tools: use shared bash checkpoint logic + if droid_tool_class == ToolClassification::Bash { + let droid_tool_input = hook_data + .get("tool_input") + .or_else(|| hook_data.get("toolInput")) + .cloned() + .unwrap_or_default(); + let command = extract_bash_command(&droid_tool_input).unwrap_or_default(); + let bash_result = evaluate_bash_command(&command, is_pre_tool)?; + + if !bash_result.should_checkpoint { + return Err(GitAiError::PresetError(format!( + "Bash command blacklisted, skipping checkpoint: {}", + command.chars().take(100).collect::() + ))); + } + + return Ok(AgentRunResult { + agent_id, + agent_metadata: if is_pre_tool { + None + } else { + Some(agent_metadata) + }, + checkpoint_kind: bash_result.checkpoint_kind, + transcript: if is_pre_tool { None } else { Some(transcript) }, + repo_working_dir: Some(cwd.to_string()), + edited_filepaths: if is_pre_tool { + None + } else { + bash_result.scoped_paths.clone() + }, + will_edit_filepaths: if is_pre_tool { + bash_result.scoped_paths + } else { + None + }, + dirty_files: None, + }); + } + + // Standard file-edit tool checkpoint logic + if is_pre_tool { return Ok(AgentRunResult { agent_id, agent_metadata: None, diff --git a/src/commands/checkpoint_agent/amp_preset.rs b/src/commands/checkpoint_agent/amp_preset.rs index 933e9830b..f870d9d2d 100644 --- a/src/commands/checkpoint_agent/amp_preset.rs +++ b/src/commands/checkpoint_agent/amp_preset.rs @@ -3,8 +3,11 @@ use crate::{ transcript::{AiTranscript, Message}, working_log::{AgentId, CheckpointKind}, }, - commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult, + commands::checkpoint_agent::{ + agent_presets::{AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult}, + bash_checkpoint::{ + ToolClassification, classify_tool, evaluate_bash_command, extract_bash_command, + }, }, error::GitAiError, observability::log_error, @@ -22,6 +25,8 @@ struct AmpHookInput { #[serde(default)] tool_use_id: Option, #[serde(default)] + tool_name: Option, + #[serde(default)] thread_id: Option, #[serde(default)] transcript_path: Option, @@ -102,6 +107,16 @@ impl AgentCheckpointPreset for AmpPreset { let is_pre_tool_use = hook_input.hook_event_name == "PreToolUse"; + // Checkpoint-time tool filtering: classify the tool and skip non-relevant tools + let amp_tool_name = hook_input.tool_name.as_deref().unwrap_or(""); + let tool_class = classify_tool("amp", amp_tool_name); + if tool_class == ToolClassification::Skip { + return Err(GitAiError::PresetError(format!( + "Skipping checkpoint for non-file-edit, non-bash tool: {}", + amp_tool_name + ))); + } + let file_paths = Self::extract_file_paths(&hook_input); let resolved_thread_path = Self::resolve_thread_path( hook_input.transcript_path.as_deref(), @@ -141,6 +156,56 @@ impl AgentCheckpointPreset for AmpPreset { model: model.unwrap_or_else(|| "unknown".to_string()), }; + // Bash/shell tools: use shared bash checkpoint logic + if tool_class == ToolClassification::Bash { + let tool_input = hook_input.tool_input.clone().unwrap_or_default(); + let command = extract_bash_command(&tool_input).unwrap_or_default(); + let bash_result = evaluate_bash_command(&command, is_pre_tool_use)?; + + if !bash_result.should_checkpoint { + return Err(GitAiError::PresetError(format!( + "Bash command blacklisted, skipping checkpoint: {}", + command.chars().take(100).collect::() + ))); + } + + let mut bash_metadata = HashMap::new(); + if let Some(path) = resolved_thread_path.as_ref() { + bash_metadata.insert( + "transcript_path".to_string(), + path.to_string_lossy().to_string(), + ); + } + + return Ok(AgentRunResult { + agent_id, + agent_metadata: if is_pre_tool_use || bash_metadata.is_empty() { + None + } else { + Some(bash_metadata) + }, + checkpoint_kind: bash_result.checkpoint_kind, + transcript: if is_pre_tool_use { + None + } else { + Some(transcript) + }, + repo_working_dir: hook_input.cwd.clone(), + edited_filepaths: if is_pre_tool_use { + None + } else { + bash_result.scoped_paths.clone() + }, + will_edit_filepaths: if is_pre_tool_use { + bash_result.scoped_paths + } else { + None + }, + dirty_files: None, + }); + } + + // Standard file-edit tool checkpoint logic if is_pre_tool_use { return Ok(AgentRunResult { agent_id, diff --git a/src/commands/checkpoint_agent/bash_checkpoint.rs b/src/commands/checkpoint_agent/bash_checkpoint.rs new file mode 100644 index 000000000..8499144db --- /dev/null +++ b/src/commands/checkpoint_agent/bash_checkpoint.rs @@ -0,0 +1,1297 @@ +//! Shared bash/shell command checkpoint logic. +//! +//! This module provides a standard code path for determining whether a shell +//! command invocation should trigger a checkpoint and what scope that checkpoint +//! should have. It is used by all agent presets when they encounter a bash/shell +//! tool invocation. +//! +//! Design: +//! - Pre-command hooks produce a **Human** checkpoint (captures user changes). +//! - Post-command hooks produce an **AI** checkpoint (captures agent changes). +//! - A blacklist of read-only / non-file-modifying commands is evaluated to skip +//! checkpoints for commands that cannot change the working tree. +//! - An 800ms timeout kill-switch aborts the evaluation and logs to Sentry. + +use crate::authorship::working_log::CheckpointKind; +use crate::error::GitAiError; +use crate::observability::log_error; +use std::time::Instant; + +/// Maximum wall-clock time allowed for bash checkpoint evaluation. +/// If exceeded, we abort and log to Sentry. +const BASH_CHECKPOINT_TIMEOUT_MS: u128 = 800; + +/// Result of evaluating a bash command for checkpointing. +#[derive(Debug, Clone, PartialEq)] +pub struct BashCheckpointResult { + /// Whether a checkpoint should be created. + pub should_checkpoint: bool, + /// The kind of checkpoint (Human for pre-command, AiAgent for post-command). + pub checkpoint_kind: CheckpointKind, + /// Optional list of file paths that the command is likely to modify. + /// `None` means "unscoped" — the checkpoint should consider all dirty files. + pub scoped_paths: Option>, +} + +/// Evaluate whether a bash command should trigger a checkpoint. +/// +/// `command` is the raw shell command string from the hook payload. +/// `is_pre_command` indicates whether this is a pre-tool (true) or post-tool (false) hook. +/// +/// Returns `Ok(BashCheckpointResult)` with `should_checkpoint = false` if the +/// command is on the blacklist (read-only). Returns an error if the evaluation +/// times out (>800ms). +pub fn evaluate_bash_command( + command: &str, + is_pre_command: bool, +) -> Result { + let start = Instant::now(); + + let checkpoint_kind = if is_pre_command { + CheckpointKind::Human + } else { + CheckpointKind::AiAgent + }; + + // Fast path: empty command + if command.trim().is_empty() { + return Ok(BashCheckpointResult { + should_checkpoint: false, + checkpoint_kind, + scoped_paths: None, + }); + } + + // Check timeout before doing work + if start.elapsed().as_millis() > BASH_CHECKPOINT_TIMEOUT_MS { + return Err(timeout_error("bash_checkpoint_evaluate", command)); + } + + // Extract the base command (first word or pipeline components) + let trimmed = command.trim(); + + // Check if the command is on the blacklist + if is_blacklisted_command(trimmed) { + return Ok(BashCheckpointResult { + should_checkpoint: false, + checkpoint_kind, + scoped_paths: None, + }); + } + + // Check timeout again after blacklist evaluation + if start.elapsed().as_millis() > BASH_CHECKPOINT_TIMEOUT_MS { + return Err(timeout_error("bash_checkpoint_blacklist", command)); + } + + // Try to extract scoped file paths from common patterns + let scoped_paths = extract_scoped_paths(trimmed); + + Ok(BashCheckpointResult { + should_checkpoint: true, + checkpoint_kind, + scoped_paths, + }) +} + +/// Commands (or command prefixes) that are read-only and should never trigger +/// a checkpoint. We match against the first token of each pipeline segment. +const BLACKLISTED_COMMANDS: &[&str] = &[ + // Read-only file inspection + "cat", + "head", + "tail", + "less", + "more", + "wc", + "file", + "stat", + "du", + "df", + "md5sum", + "sha256sum", + "sha1sum", + "shasum", + "xxd", + "hexdump", + "strings", + "od", + // Navigation & listing + "ls", + "dir", + "pwd", + "cd", + "pushd", + "popd", + "tree", + "exa", + // Search + "grep", + "egrep", + "fgrep", + "rg", + "ag", + "fd", + "locate", + "which", + "whereis", + "type", + "whence", + // Environment / identity + "echo", + "printf", + "whoami", + "id", + "env", + "printenv", + "set", + "export", + "unset", + "alias", + "date", + "uname", + "hostname", + "uptime", + "free", + "top", + "htop", + "ps", + "pgrep", + "lsof", + // Diff / comparison (read-only) + "diff", + "cmp", + "comm", + "sort", + "uniq", + "cut", + "tr", + "jq", + "yq", + // Network inspection + "curl", + "ping", + "dig", + "nslookup", + "host", + "nc", + "netstat", + "ss", + // Help / manuals + "man", + "info", + "help", + // Build / test / run (typically don't modify tracked files) + "make", + "cargo", + "npm", + "npx", + "yarn", + "pnpm", + "node", + "python", + "python3", + "ruby", + "go", + "java", + "javac", + "mvn", + "gradle", + "dotnet", + "pytest", + "jest", + "mocha", + "vitest", + // Shell builtins + "true", + "false", + "test", + "[", + "exit", + "return", + "source", + ".", + "eval", + "exec", + "wait", + "sleep", + "time", + "nohup", + "nice", + "history", + // Process management + "kill", + "killall", + "pkill", + "bg", + "fg", + "jobs", + "disown", +]; + +/// Git sub-commands that are read-only. +const GIT_READONLY_SUBCOMMANDS: &[&str] = &[ + "status", + "log", + "diff", + "show", + "branch", + "remote", + "rev-parse", + "ls-files", + "ls-tree", + "cat-file", + "describe", + "tag", + "blame", + "shortlog", + "reflog", + "config", + "name-rev", + "rev-list", + "for-each-ref", + "count-objects", + "fsck", + "verify-commit", + "verify-tag", +]; + +/// Returns true if the command should NOT trigger a checkpoint. +fn is_blacklisted_command(command: &str) -> bool { + // Handle pipelines: check each segment + // If ALL segments are blacklisted, the whole pipeline is blacklisted + // If any segment could write files, we should checkpoint + let segments: Vec<&str> = split_pipeline(command); + + // For single commands or all-blacklisted pipelines, skip checkpoint + segments + .iter() + .all(|segment| is_single_command_blacklisted(segment.trim())) +} + +/// Check if a single command (not a pipeline) is blacklisted. +fn is_single_command_blacklisted(command: &str) -> bool { + let trimmed = command.trim(); + if trimmed.is_empty() { + return true; + } + + // Commands with output redirection can modify files even if the base command + // is read-only (e.g. `echo "foo" > bar.txt`, `cat template > output.txt`) + if has_output_redirection(trimmed) { + return false; + } + + // Skip leading env vars (FOO=bar cmd ...) and sudo + let effective = skip_env_prefixes(trimmed); + if effective.is_empty() { + return true; + } + + let first_token = first_word(effective); + + // Handle `git` specifically + if first_token == "git" { + return is_git_command_readonly(effective); + } + + // Check against the blacklist + BLACKLISTED_COMMANDS.contains(&first_token) +} + +/// Check if a command contains output redirection operators (`>` or `>>`) +/// outside of quotes. These indicate the command writes to a file. +fn has_output_redirection(command: &str) -> bool { + let mut in_single_quote = false; + let mut in_double_quote = false; + let chars = command.chars(); + let mut prev_char = None; + + for c in chars { + match c { + '\'' if !in_double_quote && prev_char != Some('\\') => { + in_single_quote = !in_single_quote; + } + '"' if !in_single_quote && prev_char != Some('\\') => { + in_double_quote = !in_double_quote; + } + '>' if !in_single_quote && !in_double_quote => { + // We consider any > or >> outside quotes as output redirection + return true; + } + _ => {} + } + prev_char = Some(c); + } + false +} + +/// Skip leading `VAR=value` assignments and `sudo`/`env` wrappers. +fn skip_env_prefixes(command: &str) -> &str { + let mut rest = command; + loop { + let trimmed = rest.trim_start(); + if trimmed.is_empty() { + return trimmed; + } + let token = first_word(trimmed); + if token == "sudo" || token == "env" { + rest = &trimmed[token.len()..]; + continue; + } + // Skip VAR=value patterns (e.g. FOO=bar, PATH=/usr/bin) + if token.contains('=') && !token.starts_with('=') && !token.starts_with('-') { + rest = &trimmed[token.len()..]; + continue; + } + return trimmed; + } +} + +/// Extract the first whitespace-delimited token. +fn first_word(s: &str) -> &str { + s.split_whitespace().next().unwrap_or("") +} + +/// Check if a `git ...` command is read-only. +fn is_git_command_readonly(command: &str) -> bool { + // Extract git subcommand: skip `git` and any flags like `git -C /path` + let parts: Vec<&str> = command.split_whitespace().collect(); + let mut i = 1; // skip "git" + + // Skip global git options that take an argument + while i < parts.len() { + let part = parts[i]; + if part.starts_with('-') { + // Options that take a following argument + if matches!(part, "-C" | "-c" | "--git-dir" | "--work-tree") { + i += 2; // skip the option and its argument + } else { + i += 1; // skip the option + } + } else { + break; + } + } + + if i >= parts.len() { + return true; // bare `git` with no subcommand + } + + let subcommand = parts[i]; + + GIT_READONLY_SUBCOMMANDS.contains(&subcommand) +} + +/// Split a command string on pipe operators (`|`), but not inside quotes. +fn split_pipeline(command: &str) -> Vec<&str> { + let mut segments = Vec::new(); + let mut start = 0; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut chars = command.char_indices().peekable(); + let mut prev_char = None; + + while let Some((i, c)) = chars.next() { + match c { + '\'' if !in_double_quote && prev_char != Some('\\') => { + in_single_quote = !in_single_quote; + } + '"' if !in_single_quote && prev_char != Some('\\') => { + in_double_quote = !in_double_quote; + } + '|' if !in_single_quote && !in_double_quote => { + // Check for || (logical OR) vs | (pipe) + if chars.peek().map(|(_, c)| *c) == Some('|') { + // This is || (logical OR) — treat as command separator + chars.next(); + segments.push(&command[start..i]); + start = i + 2; + } else { + segments.push(&command[start..i]); + start = i + 1; + } + } + ';' | '&' if !in_single_quote && !in_double_quote => { + // Command separator: treat as separate commands + // Handle && (logical AND) + if c == '&' && chars.peek().map(|(_, c)| *c) == Some('&') { + chars.next(); + } + segments.push(&command[start..i]); + start = if c == '&' && command[i..].starts_with("&&") { + i + 2 + } else { + i + 1 + }; + } + _ => {} + } + prev_char = Some(c); + } + + // Last segment + let last = &command[start..]; + if !last.trim().is_empty() { + segments.push(last); + } + + if segments.is_empty() { + segments.push(command); + } + + segments +} + +/// Try to extract file paths from common file-modifying command patterns. +/// Returns `None` for unscoped (unknown pattern). +fn extract_scoped_paths(command: &str) -> Option> { + let effective = skip_env_prefixes(command.trim()); + let first = first_word(effective); + + match first { + // sed -i ... + "sed" if effective.contains("-i") => extract_sed_targets(effective), + // mv + "mv" => extract_mv_targets(effective), + // cp + "cp" => extract_last_arg(effective), + // touch ... + "touch" => extract_non_flag_args(effective), + // rm ... + "rm" => extract_non_flag_args(effective), + // mkdir ... + "mkdir" => extract_non_flag_args(effective), + // patch + "patch" => extract_non_flag_args(effective), + // chmod / chown + "chmod" | "chown" => extract_non_flag_args_skip_first(effective), + _ => None, + } +} + +/// For `sed -i ... `, extract the file arguments. +fn extract_sed_targets(command: &str) -> Option> { + let parts: Vec<&str> = command.split_whitespace().collect(); + // Find files after the expression: sed -i[suffix] 'expr' file1 file2 ... + // or: sed -i 'expr' file1 file2 ... + let mut found_i_flag = false; + let mut skip_next = false; + let mut past_expression = false; + let mut files = Vec::new(); + + for part in parts.iter().skip(1) { + if skip_next { + skip_next = false; + continue; + } + if !found_i_flag { + if part.starts_with("-i") { + found_i_flag = true; + // -i'' or -i.bak is the suffix inline, -i followed by space might be followed by expr + continue; + } + if part.starts_with('-') { + if *part == "-e" || *part == "-f" { + skip_next = true; + } + continue; + } + } + if found_i_flag + && !past_expression + && !part.starts_with('-') + && !part.starts_with('/') + && files.is_empty() + { + // This is likely the expression, skip it + past_expression = true; + continue; + } + if !part.starts_with('-') { + files.push(part.to_string()); + } + } + + if files.is_empty() { None } else { Some(files) } +} + +/// For `mv src dst`, extract the destination. +fn extract_mv_targets(command: &str) -> Option> { + let args = non_flag_args(command); + // mv has src... dst — both src and dst are affected + if args.len() >= 2 { Some(args) } else { None } +} + +/// Extract the last non-flag argument (e.g. destination for `cp`). +fn extract_last_arg(command: &str) -> Option> { + let args = non_flag_args(command); + args.last().map(|a| vec![a.clone()]) +} + +/// Extract all non-flag arguments (skip command name). +fn extract_non_flag_args(command: &str) -> Option> { + let args = non_flag_args(command); + if args.is_empty() { None } else { Some(args) } +} + +/// Extract non-flag arguments, skipping the first non-flag arg after command +/// (e.g., for `chmod 755 file` — skip the mode). +fn extract_non_flag_args_skip_first(command: &str) -> Option> { + let args = non_flag_args(command); + if args.len() <= 1 { + None + } else { + Some(args[1..].to_vec()) + } +} + +/// Helper: collect non-flag arguments, skipping the command itself. +fn non_flag_args(command: &str) -> Vec { + command + .split_whitespace() + .skip(1) // skip command + .filter(|arg| !arg.starts_with('-')) + .map(|s| s.to_string()) + .collect() +} + +/// Create a timeout error and log it to Sentry. +fn timeout_error(operation: &str, command: &str) -> GitAiError { + let truncated_cmd = if command.len() > 200 { + format!("{}...", &command[..200]) + } else { + command.to_string() + }; + let err_msg = format!( + "Bash checkpoint timed out (>{}ms) during {}: {}", + BASH_CHECKPOINT_TIMEOUT_MS, operation, truncated_cmd + ); + let error = GitAiError::Generic(err_msg.clone()); + log_error( + &error, + Some(serde_json::json!({ + "operation": operation, + "timeout_ms": BASH_CHECKPOINT_TIMEOUT_MS, + "command_preview": truncated_cmd, + })), + ); + error +} + +/// Known bash/shell tool names for each agent. +/// Returns true if the given tool name is a bash/shell tool for the specified agent. +pub fn is_bash_tool(agent: &str, tool_name: &str) -> bool { + let lower = tool_name.to_ascii_lowercase(); + match agent { + "claude" => lower == "bash" || lower == "terminal", + "gemini" => lower == "shell" || lower == "run_shell_command", + "droid" => lower == "bash" || lower == "shell" || lower == "terminal", + "github-copilot" | "vscode" => { + lower == "runinterminal" + || lower == "terminal" + || lower == "run_in_terminal" + || lower == "runcommand" + } + "codex" => lower == "shell" || lower == "bash", + "amp" => lower == "bash" || lower == "shell" || lower == "terminal", + "opencode" => lower == "bash" || lower == "shell", + "cursor" => lower == "terminal" || lower == "runinterminal" || lower == "bash", + "continue" => lower == "terminal" || lower == "bash" || lower == "shell", + _ => { + // Generic fallback: common bash tool names + matches!( + lower.as_str(), + "bash" | "shell" | "terminal" | "runinterminal" | "run_in_terminal" | "runcommand" + ) + } + } +} + +/// Known file-editing tool names for each agent. +/// Returns true if the given tool name is a known file-editing tool for the specified agent. +pub fn is_file_edit_tool(agent: &str, tool_name: &str) -> bool { + let lower = tool_name.to_ascii_lowercase(); + + match agent { + "claude" => matches!(lower.as_str(), "write" | "edit" | "multiedit"), + "gemini" => matches!(lower.as_str(), "write_file" | "replace"), + "droid" => matches!(lower.as_str(), "edit" | "write" | "create" | "applypatch"), + "github-copilot" | "vscode" => is_supported_vscode_edit_tool_name(tool_name), + "codex" => { + // Codex uses `notify` for all events, filtering not needed at this level + matches!(lower.as_str(), "write" | "edit" | "patch" | "create") + } + "amp" => { + matches!( + lower.as_str(), + "write" | "edit" | "multiedit" | "create" | "applypatch" + ) + } + "opencode" => matches!(lower.as_str(), "edit" | "write"), + "cursor" => { + // Cursor uses beforeSubmitPrompt/afterFileEdit, so file editing is implicit + // But for completeness: + matches!(lower.as_str(), "edit" | "write" | "create") + } + "continue" => matches!(lower.as_str(), "edit" | "write" | "create"), + _ => { + // Generic fallback using the vscode heuristic + is_supported_vscode_edit_tool_name(tool_name) + } + } +} + +/// Ported from the existing `is_supported_vscode_edit_tool_name` function in agent_presets.rs. +/// Determines if a tool name corresponds to a file-editing operation. +fn is_supported_vscode_edit_tool_name(tool_name: &str) -> bool { + let lower = tool_name.to_ascii_lowercase(); + + // Quick reject: tools that are clearly read-only + let non_edit_keywords = [ + "find", "search", "read", "grep", "glob", "list", "ls", "fetch", "web", "open", "todo", + "terminal", "run", "execute", + ]; + if non_edit_keywords.iter().any(|kw| lower.contains(kw)) { + return false; + } + + // Exact matches for known edit tools + let exact_edit_tools = [ + "write", + "edit", + "multiedit", + "applypatch", + "copilot_insertedit", + "copilot_replacestring", + "vscode_editfile_internal", + "create_file", + "delete_file", + "rename_file", + "move_file", + "replace_string_in_file", + "insert_edit_into_file", + ]; + if exact_edit_tools.iter().any(|name| lower == *name) { + return true; + } + + // Partial matches + lower.contains("edit") || lower.contains("write") || lower.contains("replace") +} + +/// Determine the tool category for checkpoint filtering. +/// Returns one of: "file_edit", "bash", "skip" +pub fn classify_tool(agent: &str, tool_name: &str) -> ToolClassification { + if is_file_edit_tool(agent, tool_name) { + ToolClassification::FileEdit + } else if is_bash_tool(agent, tool_name) { + ToolClassification::Bash + } else { + ToolClassification::Skip + } +} + +/// Classification of a tool for checkpoint purposes. +#[derive(Debug, Clone, PartialEq)] +pub enum ToolClassification { + /// Known file-editing tool — use standard checkpoint logic. + FileEdit, + /// Bash/shell tool — use bash checkpoint logic with blacklist evaluation. + Bash, + /// Other tool (read-only, search, etc.) — skip checkpoint. + Skip, +} + +/// Extract the bash command string from a hook payload's tool_input. +/// Different agents encode the command differently; this handles common patterns. +pub fn extract_bash_command(tool_input: &serde_json::Value) -> Option { + // Try common field names for the command string + for key in [ + "command", + "cmd", + "input", + "script", + "code", + "content", + "shell_command", + ] { + if let Some(cmd) = tool_input.get(key).and_then(|v| v.as_str()) { + let trimmed = cmd.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + + // Some agents put the command as the top-level string value + if let Some(cmd) = tool_input.as_str() { + let trimmed = cmd.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + // ==================== Blacklist Tests ==================== + + #[test] + fn test_blacklisted_readonly_commands() { + let readonly_commands = vec![ + "ls -la", + "cat file.txt", + "grep -r pattern .", + "git status", + "git log --oneline", + "git diff HEAD~1", + "pwd", + "echo hello", + "whoami", + "env", + "date", + "uname -a", + "head -n 10 file.txt", + "tail -f log.txt", + "wc -l file.txt", + "which git", + "tree src/", + "rg pattern", + "fd '*.rs'", + "diff file1 file2", + "sort file.txt", + "curl https://example.com", + "python -c 'print(1)'", + "node -e 'console.log(1)'", + "cargo test", + "npm test", + "pytest tests/", + "make test", + ]; + + for cmd in readonly_commands { + let result = evaluate_bash_command(cmd, false).unwrap(); + assert!( + !result.should_checkpoint, + "Expected '{}' to be blacklisted (no checkpoint)", + cmd + ); + } + } + + #[test] + fn test_non_blacklisted_modifying_commands() { + let modifying_commands = vec![ + "sed -i 's/foo/bar/' file.txt", + "mv old.txt new.txt", + "cp src.txt dst.txt", + "rm -rf build/", + "mkdir -p new_dir", + "touch new_file.txt", + "git checkout feature-branch", + "git merge main", + "git reset HEAD~1", + "patch -p1 < fix.patch", + "chmod 755 script.sh", + ]; + + for cmd in modifying_commands { + let result = evaluate_bash_command(cmd, false).unwrap(); + assert!( + result.should_checkpoint, + "Expected '{}' to trigger a checkpoint", + cmd + ); + } + } + + #[test] + fn test_empty_command_no_checkpoint() { + let result = evaluate_bash_command("", false).unwrap(); + assert!(!result.should_checkpoint); + + let result = evaluate_bash_command(" ", true).unwrap(); + assert!(!result.should_checkpoint); + } + + #[test] + fn test_pre_command_is_human_checkpoint() { + let result = evaluate_bash_command("touch file.txt", true).unwrap(); + assert!(result.should_checkpoint); + assert_eq!(result.checkpoint_kind, CheckpointKind::Human); + } + + #[test] + fn test_post_command_is_ai_checkpoint() { + let result = evaluate_bash_command("touch file.txt", false).unwrap(); + assert!(result.should_checkpoint); + assert_eq!(result.checkpoint_kind, CheckpointKind::AiAgent); + } + + // ==================== Pipeline Tests ==================== + + #[test] + fn test_all_blacklisted_pipeline() { + // All segments read-only => no checkpoint + let result = evaluate_bash_command("cat file.txt | grep pattern | sort", false).unwrap(); + assert!(!result.should_checkpoint); + } + + #[test] + fn test_mixed_pipeline_triggers_checkpoint() { + // Pipe into tee (file-writing) => checkpoint + let result = evaluate_bash_command("echo hello | tee output.txt", false).unwrap(); + assert!(result.should_checkpoint); + } + + #[test] + fn test_chained_commands_with_semicolons() { + // All blacklisted + let result = evaluate_bash_command("cd /tmp; ls; pwd", false).unwrap(); + assert!(!result.should_checkpoint); + } + + #[test] + fn test_chained_commands_with_modifying() { + // Has a modifying command + let result = evaluate_bash_command("cd /tmp && touch file.txt && ls", false).unwrap(); + assert!(result.should_checkpoint); + } + + // ==================== Env Prefix Tests ==================== + + #[test] + fn test_env_var_prefix_skipped() { + let result = evaluate_bash_command("FOO=bar cat file.txt", false).unwrap(); + assert!(!result.should_checkpoint); + } + + #[test] + fn test_sudo_prefix_skipped() { + let result = evaluate_bash_command("sudo cat file.txt", false).unwrap(); + assert!(!result.should_checkpoint); + } + + #[test] + fn test_sudo_with_modifying_command() { + let result = evaluate_bash_command("sudo rm -rf /tmp/foo", false).unwrap(); + assert!(result.should_checkpoint); + } + + // ==================== Git Command Tests ==================== + + #[test] + fn test_git_readonly_subcommands() { + let readonly = vec![ + "git status", + "git log --oneline", + "git diff HEAD", + "git show HEAD:file.txt", + "git branch -a", + "git remote -v", + "git rev-parse HEAD", + "git ls-files", + "git blame file.txt", + "git -C /path/to/repo status", + ]; + + for cmd in readonly { + let result = evaluate_bash_command(cmd, false).unwrap(); + assert!( + !result.should_checkpoint, + "Expected git readonly '{}' to be blacklisted", + cmd + ); + } + } + + #[test] + fn test_git_modifying_subcommands() { + let modifying = vec![ + "git checkout feature", + "git merge main", + "git reset HEAD~1", + "git stash pop", + "git cherry-pick abc123", + "git rebase main", + "git commit -m 'test'", + "git add file.txt", + "git rm file.txt", + "git mv old.txt new.txt", + "git pull", + "git push", + ]; + + for cmd in modifying { + let result = evaluate_bash_command(cmd, false).unwrap(); + assert!( + result.should_checkpoint, + "Expected git modifying '{}' to trigger checkpoint", + cmd + ); + } + } + + // ==================== Scope Extraction Tests ==================== + + #[test] + fn test_sed_scope_extraction() { + let result = evaluate_bash_command("sed -i 's/foo/bar/' file.txt", false).unwrap(); + assert!(result.should_checkpoint); + // sed scope extraction is best-effort + if let Some(paths) = &result.scoped_paths { + assert!(paths.contains(&"file.txt".to_string())); + } + } + + #[test] + fn test_mv_scope_extraction() { + let result = evaluate_bash_command("mv old.txt new.txt", false).unwrap(); + assert!(result.should_checkpoint); + if let Some(paths) = &result.scoped_paths { + assert!(paths.contains(&"old.txt".to_string())); + assert!(paths.contains(&"new.txt".to_string())); + } + } + + #[test] + fn test_touch_scope_extraction() { + let result = evaluate_bash_command("touch file1.txt file2.txt", false).unwrap(); + assert!(result.should_checkpoint); + if let Some(paths) = &result.scoped_paths { + assert!(paths.contains(&"file1.txt".to_string())); + assert!(paths.contains(&"file2.txt".to_string())); + } + } + + #[test] + fn test_rm_scope_extraction() { + let result = evaluate_bash_command("rm file.txt", false).unwrap(); + assert!(result.should_checkpoint); + if let Some(paths) = &result.scoped_paths { + assert!(paths.contains(&"file.txt".to_string())); + } + } + + #[test] + fn test_unknown_command_unscoped() { + let result = evaluate_bash_command("custom-build-tool --output dist/", false).unwrap(); + assert!(result.should_checkpoint); + assert!(result.scoped_paths.is_none()); + } + + // ==================== Tool Classification Tests ==================== + + #[test] + fn test_classify_claude_tools() { + assert_eq!( + classify_tool("claude", "Write"), + ToolClassification::FileEdit + ); + assert_eq!( + classify_tool("claude", "Edit"), + ToolClassification::FileEdit + ); + assert_eq!( + classify_tool("claude", "MultiEdit"), + ToolClassification::FileEdit + ); + assert_eq!(classify_tool("claude", "Bash"), ToolClassification::Bash); + assert_eq!(classify_tool("claude", "Read"), ToolClassification::Skip); + assert_eq!(classify_tool("claude", "Search"), ToolClassification::Skip); + assert_eq!( + classify_tool("claude", "TodoRead"), + ToolClassification::Skip + ); + } + + #[test] + fn test_classify_gemini_tools() { + assert_eq!( + classify_tool("gemini", "write_file"), + ToolClassification::FileEdit + ); + assert_eq!( + classify_tool("gemini", "replace"), + ToolClassification::FileEdit + ); + assert_eq!(classify_tool("gemini", "shell"), ToolClassification::Bash); + assert_eq!( + classify_tool("gemini", "read_file"), + ToolClassification::Skip + ); + } + + #[test] + fn test_classify_droid_tools() { + assert_eq!(classify_tool("droid", "Edit"), ToolClassification::FileEdit); + assert_eq!( + classify_tool("droid", "Write"), + ToolClassification::FileEdit + ); + assert_eq!( + classify_tool("droid", "Create"), + ToolClassification::FileEdit + ); + assert_eq!( + classify_tool("droid", "ApplyPatch"), + ToolClassification::FileEdit + ); + assert_eq!(classify_tool("droid", "Bash"), ToolClassification::Bash); + assert_eq!(classify_tool("droid", "Shell"), ToolClassification::Bash); + } + + #[test] + fn test_classify_copilot_tools() { + assert_eq!( + classify_tool("github-copilot", "write"), + ToolClassification::FileEdit + ); + assert_eq!( + classify_tool("github-copilot", "edit"), + ToolClassification::FileEdit + ); + assert_eq!( + classify_tool("github-copilot", "runInTerminal"), + ToolClassification::Bash + ); + assert_eq!( + classify_tool("github-copilot", "search"), + ToolClassification::Skip + ); + } + + #[test] + fn test_classify_opencode_tools() { + assert_eq!( + classify_tool("opencode", "edit"), + ToolClassification::FileEdit + ); + assert_eq!( + classify_tool("opencode", "write"), + ToolClassification::FileEdit + ); + assert_eq!(classify_tool("opencode", "bash"), ToolClassification::Bash); + } + + #[test] + fn test_classify_amp_tools() { + assert_eq!(classify_tool("amp", "Write"), ToolClassification::FileEdit); + assert_eq!(classify_tool("amp", "Edit"), ToolClassification::FileEdit); + assert_eq!(classify_tool("amp", "Bash"), ToolClassification::Bash); + } + + // ==================== is_bash_tool Tests ==================== + + #[test] + fn test_is_bash_tool_various_agents() { + assert!(is_bash_tool("claude", "Bash")); + assert!(is_bash_tool("claude", "bash")); + assert!(!is_bash_tool("claude", "Write")); + + assert!(is_bash_tool("gemini", "shell")); + assert!(!is_bash_tool("gemini", "write_file")); + + assert!(is_bash_tool("github-copilot", "runInTerminal")); + assert!(is_bash_tool("github-copilot", "terminal")); + + assert!(is_bash_tool("amp", "Bash")); + assert!(is_bash_tool("opencode", "bash")); + assert!(is_bash_tool("opencode", "shell")); + } + + // ==================== is_file_edit_tool Tests ==================== + + #[test] + fn test_is_file_edit_tool_various_agents() { + assert!(is_file_edit_tool("claude", "Write")); + assert!(is_file_edit_tool("claude", "Edit")); + assert!(is_file_edit_tool("claude", "MultiEdit")); + assert!(!is_file_edit_tool("claude", "Bash")); + assert!(!is_file_edit_tool("claude", "Read")); + + assert!(is_file_edit_tool("gemini", "write_file")); + assert!(is_file_edit_tool("gemini", "replace")); + assert!(!is_file_edit_tool("gemini", "shell")); + + assert!(is_file_edit_tool("droid", "Edit")); + assert!(is_file_edit_tool("droid", "ApplyPatch")); + } + + // ==================== extract_bash_command Tests ==================== + + #[test] + fn test_extract_bash_command_from_various_formats() { + let input1 = serde_json::json!({"command": "ls -la"}); + assert_eq!(extract_bash_command(&input1), Some("ls -la".to_string())); + + let input2 = serde_json::json!({"cmd": "echo hello"}); + assert_eq!( + extract_bash_command(&input2), + Some("echo hello".to_string()) + ); + + let input3 = serde_json::json!({"input": "touch file.txt"}); + assert_eq!( + extract_bash_command(&input3), + Some("touch file.txt".to_string()) + ); + + let input4 = serde_json::json!("mkdir -p dir"); + assert_eq!( + extract_bash_command(&input4), + Some("mkdir -p dir".to_string()) + ); + + let input5 = serde_json::json!({"unrelated_field": "value"}); + assert_eq!(extract_bash_command(&input5), None); + + let input6 = serde_json::json!({"command": ""}); + assert_eq!(extract_bash_command(&input6), None); + } + + // ==================== Pipeline Splitting Tests ==================== + + #[test] + fn test_split_pipeline_simple() { + let segments = split_pipeline("cat file | grep pattern"); + assert_eq!(segments.len(), 2); + } + + #[test] + fn test_split_pipeline_with_logical_or() { + // || is a command separator (like && and ;), so it should split into 2 segments + let segments = split_pipeline("cmd1 || cmd2"); + assert_eq!(segments.len(), 2); + } + + #[test] + fn test_split_pipeline_with_logical_and() { + let segments = split_pipeline("cmd1 && cmd2"); + assert_eq!(segments.len(), 2); + } + + #[test] + fn test_split_pipeline_with_semicolons() { + let segments = split_pipeline("cmd1; cmd2; cmd3"); + assert_eq!(segments.len(), 3); + } + + #[test] + fn test_split_pipeline_quoted_pipe() { + // Pipe inside quotes should not split + let segments = split_pipeline("echo 'hello | world'"); + assert_eq!(segments.len(), 1); + } + + // ==================== Edge Cases ==================== + + #[test] + fn test_redirection_not_blacklisted() { + // Commands with redirection operators should trigger checkpoints + // even if the base command is blacklisted, because redirection writes files. + let result = evaluate_bash_command("custom-tool > output.txt", false).unwrap(); + assert!(result.should_checkpoint); + + // Blacklisted commands with output redirection should still checkpoint + let result = evaluate_bash_command("echo 'foo' > bar.txt", false).unwrap(); + assert!( + result.should_checkpoint, + "echo with > redirection should trigger checkpoint" + ); + + let result = evaluate_bash_command("cat template.txt > output.txt", false).unwrap(); + assert!( + result.should_checkpoint, + "cat with > redirection should trigger checkpoint" + ); + + let result = evaluate_bash_command("printf '%s' data >> log.txt", false).unwrap(); + assert!( + result.should_checkpoint, + "printf with >> redirection should trigger checkpoint" + ); + + // Redirection inside quotes should NOT trigger (it's just a string) + let result = evaluate_bash_command("echo 'hello > world'", false).unwrap(); + assert!( + !result.should_checkpoint, + "echo with > inside quotes should be blacklisted" + ); + } + + #[test] + fn test_awk_not_blacklisted() { + // awk can modify files (e.g. with -i inplace or output redirection) + // so it should NOT be blacklisted + let result = evaluate_bash_command("awk '{print $1}' file.txt", false).unwrap(); + assert!( + result.should_checkpoint, + "awk should trigger checkpoint since it can modify files" + ); + } + + #[test] + fn test_wget_not_blacklisted() { + // wget writes files to disk by default (unlike curl which defaults to stdout) + let result = evaluate_bash_command("wget https://example.com/setup.sh", false).unwrap(); + assert!( + result.should_checkpoint, + "wget should trigger checkpoint since it writes files to disk" + ); + } + + #[test] + fn test_xargs_not_blacklisted() { + // xargs executes arbitrary commands, so pipelines like + // `grep -rl pattern | xargs sed -i 's/old/new/'` must trigger checkpoints + let result = evaluate_bash_command("xargs rm", false).unwrap(); + assert!( + result.should_checkpoint, + "xargs should trigger checkpoint since it runs arbitrary commands" + ); + + // Pipeline where xargs is the modifying segment + let result = + evaluate_bash_command("grep -rl pattern | xargs sed -i 's/old/new/'", false).unwrap(); + assert!( + result.should_checkpoint, + "pipeline with xargs should trigger checkpoint" + ); + } + + #[test] + fn test_find_not_blacklisted() { + // find can modify files via -delete, -exec, etc. + let result = evaluate_bash_command("find . -name '*.bak' -delete", false).unwrap(); + assert!( + result.should_checkpoint, + "find should trigger checkpoint since it can delete/modify files" + ); + } + + #[test] + fn test_complex_command_not_blacklisted() { + let result = evaluate_bash_command("perl -i -pe 's/foo/bar/g' file.txt", false).unwrap(); + assert!(result.should_checkpoint); + } + + #[test] + fn test_npm_install_blacklisted() { + // npm (the base command) is blacklisted since most npm commands + // only modify node_modules which is in the default ignore list + let result = evaluate_bash_command("npm install express", false).unwrap(); + assert!(!result.should_checkpoint); + } + + #[test] + fn test_cargo_build_blacklisted() { + let result = evaluate_bash_command("cargo build", false).unwrap(); + assert!(!result.should_checkpoint); + } +} diff --git a/src/commands/checkpoint_agent/mod.rs b/src/commands/checkpoint_agent/mod.rs index f6ae812b4..6a0643352 100644 --- a/src/commands/checkpoint_agent/mod.rs +++ b/src/commands/checkpoint_agent/mod.rs @@ -1,4 +1,5 @@ pub mod agent_presets; pub mod agent_v1_preset; pub mod amp_preset; +pub mod bash_checkpoint; pub mod opencode_preset; diff --git a/src/commands/checkpoint_agent/opencode_preset.rs b/src/commands/checkpoint_agent/opencode_preset.rs index e84384aad..e0e559ba5 100644 --- a/src/commands/checkpoint_agent/opencode_preset.rs +++ b/src/commands/checkpoint_agent/opencode_preset.rs @@ -3,8 +3,11 @@ use crate::{ transcript::{AiTranscript, Message}, working_log::{AgentId, CheckpointKind}, }, - commands::checkpoint_agent::agent_presets::{ - AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult, + commands::checkpoint_agent::{ + agent_presets::{AgentCheckpointFlags, AgentCheckpointPreset, AgentRunResult}, + bash_checkpoint::{ + ToolClassification, classify_tool, evaluate_bash_command, extract_bash_command, + }, }, error::GitAiError, observability::log_error, @@ -23,13 +26,12 @@ struct OpenCodeHookInput { hook_event_name: String, session_id: String, cwd: String, - tool_input: Option, -} - -#[derive(Debug, Deserialize)] -struct ToolInput { - #[serde(rename = "filePath")] - file_path: Option, + #[serde(default)] + tool_name: Option, + /// Raw tool_input as a JSON value so we can both extract typed fields + /// (like filePath) and pass the full value to bash command extraction. + #[serde(default)] + tool_input: Option, } /// Message metadata from legacy file storage message/{session_id}/{msg_id}.json @@ -164,13 +166,26 @@ impl AgentCheckpointPreset for OpenCodePreset { hook_event_name, session_id, cwd, - tool_input, + tool_name: oc_tool_name_opt, + tool_input: tool_input_raw, } = hook_input; - // Extract file_path from tool_input if present - let file_path_as_vec = tool_input - .and_then(|ti| ti.file_path) - .map(|path| vec![path]); + // Checkpoint-time tool filtering: classify the tool and skip non-relevant tools + let oc_tool_name = oc_tool_name_opt.as_deref().unwrap_or(""); + let tool_class = classify_tool("opencode", oc_tool_name); + if tool_class == ToolClassification::Skip { + return Err(GitAiError::PresetError(format!( + "Skipping checkpoint for non-file-edit, non-bash tool: {}", + oc_tool_name + ))); + } + + // Extract file_path from tool_input if present (field name: "filePath") + let file_path_as_vec = tool_input_raw + .as_ref() + .and_then(|v| v.get("filePath")) + .and_then(|v| v.as_str()) + .map(|path| vec![path.to_string()]); // Determine OpenCode path (test override can point to either root or legacy storage path) let opencode_path = if let Ok(test_path) = std::env::var("GIT_AI_OPENCODE_STORAGE_PATH") { @@ -211,7 +226,47 @@ impl AgentCheckpointPreset for OpenCodePreset { } // Check if this is a PreToolUse event (human checkpoint) - if hook_event_name == "PreToolUse" { + let is_pre_tool = hook_event_name == "PreToolUse"; + + // Bash/shell tools: use shared bash checkpoint logic + if tool_class == ToolClassification::Bash { + let bash_input = tool_input_raw.clone().unwrap_or_default(); + let command = extract_bash_command(&bash_input).unwrap_or_default(); + let bash_result = evaluate_bash_command(&command, is_pre_tool)?; + + if !bash_result.should_checkpoint { + return Err(GitAiError::PresetError(format!( + "Bash command blacklisted, skipping checkpoint: {}", + command.chars().take(100).collect::() + ))); + } + + return Ok(AgentRunResult { + agent_id, + agent_metadata: if is_pre_tool { + None + } else { + Some(agent_metadata) + }, + checkpoint_kind: bash_result.checkpoint_kind, + transcript: if is_pre_tool { None } else { Some(transcript) }, + repo_working_dir: Some(cwd), + edited_filepaths: if is_pre_tool { + None + } else { + bash_result.scoped_paths.clone() + }, + will_edit_filepaths: if is_pre_tool { + bash_result.scoped_paths + } else { + None + }, + dirty_files: None, + }); + } + + // Standard file-edit tool checkpoint logic + if is_pre_tool { return Ok(AgentRunResult { agent_id, agent_metadata: None, diff --git a/src/mdm/agents/claude_code.rs b/src/mdm/agents/claude_code.rs index 001a4ba4e..3f8a4a883 100644 --- a/src/mdm/agents/claude_code.rs +++ b/src/mdm/agents/claude_code.rs @@ -129,13 +129,17 @@ impl HookInstaller for ClaudeCodeInstaller { let pre_tool_cmd = format!("{} {}", binary_path_str, CLAUDE_PRE_TOOL_CMD); let post_tool_cmd = format!("{} {}", binary_path_str, CLAUDE_POST_TOOL_CMD); + // Old matchers that should be migrated to the new catch-all matcher + let old_matchers: &[&str] = &["Write|Edit|MultiEdit"]; + let desired_matcher = ".*"; + let desired_hooks = json!({ "PreToolUse": { - "matcher": "Write|Edit|MultiEdit", + "matcher": desired_matcher, "desired_cmd": pre_tool_cmd, }, "PostToolUse": { - "matcher": "Write|Edit|MultiEdit", + "matcher": desired_matcher, "desired_cmd": post_tool_cmd, } }); @@ -146,7 +150,6 @@ impl HookInstaller for ClaudeCodeInstaller { // Process both PreToolUse and PostToolUse for hook_type in &["PreToolUse", "PostToolUse"] { - let desired_matcher = desired_hooks[hook_type]["matcher"].as_str().unwrap(); let desired_cmd = desired_hooks[hook_type]["desired_cmd"].as_str().unwrap(); // Get or create the hooks array for this type @@ -156,19 +159,32 @@ impl HookInstaller for ClaudeCodeInstaller { .cloned() .unwrap_or_default(); - // Find existing matcher block for Write|Edit|MultiEdit + // Find existing matcher block: check for the new ".*" matcher first, + // then fall back to any old matchers we need to migrate let mut found_matcher_idx: Option = None; for (idx, item) in hook_type_array.iter().enumerate() { - if let Some(matcher) = item.get("matcher").and_then(|m| m.as_str()) - && matcher == desired_matcher - { - found_matcher_idx = Some(idx); - break; + if let Some(matcher) = item.get("matcher").and_then(|m| m.as_str()) { + if matcher == desired_matcher { + found_matcher_idx = Some(idx); + break; + } + if old_matchers.contains(&matcher) && found_matcher_idx.is_none() { + found_matcher_idx = Some(idx); + } } } let matcher_idx = match found_matcher_idx { - Some(idx) => idx, + Some(idx) => { + // Migrate old matcher to new catch-all + if let Some(matcher_block) = hook_type_array[idx].as_object_mut() { + matcher_block.insert( + "matcher".to_string(), + Value::String(desired_matcher.to_string()), + ); + } + idx + } None => { // Create new matcher block hook_type_array.push(json!({ @@ -369,7 +385,7 @@ mod tests { "hooks": { "PreToolUse": [ { - "matcher": "Write|Edit|MultiEdit", + "matcher": ".*", "hooks": [ { "type": "command", @@ -380,7 +396,7 @@ mod tests { ], "PostToolUse": [ { - "matcher": "Write|Edit|MultiEdit", + "matcher": ".*", "hooks": [ { "type": "command", @@ -408,14 +424,8 @@ mod tests { assert_eq!(pre_tool.len(), 1); assert_eq!(post_tool.len(), 1); - assert_eq!( - pre_tool[0].get("matcher").unwrap().as_str().unwrap(), - "Write|Edit|MultiEdit" - ); - assert_eq!( - post_tool[0].get("matcher").unwrap().as_str().unwrap(), - "Write|Edit|MultiEdit" - ); + assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*"); + assert_eq!(post_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*"); } #[test] diff --git a/src/mdm/agents/droid.rs b/src/mdm/agents/droid.rs index 6439420b9..19b6ef187 100644 --- a/src/mdm/agents/droid.rs +++ b/src/mdm/agents/droid.rs @@ -103,13 +103,17 @@ impl HookInstaller for DroidInstaller { let pre_tool_cmd = format!("{} {}", binary_path, DROID_PRE_TOOL_CMD); let post_tool_cmd = format!("{} {}", binary_path, DROID_POST_TOOL_CMD); + // Old matchers that should be migrated to the new catch-all matcher + let old_matchers: &[&str] = &["^(Edit|Write|Create|ApplyPatch)$"]; + let desired_matcher = ".*"; + let desired_hooks = json!({ "PreToolUse": { - "matcher": "^(Edit|Write|Create|ApplyPatch)$", + "matcher": desired_matcher, "desired_cmd": pre_tool_cmd, }, "PostToolUse": { - "matcher": "^(Edit|Write|Create|ApplyPatch)$", + "matcher": desired_matcher, "desired_cmd": post_tool_cmd, } }); @@ -118,7 +122,6 @@ impl HookInstaller for DroidInstaller { let mut hooks_obj = merged.get("hooks").cloned().unwrap_or_else(|| json!({})); for hook_type in &["PreToolUse", "PostToolUse"] { - let desired_matcher = desired_hooks[hook_type]["matcher"].as_str().unwrap(); let desired_cmd = desired_hooks[hook_type]["desired_cmd"].as_str().unwrap(); let mut hook_type_array = hooks_obj @@ -127,18 +130,32 @@ impl HookInstaller for DroidInstaller { .cloned() .unwrap_or_default(); + // Find existing matcher block: check for the new ".*" matcher first, + // then fall back to any old matchers we need to migrate let mut found_matcher_idx: Option = None; for (idx, item) in hook_type_array.iter().enumerate() { - if let Some(matcher) = item.get("matcher").and_then(|m| m.as_str()) - && matcher == desired_matcher - { - found_matcher_idx = Some(idx); - break; + if let Some(matcher) = item.get("matcher").and_then(|m| m.as_str()) { + if matcher == desired_matcher { + found_matcher_idx = Some(idx); + break; + } + if old_matchers.contains(&matcher) && found_matcher_idx.is_none() { + found_matcher_idx = Some(idx); + } } } let matcher_idx = match found_matcher_idx { - Some(idx) => idx, + Some(idx) => { + // Migrate old matcher to new catch-all + if let Some(matcher_block) = hook_type_array[idx].as_object_mut() { + matcher_block.insert( + "matcher".to_string(), + Value::String(desired_matcher.to_string()), + ); + } + idx + } None => { hook_type_array.push(json!({ "matcher": desired_matcher, @@ -325,7 +342,7 @@ mod tests { "hooks": { "PreToolUse": [ { - "matcher": "^(Edit|Write|Create)$", + "matcher": ".*", "hooks": [ { "type": "command", @@ -336,7 +353,7 @@ mod tests { ], "PostToolUse": [ { - "matcher": "^(Edit|Write|Create)$", + "matcher": ".*", "hooks": [ { "type": "command", @@ -364,14 +381,8 @@ mod tests { assert_eq!(pre_tool.len(), 1); assert_eq!(post_tool.len(), 1); - assert_eq!( - pre_tool[0].get("matcher").unwrap().as_str().unwrap(), - "^(Edit|Write|Create)$" - ); - assert_eq!( - post_tool[0].get("matcher").unwrap().as_str().unwrap(), - "^(Edit|Write|Create)$" - ); + assert_eq!(pre_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*"); + assert_eq!(post_tool[0].get("matcher").unwrap().as_str().unwrap(), ".*"); } #[test] diff --git a/src/mdm/agents/gemini.rs b/src/mdm/agents/gemini.rs index 9a3c13fe0..d5f8c2818 100644 --- a/src/mdm/agents/gemini.rs +++ b/src/mdm/agents/gemini.rs @@ -115,13 +115,17 @@ impl HookInstaller for GeminiInstaller { ); let after_tool_cmd = format!("{} {}", params.binary_path.display(), GEMINI_AFTER_TOOL_CMD); + // Old matchers that should be migrated to the new catch-all matcher + let old_matchers: &[&str] = &["write_file|replace"]; + let desired_matcher = ".*"; + let desired_hooks = json!({ "BeforeTool": { - "matcher": "write_file|replace", + "matcher": desired_matcher, "desired_cmd": before_tool_cmd, }, "AfterTool": { - "matcher": "write_file|replace", + "matcher": desired_matcher, "desired_cmd": after_tool_cmd, } }); @@ -142,7 +146,6 @@ impl HookInstaller for GeminiInstaller { // Process both BeforeTool and AfterTool for hook_type in &["BeforeTool", "AfterTool"] { - let desired_matcher = desired_hooks[hook_type]["matcher"].as_str().unwrap(); let desired_cmd = desired_hooks[hook_type]["desired_cmd"].as_str().unwrap(); // Get or create the hooks array for this type @@ -152,19 +155,32 @@ impl HookInstaller for GeminiInstaller { .cloned() .unwrap_or_default(); - // Find existing matcher block for write_file|replace + // Find existing matcher block: check for the new ".*" matcher first, + // then fall back to any old matchers we need to migrate let mut found_matcher_idx: Option = None; for (idx, item) in hook_type_array.iter().enumerate() { - if let Some(matcher) = item.get("matcher").and_then(|m| m.as_str()) - && matcher == desired_matcher - { - found_matcher_idx = Some(idx); - break; + if let Some(matcher) = item.get("matcher").and_then(|m| m.as_str()) { + if matcher == desired_matcher { + found_matcher_idx = Some(idx); + break; + } + if old_matchers.contains(&matcher) && found_matcher_idx.is_none() { + found_matcher_idx = Some(idx); + } } } let matcher_idx = match found_matcher_idx { - Some(idx) => idx, + Some(idx) => { + // Migrate old matcher to new catch-all + if let Some(matcher_block) = hook_type_array[idx].as_object_mut() { + matcher_block.insert( + "matcher".to_string(), + Value::String(desired_matcher.to_string()), + ); + } + idx + } None => { hook_type_array.push(json!({ "matcher": desired_matcher, @@ -365,7 +381,7 @@ mod tests { "hooks": { "BeforeTool": [ { - "matcher": "write_file|replace", + "matcher": ".*", "hooks": [ { "type": "command", @@ -376,7 +392,7 @@ mod tests { ], "AfterTool": [ { - "matcher": "write_file|replace", + "matcher": ".*", "hooks": [ { "type": "command", @@ -412,11 +428,11 @@ mod tests { assert_eq!( before_tool[0].get("matcher").unwrap().as_str().unwrap(), - "write_file|replace" + ".*" ); assert_eq!( after_tool[0].get("matcher").unwrap().as_str().unwrap(), - "write_file|replace" + ".*" ); } diff --git a/src/mdm/agents/opencode.rs b/src/mdm/agents/opencode.rs index 500d2c270..98f032a3a 100644 --- a/src/mdm/agents/opencode.rs +++ b/src/mdm/agents/opencode.rs @@ -193,9 +193,9 @@ mod tests { assert!(content.contains("export const GitAiPlugin: Plugin")); assert!(content.contains("\"tool.execute.before\"")); assert!(content.contains("\"tool.execute.after\"")); - assert!(content.contains("FILE_EDIT_TOOLS")); - assert!(content.contains("edit")); - assert!(content.contains("write")); + assert!(content.contains("BASH_TOOLS")); + assert!(content.contains("tool_name")); + assert!(content.contains("tool_input")); // Template contains placeholder for binary path assert!(content.contains("__GIT_AI_BINARY_PATH__")); assert!(content.contains("hook_event_name")); diff --git a/tests/agent_presets_comprehensive.rs b/tests/agent_presets_comprehensive.rs index 5e86cf426..f701022a4 100644 --- a/tests/agent_presets_comprehensive.rs +++ b/tests/agent_presets_comprehensive.rs @@ -51,7 +51,8 @@ fn test_claude_preset_missing_transcript_path() { let preset = ClaudePreset; let hook_input = json!({ "cwd": "/some/path", - "hook_event_name": "PostToolUse" + "hook_event_name": "PostToolUse", + "tool_name": "Write" }) .to_string(); @@ -73,7 +74,8 @@ fn test_claude_preset_missing_cwd() { let preset = ClaudePreset; let hook_input = json!({ "transcript_path": "tests/fixtures/example-claude-code.jsonl", - "hook_event_name": "PostToolUse" + "hook_event_name": "PostToolUse", + "tool_name": "Write" }) .to_string(); @@ -97,6 +99,7 @@ fn test_claude_preset_pretooluse_checkpoint() { "cwd": "/some/path", "hook_event_name": "PreToolUse", "transcript_path": "tests/fixtures/example-claude-code.jsonl", + "tool_name": "Write", "tool_input": { "file_path": "/some/file.rs" } @@ -124,7 +127,8 @@ fn test_claude_preset_invalid_transcript_path() { let hook_input = json!({ "cwd": "/some/path", "hook_event_name": "PostToolUse", - "transcript_path": "/nonexistent/path/to/transcript.jsonl" + "transcript_path": "/nonexistent/path/to/transcript.jsonl", + "tool_name": "Write" }) .to_string(); @@ -281,7 +285,8 @@ fn test_gemini_preset_missing_session_id() { let preset = GeminiPreset; let hook_input = json!({ "transcript_path": "tests/fixtures/gemini-session-simple.json", - "cwd": "/path" + "cwd": "/path", + "tool_name": "write_file" }) .to_string(); @@ -303,7 +308,8 @@ fn test_gemini_preset_missing_transcript_path() { let preset = GeminiPreset; let hook_input = json!({ "session_id": "test-session", - "cwd": "/path" + "cwd": "/path", + "tool_name": "write_file" }) .to_string(); @@ -325,7 +331,8 @@ fn test_gemini_preset_missing_cwd() { let preset = GeminiPreset; let hook_input = json!({ "session_id": "test-session", - "transcript_path": "tests/fixtures/gemini-session-simple.json" + "transcript_path": "tests/fixtures/gemini-session-simple.json", + "tool_name": "write_file" }) .to_string(); @@ -350,6 +357,7 @@ fn test_gemini_preset_beforetool_checkpoint() { "transcript_path": "tests/fixtures/gemini-session-simple.json", "cwd": "/path", "hook_event_name": "BeforeTool", + "tool_name": "write_file", "tool_input": { "file_path": "/file.js" } @@ -454,7 +462,8 @@ fn test_continue_preset_missing_session_id() { let hook_input = json!({ "transcript_path": "tests/fixtures/continue-cli-session-simple.json", "cwd": "/path", - "model": "gpt-4" + "model": "gpt-4", + "tool_name": "edit" }) .to_string(); @@ -477,7 +486,8 @@ fn test_continue_preset_missing_transcript_path() { let hook_input = json!({ "session_id": "test-session", "cwd": "/path", - "model": "gpt-4" + "model": "gpt-4", + "tool_name": "edit" }) .to_string(); @@ -500,7 +510,8 @@ fn test_continue_preset_missing_model_defaults_to_unknown() { let hook_input = json!({ "session_id": "test-session", "transcript_path": "tests/fixtures/continue-cli-session-simple.json", - "cwd": "/path" + "cwd": "/path", + "tool_name": "edit" }) .to_string(); @@ -523,6 +534,7 @@ fn test_continue_preset_pretooluse_checkpoint() { "cwd": "/path", "model": "gpt-4", "hook_event_name": "PreToolUse", + "tool_name": "edit", "tool_input": { "file_path": "/file.py" } @@ -1062,6 +1074,7 @@ fn test_claude_preset_with_tool_input_no_file_path() { "cwd": "/path", "hook_event_name": "PostToolUse", "transcript_path": "tests/fixtures/example-claude-code.jsonl", + "tool_name": "Write", "tool_input": { "other_field": "value" } @@ -1084,6 +1097,7 @@ fn test_gemini_preset_with_tool_input_no_file_path() { "session_id": "test", "transcript_path": "tests/fixtures/gemini-session-simple.json", "cwd": "/path", + "tool_name": "write_file", "tool_input": { "other": "value" } @@ -1107,6 +1121,7 @@ fn test_continue_preset_with_tool_input_no_file_path() { "transcript_path": "tests/fixtures/continue-cli-session-simple.json", "cwd": "/path", "model": "gpt-4", + "tool_name": "edit", "tool_input": {} }) .to_string(); @@ -1127,6 +1142,7 @@ fn test_claude_preset_with_unicode_in_path() { "cwd": "/Users/测试/项目", "hook_event_name": "PostToolUse", "transcript_path": "tests/fixtures/example-claude-code.jsonl", + "tool_name": "Write", "tool_input": { "file_path": "/Users/测试/项目/文件.rs" } diff --git a/tests/amp.rs b/tests/amp.rs index ede287109..abb553fc1 100644 --- a/tests/amp.rs +++ b/tests/amp.rs @@ -91,6 +91,7 @@ fn test_amp_preset_pretooluse_returns_human_checkpoint() { let hook_input = json!({ "hook_event_name": "PreToolUse", "tool_use_id": AMP_SIMPLE_EDIT_TOOL_USE_ID, + "tool_name": "Write", "cwd": "/Users/test/project", "edited_filepaths": ["/Users/test/project/jokes.csv"], "tool_input": { @@ -134,6 +135,7 @@ fn test_amp_preset_posttooluse_returns_ai_checkpoint() { let hook_input = json!({ "hook_event_name": "PostToolUse", "tool_use_id": AMP_SIMPLE_EDIT_TOOL_USE_ID, + "tool_name": "Write", "cwd": "/Users/test/project", "edited_filepaths": ["/Users/test/project/jokes.csv"], "tool_input": { @@ -196,6 +198,7 @@ fn test_amp_e2e_checkpoint_and_commit() { let pre_hook_input = json!({ "hook_event_name": "PreToolUse", "tool_use_id": AMP_SIMPLE_EDIT_TOOL_USE_ID, + "tool_name": "Write", "cwd": repo_root.to_string_lossy().to_string(), "edited_filepaths": [file_path.to_string_lossy().to_string()], "tool_input": { @@ -211,6 +214,7 @@ fn test_amp_e2e_checkpoint_and_commit() { let post_hook_input = json!({ "hook_event_name": "PostToolUse", "tool_use_id": AMP_SIMPLE_EDIT_TOOL_USE_ID, + "tool_name": "Write", "cwd": repo_root.to_string_lossy().to_string(), "edited_filepaths": [file_path.to_string_lossy().to_string()], "tool_input": { @@ -277,6 +281,7 @@ fn test_amp_post_commit_resyncs_latest_thread_transcript() { let pre_hook_input = json!({ "hook_event_name": "PreToolUse", "tool_use_id": AMP_SIMPLE_EDIT_TOOL_USE_ID, + "tool_name": "Write", "cwd": repo_root.to_string_lossy().to_string(), "edited_filepaths": [file_path.to_string_lossy().to_string()] }) @@ -289,6 +294,7 @@ fn test_amp_post_commit_resyncs_latest_thread_transcript() { let post_hook_input = json!({ "hook_event_name": "PostToolUse", "tool_use_id": AMP_SIMPLE_EDIT_TOOL_USE_ID, + "tool_name": "Write", "cwd": repo_root.to_string_lossy().to_string(), "edited_filepaths": [file_path.to_string_lossy().to_string()] }) diff --git a/tests/claude_code.rs b/tests/claude_code.rs index 28d26899f..50855ab75 100644 --- a/tests/claude_code.rs +++ b/tests/claude_code.rs @@ -85,7 +85,7 @@ fn test_claude_preset_no_filepath_when_tool_input_missing() { "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "PostToolUse", "session_id": "23aad27c-175d-427f-ac5f-a6830b8e6e65", - "tool_name": "Read", + "tool_name": "Write", "transcript_path": "tests/fixtures/example-claude-code.jsonl" }"##; @@ -175,6 +175,7 @@ fn test_claude_preset_does_not_ignore_when_transcript_path_is_claude() { "hookEventName": "PostToolUse", "cwd": "/Users/test/project", "toolName": "copilot_replaceString", + "tool_name": "Write", "toolInput": { "file_path": "/Users/test/project/src/main.ts" }, @@ -223,6 +224,7 @@ fn test_claude_e2e_prefers_latest_checkpoint_for_prompts() { "cwd": repo_root.to_string_lossy().to_string(), "hook_event_name": "PostToolUse", "transcript_path": transcript_path.to_string_lossy().to_string(), + "tool_name": "Write", "tool_input": { "file_path": file_path.to_string_lossy().to_string() } diff --git a/tests/continue_cli.rs b/tests/continue_cli.rs index 2d9885182..ef181919f 100644 --- a/tests/continue_cli.rs +++ b/tests/continue_cli.rs @@ -222,6 +222,7 @@ fn test_continue_cli_preset_extracts_model_from_hook_input() { "hook_event_name": "PostToolUse", "session_id": "2dbfd673-096d-4773-b5f3-9023894a7355", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": "/Users/svarlamov/projects/testing-git/index.ts" }, @@ -247,6 +248,7 @@ fn test_continue_cli_preset_defaults_to_unknown_model() { "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "PostToolUse", "session_id": "2dbfd673-096d-4773-b5f3-9023894a7355", + "tool_name": "edit", "tool_input": { "file_path": "/Users/svarlamov/projects/testing-git/index.ts" }, @@ -271,6 +273,7 @@ fn test_continue_cli_preset_extracts_edited_filepath() { "hook_event_name": "PostToolUse", "session_id": "2dbfd673-096d-4773-b5f3-9023894a7355", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": "/Users/svarlamov/projects/testing-git/index.ts" }, @@ -301,6 +304,7 @@ fn test_continue_cli_preset_no_filepath_when_tool_input_missing() { "hook_event_name": "PostToolUse", "session_id": "2dbfd673-096d-4773-b5f3-9023894a7355", "model": "claude-3.5-sonnet", + "tool_name": "edit", "transcript_path": "tests/fixtures/continue-cli-session-simple.json" }); @@ -324,6 +328,7 @@ fn test_continue_cli_preset_human_checkpoint() { "hook_event_name": "PreToolUse", "session_id": "2dbfd673-096d-4773-b5f3-9023894a7355", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": "/Users/svarlamov/projects/testing-git/index.ts" }, @@ -369,6 +374,7 @@ fn test_continue_cli_preset_ai_checkpoint() { "hook_event_name": "PostToolUse", "session_id": "2dbfd673-096d-4773-b5f3-9023894a7355", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": "/Users/svarlamov/projects/testing-git/index.ts" }, @@ -406,6 +412,7 @@ fn test_continue_cli_preset_stores_transcript_path_in_metadata() { "hook_event_name": "PostToolUse", "session_id": "2dbfd673-096d-4773-b5f3-9023894a7355", "model": "claude-3.5-sonnet", + "tool_name": "edit", "transcript_path": "tests/fixtures/continue-cli-session-simple.json" }); @@ -431,7 +438,8 @@ fn test_continue_cli_preset_handles_missing_transcript_path() { "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "PostToolUse", "session_id": "2dbfd673-096d-4773-b5f3-9023894a7355", - "model": "claude-3.5-sonnet" + "model": "claude-3.5-sonnet", + "tool_name": "edit" }); let flags = AgentCheckpointFlags { @@ -472,6 +480,7 @@ fn test_continue_cli_preset_handles_missing_session_id() { "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "PostToolUse", "model": "claude-3.5-sonnet", + "tool_name": "edit", "transcript_path": "tests/fixtures/continue-cli-session-simple.json" }); @@ -499,6 +508,7 @@ fn test_continue_cli_preset_handles_missing_file() { "hook_event_name": "PostToolUse", "session_id": "2dbfd673-096d-4773-b5f3-9023894a7355", "model": "claude-3.5-sonnet", + "tool_name": "edit", "transcript_path": "tests/fixtures/nonexistent.json" }); @@ -549,6 +559,7 @@ fn test_continue_cli_e2e_with_attribution() { "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, @@ -632,6 +643,7 @@ fn test_continue_cli_e2e_human_checkpoint() { "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PreToolUse", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, @@ -691,6 +703,7 @@ fn test_continue_cli_e2e_multiple_tool_calls() { "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, @@ -737,6 +750,7 @@ fn test_continue_cli_e2e_preserves_model_on_commit() { "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", "model": "claude-opus-4", + "tool_name": "edit", "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, diff --git a/tests/continue_session.rs b/tests/continue_session.rs index 75cae6dcb..bc1b43449 100644 --- a/tests/continue_session.rs +++ b/tests/continue_session.rs @@ -50,6 +50,7 @@ fn create_ai_commit_with_transcript( "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, @@ -397,6 +398,7 @@ fn test_continue_max_messages_truncation() { "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, @@ -454,6 +456,7 @@ fn test_continue_truncation_notice() { "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, @@ -651,6 +654,7 @@ fn test_continue_redacts_secrets() { "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, @@ -734,6 +738,7 @@ fn test_continue_redacts_before_format() { "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, @@ -828,6 +833,7 @@ fn test_continue_unicode_content() { "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, @@ -890,6 +896,7 @@ fn test_continue_empty_transcript() { "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", "model": "claude-3.5-sonnet", + "tool_name": "edit", "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, diff --git a/tests/droid.rs b/tests/droid.rs index 7109dc521..f15e2ea74 100644 --- a/tests/droid.rs +++ b/tests/droid.rs @@ -178,7 +178,7 @@ fn test_droid_preset_stores_metadata_paths() { "cwd": "/Users/testuser/projects/testing-git", "hookEventName": "PostToolUse", "sessionId": "052cb8d0-4616-488a-99fe-bfbbbe9429b3", - "toolName": "Read", + "toolName": "ApplyPatch", "transcriptPath": jsonl_path.to_str().unwrap() }) .to_string(); @@ -218,7 +218,7 @@ fn test_droid_preset_uses_raw_session_id() { "cwd": "/Users/testuser/projects/testing-git", "hookEventName": "PostToolUse", "sessionId": session_uuid, - "toolName": "Read", + "toolName": "ApplyPatch", "transcriptPath": jsonl_path.to_str().unwrap() }) .to_string(); diff --git a/tests/gemini.rs b/tests/gemini.rs index 7ee00a8b4..6088a5aa2 100644 --- a/tests/gemini.rs +++ b/tests/gemini.rs @@ -283,6 +283,7 @@ fn test_gemini_preset_extracts_edited_filepath() { "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "AfterTool", "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "tool_input": { "file_path": "/Users/svarlamov/projects/testing-git/index.ts" }, @@ -312,6 +313,7 @@ fn test_gemini_preset_no_filepath_when_tool_input_missing() { "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "AfterTool", "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "transcript_path": "tests/fixtures/gemini-session-simple.json" }); @@ -334,6 +336,7 @@ fn test_gemini_preset_human_checkpoint() { "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "BeforeTool", "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "tool_input": { "file_path": "/Users/svarlamov/projects/testing-git/index.ts" }, @@ -378,6 +381,7 @@ fn test_gemini_preset_ai_checkpoint() { "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "AfterTool", "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "tool_input": { "file_path": "/Users/svarlamov/projects/testing-git/index.ts" }, @@ -414,6 +418,7 @@ fn test_gemini_preset_extracts_model() { "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "AfterTool", "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "transcript_path": "tests/fixtures/gemini-session-simple.json" }); @@ -436,6 +441,7 @@ fn test_gemini_preset_stores_transcript_path_in_metadata() { "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "AfterTool", "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "transcript_path": "tests/fixtures/gemini-session-simple.json" }); @@ -460,7 +466,8 @@ fn test_gemini_preset_handles_missing_transcript_path() { let hook_input = json!({ "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "AfterTool", - "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f" + "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file" }); let flags = AgentCheckpointFlags { @@ -500,6 +507,7 @@ fn test_gemini_preset_handles_missing_session_id() { let hook_input = json!({ "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "AfterTool", + "tool_name": "write_file", "transcript_path": "tests/fixtures/gemini-session-simple.json" }); @@ -526,6 +534,7 @@ fn test_gemini_preset_handles_missing_file() { "cwd": "/Users/svarlamov/projects/testing-git", "hook_event_name": "AfterTool", "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "transcript_path": "tests/fixtures/nonexistent.json" }); @@ -573,6 +582,7 @@ fn test_gemini_e2e_with_attribution() { // Run checkpoint with the Gemini session let hook_input = json!({ "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "AfterTool", "tool_input": { @@ -655,6 +665,7 @@ fn test_gemini_e2e_human_checkpoint() { // Human checkpoint before tool use let hook_input = json!({ "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "BeforeTool", "tool_input": { @@ -713,6 +724,7 @@ fn test_gemini_e2e_multiple_tool_calls() { // Run checkpoint let hook_input = json!({ "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "AfterTool", "tool_input": { @@ -791,6 +803,7 @@ fn test_gemini_e2e_with_resync() { // Run checkpoint with ORIGINAL session file let hook_input = json!({ "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "AfterTool", "tool_input": { @@ -871,6 +884,7 @@ fn test_gemini_e2e_partial_staging() { // Run checkpoint let hook_input = json!({ "session_id": "18f475c0-690f-4bc9-b84e-88a0a1e9518f", + "tool_name": "write_file", "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "AfterTool", "tool_input": { diff --git a/tests/internal_db_integration.rs b/tests/internal_db_integration.rs index fb74a23d6..5a5fdda83 100644 --- a/tests/internal_db_integration.rs +++ b/tests/internal_db_integration.rs @@ -88,6 +88,7 @@ fn test_checkpoint_saves_prompt_to_internal_db() { let hook_input = json!({ "cwd": repo_root.to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "Write", "transcript_path": transcript_path.to_string_lossy().to_string(), "tool_input": { "file_path": file_path.to_string_lossy().to_string() @@ -136,6 +137,7 @@ fn test_commit_updates_prompt_with_commit_sha_and_model() { let hook_input = json!({ "cwd": repo_root.to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "Write", "transcript_path": transcript_path.to_string_lossy().to_string(), "tool_input": { "file_path": file_path.to_string_lossy().to_string() @@ -196,6 +198,7 @@ fn test_post_commit_uses_latest_transcript_messages() { let hook_input = json!({ "cwd": repo_root.to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "Write", "transcript_path": transcript_path.to_string_lossy().to_string(), "tool_input": { "file_path": file_path.to_string_lossy().to_string() @@ -269,6 +272,7 @@ fn test_multiple_checkpoints_same_session_deduplicated() { let hook_input = json!({ "cwd": repo_root.to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "Write", "transcript_path": transcript_path.to_string_lossy().to_string(), "tool_input": { "file_path": file_path.to_string_lossy().to_string() @@ -316,6 +320,7 @@ fn test_different_sessions_create_separate_prompts() { let hook_input_1 = json!({ "cwd": repo_root.to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "Write", "transcript_path": transcript_path_1.to_string_lossy().to_string(), "tool_input": { "file_path": file_path.to_string_lossy().to_string() @@ -331,6 +336,7 @@ fn test_different_sessions_create_separate_prompts() { let hook_input_2 = json!({ "cwd": repo_root.to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "Write", "transcript_path": transcript_path_2.to_string_lossy().to_string(), "tool_input": { "file_path": file_path.to_string_lossy().to_string() @@ -379,6 +385,7 @@ fn test_line_stats_saved_to_db_after_commit() { let hook_input = json!({ "cwd": repo_root.to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "Write", "transcript_path": transcript_path.to_string_lossy().to_string(), "tool_input": { "file_path": file_path.to_string_lossy().to_string() @@ -439,6 +446,7 @@ fn test_human_author_saved_to_db_after_commit() { let hook_input = json!({ "cwd": repo_root.to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "Write", "transcript_path": transcript_path.to_string_lossy().to_string(), "tool_input": { "file_path": file_path.to_string_lossy().to_string() @@ -491,6 +499,7 @@ fn test_workdir_saved_to_db() { let hook_input = json!({ "cwd": repo_root.to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "Write", "transcript_path": transcript_path.to_string_lossy().to_string(), "tool_input": { "file_path": file_path.to_string_lossy().to_string() @@ -567,6 +576,7 @@ fn test_thinking_transcript_saves_to_internal_db_after_commit() { let hook_input = json!({ "cwd": repo_root.to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "Write", "transcript_path": transcript_path.to_string_lossy().to_string(), "tool_input": { "file_path": file_path.to_string_lossy().to_string() diff --git a/tests/opencode.rs b/tests/opencode.rs index 3eba99b57..31ceebd14 100644 --- a/tests/opencode.rs +++ b/tests/opencode.rs @@ -217,6 +217,7 @@ fn test_opencode_preset_pretooluse_returns_human_checkpoint() { let hook_input = json!({ "hook_event_name": "PreToolUse", "session_id": "test-session-123", + "tool_name": "edit", "cwd": "/Users/test/project", "tool_input": { "filePath": "/Users/test/project/index.ts" @@ -273,6 +274,7 @@ fn test_opencode_preset_posttooluse_returns_ai_checkpoint() { let hook_input = json!({ "hook_event_name": "PostToolUse", "session_id": "test-session-123", + "tool_name": "edit", "cwd": "/Users/test/project", "tool_input": { "filePath": "/Users/test/project/index.ts" @@ -336,6 +338,7 @@ fn test_opencode_preset_stores_session_id_in_metadata() { let hook_input = json!({ "hook_event_name": "PostToolUse", "session_id": "test-session-123", + "tool_name": "edit", "cwd": "/Users/test/project", "tool_input": { "filePath": "/Users/test/project/index.ts" @@ -379,6 +382,7 @@ fn test_opencode_preset_sets_repo_working_dir() { let hook_input = json!({ "hook_event_name": "PostToolUse", "session_id": "test-session-123", + "tool_name": "edit", "cwd": "/Users/test/my-project", "tool_input": { "filePath": "/Users/test/my-project/src/main.ts" @@ -517,6 +521,7 @@ fn test_opencode_e2e_checkpoint_and_commit() { let pre_hook_input = json!({ "hook_event_name": "PreToolUse", "session_id": "test-session-123", + "tool_name": "edit", "cwd": repo_root.to_string_lossy().to_string(), "tool_input": { "filePath": file_path.to_string_lossy().to_string() @@ -535,6 +540,7 @@ fn test_opencode_e2e_checkpoint_and_commit() { let post_hook_input = json!({ "hook_event_name": "PostToolUse", "session_id": "test-session-123", + "tool_name": "edit", "cwd": repo_root.to_string_lossy().to_string(), "tool_input": { "filePath": file_path.to_string_lossy().to_string() diff --git a/tests/search.rs b/tests/search.rs index 1f3c50adc..b2642fa83 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -41,6 +41,7 @@ fn create_ai_commit(repo: &TestRepo, transcript_fixture: &str) -> String { "session_id": "test-session-id-12345", "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "edit", "model": "claude-3.5-sonnet", "tool_input": { "file_path": file_path.to_string_lossy().to_string() @@ -87,6 +88,7 @@ fn create_ai_commit_with_file( "session_id": format!("session-{}", filename.replace("/", "-")), "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "edit", "model": "claude-3.5-sonnet", "tool_input": { "file_path": file_path.to_string_lossy().to_string() @@ -201,6 +203,7 @@ fn test_search_by_commit_range() { "session_id": "second-session", "cwd": repo.canonical_path().to_string_lossy().to_string(), "hook_event_name": "PostToolUse", + "tool_name": "edit", "model": "claude-3.5-sonnet", "tool_input": { "file_path": file_path.to_string_lossy().to_string()