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.
//! `paid_fetch` tool spec: the advertised metadata for the 402-gated,
//! payment-settling fetch.
//!
//! Like `web_fetch`, `paid_fetch` is **advertise-only** in this crate: the
//! [`ToolRegistry`](crate::ToolRegistry) offers its [`spec`] (wallet-gated) but
//! does not execute it. Execution is owned by whoever composes the registry —
//! the harness payment proxy in production (which binds the call to an approval
//! and settles on the trusted control-plane side), or the standalone settlement
//! executor in `polyc-connectors` for a process with no proxy. Keeping the
//! settlement (and its `mpp`/payments dependencies) out of this crate is what
//! lets the pure tool core stay pure.
//!
//! # Spend gate
//!
//! `paid_fetch` is INTRINSICALLY approval-required: its [`spec`] sets the
//! per-tool `needs_approval` flag (see
//! [`ToolSpec::needs_approval`](polyc_llm::ToolSpec::needs_approval)) to true, so
//! the [`ToolRegistry`](crate::ToolRegistry) routes it through the HITL approval
//! gate even when the operator's
//! [`TOOL_NEEDS_APPROVAL_ENV`](crate::TOOL_NEEDS_APPROVAL_ENV) list is unset — an
//! operator can never accidentally enable unattended spend. The gate is the
//! per-tool spec property; the env list only ADDS further tools.

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

/// The `paid_fetch` tool name — the 402-gated, payment-settling fetch.
pub const TOOL_NAME: &str = "paid_fetch";

/// Header names the payment protocol itself owns on a `paid_fetch` request.
///
/// `mpp`'s credential header, the x402 fallback's signature/response headers,
/// and connection-level headers a caller must never hand-author. A
/// model-supplied `headers` entry naming one of these is refused at
/// [`parse_args`] (fail closed) rather than silently dropped or overridden,
/// since a caller-set `authorization` header in particular would otherwise
/// coexist with (not replace) the credential `mpp`/x402 add to the same
/// request. Matched case-insensitively.
pub const RESERVED_HEADER_NAMES: &[&str] = &[
    "authorization",
    "accept-payment",
    "payment-signature",
    "payment-receipt",
    "host",
    "content-length",
    "transfer-encoding",
    "connection",
];

/// Args spec for `paid_fetch`.
#[must_use]
pub fn spec() -> ToolSpec {
    ToolSpec::new(
        TOOL_NAME,
        "Fetch a URL (GET by default, or POST with a body) that may be \
            402-gated; pays via Tempo machine payments and returns the body \
            plus the payment receipt VERBATIM. The \
            `receipt` object contains ONLY these fields: status, method, \
            timestamp, reference, and (optionally) externalId — report them \
            exactly as returned and do NOT invent fields such as amount, source, \
            or destination. `reference` is the server's settlement reference; it \
            is not necessarily an onchain transaction hash. A top-level \
            `explorer_url` is provided: when it is non-null it is a verified link \
            to the settlement transaction — use it as-is. When `explorer_url` is \
            null, the `reference` is an opaque settlement identifier with no \
            onchain explorer link, so do NOT fabricate one or present \
            `reference` as a link. When a payment settled, a top-level `payment` \
            object reports who actually paid: `amount` (human-readable, e.g. \
            \"0.10 USD\"), `account` (the onchain address that paid), `payer`, \
            and `explorer` (a link to that account). Attribute the spend to \
            `account`, and describe it as the user's own wallet ONLY when \
            `payer` is `linked_wallet`. `payer` values: `platform` is a shared \
            platform account, not the user's; `deployment` is an account this \
            deployment funds, also not the user's. Never claim the payment came \
            from the user's wallet unless `payer` is `linked_wallet`. `headers` \
            may not set `authorization`, `accept-payment`, `payment-signature`, \
            `payment-receipt`, `host`, `content-length`, `transfer-encoding`, or \
            `connection` — those are owned by the payment protocol itself and the \
            call is refused if set.",
        json!({
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "URL to fetch. May answer 402 Payment Required."
                },
                "max_spend": {
                    "type": "string",
                    "description": "Optional ceiling on the amount to pay \
                        (decimal string, e.g. \"0.10\"). Surfaced to the HITL \
                        reviewer when paid_fetch is approval-gated."
                },
                "method": {
                    "type": "string",
                    "enum": ["GET", "POST"],
                    "description": "HTTP method. Defaults to GET."
                },
                "body": {
                    "type": "string",
                    "description": "Request body, sent verbatim. Meaningful with \
                        POST; ignored for GET."
                },
                "headers": {
                    "type": "object",
                    "additionalProperties": { "type": "string" },
                    "description": "Extra request headers as name/value pairs. \
                        Cannot set a header the payment protocol itself owns \
                        (see the tool description) — the call is refused if one \
                        is present."
                }
            },
            "required": ["url"],
            "additionalProperties": false
        }),
    )
    .titled("Pay for & fetch a web page")
    // `destructive`: settles a real onchain payment. `approval_required`: an
    // INTRINSIC gate (independent of sandbox mode) so it is always routed
    // through HITL even when no operator allow-list names it. `open_world`:
    // fetches an arbitrary URL, so its result is attacker-authorable content
    // that seeds the untrusted-content (trifecta) leg.
    .destructive()
    .approval_required()
    .open_world()
}

