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.
//! Specs for the wallet self-service tools.
//!
//! Like the history-navigation family, these tools have no in-process
//! implementation: the conversation sandbox can't reach the persona store, the
//! event-log journal, or the chain. The harness advertises them via a proxy that
//! forwards each call up the harness stream; the control plane runs it over the
//! caller's OWN wallet and replies. `wallet_status` / `wallet_history` /
//! `wallet_deposit_address` are reads; `wallet_link` returns a link card;
//! `wallet_set_policy` changes the caller's own spend authority (limit,
//! expiry, host allowlist), admin-gated the same way `wallet_roster` is AND
//! intrinsically approval-required (#1704) — an admin-only check alone isn't
//! a substitute for gating on what the call does. None of the OTHER tools in
//! this family carries a HITL approval gate, and none is egress: nothing
//! leaves the sandbox, no money moves, and a call can never touch another
//! person's wallet.
//!
//! Unlinking the wallet is NOT one of these: it moved to `crate::unlink_self`
//! (`unlink_self` with `target: "wallet"`), consolidated with `unlink_email`
//! behind one gated tool. A live incident hit exactly the failure this family
//! shape invites: `wallet_unlink` and `unlink_email` were two separate,
//! ungated, zero-argument tools sharing near-identical "ask to unlink" trigger
//! language, so a request naming a THIRD, unrelated target got misrouted to
//! one of them and executed immediately with no human check. See
//! `crate::unlink_self`'s module doc for the full rationale.
//!
//! `wallet_deposit_address` (PRD #1039, TIP-1022) is distinct from
//! `wallet_status`: `wallet_status` reports the caller's OWN linked spending
//! wallet — the account the agent pays FROM. `wallet_deposit_address` reports
//! a deployment-minted RECEIVING address that forwards straight to this
//! deployment's shared destination wallet; the caller never needs a linked
//! wallet at all to have one.
//!
//! They exist so that "what's my wallet / balance / have I linked / what have I
//! spent / link me / unlink me" is answered directly — never by settling a
//! `paid_fetch` payment as a roundabout way to discover the wallet. `paid_fetch`
//! is for buying something; these are for managing the wallet itself.
//!
//! They share the one generic control-plane-tool frame with the history family,
//! so adding a wallet tool is a spec here plus a control-plane handler — no new
//! wire message.

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

/// The `wallet_status` tool name.
pub const WALLET_STATUS: &str = "wallet_status";
/// The `wallet_history` tool name.
pub const WALLET_HISTORY: &str = "wallet_history";
/// The `wallet_deposit_address` tool name.
pub const WALLET_DEPOSIT_ADDRESS: &str = "wallet_deposit_address";
/// The `wallet_link` tool name.
pub const WALLET_LINK: &str = "wallet_link";
/// The `wallet_roster` tool name (admin-only).
pub const WALLET_ROSTER: &str = "wallet_roster";
/// The `wallet_set_policy` tool name (admin-only).
pub const WALLET_SET_POLICY: &str = "wallet_set_policy";
/// The `wallet_update_limit` tool name.
pub const WALLET_UPDATE_LIMIT: &str = "wallet_update_limit";

/// Every wallet tool name, for allowlist checks and dispatch.
pub const ALL: &[&str] = &[
    WALLET_STATUS,
    WALLET_HISTORY,
    WALLET_DEPOSIT_ADDRESS,
    WALLET_LINK,
    WALLET_ROSTER,
    WALLET_SET_POLICY,
    WALLET_UPDATE_LIMIT,
];

/// Every wallet tool spec.
#[must_use]
pub fn all_specs() -> Vec<ToolSpec> {
    vec![
        status_spec(),
        history_spec(),
        deposit_address_spec(),
        link_spec(),
        roster_spec(),
        set_policy_spec(),
        update_limit_spec(),
    ]
}

