polyc-tools 2026.8.3

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.
//! Spec for the agent-evaluable, self-service `unlink_self` tool: the
//! caller removes ONE of their own linked identifiers — their email address
//! or their spending wallet.
//!
//! Consolidates what a live incident exposed as an unsafe shape: two
//! separate tools, `unlink_email` and `wallet_unlink`, ungated and
//! zero-argument, both triggering on near-identical "ask to unlink,
//! disconnect, remove" language with no exclusion of the other's target. A
//! plainly-worded request to unlink an unrelated Slack identity got
//! misrouted to one, then the other — both executed immediately with no
//! human check, silently removing a real linked email and a real linked
//! wallet, neither of which was ever asked for.
//!
//! This tool closes both gaps the incident traced to:
//!
//! - One tool name with an explicit `target` argument removes the
//!   tool-*selection* ambiguity entirely — a wrong `target` value is one
//!   validated branch inside a single handler, never a completely different
//!   code path executing on its own.
//! - [`polyc_llm::ToolSpec::approval_required`] closes the "ungated because
//!   self-service/reversible" gap. Gating now follows destructiveness —
//!   removing a real linked identifier is a destructive action regardless of
//!   how easy it is to undo — not how cheap the wrong tool was to call.
//!
//! Distinct from the admin-gated `unlink_identity` (`crate::unlink_identity`):
//! that tool targets a NAMED platform identity (Slack/Telegram/etc.) on ANY
//! persona, admin-only. This tool only ever acts on the CALLER's own email or
//! wallet — a different actor and a different authorization boundary, so it
//! stays a separate tool rather than folding into one schema with
//! `unlink_identity` (which would blur an admin-vs-self scope distinction,
//! not resolve an ambiguity).
//!
//! Like its predecessors, this has no in-process implementation: the
//! conversation sandbox can't reach the persona store. The harness
//! advertises it via the control-plane proxy; the control plane runs it,
//! dispatching internally to the email or wallet unlink path by `target`
//! (`crate::unlink_self_nav` in `polyc-control-plane`).

use polyc_llm::ToolSpec;
use serde_json::json;

/// The `unlink_self` tool name.
pub const TOOL_NAME: &str = "unlink_self";

/// Every unlink-self tool name, for allowlist checks and dispatch (one,
/// today).
pub const ALL: &[&str] = &[TOOL_NAME];

/// The required argument: which of the caller's own linked identifiers to
/// remove.
pub const ARG_TARGET: &str = "target";

/// The `target` value naming the caller's own linked email.
pub const TARGET_EMAIL: &str = "email";
/// The `target` value naming the caller's own linked spending wallet.
pub const TARGET_WALLET: &str = "wallet";

/// Every unlink-self tool spec.
#[must_use]
pub fn all_specs() -> Vec<ToolSpec> {
    vec![unlink_self_spec()]
}

/// `unlink_self` spec — the caller removes their own linked email or wallet.
///
/// Runs trusted-side; the caller's OWN persona is resolved from the trusted
/// turn, never from an argument — only WHICH of their own identifiers to
/// remove is model-supplied, via the `target` enum. Not egress, but
/// intrinsically destructive and approval-required: removing a real linked
/// identifier always pauses for a human check, regardless of how the request
/// arrived or how easy the removal is to undo.
#[must_use]
pub fn unlink_self_spec() -> ToolSpec {
    ToolSpec::new(
        TOOL_NAME,
        "Remove ONE of the caller's own linked identifiers: their email address or their \
         spending wallet. Use it ONLY when they ask to unlink, disconnect, remove, or stop \
         using their OWN email or wallet specifically — never for a request to unlink a \
         Slack/Telegram/platform identity (that's unlink_identity, admin-only, and it \
         targets a NAMED account, never the caller's own). Set `target` to \"email\" to \
         remove their linked email — to replace an email, call this first, then \
         link_email with the new address, since this tool never links a new one itself — \
         or \"wallet\" to disconnect their spending wallet, which is reversible; they can \
         link again anytime. Refuses if removing the named target would leave the caller \
         with zero identities, or if nothing is linked for the target named.",
        json!({
            "type": "object",
            "properties": {
                ARG_TARGET: {
                    "type": "string",
                    "enum": [TARGET_EMAIL, TARGET_WALLET],
                    "description": "Which of the caller's own linked identifiers to remove."
                }
            },
            "required": [ARG_TARGET],
            "additionalProperties": false
        }),
    )
    .destructive()
    .approval_required()
    .titled("Remove a linked email or wallet")
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
    use super::*;

    #[test]
    fn all_specs_match_all_names() {
        let specs = all_specs();
        assert_eq!(specs.len(), ALL.len());
        for name in ALL {
            assert!(specs.iter().any(|s| s.name == *name), "{name} has no spec");
        }
    }

    #[test]
    fn unlink_self_spec_requires_a_target_enum_and_is_gated() {
        let spec = unlink_self_spec();
        assert_eq!(spec.name, TOOL_NAME);
        assert!(spec.destructive, "removes a real linked identifier");
        assert!(
            spec.needs_approval,
            "unlinking must always pause for a human check, regardless of reversibility"
        );
        let props = spec.schema_json["properties"].as_object().unwrap();
        let target = props.get(ARG_TARGET).expect("target property");
        let enum_values = target["enum"].as_array().unwrap();
        assert_eq!(enum_values, &[json!(TARGET_EMAIL), json!(TARGET_WALLET)]);
        let required = spec.schema_json["required"].as_array().unwrap();
        assert_eq!(required, &[json!(ARG_TARGET)]);
    }
}