Files
ironclaw/tests/admin_tool_policy_e2e.rs
firat.sertgoz e0bdd74fef feat(admin): admin tool policy to disable tools for users (#2154)
* 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>
2026-04-10 03:04:42 +09:00

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();
}
}