/// `wallet_status` spec — report the caller's own linked spending wallet: its
/// address, whether it's ready to pay, its balance, and the network. Read-only,
/// free, run trusted-side.
#[must_use]
pub fn status_spec() -> ToolSpec {
    ToolSpec::new(
        WALLET_STATUS,
        "Report the spending wallet linked to the person you're talking to: its \
         address, whether it's linked and ready to pay, its balance, and the \
         network. Use this to answer \"what's my wallet\", \"what's my balance\", \
         \"am I linked\", or \"which account will pay\" — it is FREE and moves no \
         money. Never use paid_fetch to answer a question like that; paid_fetch \
         spends money. Shows only this person's own wallet, never anyone else's. \
         Takes no arguments.",
        json!({
            "type": "object",
            "properties": {},
            "additionalProperties": false
        }),
    )
    .titled("Check the linked wallet")
    .read_only()
    .cacheable_approval()
}

/// `wallet_history` spec — list the caller's own recent payments in this
/// conversation, from the durable signed receipts. Read-only, free, run
/// trusted-side.
#[must_use]
pub fn history_spec() -> ToolSpec {
    ToolSpec::new(
        WALLET_HISTORY,
        "List the payments this person has made in this conversation: for each, \
         the amount, the settlement reference, a verified explorer link when one \
         exists, and when it settled. Use this to answer \"what have I spent\", \
         \"show my payments\", or \"did that payment go through\" — it is FREE and \
         reads the signed onchain receipts, so it never spends money. Shows only \
         this person's own payments, never anyone else's. Takes no arguments.",
        json!({
            "type": "object",
            "properties": {
                "limit": {
                    "type": "integer",
                    "description": "Most payments to return, most recent first (default 10).",
                    "minimum": 1
                }
            },
            "additionalProperties": false
        }),
    )
    .titled("List payments in this conversation")
    .read_only()
    .cacheable_approval()
}

/// `wallet_deposit_address` spec (PRD #1039, TIP-1022) — the caller's own
/// address for RECEIVING money, correctly attributed to them. Read-only,
/// free, run trusted-side.
///
/// Distinct from `wallet_status`: `wallet_status` reports the wallet the
/// agent pays FROM; this reports a deployment-minted deposit address that
/// forwards straight to this deployment's shared destination wallet — the
/// caller never needs a linked wallet at all to have one.
#[must_use]
pub fn deposit_address_spec() -> ToolSpec {
    ToolSpec::new(
        WALLET_DEPOSIT_ADDRESS,
        "Give the person their own address for RECEIVING money: an address someone else can \
         send funds to that's tracked as theirs. Use this to answer \"what's my deposit \
         address\" or \"where can people send me money\" — it is FREE and moves no money. It \
         does NOT say which tokens are accepted; if asked that, say you don't have that \
         information rather than guessing. This is NOT the wallet the agent pays FROM (use \
         wallet_status for that) — it's a separate receiving address that forwards straight to \
         this deployment's shared wallet. Shows only this person's own address, never anyone \
         else's. Takes no arguments.",
        json!({
            "type": "object",
            "properties": {},
            "additionalProperties": false
        }),
    )
    .titled("Check the deposit address")
    .read_only()
    .cacheable_approval()
}

/// `wallet_link` spec — give the caller a one-time link to set up their wallet.
///
/// Runs trusted-side; returns a URL. Not egress and moves no money — completing
/// the link on the caller's device is the real gate — so it is ungated.
///
/// The description names the passkey phrasing on purpose. This is the only
/// tool that sets a passkey up, so "I need to link a passkey" — the exact words
/// people used to reach the retired approvals-passkey tool — has to land here
/// or it lands nowhere.
#[must_use]
pub fn link_spec() -> ToolSpec {
    ToolSpec::new(
        WALLET_LINK,
        "Give the person a secure one-time link to set up — or replace — their own \
         spending wallet with a passkey. Use it when they ask to link, connect, or \
         set up a wallet or a passkey, and when they need a wallet before they can \
         pay. This is the only passkey anyone sets up here: the same one holds \
         their wallet and signs them in. Returns a link; they finish on their own \
         device, and completing it provisions the wallet. It only ever links this \
         person's own wallet, and it moves no money. Takes no arguments.",
        json!({
            "type": "object",
            "properties": {},
            "additionalProperties": false
        }),
    )
    .titled("Link a spending wallet")
}

