polyc-tools 2026.9.0

The in-process tool core for polychrome agents: local executors (coding, web fetch, wallet, ...), the tool registry, and MCP composition. The networked connectors live in polyc-connectors.
//! A composed registry carries its owner's policy, it does not flatten it.
//!
//! The harness ALWAYS runs tools through a [`CompositeRegistry`] (see
//! `build_tool_executor`), so any [`ToolExecutor`] method this registry leaves
//! to the trait default is a method whose behavior is disabled on the only
//! production path. The default is never an error — it is a fixed answer that
//! looks like a real one, which is why each instance of this bug shipped
//! green:
//!
//! * `sandbox_would_deny` inherited `false`, making per-caller escalation a
//!   silent no-op (`#301`);
//! * `for_worker` inherited `None` in the gate above this registry, running
//!   every delegated worker on the shared workspace root (`#2286`);
//! * `pre_dispatch` inherited a decision derived from `needs_approval` — a
//!   bool — which collapsed an owner's argument-aware [`ToolDecision::Deny`]
//!   into `RequireApproval`.
//!
//! The third one is what these tests pin, plus the two neighbours that share
//! its shape. The property: a decision an owning source makes on ARGUMENTS
//! reaches the turn loop intact through composition — a `Deny` stays a
//! `Deny`, a result rewrite still rewrites, and a recovery hatch a source
//! opts into is still consulted.
//!
//! Each test drives the real [`CompositeRegistry`], never a hand-rolled
//! stand-in for it: the bug this file exists for was invisible to a test that
//! exercised the source directly, because the source was always correct.

#![allow(clippy::pedantic, clippy::nursery, missing_docs)]

use std::sync::Arc;

use async_trait::async_trait;
use polyc_agent::{ToolDecision, ToolExecutor};
use polyc_llm::ToolSpec;
use polyc_tools::CompositeRegistry;

const OWNED: &str = "owned_tool";

/// A source that owns one tool and makes every decision the trait allows: it
/// gates the tool by name, denies it on arguments, rewrites its result, and
/// offers a recovery spec for an unadvertised name.
#[derive(Debug)]
struct OpinionatedSource;

#[async_trait]
impl ToolExecutor for OpinionatedSource {
    fn specs(&self) -> Vec<ToolSpec> {
        vec![ToolSpec::new(OWNED, "owned", serde_json::json!({}))]
    }

    fn needs_approval(&self, name: &str) -> bool {
        name == OWNED
    }

    fn pre_dispatch(&self, _name: &str, args_json: &str) -> ToolDecision {
        if args_json.contains("\"forbidden\"") {
            ToolDecision::Deny("the owning source refuses these arguments".to_owned())
        } else {
            ToolDecision::RequireApproval
        }
    }

    fn post_dispatch(&self, _name: &str, _args_json: &str, _result_json: &str) -> Option<String> {
        Some("[redacted by the owning source]".to_owned())
    }

    fn recover_unadvertised(&self, _name: &str, _args_json: &str) -> Vec<ToolSpec> {
        vec![ToolSpec::new(
            "recovered_tool",
            "recovered",
            serde_json::json!({}),
        )]
    }

    async fn execute(&self, _name: &str, _args_json: &str) -> String {
        String::new()
    }
}

fn composed() -> CompositeRegistry {
    CompositeRegistry::new().with(Arc::new(OpinionatedSource))
}

/// The regression proper. An owner's argument-aware `Deny` must survive
/// composition: the control-plane proxy denies a routine whose spec admission
/// would reject, so that a human approval card never offers a routine that
/// cannot be created (INV-RL2/RL3). Inheriting the default turned that `Deny`
/// into `RequireApproval` and the card appeared anyway.
#[test]
fn pre_dispatch_carries_an_owners_argument_aware_deny() {
    let decision = composed().pre_dispatch(OWNED, r#"{"forbidden":true}"#);
    assert!(
        matches!(decision, ToolDecision::Deny(_)),
        "composition flattened the owner's Deny into {decision:?}"
    );
}

/// The same forwarding must not over-reach: arguments the owner accepts still
/// take the owner's ordinary gated answer.
#[test]
fn pre_dispatch_carries_the_owners_ordinary_decision_too() {
    let decision = composed().pre_dispatch(OWNED, r#"{"fine":true}"#);
    assert!(
        matches!(decision, ToolDecision::RequireApproval),
        "expected the owner's gated decision, got {decision:?}"
    );
}

/// An unowned name keeps the behavior the inherited default gave it — the
/// `execute` path reports an unknown tool as an error rather than pausing the
/// loop, so nothing here may start gating it.
#[test]
fn pre_dispatch_allows_a_name_no_source_owns() {
    let decision = composed().pre_dispatch("nobody_owns_this", "{}");
    assert!(
        matches!(decision, ToolDecision::Allow),
        "an unowned name must stay allowed, got {decision:?}"
    );
}

/// A redaction the owner makes must reach the model's context. Inheriting the
/// default `None` would let the unredacted result through verbatim.
#[test]
fn post_dispatch_carries_an_owners_redaction() {
    assert_eq!(
        composed().post_dispatch(OWNED, "{}", "the raw secret"),
        Some("[redacted by the owning source]".to_owned()),
        "composition dropped the owner's result rewrite"
    );
}

#[test]
fn post_dispatch_rewrites_nothing_for_an_unowned_name() {
    assert_eq!(
        composed().post_dispatch("nobody_owns_this", "{}", "raw"),
        None
    );
}

/// Recovery cannot route by owner — the call named no advertised tool, so it
/// has no owner. It fans out instead, so a source that opts into the hatch is
/// still consulted through composition.
#[test]
fn recover_unadvertised_fans_out_across_sources() {
    let recovered = composed().recover_unadvertised("hallucinated_name", "{}");
    assert_eq!(
        recovered
            .iter()
            .map(|s| s.name.as_str())
            .collect::<Vec<_>>(),
        vec!["recovered_tool"],
        "composition swallowed the source's recovery hatch"
    );
}