feat(engine): parallelize read-only shell calls
Closes #2983. Allow exec_shell to opt into the existing parallel tool lane when its concrete input is a conservative read-only shell command. The whitelist covers direct git/list/search/read commands plus simple bash/sh/zsh -c wrappers whose inner command passes the same classifier; anything effectful, interactive, redirected, piped, backgrounded, or stdin-fed remains serial and approval-gated. Parallel exec_shell fanout is capped at four workers, and explicit multi_tool_use.parallel now preserves request-order results while using the same input-aware checks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -355,6 +355,90 @@ pub fn prefix_allow_matches(pattern: &str, command: &str) -> bool {
|
|||||||
command_norm == pattern_norm || command_norm.starts_with(&format!("{pattern_norm} "))
|
command_norm == pattern_norm || command_norm.starts_with(&format!("{pattern_norm} "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PARALLEL_READONLY_PREFIXES: &[&str] = &[
|
||||||
|
"git status",
|
||||||
|
"git log",
|
||||||
|
"git diff",
|
||||||
|
"git show",
|
||||||
|
"git ls-files",
|
||||||
|
"git blame",
|
||||||
|
"git grep",
|
||||||
|
"ls",
|
||||||
|
"pwd",
|
||||||
|
"cat",
|
||||||
|
"head",
|
||||||
|
"tail",
|
||||||
|
"wc",
|
||||||
|
"which",
|
||||||
|
"stat",
|
||||||
|
"file",
|
||||||
|
"du",
|
||||||
|
"df",
|
||||||
|
"grep",
|
||||||
|
"rg",
|
||||||
|
"fd",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Return `true` when a shell command is safe to auto-approve and run in a
|
||||||
|
/// parallel read-only chunk.
|
||||||
|
pub fn is_parallel_readonly_command(command: &str) -> bool {
|
||||||
|
let trimmed = command.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if trimmed.contains("$(")
|
||||||
|
|| trimmed
|
||||||
|
.chars()
|
||||||
|
.any(|ch| matches!(ch, '\n' | '\r' | ';' | '&' | '|' | '>' | '<' | '`'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens = shell_words(trimmed);
|
||||||
|
let Some(start) = primary_token_index(&tokens) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let command_tokens = tokens[start..].to_vec();
|
||||||
|
|
||||||
|
if let Some(inner_command) = readonly_shell_wrapper_inner_command(&command_tokens) {
|
||||||
|
return is_parallel_readonly_command(inner_command);
|
||||||
|
}
|
||||||
|
|
||||||
|
let command_refs = command_tokens
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let canonical = classify_command(&command_refs);
|
||||||
|
if canonical == "tail"
|
||||||
|
&& command_refs.iter().skip(1).any(|token| {
|
||||||
|
*token == "-f"
|
||||||
|
|| *token == "-F"
|
||||||
|
|| *token == "--follow"
|
||||||
|
|| token.starts_with("--follow=")
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PARALLEL_READONLY_PREFIXES
|
||||||
|
.iter()
|
||||||
|
.any(|prefix| *prefix == canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn readonly_shell_wrapper_inner_command(tokens: &[String]) -> Option<&str> {
|
||||||
|
let shell = tokens.first()?.as_str();
|
||||||
|
if !matches!(shell, "bash" | "sh" | "zsh") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if tokens.len() != 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !matches!(tokens[1].as_str(), "-c" | "-lc") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(tokens[2].as_str())
|
||||||
|
}
|
||||||
|
|
||||||
/// Safety classification of a command
|
/// Safety classification of a command
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SafetyLevel {
|
pub enum SafetyLevel {
|
||||||
@@ -1037,6 +1121,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parallel_readonly_command_classifier_is_strict() {
|
||||||
|
for command in [
|
||||||
|
"git status -s",
|
||||||
|
"git log --oneline -5",
|
||||||
|
"rg foo crates/",
|
||||||
|
"ls -la",
|
||||||
|
"cat Cargo.toml",
|
||||||
|
"bash -lc 'git status -s'",
|
||||||
|
"sh -c 'rg foo crates/'",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
is_parallel_readonly_command(command),
|
||||||
|
"{command} should be parallel read-only"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for command in [
|
||||||
|
"git status && rm -rf /",
|
||||||
|
"cat a > b",
|
||||||
|
"git push",
|
||||||
|
"cargo build",
|
||||||
|
"tail -f log",
|
||||||
|
"rg foo | head",
|
||||||
|
"find . -delete",
|
||||||
|
"sleep 5 &",
|
||||||
|
"bash -lc 'git status && rm -rf /'",
|
||||||
|
"bash -lc 'rg foo | head'",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!is_parallel_readonly_command(command),
|
||||||
|
"{command} should not be parallel read-only"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_workspace_safe_commands() {
|
fn test_workspace_safe_commands() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -2849,6 +2849,8 @@ mod tool_setup;
|
|||||||
mod turn_loop;
|
mod turn_loop;
|
||||||
pub(crate) use token_estimate_cache::TokenEstimateCache;
|
pub(crate) use token_estimate_cache::TokenEstimateCache;
|
||||||
|
|
||||||
|
pub(super) const MAX_PARALLEL_SHELL_EXEC: usize = 4;
|
||||||
|
|
||||||
pub(crate) fn default_active_native_tool_names() -> &'static [&'static str] {
|
pub(crate) fn default_active_native_tool_names() -> &'static [&'static str] {
|
||||||
tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS
|
tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -488,6 +488,48 @@ fn tool_execution_batches_use_serial_barriers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_readonly_plans_batch_around_serial_barrier() {
|
||||||
|
let mut shell_a = make_plan_at(0, true, true, false, false);
|
||||||
|
shell_a.name = "exec_shell".to_string();
|
||||||
|
shell_a.input = json!({"command": "git status -s"});
|
||||||
|
let mut shell_b = make_plan_at(1, true, true, false, false);
|
||||||
|
shell_b.name = "exec_shell".to_string();
|
||||||
|
shell_b.input = json!({"command": "git log --oneline -5"});
|
||||||
|
let mut write_shell = make_plan_at(2, false, false, true, false);
|
||||||
|
write_shell.name = "exec_shell".to_string();
|
||||||
|
write_shell.input = json!({"command": "cargo build"});
|
||||||
|
let mut shell_c = make_plan_at(3, true, true, false, false);
|
||||||
|
shell_c.name = "exec_shell".to_string();
|
||||||
|
shell_c.input = json!({"command": "bash -lc 'rg TODO crates/tui/src/core'"});
|
||||||
|
|
||||||
|
let batches = plan_tool_execution_batches(vec![shell_a, shell_b, write_shell, shell_c]);
|
||||||
|
assert_eq!(batches.len(), 3);
|
||||||
|
|
||||||
|
match &batches[0] {
|
||||||
|
ToolExecutionBatch::Parallel(plans) => {
|
||||||
|
assert_eq!(
|
||||||
|
plans.iter().map(|plan| plan.index).collect::<Vec<_>>(),
|
||||||
|
vec![0, 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ToolExecutionBatch::Serial(_) => panic!("first batch should be parallel"),
|
||||||
|
}
|
||||||
|
match &batches[1] {
|
||||||
|
ToolExecutionBatch::Serial(plan) => assert_eq!(plan.index, 2),
|
||||||
|
ToolExecutionBatch::Parallel(_) => panic!("write shell should be a serial barrier"),
|
||||||
|
}
|
||||||
|
match &batches[2] {
|
||||||
|
ToolExecutionBatch::Parallel(plans) => {
|
||||||
|
assert_eq!(
|
||||||
|
plans.iter().map(|plan| plan.index).collect::<Vec<_>>(),
|
||||||
|
vec![3]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ToolExecutionBatch::Serial(_) => panic!("third batch should be parallel"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn successful_update_plan_ends_plan_mode_turn_immediately() {
|
fn successful_update_plan_ends_plan_mode_turn_immediately() {
|
||||||
assert!(should_stop_after_plan_tool(
|
assert!(should_stop_after_plan_tool(
|
||||||
|
|||||||
@@ -185,8 +185,10 @@ impl Engine {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let result_count = calls.len();
|
||||||
let mut tasks = FuturesUnordered::new();
|
let mut tasks = FuturesUnordered::new();
|
||||||
for (tool_name, tool_input) in calls {
|
let shell_permits = Arc::new(tokio::sync::Semaphore::new(MAX_PARALLEL_SHELL_EXEC));
|
||||||
|
for (index, (tool_name, tool_input)) in calls.into_iter().enumerate() {
|
||||||
if tool_name == MULTI_TOOL_PARALLEL_NAME {
|
if tool_name == MULTI_TOOL_PARALLEL_NAME {
|
||||||
return Err(ToolError::invalid_input(
|
return Err(ToolError::invalid_input(
|
||||||
"multi_tool_use.parallel cannot call itself",
|
"multi_tool_use.parallel cannot call itself",
|
||||||
@@ -206,17 +208,17 @@ impl Engine {
|
|||||||
"tool '{tool_name}' is not registered"
|
"tool '{tool_name}' is not registered"
|
||||||
)));
|
)));
|
||||||
};
|
};
|
||||||
if !spec.is_read_only() {
|
if !spec.is_read_only_for(&tool_input) {
|
||||||
return Err(ToolError::invalid_input(format!(
|
return Err(ToolError::invalid_input(format!(
|
||||||
"Tool '{tool_name}' is not read-only and cannot run in parallel"
|
"Tool '{tool_name}' is not read-only and cannot run in parallel"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if spec.approval_requirement() != ApprovalRequirement::Auto {
|
if spec.approval_requirement_for(&tool_input) != ApprovalRequirement::Auto {
|
||||||
return Err(ToolError::invalid_input(format!(
|
return Err(ToolError::invalid_input(format!(
|
||||||
"Tool '{tool_name}' requires approval and cannot run in parallel"
|
"Tool '{tool_name}' requires approval and cannot run in parallel"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if !spec.supports_parallel() {
|
if !spec.supports_parallel_for(&tool_input) {
|
||||||
return Err(ToolError::invalid_input(format!(
|
return Err(ToolError::invalid_input(format!(
|
||||||
"Tool '{tool_name}' does not support parallel execution"
|
"Tool '{tool_name}' does not support parallel execution"
|
||||||
)));
|
)));
|
||||||
@@ -227,7 +229,13 @@ impl Engine {
|
|||||||
let lock = tool_exec_lock.clone();
|
let lock = tool_exec_lock.clone();
|
||||||
let tx_event = self.tx_event.clone();
|
let tx_event = self.tx_event.clone();
|
||||||
let mcp_pool = mcp_pool.clone();
|
let mcp_pool = mcp_pool.clone();
|
||||||
|
let shell_permits = shell_permits.clone();
|
||||||
tasks.push(async move {
|
tasks.push(async move {
|
||||||
|
let _shell_permit = if tool_name == "exec_shell" {
|
||||||
|
shell_permits.acquire_owned().await.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let result = Engine::execute_tool_with_lock(
|
let result = Engine::execute_tool_with_lock(
|
||||||
lock,
|
lock,
|
||||||
true,
|
true,
|
||||||
@@ -240,36 +248,39 @@ impl Engine {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
(tool_name, result)
|
(index, tool_name, result)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut results = Vec::new();
|
let mut results: Vec<Option<ParallelToolResultEntry>> = Vec::with_capacity(result_count);
|
||||||
while let Some((tool_name, result)) = tasks.next().await {
|
results.resize_with(result_count, || None);
|
||||||
match result {
|
while let Some((index, tool_name, result)) = tasks.next().await {
|
||||||
|
let entry = match result {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
let mut error = None;
|
let mut error = None;
|
||||||
if !output.success {
|
if !output.success {
|
||||||
error = Some(output.content.clone());
|
error = Some(output.content.clone());
|
||||||
}
|
}
|
||||||
results.push(ParallelToolResultEntry {
|
ParallelToolResultEntry {
|
||||||
tool_name,
|
tool_name,
|
||||||
success: output.success,
|
success: output.success,
|
||||||
content: output.content,
|
content: output.content,
|
||||||
error,
|
error,
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let message = format!("{err}");
|
let message = format!("{err}");
|
||||||
results.push(ParallelToolResultEntry {
|
ParallelToolResultEntry {
|
||||||
tool_name,
|
tool_name,
|
||||||
success: false,
|
success: false,
|
||||||
content: format!("Error: {message}"),
|
content: format!("Error: {message}"),
|
||||||
error: Some(message),
|
error: Some(message),
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
results[index] = Some(entry);
|
||||||
}
|
}
|
||||||
|
let results = results.into_iter().flatten().collect();
|
||||||
|
|
||||||
ToolResult::json(&ParallelToolResult { results })
|
ToolResult::json(&ParallelToolResult { results })
|
||||||
.map_err(|e| ToolError::execution_failed(e.to_string()))
|
.map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||||
@@ -381,7 +392,7 @@ impl Engine {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::{sync::Mutex, time::Duration};
|
use std::{ffi::OsString, path::Path, sync::Mutex, time::Duration};
|
||||||
|
|
||||||
/// Tests in this module mutate `DEEPSEEK_TOOL_AUDIT_LOG` which is
|
/// Tests in this module mutate `DEEPSEEK_TOOL_AUDIT_LOG` which is
|
||||||
/// process-global; serialise through this guard so the parallel
|
/// process-global; serialise through this guard so the parallel
|
||||||
@@ -392,6 +403,43 @@ mod tests {
|
|||||||
AUDIT_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner())
|
AUDIT_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AuditEnvGuard {
|
||||||
|
previous: Option<OsString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuditEnvGuard {
|
||||||
|
fn set(path: &Path) -> Self {
|
||||||
|
let previous = std::env::var_os("DEEPSEEK_TOOL_AUDIT_LOG");
|
||||||
|
// SAFETY: serialised by the guard above.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("DEEPSEEK_TOOL_AUDIT_LOG", path);
|
||||||
|
}
|
||||||
|
Self { previous }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unset() -> Self {
|
||||||
|
let previous = std::env::var_os("DEEPSEEK_TOOL_AUDIT_LOG");
|
||||||
|
// SAFETY: serialised by the guard above.
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("DEEPSEEK_TOOL_AUDIT_LOG");
|
||||||
|
}
|
||||||
|
Self { previous }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AuditEnvGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: callers hold AUDIT_TEST_GUARD for this guard's lifetime.
|
||||||
|
unsafe {
|
||||||
|
if let Some(previous) = self.previous.take() {
|
||||||
|
std::env::set_var("DEEPSEEK_TOOL_AUDIT_LOG", previous);
|
||||||
|
} else {
|
||||||
|
std::env::remove_var("DEEPSEEK_TOOL_AUDIT_LOG");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn terminal_guard_queues_resume_when_event_channel_is_full() {
|
async fn terminal_guard_queues_resume_when_event_channel_is_full() {
|
||||||
let (tx, mut rx) = mpsc::channel(1);
|
let (tx, mut rx) = mpsc::channel(1);
|
||||||
@@ -443,29 +491,35 @@ mod tests {
|
|||||||
let _g = audit_test_guard();
|
let _g = audit_test_guard();
|
||||||
let tmp = tempfile::tempdir().expect("tempdir");
|
let tmp = tempfile::tempdir().expect("tempdir");
|
||||||
let path = tmp.path().join("audit.log");
|
let path = tmp.path().join("audit.log");
|
||||||
// SAFETY: serialised by the guard above.
|
let _env = AuditEnvGuard::set(&path);
|
||||||
unsafe {
|
let marker = path.display().to_string();
|
||||||
std::env::set_var("DEEPSEEK_TOOL_AUDIT_LOG", &path);
|
|
||||||
}
|
|
||||||
|
|
||||||
emit_tool_audit(json!({
|
emit_tool_audit(json!({
|
||||||
"event": "tool.spillover",
|
"event": "tool.spillover",
|
||||||
|
"test_marker": marker,
|
||||||
"tool_id": "call-abc",
|
"tool_id": "call-abc",
|
||||||
"tool_name": "exec_shell",
|
"tool_name": "exec_shell",
|
||||||
"path": "/tmp/foo.txt",
|
"path": "/tmp/foo.txt",
|
||||||
}));
|
}));
|
||||||
emit_tool_audit(json!({
|
emit_tool_audit(json!({
|
||||||
"event": "tool.result",
|
"event": "tool.result",
|
||||||
|
"test_marker": marker,
|
||||||
"tool_id": "call-xyz",
|
"tool_id": "call-xyz",
|
||||||
"success": true,
|
"success": true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let body = std::fs::read_to_string(&path).expect("audit log written");
|
let body = std::fs::read_to_string(&path).expect("audit log written");
|
||||||
let lines: Vec<&str> = body.lines().collect();
|
let entries: Vec<serde_json::Value> = body
|
||||||
assert_eq!(lines.len(), 2, "two emits → two lines");
|
.lines()
|
||||||
|
.map(|line| serde_json::from_str(line).expect("audit line is JSON"))
|
||||||
|
.filter(|entry: &serde_json::Value| {
|
||||||
|
entry.get("test_marker").and_then(|v| v.as_str()) == Some(marker.as_str())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(entries.len(), 2, "two marked emits -> two lines");
|
||||||
|
|
||||||
// Each line round-trips as JSON, has the expected event key.
|
// Each line round-trips as JSON, has the expected event key.
|
||||||
let first: serde_json::Value = serde_json::from_str(lines[0]).expect("first line is JSON");
|
let first = &entries[0];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
first.get("event").and_then(|v| v.as_str()),
|
first.get("event").and_then(|v| v.as_str()),
|
||||||
Some("tool.spillover")
|
Some("tool.spillover")
|
||||||
@@ -475,26 +529,17 @@ mod tests {
|
|||||||
Some("call-abc")
|
Some("call-abc")
|
||||||
);
|
);
|
||||||
|
|
||||||
let second: serde_json::Value =
|
let second = &entries[1];
|
||||||
serde_json::from_str(lines[1]).expect("second line is JSON");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
second.get("event").and_then(|v| v.as_str()),
|
second.get("event").and_then(|v| v.as_str()),
|
||||||
Some("tool.result")
|
Some("tool.result")
|
||||||
);
|
);
|
||||||
|
|
||||||
// SAFETY: cleanup under the guard.
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("DEEPSEEK_TOOL_AUDIT_LOG");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emit_tool_audit_is_noop_when_env_var_unset() {
|
fn emit_tool_audit_is_noop_when_env_var_unset() {
|
||||||
let _g = audit_test_guard();
|
let _g = audit_test_guard();
|
||||||
// SAFETY: serialised by the guard above.
|
let _env = AuditEnvGuard::unset();
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("DEEPSEEK_TOOL_AUDIT_LOG");
|
|
||||||
}
|
|
||||||
// Should not panic and should not create any file. We can't
|
// Should not panic and should not create any file. We can't
|
||||||
// assert "no file written" without knowing where one might be
|
// assert "no file written" without knowing where one might be
|
||||||
// written, but the contract is "do nothing", which we verify
|
// written, but the contract is "do nothing", which we verify
|
||||||
@@ -510,16 +555,8 @@ mod tests {
|
|||||||
// Path with a parent that doesn't exist yet — the writer
|
// Path with a parent that doesn't exist yet — the writer
|
||||||
// should create it.
|
// should create it.
|
||||||
let nested = tmp.path().join("nested").join("dir").join("audit.log");
|
let nested = tmp.path().join("nested").join("dir").join("audit.log");
|
||||||
// SAFETY: serialised by the guard above.
|
let _env = AuditEnvGuard::set(&nested);
|
||||||
unsafe {
|
|
||||||
std::env::set_var("DEEPSEEK_TOOL_AUDIT_LOG", &nested);
|
|
||||||
}
|
|
||||||
emit_tool_audit(json!({"event": "test"}));
|
emit_tool_audit(json!({"event": "test"}));
|
||||||
assert!(nested.exists(), "writer should mkdir -p the parent chain");
|
assert!(nested.exists(), "writer should mkdir -p the parent chain");
|
||||||
|
|
||||||
// SAFETY: cleanup under the guard.
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("DEEPSEEK_TOOL_AUDIT_LOG");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1530,10 +1530,11 @@ impl Engine {
|
|||||||
} else if let Some(registry) = tool_registry
|
} else if let Some(registry) = tool_registry
|
||||||
&& let Some(spec) = registry.get(&tool_name)
|
&& let Some(spec) = registry.get(&tool_name)
|
||||||
{
|
{
|
||||||
approval_required = spec.approval_requirement() != ApprovalRequirement::Auto;
|
approval_required =
|
||||||
|
spec.approval_requirement_for(&tool_input) != ApprovalRequirement::Auto;
|
||||||
approval_description = spec.description().to_string();
|
approval_description = spec.description().to_string();
|
||||||
supports_parallel = spec.supports_parallel();
|
supports_parallel = spec.supports_parallel_for(&tool_input);
|
||||||
read_only = spec.is_read_only();
|
read_only = spec.is_read_only_for(&tool_input);
|
||||||
} else if tool_name == CODE_EXECUTION_TOOL_NAME {
|
} else if tool_name == CODE_EXECUTION_TOOL_NAME {
|
||||||
approval_required = true;
|
approval_required = true;
|
||||||
approval_description =
|
approval_description =
|
||||||
@@ -1666,6 +1667,8 @@ impl Engine {
|
|||||||
|
|
||||||
if parallel_allowed {
|
if parallel_allowed {
|
||||||
let mut tool_tasks = FuturesUnordered::new();
|
let mut tool_tasks = FuturesUnordered::new();
|
||||||
|
let shell_permits =
|
||||||
|
Arc::new(tokio::sync::Semaphore::new(MAX_PARALLEL_SHELL_EXEC));
|
||||||
for plan in plans {
|
for plan in plans {
|
||||||
if let Some(result) = plan.guard_result.clone() {
|
if let Some(result) = plan.guard_result.clone() {
|
||||||
let result = Ok(result);
|
let result = Ok(result);
|
||||||
@@ -1704,8 +1707,14 @@ impl Engine {
|
|||||||
let tx_event = self.tx_event.clone();
|
let tx_event = self.tx_event.clone();
|
||||||
let session_id = self.session.id.clone();
|
let session_id = self.session.id.clone();
|
||||||
let started_at = Instant::now();
|
let started_at = Instant::now();
|
||||||
|
let shell_permits = shell_permits.clone();
|
||||||
|
|
||||||
tool_tasks.push(async move {
|
tool_tasks.push(async move {
|
||||||
|
let _shell_permit = if plan.name == "exec_shell" {
|
||||||
|
shell_permits.acquire_owned().await.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let mut result = Engine::execute_tool_with_lock(
|
let mut result = Engine::execute_tool_with_lock(
|
||||||
lock,
|
lock,
|
||||||
plan.supports_parallel,
|
plan.supports_parallel,
|
||||||
|
|||||||
@@ -1768,7 +1768,9 @@ pub fn new_shared_shell_manager(workspace: PathBuf) -> SharedShellManager {
|
|||||||
|
|
||||||
// === ToolSpec Implementations ===
|
// === ToolSpec Implementations ===
|
||||||
|
|
||||||
use crate::command_safety::{SafetyLevel, analyze_command, extract_primary_command};
|
use crate::command_safety::{
|
||||||
|
SafetyLevel, analyze_command, extract_primary_command, is_parallel_readonly_command,
|
||||||
|
};
|
||||||
use crate::execpolicy::{ExecPolicyDecision, load_default_policy};
|
use crate::execpolicy::{ExecPolicyDecision, load_default_policy};
|
||||||
use crate::features::Feature;
|
use crate::features::Feature;
|
||||||
use crate::tools::cargo_failure_summary::summarize_cargo_failure;
|
use crate::tools::cargo_failure_summary::summarize_cargo_failure;
|
||||||
@@ -1913,6 +1915,26 @@ fn shell_network_restricted_hint<'a>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn exec_shell_input_is_parallel_readonly(input: &serde_json::Value) -> bool {
|
||||||
|
let Some(command) = input.get("command").and_then(serde_json::Value::as_str) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if ["background", "interactive", "tty", "combined_output"]
|
||||||
|
.iter()
|
||||||
|
.any(|key| input.get(*key).and_then(serde_json::Value::as_bool) == Some(true))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ["stdin", "input", "data"]
|
||||||
|
.iter()
|
||||||
|
.any(|key| input.get(*key).is_some())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_parallel_readonly_command(command)
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute_foreground_via_background(
|
async fn execute_foreground_via_background(
|
||||||
context: &ToolContext,
|
context: &ToolContext,
|
||||||
command: &str,
|
command: &str,
|
||||||
@@ -2061,6 +2083,22 @@ impl ToolSpec for ExecShellTool {
|
|||||||
ApprovalRequirement::Required
|
ApprovalRequirement::Required
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn approval_requirement_for(&self, input: &serde_json::Value) -> ApprovalRequirement {
|
||||||
|
if exec_shell_input_is_parallel_readonly(input) {
|
||||||
|
ApprovalRequirement::Auto
|
||||||
|
} else {
|
||||||
|
self.approval_requirement()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_read_only_for(&self, input: &serde_json::Value) -> bool {
|
||||||
|
exec_shell_input_is_parallel_readonly(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_parallel_for(&self, input: &serde_json::Value) -> bool {
|
||||||
|
exec_shell_input_is_parallel_readonly(input)
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
input: serde_json::Value,
|
input: serde_json::Value,
|
||||||
|
|||||||
@@ -148,6 +148,41 @@ fn wait_for_completed_shell(manager: &mut ShellManager, task_id: &str) -> ShellR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exec_shell_parallel_flags_are_input_aware() {
|
||||||
|
let tool = ExecShellTool;
|
||||||
|
let readonly = json!({"command": "git status -s"});
|
||||||
|
assert!(tool.supports_parallel_for(&readonly));
|
||||||
|
assert!(tool.is_read_only_for(&readonly));
|
||||||
|
assert_eq!(
|
||||||
|
tool.approval_requirement_for(&readonly),
|
||||||
|
ApprovalRequirement::Auto
|
||||||
|
);
|
||||||
|
|
||||||
|
let bash_readonly = json!({"command": "bash -lc 'rg TODO crates/tui/src/tools'"});
|
||||||
|
assert!(tool.supports_parallel_for(&bash_readonly));
|
||||||
|
assert!(tool.is_read_only_for(&bash_readonly));
|
||||||
|
assert_eq!(
|
||||||
|
tool.approval_requirement_for(&bash_readonly),
|
||||||
|
ApprovalRequirement::Auto
|
||||||
|
);
|
||||||
|
|
||||||
|
for input in [
|
||||||
|
json!({"command": "git status -s", "background": true}),
|
||||||
|
json!({"command": "git status -s", "stdin": ""}),
|
||||||
|
json!({"command": "cargo build"}),
|
||||||
|
json!({"command": "bash -lc 'rg TODO crates | head'"}),
|
||||||
|
] {
|
||||||
|
assert!(!tool.supports_parallel_for(&input), "{input:?}");
|
||||||
|
assert!(!tool.is_read_only_for(&input), "{input:?}");
|
||||||
|
assert_eq!(
|
||||||
|
tool.approval_requirement_for(&input),
|
||||||
|
ApprovalRequirement::Required,
|
||||||
|
"{input:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn shell_execution_scrubs_parent_env_and_keeps_explicit_env() {
|
fn shell_execution_scrubs_parent_env_and_keeps_explicit_env() {
|
||||||
|
|||||||
@@ -643,6 +643,11 @@ pub trait ToolSpec: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the approval requirement for this concrete tool input.
|
||||||
|
fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement {
|
||||||
|
self.approval_requirement()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns whether this tool is sandboxable.
|
/// Returns whether this tool is sandboxable.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn is_sandboxable(&self) -> bool {
|
fn is_sandboxable(&self) -> bool {
|
||||||
@@ -657,11 +662,21 @@ pub trait ToolSpec: Send + Sync {
|
|||||||
&& !caps.contains(&ToolCapability::ExecutesCode)
|
&& !caps.contains(&ToolCapability::ExecutesCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns whether this concrete tool input is read-only.
|
||||||
|
fn is_read_only_for(&self, _input: &Value) -> bool {
|
||||||
|
self.is_read_only()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns whether this tool can be executed in parallel with others.
|
/// Returns whether this tool can be executed in parallel with others.
|
||||||
fn supports_parallel(&self) -> bool {
|
fn supports_parallel(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns whether this concrete tool input can run in parallel.
|
||||||
|
fn supports_parallel_for(&self, _input: &Value) -> bool {
|
||||||
|
self.supports_parallel()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns whether this tool should be excluded from the model-visible
|
/// Returns whether this tool should be excluded from the model-visible
|
||||||
/// tool catalog (deferred loading). Tools marked `true` are registered
|
/// tool catalog (deferred loading). Tools marked `true` are registered
|
||||||
/// but not sent to the model until explicitly activated via tool search.
|
/// but not sent to the model until explicitly activated via tool search.
|
||||||
|
|||||||
Reference in New Issue
Block a user