/// Parsed `paid_fetch` tool arguments.
///
/// The ONE place both the control-plane proxy dialer
/// (`polyc_control_plane::harness_dialer`) and the standalone connector
/// executor (`polyc_connectors::paid_fetch`) read this tool's argument shape
/// from, so the two can never drift on what a field means.
#[derive(Debug, Clone)]
pub struct FetchArgs {
    /// The URL to fetch.
    pub url: String,
    /// Optional per-call spend ceiling, a decimal dollar string.
    pub max_spend: Option<String>,
    /// The HTTP method — `GET` or `POST` only (see [`spec`]'s schema).
    pub method: reqwest::Method,
    /// Optional request body, sent verbatim. Meaningful with `POST`.
    pub body: Option<String>,
    /// Extra caller-supplied headers, name/value pairs — never one of
    /// [`RESERVED_HEADER_NAMES`] (rejected by [`parse_args`]).
    pub headers: Vec<(String, String)>,
}

/// Parses and validates a `paid_fetch` call's `args_json` into [`FetchArgs`].
///
/// # Errors
///
/// A human-readable reason (this crate has no shared tool-argument error type
/// with its callers) when the JSON is malformed, `url` is missing or empty,
/// `method` is anything other than `GET`/`POST`, a `headers` entry's value
/// isn't a string, or a `headers` entry names a header the payment protocol
/// itself owns (see [`RESERVED_HEADER_NAMES`]) — fail closed rather than let
/// a model-authored header collide with or override the credential/
/// settlement headers `mpp`/x402 add to the SAME request.
pub fn parse_args(args_json: &str) -> Result<FetchArgs, String> {
    let v: serde_json::Value =
        serde_json::from_str(args_json).map_err(|_| "invalid args JSON".to_owned())?;
    let url = v
        .get("url")
        .and_then(serde_json::Value::as_str)
        .filter(|s| !s.is_empty())
        .ok_or_else(|| "missing 'url' in args".to_owned())?
        .to_owned();
    let max_spend = v
        .get("max_spend")
        .and_then(serde_json::Value::as_str)
        .filter(|s| !s.is_empty())
        .map(str::to_owned);
    let method = match v.get("method").and_then(serde_json::Value::as_str) {
        None | Some("GET") => reqwest::Method::GET,
        Some("POST") => reqwest::Method::POST,
        Some(other) => {
            return Err(format!(
                "unsupported method '{other}' (must be GET or POST)"
            ));
        }
    };
    let body = v
        .get("body")
        .and_then(serde_json::Value::as_str)
        .map(str::to_owned);
    let headers = match v.get("headers") {
        None => Vec::new(),
        Some(serde_json::Value::Object(map)) => {
            let mut out = Vec::with_capacity(map.len());
            for (name, value) in map {
                let value = value
                    .as_str()
                    .ok_or_else(|| format!("header '{name}' value must be a string"))?;
                if RESERVED_HEADER_NAMES.contains(&name.to_lowercase().as_str()) {
                    return Err(format!(
                        "header '{name}' is reserved by the payment protocol and cannot be set"
                    ));
                }
                out.push((name.clone(), value.to_owned()));
            }
            out
        }
        Some(_) => return Err("'headers' must be a JSON object".to_owned()),
    };
    Ok(FetchArgs {
        url,
        max_spend,
        method,
        body,
        headers,
    })
}

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

    #[test]
    fn spec_carries_curated_title() {
        let spec = spec();
        assert_eq!(spec.title.as_deref(), Some("Pay for & fetch a web page"));
    }

    #[test]
    fn spec_has_url_field() {
        let spec = spec();
        assert_eq!(spec.name, "paid_fetch");
        let schema = &spec.schema_json;
        let required = schema["required"].as_array().expect("required array");
        assert!(required.iter().any(|v| v == "url"), "url must be required");
        // url is a string property; max_spend exists but is optional.
        assert_eq!(schema["properties"]["url"]["type"], "string");
        assert!(
            schema["properties"].get("max_spend").is_some(),
            "max_spend should be advertised"
        );
        assert!(
            !required.iter().any(|v| v == "max_spend"),
            "max_spend must be optional"
        );
        // #1141: strict schema — no undeclared arguments.
        assert_eq!(schema["additionalProperties"], serde_json::json!(false));
    }

    #[test]
    fn spec_description_forbids_inventing_receipt_fields() {
        let d = spec().description.to_lowercase();
        assert!(
            d.contains("verbatim"),
            "description must say receipt is verbatim"
        );
        assert!(
            d.contains("do not invent") || d.contains("not invent"),
            "description must warn against inventing fields"
        );
        // Names the real field set so the model is anchored.
        for f in ["status", "method", "timestamp", "reference"] {
            assert!(d.contains(f), "description should name the `{f}` field");
        }
    }

    #[test]
    fn spec_description_explains_explorer_url() {
        let d = spec().description.to_lowercase();
        assert!(
            d.contains("explorer_url"),
            "description must document the explorer_url field"
        );
        assert!(
            d.contains("verified"),
            "description must say a non-null explorer_url is verified"
        );
        assert!(
            d.contains("null"),
            "description must explain the null (no-link) case"
        );
    }

    #[test]
    fn spec_advertises_method_body_and_headers() {
        let spec = spec();
        let schema = &spec.schema_json;
        let required = schema["required"].as_array().expect("required array");
        assert_eq!(
            schema["properties"]["method"]["enum"],
            serde_json::json!(["GET", "POST"])
        );
        assert_eq!(schema["properties"]["body"]["type"], "string");
        assert_eq!(schema["properties"]["headers"]["type"], "object");
        for optional in ["method", "body", "headers"] {
            assert!(
                !required.iter().any(|v| v == optional),
                "{optional} must be optional"
            );
        }
    }

    #[test]
    fn parse_args_defaults_to_get_with_no_body_or_headers() {
        let parsed = parse_args(r#"{"url":"https://svc.test/x"}"#).expect("parses");
        assert_eq!(parsed.url, "https://svc.test/x");
        assert_eq!(parsed.method, reqwest::Method::GET);
        assert!(parsed.body.is_none());
        assert!(parsed.headers.is_empty());
    }

    #[test]
    fn parse_args_reads_post_with_body_and_headers() {
        let parsed = parse_args(
            r#"{"url":"https://svc.test/x","method":"POST","body":"{\"a\":1}","headers":{"x-custom":"1"}}"#,
        )
        .expect("parses");
        assert_eq!(parsed.method, reqwest::Method::POST);
        assert_eq!(parsed.body.as_deref(), Some(r#"{"a":1}"#));
        assert_eq!(
            parsed.headers,
            vec![("x-custom".to_owned(), "1".to_owned())]
        );
    }

    #[test]
    fn parse_args_rejects_an_unsupported_method() {
        let err = parse_args(r#"{"url":"https://svc.test/x","method":"DELETE"}"#)
            .expect_err("DELETE must be refused");
        assert!(err.contains("DELETE"));
    }

    #[test]
    fn parse_args_rejects_a_reserved_header_case_insensitively() {
        for reserved in [
            "authorization",
            "Authorization",
            "PAYMENT-SIGNATURE",
            "Host",
        ] {
            let args = format!(r#"{{"url":"https://svc.test/x","headers":{{"{reserved}":"x"}}}}"#);
            let err = parse_args(&args).expect_err(&format!("{reserved} must be refused"));
            assert!(
                err.contains("reserved"),
                "expected a reserved-header refusal for {reserved}, got: {err}"
            );
        }
    }

    #[test]
    fn parse_args_rejects_a_non_string_header_value() {
        let err = parse_args(r#"{"url":"https://svc.test/x","headers":{"x":1}}"#)
            .expect_err("non-string header value must be refused");
        assert!(err.contains("string"));
    }
}