mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
* feat(admin): admin tool policy to disable tools for users (#2078) Adds the ability for admins to disable specific tools (e.g. build_software, tool_install, skill_install) for all non-admin users or specific users in multi-tenant deployments. - AdminToolPolicy stored in settings table under __admin__ scope - GET/PUT /api/admin/tool-policy endpoints (admin-only, multi-tenant gated) - Enforcement in dispatcher before_llm_call strips disabled tools from LLM context - Admin users are exempt from the policy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: enforce admin tool policy across all execution paths - Extract inline filtering into shared `filter_admin_disabled_tools()` helper - Apply in JobDelegate and ContainerDelegate (not just ChatDelegate) - Change from fail-open to fail-closed: DB errors return empty tool list - Log warnings on deserialization failures instead of silent fallback - Add `multi_tenant` field to WorkerDeps for job-level enforcement - Add `db()` accessor to SystemScope for system-level DB access Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: tighten admin tool policy review follow-ups * fix(admin-policy): enforce ordering and add dispatcher e2e regression * fix(admin-policy): address review feedback — encapsulate DB access, cache policy, strengthen validation - Remove raw `SystemScope::db()` accessor; add purpose-built `get_admin_tool_policy()`, `set_admin_tool_policy()`, `get_user_role()` methods to preserve tenant isolation boundary - Canonicalize admin role detection: add `UserRole::is_admin()` helper, replace string comparisons in engine.rs and role enum comparisons across dispatcher/job delegates with the single canonical path - Cache admin tool policy per agentic loop via `AdminToolPolicyCache` (tokio::sync::OnceCell) to avoid DB reads on every LLM iteration - Switch `disabled_tools` from Vec<String> to HashSet<String> for O(1) lookups - Extract shared `validate_admin_tool_policy()` with tool name format checks, user key validation, and 32KB max payload size; deduplicate from HTTP handler - Add `parse_admin_tool_policy()` helper with tracing::warn on deserialization failure (was silently falling back to default) - Document PUT endpoint's last-write-wins replacement semantics - Add regression tests: path-like tool names, invalid user keys, oversized policy, and E2E test verifying disabled tools don't reach the LLM Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(admin-policy): entry count caps, fail-closed GET, dispatch-exempt annotation - Add entry count limits: 1000 global disabled tools, 1000 user keys, 1000 per-user entries — prevents multi-MB policy payloads - GET handler now returns 500 on corrupt stored policy instead of silently falling back to empty default (fail-closed, consistent with enforcement) - Add dispatch-exempt annotation explaining why these admin handlers access state.store directly (consistent with users/secrets/tokens handlers) - Remove redundant debug_assert_ne in users_create_handler (runtime guard already covers both debug and release builds) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(admin-policy): merge staging, add inline dispatch-exempt annotations Merge staging to pick up ToolDispatcher (#2049) and the "everything goes through tools" pre-commit check. Add inline // dispatch-exempt: annotations on the state.store access lines so the pre-commit check passes. These handlers are admin-only infrastructure operating on a cross-tenant policy scope — consistent with other admin handlers that haven't been migrated to the dispatcher yet. Also fix post-merge compilation: add auth_manager and tool_dispatcher fields to test struct initializers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(admin-policy): satisfy per-line dispatch-exempt check on all state.* accesses The pre-commit hook (scripts/pre-commit-safety.sh) checks each added line that touches state.{store,workspace_pool,...} for a trailing // dispatch-exempt: comment on the same physical line. The previous annotations were either: - on the workspace_pool lines: missing entirely - on the store.as_ref().ok_or(( lines: rustfmt-broken because the trailing comment was placed inside the tuple, where the per-line check no longer matches it Lift each state.workspace_pool / state.store access into a dedicated local binding that carries the trailing dispatch-exempt comment, so the annotation survives cargo fmt and the per-line hook regex matches. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
132 lines
4.1 KiB
Rust
132 lines
4.1 KiB
Rust
//! End-to-end regression test for admin tool policy enforcement.
|
|
//!
|
|
//! Verifies that a tool disabled by the admin policy is removed from the
|
|
//! actual tool list passed to the LLM in a multi-tenant chat turn.
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod support;
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod tests {
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use ironclaw::channels::IncomingMessage;
|
|
use ironclaw::config::Config;
|
|
use ironclaw::llm::{
|
|
CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ToolCompletionRequest,
|
|
ToolCompletionResponse,
|
|
};
|
|
use ironclaw::tools::permissions::{ADMIN_SETTINGS_USER_ID, ADMIN_TOOL_POLICY_KEY};
|
|
use rust_decimal::Decimal;
|
|
|
|
use crate::support::test_rig::TestRigBuilder;
|
|
|
|
#[derive(Default)]
|
|
struct RecordingToolsProvider {
|
|
seen_tools: Mutex<Vec<Vec<String>>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl LlmProvider for RecordingToolsProvider {
|
|
fn model_name(&self) -> &str {
|
|
"recording-tools-e2e"
|
|
}
|
|
|
|
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
|
(Decimal::ZERO, Decimal::ZERO)
|
|
}
|
|
|
|
async fn complete(
|
|
&self,
|
|
_request: CompletionRequest,
|
|
) -> Result<CompletionResponse, ironclaw::error::LlmError> {
|
|
Ok(CompletionResponse {
|
|
content: "ok".to_string(),
|
|
input_tokens: 0,
|
|
output_tokens: 1,
|
|
finish_reason: FinishReason::Stop,
|
|
cache_read_input_tokens: 0,
|
|
cache_creation_input_tokens: 0,
|
|
})
|
|
}
|
|
|
|
async fn complete_with_tools(
|
|
&self,
|
|
request: ToolCompletionRequest,
|
|
) -> Result<ToolCompletionResponse, ironclaw::error::LlmError> {
|
|
let names: Vec<String> = request.tools.iter().map(|tool| tool.name.clone()).collect();
|
|
self.seen_tools
|
|
.lock()
|
|
.expect("recording tools mutex poisoned")
|
|
.push(names);
|
|
Ok(ToolCompletionResponse {
|
|
content: Some("ok".to_string()),
|
|
tool_calls: Vec::new(),
|
|
input_tokens: 0,
|
|
output_tokens: 1,
|
|
finish_reason: FinishReason::Stop,
|
|
cache_read_input_tokens: 0,
|
|
cache_creation_input_tokens: 0,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_disabled_tool_does_not_reach_llm() {
|
|
let mut config = Config::for_testing(
|
|
PathBuf::from("ignored.db"),
|
|
PathBuf::from("ignored-skills"),
|
|
PathBuf::from("ignored-installed-skills"),
|
|
);
|
|
config.agent.multi_tenant = true;
|
|
|
|
let llm = Arc::new(RecordingToolsProvider::default());
|
|
let llm_provider: Arc<dyn LlmProvider> = llm.clone();
|
|
let rig = TestRigBuilder::new()
|
|
.with_config(config)
|
|
.with_llm(llm_provider)
|
|
.build()
|
|
.await;
|
|
|
|
rig.database()
|
|
.set_setting(
|
|
ADMIN_SETTINGS_USER_ID,
|
|
ADMIN_TOOL_POLICY_KEY,
|
|
&serde_json::json!({
|
|
"disabled_tools": ["echo"]
|
|
}),
|
|
)
|
|
.await
|
|
.expect("failed to persist admin tool policy");
|
|
|
|
rig.send_incoming(IncomingMessage::new("test", "member-user", "hello"))
|
|
.await;
|
|
let _responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
|
|
let calls = llm
|
|
.seen_tools
|
|
.lock()
|
|
.expect("recording tools mutex poisoned")
|
|
.clone();
|
|
assert!(
|
|
!calls.is_empty(),
|
|
"the LLM should have received at least one tool-enabled request"
|
|
);
|
|
assert!(
|
|
!calls[0].iter().any(|tool| tool == "echo"),
|
|
"admin-disabled tool leaked into the LLM tool list: {:?}",
|
|
calls[0]
|
|
);
|
|
assert!(
|
|
calls[0].iter().any(|tool| tool == "time"),
|
|
"expected at least one non-disabled built-in tool to remain available: {:?}",
|
|
calls[0]
|
|
);
|
|
|
|
rig.shutdown();
|
|
}
|
|
}
|