/// `wallet_roster` spec — an ADMIN-ONLY view of everyone in this conversation.
///
/// Lists whether each participant has linked a spending wallet. Runs trusted-side;
/// the admin check is enforced there, so a non-admin caller gets a refusal, not
/// other people's wallets. Read-only and NOT egress.
#[must_use]
pub fn roster_spec() -> ToolSpec {
    ToolSpec::new(
        WALLET_ROSTER,
        "For an admin only: list everyone who has taken part in this conversation \
         and whether each has linked a spending wallet (with address and balance \
         when they have). Use it when an admin asks to see \"our\" or \"everyone's\" \
         wallets. If the person asking isn't an admin, it returns a refusal rather \
         than anyone else's wallet — for their own wallet use wallet_status. Takes \
         no arguments.",
        json!({
            "type": "object",
            "properties": {},
            "additionalProperties": false
        }),
    )
    .titled("List everyone's wallets (admin)")
    .read_only()
    .cacheable_approval()
}

/// `wallet_set_policy` spec — an ADMIN-ONLY change to the caller's OWN
/// wallet's spend authority: a limit, a reset period, an expiry, and a host
/// allowlist.
///
/// Runs trusted-side; the admin check is enforced there, mirroring
/// `wallet_roster`'s gate. Every argument is optional and independently
/// settable — leaving one off keeps that axis unchanged, and an explicit
/// empty/zero value clears it back to "the deployment default applies".
/// Enforced at delegation/mint time (the onchain limit, period, and key
/// lifetime) and again at payment time (the host allowlist).
///
/// Intrinsically approval-required (#1704): the admin check alone is not a
/// substitute for gating on what the call does. This tool's description used
/// to claim it "can only narrow" spend authority, but clearing a setting
/// (`limit: ""`, `expires_in_days: 0`, `hosts: []`) resets it back to the
/// deployment default — which can be BROADER than a previously-narrower
/// custom setting, i.e. a widen the old claim didn't account for. It is
/// still NOT egress — no money moves and no call leaves the sandbox — but a
/// human always confirms the resulting policy before it's applied, matching
/// the pattern `unlink_self` (#1635) established.
#[must_use]
pub fn set_policy_spec() -> ToolSpec {
    ToolSpec::new(
        WALLET_SET_POLICY,
        "For an admin only: set a spend policy on the person's OWN linked wallet — a \
         spend limit, how often it resets, how long the delegated key stays valid, \
         and which hosts it may pay. Use it when an admin says things like \"cap my \
         spending at $5 a day\", \"only let it pay api.example.com\", or \"make the \
         wallet expire in a week\". Every argument is optional: give only the ones \
         being changed, and leave the rest out. Setting `limit` to an empty string, \
         `period` to \"once\", `expires_in_days` to 0, or `hosts` to an empty list \
         clears that one setting back to the deployment default — it does not touch \
         the others. Clearing a setting can WIDEN authority back to the deployment \
         default rather than narrow it, so every call — narrowing or widening — pauses \
         for the admin to confirm the resulting policy before it's applied. It never \
         moves money. If the person asking isn't an admin, it returns a refusal instead \
         of changing anything.",
        json!({
            "type": "object",
            "properties": {
                "limit": {
                    "type": "string",
                    "description": "Spend cap in the settlement currency's human units \
                        (e.g. \"5\"). Omit to leave unchanged; an empty string clears it \
                        back to the deployment default cap."
                },
                "period": {
                    "type": "string",
                    "enum": ["once", "day", "week", "month"],
                    "description": "How often the cap resets. \"once\" is a one-time cap \
                        (spent once, then exhausted); \"day\"/\"week\"/\"month\" reset on \
                        that cadence. Omit to leave unchanged."
                },
                "expires_in_days": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "How many days the delegated key stays valid before a \
                        re-link is needed. Omit to leave unchanged; 0 clears it back to \
                        the deployment default (still bounded by the platform's maximum)."
                },
                "hosts": {
                    "type": "array",
                    "items": { "type": "string" },
                    "description": "Hosts (e.g. \"api.example.com\") this wallet may pay. \
                        Omit to leave unchanged; an empty list clears the restriction so \
                        any host allowed by the deployment is payable again."
                }
            },
            "additionalProperties": false
        }),
    )
    .destructive()
    .approval_required()
    .titled("Set the wallet's spend policy (admin)")
}

