mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(shell): add Low/Medium/High risk levels for graduated approval (#172) - Add `RiskLevel` enum (Low/Medium/High, Ord-comparable) to `tool.rs` and re-export from `tools/mod.rs` - Add `risk_level_for(¶ms) -> RiskLevel` to the `Tool` trait (default: Low); override on `ShellTool` via `classify_command_risk` - Add `classify_command_risk(command: &str) -> RiskLevel` to `shell.rs`: High for NEVER_AUTO_APPROVE patterns, Low for read-only prefixes, Medium for reversible mutations, Medium as the unknown-command default - Add `extract_command_param` helper to de-duplicate JSON extraction - Add `sudo ` to `NEVER_AUTO_APPROVE_PATTERNS` (now classified High) - Wire `risk_level_for` into `requires_approval`: Low → Never, Medium → UnlessAutoApproved, High → Always (uses upstream's new API) - Log risk level at INFO on every tool call in `worker.rs` - Replace `requires_explicit_approval` (simple bool) with the richer `classify_command_risk`; update dispatcher.rs test - Add tests: `test_classify_command_risk_high/low/medium/pipeline`, `test_risk_level_for_via_tool_trait`, updated approval tests Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * style: apply cargo fmt to shell.rs and dispatcher.rs Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(shell): fix pipeline risk aggregation and word-boundary matching Address reviewer feedback: - `classify_command_risk` now iterates ALL pipeline segments and takes the maximum risk, so `echo hello | cargo build` → Medium instead of the previous (wrong) Low - Replace `starts_with` with `matches_command_pattern`: single-word patterns use exact first-token comparison so `lsblk` no longer matches `ls`, `makeself` no longer matches `make`, etc.; multi-word patterns (e.g. `git status`) still use starts_with + space boundary - Drop `--help` / `-h` from LOW_RISK_PATTERNS (can never be first token) - Add `test_classify_command_risk_word_boundary` and extend pipeline test with mixed Low+Medium and unknown-command cases Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(shell): move sed/awk/find from Low to Medium risk `sed -i`, `awk -i inplace`, and `find -delete`/`find -exec rm` can all modify or delete files. Classifying these as Low (auto-approve) was unsafe. Moving to Medium requires UnlessAutoApproved approval, which prompts the user unless they have explicitly enabled auto-approve mode. Fixes review feedback from zmanian on PR #368. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(shell): update test to use classify_command_risk after requires_explicit_approval removal The rebase brought in upstream commits that removed requires_explicit_approval. Update the mixed-case destructive command test to assert RiskLevel::High via classify_command_risk instead. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(shell): use word-boundary matching for High-risk patterns to prevent false positives The NEVER_AUTO_APPROVE_PATTERNS check used `contains()` on the full command string, causing false positives: `makeshutdownscript` matched `shutdown`, `nftables-config` matched `nft`, and `passwdqc-check` matched `passwd`. Fix: move the High-risk check inside the per-segment loop and use `matches_command_pattern` (the same word-boundary logic used for Low/Medium), so classification is consistent across all three risk levels. Also remove the trailing spaces from `"nft "` and `"sudo "` in NEVER_AUTO_APPROVE_PATTERNS since `matches_command_pattern` handles word-boundary detection without them. Adds three regression tests for the false-positive cases. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(shell): address zmanian review — redirect safety + explicit git push pattern Two issues from zmanian's CHANGES_REQUESTED review on PR #368: 1. **Security (Low → UnlessAutoApproved)**: `Low` was mapped to `ApprovalRequirement::Never`, bypassing approval entirely for commands like `cat /etc/shadow > /tmp/out` since the pipeline splitter does not split on shell redirections (`>`, `>>`). Changing to `UnlessAutoApproved` preserves the graduated risk metadata for audit while keeping approval policy conservative until redirect-aware parsing is in place. 2. **Minor (explicit git push pattern)**: `git push origin feature-branch` fell through to the unknown-command Medium default rather than matching an explicit pattern. Adding `"git push"` to MEDIUM_RISK_PATTERNS makes the classification intentional. Force-push variants (`git push --force`, `git push -f`) remain in NEVER_AUTO_APPROVE_PATTERNS (High). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * test(shell): add regression tests for redirect bypass and git push pattern fixes Two regression tests for the fixes in the previous commit: 1. `test_low_risk_with_redirect_not_never` — verifies that Low-risk commands containing shell redirections (`echo x > /etc/passwd`, `cat /etc/shadow > /tmp/out`, etc.) return `UnlessAutoApproved`, not `Never`. Before the fix, `Low` mapped to `Never` which would have allowed these writes to bypass approval entirely. 2. `test_git_push_explicit_medium_pattern` — verifies that `git push origin branch` is classified `Medium` via the explicit `MEDIUM_RISK_PATTERNS` entry (not the unknown-command fallthrough). Force variants (`--force`, `-f`) remain `High`. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * test(shell): add integration regression tests for redirect bypass and git push Covers the two fixes from the previous commits at the integration-test level (tests/ directory) to ensure the CI regression-test gate is satisfied: 1. `low_risk_command_with_redirect_is_unless_auto_approved` -- verifies that Low-risk commands containing shell redirections return UnlessAutoApproved, not Never (the pre-fix behaviour that allowed redirect-based bypass). 2. `git_push_is_unless_auto_approved` -- verifies git push is Medium risk (UnlessAutoApproved) via the explicit pattern, not unknown-command fallthrough. 3. `git_push_force_requires_always_approval` -- verifies force-push variants remain High risk (Always approval required). Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * refactor(test): move inline assertions to tests/ to satisfy no-panics CI check The project's no-panics CI check (code_style.yml) scans src/**/*.rs for assert_eq!/assert_ne!/.unwrap() in added lines. Moving classify_command_risk tests to tests/shell_risk_regression.rs and adding // safety: comments on the two remaining assertions in dispatcher.rs eliminates all false positives. - Remove test_classify_command_risk_* and related functions from shell.rs - Remove test_low_risk_with_redirect_not_never and test_git_push_* from shell.rs (covered by integration tests in tests/) - Expand tests/shell_risk_regression.rs with full coverage via public API - Add // safety: test code comments on dispatcher.rs assert lines Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix(shell): address review findings — force-with-lease, test runners, Display - Add `git push --force-with-lease` to NEVER_AUTO_APPROVE_PATTERNS — the word-boundary matching in matches_command_pattern would not match it against the existing `git push --force` pattern (next char is `-`, not space), causing it to fall through to Medium instead of High. - Move `cargo test`, `npm test`, `npm run test`, `yarn test` from LOW_RISK_PATTERNS to MEDIUM_RISK_PATTERNS — test runners execute arbitrary code and can have side effects (file creation, network calls, process spawning). - Add `Display` impl for `RiskLevel` (lowercase: low/medium/high) and switch worker logging from `?risk` (Debug) to `%risk` (Display) for cleaner audit logs. - Fix integration test helper to call `register_dev_tools()` since ShellTool is registered there, not in `register_builtin_tools()`. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: [email protected] <[email protected]>
1074 lines
36 KiB
Rust
1074 lines
36 KiB
Rust
//! Tool trait and types.
|
|
|
|
use std::fmt;
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use rust_decimal::Decimal;
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
use crate::context::JobContext;
|
|
|
|
/// How much approval a specific tool invocation requires.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ApprovalRequirement {
|
|
/// No approval needed.
|
|
Never,
|
|
/// Needs approval, but session auto-approve can bypass.
|
|
UnlessAutoApproved,
|
|
/// Always needs explicit approval (even if auto-approved).
|
|
Always,
|
|
}
|
|
|
|
impl ApprovalRequirement {
|
|
/// Whether this invocation requires approval in contexts where
|
|
/// auto-approve is irrelevant (e.g. autonomous worker/scheduler).
|
|
pub fn is_required(&self) -> bool {
|
|
!matches!(self, Self::Never)
|
|
}
|
|
}
|
|
|
|
/// Precomputed autonomous tool scope for background jobs and routines.
|
|
///
|
|
/// Interactive sessions don't use this type — they still rely on
|
|
/// `requires_approval()` and session-level approval state.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ApprovalContext {
|
|
/// Autonomous job with no interactive user. Only tools in `allowed_tools`
|
|
/// may run; interactive approval requirements are ignored.
|
|
Autonomous {
|
|
/// Tool names that may run autonomously for this job/run.
|
|
allowed_tools: std::collections::HashSet<String>,
|
|
},
|
|
}
|
|
|
|
impl ApprovalContext {
|
|
/// Create an autonomous context with no allowed tools.
|
|
pub fn autonomous() -> Self {
|
|
Self::Autonomous {
|
|
allowed_tools: std::collections::HashSet::new(),
|
|
}
|
|
}
|
|
|
|
/// Create an autonomous context with specific allowed tools.
|
|
pub fn autonomous_with_tools(tools: impl IntoIterator<Item = String>) -> Self {
|
|
Self::Autonomous {
|
|
allowed_tools: tools.into_iter().collect(),
|
|
}
|
|
}
|
|
|
|
/// Check whether a tool invocation is blocked in this context.
|
|
pub fn is_blocked(&self, tool_name: &str, _requirement: ApprovalRequirement) -> bool {
|
|
match self {
|
|
Self::Autonomous { allowed_tools } => !allowed_tools.contains(tool_name),
|
|
}
|
|
}
|
|
|
|
/// Check whether a tool is blocked given an optional context.
|
|
///
|
|
/// When `None`, falls back to legacy behavior: all non-`Never` tools are blocked.
|
|
pub fn is_blocked_or_default(
|
|
context: &Option<Self>,
|
|
tool_name: &str,
|
|
requirement: ApprovalRequirement,
|
|
) -> bool {
|
|
match context {
|
|
Some(ctx) => ctx.is_blocked(tool_name, requirement),
|
|
None => requirement.is_required(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Per-tool rate limit configuration for built-in tool invocations.
|
|
///
|
|
/// Controls how many times a tool can be invoked per user, per time window.
|
|
/// Read-only tools (echo, time, json, file_read, etc.) should NOT be rate limited.
|
|
/// Write/external tools (shell, http, file_write, memory_write, create_job) should be.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ToolRateLimitConfig {
|
|
/// Maximum invocations per minute.
|
|
pub requests_per_minute: u32,
|
|
/// Maximum invocations per hour.
|
|
pub requests_per_hour: u32,
|
|
}
|
|
|
|
impl ToolRateLimitConfig {
|
|
/// Create a config with explicit limits.
|
|
pub fn new(requests_per_minute: u32, requests_per_hour: u32) -> Self {
|
|
Self {
|
|
requests_per_minute,
|
|
requests_per_hour,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for ToolRateLimitConfig {
|
|
/// Default: 60 requests/minute, 1000 requests/hour (generous for WASM HTTP).
|
|
fn default() -> Self {
|
|
Self {
|
|
requests_per_minute: 60,
|
|
requests_per_hour: 1000,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Risk level of a tool invocation.
|
|
///
|
|
/// Used by the shell tool to classify commands and by the worker to drive
|
|
/// approval decisions and observability logging. Implements `Ord` so callers
|
|
/// can compare levels (e.g. `risk >= RiskLevel::High`).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
pub enum RiskLevel {
|
|
/// Read-only, safe, reversible (e.g. `ls`, `cat`, `grep`).
|
|
Low,
|
|
/// Creates or modifies state, but generally reversible
|
|
/// (e.g. `mkdir`, `git commit`, `cargo build`).
|
|
Medium,
|
|
/// Destructive, irreversible, or security-sensitive
|
|
/// (e.g. `rm -rf`, `git push --force`, `kill -9`).
|
|
High,
|
|
}
|
|
|
|
impl fmt::Display for RiskLevel {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Low => f.write_str("low"),
|
|
Self::Medium => f.write_str("medium"),
|
|
Self::High => f.write_str("high"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where a tool should execute: orchestrator process or inside a container.
|
|
///
|
|
/// Orchestrator tools run in the main agent process (memory access, job mgmt, etc).
|
|
/// Container tools run inside Docker containers (shell, file ops, code mods).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum ToolDomain {
|
|
/// Safe to run in the orchestrator (pure functions, memory, job management).
|
|
Orchestrator,
|
|
/// Must run inside a sandboxed container (filesystem, shell, code).
|
|
Container,
|
|
}
|
|
|
|
/// Error type for tool execution.
|
|
#[derive(Debug, Error)]
|
|
pub enum ToolError {
|
|
#[error("Invalid parameters: {0}")]
|
|
InvalidParameters(String),
|
|
|
|
#[error("Execution failed: {0}")]
|
|
ExecutionFailed(String),
|
|
|
|
#[error("Timeout after {0:?}")]
|
|
Timeout(Duration),
|
|
|
|
#[error("Not authorized: {0}")]
|
|
NotAuthorized(String),
|
|
|
|
#[error("Rate limited, retry after {0:?}")]
|
|
RateLimited(Option<Duration>),
|
|
|
|
#[error("External service error: {0}")]
|
|
ExternalService(String),
|
|
|
|
#[error("Sandbox error: {0}")]
|
|
Sandbox(String),
|
|
}
|
|
|
|
/// Output from a tool execution.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToolOutput {
|
|
/// The result data.
|
|
pub result: serde_json::Value,
|
|
/// Cost incurred (if any).
|
|
pub cost: Option<Decimal>,
|
|
/// Time taken.
|
|
pub duration: Duration,
|
|
/// Raw output before sanitization (for debugging).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub raw: Option<String>,
|
|
}
|
|
|
|
impl ToolOutput {
|
|
/// Create a successful output with a JSON result.
|
|
pub fn success(result: serde_json::Value, duration: Duration) -> Self {
|
|
Self {
|
|
result,
|
|
cost: None,
|
|
duration,
|
|
raw: None,
|
|
}
|
|
}
|
|
|
|
/// Create a text output.
|
|
pub fn text(text: impl Into<String>, duration: Duration) -> Self {
|
|
Self {
|
|
result: serde_json::Value::String(text.into()),
|
|
cost: None,
|
|
duration,
|
|
raw: None,
|
|
}
|
|
}
|
|
|
|
/// Set the cost.
|
|
pub fn with_cost(mut self, cost: Decimal) -> Self {
|
|
self.cost = Some(cost);
|
|
self
|
|
}
|
|
|
|
/// Set the raw output.
|
|
pub fn with_raw(mut self, raw: impl Into<String>) -> Self {
|
|
self.raw = Some(raw.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Definition of a tool's parameters using JSON Schema.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToolSchema {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub parameters: serde_json::Value,
|
|
}
|
|
|
|
impl ToolSchema {
|
|
/// Create a new tool schema.
|
|
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
description: description.into(),
|
|
parameters: serde_json::json!({
|
|
"type": "object",
|
|
"properties": {},
|
|
"required": []
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Set the parameters schema.
|
|
pub fn with_parameters(mut self, parameters: serde_json::Value) -> Self {
|
|
self.parameters = parameters;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Curated discovery guidance surfaced by `tool_info(detail: "summary")`.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
|
pub struct ToolDiscoverySummary {
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub always_required: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub conditional_requirements: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub notes: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub examples: Vec<serde_json::Value>,
|
|
}
|
|
|
|
/// Trait for tools that the agent can use.
|
|
#[async_trait]
|
|
pub trait Tool: Send + Sync {
|
|
/// Get the tool name.
|
|
fn name(&self) -> &str;
|
|
|
|
/// Get a description of what the tool does.
|
|
fn description(&self) -> &str;
|
|
|
|
/// Get the JSON Schema for the tool's parameters.
|
|
fn parameters_schema(&self) -> serde_json::Value;
|
|
|
|
/// Execute the tool with the given parameters.
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError>;
|
|
|
|
/// Estimate the cost of running this tool with the given parameters.
|
|
fn estimated_cost(&self, _params: &serde_json::Value) -> Option<Decimal> {
|
|
None
|
|
}
|
|
|
|
/// Estimate how long this tool will take with the given parameters.
|
|
fn estimated_duration(&self, _params: &serde_json::Value) -> Option<Duration> {
|
|
None
|
|
}
|
|
|
|
/// Whether this tool's output needs sanitization.
|
|
///
|
|
/// Returns true for tools that interact with external services,
|
|
/// where the output might contain malicious content.
|
|
fn requires_sanitization(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
/// Risk level for a specific invocation of this tool.
|
|
///
|
|
/// Defaults to `Low` (read-only, safe). Override for tools whose risk
|
|
/// depends on the parameters — the shell tool classifies commands into
|
|
/// `Low` / `Medium` / `High` based on the command string.
|
|
///
|
|
/// The worker logs this value with every tool call so operators can audit
|
|
/// the risk level at which each execution was classified.
|
|
fn risk_level_for(&self, _params: &serde_json::Value) -> RiskLevel {
|
|
RiskLevel::Low
|
|
}
|
|
|
|
/// Whether this tool invocation requires user approval.
|
|
///
|
|
/// Returns `Never` by default (most tools run in a sandboxed environment).
|
|
/// Override to return `UnlessAutoApproved` for tools that need approval
|
|
/// but can be session-auto-approved, or `Always` for invocations that
|
|
/// must always prompt (e.g. destructive shell commands, HTTP with auth).
|
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
ApprovalRequirement::Never
|
|
}
|
|
|
|
/// Maximum time this tool is allowed to run before the caller kills it.
|
|
/// Override for long-running tools like sandbox execution.
|
|
/// Default: 60 seconds.
|
|
fn execution_timeout(&self) -> Duration {
|
|
Duration::from_secs(60)
|
|
}
|
|
|
|
/// Where this tool should execute.
|
|
///
|
|
/// `Orchestrator` tools run in the main agent process (safe, no FS access).
|
|
/// `Container` tools run inside Docker containers (shell, file ops).
|
|
///
|
|
/// Default: `Orchestrator` (safe for the main process).
|
|
fn domain(&self) -> ToolDomain {
|
|
ToolDomain::Orchestrator
|
|
}
|
|
|
|
/// Parameter names whose values must be redacted before logging, hooks, and approvals.
|
|
///
|
|
/// The agent framework replaces these parameter values with `"[REDACTED]"` before:
|
|
/// - Writing to debug logs
|
|
/// - Storing in `ActionRecord` (in-memory job history)
|
|
/// - Recording in `TurnToolCall` (session state)
|
|
/// - Sending to `BeforeToolCall` hooks
|
|
/// - Displaying in the approval UI
|
|
///
|
|
/// **The `execute()` method still receives the original, unredacted parameters.**
|
|
/// Redaction only applies to the observability and audit paths, not execution.
|
|
///
|
|
/// Use this for tools that accept plaintext secrets as parameters (e.g. `secret_save`).
|
|
fn sensitive_params(&self) -> &[&str] {
|
|
&[]
|
|
}
|
|
|
|
/// Per-invocation rate limit for this tool.
|
|
///
|
|
/// Return `Some(config)` to throttle how often this tool can be called per user.
|
|
/// Read-only tools (echo, time, json, file_read, memory_search, etc.) should
|
|
/// return `None`. Write/external tools (shell, http, file_write, memory_write,
|
|
/// create_job) should return sensible limits to prevent runaway agents.
|
|
///
|
|
/// Rate limits are per-user, per-tool, and in-memory (reset on restart).
|
|
/// This is orthogonal to `requires_approval()` — a tool can be both
|
|
/// approval-gated and rate limited. Rate limit is checked first (cheaper).
|
|
///
|
|
/// Default: `None` (no rate limiting).
|
|
fn rate_limit_config(&self) -> Option<ToolRateLimitConfig> {
|
|
None
|
|
}
|
|
|
|
/// Optional host-side webhook verification configuration for this tool.
|
|
///
|
|
/// When present, `/webhook/tools/{tool}` validates shared secret/signatures
|
|
/// before invoking the tool. Tools should then only handle payload normalization.
|
|
fn webhook_capability(&self) -> Option<crate::tools::wasm::WebhookCapability> {
|
|
None
|
|
}
|
|
|
|
/// Full parameter schema for discovery and coercion purposes.
|
|
///
|
|
/// Unlike `parameters_schema()` (which may be permissive to keep the tools
|
|
/// array compact), this returns the complete typed schema. Used by the
|
|
/// `tool_info` built-in and by WASM parameter coercion.
|
|
///
|
|
/// Default: delegates to `parameters_schema()`.
|
|
fn discovery_schema(&self) -> serde_json::Value {
|
|
self.parameters_schema()
|
|
}
|
|
|
|
/// Curated discovery guidance used by `tool_info(detail: "summary")`.
|
|
///
|
|
/// Default: no custom summary; callers may derive a minimal fallback from
|
|
/// `discovery_schema()`.
|
|
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
|
|
None
|
|
}
|
|
|
|
/// Get the tool schema for LLM function calling.
|
|
fn schema(&self) -> ToolSchema {
|
|
let parameters = self.parameters_schema();
|
|
let has_discovery_hint =
|
|
self.discovery_summary().is_some() || self.discovery_schema() != parameters;
|
|
let description = if has_discovery_hint {
|
|
format!(
|
|
"{} (call tool_info(name: \"{}\", detail: \"summary\") for rules/examples or detail: \"schema\" for the full discovery schema)",
|
|
self.description(),
|
|
self.name()
|
|
)
|
|
} else {
|
|
self.description().to_string()
|
|
};
|
|
ToolSchema {
|
|
name: self.name().to_string(),
|
|
description,
|
|
parameters,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract a required string parameter from a JSON object.
|
|
///
|
|
/// Returns `ToolError::InvalidParameters` if the key is missing or not a string.
|
|
pub fn require_str<'a>(params: &'a serde_json::Value, name: &str) -> Result<&'a str, ToolError> {
|
|
params
|
|
.get(name)
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
|
|
}
|
|
|
|
/// Extract a required parameter of any type from a JSON object.
|
|
///
|
|
/// Returns `ToolError::InvalidParameters` if the key is missing.
|
|
pub fn require_param<'a>(
|
|
params: &'a serde_json::Value,
|
|
name: &str,
|
|
) -> Result<&'a serde_json::Value, ToolError> {
|
|
params
|
|
.get(name)
|
|
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
|
|
}
|
|
|
|
/// Replace sensitive parameter values with `"[REDACTED]"`.
|
|
///
|
|
/// Returns a new JSON value with the specified keys replaced. Non-object params
|
|
/// and unknown keys are passed through unchanged. The original value is cloned
|
|
/// only if there are sensitive params to redact; otherwise it is cloned once
|
|
/// (cheap — callers own the result).
|
|
///
|
|
/// Used by the agent framework before logging, hook dispatch, approval display,
|
|
/// and `ActionRecord` storage so plaintext secrets never reach those paths.
|
|
pub fn redact_params(params: &serde_json::Value, sensitive: &[&str]) -> serde_json::Value {
|
|
if sensitive.is_empty() {
|
|
return params.clone();
|
|
}
|
|
let mut redacted = params.clone();
|
|
if let Some(obj) = redacted.as_object_mut() {
|
|
for key in sensitive {
|
|
if obj.contains_key(*key) {
|
|
obj.insert(
|
|
(*key).to_string(),
|
|
serde_json::Value::String("[REDACTED]".into()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
redacted
|
|
}
|
|
|
|
/// Lenient runtime validation of a tool's `parameters_schema()`.
|
|
///
|
|
/// Use this function at tool-registration time to catch structural mistakes
|
|
/// (missing `"type": "object"`, orphan `"required"` keys, arrays without
|
|
/// `"items"`) without rejecting intentional freeform properties.
|
|
///
|
|
/// For the stricter variant that also enforces `additionalProperties: false`,
|
|
/// enum-type consistency, and per-property `"type"` fields, see
|
|
/// [`validate_strict_schema`](crate::tools::schema_validator::validate_strict_schema)
|
|
/// in `schema_validator.rs` (used in CI tests).
|
|
///
|
|
/// Returns a list of validation errors. An empty list means the schema is valid.
|
|
///
|
|
/// # Rules enforced
|
|
///
|
|
/// 1. Top-level must have `"type": "object"`
|
|
/// 2. Top-level must have `"properties"` as an object
|
|
/// 3. Every key in `"required"` must exist in `"properties"`
|
|
/// 4. Nested objects follow the same rules recursively
|
|
/// 5. Array properties should have `"items"` defined
|
|
///
|
|
/// Properties without a `"type"` field are allowed (freeform/any-type).
|
|
/// This is an intentional pattern used by tools like `json` and `http` for
|
|
/// OpenAI compatibility, since union types with arrays require `items`.
|
|
/// Maximum nesting depth for tool schema validation to prevent stack overflow
|
|
/// on maliciously crafted schemas.
|
|
const MAX_SCHEMA_DEPTH: usize = 16;
|
|
|
|
/// Returns true if the schema uses `oneOf`, `anyOf`, or `allOf` combinators
|
|
/// where at least one variant is an object type (has `type: "object"` or `properties`).
|
|
fn has_object_combinator_variants(schema: &serde_json::Value) -> bool {
|
|
for key in ["oneOf", "anyOf", "allOf"] {
|
|
if let Some(variants) = schema.get(key).and_then(|v| v.as_array())
|
|
&& variants.iter().any(|v| {
|
|
v.get("type").and_then(|t| t.as_str()) == Some("object")
|
|
|| v.get("properties").is_some()
|
|
})
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
pub fn validate_tool_schema(schema: &serde_json::Value, path: &str) -> Vec<String> {
|
|
validate_tool_schema_inner(schema, path, 0)
|
|
}
|
|
|
|
fn validate_tool_schema_inner(schema: &serde_json::Value, path: &str, depth: usize) -> Vec<String> {
|
|
let mut errors = Vec::new();
|
|
|
|
if depth > MAX_SCHEMA_DEPTH {
|
|
errors.push(format!(
|
|
"{path}: schema nesting exceeds maximum depth of {MAX_SCHEMA_DEPTH}"
|
|
));
|
|
return errors;
|
|
}
|
|
|
|
// Report non-array combinator values as errors.
|
|
for key in ["oneOf", "anyOf", "allOf"] {
|
|
if let Some(val) = schema.get(key)
|
|
&& !val.is_array()
|
|
{
|
|
errors.push(format!("{path}: \"{key}\" must be an array"));
|
|
}
|
|
}
|
|
|
|
let has_combinators = has_object_combinator_variants(schema);
|
|
|
|
// Rule 1: must have "type": "object" at this level (unless combinators define the structure)
|
|
match schema.get("type").and_then(|t| t.as_str()) {
|
|
Some("object") => {}
|
|
Some(other) => {
|
|
errors.push(format!("{path}: expected type \"object\", got \"{other}\""));
|
|
return errors; // Can't check further
|
|
}
|
|
None => {
|
|
if !has_combinators {
|
|
errors.push(format!("{path}: missing \"type\": \"object\""));
|
|
return errors;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate combinator variants recursively
|
|
for key in ["allOf", "oneOf", "anyOf"] {
|
|
if let Some(variants) = schema.get(key).and_then(|v| v.as_array()) {
|
|
for (i, variant) in variants.iter().enumerate() {
|
|
if variant.get("type").and_then(|t| t.as_str()) == Some("object")
|
|
|| variant.get("properties").is_some()
|
|
{
|
|
let variant_path = format!("{path}.{key}[{i}]");
|
|
errors.extend(validate_tool_schema_inner(
|
|
variant,
|
|
&variant_path,
|
|
depth + 1,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rule 2: must have "properties" as an object (unless combinators define them)
|
|
let properties = match schema.get("properties").and_then(|p| p.as_object()) {
|
|
Some(p) => p,
|
|
None => {
|
|
if !has_combinators {
|
|
errors.push(format!("{path}: missing or non-object \"properties\""));
|
|
return errors;
|
|
}
|
|
// Combinators define the structure — validate top-level `required` keys
|
|
// against merged properties from all combinator variants.
|
|
if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
|
|
let mut merged_keys = std::collections::HashSet::new();
|
|
if let Some(all_of) = schema.get("allOf").and_then(|a| a.as_array()) {
|
|
for variant in all_of {
|
|
if let Some(props) = variant.get("properties").and_then(|p| p.as_object()) {
|
|
merged_keys.extend(props.keys().cloned());
|
|
}
|
|
}
|
|
}
|
|
for key in ["oneOf", "anyOf"] {
|
|
if let Some(variants) = schema.get(key).and_then(|v| v.as_array()) {
|
|
for variant in variants {
|
|
if let Some(props) =
|
|
variant.get("properties").and_then(|p| p.as_object())
|
|
{
|
|
merged_keys.extend(props.keys().cloned());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for req in required {
|
|
if let Some(key) = req.as_str()
|
|
&& !merged_keys.contains(key)
|
|
{
|
|
errors.push(format!(
|
|
"{path}: required key \"{key}\" not found in any combinator variant properties"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
};
|
|
|
|
// Rule 3: every key in "required" must exist in "properties"
|
|
if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
|
|
for req in required {
|
|
if let Some(key) = req.as_str()
|
|
&& !properties.contains_key(key)
|
|
{
|
|
errors.push(format!(
|
|
"{path}: required key \"{key}\" not found in properties"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rule 4 & 5: recurse into nested objects and check arrays
|
|
for (key, prop) in properties {
|
|
let prop_path = format!("{path}.{key}");
|
|
if let Some(prop_type) = prop.get("type").and_then(|t| t.as_str()) {
|
|
match prop_type {
|
|
"object" => {
|
|
errors.extend(validate_tool_schema_inner(prop, &prop_path, depth + 1));
|
|
}
|
|
"array" => {
|
|
if let Some(items) = prop.get("items") {
|
|
// If items is an object type, recurse
|
|
if items.get("type").and_then(|t| t.as_str()) == Some("object") {
|
|
errors.extend(validate_tool_schema_inner(
|
|
items,
|
|
&format!("{prop_path}.items"),
|
|
depth + 1,
|
|
));
|
|
}
|
|
} else {
|
|
errors.push(format!("{prop_path}: array property missing \"items\""));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
// No "type" field is intentionally allowed (freeform properties)
|
|
}
|
|
|
|
errors
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::testing::credentials::TEST_REDACT_SECRET;
|
|
|
|
/// A simple no-op tool for testing.
|
|
#[derive(Debug)]
|
|
pub struct EchoTool;
|
|
|
|
#[async_trait]
|
|
impl Tool for EchoTool {
|
|
fn name(&self) -> &str {
|
|
"echo"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Echoes back the input message. Useful for testing."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"message": {
|
|
"type": "string",
|
|
"description": "The message to echo back"
|
|
}
|
|
},
|
|
"required": ["message"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let message = require_str(¶ms, "message")?;
|
|
|
|
Ok(ToolOutput::text(message, Duration::from_millis(1)))
|
|
}
|
|
|
|
fn requires_sanitization(&self) -> bool {
|
|
false // Echo is a trusted internal tool
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_echo_tool() {
|
|
let tool = EchoTool;
|
|
let ctx = JobContext::default();
|
|
|
|
let result = tool
|
|
.execute(serde_json::json!({"message": "hello"}), &ctx)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result.result, serde_json::json!("hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_schema() {
|
|
let tool = EchoTool;
|
|
let schema = tool.schema();
|
|
|
|
assert_eq!(schema.name, "echo");
|
|
assert!(!schema.description.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_execution_timeout_default() {
|
|
let tool = EchoTool;
|
|
assert_eq!(tool.execution_timeout(), Duration::from_secs(60));
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_str_present() {
|
|
let params = serde_json::json!({"name": "alice"});
|
|
assert_eq!(require_str(¶ms, "name").unwrap(), "alice");
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_str_missing() {
|
|
let params = serde_json::json!({});
|
|
let err = require_str(¶ms, "name").unwrap_err();
|
|
assert!(err.to_string().contains("missing 'name'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_str_wrong_type() {
|
|
let params = serde_json::json!({"name": 42});
|
|
let err = require_str(¶ms, "name").unwrap_err();
|
|
assert!(err.to_string().contains("missing 'name'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_param_present() {
|
|
let params = serde_json::json!({"data": [1, 2, 3]});
|
|
assert_eq!(
|
|
require_param(¶ms, "data").unwrap(),
|
|
&serde_json::json!([1, 2, 3])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_param_missing() {
|
|
let params = serde_json::json!({});
|
|
let err = require_param(¶ms, "data").unwrap_err();
|
|
assert!(err.to_string().contains("missing 'data'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_requires_approval_default() {
|
|
let tool = EchoTool;
|
|
// Default requires_approval() returns Never.
|
|
assert_eq!(
|
|
tool.requires_approval(&serde_json::json!({"message": "hi"})),
|
|
ApprovalRequirement::Never
|
|
);
|
|
assert!(!ApprovalRequirement::Never.is_required());
|
|
assert!(ApprovalRequirement::UnlessAutoApproved.is_required());
|
|
assert!(ApprovalRequirement::Always.is_required());
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_params_replaces_sensitive_key() {
|
|
let params = serde_json::json!({"name": "openai_key", "value": TEST_REDACT_SECRET});
|
|
let redacted = redact_params(¶ms, &["value"]);
|
|
assert_eq!(redacted["name"], "openai_key");
|
|
assert_eq!(redacted["value"], "[REDACTED]");
|
|
// Original unchanged
|
|
assert_eq!(params["value"], TEST_REDACT_SECRET);
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_params_empty_sensitive_is_noop() {
|
|
let params = serde_json::json!({"name": "key", "value": "secret"});
|
|
let redacted = redact_params(¶ms, &[]);
|
|
assert_eq!(redacted, params);
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_params_missing_key_is_noop() {
|
|
let params = serde_json::json!({"name": "key"});
|
|
let redacted = redact_params(¶ms, &["value"]);
|
|
assert_eq!(redacted, params);
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_params_non_object_is_passthrough() {
|
|
let params = serde_json::json!("just a string");
|
|
let redacted = redact_params(¶ms, &["value"]);
|
|
assert_eq!(redacted, params);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_schema_valid() {
|
|
let schema = serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": { "type": "string", "description": "A name" }
|
|
},
|
|
"required": ["name"]
|
|
});
|
|
let errors = validate_tool_schema(&schema, "test");
|
|
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_schema_missing_type() {
|
|
let schema = serde_json::json!({
|
|
"properties": {
|
|
"name": { "type": "string" }
|
|
}
|
|
});
|
|
let errors = validate_tool_schema(&schema, "test");
|
|
assert_eq!(errors.len(), 1);
|
|
assert!(errors[0].contains("missing \"type\": \"object\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_schema_wrong_type() {
|
|
let schema = serde_json::json!({
|
|
"type": "string"
|
|
});
|
|
let errors = validate_tool_schema(&schema, "test");
|
|
assert_eq!(errors.len(), 1);
|
|
assert!(errors[0].contains("expected type \"object\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_schema_required_not_in_properties() {
|
|
let schema = serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": { "type": "string" }
|
|
},
|
|
"required": ["name", "age"]
|
|
});
|
|
let errors = validate_tool_schema(&schema, "test");
|
|
assert_eq!(errors.len(), 1);
|
|
assert!(errors[0].contains("\"age\" not found in properties"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_schema_nested_object() {
|
|
let schema = serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"config": {
|
|
"type": "object",
|
|
"properties": {
|
|
"key": { "type": "string" }
|
|
},
|
|
"required": ["key", "missing"]
|
|
}
|
|
}
|
|
});
|
|
let errors = validate_tool_schema(&schema, "test");
|
|
assert_eq!(errors.len(), 1);
|
|
assert!(errors[0].contains("test.config"));
|
|
assert!(errors[0].contains("\"missing\" not found"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_schema_array_missing_items() {
|
|
let schema = serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"tags": { "type": "array", "description": "Tags" }
|
|
}
|
|
});
|
|
let errors = validate_tool_schema(&schema, "test");
|
|
assert_eq!(errors.len(), 1);
|
|
assert!(errors[0].contains("array property missing \"items\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_schema_array_with_items_ok() {
|
|
let schema = serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"tags": {
|
|
"type": "array",
|
|
"items": { "type": "string" }
|
|
}
|
|
}
|
|
});
|
|
let errors = validate_tool_schema(&schema, "test");
|
|
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_schema_freeform_property_allowed() {
|
|
// Properties without "type" are intentionally allowed (json/http tools)
|
|
let schema = serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"data": { "description": "Any JSON value" }
|
|
},
|
|
"required": ["data"]
|
|
});
|
|
let errors = validate_tool_schema(&schema, "test");
|
|
assert!(
|
|
errors.is_empty(),
|
|
"freeform property should be allowed: {errors:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_schema_nested_array_items_object() {
|
|
let schema = serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"headers": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": { "type": "string" },
|
|
"value": { "type": "string" }
|
|
},
|
|
"required": ["name", "value"]
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let errors = validate_tool_schema(&schema, "test");
|
|
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_schema_nested_array_items_object_bad() {
|
|
let schema = serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"headers": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": { "type": "string" }
|
|
},
|
|
"required": ["name", "missing_field"]
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let errors = validate_tool_schema(&schema, "test");
|
|
assert_eq!(errors.len(), 1);
|
|
assert!(errors[0].contains("headers.items"));
|
|
assert!(errors[0].contains("\"missing_field\""));
|
|
}
|
|
|
|
/// Regression test for issue #975: deeply nested schemas must not cause
|
|
/// stack overflow. The validator should stop at MAX_SCHEMA_DEPTH and
|
|
/// report an error instead of recursing infinitely.
|
|
#[test]
|
|
fn test_validate_schema_depth_limit() {
|
|
// Build a schema nested 20 levels deep (exceeds MAX_SCHEMA_DEPTH=16)
|
|
let mut schema = serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"leaf": { "type": "string" }
|
|
}
|
|
});
|
|
for _ in 0..20 {
|
|
schema = serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"nested": schema
|
|
}
|
|
});
|
|
}
|
|
let errors = validate_tool_schema(&schema, "test");
|
|
assert!(
|
|
errors.iter().any(|e| e.contains("maximum depth")),
|
|
"expected depth limit error, got: {errors:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_approval_context_autonomous_blocks_tools_not_in_scope() {
|
|
let ctx = ApprovalContext::autonomous();
|
|
assert!(ctx.is_blocked("shell", ApprovalRequirement::Never));
|
|
assert!(ctx.is_blocked("shell", ApprovalRequirement::UnlessAutoApproved));
|
|
assert!(ctx.is_blocked("shell", ApprovalRequirement::Always));
|
|
}
|
|
|
|
#[test]
|
|
fn test_approval_context_autonomous_with_tools_allows_registered_name() {
|
|
let ctx =
|
|
ApprovalContext::autonomous_with_tools(["shell".to_string(), "message".to_string()]);
|
|
assert!(!ctx.is_blocked("shell", ApprovalRequirement::Never));
|
|
assert!(!ctx.is_blocked("shell", ApprovalRequirement::Always));
|
|
assert!(!ctx.is_blocked("message", ApprovalRequirement::Always));
|
|
assert!(ctx.is_blocked("http", ApprovalRequirement::Always));
|
|
}
|
|
|
|
#[test]
|
|
fn test_approval_context_blocks_never_when_not_in_scope() {
|
|
let ctx = ApprovalContext::autonomous();
|
|
assert!(ctx.is_blocked("any_tool", ApprovalRequirement::Never));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_blocked_or_default_with_none_uses_legacy() {
|
|
// None context: all non-Never tools are blocked
|
|
assert!(!ApprovalContext::is_blocked_or_default(
|
|
&None,
|
|
"any",
|
|
ApprovalRequirement::Never
|
|
));
|
|
assert!(ApprovalContext::is_blocked_or_default(
|
|
&None,
|
|
"any",
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
));
|
|
assert!(ApprovalContext::is_blocked_or_default(
|
|
&None,
|
|
"any",
|
|
ApprovalRequirement::Always
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_blocked_or_default_with_some_delegates() {
|
|
let ctx = Some(ApprovalContext::autonomous_with_tools(
|
|
["shell".to_string()],
|
|
));
|
|
assert!(!ApprovalContext::is_blocked_or_default(
|
|
&ctx,
|
|
"shell",
|
|
ApprovalRequirement::Always
|
|
));
|
|
assert!(ApprovalContext::is_blocked_or_default(
|
|
&ctx,
|
|
"other",
|
|
ApprovalRequirement::Always
|
|
));
|
|
assert!(ApprovalContext::is_blocked_or_default(
|
|
&ctx,
|
|
"any",
|
|
ApprovalRequirement::UnlessAutoApproved
|
|
));
|
|
}
|
|
}
|