Skip to content
2 changes: 1 addition & 1 deletion FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Device pairing | ✅ | ❌ | |
| Tailscale identity | ✅ | ❌ | |
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth plus hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
| Per-group tool policies | ✅ | ❌ | |
Expand Down
46 changes: 39 additions & 7 deletions src/agent/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub(super) enum AgenticLoopResult {
/// A tool requires approval before continuing.
NeedApproval {
/// The pending approval request to store.
pending: PendingApproval,
pending: Box<PendingApproval>,
},
}

Expand Down Expand Up @@ -217,9 +217,7 @@ impl Agent {
reason: format!("Exceeded maximum tool iterations ({max_tool_iterations})"),
}
.into()),
LoopOutcome::NeedApproval(pending) => {
Ok(AgenticLoopResult::NeedApproval { pending: *pending })
}
LoopOutcome::NeedApproval(pending) => Ok(AgenticLoopResult::NeedApproval { pending }),
}
}

Expand Down Expand Up @@ -482,6 +480,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
usize,
crate::llm::ToolCall,
Arc<dyn crate::tools::Tool>,
bool, // allow_always
)> = None;

for (idx, original_tc) in tool_calls.iter().enumerate() {
Expand Down Expand Up @@ -551,7 +550,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
&& let Some(tool) = tool_opt
{
use crate::tools::ApprovalRequirement;
let needs_approval = match tool.requires_approval(&tc.arguments) {
let requirement = tool.requires_approval(&tc.arguments);
let needs_approval = match requirement {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = self.session.lock().await;
Expand Down Expand Up @@ -586,7 +586,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
continue;
}

approval_needed = Some((idx, tc, tool));
let allow_always = !matches!(requirement, ApprovalRequirement::Always);
approval_needed = Some((idx, tc, tool, allow_always));
break;
}
}
Expand Down Expand Up @@ -887,7 +888,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
}

// Handle approval if a tool needed it
if let Some((approval_idx, tc, tool)) = approval_needed {
if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
let display_params = redact_params(&tc.arguments, tool.sensitive_params());
let pending = PendingApproval {
request_id: Uuid::new_v4(),
Expand All @@ -899,6 +900,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
context_messages: reason_ctx.messages.clone(),
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
user_timezone: Some(self.user_tz.name().to_string()),
allow_always,
};

return Ok(Some(LoopOutcome::NeedApproval(Box::new(pending))));
Expand Down Expand Up @@ -1365,6 +1367,35 @@ mod tests {
assert!(always_needs, "Always must always require approval");
}

/// Regression test: `allow_always` must be `false` for `Always` and
/// `true` for `UnlessAutoApproved`, so the UI hides the "always" button
/// for tools that truly cannot be auto-approved.
#[test]
fn test_allow_always_matches_approval_requirement() {
use crate::tools::ApprovalRequirement;

// Mirrors the expression used in dispatcher.rs and thread_ops.rs:
// let allow_always = !matches!(requirement, ApprovalRequirement::Always);

// UnlessAutoApproved → allow_always = true
let req = ApprovalRequirement::UnlessAutoApproved;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(
allow_always,
"UnlessAutoApproved should set allow_always = true"
);

// Always → allow_always = false
let req = ApprovalRequirement::Always;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(!allow_always, "Always should set allow_always = false");

// Never → allow_always = true (approval is never needed, but if it were, always would be ok)
let req = ApprovalRequirement::Never;
let allow_always = !matches!(req, ApprovalRequirement::Always);
assert!(allow_always, "Never should set allow_always = true");
}

#[test]
fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
// PendingApproval from before the deferred_tool_calls field was added
Expand Down Expand Up @@ -1410,6 +1441,7 @@ mod tests {
},
],
user_timezone: None,
allow_always: true,
};

let json = serde_json::to_string(&pending).expect("serialize");
Expand Down
1 change: 1 addition & 0 deletions src/agent/job_monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ mod tests {
job_id: job_id.to_string(),
status: "completed".to_string(),
session_id: None,
fallback_deliverable: None,
},
))
.unwrap();
Expand Down
11 changes: 11 additions & 0 deletions src/agent/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ pub struct PendingApproval {
/// through the approval flow even if the approval message lacks timezone.
#[serde(default)]
pub user_timezone: Option<String>,
/// Whether the "always" auto-approve option should be offered to the user.
/// `false` when the tool returned `ApprovalRequirement::Always` (e.g.
/// destructive shell commands), meaning every invocation must be confirmed.
#[serde(default = "default_true")]
pub allow_always: bool,
}

fn default_true() -> bool {
true
}

/// A conversation thread within a session.
Expand Down Expand Up @@ -1106,6 +1115,7 @@ mod tests {
context_messages: vec![ChatMessage::user("do it")],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: false,
};

thread.await_approval(approval);
Expand All @@ -1132,6 +1142,7 @@ mod tests {
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: true,
};

thread.await_approval(approval);
Expand Down
2 changes: 2 additions & 0 deletions src/agent/submission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,8 @@ pub enum SubmissionResult {
description: String,
/// Parameters being passed.
parameters: serde_json::Value,
/// Whether "always" auto-approve should be offered to the user.
allow_always: bool,
},

