From fd84f5a9216187e4ebe66b7a67315251287bc764 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:48:40 +0000 Subject: [PATCH 1/6] Fix clippy drain_collect and chunks_exact failures Co-authored-by: Gao Yu --- crates/agent-core/src/subagents.rs | 2 +- crates/agent-core/src/tools.rs | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/agent-core/src/subagents.rs b/crates/agent-core/src/subagents.rs index ef8938e..a0d7404 100644 --- a/crates/agent-core/src/subagents.rs +++ b/crates/agent-core/src/subagents.rs @@ -385,7 +385,7 @@ impl SubagentManager { /// parent can fold it into its cumulative totals. Returns each result once. pub(crate) fn drain_finished_usage(&self) -> Vec { let mut state = self.inner.state.lock().unwrap(); - state.finished_usage.drain(..).collect() + std::mem::take(&mut state.finished_usage) } fn resolve_existing_target( diff --git a/crates/agent-core/src/tools.rs b/crates/agent-core/src/tools.rs index c6514e4..9f40f5b 100644 --- a/crates/agent-core/src/tools.rs +++ b/crates/agent-core/src/tools.rs @@ -2222,12 +2222,14 @@ fn decode_utf16(bytes: &[u8], little_endian: bool) -> Option { return None; } let units = bytes - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|chunk| { if little_endian { - u16::from_le_bytes([chunk[0], chunk[1]]) + u16::from_le_bytes(*chunk) } else { - u16::from_be_bytes([chunk[0], chunk[1]]) + u16::from_be_bytes(*chunk) } }) .collect::>(); From e1a8bfbd02b45e5582f5e81450181a6080c3b6a6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:48:40 +0000 Subject: [PATCH 2/6] Add edit_tools config gating which edit tools the model sees Default exposes only hashline_edit; str_replace (alias edit), write, and apply_patch must be enabled via the edit_tools array in config.json. Disabled edit tools are filtered from the model's tool definitions and rejected with a clear error if called anyway. Also adds enable_browser_open to switch off the desktop-only browser_open tool. Co-authored-by: Gao Yu --- crates/agent-core/src/config.rs | 123 ++++++++++++++++++++++++- crates/agent-core/src/core.rs | 8 ++ crates/agent-core/src/llm.rs | 154 +++++++++++++++++++++++++++++++- 3 files changed, 279 insertions(+), 6 deletions(-) diff --git a/crates/agent-core/src/config.rs b/crates/agent-core/src/config.rs index 151fe5d..384d57e 100644 --- a/crates/agent-core/src/config.rs +++ b/crates/agent-core/src/config.rs @@ -137,10 +137,26 @@ fn is_shell_tool(name: &str) -> bool { } fn is_edit_tool(name: &str) -> bool { - matches!( - name, - "write" | "edit" | "str_replace" | "hashline_edit" | "apply_patch" - ) + canonical_edit_tool_name(name).is_some() +} + +/// Canonical name for an edit tool, accepting the `edit` alias for +/// `str_replace`. Returns None for anything that is not an edit tool. +pub fn canonical_edit_tool_name(name: &str) -> Option<&'static str> { + match name { + "hashline_edit" => Some("hashline_edit"), + "str_replace" | "edit" => Some("str_replace"), + "write" => Some("write"), + "apply_patch" => Some("apply_patch"), + _ => None, + } +} + +/// Edit tools exposed to the model when config.json has no `edit_tools` +/// field. Only hashline_edit is on by default; users opt in to the others +/// with e.g. `"edit_tools": ["hashline_edit", "str_replace", "write"]`. +pub fn default_edit_tools() -> Vec { + vec!["hashline_edit".to_string()] } fn is_network_tool(name: &str) -> bool { @@ -168,6 +184,16 @@ pub struct Config { pub compaction_threshold_percent: u64, pub include_project_instructions: bool, pub approval_mode: ApprovalMode, + /// Edit tools offered to the model (`edit_tools` in config.json). + /// Canonical names: hashline_edit, str_replace (alias edit), write, + /// apply_patch. Defaults to hashline_edit only; an explicit empty array + /// disables all edit tools. Tools not listed here are neither sent to the + /// model nor executed if called anyway. + pub edit_tools: Vec, + /// Whether the desktop-only browser_open tool may be offered at all + /// (`enable_browser_open` in config.json, default true). It is only ever + /// exposed when running under JuCode Desktop (JUCODE_DESKTOP set). + pub enable_browser_open: bool, pub extensions: Vec, pub mcp_servers: Vec, path: PathBuf, @@ -286,6 +312,8 @@ impl Config { compaction_threshold_percent: DEFAULT_COMPACTION_THRESHOLD_PERCENT, include_project_instructions: true, approval_mode: ApprovalMode::default(), + edit_tools: default_edit_tools(), + enable_browser_open: true, extensions: Vec::new(), mcp_servers: Vec::new(), path, @@ -365,6 +393,8 @@ impl Config { .clamp(10, 95), include_project_instructions: read_bool(&value, "include_project_instructions", true), approval_mode: read_approval_mode(&value)?, + edit_tools: read_edit_tools(&value)?, + enable_browser_open: read_bool(&value, "enable_browser_open", true), extensions: read_extensions(&value), mcp_servers: read_mcp_servers(&value), path, @@ -396,6 +426,8 @@ impl Config { "compaction_threshold_percent": self.compaction_threshold_percent, "include_project_instructions": self.include_project_instructions, "approval_mode": self.approval_mode.as_str(), + "edit_tools": self.edit_tools, + "enable_browser_open": self.enable_browser_open, "extensions": self.extensions.iter().map(extension_config_value).collect::>(), "mcp_servers": self.mcp_servers.iter().map(mcp_server_config_value).collect::>() }); @@ -556,6 +588,38 @@ fn read_approval_mode(value: &Value) -> io::Result { } } +/// Optional `edit_tools` in config.json. Absent defaults to hashline_edit +/// only; an explicit empty array disables all edit tools. Unknown names are a +/// hard load error rather than a silent fallback. The `edit` alias is stored +/// canonically as `str_replace` and duplicates collapse. +fn read_edit_tools(value: &Value) -> io::Result> { + let Some(raw) = value.get("edit_tools") else { + return Ok(default_edit_tools()); + }; + let Some(items) = raw.as_array() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid edit_tools in config.json: expected an array of tool names", + )); + }; + let mut tools = Vec::new(); + for item in items { + let name = item.as_str().map(str::trim).unwrap_or_default(); + let Some(canonical) = canonical_edit_tool_name(name) else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "invalid edit_tools entry '{name}' in config.json: use hashline_edit, str_replace (alias edit), write, or apply_patch" + ), + )); + }; + if !tools.iter().any(|tool| tool == canonical) { + tools.push(canonical.to_string()); + } + } + Ok(tools) +} + fn read_bool(value: &Value, key: &str, default: bool) -> bool { value.get(key).and_then(Value::as_bool).unwrap_or(default) } @@ -1175,6 +1239,8 @@ mod tests { compaction_threshold_percent: DEFAULT_COMPACTION_THRESHOLD_PERCENT, include_project_instructions: true, approval_mode: ApprovalMode::default(), + edit_tools: default_edit_tools(), + enable_browser_open: true, extensions: Vec::new(), mcp_servers: Vec::new(), path: PathBuf::from("config.json"), @@ -1348,6 +1414,55 @@ mod tests { assert_eq!(parsed[0].command, "run"); } + #[test] + fn edit_tools_default_to_hashline_only() { + assert_eq!(read_edit_tools(&json!({})).unwrap(), vec!["hashline_edit"]); + assert_eq!(default_edit_tools(), vec!["hashline_edit"]); + } + + #[test] + fn edit_tools_accept_known_names_and_canonicalize_the_edit_alias() { + let tools = read_edit_tools(&json!({ + "edit_tools": ["hashline_edit", "edit", "write", "str_replace", "apply_patch"] + })) + .unwrap(); + assert_eq!( + tools, + vec!["hashline_edit", "str_replace", "write", "apply_patch"] + ); + + // An explicit empty array disables all edit tools. + assert_eq!( + read_edit_tools(&json!({ "edit_tools": [] })).unwrap(), + Vec::::new() + ); + } + + #[test] + fn edit_tools_reject_unknown_names_and_non_arrays() { + let error = read_edit_tools(&json!({ "edit_tools": ["bash"] })).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("bash")); + + let error = read_edit_tools(&json!({ "edit_tools": "write" })).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("array")); + } + + #[test] + fn canonical_edit_tool_name_covers_aliases_and_rejects_others() { + assert_eq!(canonical_edit_tool_name("edit"), Some("str_replace")); + assert_eq!(canonical_edit_tool_name("str_replace"), Some("str_replace")); + assert_eq!( + canonical_edit_tool_name("hashline_edit"), + Some("hashline_edit") + ); + assert_eq!(canonical_edit_tool_name("write"), Some("write")); + assert_eq!(canonical_edit_tool_name("apply_patch"), Some("apply_patch")); + assert_eq!(canonical_edit_tool_name("read"), None); + assert_eq!(canonical_edit_tool_name("bash"), None); + } + #[test] fn read_approval_mode_defaults_and_validates() { assert_eq!( diff --git a/crates/agent-core/src/core.rs b/crates/agent-core/src/core.rs index fe2bb8a..1c3b952 100644 --- a/crates/agent-core/src/core.rs +++ b/crates/agent-core/src/core.rs @@ -1287,6 +1287,8 @@ impl AgentCore { goal_tool_tx: Some(goal_tool_tx), approval_tx: Some(approval_tx), approval_mode: self.approval_mode, + edit_tools: self.config.edit_tools.clone(), + enable_browser_open: self.config.enable_browser_open, subagent_manager: Some(self.subagent_manager.clone()), hooks: self.hooks.clone(), }) else { @@ -1519,6 +1521,9 @@ impl AgentCore { goal_tool_tx: None, approval_tx: None, approval_mode: self.approval_mode, + // Summarization clients never expose or execute tools. + edit_tools: Vec::new(), + enable_browser_open: false, subagent_manager: None, hooks: Hooks::default(), }) @@ -1558,6 +1563,9 @@ impl AgentCore { goal_tool_tx: None, approval_tx: None, approval_mode: self.approval_mode, + // Summarization clients never expose or execute tools. + edit_tools: Vec::new(), + enable_browser_open: false, subagent_manager: None, hooks: Hooks::default(), }) diff --git a/crates/agent-core/src/llm.rs b/crates/agent-core/src/llm.rs index 54fc27a..bb5388f 100644 --- a/crates/agent-core/src/llm.rs +++ b/crates/agent-core/src/llm.rs @@ -76,6 +76,13 @@ pub struct OpenAiClient { /// at client construction (turn start / subagent spawn) and never changed /// mid-run; loosening a running turn happens core-side instead. approval_mode: ApprovalMode, + /// Canonical edit-tool names offered to the model (config `edit_tools`). + /// Edit tools not in this list are removed from the tool definitions and + /// rejected with a clear error if the model calls them anyway. + enabled_edit_tools: Vec, + /// Whether browser_open may be offered/executed at all (config + /// `enable_browser_open`); it additionally requires JUCODE_DESKTOP. + browser_open_enabled: bool, subagent_manager: Option, agent_path: String, agent_depth: u64, @@ -103,6 +110,10 @@ pub struct OpenAiClientConfig<'a> { pub goal_tool_tx: Option>, pub approval_tx: Option>, pub approval_mode: ApprovalMode, + /// Canonical edit-tool names to expose (see `Config::edit_tools`). + pub edit_tools: Vec, + /// Config-level switch for the desktop-only browser_open tool. + pub enable_browser_open: bool, pub subagent_manager: Option, pub hooks: Hooks, } @@ -297,6 +308,8 @@ impl OpenAiClient { goal_tool_tx: config.goal_tool_tx, approval_tx: config.approval_tx, approval_mode: config.approval_mode, + enabled_edit_tools: config.edit_tools, + browser_open_enabled: config.enable_browser_open, subagent_manager: config.subagent_manager, agent_path: "/root".to_string(), agent_depth: 0, @@ -931,8 +944,16 @@ impl OpenAiClient { } fn tool_definitions(&self) -> Vec { - let mut definitions = tools::definitions(); - if std::env::var("JUCODE_DESKTOP").is_ok() { + let mut definitions = tools::definitions() + .into_iter() + .filter(|definition| { + definition + .get("name") + .and_then(Value::as_str) + .is_none_or(|name| self.disabled_tool_error(name).is_none()) + }) + .collect::>(); + if self.browser_open_enabled && std::env::var("JUCODE_DESKTOP").is_ok() { definitions.push(tools::browser_open_definition()); } if self.allow_subagents && self.subagent_manager.is_some() { @@ -1000,6 +1021,9 @@ impl OpenAiClient { pending_call_ids: &HashSet, emit: &mut impl FnMut(StreamEvent) -> Result<(), String>, ) -> tools::ToolExecutionResult { + if let Some(error) = self.disabled_tool_error(&request.name) { + return json_tool_result(json!({ "error": error }), true); + } let result = if let Some(result) = self.run_goal_tool(&request.name, &request.arguments) { result } else if let Some(result) = self.run_subagent_tool( @@ -1159,6 +1183,8 @@ impl OpenAiClient { // auto-approved core-side if the live mode is looser). approval_tx: self.approval_tx.clone(), approval_mode: self.approval_mode, + enabled_edit_tools: self.enabled_edit_tools.clone(), + browser_open_enabled: self.browser_open_enabled, subagent_manager: Some(manager.clone()), agent_path: child_path.clone(), agent_depth: child_depth, @@ -1334,6 +1360,31 @@ impl OpenAiClient { }) } + /// Config-level tool gating, checked both when building the tool + /// definitions sent to the model and when executing a call. Returns the + /// rejection reason when `name` is an edit tool that is not enabled (or + /// browser_open while disabled); None means the tool may run. + fn disabled_tool_error(&self, name: &str) -> Option { + if let Some(canonical) = crate::config::canonical_edit_tool_name(name) { + if !self.enabled_edit_tools.iter().any(|tool| tool == canonical) { + let enabled = if self.enabled_edit_tools.is_empty() { + "none".to_string() + } else { + self.enabled_edit_tools.join(", ") + }; + return Some(format!( + "edit tool '{name}' is disabled by config (enabled edit tools: {enabled}). Add it to the edit_tools array in config.json to enable it." + )); + } + } + if name == "browser_open" && !self.browser_open_enabled { + return Some( + "browser_open is disabled by config (enable_browser_open is false)".to_string(), + ); + } + None + } + /// Tools whose side effects warrant a user decision before they run under /// this client's approval mode. Only gated when an approval handler is /// wired (interactive serve / TUI); the class-per-mode policy lives in @@ -3677,6 +3728,8 @@ mod tests { goal_tool_tx: None, approval_tx: None, approval_mode: ApprovalMode::default(), + edit_tools: crate::config::default_edit_tools(), + enable_browser_open: true, subagent_manager: None, hooks: Hooks::default(), }) @@ -3693,6 +3746,103 @@ mod tests { client } + fn definition_names(client: &OpenAiClient) -> Vec { + client + .tool_definitions() + .iter() + .filter_map(|definition| definition.get("name").and_then(Value::as_str)) + .map(str::to_string) + .collect() + } + + #[test] + fn default_tool_definitions_expose_only_hashline_among_edit_tools() { + let client = test_client(); + let names = definition_names(&client); + assert!(names.contains(&"hashline_edit".to_string())); + for disabled in ["str_replace", "write", "apply_patch"] { + assert!(!names.contains(&disabled.to_string()), "{disabled}"); + } + // Non-edit tools are not gated by edit_tools. + for kept in ["read", "bash", "ls", "ripgrep", "outline", "checkpoint"] { + assert!(names.contains(&kept.to_string()), "{kept}"); + } + } + + #[test] + fn enabling_extra_edit_tools_exposes_and_executes_them() { + let mut client = test_client(); + client.enabled_edit_tools = vec![ + "hashline_edit".to_string(), + "str_replace".to_string(), + "write".to_string(), + ]; + let names = definition_names(&client); + for enabled in ["hashline_edit", "str_replace", "write"] { + assert!(names.contains(&enabled.to_string()), "{enabled}"); + } + assert!(!names.contains(&"apply_patch".to_string())); + + // An enabled edit tool actually runs. + let dir = + std::env::temp_dir().join(format!("jucode-llm-edit-tools-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let request = ToolCallRequest { + call_id: "call_write".to_string(), + name: "write".to_string(), + arguments: json!({ "path": "enabled.txt", "content": "hi" }).to_string(), + }; + let result = client.run_tool_call(&request, &dir, &[], &HashSet::new(), &mut |_| Ok(())); + assert!(!result.is_error, "{}", result.output); + assert_eq!( + std::fs::read_to_string(dir.join("enabled.txt")).unwrap(), + "hi" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn disabled_edit_tool_calls_are_rejected_with_a_clear_error() { + let client = test_client(); + for name in ["write", "str_replace", "apply_patch", "edit"] { + let request = ToolCallRequest { + call_id: format!("call_{name}"), + name: name.to_string(), + arguments: json!({ "path": "x.txt", "content": "hi" }).to_string(), + }; + let result = + client.run_tool_call(&request, Path::new("."), &[], &HashSet::new(), &mut |_| { + Ok(()) + }); + assert!(result.is_error, "{name}"); + assert!(result.output.contains("disabled by config"), "{name}"); + assert!(result.output.contains("hashline_edit"), "{name}"); + } + assert!(client.disabled_tool_error("hashline_edit").is_none()); + assert!(client.disabled_tool_error("read").is_none()); + assert!(client.disabled_tool_error("bash").is_none()); + } + + #[test] + fn browser_open_can_be_disabled_by_config() { + let mut client = test_client(); + assert!(client.disabled_tool_error("browser_open").is_none()); + client.browser_open_enabled = false; + let error = client.disabled_tool_error("browser_open").unwrap(); + assert!(error.contains("enable_browser_open")); + let request = ToolCallRequest { + call_id: "call_browser".to_string(), + name: "browser_open".to_string(), + arguments: json!({ "url": "https://example.com" }).to_string(), + }; + let result = + client.run_tool_call(&request, Path::new("."), &[], &HashSet::new(), &mut |_| { + Ok(()) + }); + assert!(result.is_error); + assert!(!definition_names(&client).contains(&"browser_open".to_string())); + } + #[test] fn needs_approval_follows_mode_per_tool_class() { let (tx, _rx) = mpsc::channel(); From f31420a81d414c3dad1530d667e41908e010a183 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:48:53 +0000 Subject: [PATCH 3/6] Enforce workspace path policy in file tools read/write/str_replace/hashline_edit/apply_patch/ls/outline/checkpoint reject paths that resolve outside the workspace cwd (absolute paths, .., and symlinks pointing outside). This is a permission/path policy, not an OS sandbox; bash and ripgrep are intentionally not gated. Co-authored-by: Gao Yu --- crates/agent-core/src/hunks.rs | 9 +- crates/agent-core/src/tools.rs | 217 ++++++++++++++++++++++++++++++--- 2 files changed, 209 insertions(+), 17 deletions(-) diff --git a/crates/agent-core/src/hunks.rs b/crates/agent-core/src/hunks.rs index 082d497..ce1afab 100644 --- a/crates/agent-core/src/hunks.rs +++ b/crates/agent-core/src/hunks.rs @@ -226,7 +226,8 @@ fn filter_patch_call(arguments: &str, approved: &HashSet<&str>) -> Result Option> { let path = args.get("path").and_then(Value::as_str)?; let content = args.get("content").and_then(Value::as_str)?; - let path = tools::resolve_path(cwd, path); + // Paths outside the workspace get no preview; the tool itself rejects them. + let path = tools::workspace_path(cwd, path).ok()?; let original = fs::read_to_string(&path).unwrap_or_default(); if original == content { return None; @@ -247,7 +248,8 @@ fn plan_str_replace(args: &Value, cwd: &Path) -> Option> { if edits.is_empty() { return None; } - let path = tools::resolve_path(cwd, path); + // Paths outside the workspace get no preview; the tool itself rejects them. + let path = tools::workspace_path(cwd, path).ok()?; let original = fs::read_to_string(&path).ok()?; let mut views = Vec::new(); @@ -281,7 +283,8 @@ fn plan_hashline_edit(args: &Value, cwd: &Path) -> Option> { if edits.is_empty() { return None; } - let path = tools::resolve_path(cwd, path); + // Paths outside the workspace get no preview; the tool itself rejects them. + let path = tools::workspace_path(cwd, path).ok()?; let original = fs::read_to_string(&path).ok()?; let mut views = Vec::new(); diff --git a/crates/agent-core/src/tools.rs b/crates/agent-core/src/tools.rs index 9f40f5b..6c17603 100644 --- a/crates/agent-core/src/tools.rs +++ b/crates/agent-core/src/tools.rs @@ -393,7 +393,10 @@ fn read_file(args: &Value, cwd: &Path) -> Value { return json!({ "error": "missing path" }); }; - let path = resolve_path(cwd, path); + let path = match workspace_path(cwd, path) { + Ok(path) => path, + Err(error) => return json!({ "error": error }), + }; let offset = match optional_usize(args, "offset") { Ok(offset) => offset.unwrap_or(1).max(1), Err(error) => return json!({ "error": error }), @@ -508,7 +511,10 @@ fn str_replace_file(args: &Value, cwd: &Path) -> Value { return json!({ "error": "edits must not be empty" }); } - let path = resolve_path(cwd, path); + let path = match workspace_path(cwd, path) { + Ok(path) => path, + Err(error) => return json!({ "error": error }), + }; if !has_read(&path) { return json!({ "path": path.display().to_string(), @@ -608,7 +614,10 @@ fn hashline_edit_file(args: &Value, cwd: &Path) -> Value { return json!({ "error": "edits must not be empty" }); } - let path = resolve_path(cwd, path); + let path = match workspace_path(cwd, path) { + Ok(path) => path, + Err(error) => return json!({ "error": error }), + }; if !has_read(&path) { return json!({ "path": path.display().to_string(), @@ -656,7 +665,10 @@ fn write_file(args: &Value, cwd: &Path) -> Value { return json!({ "error": "missing content" }); }; - let path = resolve_path(cwd, path); + let path = match workspace_path(cwd, path) { + Ok(path) => path, + Err(error) => return json!({ "error": error }), + }; let exists = path.exists(); if exists && !has_read(&path) { return json!({ @@ -1917,6 +1929,14 @@ fn apply_patch( if patch.trim().is_empty() { return json!({ "error": "patch must not be empty" }); } + // Workspace path policy: reject the whole patch when any target escapes + // the workspace, before anything is checked or applied. + let targets = patch_target_paths(patch, cwd); + for target in &targets { + if let Err(error) = ensure_in_workspace(cwd, target) { + return json!({ "error": error }); + } + } let check = run_command_events( "git", @@ -1934,7 +1954,6 @@ fn apply_patch( match check { Ok(_) => { - let targets = patch_target_paths(patch, cwd); // Snapshot the patch's target files (pre-apply) so /rewind can undo it. let _ = create_checkpoint(cwd, "auto-patch", &targets); let result = run_command_events( @@ -1966,11 +1985,13 @@ fn apply_patch( } fn list_dir(args: &Value, cwd: &Path) -> Value { - let path = args - .get("path") - .and_then(Value::as_str) - .map(|path| resolve_path(cwd, path)) - .unwrap_or_else(|| cwd.to_path_buf()); + let path = match args.get("path").and_then(Value::as_str) { + Some(path) => match workspace_path(cwd, path) { + Ok(path) => path, + Err(error) => return json!({ "error": error }), + }, + None => cwd.to_path_buf(), + }; let limit = match optional_usize(args, "limit") { Ok(limit) => limit.map(|limit| limit.max(1)), Err(error) => return json!({ "error": error }), @@ -2119,7 +2140,10 @@ fn outline_file(args: &Value, cwd: &Path) -> Value { let Some(path) = args.get("path").and_then(Value::as_str) else { return json!({ "error": "missing path" }); }; - let path = resolve_path(cwd, path); + let path = match workspace_path(cwd, path) { + Ok(path) => path, + Err(error) => return json!({ "error": error }), + }; let limit = match optional_usize(args, "limit") { Ok(limit) => limit.unwrap_or(200).max(1), Err(error) => return json!({ "error": error }), @@ -2155,14 +2179,19 @@ fn checkpoint_tool(args: &Value, cwd: &Path) -> Value { .unwrap_or_default(); match action { "create" => { - let paths = args + let mut paths = Vec::new(); + for path in args .get("paths") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(Value::as_str) - .map(|path| resolve_path(cwd, path)) - .collect::>(); + { + match workspace_path(cwd, path) { + Ok(path) => paths.push(path), + Err(error) => return json!({ "error": error }), + } + } if paths.is_empty() { return json!({ "error": "checkpoint create requires paths" }); } @@ -3186,6 +3215,69 @@ pub(crate) fn resolve_path(cwd: &Path, path: &str) -> PathBuf { } } +/// Resolve `path` and enforce the workspace path policy: file tools only +/// operate on paths inside the workspace root (`cwd`). This is a permission +/// policy (resolve + prefix check), not an OS sandbox — shell commands are +/// intentionally not gated. Symlinks in the existing part of the path are +/// resolved before the check, so a symlink pointing outside the workspace is +/// rejected too. +pub(crate) fn workspace_path(cwd: &Path, path: &str) -> Result { + let resolved = resolve_path(cwd, path); + ensure_in_workspace(cwd, &resolved)?; + Ok(resolved) +} + +/// Errors when `resolved` escapes the workspace root after resolving +/// symlinks in its existing prefix and `.`/`..` components lexically in the +/// (necessarily symlink-free) non-existent remainder. +pub(crate) fn ensure_in_workspace(cwd: &Path, resolved: &Path) -> Result<(), String> { + let workspace = cwd.canonicalize().map_err(|error| { + format!( + "cannot resolve the workspace root {}: {error}", + cwd.display() + ) + })?; + let normalized = normalize_for_policy(resolved); + if normalized == workspace || normalized.starts_with(&workspace) { + Ok(()) + } else { + Err(format!( + "path escapes the workspace: {} resolves outside {}. File tools only operate on paths inside the workspace.", + resolved.display(), + workspace.display() + )) + } +} + +/// Canonicalizes the deepest existing ancestor of `path` (resolving symlinks +/// and `..`), then applies the remaining non-existent components lexically. +fn normalize_for_policy(path: &Path) -> PathBuf { + let (base, remainder) = deepest_canonical_ancestor(path); + let mut normalized = base; + for component in remainder.components() { + match component { + std::path::Component::Normal(part) => normalized.push(part), + std::path::Component::ParentDir => { + normalized.pop(); + } + // CurDir is dropped; RootDir/Prefix cannot appear in a stripped + // remainder. + _ => {} + } + } + normalized +} + +fn deepest_canonical_ancestor(path: &Path) -> (PathBuf, PathBuf) { + for ancestor in path.ancestors() { + if let Ok(canonical) = ancestor.canonicalize() { + let remainder = path.strip_prefix(ancestor).unwrap_or(Path::new("")); + return (canonical, remainder.to_path_buf()); + } + } + (path.to_path_buf(), PathBuf::new()) +} + fn expand_tilde(path: &str) -> PathBuf { if path != "~" && !path.starts_with("~/") { return PathBuf::from(path); @@ -4372,6 +4464,103 @@ mod tests { } #[cfg(not(windows))] + #[test] + fn workspace_path_allows_inside_and_rejects_outside() { + let dir = env::temp_dir().join(format!("jucode-policy-basic-{}", std::process::id())); + fs::create_dir_all(dir.join("sub")).unwrap(); + fs::write(dir.join("inside.txt"), "ok").unwrap(); + + assert!(workspace_path(&dir, "inside.txt").is_ok()); + assert!(workspace_path(&dir, "sub/../inside.txt").is_ok()); + assert!(workspace_path(&dir, "new/dir/file.txt").is_ok()); + assert!(workspace_path(&dir, &dir.join("inside.txt").display().to_string()).is_ok()); + + let error = workspace_path(&dir, "../escape.txt").unwrap_err(); + assert!(error.contains("escapes the workspace"), "{error}"); + assert!(workspace_path(&dir, "/etc/passwd").is_err()); + assert!(workspace_path(&dir, "sub/../../escape.txt").is_err()); + // `..` inside a not-yet-existing prefix must not escape either. + assert!(workspace_path(&dir, "missing/../../escape.txt").is_err()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn file_tools_reject_paths_outside_the_workspace() { + let dir = env::temp_dir().join(format!("jucode-policy-tools-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let cases: [(&str, Value); 7] = [ + ("read", json!({ "path": "/etc/passwd" })), + ("write", json!({ "path": "../escape.txt", "content": "x" })), + ( + "str_replace", + json!({ "path": "/etc/passwd", "edits": [{ "oldText": "a", "newText": "b" }] }), + ), + ( + "hashline_edit", + json!({ "path": "/etc/passwd", "edits": [{ "op": "append", "lines": "x" }] }), + ), + ("ls", json!({ "path": ".." })), + ("outline", json!({ "path": "/etc/passwd" })), + ( + "checkpoint", + json!({ "action": "create", "name": "cp", "paths": ["../escape.txt"] }), + ), + ]; + for (tool, args) in cases { + let result = run_tool(tool, &args.to_string(), &dir); + let value = serde_json::from_str::(&result).unwrap(); + let error = value + .get("error") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!(error.contains("escapes the workspace"), "{tool}: {result}"); + } + assert!(!dir.parent().unwrap().join("escape.txt").exists()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn apply_patch_rejects_targets_outside_the_workspace() { + let dir = env::temp_dir().join(format!("jucode-policy-patch-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let patch = "--- /dev/null\n+++ b/../evil.txt\n@@ -0,0 +1 @@\n+evil\n"; + let result = run_tool("apply_patch", &json!({ "patch": patch }).to_string(), &dir); + let value = serde_json::from_str::(&result).unwrap(); + let error = value + .get("error") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!(error.contains("escapes the workspace"), "{result}"); + assert!(!dir.parent().unwrap().join("evil.txt").exists()); + let _ = fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn file_tools_reject_symlinks_that_point_outside_the_workspace() { + let outside = env::temp_dir().join(format!("jucode-policy-outside-{}", std::process::id())); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("secret.txt"), "secret").unwrap(); + let dir = env::temp_dir().join(format!("jucode-policy-symlink-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + std::os::unix::fs::symlink(&outside, dir.join("link")).unwrap(); + + let result = run_tool( + "read", + &json!({ "path": "link/secret.txt" }).to_string(), + &dir, + ); + let value = serde_json::from_str::(&result).unwrap(); + let error = value + .get("error") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!(error.contains("escapes the workspace"), "{result}"); + + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&outside); + } + #[test] fn resolve_path_expands_tilde_to_home() { let home = env::var("HOME").unwrap(); From 56f6bfdf5f66d04cfa8b5054ff9b1be554900b47 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:48:53 +0000 Subject: [PATCH 4/6] Let headless use --approval-mode and default it to read-only Headless no longer forces full-auto: it accepts any --approval-mode, defaults to read-only, and auto-denies approval requests (which could never be answered) with a clear message instead of hanging. Use --approval-mode full-auto for mutating tasks. Co-authored-by: Gao Yu --- src/main.rs | 70 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0c95a1c..5cede70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -136,6 +136,15 @@ fn take_approval_mode_flag(args: &mut Vec) -> Result) -> ApprovalMode { + flag.unwrap_or(ApprovalMode::ReadOnly) +} + fn run_headless(args: Vec, approval_mode: Option) -> io::Result { let mut prompt = args.join(" "); if prompt.trim().is_empty() { @@ -143,33 +152,24 @@ fn run_headless(args: Vec, approval_mode: Option) -> io::R } let mut core = AgentCore::new()?; let mut stdout = io::stdout(); - // Headless reads no further stdin, so approval prompts could never be - // answered; it therefore always runs full-auto and rejects tighter modes. - if let Some(mode) = approval_mode { - if mode != ApprovalMode::FullAuto { - write_event( - &mut stdout, - AgentEvent::Error(format!( - "--approval-mode {} is not supported in --headless mode: approvals cannot be answered, so headless always runs full-auto", - mode.as_str() - )), - )?; - return Ok(2); - } - } - for event in core.set_approval_mode(ApprovalMode::FullAuto) { + for event in core.set_approval_mode(headless_approval_mode(approval_mode)) { write_event(&mut stdout, event)?; } let mut done = false; let mut stats = HeadlessStats::default(); let started = Instant::now(); + let mut pending_denials = Vec::new(); for event in core.submit_user_message(prompt) { if matches!(event, AgentEvent::Error(_)) { done = true; } + if let AgentEvent::ApprovalRequest { call_id, name, .. } = &event { + pending_denials.push((call_id.clone(), name.clone())); + } record_headless_event(&event, &mut stats); write_event(&mut stdout, event)?; } + auto_deny_approvals(&mut core, &mut stdout, &mut stats, &mut pending_denials)?; while !done { let events = core.poll_events(); for event in events { @@ -178,9 +178,13 @@ fn run_headless(args: Vec, approval_mode: Option) -> io::R { done = true; } + if let AgentEvent::ApprovalRequest { call_id, name, .. } = &event { + pending_denials.push((call_id.clone(), name.clone())); + } record_headless_event(&event, &mut stats); write_event(&mut stdout, event)?; } + auto_deny_approvals(&mut core, &mut stdout, &mut stats, &mut pending_denials)?; thread::sleep(Duration::from_millis(50)); } stats.status = if stats.last_error.is_some() { @@ -195,6 +199,29 @@ fn run_headless(args: Vec, approval_mode: Option) -> io::R Ok(if stats.last_error.is_some() { 1 } else { 0 }) } +/// Denies every approval request surfaced by a headless run: nobody can +/// answer them, so blocking would hang the turn. The model receives the +/// denial as the tool result and can adapt or finish. +fn auto_deny_approvals( + core: &mut AgentCore, + stdout: &mut impl Write, + stats: &mut HeadlessStats, + pending: &mut Vec<(String, String)>, +) -> io::Result<()> { + for (call_id, name) in pending.drain(..) { + let info = AgentEvent::Info(format!( + "auto-denying {name} ({call_id}): approvals cannot be answered in --headless mode; rerun with --approval-mode auto-edit or full-auto to allow this class of tools" + )); + record_headless_event(&info, stats); + write_event(stdout, info)?; + for event in core.approve(&call_id, false, false, None) { + record_headless_event(&event, stats); + write_event(stdout, event)?; + } + } + Ok(()) +} + /// Persistent bidirectional protocol mode for GUI/IDE front-ends. /// /// Reads newline-delimited JSON commands on stdin and emits the engine's @@ -775,6 +802,19 @@ mod tests { assert_eq!(value["elapsed_ms"], 123); } + #[test] + fn headless_defaults_to_read_only_and_honors_explicit_flag() { + assert_eq!(headless_approval_mode(None), ApprovalMode::ReadOnly); + assert_eq!( + headless_approval_mode(Some(ApprovalMode::AutoEdit)), + ApprovalMode::AutoEdit + ); + assert_eq!( + headless_approval_mode(Some(ApprovalMode::FullAuto)), + ApprovalMode::FullAuto + ); + } + #[test] fn approve_op_round_trips_decision_always_and_hunks() { let op = json!({ From b1278b927bf3fd18e5638f4c01ee63645d19476a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:48:53 +0000 Subject: [PATCH 5/6] Fix README provider claim and document edit_tools and headless approvals Co-authored-by: Gao Yu --- README.md | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cde5c9c..012ff8c 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ On first run, JuCode creates its configuration under the user profile directory. - default API base URL: `https://api.jucode.cn/v1` - default API key environment variable: `OPENAI_API_KEY` -Sign in with `/login` to use the JuCode gateway, or set an API key and point the config at any OpenAI-compatible endpoint (`openai` and `deepseek` are built in; Anthropic-protocol models are supported via the `protocol` setting): +Sign in with `/login` to use the JuCode gateway, or set an API key and point the config at any OpenAI-compatible endpoint. The built-in provider templates are `jucode` and `deepseek`; other vendors (for example OpenAI) work by setting `base_url`, `model`, and `api_key_env` manually — dedicated vendor templates are separate future work. Anthropic-protocol models are supported via the `protocol` setting: ```bash export OPENAI_API_KEY="..." @@ -62,6 +62,18 @@ You can switch model and reasoning effort inside the TUI: The config also supports custom OpenAI-compatible base URLs, retry settings, model metadata, project-instruction discovery, and optional extensions. +### Edit tools (`edit_tools`) + +The default edit tool is `hashline_edit`; the other edit tools are off unless you enable them. The `edit_tools` array in `config.json` controls which edit tools the model sees (and may execute): + +```json +"edit_tools": ["hashline_edit", "str_replace", "write", "apply_patch"] +``` + +Valid names are `hashline_edit`, `str_replace` (alias `edit`), `write`, and `apply_patch`. Omitting the field enables only `hashline_edit`; an empty array disables all edit tools. Disabled edit tools are not sent to the model and return a clear error if called anyway. Non-edit tools (`read`, `bash`, `ls`, `ripgrep`, `outline`, `checkpoint`, and so on) are not affected by this field. The desktop-only `browser_open` tool can be switched off with `"enable_browser_open": false`. + +File tools (read/write/edit/ls/outline/checkpoint/apply_patch) only operate on paths inside the working directory: absolute paths, `..`, and symlinks that resolve outside the workspace are rejected with a clear error. This is a path policy, not an OS sandbox — shell commands are not restricted by it. + ## Usage ### Interactive mode @@ -97,8 +109,16 @@ Useful commands: Headless mode emits JSONL events and finishes with a `final_result` event containing status, usage, context, tool-call counts, and elapsed time. +Headless runs default to the `read-only` approval mode: tool calls that would need interactive approval (edits, shell commands) are auto-denied with a clear message instead of hanging. Pass `--approval-mode` explicitly for tasks that change files or run commands: + +```bash +jucode --headless --approval-mode full-auto "Fix the failing test and verify the focused suite" +``` + +Read-only tasks work without a flag: + ```bash -jucode --headless "Fix the failing test and verify the focused suite" +jucode --headless "List the repository structure and stop." ``` You can also pipe the task through stdin: @@ -116,10 +136,10 @@ JuCode exposes a small set of direct tools to the model: | Tool | Purpose | | --- | --- | | `read` | Read text, image metadata/payload, or binary metadata. Supports `offset` and `limit`. | -| `str_replace` | Apply exact targeted replacements after reading a file. | -| `hashline_edit` | Patch lines using stable `LINE#HASH` anchors from `read`. | -| `write` | Create new files or overwrite previously read files. | -| `apply_patch` | Apply a unified patch when targeted edits are awkward. | +| `hashline_edit` | Patch lines using stable `LINE#HASH` anchors from `read`. The only edit tool enabled by default. | +| `str_replace` | Apply exact targeted replacements after reading a file. Off by default; enable via `edit_tools`. | +| `write` | Create new files or overwrite previously read files. Off by default; enable via `edit_tools`. | +| `apply_patch` | Apply a unified patch when targeted edits are awkward. Off by default; enable via `edit_tools`. | | `bash` / `exec_command` | Run shell commands with timeout, sessions, output truncation, and progress updates. | | `write_stdin` | Poll or send input to a running shell session. | | `ls` | List directory entries. | From 6f9aa5c0441f9724bf594518c2fb7184fcaa8233 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:48:53 +0000 Subject: [PATCH 6/6] Add PR CI with fmt, clippy -D warnings, and tests Co-authored-by: Gao Yu --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c6b9827 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + lint-test: + name: Lint and test (Linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --check + + - name: Clippy (deny warnings) + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Run tests + run: cargo test --workspace + + # The release workflow already builds Windows binaries, so keep Windows + # compiling on every PR. Tests are only run on Linux for now. + build-windows: + name: Build (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Build workspace (including tests) + run: cargo build --workspace --all-targets