diff --git a/crates/jcode-app-core/src/tool/apply_patch.rs b/crates/jcode-app-core/src/tool/apply_patch.rs index f5d4618f50..be36575e7d 100644 --- a/crates/jcode-app-core/src/tool/apply_patch.rs +++ b/crates/jcode-app-core/src/tool/apply_patch.rs @@ -93,6 +93,14 @@ impl Tool for ApplyPatchTool { match hunk { PatchHunk::AddFile { path, contents } => { let resolved = ctx.resolve_path(Path::new(path)); + // Verify-then-commit: confirm file creation before mkdir/write. + if let Some(refusal) = + super::edit_approval::refusal_text_for(&ctx, path, false, None, contents) + .await + { + results.push(format!("✗ {path}: {refusal}")); + continue; + } if let Some(parent) = resolved.parent() { tokio::fs::create_dir_all(parent).await?; } @@ -133,6 +141,20 @@ impl Tool for ApplyPatchTool { let old_contents = tokio::fs::read_to_string(&resolved) .await .unwrap_or_default(); + // Verify-then-commit: a delete is the biggest mutation of + // all; hold it for confirmation like any other edit. + if let Some(refusal) = super::edit_approval::refusal_text_for( + &ctx, + path, + true, + Some(old_contents.as_str()), + "", + ) + .await + { + results.push(format!("✗ {path}: {refusal}")); + continue; + } if tokio::fs::remove_file(&resolved).await.is_ok() { let diff = generate_diff_summary(&old_contents, ""); publish_file_touch( @@ -164,6 +186,23 @@ impl Tool for ApplyPatchTool { let diff = generate_diff_summary(&old_contents, &new_contents); if let Some(dest) = move_to { let dest_resolved = ctx.resolve_path(Path::new(dest)); + // Verify-then-commit: moving rewrites the + // destination (and removes the source); gate on + // the destination diff. + let dest_existed = dest_resolved.exists(); + let dest_old = tokio::fs::read_to_string(&dest_resolved).await.ok(); + if let Some(refusal) = super::edit_approval::refusal_text_for( + &ctx, + dest, + dest_existed, + dest_old.as_deref(), + new_contents.as_str(), + ) + .await + { + results.push(format!("✗ {dest}: {refusal}")); + continue; + } if let Some(parent) = dest_resolved.parent() { tokio::fs::create_dir_all(parent).await?; } @@ -204,6 +243,19 @@ impl Tool for ApplyPatchTool { )); } } else { + // Verify-then-commit: hold in-place patch updates. + if let Some(refusal) = super::edit_approval::refusal_text_for( + &ctx, + path, + true, + Some(old_contents.as_str()), + new_contents.as_str(), + ) + .await + { + results.push(format!("✗ {path}: {refusal}")); + continue; + } tokio::fs::write(&resolved, &new_contents).await?; publish_file_touch( &ctx, diff --git a/crates/jcode-app-core/src/tool/edit.rs b/crates/jcode-app-core/src/tool/edit.rs index f1444706c5..eb7ca0796b 100644 --- a/crates/jcode-app-core/src/tool/edit.rs +++ b/crates/jcode-app-core/src/tool/edit.rs @@ -109,6 +109,20 @@ impl Tool for EditTool { // Find line number where edit starts let start_line = find_line_number(&content, ¶ms.old_string); + // Verify-then-commit: hold non-trivial edits for user approval before + // the file is modified. + if let Some(refusal) = super::edit_approval::refusal_text_for( + &ctx, + ¶ms.file_path, + true, + Some(content.as_str()), + new_content.as_str(), + ) + .await + { + return Ok(ToolOutput::new(refusal)); + } + // Write back tokio::fs::write(&path, &new_content).await?; diff --git a/crates/jcode-app-core/src/tool/edit_approval.rs b/crates/jcode-app-core/src/tool/edit_approval.rs new file mode 100644 index 0000000000..543fb55168 --- /dev/null +++ b/crates/jcode-app-core/src/tool/edit_approval.rs @@ -0,0 +1,277 @@ +//! Verify-then-commit: explicit user confirmation before non-trivial file +//! mutations (improvement-report pick #1). +//! +//! The audit-era diff previews show what an edit did *after* it happened; +//! this gate asks *before* the write lands. The shape deliberately mirrors +//! the shipped #604 destructive-command gate: a small deterministic policy +//! seam that refuses or holds a mutation and returns text the model can act +//! on, instead of a side-channel UI contract. +//! +//! Flow when enabled (`[tools] verify_file_edits = true`): +//! 1. Trivial changes (≤ [`AUTO_ACCEPT_MAX_CHANGED_LINES`] changed lines) are +//! applied without asking — friction stays near zero. +//! 2. Anything larger sends a prompt with a diff excerpt through the tool +//! stdin channel (`ServerEvent::StdinRequest`) and waits for one line: +//! `y` approves, `n` rejects, `all` bulk-approves the rest of the session. +//! 3. No channel (headless runs), timeout, or an unrecognized reply blocks +//! the mutation with an actionable message. The model is told not to +//! retry unchanged so rejection always means the edit does not land. +//! +//! Defaults to off until the answer UX on the client side lands for every +//! surface; see docs/VERIFY_THEN_COMMIT.md. + +use jcode_tool_core::{StdinInputRequest, ToolContext}; +use similar::TextDiff; +use std::sync::{LazyLock, RwLock}; + +/// Changed lines (insertions + deletions) at or below this are applied +/// without asking. Small enough to keep honest refactors interactive, +/// large enough that a typo fix never prompts. +pub(crate) const AUTO_ACCEPT_MAX_CHANGED_LINES: usize = 3; + +/// How long to wait for the user's reply before blocking the mutation. +const ASK_TIMEOUT_SECS: u64 = 300; + +/// How many diff lines ride along in the prompt before truncation. +const PROMPT_DIFF_MAX_LINES: usize = 40; + +/// What a pre-write check tells the calling tool to do. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum MutationDecision { + /// Apply the mutation as usual. + Proceed, + /// Skip the mutation and return this text as the tool result. + Blocked(String), +} + +impl MutationDecision { + /// True when the write may proceed. + pub(crate) fn is_proceed(&self) -> bool { + matches!(self, MutationDecision::Proceed) + } +} + +/// Sessions where the user answered "all", covering every later edit this +/// session without asking again (the report's bulk-accept rule). +static BULK_ACCEPTED_SESSIONS: LazyLock>> = + LazyLock::new(|| RwLock::new(std::collections::HashSet::new())); + +fn session_bulk_accepted(session_id: &str) -> bool { + BULK_ACCEPTED_SESSIONS + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains(session_id) +} + +fn mark_session_bulk_accepted(session_id: &str) { + BULK_ACCEPTED_SESSIONS + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(session_id.to_string()); +} + +/// Count changed lines between old and new content (insertions + deletions). +/// +/// A deletion counts its removed lines and an insertion its added ones; a +/// modified line therefore costs two, matching how a human reads a diff. +pub(crate) fn changed_line_count(old: Option<&str>, new_content: &str) -> usize { + let old = old.unwrap_or(""); + TextDiff::from_lines(old, new_content) + .iter_all_changes() + .filter(|change| change.tag() != similar::ChangeTag::Equal) + .count() +} + +/// Classify a pending mutation against the auto-accept rules alone. +/// +/// Pure and config-free so tests and future callers (batch tools, MCP proxy +/// surfaces) can reuse the policy without touching channels. `enabled` is +/// the resolved `[tools] verify_file_edits` value. +pub(crate) fn classify( + enabled: bool, + bulk_accepted: bool, + changed_lines: usize, +) -> MutationDecision { + if !enabled || bulk_accepted || changed_lines <= AUTO_ACCEPT_MAX_CHANGED_LINES { + return MutationDecision::Proceed; + } + // Everything else needs the user; whether we can actually ask depends on + // having an interactive channel, decided by the async wrapper below. + MutationDecision::Blocked(String::new()) +} + +/// Build the prompt shown to the user for a held mutation. +pub(crate) fn build_approval_prompt( + display_path: &str, + existed: bool, + old: Option<&str>, + new_content: &str, +) -> String { + let mut prompt = String::new(); + let action = if existed { "Modify" } else { "Create" }; + prompt.push_str(&format!("VERIFY EDIT: {action} {display_path}\n")); + let diff = TextDiff::from_lines(old.unwrap_or(""), new_content); + let mut diff_lines = diff.iter_all_changes().map(|change| { + use similar::ChangeTag::*; + match change.tag() { + Delete => format!("- {}", change.value()), + Insert => format!("+ {}", change.value()), + Equal => format!(" {}", change.value()), + } + }); + for line in (&mut diff_lines).take(PROMPT_DIFF_MAX_LINES) { + prompt.push_str(line.trim_end_matches('\n')); + prompt.push('\n'); + } + if diff_lines.next().is_some() { + prompt.push_str("… (diff truncated)\n"); + } + prompt.push_str( + "Reply 'y' to apply, 'n' to reject, 'all' to approve remaining edits \ + for this session.", + ); + prompt +} + +/// Interpret the user's one-line reply. +enum Reply { + ApproveOnce, + ApproveAll, + Reject(String), +} + +fn classify_reply(raw: &str) -> Reply { + match raw.trim().to_ascii_lowercase().as_str() { + "y" | "yes" | "ok" | "approve" | "a" | "apply" => Reply::ApproveOnce, + "all" | "always" => Reply::ApproveAll, + "" | "n" | "no" | "deny" | "reject" | "esc" | "cancel" | "stop" => { + Reply::Reject("The user REJECTED this edit.".to_string()) + } + other => Reply::Reject(format!( + "Unrecognized confirmation reply '{other}', treated as rejection. \ + Ask the user to reply 'y' or 'n'." + )), + } +} + +/// Pre-write gate for a single file mutation. Call after the new content is +/// fully computed but before anything touches disk. +/// +/// Blocked results are meant to become the whole tool output so the model +/// understands the user declined rather than seeing an error trace. +pub(crate) async fn ensure_mutation_approved( + ctx: &ToolContext, + display_path: &str, + existed: bool, + old_content: Option<&str>, + new_content: &str, +) -> MutationDecision { + let enabled = crate::config::config().tools.verify_file_edits; + ensure_mutation_approved_with( + enabled, + std::time::Duration::from_secs(ASK_TIMEOUT_SECS), + ctx, + display_path, + existed, + old_content, + new_content, + ) + .await +} + +/// Same policy with an explicit flag so tests never depend on the developer +/// machine's real `~/.jcode/config.toml`. `ask_timeout` is likewise injected: +/// production passes [`ASK_TIMEOUT_SECS`], tests shrink it so the +/// timeout-fails-closed path takes milliseconds. +pub(crate) async fn ensure_mutation_approved_with( + enabled: bool, + ask_timeout: std::time::Duration, + ctx: &ToolContext, + display_path: &str, + existed: bool, + old_content: Option<&str>, + new_content: &str, +) -> MutationDecision { + let decision = classify( + enabled, + session_bulk_accepted(&ctx.session_id), + changed_line_count(old_content, new_content), + ); + // Config off or auto-accepted (classify returns Proceed for both). + if decision.is_proceed() { + return MutationDecision::Proceed; + } + + let Some(stdin_tx) = ctx.stdin_request_tx.clone() else { + return MutationDecision::Blocked(format!( + "Edit verification is enabled ([tools] verify_file_edits), but this \ + session has no interactive user attached, so {display_path} could \ + not be confirmed. Nothing was written." + )); + }; + + let prompt = build_approval_prompt(display_path, existed, old_content, new_content); + let request_id = format!("edit-approval-{}", ctx.tool_call_id); + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + if stdin_tx + .send(StdinInputRequest { + request_id, + prompt, + is_password: false, + response_tx, + }) + .is_err() + { + return MutationDecision::Blocked(format!( + "No user surface available to confirm {display_path}. Nothing was written." + )); + } + + let reply = tokio::time::timeout(ask_timeout, response_rx).await; + match reply { + Ok(Ok(answer)) => match classify_reply(&answer) { + Reply::ApproveOnce => MutationDecision::Proceed, + Reply::ApproveAll => { + mark_session_bulk_accepted(&ctx.session_id); + MutationDecision::Proceed + } + Reply::Reject(reason) => MutationDecision::Blocked(format!( + "{reason} No changes were made to {display_path}. Do not retry the same edit." + )), + }, + _ => MutationDecision::Blocked(format!( + "No approval arrived for {display_path} within {} seconds, \ + so the edit was dropped. Nothing was written.", + ask_timeout.as_secs() + )), + } +} + +/// Convenience for write-sites that need a refusal string instead of an enum. +/// Returns `Some(refusal_text)` when the mutation must not proceed. +pub(crate) async fn refusal_text_for( + ctx: &ToolContext, + display_path: &str, + existed: bool, + old_content: Option<&str>, + new_content: &str, +) -> Option { + match ensure_mutation_approved(ctx, display_path, existed, old_content, new_content).await { + MutationDecision::Proceed => None, + MutationDecision::Blocked(text) => Some(text), + } +} + +/// Forget bulk-accept state (tests only today; keeps maps from leaking +/// across restarts should future code call it at session teardown). +#[cfg(test)] +pub(crate) fn clear_bulk_accepted_for_tests() { + BULK_ACCEPTED_SESSIONS + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clear(); +} + +#[cfg(test)] +#[path = "edit_approval_tests.rs"] +mod tests; diff --git a/crates/jcode-app-core/src/tool/edit_approval_tests.rs b/crates/jcode-app-core/src/tool/edit_approval_tests.rs new file mode 100644 index 0000000000..2d9020c91e --- /dev/null +++ b/crates/jcode-app-core/src/tool/edit_approval_tests.rs @@ -0,0 +1,249 @@ +//! Tests for the verify-then-commit pre-write gate. +//! +//! Policy tests are pure; channel tests drive a real mpsc/oneshot pair the +//! same way `bash_tests` exercises stdin forwarding. + +use super::*; +use jcode_tool_core::ToolExecutionMode; +use std::time::Duration; +use tokio::sync::mpsc; + +/// Shrink the ask window so timeout paths run in milliseconds. Production +/// passes `Duration::from_secs(ASK_TIMEOUT_SECS)` instead. +const TEST_TIMEOUT: Duration = Duration::from_millis(200); + +/// The bulk-accept map is process-global, and cargo runs tests in parallel. +/// Channel tests that read or mutate it take this guard so one test's +/// `clear_bulk_accepted_for_tests()` cannot race another's assertions. +static BULK_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn bulk_guard() -> std::sync::MutexGuard<'static, ()> { + BULK_GUARD + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn test_ctx( + session_id: &str, +) -> ( + ToolContext, + Option>, +) { + let (tx, rx) = mpsc::unbounded_channel::(); + ( + ToolContext { + session_id: session_id.to_string(), + message_id: String::new(), + tool_call_id: "call-1".to_string(), + working_dir: None, + stdin_request_tx: Some(tx), + graceful_shutdown_signal: None, + execution_mode: ToolExecutionMode::AgentTurn, + }, + Some(rx), + ) +} + +const OLD: &str = "line one\nline two\nline three"; +const BIG_NEW: &str = "line one\nCHANGED two\nCHANGED three"; + +#[test] +fn changed_line_count_counts_both_sides_of_a_modification() { + // One line replaced = one deletion + one insertion. + assert_eq!(changed_line_count(Some(OLD), BIG_NEW), 4); +} + +#[test] +fn changed_line_counts_new_file_as_all_insertions() { + assert_eq!(changed_line_count(None, OLD), 3); + assert_eq!(changed_line_count(Some(""), OLD), 3); +} + +#[test] +fn identical_content_is_zero_changed_lines() { + assert_eq!(changed_line_count(Some(OLD), OLD), 0); +} + +#[test] +fn classification_auto_accepts_trivial_changes_when_enabled() { + assert!(classify(true, false, AUTO_ACCEPT_MAX_CHANGED_LINES).is_proceed()); + assert!(!classify(true, false, AUTO_ACCEPT_MAX_CHANGED_LINES + 1).is_proceed()); +} + +#[test] +fn classification_disabled_or_bulk_accepted_never_asks() { + assert!(classify(false, false, 5_000).is_proceed()); + assert!(classify(true, true, 5_000).is_proceed()); +} + +#[test] +fn prompt_shows_path_action_and_diff() { + let prompt = build_approval_prompt("src/lib.rs", true, Some(OLD), BIG_NEW); + assert!(prompt.contains("VERIFY EDIT: Modify src/lib.rs")); + assert!(prompt.contains("- line two")); + assert!(prompt.contains("+ CHANGED two")); + assert!(prompt.contains("'y'")); + let create = build_approval_prompt("docs/new.md", false, None, "# title"); + assert!(create.contains("VERIFY EDIT: Create docs/new.md")); + assert!(create.contains("+ # title")); +} + +#[test] +fn prompt_truncates_large_diffs() { + let big: String = std::iter::repeat_n("row\n", PROMPT_DIFF_MAX_LINES + 20).collect(); + let prompt = build_approval_prompt("x", false, None, &big); + assert!( + prompt.contains("(diff truncated)"), + "long diff must truncate" + ); + let small_prompt = build_approval_prompt("x", false, None, "a\n"); + assert!(!small_prompt.contains("(diff truncated)")); +} + +#[tokio::test] +async fn disabled_gate_proceeds_without_any_channel() { + let (mut ctx, _rx) = test_ctx("disabled"); + ctx.stdin_request_tx = None; + assert!( + ensure_mutation_approved_with(false, TEST_TIMEOUT, &ctx, "f.rs", true, Some(OLD), BIG_NEW) + .await + .is_proceed() + ); +} + +#[tokio::test] +async fn enabled_without_channel_blocks_instead_of_silent_write() { + let (mut ctx, _rx) = test_ctx("no-channel"); + ctx.stdin_request_tx = None; + match ensure_mutation_approved_with(true, TEST_TIMEOUT, &ctx, "f.rs", true, Some(OLD), BIG_NEW) + .await + { + MutationDecision::Blocked(text) => { + assert!(text.contains("verify_file_edits")); + assert!(text.contains("Nothing was written")); + } + MutationDecision::Proceed => panic!("enabled gate with no user surface must block"), + } +} + +#[tokio::test] +async fn approve_reply_lets_the_write_proceed() { + let _guard = bulk_guard(); + let (ctx, rx) = test_ctx("approve"); + let feeder = tokio::spawn(async move { + let req = rx.unwrap().recv().await.expect("request arrives"); + assert!(req.prompt.contains("VERIFY EDIT")); + req.response_tx.send("y".to_string()).unwrap(); + }); + let decision = + ensure_mutation_approved_with(true, TEST_TIMEOUT, &ctx, "f.rs", true, Some(OLD), BIG_NEW) + .await; + assert!(decision.is_proceed(), "{decision:?}"); + feeder.await.unwrap(); + clear_bulk_accepted_for_tests(); +} + +#[tokio::test] +async fn reject_reply_blocks_and_names_the_file() { + let _guard = bulk_guard(); + let (ctx, rx) = test_ctx("reject"); + let feeder = tokio::spawn(async move { + let req = rx.unwrap().recv().await.expect("request arrives"); + req.response_tx.send("n".to_string()).unwrap(); + }); + match ensure_mutation_approved_with(true, TEST_TIMEOUT, &ctx, "f.rs", true, Some(OLD), BIG_NEW) + .await + { + MutationDecision::Blocked(text) => { + assert!(text.contains("REJECTED")); + assert!(text.contains("f.rs")); + assert!(text.contains("Do not retry"), "model must be told to stop"); + } + MutationDecision::Proceed => panic!("rejection must block the write"), + } + feeder.await.unwrap(); + clear_bulk_accepted_for_tests(); +} + +#[tokio::test] +async fn dropped_responder_blocks_rather_than_writes() { + let _guard = bulk_guard(); + // The client vanished between ask and answer: the request is delivered, + // but its responder is dropped without a reply. + let (ctx, rx) = test_ctx("dropped"); + let feeder = tokio::spawn(async move { + let req = rx.unwrap().recv().await.expect("request arrives"); + drop(req); // drops response_tx without answering + }); + match ensure_mutation_approved_with(true, TEST_TIMEOUT, &ctx, "f.rs", true, Some(OLD), BIG_NEW) + .await + { + MutationDecision::Blocked(text) => assert!(text.contains("No approval arrived")), + MutationDecision::Proceed => panic!("dropped responder must not approve"), + } + feeder.await.unwrap(); + clear_bulk_accepted_for_tests(); +} + +#[tokio::test] +async fn bulk_accept_answer_covers_later_edits_in_the_session() { + let _guard = bulk_guard(); + let (first_ctx, first_rx) = test_ctx("bulk-session"); + let feeder = tokio::spawn(async move { + let req = first_rx.unwrap().recv().await.expect("first request"); + req.response_tx.send("all".to_string()).unwrap(); + }); + assert!( + ensure_mutation_approved_with( + true, + TEST_TIMEOUT, + &first_ctx, + "a.rs", + true, + Some(OLD), + BIG_NEW + ) + .await + .is_proceed() + ); + feeder.await.unwrap(); + + // Same session: subsequent big edits skip the prompt entirely. + let (second_ctx, second_rx) = test_ctx("bulk-session"); + let decision = ensure_mutation_approved_with( + true, + TEST_TIMEOUT, + &second_ctx, + "b.rs", + true, + Some(OLD), + BIG_NEW, + ) + .await; + assert!( + decision.is_proceed(), + "bulk accept must persist per session" + ); + // No request was sent for the second edit. + assert!( + second_rx.unwrap().try_recv().is_err(), + "bulk-accepted edits must not prompt again" + ); + + // A different session is still gated. + let (third_ctx, _third_rx) = test_ctx("other-session"); + assert!( + !ensure_mutation_approved_with( + true, + TEST_TIMEOUT, + &third_ctx, + "c.rs", + true, + Some(OLD), + BIG_NEW + ) + .await + .is_proceed() + ); + clear_bulk_accepted_for_tests(); +} diff --git a/crates/jcode-app-core/src/tool/mod.rs b/crates/jcode-app-core/src/tool/mod.rs index 5c39d4cdcd..81426c5f14 100644 --- a/crates/jcode-app-core/src/tool/mod.rs +++ b/crates/jcode-app-core/src/tool/mod.rs @@ -16,6 +16,7 @@ mod destructive_gate; mod discover; mod discover_secrets; mod edit; +pub(crate) mod edit_approval; mod feedback; mod gmail; mod goal; diff --git a/crates/jcode-app-core/src/tool/multiedit.rs b/crates/jcode-app-core/src/tool/multiedit.rs index f845791bcc..bab5f391c7 100644 --- a/crates/jcode-app-core/src/tool/multiedit.rs +++ b/crates/jcode-app-core/src/tool/multiedit.rs @@ -125,7 +125,20 @@ impl Tool for MultiEditTool { } } - // Write the result + // Write the result (verify-then-commit applies to whole-file rewrites) + if !applied.is_empty() { + if let Some(refusal) = super::edit_approval::refusal_text_for( + &ctx, + ¶ms.file_path, + true, + Some(original_content.as_str()), + content.as_str(), + ) + .await + { + return Ok(ToolOutput::new(refusal)); + } + } tokio::fs::write(&path, &content).await?; // Format output diff --git a/crates/jcode-app-core/src/tool/patch.rs b/crates/jcode-app-core/src/tool/patch.rs index 69ed9b617d..c2db91b4cb 100644 --- a/crates/jcode-app-core/src/tool/patch.rs +++ b/crates/jcode-app-core/src/tool/patch.rs @@ -74,7 +74,7 @@ impl Tool for PatchTool { for patch in patches { let resolved_path = ctx.resolve_path(Path::new(&patch.path)); - let result = apply_patch_with_diff(&patch, &resolved_path).await; + let result = apply_patch_with_diff(&ctx, &patch, &resolved_path).await; match result { Ok((msg, diff)) => { if diff.is_empty() { @@ -211,11 +211,26 @@ fn parse_hunk(lines: &[&str], i: &mut usize) -> Option { } /// Apply a patch and return (status_message, diff_output) -async fn apply_patch_with_diff(patch: &FilePatch, path: &Path) -> Result<(String, String)> { +async fn apply_patch_with_diff( + ctx: &ToolContext, + patch: &FilePatch, + path: &Path, +) -> Result<(String, String)> { // Handle deletion if patch.is_delete { if path.exists() { let old_content = tokio::fs::read_to_string(path).await.unwrap_or_default(); + if let Some(refusal) = super::edit_approval::refusal_text_for( + ctx, + &patch.path, + true, + Some(old_content.as_str()), + "", + ) + .await + { + anyhow::bail!("{refusal}"); + } tokio::fs::remove_file(path).await?; let diff = generate_diff(&old_content, "", 1); return Ok(("deleted".to_string(), diff)); @@ -230,11 +245,6 @@ async fn apply_patch_with_diff(patch: &FilePatch, path: &Path) -> Result<(String return Err(anyhow::anyhow!("file already exists")); } - // Create parent directories - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - // Collect all new lines from hunks let content: String = patch .hunks @@ -243,6 +253,19 @@ async fn apply_patch_with_diff(patch: &FilePatch, path: &Path) -> Result<(String .map(|l| format!("{}\n", l)) .collect(); + // Verify-then-commit: confirm the creation before directories are made. + if let Some(refusal) = + super::edit_approval::refusal_text_for(ctx, &patch.path, false, None, content.as_str()) + .await + { + anyhow::bail!("{refusal}"); + } + + // Create parent directories + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, &content).await?; let diff = generate_diff("", &content, 1); return Ok(("created".to_string(), diff)); @@ -269,6 +292,18 @@ async fn apply_patch_with_diff(patch: &FilePatch, path: &Path) -> Result<(String } let new_content = lines.join("\n") + "\n"; + // Verify-then-commit: hold the computed modification for user approval. + if let Some(refusal) = super::edit_approval::refusal_text_for( + ctx, + &patch.path, + true, + Some(old_content.as_str()), + new_content.as_str(), + ) + .await + { + anyhow::bail!("{refusal}"); + } tokio::fs::write(path, &new_content).await?; let diff = generate_diff(&old_content, &new_content, first_line); diff --git a/crates/jcode-app-core/src/tool/write.rs b/crates/jcode-app-core/src/tool/write.rs index c58395eefb..f18eaf5ba2 100644 --- a/crates/jcode-app-core/src/tool/write.rs +++ b/crates/jcode-app-core/src/tool/write.rs @@ -59,13 +59,6 @@ impl Tool for WriteTool { let path = ctx.resolve_path(Path::new(¶ms.file_path)); - // Create parent directories if needed - if let Some(parent) = path.parent() - && !parent.exists() - { - tokio::fs::create_dir_all(parent).await?; - } - // Check if file existed before and read old content for diff let existed = path.exists(); let old_content = if existed { @@ -74,6 +67,27 @@ impl Tool for WriteTool { None }; + // Verify-then-commit: hold non-trivial writes for user approval + // before anything (including parent directories) touches disk. + if let Some(refusal) = super::edit_approval::refusal_text_for( + &ctx, + ¶ms.file_path, + existed, + old_content.as_deref(), + ¶ms.content, + ) + .await + { + return Ok(ToolOutput::new(refusal)); + } + + // Create parent directories if needed + if let Some(parent) = path.parent() + && !parent.exists() + { + tokio::fs::create_dir_all(parent).await?; + } + // Write the file tokio::fs::write(&path, ¶ms.content).await?; diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index 8b1aa63a19..3f496411a5 100644 --- a/crates/jcode-base/src/config.rs +++ b/crates/jcode-base/src/config.rs @@ -651,6 +651,11 @@ pub struct ToolConfig { alias = "mcp_tools_auto_threshold_tokens" )] pub mcp_tools_token_threshold: usize, + /// Verify-then-commit: hold non-trivial file edits for explicit user + /// approval before the write lands (`y`/`n`/`all` reply). Trivial changes + /// of up to a few lines still apply without asking. Defaults to off; see + /// docs/VERIFY_THEN_COMMIT.md for the current client-surface coverage. + pub verify_file_edits: bool, } impl Default for ToolConfig { @@ -662,6 +667,7 @@ impl Default for ToolConfig { disable_base_tools: false, mcp_tools: McpToolsMode::Auto, mcp_tools_token_threshold: 8_000, + verify_file_edits: false, } } } diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 85bb500e17..885206abb2 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -49,6 +49,18 @@ pub enum AppRuntimeMode { TestHarness, } +/// A pending stdin prompt from the server (verify-then-commit approval, or an +/// interactive command waiting on input). While set, the next submitted line +/// is routed to `Request::StdinResponse` instead of the chat, and Esc +/// declines with an empty reply so the waiting tool unblocks. +pub(crate) mod stdin_answer { + #[derive(Debug, Clone)] + pub(crate) struct PendingStdinAnswer { + pub(crate) request_id: String, + pub(crate) prompt: String, + } +} + mod auth; mod auth_account_picker_saved_accounts; mod catchup; @@ -1588,6 +1600,9 @@ pub struct App { pending_account_input: Option, /// Pending SSH remote target prompt. Stores the friendly remote name. pending_ssh_remote_name: Option, + /// Pending stdin prompt from the server: the next submitted line is sent + /// as its reply (Enter), Esc declines with an empty line. + pending_stdin_answer: Option, /// One-shot flag: force the next paint to clear the terminal first. /// Needed after native terminal scrolls mutate the screen outside ratatui's diff model. force_full_redraw: bool, diff --git a/crates/jcode-tui/src/tui/app/prompt_history.rs b/crates/jcode-tui/src/tui/app/prompt_history.rs index 2a798f0635..32d66e9e50 100644 --- a/crates/jcode-tui/src/tui/app/prompt_history.rs +++ b/crates/jcode-tui/src/tui/app/prompt_history.rs @@ -214,6 +214,7 @@ impl App { if self.pending_login.is_some() || self.pending_account_input.is_some() || self.pending_ssh_remote_name.is_some() + || self.pending_stdin_answer.is_some() { return; } diff --git a/crates/jcode-tui/src/tui/app/remote/key_handling.rs b/crates/jcode-tui/src/tui/app/remote/key_handling.rs index aec68a9732..945b59e167 100644 --- a/crates/jcode-tui/src/tui/app/remote/key_handling.rs +++ b/crates/jcode-tui/src/tui/app/remote/key_handling.rs @@ -317,6 +317,50 @@ async fn handle_remote_key_internal( return Ok(()); } + // A pending stdin request (verify-then-commit approval or an interactive + // command) owns Enter and Esc: the submitted line becomes its reply. + // Enter with an empty buffer is allowed — an empty reply is meaningful + // (declines the edit gate, sends a bare line to a command's stdin). + if app.pending_stdin_answer.is_some() && matches!(code, KeyCode::Enter | KeyCode::Esc) { + // Checked above; take must not run for non-Enter/Esc keys, which + // fall through so the user can scroll or edit the draft reply. + let pending = app + .pending_stdin_answer + .take() + .expect("pending_stdin_answer checked above"); + if code == KeyCode::Esc { + // Explicit decline so the waiting tool unblocks instead of + // running out its timeout. + let _ = remote.send_stdin_response(&pending.request_id, "").await; + app.set_status_notice("Input request declined"); + return Ok(()); + } + let reply = std::mem::take(&mut app.input); + app.cursor_pos = 0; + app.clear_input_undo_history(); + match remote + .send_stdin_response(&pending.request_id, &reply) + .await + { + Ok(()) => { + app.set_status_notice(if pending.prompt.is_empty() { + "Input sent".to_string() + } else { + "Reply sent".to_string() + }); + } + Err(error) => { + app.push_display_message(DisplayMessage::error(format!( + "Failed to send reply: {error}" + ))); + app.set_status_notice("Reply failed"); + } + } + return Ok(()); + } + // Any other key with a pending stdin request falls through unchanged, so + // the user can still scroll or edit their draft reply while waiting. + if let Some(ref picker) = app.inline_interactive_state && !picker.preview { diff --git a/crates/jcode-tui/src/tui/app/remote/server_events.rs b/crates/jcode-tui/src/tui/app/remote/server_events.rs index 496636f199..44c3321554 100644 --- a/crates/jcode-tui/src/tui/app/remote/server_events.rs +++ b/crates/jcode-tui/src/tui/app/remote/server_events.rs @@ -2845,8 +2845,27 @@ pub(in crate::tui::app) fn handle_server_event( } false } - ServerEvent::StdinRequest { .. } => { - app.set_status_notice("⌨ Interactive terminal detected (command will timeout)"); + ServerEvent::StdinRequest { + request_id, prompt, .. + } => { + // Capture the request so the next submitted line goes back as its + // reply (remote/key_handling routes Enter/Esc). Empty prompts are + // bash-style raw stdin (no visible question); non-empty ones are + // shown in the transcript so the user can read the diff/question. + app.pending_stdin_answer = Some(crate::tui::app::stdin_answer::PendingStdinAnswer { + request_id, + prompt: prompt.clone(), + }); + if prompt.is_empty() { + app.set_status_notice( + "⌨ A command is waiting for input — type a reply and press Enter", + ); + } else { + app.push_display_message(DisplayMessage::system(prompt)); + app.set_status_notice( + "⌨ Input requested — type a reply (Enter sends, Esc declines)", + ); + } false } _ => false, diff --git a/crates/jcode-tui/src/tui/app/tests.rs b/crates/jcode-tui/src/tui/app/tests.rs index 926ceb8c8c..2193352d5a 100644 --- a/crates/jcode-tui/src/tui/app/tests.rs +++ b/crates/jcode-tui/src/tui/app/tests.rs @@ -55,6 +55,7 @@ include!("tests/spinner_slash_commands.rs"); include!("tests/command_suggestions_cache.rs"); include!("tests/skill_invocation_multi_word.rs"); include!("tests/prompt_history_cross_session.rs"); +include!("tests/stdin_answer_remote.rs"); #[test] fn kv_cache_signature_prefix_match_allows_appended_messages() { let baseline_messages = vec![ diff --git a/crates/jcode-tui/src/tui/app/tests/stdin_answer_remote.rs b/crates/jcode-tui/src/tui/app/tests/stdin_answer_remote.rs new file mode 100644 index 0000000000..688e7951ad --- /dev/null +++ b/crates/jcode-tui/src/tui/app/tests/stdin_answer_remote.rs @@ -0,0 +1,169 @@ +// Verify-then-commit / interactive stdin answer UX in remote mode. +// +// Covers the server-event capture (`StdinRequest` → pending answer state + +// visible prompt) and the composer routing (Enter sends the reply through +// `Request::StdinResponse`, Esc declines with an empty line, other keys keep +// editing the draft without consuming the pending state). +// +// NOTE: this file is spliced into the tests module via include!; KeyCode and +// KeyModifiers are already imported by a sibling test file. + +fn stdin_request_event(request_id: &str, prompt: &str) -> crate::protocol::ServerEvent { + crate::protocol::ServerEvent::StdinRequest { + request_id: request_id.to_string(), + prompt: prompt.to_string(), + is_password: false, + tool_call_id: "call_stdin".to_string(), + } +} + +#[test] +fn stdin_request_event_captures_prompt_and_arms_reply() { + let mut app = create_test_app(); + app.is_remote = true; + // dummy() builds a real socketpair, which needs a reactor in scope. + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + let mut remote = crate::tui::backend::RemoteConnection::dummy(); + + // House style: display-message branches return false (repaint flows + // through the display-message dirty path, not this return value). + let redraw = app.handle_server_event( + stdin_request_event( + "stdin-1", + "VERIFY EDIT: Modify src/lib.rs\nReply 'y' to apply, 'n' to reject.", + ), + &mut remote, + ); + assert!(!redraw); + let pending = app + .pending_stdin_answer + .as_ref() + .expect("stdin request must arm the reply state"); + assert_eq!(pending.request_id, "stdin-1"); + assert!(pending.prompt.contains("VERIFY EDIT")); + // The prompt must be visible in the transcript so the user can read the + // diff before answering. + let last = app.display_messages().last().unwrap().content.clone(); + assert!(last.contains("VERIFY EDIT"), "{last}"); +} + +#[test] +fn enter_routes_reply_to_stdin_response_and_clears_pending() { + use tokio::io::AsyncBufReadExt; + + let mut app = create_test_app(); + app.is_remote = true; + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut line = String::new(); + rt.block_on(async { + let mut remote = crate::tui::backend::RemoteConnection::dummy(); + let peer = remote + .take_dummy_peer() + .expect("dummy remote should retain peer stream"); + let (reader, _writer) = peer.into_split(); + let mut reader = tokio::io::BufReader::new(reader); + + app.handle_server_event(stdin_request_event("stdin-1", ""), &mut remote); + app.input = "y".to_string(); + app.cursor_pos = 1; + + app.handle_remote_key(KeyCode::Enter, KeyModifiers::empty(), &mut remote) + .await + .expect("Enter should route the reply"); + reader + .read_line(&mut line) + .await + .expect("reply should be readable by peer"); + + assert!( + app.pending_stdin_answer.is_none(), + "Enter must consume the pending request" + ); + assert!(app.input.is_empty(), "input buffer is consumed as the reply"); + }); + + match serde_json::from_str::(&line) + .expect("reply should deserialize") + { + crate::protocol::Request::StdinResponse { + request_id, input, .. + } => { + assert_eq!(request_id, "stdin-1"); + assert_eq!(input, "y"); + } + other => panic!("expected StdinResponse, got {other:?}"), + } +} + +#[test] +fn esc_declines_with_empty_reply_and_keeps_draft() { + use tokio::io::AsyncBufReadExt; + + let mut app = create_test_app(); + app.is_remote = true; + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut line = String::new(); + rt.block_on(async { + let mut remote = crate::tui::backend::RemoteConnection::dummy(); + let peer = remote + .take_dummy_peer() + .expect("dummy remote should retain peer stream"); + let (reader, _writer) = peer.into_split(); + let mut reader = tokio::io::BufReader::new(reader); + + app.handle_server_event( + stdin_request_event("stdin-2", "VERIFY EDIT: Modify x.rs"), + &mut remote, + ); + // A draft typed while waiting must survive the decline. + app.input = "actually, let me rephrase".to_string(); + + app.handle_remote_key(KeyCode::Esc, KeyModifiers::empty(), &mut remote) + .await + .expect("Esc should decline"); + reader + .read_line(&mut line) + .await + .expect("decline should be readable by peer"); + + assert!(app.pending_stdin_answer.is_none()); + assert_eq!( + app.input, "actually, let me rephrase", + "Esc declines but must not erase the draft" + ); + }); + + match serde_json::from_str::(&line) + .expect("decline should deserialize") + { + crate::protocol::Request::StdinResponse { + request_id, input, .. + } => { + assert_eq!(request_id, "stdin-2"); + assert_eq!(input, "", "Esc declines with an empty reply"); + } + other => panic!("expected StdinResponse, got {other:?}"), + } +} + +#[test] +fn other_keys_keep_editing_without_consuming_the_pending_request() { + let mut app = create_test_app(); + app.is_remote = true; + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + let mut remote = crate::tui::backend::RemoteConnection::dummy(); + + app.handle_server_event(stdin_request_event("stdin-3", ""), &mut remote); + rt.block_on( + app.handle_remote_key(KeyCode::Char('n'), KeyModifiers::empty(), &mut remote), + ) + .expect("typing should still work while a reply is pending"); + + assert!( + app.pending_stdin_answer.is_some(), + "non-Enter/Esc keys must not consume the pending request" + ); + assert_eq!(app.input, "n", "typed characters keep building the reply"); +} \ No newline at end of file diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 5727959537..95ea6df4f7 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -729,6 +729,8 @@ impl App { pending_login: None, pending_account_input: None, pending_ssh_remote_name: None, + pending_stdin_answer: None, + force_full_redraw: false, force_full_repaint: false, last_mouse_scroll: None, @@ -1175,6 +1177,8 @@ impl App { ambient_system_prompt: None, pending_login: None, pending_account_input: None, + pending_stdin_answer: None, + pending_ssh_remote_name: None, force_full_redraw: false, force_full_repaint: false, diff --git a/docs/VERIFY_THEN_COMMIT.md b/docs/VERIFY_THEN_COMMIT.md new file mode 100644 index 0000000000..8bd11100bb --- /dev/null +++ b/docs/VERIFY_THEN_COMMIT.md @@ -0,0 +1,72 @@ +# Verify-then-commit + +> **Status:** Shipped server-side (opt-in). Client answer-UX coverage below. +> **Origin:** Apodex improvement report, top-5 pick #1 — "inline diff preview + +> accept/reject before mutations", rooted in the same instinct as the #604 +> destructive-command gate. + +When `[tools] verify_file_edits = true`, file mutations held by the gate ask the +user before the write lands: + +- **Trivial changes are never gated** — up to `AUTO_ACCEPT_MAX_CHANGED_LINES` + changed lines (3 today) apply silently, so typo fixes stay zero-friction. +- Everything larger sends a prompt containing the path, action (create / modify / + delete), and a diff excerpt through jcode's existing interactive stdin channel + (`ServerEvent::StdinRequest`) and waits up to 5 minutes for one line: + - `y` / `yes` / `ok` / `approve` — apply this change. + - `n` / `no` / empty / anything unrecognized — reject; the model is told the + user declined and not to retry unchanged. + - `all` / `always` — bulk-accept every later edit in this session. +- No interactive user attached (headless runs), a dropped client, or a timeout + blocks the mutation with an actionable message ("Nothing was written"). The + gate fails closed: an ambiguous answer never becomes a silent write. +- Deletions (`apply_patch` delete hunks, unified-diff deletes) are gated like + any other mutation. + +## Enabled surfaces + +| Tool | Gated operations | +|------|------------------| +| `edit` | single replacement | +| `multiedit` | whole-file result after applying its edit list | +| `write` | overwrites and new-file creation (before parent dirs are made) | +| `apply_patch` | AddFile, DeleteFile, Update, Move destination | +| `patch` | unified-diff create, modify, delete | + +## Known gaps / follow-ups + +1. **Client answer UX — SHIPPED (remote TUI).** `ServerEvent::StdinRequest` + now arms a pending-answer state in the remote TUI: the prompt is shown in + the transcript, **Enter** sends the composer line as the reply + (`Request::StdinResponse`), **Esc** declines with an empty reply (which the + edit gate treats as a rejection), and other keys keep editing the draft + without consuming the request. Interactive bash stdin benefits too — + prompts no longer just show a "will timeout" notice. + Not yet covered: local (non-remote) sessions route tools without a stdin + channel, so the gate there still fails closed (documented above); masked + (`is_password`) replies currently render unmasked in the composer. +2. Timeout is fixed at 5 minutes; a config knob can follow if sessions need + unattended-but-gated modes. +3. Path-scoped trust rules ("always allow under `.jcode/skills/`") are not yet + implemented; bulk-accept is session-wide only. + +## Tests + +`crates/jcode-app-core/src/tool/edit_approval_tests.rs` covers classification, +prompt construction/reply parsing, channel round-trips (approve / reject / bulk +/ dropped responder), and fail-closed behavior without a channel. + +Run with: + +``` +cargo test -p jcode-app-core --lib edit_approval +``` + +**Local-machine note (2026-08-30):** the full in-workspace run completed after +disk was freed: `cargo test -p jcode-app-core --lib edit_approval` — **13/13 +pass**. The TUI answer-UX is covered by 4 tests in +`crates/jcode-tui/src/tui/app/tests/stdin_answer_remote.rs` (capture, Enter +routing, Esc decline, draft-preserving keys), run with +`cargo test -p jcode-tui --lib stdin_` plus the `esc_declines other_keys` +filters — **4/4 pass**. An earlier scratch-harness verification on this machine +(`#[path]`-including the real module against stubs) is now redundant.