/// `wallet_update_limit` spec — give the caller a one-time link to adjust
/// their OWN already-linked wallet's onchain daily spending cap in place
/// (TIP-1011, issue #1041/#1159), without a full re-link.
///
/// Runs trusted-side; returns a URL. Not egress and moves no money —
/// completing the update on the caller's device is the real gate — so it is
/// ungated, mirroring [`link_spec`]'s self-service shape.
/// Self-scoped like every other non-admin wallet tool: it only ever adjusts
/// THIS person's own linked wallet.
#[must_use]
pub fn update_limit_spec() -> ToolSpec {
    ToolSpec::new(
        WALLET_UPDATE_LIMIT,
        "Give the person a secure one-time link to raise or lower their OWN linked wallet's \
         daily spending cap in place, without a full re-link. Use it when they ask to change \
         how much the agent can spend per day — e.g. \"raise my limit to $20\", \"lower my cap \
         to $5\". Needs a wallet already linked; if none is linked, use wallet_link instead. \
         This changes ONLY the spending cap — it can never change how often the cap resets. \
         Returns a link; they finish on their own device, and completing it takes effect \
         immediately. It only ever adjusts THIS person's own wallet, and it moves no money.",
        json!({
            "type": "object",
            "properties": {
                "new_limit": {
                    "type": "string",
                    "description": "The new daily spend cap in the settlement currency's human \
                        units (e.g. \"20\")."
                }
            },
            "required": ["new_limit"],
            "additionalProperties": false
        }),
    )
    .titled("Update the wallet's spending cap")
}

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

    /// #1704: `set_policy_spec`'s description claimed the tool "can only
    /// narrow" the wallet's spend authority, but clearing a setting (`limit:
    /// ""`, `expires_in_days: 0`, `hosts: []`) resets it back to the
    /// deployment default — which can be BROADER than a previously-narrower
    /// custom setting, i.e. a widen. An admin-only check is not a substitute
    /// for gating on what the call does (both live #1635 incidents were
    /// triggered by the account's own admin), so this must always pause for
    /// a human regardless of whether a given call narrows or widens.
    #[test]
    fn set_policy_spec_is_gated_by_intent_not_just_caller_identity() {
        let spec = set_policy_spec();
        assert!(
            spec.destructive,
            "clearing a setting can widen spend authority back to the deployment default"
        );
        assert!(
            spec.needs_approval,
            "setting a wallet policy must always pause for a human check, regardless of \
             whether this particular call narrows or widens authority"
        );
    }

    /// The tool's own description must not promise a guarantee ("can only
    /// narrow") the schema doesn't actually enforce — every field can clear
    /// back to a default that may be wider than what's already set.
    #[test]
    fn set_policy_spec_description_does_not_claim_narrow_only() {
        let spec = set_policy_spec();
        assert!(
            !spec.description.to_lowercase().contains("can only narrow"),
            "the description must not claim a guarantee the tool doesn't enforce — \
             clearing a setting can widen authority back to the deployment default"
        );
    }
}