Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,24 @@ call IDs and matching results, including errors and denied actions. Incomplete
native tool streams do not execute tools. Ollama keeps the JSON response format
for models that do not support native tools.

Approvals, workspace file controls, and command timeouts still apply. Shell
commands do not run in an OS sandbox. `/clear` removes chat and tool history.
Sessions stay in memory; closing the app removes them.
One execution policy checks each tool call in normal turns and audits. It allows
the call, denies it, or requests approval. Invalid arguments, conflicting
aliases, workspace escapes, and calls outside the audit scope are denied before
approval. Sensitive file reads and diffs require approval. These checks also
apply to the Ollama JSON fallback.

Approval covers the reviewed arguments and resolved targets. Execution checks
them again. If a target changes while approval is pending, the call fails and
must be submitted again. File changes reject symlink paths. Patch checks cover
all source and destination paths.

Shell commands require approval and show their resolved working directory. The
policy does not restrict paths or network targets inside shell command text.
Commands do not run in an OS sandbox. File access controls, public URL checks,
DNS checks, and timeouts still apply.

`/clear` removes chat and tool history. Sessions stay in memory; closing the app
removes them.

## Stop a turn

Expand Down
58 changes: 22 additions & 36 deletions src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::models::{
};
use crate::providers::{Provider, build_provider_with_secret_store};
use crate::secrets::{SETTING_MODEL, SETTING_PROVIDER, SecretMutationReport, SecretStore};
use crate::tools::{ToolRuntime, audit_tool_allowed, tool_can_interrupt};
use crate::tools::{ExecutionMode, PolicyDecision, ToolRuntime, tool_can_interrupt};

const MAX_STEPS: usize = 15;
const AUDIT_MAX_STEPS: usize = 30;
Expand Down Expand Up @@ -549,29 +549,27 @@ impl Agent {
);
continue;
}
if audit_scope.is_some() && !audit_tool_allowed(&call.name, allow_audit_mutations) {
self.record_tool_result(
&request,
serde_json::json!({"error": "unavailable in this audit mode"}),
true,
);
continue;
}

if native_tools && let Err(error) = crate::tools::schema::validate_call(call) {
self.record_tool_result(
&request,
serde_json::json!({"error": error.to_string()}),
true,
);
let mode = match audit_scope {
Some(scope) => ExecutionMode::Audit {
scope,
allow_mutations: allow_audit_mutations,
},
None => ExecutionMode::Workspace,
};
let mut prepared = self.tools.prepare_call(call, mode);
if let PolicyDecision::Deny(reason) = prepared.decision() {
self.record_tool_result(&request, serde_json::json!({"error": reason}), true);
continue;
}
let summary = self.tools.arg_summary(&call.name, &call.arguments);
if self.tools.call_requires_approval(call) {
let summary = self
.tools
.arg_summary(&prepared.call().name, &prepared.call().arguments);
if let PolicyDecision::RequireApproval(reason) = prepared.decision() {
let approval_summary = format!("{summary}; {reason}");
let (response, approval) = tokio::sync::oneshot::channel();
let _ = events.send(AgentEvent::ApprovalRequired {
name: call.name.clone(),
args_summary: summary.clone(),
args_summary: approval_summary,
response,
});
let approved = tokio::select! {
Expand All @@ -595,6 +593,7 @@ impl Agent {
);
continue;
}
prepared.approve();
}

if cancellation.is_cancelled() {
Expand All @@ -606,27 +605,14 @@ impl Agent {
continue;
}

let interruptible = tool_can_interrupt(&prepared.call().name);
let _ = events.send(AgentEvent::ToolCall {
name: call.name.clone(),
name: prepared.call().name.clone(),
args_summary: summary,
});

let operation = async {
match audit_scope {
Some(scope) => {
self.tools
.execute_audit(call, scope, allow_audit_mutations)
.await
}
None => {
self.tools
.execute_with_cancellation(call, cancellation)
.await
}
}
};
let operation = self.tools.execute_prepared(prepared, cancellation);
// File changes must finish or roll back. Commands handle process cleanup themselves.
let result = if tool_can_interrupt(&call.name) {
let result = if interruptible {
tokio::select! {
biased;
_ = cancellation.cancelled() => Err(anyhow::anyhow!("Tool cancelled")),
Expand Down
2 changes: 1 addition & 1 deletion src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ impl ConversationMessage {
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub name: String,
#[serde(default)]
Expand Down
84 changes: 76 additions & 8 deletions src/tests/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,14 +485,7 @@ async fn native_turn_records_batch_results_and_executes_approved_mutation_once()
result.unwrap();
assert_eq!(
events,
(
vec![
"writeFile".to_string(),
"readFile".to_string(),
"readFile".to_string()
],
1
)
(vec!["writeFile".to_string(), "readFile".to_string()], 1)
);
assert_eq!(
std::fs::read_to_string(directory.path().join("result.txt")).unwrap(),
Expand Down Expand Up @@ -929,3 +922,78 @@ async fn cancelling_an_approval_keeps_completed_changes_and_closes_remaining_cal
assert!(result.is_error);
}
}

#[tokio::test]
async fn policy_denies_invalid_and_outside_calls_before_approval_for_all_protocols() {
use crate::models::{AgentEvent, Completion, ToolCall};
use serde_json::json;
for native in [true, false] {
let directory = tempfile::tempdir().unwrap();
let workspace = directory.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let mut agent = test_agent(ProviderKind::OpenAi);
agent.tools = ToolRuntime::new(workspace).unwrap();
let calls = vec![
ToolCall {
name: "Write".to_string(),
arguments: json!({"file_path":"../blocked", "content":"no"}),
},
ToolCall {
name: "Bash".to_string(),
arguments: json!({"command":"must not run", "timeout":"30000"}),
},
];
let first = if native {
Completion {
tool_calls: calls
.into_iter()
.enumerate()
.map(|(index, call)| crate::models::ToolRequest {
id: format!("denied_{index}"),
provider_id: None,
call,
})
.collect(),
..Completion::default()
}
} else {
Completion::text(json!({"assistant":"Checking", "tool_calls":calls}).to_string())
};
let final_text = if native {
"No actions were run".to_string()
} else {
json!({"assistant":"No actions were run", "tool_calls":[]}).to_string()
};
agent.provider = Box::new(ScriptedProvider {
replies: std::sync::Mutex::new(std::collections::VecDeque::from([
first,
Completion::text(final_text),
])),
seen: Default::default(),
native,
});
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
agent
.handle_user_message(
ConversationMessage::user("inspect"),
tx,
&TurnCancellation::default(),
)
.await
.unwrap();
while let Ok(event) = rx.try_recv() {
assert!(!matches!(
event,
AgentEvent::ApprovalRequired { .. } | AgentEvent::ToolCall { .. }
));
}
let results = agent
.history
.iter()
.filter_map(|m| m.tool_result.as_ref())
.collect::<Vec<_>>();
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.is_error));
assert!(!directory.path().join("blocked").exists());
}
}
Loading
Loading