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.
//! Spec for the email-verification magic-link tool (issue #962).
//!
//! Like [`crate::wallet`], this tool has no in-process implementation: the
//! conversation sandbox can't reach the persona store.
//! The harness advertises it via the same control-plane-tool proxy that
//! forwards a call up the harness stream. `link_email` mints a one-time
//! link-ceremony token bound to the caller's OWN persona, mails a
//! confirmation link, and replies. It moves no money and reveals no secret,
//! and only ever acts on the caller's own persona — so it is ungated, like
//! `wallet_link`.
//!
//! `link_email` never links anything itself: it only sends the caller a
//! one-click link to a landing page (`apps/web`) that completes the
//! ceremony when clicked. The verified email identity it produces is read
//! later by a consumer that needs a durable, ceremony-verified address, not by
//! this tool.
//!
//! Its former inverse, `unlink_email`, moved to `crate::unlink_self`
//! (`unlink_self` with `target: "email"`), consolidated with `wallet_unlink`
//! behind one gated tool — a live incident hit exactly the failure two
//! separate, ungated, zero-argument "unlink" tools invite: a request naming a
//! THIRD, unrelated target got misrouted to one of them and executed
//! immediately with no human check. "Replace my email" is still `link_email`
//! (after unlinking with `unlink_self`) for a fresh ceremony over the new
//! address, not a separate "supersede" mechanism.

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

/// The `link_email` tool name.
pub const LINK_EMAIL: &str = "link_email";

/// Every email-link tool name, for allowlist checks and dispatch (one,
/// today — see the module doc for where `unlink_email` went).
pub const ALL: &[&str] = &[LINK_EMAIL];

/// Every email-link tool spec.
#[must_use]
pub fn all_specs() -> Vec<ToolSpec> {
    vec![link_email_spec()]
}

/// `link_email` spec — send the caller a one-click confirmation link for an
/// email address.
///
/// Runs trusted-side; returns no secret, only a plain confirmation that the
/// mail was sent. Not egress and moves no money — completing the ceremony by
/// clicking the mailed link is the real gate — so it is ungated, mirroring
/// `wallet_link`.
#[must_use]
pub fn link_email_spec() -> ToolSpec {
    ToolSpec::new(
        LINK_EMAIL,
        "Verify an email address by sending a one-click confirmation link. Use it when \
         the person gives you an address to verify, or when another tool needs a \
         verified email on file before it can proceed. Takes the address, sends a mail \
         with a link, and confirms the mail went out — nothing is linked until the \
         person clicks the link on their own device. It only ever verifies THIS \
         person's own address.",
        json!({
            "type": "object",
            "properties": {
                "email": {
                    "type": "string",
                    "description": "The email address to verify."
                }
            },
            "required": ["email"],
            "additionalProperties": false
        }),
    )
    .titled("Verify an email address")
}

#[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 link_email_spec_requires_the_email_argument_and_is_ungated() {
        let spec = link_email_spec();
        assert_eq!(spec.name, LINK_EMAIL);
        assert!(!spec.destructive, "moves no money, is not destructive");
        let props = spec.schema_json["properties"].as_object().unwrap();
        assert!(props.contains_key("email"));
        let required = spec.schema_json["required"].as_array().unwrap();
        assert_eq!(required, &[json!("email")]);
    }
}