diff --git a/README.md b/README.md index 676aa160..1bdd1974 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,17 @@ See [Reaching a canister is gated on the discoverability manifest](#reaching-a-c **Not for financial operations.** IMCP2 is infrastructure tooling for reading, building, and operating canisters — it is not a wallet or trading tool, and financial operations (token transfers, spending approvals, payments, trades) -are unsupported by policy. The standardized ledger surface is refused -mechanically (`canister_update_call` rejects the ICRC-1/ICRC-2 and related -ledger-standard transfer/approval methods on every canister), and the -connector serves no funding or management tools: creating, funding, and topping up +are unsupported by policy. The ledger surface is refused mechanically: +`canister_update_call` rejects the ICRC-1/ICRC-2 and related ledger-standard +transfer/approval methods on every canister, and refuses every update call to a +canister whose own Candid interface declares ICRC-1 or ICRC-2 methods — every +ICRC ledger and the ICP ledger, detected from the canister itself — or that is +one of the financial-service canisters it carries. An update call needs the +canister's published interface: a canister that publishes none, or whose +interface cannot be read, receives no update call through this server, so +every update the server sends was checked against the canister's own +interface. The connector serves no +funding or management tools: creating, funding, and topping up canisters is done by the user with the [`icp` CLI](https://github.com/dfinity/icp-cli) in their own terminal. For financial operations, users act themselves outside the connector, in a @@ -163,7 +170,7 @@ results). | `get_canister_candid` | `canister_id`, `app_url?`, `derivation_origin?` | The canister's `candid:service` interface (`.did` text), plus two capability flags: `oql` (`true` when it exposes an OQL query surface — a `schema` + `execute` pair — with a pointer to `icp_oql_guide`) and `api_doc_available` (`true` when it declares a `getApiDoc`/`get_api_doc` method, gating `get_canister_api_doc`) | | `get_canister_api_doc` | `canister_id`, `app_url?`, `derivation_origin?` | The canister's own prose API guide ("how this app behaves" — units, auth, lifecycle, mutation safety, polling, gotchas), from its `getApiDoc`/`get_api_doc` method. Call **only** when `get_canister_candid`/`open_app` report `api_doc_available`. Returns a **structured** result for every documentation outcome — `available` + the doc on success, else `available:false` with `expected`/`retry`/`next`, so "no compatible method was detected" is distinct from "no answer was obtained". An unusable `canister_id` is rejected before any lookup and is a plain error, not that shape; and `expected:true` is not proof of absence, since an interface the parser cannot read also comes up empty | | `canister_query` | `canister_id`, `method?` **or** `oql?`, `args?` (textual Candid), `app_url?`, `derivation_origin?`, `account?`, `candid?` | READ a canister — provide EITHER a Candid `query` `method` (with `args`) OR an `oql` query (a JSON object string, run against `execute`). A Candid `method` query may be anonymous or as your account and returns textual Candid; an `oql` query **requires** `derivation_origin` and returns `columns` + `rows` (a table) with `has_more`, validating `start` against the schema on an empty result. On an OQL canister a Candid `method` query is rejected — use `oql`. `candid` is a fallback: the `.did` interface text to encode/decode against when the canister exposes no `candid:service` metadata. Echoes `derived_for_origin` / `requested` so an origin mismatch is visible (never the principal — `get_app_principal` returns it on request) | -| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `app_url?`, `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`). **The target must be declared by its app**: the call is made only when the app at `app_url` (from `open_app`; falling back to `derivation_origin` when no `app_url` is given) declares `canister_id` in its `/.well-known/ic-architecture` manifest — see [Reaching a canister is gated on the discoverability manifest](#reaching-a-canister-is-gated-on-the-discoverability-manifest). When `derivation_origin` is also given, the two must belong to the same app — the app at `app_url` is resolved to the derivation origin II derives its users from, and a mismatch is refused rather than signed. The reply echoes `declared_by` / `declared_at` (which origin authorized the write, and at which path). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) and the NNS/SNS governance method `manage_neuron` (neuron staking and disbursement) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) and the cycles-minting canister's funding-completion methods (`notify_top_up`, `notify_create_canister`, `notify_mint_cycles`, `create_canister`) on those canisters; and **every** update call is refused on the financial-service canisters the guard carries — all to protect the user. The refusal directs the user to perform the operation outside the connector, in a trusted interface they control — or, for canister creation and funding, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. The policy is stated in the server-level instructions, deliberately not in any tool description. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` so an origin mismatch is visible (never the principal — `get_app_principal` returns it on request) | +| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `app_url?`, `derivation_origin?`, `account?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`). **The target must be declared by its app**: the call is made only when the app at `app_url` (from `open_app`; falling back to `derivation_origin` when no `app_url` is given) declares `canister_id` in its `/.well-known/ic-architecture` manifest — see [Reaching a canister is gated on the discoverability manifest](#reaching-a-canister-is-gated-on-the-discoverability-manifest). When `derivation_origin` is also given, the two must belong to the same app — the app at `app_url` is resolved to the derivation origin II derives its users from, and a mismatch is refused rather than signed. The reply echoes `declared_by` / `declared_at` (which origin authorized the write, and at which path). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) and the NNS/SNS governance method `manage_neuron` (neuron staking and disbursement) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) and the cycles-minting canister's funding-completion methods (`notify_top_up`, `notify_create_canister`, `notify_mint_cycles`, `create_canister`) on those canisters; any other `icrc1_*`/`icrc2_*` method is refused by name as well; and **every** update call is refused on a canister whose own Candid interface declares ICRC-1 or ICRC-2 methods — every ICRC ledger and the ICP ledger, detected from the interface the canister publishes — and on the financial-service canisters the guard carries — all to protect the user. **The canister must publish its interface**: an update call to a canister that publishes none, or whose interface cannot be read, is refused (there is no `candid` fallback here, unlike `canister_query`), so every update the server sends was checked against the canister's own interface. The refusal directs the user to perform the operation outside the connector, in a trusted interface they control — or, for canister creation and funding, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. The policy is stated in the server-level instructions, deliberately not in any tool description. Echoes `derived_for_origin` / `requested` so an origin mismatch is visible (never the principal — `get_app_principal` returns it on request) | | `get_app_principal` | `derivation_origin`, `account?` | The principal you act as at an app. Identify the app by its `derivation_origin` (from `open_app`). Echoes `derived_for_origin` / `requested` so an origin mismatch is visible | | `list_app_accounts` | `derivation_origin` | The user's Internet Identity accounts at an app — the default account plus any named ones — with name, number, and the derivation origin they were listed for (no principals and no last-used timestamps: the listing exists to pick an account). Identify the app by its `derivation_origin` (from `open_app`) | | `candid_syntax_guide` | — | The textual-Candid value syntax the `args` of `canister_query` / `canister_update_call` take and their replies come back in: the literal form for each Candid type, and when a value needs an explicit `: type` annotation. The full type system stays available as the `candid://reference` MCP resource | diff --git a/crates/imcp2-core/src/calls.rs b/crates/imcp2-core/src/calls.rs index 7884d173..bf5f68fd 100644 --- a/crates/imcp2-core/src/calls.rs +++ b/crates/imcp2-core/src/calls.rs @@ -4,7 +4,8 @@ //! `canister_update_call` (invoke an update method). The LLM only ever deals with //! textual Candid — the binary encoding/decoding against a method's declared types //! happens here, and the `.did` interface is resolved from the canister's own -//! `candid:service` metadata (or a caller-supplied definition). +//! `candid:service` metadata (or, for a query, a caller-supplied definition; an +//! update call needs the published one). //! //! The `#[tool]` entry points live on `IcTools` in `main.rs` (they need the //! agent, identities, and request context); this module owns their argument and @@ -15,11 +16,12 @@ use candid::{ types::Label, Principal, }; -use ic_agent::Agent; +use ic_agent::{Agent, AgentError}; // rmcp re-exports schemars 1.x; the `#[tool]` output-schema machinery requires // THAT version's `JsonSchema`, so derive the MCP types against it. use rmcp::schemars; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; // =========================================================================== // MCP-facing argument and output types (textual in, textual out — the LLM @@ -201,9 +203,8 @@ pub struct CanisterUpdateCallArgs { pub method: String, /// Arguments in textual Candid, e.g. `()` or `(record { owner = principal "..." })`; the /// `candid://textual-syntax` resource documents that syntax and `candid://reference` its - /// type system. Encoded against the method's declared types when the interface is available, - /// published or supplied in `candid`, so plain literals need no `: type` annotation. - /// Without an interface they are encoded as written and need their own annotations. + /// type system. Encoded against the method's declared types in the canister's published + /// interface, so plain literals need no `: type` annotation. #[serde(default = "default_args")] pub args: String, /// The website URL of the app that owns `canister_id`, which open_app returns. @@ -221,10 +222,6 @@ pub struct CanisterUpdateCallArgs { /// app's default account. Ignored for anonymous calls. #[serde(default)] pub account: Option, - /// The canister's Candid interface as `.did` text, for when the canister does not publish - /// its own. Supplying it keeps arguments and the reply typed. - #[serde(default)] - pub candid: Option, } /// Output of `canister_update_call`. @@ -378,23 +375,91 @@ pub fn default_args() -> String { // Candid interface resolution + encode/decode/call helpers. // =========================================================================== -/// The interface to encode/decode against: the canister's own `candid:service` -/// if exposed, else the caller-supplied `provided` definition. +/// The interface to encode/decode against for the READ-ONLY paths: the +/// canister's own `candid:service` if exposed, else the caller-supplied +/// `provided` definition — for a read that FAILED as well as for a canister that +/// publishes none, since a query cannot commit state. `canister_update_call` +/// reads with [`read_candid_service`] instead, takes no fallback, and refuses +/// unless the read is [`CandidServiceRead::Published`]. pub async fn resolve_did( agent: &Agent, canister: Principal, provided: Option<&str>, ) -> Option { - if let Some(did) = candid_service(agent, canister).await { - return Some(did); - } - provided.map(str::to_string) + read_candid_service(agent, canister).await.or_provided(provided) } -/// The canister's `candid:service` interface (`.did` text), if exposed. +/// The canister's `candid:service` interface (`.did` text), if exposed: `None` +/// for a canister that publishes none AND for a read that failed. pub async fn candid_service(agent: &Agent, canister: Principal) -> Option { - let raw = agent.read_state_canister_metadata(canister, "candid:service").await.ok()?; - String::from_utf8(raw).ok() + read_candid_service(agent, canister).await.published() +} + +/// What reading a canister's `candid:service` metadata established — three +/// outcomes, because an update call needs the published interface and the +/// refusal for not having one says which it was (per review): a canister that +/// publishes none is told to publish, while a read that FAILED is told to retry. +#[derive(Debug, PartialEq, Eq)] +pub enum CandidServiceRead { + /// The canister publishes the interface: its `.did` text. + Published(String), + /// The canister publishes `candid:service` bytes that are not text — a + /// property of the canister, like unparseable text: published, unreadable. + NotText, + /// The canister publishes no `candid:service` — a CERTIFIED absence: the + /// replica's state tree proves the path is not there, which is also what + /// the tree says of a canister id in a subnet's range that no canister + /// occupies (never created, or deleted). A property of the canister, not + /// of this read. + NotPublished, + /// The read itself failed, so whether the canister publishes an interface + /// is unknown: a transport, HTTP or certificate error, or a lookup the + /// certificate could not settle. Carries the error's text. + Failed(String), +} + +impl CandidServiceRead { + /// The published text, if any. + pub fn published(self) -> Option { + match self { + Self::Published(did) => Some(did), + Self::NotText | Self::NotPublished | Self::Failed(_) => None, + } + } + + /// The interface to encode against on a read-only path: the published text, + /// else `provided` — whether the canister publishes none or the read failed. + /// The update path never calls this: it refuses instead of falling back. + pub fn or_provided(self, provided: Option<&str>) -> Option { + self.published().or_else(|| provided.map(str::to_string)) + } +} + +/// Read the canister's `candid:service` metadata and say what the read +/// established (see [`CandidServiceRead`]). +pub async fn read_candid_service(agent: &Agent, canister: Principal) -> CandidServiceRead { + classify_candid_service_read( + agent.read_state_canister_metadata(canister, "candid:service").await, + ) +} + +/// The classification behind [`read_candid_service`], over the agent's result. +/// Only a certified absence (`LookupPathAbsent`: the certificate proves the +/// path is not in the state tree) means the canister publishes none. Every +/// other error — an HTTP or transport error, a certificate that did not +/// verify, a path the certificate leaves unknown — is a failed read, since +/// none of them says anything about the canister. +pub(crate) fn classify_candid_service_read( + result: Result, AgentError>, +) -> CandidServiceRead { + match result { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(did) => CandidServiceRead::Published(did), + Err(_) => CandidServiceRead::NotText, + }, + Err(AgentError::LookupPathAbsent(_)) => CandidServiceRead::NotPublished, + Err(e) => CandidServiceRead::Failed(e.to_string()), + } } /// Maximum byte length of caller-supplied textual Candid we will parse. Real @@ -832,6 +897,33 @@ pub fn has_oql(did: &str) -> bool { .unwrap_or(false) } +/// Every method name the service in `did` declares — read from the actor through +/// `TypeEnv::as_service`, which resolves a service CONSTRUCTOR +/// (`service : (InitArgs) -> { … }`, the form every real ledger publishes) and a +/// `type S = service { … }; service : S` alias as well as a bare +/// `service : { … }`. `None` when the interface cannot be read: it trips +/// [`guard_candid_text`] (CWE-674), does not parse, or declares no actor (types +/// only) — `canister_update_call` then refuses the call +/// (`compliance::unchecked_update_refusal`), since an update needs a readable +/// published interface. `Some(empty)` is a service that declares no methods. +/// +/// Name-based only, like [`has_oql`]: the names are collected INSIDE the +/// deep-stack closure (the AST is `!Send`, and its recursive `Drop` stays on that +/// stack), and nothing about modes or signatures crosses back. Takes its own +/// [`on_deep_stack`], so it must not be called from inside another. +pub fn declared_methods(did: &str) -> Option> { + if guard_candid_text("the `candid` interface", did).is_err() { + return None; + } + on_deep_stack(|| { + let (env, actor) = candid_parser::utils::CandidSource::Text(did).load().ok()?; + let actor = actor?; + let methods = env.as_service(&actor).ok()?; + Some(methods.iter().map(|(name, _)| name.clone()).collect()) + }) + .flatten() +} + /// Whether `method` is callable as a Candid `query` in `did`: `Some(true)` for a /// query (or composite-query) method, `Some(false)` for an update method, `None` /// when the interface can't be parsed or the method isn't declared. Used by @@ -1874,8 +1966,8 @@ mod tests { #[test] fn interface_entry_points_refuse_alias_chains() { use super::{ - api_doc_method, decode_bytes_with_did, encode_args, has_oql, is_query_method, - MAX_CANDID_TYPE_DECLS, + api_doc_method, declared_methods, decode_bytes_with_did, encode_args, has_oql, + is_query_method, MAX_CANDID_TYPE_DECLS, }; // Just past the cap: cheap to check even if the guard ever stopped firing, // so a regression here shows up as a failure rather than a hung CI job. @@ -1893,6 +1985,7 @@ mod tests { // query-vs-update (the caller then fails open and lets the IC decide). assert!(!has_oql(&did), "an unparseable interface must not advertise OQL"); assert_eq!(api_doc_method(&did), None); + assert_eq!(declared_methods(&did), None, "an unparseable interface declares nothing"); assert_eq!(is_query_method(&did, "m"), None); // Decode falls back to type-less rather than erroring. assert_eq!(decode_bytes_with_did(&did, "m", &[]), None); @@ -1984,6 +2077,113 @@ mod tests { ); } + // The declared method set is what the financial guard's interface scope + // reads: every name on the actor, through whichever form the `.did` uses — a + // bare service, a service CONSTRUCTOR (what real ledgers publish), or an + // alias — with a quoted name normalized to the bare identifier. Anything that + // is not a readable service is `None`, never a panic. + #[test] + fn declared_methods_lists_the_actor_methods() { + use super::declared_methods; + let names = + |did: &str| declared_methods(did).map(|set| set.into_iter().collect::>()); + assert_eq!( + names("service : { greet : (text) -> (text) query; set_message : (text) -> (text); }"), + Some(vec!["greet".to_string(), "set_message".to_string()]) + ); + // A service constructor (init arguments) is unwrapped to its methods — + // the form the ICP ledger and every ic-icrc1 ledger publish. + assert_eq!( + names("service : (nat) -> { icrc1_transfer : (text) -> (text); }"), + Some(vec!["icrc1_transfer".to_string()]), + "a service constructor must resolve to its methods" + ); + // An aliased actor resolves through the alias. + assert_eq!( + names("type S = service { a : () -> (); b : () -> () query; }; service : S"), + Some(vec!["a".to_string(), "b".to_string()]) + ); + // A quoted method name is the bare identifier once parsed. + assert_eq!( + names("service : { \"icrc1_transfer\" : (text) -> (text); }"), + Some(vec!["icrc1_transfer".to_string()]) + ); + // No actor at all (types only), garbage, and empty text are unreadable. + assert_eq!(names("type T = record { x : nat };"), None); + assert_eq!(names("not a candid interface at all"), None); + assert_eq!(names(""), None); + // A service declaring no methods is readable, and empty. + assert_eq!(names("service : {}"), Some(vec![])); + } + + // An over-limit interface is unreadable, never parsed: the CWE-674 guard + // trips BEFORE parsing and the caller gets `None` rather than a verdict + // (which the update path turns into a refusal) — and the shallow twin + // proves the guard, not the shape, is why. + #[test] + fn declared_methods_is_none_for_an_over_limit_interface() { + use super::declared_methods; + let over_deep = format!( + "service : {{ icrc1_balance_of : (text) -> ({}nat) query; set_message : (text) -> (text); }}", + "vec ".repeat(5000), + ); + assert_eq!(declared_methods(&over_deep), None, "an over-limit interface is unreadable"); + let shallow_twin = + "service : { icrc1_balance_of : (text) -> (nat) query; set_message : (text) -> (text); }"; + assert_eq!(declared_methods(shallow_twin).map(|set| set.len()), Some(2)); + } + + // What a candid:service read establishes, from the agent's result: only a + // certified absence means the canister publishes none; every other error is + // a FAILED read. The update path refuses both, with a different message for + // each (publish it / retry), so the two must not be confused. Bytes that are + // not text are published and unreadable — a property of the canister, like + // unparseable text, not of the read — and get their own outcome. + #[test] + fn a_candid_service_read_distinguishes_absent_from_failed() { + use super::{classify_candid_service_read as classify, CandidServiceRead::*}; + use ic_agent::{agent_error::HttpErrorPayload, AgentError}; + assert_eq!(classify(Ok(b"service : {}".to_vec())), Published("service : {}".to_string())); + assert_eq!(classify(Ok(vec![0xff, 0xfe])), NotText, "published, but not text"); + assert_eq!( + classify(Err(AgentError::LookupPathAbsent(vec![]))), + NotPublished, + "a certified absence is the one error that means 'publishes none'" + ); + for error in [ + AgentError::HttpError(HttpErrorPayload { + status: 502, + content_type: None, + content: b"bad gateway".to_vec(), + }), + AgentError::TimeoutWaitingForResponse(), + AgentError::CertificateVerificationFailed(), + AgentError::LookupPathUnknown(vec![]), + AgentError::LookupPathError(vec![]), + AgentError::InvalidReplicaUrl("x".into()), + ] { + let text = error.to_string(); + assert_eq!(classify(Err(error)), Failed(text.clone()), "{text}"); + } + } + + // The interface to encode against on a read-only path: the published text + // wins; a caller-supplied text stands in when the canister publishes none — + // and when the read failed too. (The update path never falls back: it + // refuses unless the read is Published.) + #[test] + fn or_provided_falls_back_for_absent_and_failed_reads() { + use super::CandidServiceRead::*; + assert_eq!(Published("a".into()).or_provided(Some("b")), Some("a".to_string())); + assert_eq!(NotPublished.or_provided(Some("b")), Some("b".to_string())); + assert_eq!(NotPublished.or_provided(None), None); + assert_eq!(NotText.or_provided(Some("b")), Some("b".to_string())); + assert_eq!(NotText.published(), None); + assert_eq!(Failed("boom".into()).or_provided(Some("b")), Some("b".to_string())); + assert_eq!(Failed("boom".into()).or_provided(None), None); + assert_eq!(Failed("boom".into()).published(), None); + } + // is_query_method classifies a method by its Candid mode so canister_query can // reject a query call to an UPDATE method up front: Some(true) for a query AND a // composite_query, Some(false) for an update, None when the method isn't declared diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index 144bc867..8adf6e25 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -13,9 +13,11 @@ //! creation and funding are the one exception — they point at the user's own //! icp CLI, which is where this connector already says that work happens. //! -//! The guard has four groups (the method groups are split per review, so an -//! app that happens to name a non-financial method `transfer` or `approve` -//! keeps working): +//! The guard has six scopes — five decided from the request alone, before any +//! network work, and one decided from the canister's own declared interface +//! once `canister_update_call` has read it (the method groups are split per +//! review, so an app that happens to name a non-financial method `transfer` or +//! `approve` keeps working): //! //! * **Standardized value-moving methods, matched literally on every //! canister.** Token ledgers on the Internet Computer follow the ICRC @@ -56,6 +58,37 @@ //! destination, the refusal says so and claims nothing about the call //! itself: `store` on a wallet backend is refused for where it goes, not //! for what it does (per review). +//! * **ICRC ledger methods by name, refused on every canister.** A call +//! addressed to any other `icrc1_*` / `icrc2_*` method — a balance read, +//! the metadata, an allowance query invoked as an update — is refused +//! before any network work: the caller is addressing the canister as an +//! ICRC ledger, and a canister that provides those methods is a financial +//! canister (next scope). The register disclaims the call: a balance read +//! is not a transfer, and the guard says only that the name is a ledger +//! method's. +//! * **Financial canisters detected from the canister itself, refused +//! entirely.** Once the interface is in hand — the canister's own +//! published `candid:service`, which `canister_update_call` reads before +//! anything is encoded or signed, and the only interface an update call +//! is ever checked against — a canister whose declared interface provides +//! ANY `icrc1_*` / `icrc2_*` method (a query such as `icrc1_balance_of` +//! included) is a financial canister, and every update call to it gets the +//! destination register, naming the standard(s) and the methods that +//! established them. That covers every ICRC-1/ICRC-2 ledger — the ICP +//! ledger, the cycles ledger, every SNS and chain-key ledger, every token +//! built on the ic-icrc1 ledger — with no list to keep current. An update +//! call NEEDS that published interface, so this scope always has it (per +//! review and the maintainer's direction): a canister that publishes none +//! — a CERTIFIED absence, the certificate proving the path is not in the +//! state tree — one whose published text the parser cannot read, and one +//! whose read FAILED (a transport, HTTP or certificate error) are each +//! refused with [`unchecked_update_refusal`], which says which it was and +//! what follows (publish the interface; retry). No update this server +//! sends went unchecked, and no caller-supplied text is consulted for an +//! update call (`canister_query` keeps its `candid` fallback: a query +//! cannot commit state). Other ICRC token namespaces (`icrc4_`, `icrc7_`, +//! `icrc37_`, `icrc152_`) are not detected this way; their value-moving +//! names are refused literally above. //! //! Why the standardized surface is where real funds live: tokens are only //! valuable if they can be exchanged or used, and the ICP ecosystem's @@ -75,7 +108,14 @@ //! a policy paragraph inside `canister_update_call`'s description would //! read as a hint that the tool is usable for financial transactions. //! This guard enforces the policy for the standardized ledger surface, -//! where real funds overwhelmingly live (see above). +//! where real funds overwhelmingly live (see above), and — from the +//! canister's own interface, which every update call must have published +//! — for every canister that provides ICRC methods; a canister whose +//! interface is missing, unreadable or failed to read gets no update call +//! at all. What is not detected is a bespoke value-moving method under an +//! unrelated name on a canister whose published interface is not a +//! ledger's, and that limit is stated wherever the guard is described, +//! never implied away. //! Likewise the canister list is curated and static, while exchanges //! create per-pair pool/farm canisters dynamically and new services //! launch: entries cover each service's central canisters (verified @@ -89,6 +129,7 @@ //! ecosystem's ICRC-integrated platforms. The stated policy covers them. use candid::Principal; +use std::collections::BTreeSet; /// Standardized value-moving methods, refused on EVERY canister, matched /// literally (the ICRC token standards fix these exact names; Candid method @@ -425,13 +466,50 @@ const DISALLOWED_FINANCE_CANISTERS: &[(&str, &str, &str)] = &[ /// [`DISALLOWED_FINANCE_CANISTERS`]. const CK_LEDGER_WHY: &str = "its update surface transfers the token or grants spending approvals"; -/// The financial-transactions gate: `Some(refusal)` when the call is -/// disallowed, `None` when it may proceed. Four scopes, checked in order: the -/// mixed-purpose entry points and the standardized value-moving method names -/// (both refused on every canister), the abstract names scoped to the system -/// ledger where they are financial, and the finance-canister list — on a -/// listed canister EVERY update method is refused, so `None` never depends on -/// the method name alone. Method matching is literal — the IC matches method +/// The ICRC token-ledger namespaces, with the standard each names: a method +/// whose name starts with one of these prefixes is an ICRC-1 / ICRC-2 ledger +/// method, and a canister that provides one is a financial canister (see the +/// module doc). Literal prefixes WITH the underscore, so +/// `icrc10_supported_standards` (ICRC-10, a generic capability listing) and +/// `icrc21_canister_call_consent_message` (ICRC-21, consent messages) do not +/// match. +const ICRC_LEDGER_PREFIXES: &[(&str, &str)] = &[("icrc1_", "ICRC-1"), ("icrc2_", "ICRC-2")]; + +/// The ICRC-1 / ICRC-2 standard `method` is named for, if any. +fn icrc_ledger_standard(method: &str) -> Option<&'static str> { + ICRC_LEDGER_PREFIXES.iter().find(|(prefix, _)| method.starts_with(prefix)).map(|(_, s)| *s) +} + +/// How many of a financial canister's ICRC method names a refusal lists before +/// summarizing the rest — enough to make the evidence checkable against +/// `get_canister_candid`, not the whole ledger interface. +const MAX_LISTED_METHODS: usize = 5; + +/// The destination register: what is disallowed is the service the call goes +/// to, not this call, so the message says that and claims nothing about the +/// method (per review — `store` on a wallet backend is not a transfer). Shared +/// by the listed-canister blanket and the interface scope, so both carry the +/// same pinned phrases by construction. +fn destination_refusal(method: &str, service: &str, why: &str) -> String { + format!( + "`{method}` is an update call to {service} — {why}. Every state-changing \ + call to that service is refused, so this refusal does not mean this \ + particular call moves assets. State-changing calls to financial services \ + are not supported by this server, to protect the user. {NEUTRAL_REDIRECT}" + ) +} + +/// The financial-transactions gate, the scopes decided from the request alone: +/// `Some(refusal)` when the call is disallowed, `None` when it may proceed. +/// Five scopes, checked in order: the mixed-purpose entry points and the +/// standardized value-moving method names (both refused on every canister), +/// the abstract names scoped to the system ledger where they are financial, +/// the finance-canister list — on a listed canister EVERY update method is +/// refused, so `None` never depends on the method name alone — and, last, a +/// call addressed to any other ICRC-1 / ICRC-2 ledger method by name. The +/// sixth scope, decided from the canister's own declared interface, is +/// [`disallowed_update_on_ledger_interface`], which the tool runs once it has +/// read that interface. Method matching is literal — the IC matches method /// names by exact bytes, so a differently-spelled name would not reach the /// real method anyway. /// @@ -443,9 +521,12 @@ const CK_LEDGER_WHY: &str = "its update surface transfers the token or grants sp /// * a mixed-purpose entry point: some of its commands move assets and the /// method is disabled as a whole, so this call is not classified; /// * a listed canister: every state-changing call to that service is -/// refused, which again says nothing about this particular call. +/// refused, which again says nothing about this particular call; +/// * a call addressed to an ICRC ledger method by name: the caller is +/// treating the canister as a ledger, so the call is refused and, again, +/// not classified. /// -/// All three name the method, state the policy and why it exists, and tell the +/// All four name the method, state the policy and why it exists, and tell the /// agent what to recommend to the user instead: performing the operation /// outside this connector in a trusted interface they control, or, for the /// creation/funding methods, with their own icp CLI. @@ -494,18 +575,136 @@ pub fn disallowed_update_method(canister_id: &Principal, method: &str) -> Option {instead}" )); } - let (_, service, why) = DISALLOWED_FINANCE_CANISTERS.iter().find(|(c, _, _)| *c == canister)?; // The blanket: what is disallowed here is the destination, not this call, // so the message says that and claims nothing about the method (per // review — `store` on a wallet backend is not a transfer). + if let Some((_, service, why)) = + DISALLOWED_FINANCE_CANISTERS.iter().find(|(c, _, _)| *c == canister) + { + return Some(destination_refusal(method, service, why)); + } + // Last, a call addressed to an ICRC ledger method by name — any `icrc1_*` / + // `icrc2_*` beyond the exact value-moving names above. The caller is + // treating the canister as a ledger, which makes it a financial canister + // (see `disallowed_update_on_ledger_interface`); the register disclaims the + // call, since a balance read invoked as an update is not a transfer. + let standard = icrc_ledger_standard(method)?; Some(format!( - "`{method}` is an update call to {service} — {why}. Every state-changing \ - call to that service is refused, so this refusal does not mean this \ - particular call moves assets. State-changing calls to financial services \ - are not supported by this server, to protect the user. {NEUTRAL_REDIRECT}" + "`{method}` is named as an {standard} ledger method. A canister that provides \ + ICRC-1 or ICRC-2 methods is treated as a financial service and update calls \ + to it are refused, so this refusal does not mean this particular call moves \ + assets. Financial transactions (token transfers, spending approvals, payments, \ + or trades) are not supported by this server, to protect the user. \ + {NEUTRAL_REDIRECT}" )) } +/// The interface scope of the gate, decided from the canister itself: +/// `Some(refusal)` when `declared` — the method names the canister's Candid +/// interface declares, as `calls::declared_methods` read them — includes an +/// ICRC-1 / ICRC-2 ledger method, `None` otherwise. A canister that provides +/// those methods is a financial canister, and EVERY update call to it is refused +/// with the destination register: the rule is about where the call goes, so the +/// message names the standard(s) and the methods that established them and +/// claims nothing about this call. +/// +/// Runs only where the interface is legitimately in hand — after the +/// discoverability gate admitted the canister and `canister_update_call` read +/// the interface it encodes against — and AFTER [`disallowed_update_method`], +/// whose scopes are cheaper and more specific. A canister that publishes no +/// interface, one the parser cannot read, and a read that FAILED never reach +/// here: the caller refuses each with [`unchecked_update_refusal`], since an +/// update call needs the published interface. +pub fn disallowed_update_on_ledger_interface( + method: &str, + declared: &BTreeSet, +) -> Option { + let ledger_methods: Vec<(&String, &'static str)> = declared + .iter() + .filter_map(|name| icrc_ledger_standard(name).map(|standard| (name, standard))) + .collect(); + if ledger_methods.is_empty() { + return None; + } + // The standards present, in the table's order ("ICRC-1 and ICRC-2"). + let standards = ICRC_LEDGER_PREFIXES + .iter() + .map(|(_, standard)| *standard) + .filter(|standard| ledger_methods.iter().any(|(_, s)| s == standard)) + .collect::>() + .join(" and "); + let mut listed = ledger_methods + .iter() + .take(MAX_LISTED_METHODS) + .map(|(name, _)| format!("`{name}`")) + .collect::>() + .join(", "); + if ledger_methods.len() > MAX_LISTED_METHODS { + listed.push_str(&format!(", and {} more", ledger_methods.len() - MAX_LISTED_METHODS)); + } + let service = + format!("a canister whose Candid interface declares {standards} ledger methods ({listed})"); + Some(destination_refusal( + method, + &service, + "a canister that provides the ICRC token-ledger interface is treated as a financial \ + service as a whole", + )) +} + +/// Why `canister_update_call` has no interface to check a call against — each +/// a refusal, since an update call needs the canister's published interface, +/// and each with its own next step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MissingInterface { + /// The canister publishes no `candid:service` — a certified absence, which + /// is also what the state tree says of an id no canister occupies. + NotPublished, + /// The canister publishes something this server cannot read: bytes that + /// are not text, text that is not valid Candid or declares no service, or + /// text over the size / depth limits (CWE-674). + Unreadable, + /// The read failed — a transport, HTTP or certificate error — with its text. + ReadFailed(String), +} + +/// The refusal for an update whose target could not be checked: the interface +/// scope needs the canister's own published interface, and there is none in +/// hand — `missing` says why. Refused rather than sent unchecked (per review and +/// the maintainer's direction: a canister must publish its interface to receive +/// update calls, and a failed read must not be the way past the scope). The +/// register claims nothing about the call: the guard learned nothing about it. +pub fn unchecked_update_refusal(method: &str, missing: &MissingInterface) -> String { + let (what, next) = match missing { + MissingInterface::NotPublished => ( + "the canister publishes no Candid interface (no `candid:service` metadata), or no \ + canister exists at this id" + .to_string(), + "A canister receives update calls through this server only once it publishes its \ + interface.", + ), + MissingInterface::Unreadable => ( + "the canister's published Candid interface could not be read by this server (not \ + text, not valid Candid, no service declared, or over its size or depth limits)" + .to_string(), + "Until the canister publishes a readable interface, no update call to it is sent.", + ), + MissingInterface::ReadFailed(error) => ( + format!("the canister's Candid interface could not be read ({error})"), + "Retry: an error that persists means this server cannot reach the interface it \ + needs, or the canister id is not one the network routes.", + ), + }; + format!( + "`{method}` was not sent: {what}. An update call is checked against the canister's \ + published interface before it is sent — a canister that provides the ICRC token-ledger \ + interface is treated as a financial service as a whole — and a call that cannot be \ + checked is refused rather than sent unchecked, to protect the user. This refusal is \ + about the check that could not run, so it does not mean this particular call moves \ + assets. {next} {NEUTRAL_REDIRECT}" + ) +} + #[cfg(test)] mod tests { use super::*; @@ -526,6 +725,30 @@ mod tests { /// The clause a listed-canister refusal must carry: the rule is about the /// destination, so the message states that rather than the call. const DESTINATION_RULE: &str = "Every state-changing call to that service is refused"; + /// The one claim only a value-moving method's refusal may make. + const ITSELF: &str = "the call itself carries out that operation"; + /// The disclaimer every other register must carry. + const NOT_THIS_CALL: &str = "does not mean this particular call moves assets"; + /// The clause the interface scope's refusal must carry: the evidence is what + /// the canister itself declares. + const INTERFACE_RULE: &str = "whose Candid interface declares"; + /// The clause the missing-interface refusal must carry: an update call needs + /// the published interface, and the check could not run without it. + const UNCHECKED_RULE: &str = + "checked against the canister's published interface before it is sent"; + /// Every way an update call can lack the interface it needs. + fn every_missing_interface() -> [MissingInterface; 3] { + [ + MissingInterface::NotPublished, + MissingInterface::Unreadable, + MissingInterface::ReadFailed("The request timed out.".to_string()), + ] + } + + /// A declared method set, as `calls::declared_methods` would read it. + fn declared(names: &[&str]) -> BTreeSet { + names.iter().map(|n| n.to_string()).collect() + } // Every standardized value-moving method is refused on ANY canister, in // its exact standard spelling — the ICRC token surface and the NNS/SNS @@ -620,17 +843,15 @@ mod tests { } } - // Reads, fees, metadata, and legacy DIP20-style names on arbitrary - // canisters stay callable. The cycles-minting canister's method names are - // in the list too: they are refused on the CMC itself (above), and an - // unrelated canister's method that happens to share the name is not a - // funding completion. + // Reads, fees, and legacy DIP20-style names on arbitrary canisters stay + // callable (an ICRC-named read is the exception: it addresses the canister + // as a ledger — see `icrc_named_calls_are_refused_pre_network`). The + // cycles-minting canister's method names are in the list too: they are + // refused on the CMC itself (above), and an unrelated canister's method + // that happens to share the name is not a funding completion. #[test] fn allows_non_financial_methods() { for method in [ - "icrc1_balance_of", - "icrc1_metadata", - "icrc1_fee", "transfer_fee", "get_transactions", "notify_create_canister", @@ -770,9 +991,6 @@ mod tests { // edit that collapses them back into one message fails here. #[test] fn each_scope_gets_the_refusal_register_that_is_true_of_it() { - const ITSELF: &str = "the call itself carries out that operation"; - const NOT_THIS_CALL: &str = "does not mean this particular call moves assets"; - // A method that IS the operation: transfers, approvals, and the // funding completions that finish a payment. for (canister, method) in [ @@ -803,5 +1021,229 @@ mod tests { assert!(msg.contains(DESTINATION_RULE), "{msg}"); assert!(msg.contains(NOT_THIS_CALL), "{msg}"); assert!(!msg.contains(ITSELF), "{msg}"); + + // A financial canister detected from its own interface: the destination + // register again, with the interface as the evidence. + let ledger = declared(&["icrc1_transfer", "icrc1_balance_of", "set_message"]); + let msg = + disallowed_update_on_ledger_interface("set_message", &ledger).expect("must be refused"); + assert!(msg.contains(DESTINATION_RULE), "{msg}"); + assert!(msg.contains(NOT_THIS_CALL), "{msg}"); + assert!(msg.contains(INTERFACE_RULE), "{msg}"); + assert!(!msg.contains(ITSELF), "{msg}"); + + // A call addressed to an ICRC ledger method by name, with no interface in + // hand: disclaimed, and neither of the other two registers. + let msg = + disallowed_update_method(&any_canister(), "icrc1_balance_of").expect("must be refused"); + assert!(msg.contains(NOT_THIS_CALL), "{msg}"); + assert!(!msg.contains(ITSELF), "{msg}"); + assert!(!msg.contains(DESTINATION_RULE), "{msg}"); + + // No interface to check against — not published, unreadable, or a read + // that failed: nothing was learned about the call or the canister, so + // the message says only that the check could not run — disclaimed, and + // none of the other registers. + for missing in every_missing_interface() { + let msg = unchecked_update_refusal("set_message", &missing); + assert!(msg.contains(UNCHECKED_RULE), "{msg}"); + assert!(msg.contains(NOT_THIS_CALL), "{msg}"); + assert!(!msg.contains(ITSELF), "{msg}"); + assert!(!msg.contains(DESTINATION_RULE), "{msg}"); + assert!(!msg.contains(INTERFACE_RULE), "{msg}"); + } + } + + // A canister that provides an ICRC-1 / ICRC-2 method is a financial canister: + // every update call to it is refused, whatever the method is called, and the + // refusal names the standard(s) and the methods that established them — + // enough to check against get_canister_candid, summarizing the rest. + #[test] + fn an_icrc_interface_makes_every_update_refused() { + let ledger = declared(&["greet", "set_message", "icrc1_transfer", "icrc1_balance_of"]); + for method in ["set_message", "greet", "http_request_update"] { + let msg = disallowed_update_on_ledger_interface(method, &ledger) + .unwrap_or_else(|| panic!("{method} on an ICRC-1 canister must be refused")); + assert!(msg.contains(&format!("`{method}`")), "{msg}"); + assert!(msg.contains("ICRC-1 ledger methods"), "{msg}"); + assert!( + msg.contains("`icrc1_balance_of`") && msg.contains("`icrc1_transfer`"), + "{msg}" + ); + } + let approvals = declared(&["icrc2_allowance", "set_message"]); + let msg = disallowed_update_on_ledger_interface("set_message", &approvals).unwrap(); + assert!(msg.contains("ICRC-2 ledger methods (`icrc2_allowance`)"), "{msg}"); + let both = declared(&["icrc1_transfer", "icrc2_approve", "x"]); + let msg = disallowed_update_on_ledger_interface("x", &both).unwrap(); + assert!(msg.contains("ICRC-1 and ICRC-2 ledger methods"), "{msg}"); + // Seven ICRC names: five are listed, the rest counted. + let many = declared(&[ + "icrc1_name", + "icrc1_symbol", + "icrc1_decimals", + "icrc1_fee", + "icrc1_metadata", + "icrc1_total_supply", + "icrc1_transfer", + "store", + ]); + let msg = disallowed_update_on_ledger_interface("store", &many).unwrap(); + assert!(msg.contains(", and 2 more)"), "{msg}"); + assert_eq!(msg.matches("`icrc1_").count(), 5, "{msg}"); + // The real ICP ledger's surface (the methods of its service constructor). + let icp = declared(&[ + "transfer", + "account_balance", + "transfer_fee", + "send_dfx", + "account_balance_dfx", + "query_blocks", + "icrc1_balance_of", + "icrc1_transfer", + "icrc2_approve", + "icrc2_transfer_from", + "icrc21_canister_call_consent_message", + "icrc10_supported_standards", + ]); + let msg = disallowed_update_on_ledger_interface("remove_approval", &icp).unwrap(); + assert!(msg.contains("ICRC-1 and ICRC-2 ledger methods"), "{msg}"); + } + + // The rule is "provides an ICRC-1 / ICRC-2 function", not "implements the + // standard": a lone query such as icrc1_balance_of is enough. + #[test] + fn a_query_only_icrc_method_still_counts() { + let index = declared(&["icrc1_balance_of", "get_message", "set_message"]); + assert!(disallowed_update_on_ledger_interface("set_message", &index).is_some()); + } + + // The prefixes are literal, underscore included: the generic ICRC-10 listing + // and the ICRC-21 consent message are not ledger methods, nor are other + // token namespaces, re-spellings, or a name that merely contains the prefix. + // The e2e `app` fixture (greet/whoami/get_message/set_message/get_api_doc) + // is the plain shape that must stay callable. + #[test] + fn icrc_prefixes_are_literal() { + for names in [ + vec!["icrc10_supported_standards", "set_message"], + vec!["icrc21_canister_call_consent_message", "set_message"], + vec!["icrc7_transfer", "set_message"], + vec!["ICRC1_transfer", "set_message"], + vec!["my_icrc1_transfer", "set_message"], + vec!["greet", "whoami", "get_message", "set_message", "get_api_doc"], + vec![], + ] { + let set = declared(&names); + assert_eq!( + disallowed_update_on_ledger_interface("set_message", &set), + None, + "{names:?} must not be a financial canister" + ); + } + } + + // The interface refusal is the destination register, in the same plain + // protective terms as the listed-canister one: it names the method, the + // standard, the policy, and the redirect, claims nothing about this call, + // and names no venue. + #[test] + fn ledger_interface_refusal_uses_the_destination_register() { + let ledger = declared(&["icrc1_transfer", "icrc1_balance_of", "set_message"]); + let msg = + disallowed_update_on_ledger_interface("set_message", &ledger).expect("must be refused"); + assert!(msg.contains("`set_message`"), "{msg}"); + assert!(msg.contains(INTERFACE_RULE), "{msg}"); + assert!(msg.contains("ICRC-1 ledger methods"), "{msg}"); + assert!(msg.contains(DESTINATION_RULE), "{msg}"); + assert!(msg.contains(NOT_THIS_CALL), "{msg}"); + assert!(!msg.contains(ITSELF), "{msg}"); + assert!(msg.contains("not supported by this server, to protect the user"), "{msg}"); + assert!(msg.contains("outside this connector"), "{msg}"); + assert!(msg.contains("trusted interface they control"), "{msg}"); + assert!(!msg.to_lowercase().contains("wallet"), "no venue may be named: {msg}"); + assert!(!msg.contains(".com"), "no venue may be named: {msg}"); + assert!(!msg.contains("marketplace"), "{msg}"); + assert!(!msg.contains("compliance"), "{msg}"); + } + + // An update call needs the canister's published interface. Without one — + // not published, published but unreadable, or a read that FAILED — the call + // is refused with a message that names the method, says which it was and + // what follows (publish it; retry only for the failed read), claims nothing + // about the call — the guard learned nothing about it — and names no venue. + #[test] + fn an_update_without_a_readable_interface_is_refused() { + for missing in every_missing_interface() { + let msg = unchecked_update_refusal("set_message", &missing); + assert!(msg.contains("`set_message`"), "{msg}"); + assert!(msg.contains("was not sent"), "{msg}"); + assert!(msg.contains(UNCHECKED_RULE), "{msg}"); + assert!(msg.contains(NOT_THIS_CALL), "{msg}"); + assert!(!msg.contains(ITSELF), "{msg}"); + assert!(!msg.contains(DESTINATION_RULE), "{msg}"); + assert!(!msg.contains(INTERFACE_RULE), "{msg}"); + assert!(msg.contains("to protect the user"), "{msg}"); + assert!(msg.contains("outside this connector"), "{msg}"); + assert!(msg.contains("trusted interface they control"), "{msg}"); + assert!(!msg.to_lowercase().contains("wallet"), "no venue may be named: {msg}"); + assert!(!msg.contains(".com"), "no venue may be named: {msg}"); + assert!(!msg.contains("marketplace"), "{msg}"); + assert!(!msg.contains("compliance"), "{msg}"); + match missing { + MissingInterface::NotPublished => { + assert!(msg.contains("publishes no Candid interface"), "{msg}"); + // A certified absence is also what an unoccupied id yields, + // so the message must not assert that the canister exists. + assert!(msg.contains("or no canister exists at this id"), "{msg}"); + assert!(msg.contains("only once it publishes its interface"), "{msg}"); + assert!(!msg.contains("Retry"), "nothing to retry: {msg}"); + } + MissingInterface::Unreadable => { + assert!(msg.contains("could not be read by this server"), "{msg}"); + assert!(msg.contains("not text, not valid Candid"), "{msg}"); + assert!(msg.contains("publishes a readable interface"), "{msg}"); + assert!(!msg.contains("Retry"), "nothing to retry: {msg}"); + } + MissingInterface::ReadFailed(error) => { + assert!(msg.contains(&error), "the error is named: {msg}"); + assert!(msg.contains("Retry"), "{msg}"); + assert!(msg.contains("canister id is not one the network routes"), "{msg}"); + } + } + } + } + + // Addressing a canister by an ICRC-1 / ICRC-2 method name is refused before + // any network work, even when no interface says the canister provides it: + // the caller is treating it as a ledger. The exact transfer/approval names + // keep their sharper register (they ARE the operation); the rest get the + // disclaiming one; ICRC-10 / ICRC-21 names and other token namespaces are + // not ledger methods here. + #[test] + fn icrc_named_calls_are_refused_pre_network() { + for method in ["icrc1_balance_of", "icrc1_metadata", "icrc1_fee", "icrc2_allowance"] { + let msg = disallowed_update_method(&any_canister(), method) + .unwrap_or_else(|| panic!("{method} must be refused on any canister")); + assert!(msg.contains(&format!("`{method}`")), "{msg}"); + assert!(msg.contains("ledger method"), "{msg}"); + assert!(msg.contains(NOT_THIS_CALL), "{msg}"); + assert!(!msg.contains(ITSELF), "{msg}"); + assert!(msg.contains("to protect the user"), "{msg}"); + assert!(msg.contains("outside this connector"), "{msg}"); + } + let msg = disallowed_update_method(&any_canister(), "icrc1_transfer").unwrap(); + assert!(msg.contains(ITSELF), "{msg}"); + for method in [ + "icrc10_supported_standards", + "icrc21_canister_call_consent_message", + "icrc7_balance_of", + "ICRC1_balance_of", + ] { + assert!( + disallowed_update_method(&any_canister(), method).is_none(), + "{method} is not an ICRC-1 / ICRC-2 ledger method" + ); + } } } diff --git a/crates/imcp2-core/src/e2e_canister_tools.rs b/crates/imcp2-core/src/e2e_canister_tools.rs index bac56b94..d7b62ba3 100644 --- a/crates/imcp2-core/src/e2e_canister_tools.rs +++ b/crates/imcp2-core/src/e2e_canister_tools.rs @@ -296,16 +296,30 @@ fn leb128(out: &mut Vec, mut n: u64) { } /// The interface of the plain (non-OQL) canister: a query to read, an update to -/// write, and an API-doc method for `get_canister_api_doc` to find. +/// write, and an API-doc method for `get_canister_api_doc` to find. It must +/// never declare an `icrc1_` / `icrc2_` method: that would make it a financial +/// canister to the guard's interface scope and refuse every write-path case +/// below (the plain shape is pinned by `compliance::tests::icrc_prefixes_are_literal`). const APP_DID: &str = "service : {\n \ greet : (text) -> (text) query;\n \ whoami : () -> (principal) query;\n \ get_message : () -> (text) query;\n \ set_message : (text) -> (text);\n \ - icrc1_transfer : (text) -> (text);\n \ get_api_doc : () -> (text) query;\n\ }"; +/// The interface of the ledger canister: ICRC-1 methods — the evidence the +/// financial guard's interface scope keys on — next to an unrelated update that +/// writes the store `get_message` reads, the write that scope must refuse. +/// `icrc1_balance_of` is a query, so a read of a financial canister can be shown +/// to still work. +const LEDGER_DID: &str = "service : {\n \ + icrc1_balance_of : (text) -> (text) query;\n \ + icrc1_transfer : (text) -> (text);\n \ + get_message : () -> (text) query;\n \ + set_message : (text) -> (text);\n\ +}"; + /// What the app canister's `get_api_doc` returns. const API_DOC: &str = "Messages are plain text, last write wins, and there is no history."; @@ -405,12 +419,17 @@ struct Harness { oql: Principal, /// A canister publishing no `candid:service` metadata at all. opaque: Principal, + /// A canister whose published interface provides ICRC-1 methods — a + /// financial canister to the guard's interface scope — with an unrelated + /// update method alongside. + ledger: Principal, /// The principal the seeded session signs as at [`Self::origin`]. principal: Principal, /// The session store, so a test that needs the user to hold an identity at /// a SECOND app can seed one ([`Identities::seed_app_identity`]). identities: Identities, - /// The app origin whose manifest declares [`Self::app`] and [`Self::oql`]. + /// The app origin whose manifest declares [`Self::app`], [`Self::oql`] and + /// [`Self::ledger`]. origin: String, } @@ -446,13 +465,6 @@ impl Harness { Method::query("whoami", Behavior::Caller), Method::query("get_message", Behavior::Load), Method::update("set_message", Behavior::Store), - // A value-moving method name, declared and exported, that - // writes to the SAME store `get_message` reads. The - // financial-transactions gate is the only thing standing - // between a tool call and that write, so a test that gets - // the refusal AND an unchanged store has shown the gate - // ran — an undeclared method would give both for free. - Method::update("icrc1_transfer", Behavior::Store), Method::query("get_api_doc", Behavior::Const(text_reply(API_DOC))), ], ), @@ -485,9 +497,30 @@ impl Harness { let plain = canister_wasm(None, &[Method::query("greet", Behavior::Echo)]); let opaque = install(&pic, plain).await; - // The app's published manifest declares the two canisters under test — - // and, deliberately, not `opaque`, which several cases rely on. - webfixture::serve(origin, webfixture::Site::declaring(&[app, oql])); + // A canister that provides ICRC-1 methods — a financial canister to the + // guard's interface scope — alongside an unrelated update that writes + // the store its `get_message` reads. `icrc1_transfer` is declared AND + // exported: the financial-transactions gate is the only thing standing + // between a tool call and that write, so a test that gets the refusal + // AND an unchanged store has shown the gate ran — an undeclared method + // would give both for free. + let ledger = install( + &pic, + canister_wasm( + Some(LEDGER_DID), + &[ + Method::query("icrc1_balance_of", Behavior::Const(text_reply("0"))), + Method::update("icrc1_transfer", Behavior::Store), + Method::query("get_message", Behavior::Load), + Method::update("set_message", Behavior::Store), + ], + ), + ) + .await; + + // The app's published manifest declares the three canisters under test + // — and, deliberately, not `opaque`, which several cases rely on. + webfixture::serve(origin, webfixture::Site::declaring(&[app, oql, ledger])); // The server's own agent, pointed at this replica's gateway. let gateway = pic.make_live(None).await; @@ -533,6 +566,7 @@ impl Harness { app, oql, opaque, + ledger, principal, identities, origin: origin.to_string(), @@ -993,12 +1027,29 @@ async fn an_update_call_writes_state_a_query_reads_back() { assert_eq!(ok_text(&result), "(\"as an update\")"); // --- the financial-transactions gate: refused before the network --- - // A value-moving method name is refused on EVERY canister. This canister - // really declares and exports `icrc1_transfer`, and it writes to the same - // store `get_message` reads — so the assertions below distinguish "the - // gate stopped this" from "the canister would have rejected it anyway". + // A value-moving method name is refused on EVERY canister. The ledger + // fixture really declares and exports `icrc1_transfer`, and it writes to + // the same store its `get_message` reads — so the assertions below + // distinguish "the gate stopped this" from "the canister would have + // rejected it anyway". + let ledger = h.ledger.to_text(); + let read_ledger = || async { + let result = h + .call( + "canister_query", + serde_json::json!({ + "canister_id": ledger, + "method": "get_message", + "args": "()", + "app_url": h.origin, + }), + ) + .await; + ok_text(&result) + }; + assert_eq!(read_ledger().await, "(\"\")", "the ledger fixture starts empty"); let transfer = serde_json::json!({ - "canister_id": app, + "canister_id": ledger, "method": "icrc1_transfer", "args": "(\"moved through the connector\")", "app_url": h.origin, @@ -1011,7 +1062,7 @@ async fn an_update_call_writes_state_a_query_reads_back() { msg.contains("not supported by this server, to protect the user"), "the refusal must be the connector's policy, not the replica's: {msg}" ); - assert_eq!(read_back().await, "(\"booked seat 14\")", "and the canister was never called"); + assert_eq!(read_ledger().await, "(\"\")", "and the canister was never called"); // The positive control for that last assertion: the method is live and // does mutate. Called from OUTSIDE the connector it writes, which is what @@ -1019,7 +1070,7 @@ async fn an_update_call_writes_state_a_query_reads_back() { // canister that could not have done anything anyway. h.pic .update_call( - h.app, + h.ledger, Principal::anonymous(), "icrc1_transfer", Encode!(&"moved on the replica").expect("encode the transfer argument"), @@ -1027,11 +1078,64 @@ async fn an_update_call_writes_state_a_query_reads_back() { .await .expect("the fixture's icrc1_transfer is callable on the replica"); assert_eq!( - read_back().await, + read_ledger().await, "(\"moved on the replica\")", "the method the gate refused really does change state when it is reached" ); + // --- a financial canister, detected from the canister itself --- + // The ledger fixture declares ICRC-1 methods, so EVERY update call to it is + // refused — `set_message` included, a name the gate knows nothing about — + // once its interface has been read. This is the one scope that runs after + // the network is touched: the refusal names the standard and the methods + // that established it, and the store shows the call never arrived. + let msg = refusal( + &h.call( + "canister_update_call", + serde_json::json!({ + "canister_id": ledger, + "method": "set_message", + "args": "(\"written through the connector\")", + "app_url": h.origin, + }), + ) + .await, + ); + assert!(msg.contains("`set_message`"), "the refusal names the method: {msg}"); + assert!(msg.contains("ICRC-1 ledger methods"), "the refusal names the standard: {msg}"); + assert!(msg.contains("`icrc1_transfer`"), "and the method that established it: {msg}"); + assert!( + msg.contains("Every state-changing call to that service is refused") + && msg.contains("does not mean this particular call moves assets"), + "the destination register, claiming nothing about this call: {msg}" + ); + assert!(msg.contains("not supported by this server, to protect the user"), "{msg}"); + assert_eq!(read_ledger().await, "(\"moved on the replica\")", "and nothing was written"); + // The positive control again: the refused method is live on the replica. + h.pic + .update_call( + h.ledger, + Principal::anonymous(), + "set_message", + Encode!(&"written on the replica").expect("encode the message"), + ) + .await + .expect("the ledger fixture's set_message is callable on the replica"); + assert_eq!(read_ledger().await, "(\"written on the replica\")"); + // Reads of a financial canister are not gated: a query cannot move value. + let result = h + .call( + "canister_query", + serde_json::json!({ + "canister_id": ledger, + "method": "icrc1_balance_of", + "args": "(\"anyone\")", + "app_url": h.origin, + }), + ) + .await; + assert_eq!(ok_text(&result), "(\"0\")", "a ledger's balance query still reads"); + // --- the update path signs as the app identity too --- // `is_anonymous` above says only that an origin was passed. This is the // canister's own view of who called, on the WRITE path: a signer @@ -1069,7 +1173,7 @@ async fn an_update_call_writes_state_a_query_reads_back() { refusal(&result); assert_eq!( read_back().await, - "(\"moved on the replica\")", + "(\"booked seat 14\")", "a rejected argument writes nothing — the store still holds the last write" ); } @@ -1433,6 +1537,100 @@ async fn a_read_that_cannot_run_says_what_to_fix() { ); assert!(msg.contains("query failed"), "the replica's rejection is surfaced: {msg}"); + // --- an update call needs the canister's published interface --- + // The opaque canister publishes none (a certified absence): the update is + // refused with the "publish it" message, and the canister is never reached + // — `greet` is a query method the Internet Computer would have let run as + // an update, so a "call failed" or a reply would mean the call was sent. + let msg = refusal( + &h.call( + "canister_update_call", + serde_json::json!({ + "canister_id": opaque, + "method": "greet", + "args": "(\"x\")", + "app_url": declares_all, + }), + ) + .await, + ); + assert!(msg.contains("publishes no Candid interface"), "no interface, no update: {msg}"); + assert!(msg.contains("does not mean this particular call moves assets"), "{msg}"); + assert!(!msg.contains("call failed"), "the call must not have been sent: {msg}"); + // A published interface this server cannot read is no better: the refusal + // says so, and says a readable one is what unblocks the canister. + let garbled = install( + &h.pic, + canister_wasm( + Some("service : { greet : (text) -> ("), + &[Method::query("greet", Behavior::Echo)], + ), + ) + .await; + // A canister id no subnet of this replica serves: the gateway cannot route + // the metadata read, so the read FAILS instead of proving the interface + // absent — a different refusal, with a retry message, since the canister + // itself was not judged. + let phantom = Principal::from_slice(&[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 1, 1]); + let declares_more = "https://errors-more.e2e.test"; + webfixture::serve(declares_more, webfixture::Site::declaring(&[garbled, phantom])); + let msg = refusal( + &h.call( + "canister_update_call", + serde_json::json!({ + "canister_id": garbled.to_text(), + "method": "greet", + "args": "(\"x\")", + "app_url": declares_more, + }), + ) + .await, + ); + assert!(msg.contains("could not be read by this server"), "unreadable interface: {msg}"); + assert!(msg.contains("publishes a readable interface"), "{msg}"); + assert!(!msg.contains("call failed"), "the call must not have been sent: {msg}"); + let msg = refusal( + &h.call( + "canister_update_call", + serde_json::json!({ + "canister_id": phantom.to_text(), + "method": "greet", + "args": "(\"x\")", + "app_url": declares_more, + }), + ) + .await, + ); + assert!(msg.contains("could not be read ("), "a failed read names its error: {msg}"); + assert!(msg.contains("Retry"), "and is the one case worth retrying: {msg}"); + assert!(msg.contains("does not mean this particular call moves assets"), "{msg}"); + assert!(!msg.contains("call failed"), "the call must not have been sent: {msg}"); + // An id inside this subnet's range that no canister occupies any more — + // the common mainnet shape of a nonexistent canister — is a certified + // absence too: the same "publishes none" refusal, whose wording covers + // the id being unoccupied, and never a replica rejection. + let deleted = + install(&h.pic, canister_wasm(None, &[Method::query("greet", Behavior::Echo)])).await; + h.pic.stop_canister(deleted, None).await.expect("stop the canister"); + h.pic.delete_canister(deleted, None).await.expect("delete the canister"); + let declares_deleted = "https://errors-deleted.e2e.test"; + webfixture::serve(declares_deleted, webfixture::Site::declaring(&[deleted])); + let msg = refusal( + &h.call( + "canister_update_call", + serde_json::json!({ + "canister_id": deleted.to_text(), + "method": "greet", + "args": "(\"x\")", + "app_url": declares_deleted, + }), + ) + .await, + ); + assert!(msg.contains("or no canister exists at this id"), "a deleted canister: {msg}"); + assert!(msg.contains("publishes no Candid interface"), "{msg}"); + assert!(!msg.contains("call failed"), "the call must not have been sent: {msg}"); + // --- canister_query needs exactly one of `method` and `oql` --- let msg = refusal( &h.call("canister_query", serde_json::json!({ "canister_id": app, "app_url": h.origin })) diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index 35bddf36..3563003d 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -598,7 +598,7 @@ impl IcCanisterTools { } #[tool( - description = "Calls a canister's *update* method (as opposed to read-only queries which go through canister_query). Method names and argument types come from the canister's Candid interface, which get_canister_candid returns. Runs anonymously unless given a derivation origin, which acts as the user's account at that app.", + description = "Calls a canister's *update* method (as opposed to read-only queries which go through canister_query). Method names and argument types come from the canister's Candid interface, which get_canister_candid returns; the canister must publish that interface, and an update call to a canister that publishes none, or whose interface cannot be read, is refused. Runs anonymously unless given a derivation origin, which acts as the user's account at that app.", annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -611,7 +611,6 @@ impl IcCanisterTools { app_url, derivation_origin, account, - candid, }): Parameters, ctx: RequestContext, ) -> Result { @@ -619,17 +618,22 @@ impl IcCanisterTools { Ok(p) => p, Err(e) => return Ok(err(format!("invalid canister id: {e}"))), }; - // The financial-transactions gate (see `compliance`), in four scopes, - // before any network work: the standardized value-moving method names - // (the ICRC transfer/approval surface) and the mixed-purpose - // governance entry point manage_neuron, both refused on EVERY - // canister; the system ledgers'/cycles-minting canister's own - // value-moving methods on those canisters; and EVERY update method on - // a listed financial-service canister, so a refusal here does not - // depend on the method name alone. Each scope's refusal claims only - // what that scope knows about the call, and points the user outside - // this connector. Queries need no gate — a query cannot commit state, - // so it cannot move funds. + // The financial-transactions gate (see `compliance`): every scope + // decided from the request alone runs here, before any network work — + // the standardized value-moving method names (the ICRC transfer/approval + // surface) and the mixed-purpose governance entry point manage_neuron, + // both refused on EVERY canister; the system ledgers'/cycles-minting + // canister's own value-moving methods on those canisters; EVERY update + // method on a listed financial-service canister, so a refusal here does + // not depend on the method name alone; and a call addressed to any + // other ICRC-1 / ICRC-2 ledger method by name. The one scope that needs + // the canister's interface — a financial canister detected from the + // ICRC methods it declares — runs below, once the canister's published + // interface has been read; an update call needs that interface, so a + // canister that gives none is refused there. + // Each scope's refusal claims only what that scope knows about the + // call, and points the user outside this connector. Queries need no + // gate — a query cannot commit state, so it cannot move funds. if let Some(refusal) = compliance::disallowed_update_method(&principal, &method) { return Ok(err(refusal)); } @@ -657,10 +661,63 @@ impl IcCanisterTools { Err(refusal) => return Ok(err(refusal)), }; // The interface to encode/decode against: the canister's own - // candid:service if exposed, else the caller-supplied `candid`. Update calls - // are never redirected (OQL is read-only), so no oql_query_redirect here. - let did = calls::resolve_did(&self.agent, principal, candid.as_deref()).await; - let arg_bytes = match calls::encode_args(did.as_deref(), &method, &args) { + // candid:service, which an update call REQUIRES — no caller-supplied + // fallback here, unlike canister_query (a query cannot commit state). + // Update calls are never redirected (OQL is read-only), so no + // oql_query_redirect here. Read with the outcome kept, not through + // resolve_did: the refusal below says whether the interface is missing + // or the read failed. + let read = calls::read_candid_service(&self.agent, principal).await; + // The interface scope of the financial-transactions gate (see + // `compliance`): a canister whose declared interface provides an ICRC-1 / + // ICRC-2 method is a financial canister, and every update call to it is + // refused — decided from the canister itself, here and only here. The + // interface is first in hand at this point: reading it IS a contact with + // the canister, which the discoverability gate above has to admit first + // (an undeclared ledger is refused as undeclared), and the read just + // made is the one the encoding needs, so this costs no round-trip. It + // runs before the arguments are encoded (an argument error must not mask + // the policy refusal) and before anything is signed. The scope always + // has the interface, because an update call to a canister that gives it + // none is refused (per review and the maintainer's direction): a + // canister that publishes none, one whose published text the parser + // cannot read, and one whose read FAILED — a transport, HTTP or + // certificate error, refused with a retry message — each get + // `unchecked_update_refusal`, so no update this server sends went + // unchecked. What the guard still does not judge is a bespoke method on + // a canister whose published interface is not a ledger's. + let did = match read { + calls::CandidServiceRead::Published(did) => did, + calls::CandidServiceRead::NotText => { + return Ok(err(compliance::unchecked_update_refusal( + &method, + &compliance::MissingInterface::Unreadable, + ))); + } + calls::CandidServiceRead::NotPublished => { + return Ok(err(compliance::unchecked_update_refusal( + &method, + &compliance::MissingInterface::NotPublished, + ))); + } + calls::CandidServiceRead::Failed(error) => { + return Ok(err(compliance::unchecked_update_refusal( + &method, + &compliance::MissingInterface::ReadFailed(error), + ))); + } + }; + let Some(declared) = calls::declared_methods(&did) else { + return Ok(err(compliance::unchecked_update_refusal( + &method, + &compliance::MissingInterface::Unreadable, + ))); + }; + if let Some(refusal) = compliance::disallowed_update_on_ledger_interface(&method, &declared) + { + return Ok(err(refusal)); + } + let arg_bytes = match calls::encode_args(Some(&did), &method, &args) { Ok(b) => b, Err(e) => return Ok(err(e)), }; @@ -675,7 +732,7 @@ impl IcCanisterTools { Err(e) => return Ok(err(format!("call failed: {e}"))), }; // Decode against the Candid interface so field names are recovered. - let reply = calls::decode_reply(did.as_deref(), &method, &reply_bytes); + let reply = calls::decode_reply(Some(&did), &method, &reply_bytes); let (derived_for_origin, requested, derivation_origin_source) = match &target { Some(t) => (Some(t.origin.clone()), Some(t.requested.clone()), Some(t.source.clone())), None => (None, None, None), diff --git a/crates/imcp2-core/static/candid-textual-syntax.md b/crates/imcp2-core/static/candid-textual-syntax.md index 52a5acf1..0746dd36 100644 --- a/crates/imcp2-core/static/candid-textual-syntax.md +++ b/crates/imcp2-core/static/candid-textual-syntax.md @@ -17,10 +17,14 @@ Quick reminders: declared Candid types** whenever they can read the canister's interface — so plain literals coerce to what the method expects (`42` → `nat64`, `1` → `float64`, the right `opt`/`vec` element types, etc.) and you usually **don't** need `: type` annotations. - -Annotations and the footguns below only bite in the **fallback**: when the -canister's `candid:service` interface can't be read (access-restricted), args -are encoded by *inference from the literals alone*. In that mode: +`canister_update_call` always has that interface: an update call to a canister +that publishes none, or whose interface cannot be read, is refused rather than +sent. + +Annotations and the footguns below only bite in `canister_query`'s **fallback**: +when the canister's `candid:service` interface can't be read and no `candid` +text is supplied, its args are encoded by *inference from the literals alone*. +In that mode: - **Numbers default to `int` (whole) and `float64` (decimal).** A bare `42` encodes as `int`, not `nat`/`nat64`/…; a bare `1.5` as `float64`, not diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 98784148..36177268 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -204,7 +204,11 @@ transactions:** cycles ledgers' own `transfer`/`send_dfx`/`withdraw`/`create_canister` methods on those ledgers, the cycles-minting canister's funding-completion methods (`notify_top_up`, `notify_create_canister`, `notify_mint_cycles`, - `create_canister`), and refuses **every** update call on the + `create_canister`), and refuses **every** update call to a canister whose + own Candid interface declares ICRC-1 or ICRC-2 methods — every ICRC ledger + and the ICP ledger, detected from the canister itself; an update call needs + that published interface, so a canister that publishes none, or whose + interface cannot be read, is refused outright — and to the financial-service canisters it carries; the refusal tells the user to perform the operation outside the connector, in a trusted interface they control, and names no @@ -436,9 +440,13 @@ next edit of the listing. > "Actions & questions" is what permits such calls. A call with no > `derivation_origin` is not signed with the delegation at all: it runs > as the anonymous principal, so the access level does not decide it. -> The connector's own checks still do — the financial-transactions guard -> runs before any identity resolution or network I/O, so a call it refuses -> is refused with or without an origin — and past that the canister +> The connector's own checks still do — the financial-transactions guard's +> name and canister-id checks run before any identity resolution or network +> I/O, and its interface check runs once the canister's declared interface +> is read, before the call is signed or sent (an update call needs that +> published interface: a canister that publishes none, or whose interface +> cannot be read, is refused rather than called), so a call it refuses is +> refused with or without an origin — and past that the canister > decides. The server instructions describe both, so the assistant can > explain which case a call is in. Access is revocable at > any time at https://id.ai/manage/settings. @@ -461,8 +469,15 @@ Topics: directory guidelines, first-party API usage, financial transactions, AI media generation, prompt injection, conversation-data collection, public documentation. **Financial transactions** is a clean acknowledgment: no tool initiates or executes a transfer of the user's funds — financial ledger -methods are refused, and no funding or management tools are served (users run -those operations with the icp CLI). +methods are refused on every canister, every update call to a canister whose +published interface declares ICRC-1/ICRC-2 methods is refused, and no funding +or management tools are served (users run those operations with the icp CLI). +State the limit in the same breath rather than implying a seal: every update +call needs the canister's published interface — a canister that publishes +none, or whose interface cannot be read, is refused outright — and the guard +refuses every canister whose interface declares ICRC-1/ICRC-2 methods; what it +does not judge is a bespoke method on a canister whose published interface is +not a ledger's. **First-party API usage** is answered by describing the architecture as it is: DFINITY operates the connector itself; it reaches the network through public Internet Computer infrastructure (`icp-api.io`, `id.ai`) and forwards diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index a6edebbf..e57f690d 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -164,8 +164,12 @@ submission states, not from a narrower reading: governance method `manage_neuron` — neuron staking and disbursement) on every canister, the ICP and cycles ledgers' own value-moving methods and the cycles-minting canister's funding-completion methods on those - canisters, and every update call on the financial-service canisters it - carries. The refusal tells the user to perform the + canisters, every update call to a canister whose own Candid interface + declares ICRC-1 or ICRC-2 methods (every ICRC ledger and the ICP ledger, + detected from the canister itself; an update call needs that published + interface, so a canister that publishes none, or whose interface cannot be + read, is refused outright), and every update call on the + financial-service canisters it carries. The refusal tells the user to perform the operation outside the connector, in a trusted interface they control, and names no venue. - **The descriptions match the behavior**, as the guidelines require ("tools