/// Successfully processed (for control commands).
Expand Down
29 changes: 21 additions & 8 deletions src/agent/thread_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,8 @@ impl Agent {
let tool_name = pending.tool_name.clone();
let description = pending.description.clone();
let parameters = pending.display_parameters.clone();
thread.await_approval(pending);
let allow_always = pending.allow_always;
thread.await_approval(*pending);
let _ = self
.channels
.send_status(
Expand All @@ -516,6 +517,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
allow_always,
},
&message.metadata,
)
Expand All @@ -525,6 +527,7 @@ impl Agent {
tool_name,
description,
parameters,
allow_always,
})
}
Err(e) => {
Expand Down Expand Up @@ -1069,28 +1072,31 @@ impl Agent {
usize,
crate::llm::ToolCall,
Arc<dyn crate::tools::Tool>,
bool, // allow_always
)> = None;

for (idx, tc) in deferred_tool_calls.iter().enumerate() {
if let Some(tool) = self.tools().get(&tc.name).await {
// Match dispatcher.rs: when auto_approve_tools is true, skip
// all approval checks (including ApprovalRequirement::Always).
let needs_approval = if self.config.auto_approve_tools {
false
let (needs_approval, allow_always) = if self.config.auto_approve_tools {
(false, true)
} else {
use crate::tools::ApprovalRequirement;
match tool.requires_approval(&tc.arguments) {
let requirement = tool.requires_approval(&tc.arguments);
let needs = match requirement {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => {
let sess = session.lock().await;
!sess.is_tool_auto_approved(&tc.name)
}
ApprovalRequirement::Always => true,
}
};
(needs, !matches!(requirement, ApprovalRequirement::Always))
};

if needs_approval {
approval_needed = Some((idx, tc.clone(), tool));
approval_needed = Some((idx, tc.clone(), tool, allow_always));
break; // remaining tools stay deferred
}
}
Expand Down Expand Up @@ -1298,7 +1304,7 @@ impl Agent {
}

// Handle approval if a tool needed it
if let Some((approval_idx, tc, tool)) = approval_needed {
if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
let new_pending = PendingApproval {
request_id: Uuid::new_v4(),
tool_name: tc.name.clone(),
Expand All @@ -1310,6 +1316,7 @@ impl Agent {
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
// Carry forward the resolved timezone from the original pending approval
user_timezone: pending.user_timezone.clone(),
allow_always,
};

let request_id = new_pending.request_id;
Expand All @@ -1333,6 +1340,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
allow_always,
},
&message.metadata,
)
Expand All @@ -1343,6 +1351,7 @@ impl Agent {
tool_name,
description,
parameters,
allow_always,
});
}

Expand Down Expand Up @@ -1411,7 +1420,8 @@ impl Agent {
let tool_name = new_pending.tool_name.clone();
let description = new_pending.description.clone();
let parameters = new_pending.display_parameters.clone();
thread.await_approval(new_pending);
let allow_always = new_pending.allow_always;
thread.await_approval(*new_pending);
let _ = self
.channels
.send_status(
Expand All @@ -1421,6 +1431,7 @@ impl Agent {
tool_name: tool_name.clone(),
description: description.clone(),
parameters: parameters.clone(),
allow_always,
},
&message.metadata,
)
Expand All @@ -1430,6 +1441,7 @@ impl Agent {
tool_name,
description,
parameters,
allow_always,
})
}
Err(e) => {
Expand Down Expand Up @@ -1949,6 +1961,7 @@ mod tests {
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: false,
};
thread.await_approval(pending);

Expand Down
7 changes: 5 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use crate::tools::ToolRegistry;
use crate::tools::mcp::{McpProcessManager, McpSessionManager};
use crate::tools::wasm::SharedCredentialRegistry;
use crate::tools::wasm::WasmToolRuntime;
use crate::workspace::{EmbeddingProvider, Workspace};
use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, Workspace};

/// Fully initialized application components, ready for channel wiring
/// and agent construction.
Expand Down Expand Up @@ -313,10 +313,13 @@ impl AppBuilder {

// Register memory tools if database is available
let workspace = if let Some(ref db) = self.db {
let emb_cache_config = EmbeddingCacheConfig {
max_entries: self.config.embeddings.cache_size,
};
let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone())
.with_search_config(&self.config.search);
if let Some(ref emb) = embeddings {
ws = ws.with_embeddings(emb.clone());
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
}
let ws = Arc::new(ws);
tools.register_memory_tools(Arc::clone(&ws));
Expand Down
5 changes: 5 additions & 0 deletions src/channels/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,11 @@ pub enum StatusUpdate {
tool_name: String,
description: String,
parameters: serde_json::Value,
/// When `true`, the UI should offer an "always" option that auto-approves
/// future calls to this tool for the rest of the session. When `false`
/// (i.e. `ApprovalRequirement::Always`), the tool must be approved every
/// time and the "always" button should be hidden.
allow_always: bool,
},
/// Extension needs user authentication (token or OAuth).
AuthRequired {
Expand Down
5 changes: 5 additions & 0 deletions src/channels/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@ impl ChannelManager {
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.read().await.get(name).cloned()
}

/// Remove a channel from the manager.
pub async fn remove(&self, name: &str) -> Option<Arc<dyn Channel>> {
self.channels.write().await.remove(name)
}
}

impl Default for ChannelManager {
Expand Down
Loading
Loading