From 1bde2961291cb4cf0a6cc84bce2bcc1671032cba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:14:34 +0000 Subject: [PATCH 1/7] Refuse every update to a canister that provides ICRC-1/ICRC-2 methods The financial-transactions guard decided everything from the method name and the canister id: the exact ICRC transfer/approval names and manage_neuron on every canister, a few names on the system ledgers, and every update on a curated list of finance canisters. A ledger that is not on that list - an SNS or chain-key token, a new ledger built on the ic-icrc1 code - was reachable through any method the list did not name, and the list can never stay complete. The guard now reads the canister itself. Once canister_update_call has the canister's Candid interface in hand - the published candid:service, else the caller-supplied text, which resolve_did consults only when the canister publishes none, so it can only add a refusal - a canister whose interface declares ANY icrc1_* / icrc2_* method is a financial canister, and every update call to it is refused before the call is encoded or signed. That covers every ICRC ledger and the ICP ledger with no list to keep current. The check runs where the interface is legitimately in hand: after the discoverability gate (reading an interface is a contact with the canister, so an undeclared ledger is refused as undeclared) and on the read the tool already makes for encoding, so it costs no extra round-trip. No interface, or one the parser cannot read, fails open: the guard remains a guardrail, and the stated policy covers the rest. A call addressed to any other icrc1_* / icrc2_* method by name is also refused, before any network work: the caller is treating the canister as a ledger. The prefixes are literal, underscore included, so ICRC-10 and ICRC-21 names and other token namespaces are untouched. Each refusal keeps the register rule - it says only what the guard knows. The interface refusal reuses the listed-canister destination register (every state-changing call to that service is refused, which says nothing about this call), naming the standard(s) and the methods that established them; the by-name refusal disclaims the call too. The sharper registers keep precedence: icrc1_transfer still says the call itself carries out the operation, and a listed service still names the service. Tests: calls::declared_methods reads a bare service, a service constructor (the form every real ledger publishes), an alias, and fails open on unreadable or over-limit text (alias-chain bomb included); the compliance tests pin the detection, the literal prefixes, the registers and their precedence, and the plain e2e fixture shape. The e2e suite gains a declared ledger fixture: icrc1_transfer on it is refused before the network, set_message on it is refused from its interface with the store unchanged, each with a positive control on the replica; reads of it still work; an update to the canister that publishes no interface goes through, and goes through no longer once a ledger interface is supplied for it. README, both directory submission docs, and the module and call-site docs describe the new scope; the server instructions are unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 --- README.md | 13 +- crates/imcp2-core/src/calls.rs | 87 ++++- crates/imcp2-core/src/compliance.rs | 361 ++++++++++++++++++-- crates/imcp2-core/src/e2e_canister_tools.rs | 187 ++++++++-- crates/imcp2-core/src/tools.rs | 46 ++- docs/anthropic-directory-submission.md | 12 +- docs/openai-directory-submission.md | 6 +- 7 files changed, 637 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 676aa16..f0c02eb 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,13 @@ 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. 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 +166,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?`, `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; 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 (or the `candid` text supplied when it publishes none) — and 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) | | `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 7884d17..fd8f233 100644 --- a/crates/imcp2-core/src/calls.rs +++ b/crates/imcp2-core/src/calls.rs @@ -20,6 +20,7 @@ use ic_agent::Agent; // 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 @@ -832,6 +833,32 @@ 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) — the caller then fails OPEN, as it does for [`is_query_method`]. +/// `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 +1901,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 +1920,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 +2012,61 @@ 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![])); + } + + // Fail-open on an over-limit interface: the CWE-674 guard trips BEFORE + // parsing and the caller gets `None` rather than a verdict — and the shallow + // twin proves the guard, not the shape, is why. + #[test] + fn declared_methods_fails_open_on_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 must fail open"); + 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)); + } + // 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 144bc86..88fc1e0 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,28 @@ //! 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 +//! `candid:service`, else the caller-supplied `candid`, which +//! `resolve_did` consults only when the canister publishes none, so it can +//! only ADD a refusal — 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. No +//! interface, or one the parser cannot read, fails open. 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 +99,10 @@ //! 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 — for every canister that provides ICRC +//! methods; a canister that publishes no interface and names nothing +//! financial is not detected. //! 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 +116,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 +453,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 +508,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,15 +562,79 @@ 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. No interface, or one that cannot +/// be parsed, never reaches here: the caller fails open (a guardrail, not a +/// seal). +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", )) } @@ -526,6 +658,18 @@ 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"; + + /// 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 +764,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 +912,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 +942,169 @@ 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}"); + } + + // 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}"); + } + + // 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 bac56b9..1217c4b 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,45 @@ async fn a_read_that_cannot_run_says_what_to_fix() { ); assert!(msg.contains("query failed"), "the replica's rejection is surfaced: {msg}"); + // --- the guard's interface scope fails open on an unreadable canister --- + // With no interface to read there is nothing to classify, so an update to + // the opaque canister goes through: `greet` is a query method, and the + // Internet Computer lets a query method be called as an update. + let result = h + .call( + "canister_update_call", + serde_json::json!({ + "canister_id": opaque, + "method": "greet", + "args": "(\"x\")", + "app_url": declares_all, + }), + ) + .await; + assert_eq!( + ok_text(&result), + "(\"x\")", + "no interface: nothing for the interface scope to refuse on" + ); + // A caller-supplied interface is consulted only because the canister + // publishes none — and it can only ADD a refusal: declare ICRC-1 methods + // for the same canister and the same call is refused as a financial one. + let msg = refusal( + &h.call( + "canister_update_call", + serde_json::json!({ + "canister_id": opaque, + "method": "greet", + "args": "(\"x\")", + "app_url": declares_all, + "candid": LEDGER_DID, + }), + ) + .await, + ); + assert!(msg.contains("whose Candid interface declares"), "{msg}"); + assert!(msg.contains("ICRC-1 ledger methods"), "{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 35bddf3..5a84ee4 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -619,17 +619,20 @@ 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 resolve_did has read it. + // 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)); } @@ -660,6 +663,27 @@ impl IcCanisterTools { // 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; + // 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 `resolve_did` has + // just read it for encoding, 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. `resolve_did` consults the + // caller-supplied `candid` only when the canister publishes no + // interface, so a supplied text can only ADD a refusal, never hide one. + // No interface, or one the parser cannot read, fails OPEN: the guard is + // a guardrail, not a seal. + if let Some(refusal) = + did.as_deref().and_then(calls::declared_methods).and_then(|declared| { + compliance::disallowed_update_on_ledger_interface(&method, &declared) + }) + { + return Ok(err(refusal)); + } let arg_bytes = match calls::encode_args(did.as_deref(), &method, &args) { Ok(b) => b, Err(e) => return Ok(err(e)), diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 9878414..c2b6247 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -204,7 +204,9 @@ 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 — 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 +438,11 @@ 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, 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. diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index a6edebb..198a505 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -164,8 +164,10 @@ 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), 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 From b19423fd7c4fe10beaa73e9e5d03b5b78302268e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:19:23 +0000 Subject: [PATCH 2/7] Refuse update calls whose method name carries a financial keyword Generic value-moving names - transfer, withdraw, swap - were allowed on every canister the guard did not list by id: review on #154 found them too abstract to refuse everywhere without breaking non-financial apps, and the module doc recorded that the stated policy covered them. The maintainer reversed that decision: refusing a real ledger's, pool's or DIP20 token's method is worth more than allowing an app's non-financial one. canister_update_call now refuses, before any network work and on every canister, a call whose method name contains a financial / crypto keyword - the maintainer's list (trade, swap, transfer, withdraw, exchange, pay, buy) plus the value verbs (deposit, purchase, sell, mint, burn, stake, unstake, redeem, refund, liquidity, allowance, escrow, loan, lend, borrow, bridge). Matching is by whole word: the name is split on `_`, `-`, digits and camelCase humps and lowercased, so swapTokens, withdraw_all, icrc1_transfer and Transfer match while payload, buyer and swapped do not - a substring test would have refused every set_payload. A literal list of control-handover names (transfer_ownership and its spellings) is exempt, by exact bytes. Derived forms such as payment or withdrawal are deliberately unlisted for now, as are approve, send, claim and the other ordinary app verbs; each is one line to add. The scope runs last of the request-only scopes, so the sharper registers keep precedence: transfer on the ICP ledger still says the call itself carries out the operation, and swap on a listed exchange still names the service. Its own register says only what the guard knows - the keyword is in the name, refused on every canister rather than judged app by app, so this refusal does not mean this particular call moves assets - and the accepted false positives (deposit_count, get_exchange_rate, transfer_fee as an update) are recorded in the module doc along with the reversal itself. Tests pin the word splitting, whole-word matching in every spelling convention, the literal exemptions, every keyword's refusal and register, precedence over the listed-canister and ledger-scoped registers, the well-formedness of both lists, and that the standard names stay literal (a re-spelled icrc1_transfer is refused by keyword, never with the standard's label). The e2e suite shows withdraw refused by name on a canister that publishes no interface. README, both directory submission docs, and the module and call-site docs describe the scope; the server instructions are unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 --- README.md | 7 +- crates/imcp2-core/src/compliance.rs | 448 +++++++++++++++++--- crates/imcp2-core/src/e2e_canister_tools.rs | 22 + crates/imcp2-core/src/tools.rs | 5 +- docs/anthropic-directory-submission.md | 6 +- docs/openai-directory-submission.md | 12 +- 6 files changed, 430 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index f0c02eb..dbf0aec 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,10 @@ 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 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 +transfer/approval methods and any method whose name carries a financial keyword +(`transfer`, `withdraw`, `swap`, `trade`, `exchange`, `pay`, `buy`, `deposit`, +… as whole words, bar a short list of exempt control-handover names such as +`transfer_ownership`) 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. The connector serves no @@ -166,7 +169,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; 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 (or the `candid` text supplied when it publishes none) — and 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?`, `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; any other `icrc1_*`/`icrc2_*` method is refused by name as well, as is any method whose name carries a financial keyword as a whole word (`transfer`, `withdraw`, `swap`, `trade`, `exchange`, `pay`, `buy`, `deposit`, `mint`, `stake`, … — `swapTokens` and `withdraw_all` match, `payload` does not — except a literal list of control-handover names such as `transfer_ownership`); 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 (or the `candid` text supplied when it publishes none) — and 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) | | `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/compliance.rs b/crates/imcp2-core/src/compliance.rs index 88fc1e0..f720487 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -13,11 +13,10 @@ //! 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 six scopes — five decided from the request alone, before any +//! The guard has seven scopes — six 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): +//! once `canister_update_call` has read it (the scopes are split per review so +//! that each refusal says only what its scope knows about the call): //! //! * **Standardized value-moving methods, matched literally on every //! canister.** Token ledgers on the Internet Computer follow the ICRC @@ -36,9 +35,10 @@ //! says exactly that instead of calling the caller's command a transfer. //! * **Abstract names, scoped to the system canister where they are //! financial.** `transfer` on the ICP ledger or `withdraw` on the cycles -//! ledger moves the user's funds; the same names on an arbitrary app -//! canister are often something else entirely, so they are refused only -//! on those specific canister ids. The cycles-minting canister's whole +//! ledger moves the user's funds, so on those specific canister ids they +//! get the precise register and label; on any other canister the same +//! names fall to the keyword scope below, which refuses them by name with +//! a register that claims less. The cycles-minting canister's whole //! user-callable update surface is here too — `notify_top_up`, //! `notify_create_canister`, `notify_mint_cycles`, `create_canister` — //! because each one *completes* a funding operation: the ICP debit @@ -66,6 +66,21 @@ //! 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 keywords in the method name, refused on every canister.** +//! A call whose method name contains a financial / crypto keyword +//! (`transfer`, `withdraw`, `swap`, `trade`, `exchange`, `pay`, `buy`, +//! `deposit`, … — [`FINANCIAL_KEYWORDS`]) is refused before any network +//! work, modulo a literal list of exempt control-handover names +//! ([`EXEMPT_METHODS`], `transfer_ownership` and its spellings). Matching +//! is by WHOLE WORD: the name is split on `_`, `-`, digits and camelCase +//! humps and lowercased, so `swapTokens`, `withdraw_all` and `Transfer` +//! match while `payload`, `buyer` and `swapped` do not — a substring test +//! would refuse every `set_payload`. The guard cannot know what an +//! arbitrary app's `transfer` does, so the register says exactly that: +//! the name is refused everywhere rather than judged app by app, and the +//! call is not classified. Checked last of the request-only scopes, so a +//! ledger-scoped name keeps its precise register and a listed canister +//! keeps its destination register. //! * **Financial canisters detected from the canister itself, refused //! entirely.** Once the interface is in hand — the canister's own //! `candid:service`, else the caller-supplied `candid`, which @@ -109,11 +124,20 @@ //! against the IC dashboard's registry and the services' own published //! sources), and the standardized-methods group plus the stated policy //! cover the rest. -//! * Legacy pre-ICRC token standards (DIP20/EXT `transfer`/`transferFrom`/ -//! `approve` on arbitrary canisters) are deliberately NOT matched: the -//! names are too abstract to block everywhere without breaking -//! non-financial apps (per review), and those standards sit outside the -//! ecosystem's ICRC-integrated platforms. The stated policy covers them. +//! * Generic value-moving names (`transfer`, `withdraw`, `swap`, … on +//! arbitrary canisters) were deliberately unmatched between #154 and the +//! keyword scope: review on #154 found them too abstract to refuse +//! everywhere without breaking non-financial apps, and this note recorded +//! that the stated policy covered them. The maintainer reversed that: +//! refusing a real ledger's, pool's or DIP20 token's method is worth more +//! than allowing an app's non-financial one, so such names are now +//! refused on every canister by whole-word keyword, with a register that +//! says so rather than classifying the call, and a literal exemption list +//! for the control-handover names. Still unlisted, on purpose: `approve`, +//! `send`, `claim`, `reward`, `balance`, `token`, `wallet`, `ledger`, +//! `order`, `fee`, `cycles` (ordinary app verbs and nouns far more often +//! than value operations), and derived forms such as `payment` or +//! `withdrawal` (one line each to add). use candid::Principal; use std::collections::BTreeSet; @@ -162,10 +186,11 @@ const CYCLES_LEDGER: &str = "um5iw-rqaaa-aaaaq-qaaba-cai"; /// from it). const CYCLES_MINTING_CANISTER: &str = "rkp4c-7iaaa-aaaaa-aaaca-cai"; -/// Abstract method names refused only on the specific system canister where -/// they are financial: (canister id, method, label). On any other canister -/// these names are allowed — an app's own `transfer` is often not a token -/// transfer at all. +/// Abstract method names refused on the specific system canister where they +/// are financial, with the precise label and register: (canister id, method, +/// label). On any other canister the ones that carry a financial keyword +/// (`transfer`, `withdraw`, `withdraw_from`, `notify_mint_cycles`) fall to +/// the keyword scope, whose refusal claims less; the rest are allowed there. const DISALLOWED_CANISTER_METHODS: &[(&str, &str, &str)] = &[ // The ICP ledger's pre-ICRC surface. (ICP_LEDGER, "transfer", "the ICP ledger's legacy transfer"), @@ -379,7 +404,8 @@ const DISALLOWED_FINANCE_CANISTERS: &[(&str, &str, &str)] = &[ "its update surface is the content store behind the exchange UI", ), // --- Exchanges: Sonic (plus the legacy DIP-20 ledgers its DAO operates, - // whose transfer names the ICRC group deliberately does not match) --- + // whose legacy `transfer` the keyword scope refuses everywhere; the list + // still closes the rest of their surface) --- ( "3xwpq-ziaaa-aaaah-qcn4a-cai", "the Sonic exchange swap canister", @@ -472,6 +498,100 @@ fn icrc_ledger_standard(method: &str) -> Option<&'static str> { /// `get_canister_candid`, not the whole ledger interface. const MAX_LISTED_METHODS: usize = 5; +/// Financial / crypto keywords: a method whose name contains one of these as a +/// WHOLE WORD (see [`name_words`]) is refused on every canister. Admission +/// rule: a word that names a value operation and is rare as an ordinary app +/// verb. The first row is the maintainer's list; the second adds the value +/// verbs. Deliberately absent — ordinary app verbs and nouns far more often +/// than value operations: `approve`, `send`, `claim`, `reward`, `balance`, +/// `token`, `wallet`, `ledger`, `order`, `fee`, `cycles`; and derived forms +/// (`payment`, `payout`, `withdrawal`, `trading`, `staking`, plurals), which +/// whole-word matching does not reach — add a form here to match it. +const FINANCIAL_KEYWORDS: &[&str] = &[ + "trade", + "swap", + "transfer", + "withdraw", + "exchange", + "pay", + "buy", + "deposit", + "purchase", + "sell", + "mint", + "burn", + "stake", + "unstake", + "redeem", + "refund", + "liquidity", + "allowance", + "escrow", + "loan", + "lend", + "borrow", + "bridge", +]; + +/// Method names exempt from the keyword scope although they contain a keyword +/// — a control handover (of a canister, a record, an app), not a value +/// movement — matched LITERALLY, by exact bytes: `Transfer_Ownership` or +/// `transfer_ownership_v2` are not exempt. +const EXEMPT_METHODS: &[&str] = &[ + "transfer_ownership", + "transferOwnership", + "transfer_controller", + "transferController", + "transfer_controllers", + "transferControllers", +]; + +/// The words of a method name, for whole-word keyword matching: maximal runs +/// of ASCII letters, split additionally at a lower→upper boundary and before +/// the last upper of an upper run that a lower follows (`HTTPRequest` → +/// `http`, `request`); digits and every other byte separate; each word +/// lowercased. So `swapTokens` → `swap`, `tokens`; `icrc1_transfer` → `icrc`, +/// `transfer`; `withdraw2` → `withdraw`; `payload` stays one word. The one +/// corner: an all-caps keyword with a lowercase plural (`SWAPs`) splits as +/// `swa`, `ps` and misses — no Internet Computer interface spells names that +/// way. +fn name_words(method: &str) -> Vec { + let chars: Vec = method.chars().collect(); + let mut words = Vec::new(); + let mut current = String::new(); + for (i, &c) in chars.iter().enumerate() { + if !c.is_ascii_alphabetic() { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + continue; + } + if c.is_ascii_uppercase() && !current.is_empty() { + let prev = chars[i - 1]; + let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_ascii_lowercase()); + if prev.is_ascii_lowercase() || (prev.is_ascii_uppercase() && next_is_lower) { + words.push(std::mem::take(&mut current)); + } + } + current.push(c.to_ascii_lowercase()); + } + if !current.is_empty() { + words.push(current); + } + words +} + +/// The first financial keyword `method` carries as a whole word, unless the +/// name is one of the literal [`EXEMPT_METHODS`]. +pub(crate) fn financial_keyword(method: &str) -> Option<&'static str> { + if EXEMPT_METHODS.contains(&method) { + return None; + } + name_words(method) + .into_iter() + .find_map(|word| FINANCIAL_KEYWORDS.iter().copied().find(|keyword| *keyword == word)) +} + /// 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 @@ -488,13 +608,14 @@ fn destination_refusal(method: &str, service: &str, why: &str) -> String { /// 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 +/// Six 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 +/// refused, so `None` never depends on the method name alone — a call +/// addressed to any other ICRC-1 / ICRC-2 ledger method by name, and, last, a +/// method name that carries a financial keyword as a whole word. The seventh +/// 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 @@ -511,9 +632,11 @@ fn destination_refusal(method: &str, service: &str, why: &str) -> String { /// 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. +/// not classified; +/// * a keyword-named method: the name is refused on every canister rather +/// than judged app by app, so the call is not classified. /// -/// All four name the method, state the policy and why it exists, and tell the +/// All five 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. @@ -575,14 +698,27 @@ pub fn disallowed_update_method(canister_id: &Principal, method: &str) -> Option // 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)?; + if let Some(standard) = icrc_ledger_standard(method) { + return Some(format!( + "`{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}" + )); + } + // Last, a method name that carries a financial keyword — the weakest + // evidence the guard acts on, so the weakest claim: the name is refused + // everywhere rather than judged app by app, and nothing is said about what + // this call does. + let keyword = financial_keyword(method)?; Some(format!( - "`{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}" + "`{method}` has the financial keyword `{keyword}` in its name. Method names that \ + contain a financial keyword are refused on every canister rather than judged app \ + by app, 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}" )) } @@ -665,6 +801,9 @@ mod tests { /// 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 keyword scope's refusal must carry: the name is refused + /// everywhere, which is all the guard knows. + const NAME_RULE: &str = "refused on every canister rather than judged app by app"; /// A declared method set, as `calls::declared_methods` would read it. fn declared(names: &[&str]) -> BTreeSet { @@ -696,48 +835,64 @@ mod tests { } } - // Matching is literal: a differently-spelled name is NOT the standard - // method (Candid method names are case-sensitive), so it is allowed on an - // unlisted canister — it could never reach the ledger method on chain - // anyway. (On a finance-listed canister every update call is refused - // regardless of spelling, so the property is observable only off the list.) + // The standard names are matched literally: a differently-spelled name is + // NOT the standard method (Candid method names are case-sensitive), so it + // never gets the standard's label or the "the call itself" register. It is + // still refused — by the keyword scope, which sees the word `transfer` in + // every one of these spellings — but with the weaker, disclaiming register. #[test] - fn matching_is_literal_not_normalized() { + fn standard_names_stay_literal_while_keywords_catch_variants() { for method in ["ICRC1_Transfer", "icrc1-transfer", " icrc1_transfer ", "Transfer"] { - assert!( - disallowed_update_method(&any_canister(), method).is_none(), - "{method} is not the standard spelling and must not match" - ); + let msg = disallowed_update_method(&any_canister(), method).unwrap_or_else(|| { + panic!("{method} carries the keyword transfer: must be refused") + }); + assert!(msg.contains(NAME_RULE), "{msg}"); + assert!(!msg.contains(ITSELF), "{method} is not the standard spelling: {msg}"); + assert!(!msg.contains("an ICRC-1 token transfer"), "{msg}"); } } - // Abstract names are refused only on the system canister where they are - // financial — and allowed on any other canister, so an app whose own - // `transfer`/`withdraw` is non-financial keeps working. + // The system canisters' own names are refused there with the precise + // register and label. Elsewhere, the ones that carry no financial keyword + // stay allowed; the ones that do (`transfer`, `withdraw`, `withdraw_from`, + // and `notify_mint_cycles` with its `mint`) fall to the keyword scope, + // which refuses them by name with the weaker, disclaiming register — never + // the system canister's label. #[test] - fn abstract_names_are_scoped_to_their_ledger() { + fn system_canister_names_keep_their_register_and_scope() { for (canister, method) in [ - (icp_ledger(), "transfer"), (icp_ledger(), "send_dfx"), (icp_ledger(), "notify_dfx"), - (cycles_ledger(), "withdraw"), - (cycles_ledger(), "withdraw_from"), (cycles_ledger(), "create_canister"), (cycles_ledger(), "create_canister_from"), (cmc(), "notify_top_up"), (cmc(), "notify_create_canister"), - (cmc(), "notify_mint_cycles"), (cmc(), "create_canister"), ] { assert!( disallowed_update_method(&canister, method).is_some(), - "{method} must be refused on its ledger" + "{method} must be refused on its canister" ); assert!( disallowed_update_method(&any_canister(), method).is_none(), "{method} must be allowed on an unrelated canister" ); } + for (canister, method, label) in [ + (icp_ledger(), "transfer", "the ICP ledger's legacy transfer"), + (cycles_ledger(), "withdraw", "a cycles-ledger withdrawal"), + (cycles_ledger(), "withdraw_from", "a cycles-ledger delegated withdrawal"), + (cmc(), "notify_mint_cycles", "a cycles mint to a cycles-ledger account"), + ] { + let msg = + disallowed_update_method(&canister, method).expect("refused on its own canister"); + assert!(msg.contains(label) && msg.contains(ITSELF), "{msg}"); + let msg = disallowed_update_method(&any_canister(), method).unwrap_or_else(|| { + panic!("{method} carries a financial keyword: refused anywhere") + }); + assert!(msg.contains(NAME_RULE), "{msg}"); + assert!(!msg.contains(label) && !msg.contains(ITSELF), "{msg}"); + } } // The creation and funding-completion calls — the cycles ledger's creation @@ -764,16 +919,18 @@ mod tests { } } - // 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. + // Reads and generic names on arbitrary canisters stay callable: a word + // that merely contains a keyword (`payload`, `buyer`, `swapped`, `trader`) + // is not that keyword, `approve`/`send`/`claim` are deliberately not + // keywords, and the literal exemptions are control handovers. (An + // ICRC-named read is refused — it addresses the canister as a ledger — and + // `transfer_fee` is the keyword scope's accepted false positive.) The + // cycles-minting canister's non-keyword names are here too: refused on the + // CMC itself (above), an unrelated canister's method that shares the name + // is not a funding completion. #[test] fn allows_non_financial_methods() { for method in [ - "transfer_fee", "get_transactions", "notify_create_canister", "notify_top_up", @@ -781,12 +938,20 @@ mod tests { "set_name", "register_user", "http_request_update", - // Abstract names on a non-ledger canister (per review: an app's - // own transfer/approve is often not financial at all). - "transfer", - "transferFrom", "approve", - "withdraw", + "send", + "claim", + "set_payload", + "upload_payload", + "get_buyer", + "swapped", + "trader", + "transfer_ownership", + "transferOwnership", + "transfer_controller", + "transferController", + "transfer_controllers", + "transferControllers", ] { assert!( disallowed_update_method(&any_canister(), method).is_none(), @@ -803,6 +968,8 @@ mod tests { // must NOT do is classify the call: `store` on a wallet backend is refused // because of where it goes, not because it moves anything (per review), so // the message says the destination rule out loud and says so explicitly. + // (`swap` is a keyword too, so this loop also pins that a listed service's + // destination register wins over the keyword scope's name register.) #[test] fn refuses_every_update_on_finance_canisters() { for (id, service, why) in DISALLOWED_FINANCE_CANISTERS { @@ -960,6 +1127,165 @@ mod tests { assert!(msg.contains(NOT_THIS_CALL), "{msg}"); assert!(!msg.contains(ITSELF), "{msg}"); assert!(!msg.contains(DESTINATION_RULE), "{msg}"); + + // A keyword in the method name: refused everywhere rather than judged, + // so disclaimed — and neither the operation nor the destination claim. + let msg = disallowed_update_method(&any_canister(), "swapTokens").expect("must be refused"); + assert!(msg.contains(NAME_RULE), "{msg}"); + assert!(msg.contains(NOT_THIS_CALL), "{msg}"); + assert!(!msg.contains(ITSELF), "{msg}"); + assert!(!msg.contains(DESTINATION_RULE), "{msg}"); + // ...while the sharper scopes keep precedence over it: the ICP ledger's + // own `transfer` is the operation, and `swap` on a listed exchange is + // refused for its destination. + let msg = disallowed_update_method(&icp_ledger(), "transfer").expect("must be refused"); + assert!(msg.contains(ITSELF) && !msg.contains(NAME_RULE), "{msg}"); + let msg = disallowed_update_method(&listed, "swap").expect("must be refused"); + assert!(msg.contains(DESTINATION_RULE) && !msg.contains(NAME_RULE), "{msg}"); + } + + // Whole-word splitting: snake_case, kebab-case, camelCase humps (an acronym + // run keeps its last capital with the word that follows), digits and other + // bytes as separators, everything lowercased. + #[test] + fn name_words_splits_snake_camel_and_digits() { + let words = |m: &str| name_words(m); + assert_eq!(words("swapTokens"), ["swap", "tokens"]); + assert_eq!(words("withdraw_all"), ["withdraw", "all"]); + assert_eq!(words("icrc1_transfer"), ["icrc", "transfer"]); + assert_eq!(words("ICRC1Transfer"), ["icrc", "transfer"]); + assert_eq!(words("Transfer"), ["transfer"]); + assert_eq!(words("withdraw2"), ["withdraw"]); + assert_eq!(words("payload"), ["payload"]); + assert_eq!(words("transferOwnership"), ["transfer", "ownership"]); + assert_eq!(words("HTTPRequest"), ["http", "request"]); + assert_eq!(words("getHTTPResponse"), ["get", "http", "response"]); + assert_eq!(words("icrc1-transfer"), ["icrc", "transfer"]); + assert_eq!(words(" icrc1_transfer "), ["icrc", "transfer"]); + assert!(words("").is_empty()); + assert!(words("42").is_empty()); + } + + // A keyword matches a whole word only — in any spelling convention — and + // the literal exemptions are the one way around it. + #[test] + fn financial_keyword_matches_whole_words_only() { + for (method, keyword) in [ + ("swapTokens", "swap"), + ("withdraw_all", "withdraw"), + ("icrc1_transfer", "transfer"), + ("Transfer", "transfer"), + ("TRANSFER", "transfer"), + ("withdraw2", "withdraw"), + ("pay_invoice", "pay"), + ("buy_ticket", "buy"), + ("exchange_rate", "exchange"), + ("key_exchange", "exchange"), + ("deposit_count", "deposit"), + ("notify_mint_cycles", "mint"), + ("icrc2_allowance", "allowance"), + ("add_liquidity", "liquidity"), + ("transfer_tokens_to_controller", "transfer"), + ("trade", "trade"), + ] { + assert_eq!(financial_keyword(method), Some(keyword), "{method}"); + } + for method in [ + "payload", + "set_payload", + "buyer", + "swapped", + "trader", + "get_transactions", + "approve", + "send", + "claim", + "process_payment", + "transfer_ownership", + "transferOwnership", + "transfer_controller", + "transferController", + "transfer_controllers", + "transferControllers", + "", + ] { + assert_eq!(financial_keyword(method), None, "{method}"); + } + } + + // Every keyword refuses a method named for it on any canister, with the + // name register: the method and the keyword named, the disclaimer, the + // policy, the redirect, and no venue. + #[test] + fn refuses_keyword_names_on_every_canister() { + for keyword in FINANCIAL_KEYWORDS { + let method = format!("do_{keyword}_now"); + 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(&format!("keyword `{keyword}`")), "{msg}"); + assert!(msg.contains(NAME_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}"); + } + } + + // Exemptions are literal: the exact spellings pass, anything else that + // contains the keyword does not. + #[test] + fn exemptions_are_literal() { + for method in EXEMPT_METHODS { + assert!( + disallowed_update_method(&any_canister(), method).is_none(), + "{method} is a literal exemption" + ); + } + for method in ["Transfer_Ownership", "transfer_ownership_v2", "transfer_owner"] { + let msg = disallowed_update_method(&any_canister(), method) + .unwrap_or_else(|| panic!("{method} is not literally exempt")); + assert!(msg.contains(NAME_RULE), "{msg}"); + } + } + + // The lists stay well-formed: keywords unique, lowercase, alphabetic, and + // none of them a word of the mixed-purpose entry point (one register per + // name); every exemption unique and actually carrying a keyword, so no + // entry is dead. + #[test] + fn keyword_and_exemption_lists_are_well_formed() { + let mut keywords: Vec<&str> = FINANCIAL_KEYWORDS.to_vec(); + for keyword in &keywords { + assert!( + !keyword.is_empty() && keyword.chars().all(|c| c.is_ascii_lowercase()), + "{keyword}" + ); + } + let total = keywords.len(); + keywords.sort_unstable(); + keywords.dedup(); + assert_eq!(keywords.len(), total, "duplicate keywords"); + for (method, _) in MIXED_PURPOSE_METHODS { + assert_eq!(financial_keyword(method), None, "{method} has its own register"); + } + let mut exempt: Vec<&str> = EXEMPT_METHODS.to_vec(); + for method in &exempt { + let words = name_words(method); + assert!( + words.iter().any(|w| FINANCIAL_KEYWORDS.contains(&w.as_str())), + "{method} carries no keyword, so the exemption is dead" + ); + } + let total = exempt.len(); + exempt.sort_unstable(); + exempt.dedup(); + assert_eq!(exempt.len(), total, "duplicate exemptions"); } // A canister that provides an ICRC-1 / ICRC-2 method is a financial canister: diff --git a/crates/imcp2-core/src/e2e_canister_tools.rs b/crates/imcp2-core/src/e2e_canister_tools.rs index 1217c4b..4c70288 100644 --- a/crates/imcp2-core/src/e2e_canister_tools.rs +++ b/crates/imcp2-core/src/e2e_canister_tools.rs @@ -1576,6 +1576,28 @@ async fn a_read_that_cannot_run_says_what_to_fix() { assert!(msg.contains("whose Candid interface declares"), "{msg}"); assert!(msg.contains("ICRC-1 ledger methods"), "{msg}"); + // --- a keyword-named call is refused by name, before the network --- + // `withdraw` carries a financial keyword, so the call is refused on any + // canister without an interface being read — this one publishes none, and + // exports no such method, so the wording is the proof the gate answered. + let msg = refusal( + &h.call( + "canister_update_call", + serde_json::json!({ + "canister_id": opaque, + "method": "withdraw", + "args": "(\"x\")", + "app_url": declares_all, + }), + ) + .await, + ); + assert!(msg.contains("`withdraw`"), "{msg}"); + assert!( + msg.contains("refused on every canister rather than judged app by app"), + "the keyword scope's own wording: {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 5a84ee4..d8c0d5a 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -626,8 +626,9 @@ impl IcCanisterTools { // 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 + // not depend on the method name alone; a call addressed to any other + // ICRC-1 / ICRC-2 ledger method by name; and a method name that carries + // a financial keyword as a whole word. The one scope that needs // the canister's interface — a financial canister detected from the // ICRC methods it declares — runs below, once resolve_did has read it. // Each scope's refusal claims only what that scope knows about the diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index c2b6247..4fae5ca 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 to a canister whose + `create_canister`), refuses any method whose name carries a financial + keyword as a whole word (`transfer`, `withdraw`, `swap`, `trade`, + `exchange`, `pay`, `buy`, `deposit`, …, bar a literal list of + control-handover names such as `transfer_ownership`) 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 — and to the financial-service canisters it carries; the refusal tells the user to diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index 198a505..0ec1806 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -164,10 +164,14 @@ 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, 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), and every update call on the - financial-service canisters it carries. The refusal tells the user to perform the + canisters, any method whose name carries a financial keyword as a whole + word (`transfer`, `withdraw`, `swap`, `trade`, `exchange`, `pay`, `buy`, + `deposit`, …, bar a literal list of control-handover names such as + `transfer_ownership`) on every canister, 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), 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 From 371bbb591a6513ad1b616a44e7df3b0a9d055a2d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:16:07 +0000 Subject: [PATCH 3/7] Source the keyword exemptions and drop the controller spellings Review on #214 asked where the exempt names come from, with a comment per entry naming its source so the list can be audited. The exemption list now carries that rule in its doc comment: a name with no source does not get in, and an entry whose source goes away goes with it. The ownership pair stays, with its source: `transferOwnership` is the OpenZeppelin `Ownable` convention that app code ported from the EVM keeps when it hands an admin role to a new principal, and `transfer_ownership` is the ic-cdk (Rust) spelling of the same method. It is kept at the maintainer's direction as the one control-handover name worth exempting; the comment records that no mainnet interface declaring either spelling has been verified yet (the IC token standards use `setOwner` or `set_custodians`, which carry no keyword) and that the exemption goes should a canister whose `transfer_ownership` moves value turn up. The four controller spellings (`transfer_controller(s)`, `transferController(s)`) had no source: on the Internet Computer a canister's controllers change through the management canister's `update_settings`, never through an app method. They are removed, so a method of that name is now refused by the `transfer` keyword like any other; the tests pin that, and pin the sourced pair as allowed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 --- crates/imcp2-core/src/compliance.rs | 30 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index f720487..426d7e9 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -536,14 +536,24 @@ const FINANCIAL_KEYWORDS: &[&str] = &[ /// Method names exempt from the keyword scope although they contain a keyword /// — a control handover (of a canister, a record, an app), not a value /// movement — matched LITERALLY, by exact bytes: `Transfer_Ownership` or -/// `transfer_ownership_v2` are not exempt. +/// `transfer_ownership_v2` are not exempt. Every entry records where the name +/// comes from, so the list can be audited (per review): a name with no source +/// does not get in, and an entry whose source goes away goes with it. const EXEMPT_METHODS: &[&str] = &[ + // The `Ownable` access-control convention — OpenZeppelin's Solidity + // `transferOwnership(address)` — which app code ported from the EVM keeps + // when it hands a canister's or a record's admin role to a new principal; + // the snake_case form is the Rust (ic-cdk) spelling of the same method. + // Kept at the maintainer's direction as the one control-handover name + // worth exempting. No mainnet interface declaring either spelling has + // been verified yet (the IC token standards use `setOwner` — DIP20, + // Sonic — or `set_custodians` — DIP721 — which carry no keyword); should + // a canister whose `transfer_ownership` moves VALUE turn up (an NFT + // handover is one), the exemption goes. Controller handover has no entry: + // on the Internet Computer a canister's controllers change through the + // management canister's `update_settings`, never through an app method. "transfer_ownership", "transferOwnership", - "transfer_controller", - "transferController", - "transfer_controllers", - "transferControllers", ]; /// The words of a method name, for whole-word keyword matching: maximal runs @@ -948,10 +958,6 @@ mod tests { "trader", "transfer_ownership", "transferOwnership", - "transfer_controller", - "transferController", - "transfer_controllers", - "transferControllers", ] { assert!( disallowed_update_method(&any_canister(), method).is_none(), @@ -1186,6 +1192,8 @@ mod tests { ("icrc2_allowance", "allowance"), ("add_liquidity", "liquidity"), ("transfer_tokens_to_controller", "transfer"), + ("transfer_controller", "transfer"), + ("transferControllers", "transfer"), ("trade", "trade"), ] { assert_eq!(financial_keyword(method), Some(keyword), "{method}"); @@ -1203,10 +1211,6 @@ mod tests { "process_payment", "transfer_ownership", "transferOwnership", - "transfer_controller", - "transferController", - "transfer_controllers", - "transferControllers", "", ] { assert_eq!(financial_keyword(method), None, "{method}"); From 55b1b8fd7f1090ed94254ddafdda44d43a800854 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:28:05 +0000 Subject: [PATCH 4/7] Refuse an update when the interface read fails, not only when it declares a ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #213: resolve_did folded every failure of the candid:service read into "no interface", so a transport, HTTP or certificate error at that read was indistinguishable from a canister that publishes none, and the interface scope let the update through on the name-based scopes alone. For the case the scope exists for — a declared canister that is a ledger under non-standard names — one failed read at that point was the way past it. The read now says what it established. `calls::read_candid_service` returns `CandidServiceRead`: `Published` (the `.did` text), `NotPublished` (a CERTIFIED absence — ic-agent's `LookupPathAbsent`, the certificate proving the path is not in the state tree — or bytes that are not text, both properties of the canister) or `Failed` (every other error: HTTP, transport, a certificate that did not verify, a lookup the certificate leaves unknown). `resolve_did` and `candid_service` are re-implemented on top with unchanged semantics for the read-only paths. `canister_update_call` reads with it and, on `Failed`, refuses the call with `compliance::unchecked_update_refusal` before anything is encoded or signed: a retry message that names the method and the error, says a call that cannot be checked is refused rather than sent unchecked, claims nothing about the call (the guard learned nothing about it), and says what a persistent error means. A canister that publishes none keeps today's behaviour — the name scopes decide, the caller-supplied `candid` is read in its place and can only add a refusal — and published text the parser cannot read fails open like an absent interface, since a canister that wants to hide its interface need only not publish it. The limit is stated where the guard is described: the module doc, the README, and both directory-submission docs, including the financial-transactions acknowledgment for Anthropic, which now states it rather than implying a seal. Tests: the classification (only a certified absence means "publishes none"; HTTP, timeout, certificate and unknown-path errors are failed reads), the fallback rule, the refusal's register (disclaims the call, names no venue, carries the pinned phrases), and a PocketIC case where a canister id no subnet serves yields a failed read and the refusal while the canister that publishes none still goes through. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 --- README.md | 7 +- crates/imcp2-core/src/calls.rs | 128 ++++++++++++++++++-- crates/imcp2-core/src/compliance.rs | 88 ++++++++++++-- crates/imcp2-core/src/e2e_canister_tools.rs | 34 +++++- crates/imcp2-core/src/tools.rs | 29 +++-- docs/anthropic-directory-submission.md | 18 ++- docs/openai-directory-submission.md | 4 +- 7 files changed, 270 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index f0c02eb..ed739db 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,10 @@ are unsupported by policy. The ledger surface is refused mechanically: 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. The connector serves no +one of the financial-service canisters it carries. That interface check is a +guardrail, not a seal: a canister that publishes no interface is checked by +method name only, and an update is refused, retryably, when its canister's +interface cannot be read. 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. @@ -166,7 +169,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; 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 (or the `candid` text supplied when it publishes none) — and 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?`, `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; 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 (or the `candid` text supplied when it publishes none; a canister that publishes none is checked by name only, and a call is refused, retryably, when the interface cannot be read) — and 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) | | `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 fd8f233..e747c0b 100644 --- a/crates/imcp2-core/src/calls.rs +++ b/crates/imcp2-core/src/calls.rs @@ -15,7 +15,7 @@ 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; @@ -380,22 +380,84 @@ pub fn default_args() -> String { // =========================================================================== /// The interface to encode/decode against: the canister's own `candid:service` -/// if exposed, else the caller-supplied `provided` definition. +/// if exposed, else the caller-supplied `provided` definition — for a read that +/// FAILED as well as for a canister that publishes none. The read-only paths +/// fail open this way; `canister_update_call` reads with [`read_candid_service`] +/// instead and refuses on a failed read before falling back. 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 the financial-transactions guard treats two of them +/// differently (per review): a canister that publishes no interface is checked +/// by name only, while a read that FAILED is not let past the check at all. +#[derive(Debug, PartialEq, Eq)] +pub enum CandidServiceRead { + /// The canister publishes the interface: its `.did` text. + Published(String), + /// The canister publishes no `candid:service` — a CERTIFIED absence: the + /// replica's state tree proves the path is not there — or publishes bytes + /// that are not text. 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::NotPublished | Self::Failed(_) => None, + } + } + + /// The interface to encode against: the published text, else `provided` — + /// whether the canister publishes none or the read failed. A caller that + /// must tell those apart checks the outcome before calling this. + 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::NotPublished, + }, + Err(AgentError::LookupPathAbsent(_)) => CandidServiceRead::NotPublished, + Err(e) => CandidServiceRead::Failed(e.to_string()), + } } /// Maximum byte length of caller-supplied textual Candid we will parse. Real @@ -2067,6 +2129,54 @@ mod tests { 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, which the update path refuses rather than treats as "no + // interface" — a failed read must not be the way past the guard's interface + // scope. Bytes that are not text are a property of the canister, like an + // absent interface, not of the read. + #[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])), NotPublished, "not text: nothing to read"); + 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: the published text wins; a caller-supplied + // text stands in when the canister publishes none — and when the read failed + // too, which is why the update path checks the outcome before falling back. + #[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!(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 88fc1e0..71c798d 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -76,10 +76,18 @@ //! 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. No -//! interface, or one the parser cannot read, fails open. Other ICRC token -//! namespaces (`icrc4_`, `icrc7_`, `icrc37_`, `icrc152_`) are not detected -//! this way; their value-moving names are refused literally above. +//! built on the ic-icrc1 ledger — with no list to keep current. What the +//! read establishes decides what happens without an interface (per +//! review): a canister that publishes none — a CERTIFIED absence — is +//! checked by name only, the scopes above still applying and a +//! caller-supplied text read in its place; a read that FAILS (a transport, +//! HTTP or certificate error) refuses the update with a retry message +//! ([`unchecked_update_refusal`]), because a failed read must not be the +//! way past this scope; and published text the parser cannot read fails +//! open like an absent interface, since a canister that wants to hide its +//! interface need only not publish it. 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 @@ -101,8 +109,10 @@ //! This guard enforces the policy for the standardized ledger surface, //! where real funds overwhelmingly live (see above), and — from the //! canister's own interface — for every canister that provides ICRC -//! methods; a canister that publishes no interface and names nothing -//! financial is not detected. +//! methods, refusing the update outright when that interface cannot be +//! read; a canister that publishes no interface and names nothing +//! financial is not detected, 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 @@ -598,9 +608,11 @@ pub fn disallowed_update_method(canister_id: &Principal, method: &str) -> Option /// 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. No interface, or one that cannot -/// be parsed, never reaches here: the caller fails open (a guardrail, not a -/// seal). +/// whose scopes are cheaper and more specific. A canister that publishes no +/// interface, or one the parser cannot read, never reaches here: the caller +/// fails open (a guardrail, not a seal). A read that FAILED does not reach here +/// either: the caller refuses with [`unchecked_update_refusal`] instead of +/// guessing. pub fn disallowed_update_on_ledger_interface( method: &str, declared: &BTreeSet, @@ -638,6 +650,26 @@ pub fn disallowed_update_on_ledger_interface( )) } +/// The refusal for an update whose target could not be checked: the interface +/// scope needs the canister's own interface, and the read of it FAILED — a +/// transport, HTTP or certificate error (`error` is its text), not a canister +/// that publishes none, which is checked by name only (see the module doc). +/// Refused rather than sent unchecked, with a retry message, because a failed +/// read must not be the way past the scope (per review). The register claims +/// nothing about the call: the guard learned nothing about it. +pub fn unchecked_update_refusal(method: &str, error: &str) -> String { + format!( + "`{method}` was not sent: the canister's Candid interface could not be read ({error}). \ + An update call is checked against that 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 read that failed, so it does not mean this \ + particular call moves assets. Retry: a canister that publishes no interface is not \ + refused this way, so an error that persists means this server cannot read the \ + interface it needs, or the canister id is not one the network routes. {NEUTRAL_REDIRECT}" + ) +} + #[cfg(test)] mod tests { use super::*; @@ -665,6 +697,8 @@ mod tests { /// 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 failed-read refusal must carry: the check could not run. + const UNCHECKED_RULE: &str = "could not be read"; /// A declared method set, as `calls::declared_methods` would read it. fn declared(names: &[&str]) -> BTreeSet { @@ -960,6 +994,16 @@ mod tests { assert!(msg.contains(NOT_THIS_CALL), "{msg}"); assert!(!msg.contains(ITSELF), "{msg}"); assert!(!msg.contains(DESTINATION_RULE), "{msg}"); + + // A read of the interface 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. + let msg = unchecked_update_refusal("set_message", "The request timed out."); + 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: @@ -1075,6 +1119,32 @@ mod tests { assert!(!msg.contains("compliance"), "{msg}"); } + // A read of the canister's interface that FAILED is not an absent + // interface: the update is refused with a retry message that names the + // method and the error, says what a persistent error means, claims nothing + // about the call — the guard learned nothing about it — and names no venue. + #[test] + fn an_unreadable_interface_refuses_the_update_with_a_retry() { + let msg = unchecked_update_refusal("set_message", "The request timed out."); + assert!(msg.contains("`set_message`"), "{msg}"); + assert!(msg.contains("The request timed out."), "{msg}"); + assert!(msg.contains(UNCHECKED_RULE), "{msg}"); + assert!(msg.contains("was not sent"), "{msg}"); + assert!(msg.contains("Retry"), "{msg}"); + assert!(msg.contains("publishes no interface is not refused this way"), "{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}"); + } + // 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 diff --git a/crates/imcp2-core/src/e2e_canister_tools.rs b/crates/imcp2-core/src/e2e_canister_tools.rs index 1217c4b..7d4bcca 100644 --- a/crates/imcp2-core/src/e2e_canister_tools.rs +++ b/crates/imcp2-core/src/e2e_canister_tools.rs @@ -1537,10 +1537,11 @@ async fn a_read_that_cannot_run_says_what_to_fix() { ); assert!(msg.contains("query failed"), "the replica's rejection is surfaced: {msg}"); - // --- the guard's interface scope fails open on an unreadable canister --- - // With no interface to read there is nothing to classify, so an update to - // the opaque canister goes through: `greet` is a query method, and the - // Internet Computer lets a query method be called as an update. + // --- the guard's interface scope on a canister that publishes none --- + // A certified absence: with no interface to read, the name scopes decide + // and nothing else does, so an update to the opaque canister goes through: + // `greet` is a query method, and the Internet Computer lets a query method + // be called as an update. let result = h .call( "canister_update_call", @@ -1557,6 +1558,31 @@ async fn a_read_that_cannot_run_says_what_to_fix() { "(\"x\")", "no interface: nothing for the interface scope to refuse on" ); + // --- a read that FAILS is not an absent interface --- + // 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. That call is refused with a retry message — the guard cannot + // check the canister, and a failed read must not be the way past the check + // — and the wording is the proof: sent on, the call would have failed at + // the replica, and the tool would report "call failed". + let phantom = Principal::from_slice(&[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 1, 1]); + let declares_phantom = "https://errors-phantom.e2e.test"; + webfixture::serve(declares_phantom, webfixture::Site::declaring(&[phantom])); + let msg = refusal( + &h.call( + "canister_update_call", + serde_json::json!({ + "canister_id": phantom.to_text(), + "method": "greet", + "args": "(\"x\")", + "app_url": declares_phantom, + }), + ) + .await, + ); + assert!(msg.contains("could not be read"), "a failed read refuses the call: {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 caller-supplied interface is consulted only because the canister // publishes none — and it can only ADD a refusal: declare ICRC-1 methods // for the same canister and the same call is refused as a financial one. diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index 5a84ee4..4e1462c 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -662,21 +662,32 @@ impl IcCanisterTools { // 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; + // Read with the outcome kept, not through resolve_did: the gate below + // must tell a FAILED read from a canister that publishes none. + 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 `resolve_did` has - // just read it for encoding, 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. `resolve_did` consults the - // caller-supplied `candid` only when the canister publishes no - // interface, so a supplied text can only ADD a refusal, never hide one. - // No interface, or one the parser cannot read, fails OPEN: the guard is - // a guardrail, not a seal. + // (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. What the read + // established decides the rest (per review): a read that FAILED — a + // transport, HTTP or certificate error — refuses the call with a retry + // message, since the guard cannot tell a ledger from an app without the + // interface and a failed read must not be the way past it; a canister + // that publishes none is checked by the name scopes above only, with + // the caller-supplied `candid` consulted in its place — so a supplied + // text can only ADD a refusal, never hide one; and published text the + // parser cannot read fails OPEN the same way: the guard is a guardrail, + // not a seal. + if let calls::CandidServiceRead::Failed(error) = &read { + return Ok(err(compliance::unchecked_update_refusal(&method, error))); + } + let did = read.or_provided(candid.as_deref()); if let Some(refusal) = did.as_deref().and_then(calls::declared_methods).and_then(|declared| { compliance::disallowed_update_on_ledger_interface(&method, &declared) diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index c2b6247..1e0fa28 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -206,7 +206,9 @@ transactions:** methods (`notify_top_up`, `notify_create_canister`, `notify_mint_cycles`, `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 — and to the + and the ICP ledger, detected from the canister itself, and refused rather + than sent when that interface cannot be read; a canister that publishes no + interface is checked by name only — 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 @@ -441,7 +443,9 @@ next edit of the listing. > 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, so a call it refuses is +> is read, before the call is signed or sent (a read that fails refuses +> the call, retryably, rather than sending it unchecked; a canister that +> publishes no interface is checked by name only), 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 @@ -465,8 +469,14 @@ 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: the guard +covers the standardized ledger surface and every canister that publishes an +ICRC interface; a canister that publishes no interface is checked by method +name only, and a call whose interface read fails is refused rather than sent +unchecked. **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 198a505..44d1583 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -166,7 +166,9 @@ submission states, not from a narrower reading: the cycles-minting canister's funding-completion methods on those 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), and every update call on the + detected from the canister itself; a call whose interface read fails is + refused rather than sent unchecked, and a canister that publishes no + interface is checked by name only), 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. From e732ed3bf4fcac758868de6275e316feb19a3350 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:38:18 +0000 Subject: [PATCH 5/7] Require the canister's published interface for every update call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer's direction, simplifying the rule from the previous commit: a canister must publish its Candid interface to receive update calls through this server, or they are denied. The interface scope of the financial-transactions guard therefore always has the interface it checks against, and the "checked by name only" caveat for a canister that publishes none is gone. `canister_update_call` refuses, before anything is encoded or signed, whenever the published interface is not in hand, with `compliance::unchecked_update_refusal` and a `MissingInterface` that says which case it is and what follows: `NotPublished` (a certified absence, or bytes that are not text) tells the user the canister must publish its interface; `Unreadable` (published text the parser cannot read: not valid Candid, no service, or over the CWE-674 limits) says a readable one is what unblocks it; `ReadFailed` (a transport, HTTP or certificate error, named in the message) is the one case worth a retry. Every variant disclaims the call, names no venue, and carries the pinned phrases. The `candid` argument is removed from `canister_update_call`: it was a fallback for a canister that publishes no interface, and such a call is now refused outright, so the argument had no effect left. It stays on `canister_query`, where a query cannot commit state. `resolve_did` and `candid_service` are unchanged for the read-only paths. Docs state the rule and its remaining limit — a bespoke method on a canister whose published interface is not a ledger's is not judged — in the module doc, the README, both directory-submission docs and the financial-transactions acknowledgment for Anthropic. Tests: the refusal's three variants and their register; a PocketIC case for each — the canister that publishes none, a canister whose published interface is truncated Candid, and a canister id no subnet serves — each refused with its own wording and never reached. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 --- README.md | 11 +- crates/imcp2-core/src/calls.rs | 63 +++--- crates/imcp2-core/src/compliance.rs | 200 +++++++++++++------- crates/imcp2-core/src/e2e_canister_tools.rs | 68 +++---- crates/imcp2-core/src/tools.rs | 66 ++++--- docs/anthropic-directory-submission.md | 23 +-- docs/openai-directory-submission.md | 6 +- 7 files changed, 259 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index ed739db..1bdd197 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,11 @@ are unsupported by policy. The ledger surface is refused mechanically: 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. That interface check is a -guardrail, not a seal: a canister that publishes no interface is checked by -method name only, and an update is refused, retryably, when its canister's -interface cannot be read. The connector serves no +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. @@ -169,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; 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 (or the `candid` text supplied when it publishes none; a canister that publishes none is checked by name only, and a call is refused, retryably, when the interface cannot be read) — and 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 e747c0b..9c4679f 100644 --- a/crates/imcp2-core/src/calls.rs +++ b/crates/imcp2-core/src/calls.rs @@ -202,9 +202,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. @@ -222,10 +221,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`. @@ -379,11 +374,12 @@ 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 — for a read that -/// FAILED as well as for a canister that publishes none. The read-only paths -/// fail open this way; `canister_update_call` reads with [`read_candid_service`] -/// instead and refuses on a failed read before falling back. +/// 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, @@ -399,9 +395,9 @@ pub async fn candid_service(agent: &Agent, canister: Principal) -> Option) -> Option { self.published().or_else(|| provided.map(str::to_string)) } @@ -901,8 +897,9 @@ pub fn has_oql(did: &str) -> bool { /// `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) — the caller then fails OPEN, as it does for [`is_query_method`]. -/// `Some(empty)` is a service that declares no methods. +/// 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 @@ -2113,17 +2110,18 @@ mod tests { assert_eq!(names("service : {}"), Some(vec![])); } - // Fail-open on an over-limit interface: the CWE-674 guard trips BEFORE - // parsing and the caller gets `None` rather than a verdict — and the shallow - // twin proves the guard, not the shape, is why. + // 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_fails_open_on_an_over_limit_interface() { + 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 must fail open"); + 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)); @@ -2131,10 +2129,10 @@ mod tests { // 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, which the update path refuses rather than treats as "no - // interface" — a failed read must not be the way past the guard's interface - // scope. Bytes that are not text are a property of the canister, like an - // absent interface, not of the read. + // 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 a property of the canister, like an absent interface, not of + // the read. #[test] fn a_candid_service_read_distinguishes_absent_from_failed() { use super::{classify_candid_service_read as classify, CandidServiceRead::*}; @@ -2163,9 +2161,10 @@ mod tests { } } - // The interface to encode against: the published text wins; a caller-supplied - // text stands in when the canister publishes none — and when the read failed - // too, which is why the update path checks the outcome before falling back. + // 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::*; diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index 71c798d..e78b672 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -76,18 +76,19 @@ //! 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. What the -//! read establishes decides what happens without an interface (per -//! review): a canister that publishes none — a CERTIFIED absence — is -//! checked by name only, the scopes above still applying and a -//! caller-supplied text read in its place; a read that FAILS (a transport, -//! HTTP or certificate error) refuses the update with a retry message -//! ([`unchecked_update_refusal`]), because a failed read must not be the -//! way past this scope; and published text the parser cannot read fails -//! open like an absent interface, since a canister that wants to hide its -//! interface need only not publish it. Other ICRC token namespaces -//! (`icrc4_`, `icrc7_`, `icrc37_`, `icrc152_`) are not detected this way; -//! their value-moving names are refused literally above. +//! 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 @@ -108,11 +109,13 @@ //! 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), and — from the -//! canister's own interface — for every canister that provides ICRC -//! methods, refusing the update outright when that interface cannot be -//! read; a canister that publishes no interface and names nothing -//! financial is not detected, and that limit is stated wherever the guard -//! is described, never implied away. +//! 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 @@ -609,10 +612,9 @@ pub fn disallowed_update_method(canister_id: &Principal, method: &str) -> Option /// 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, or one the parser cannot read, never reaches here: the caller -/// fails open (a guardrail, not a seal). A read that FAILED does not reach here -/// either: the caller refuses with [`unchecked_update_refusal`] instead of -/// guessing. +/// 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, @@ -650,23 +652,53 @@ pub fn disallowed_update_on_ledger_interface( )) } +/// 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), or + /// publishes bytes that are not text. + NotPublished, + /// The canister publishes text this server's parser cannot read: not valid + /// Candid, no service declared, or 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 interface, and the read of it FAILED — a -/// transport, HTTP or certificate error (`error` is its text), not a canister -/// that publishes none, which is checked by name only (see the module doc). -/// Refused rather than sent unchecked, with a retry message, because a failed -/// read must not be the way past the scope (per review). The register claims -/// nothing about the call: the guard learned nothing about it. -pub fn unchecked_update_refusal(method: &str, error: &str) -> String { +/// 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)".to_string(), + "A canister receives update calls through this server only once it publishes its \ + interface; this one does not.", + ), + MissingInterface::Unreadable => ( + "the canister's published Candid interface could not be read by this server (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: the canister's Candid interface could not be read ({error}). \ - An update call is checked against that 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 read that failed, so it does not mean this \ - particular call moves assets. Retry: a canister that publishes no interface is not \ - refused this way, so an error that persists means this server cannot read the \ - interface it needs, or the canister id is not one the network routes. {NEUTRAL_REDIRECT}" + "`{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 missing interface, so it does not mean this particular call moves assets. \ + {next} {NEUTRAL_REDIRECT}" ) } @@ -697,8 +729,18 @@ mod tests { /// 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 failed-read refusal must carry: the check could not run. - const UNCHECKED_RULE: &str = "could not be read"; + /// 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 { @@ -995,15 +1037,18 @@ mod tests { assert!(!msg.contains(ITSELF), "{msg}"); assert!(!msg.contains(DESTINATION_RULE), "{msg}"); - // A read of the interface 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. - let msg = unchecked_update_refusal("set_message", "The request timed out."); - 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}"); + // 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: @@ -1119,30 +1164,47 @@ mod tests { assert!(!msg.contains("compliance"), "{msg}"); } - // A read of the canister's interface that FAILED is not an absent - // interface: the update is refused with a retry message that names the - // method and the error, says what a persistent error means, claims nothing + // 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_unreadable_interface_refuses_the_update_with_a_retry() { - let msg = unchecked_update_refusal("set_message", "The request timed out."); - assert!(msg.contains("`set_message`"), "{msg}"); - assert!(msg.contains("The request timed out."), "{msg}"); - assert!(msg.contains(UNCHECKED_RULE), "{msg}"); - assert!(msg.contains("was not sent"), "{msg}"); - assert!(msg.contains("Retry"), "{msg}"); - assert!(msg.contains("publishes no interface is not refused this way"), "{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}"); + 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}"); + 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("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 diff --git a/crates/imcp2-core/src/e2e_canister_tools.rs b/crates/imcp2-core/src/e2e_canister_tools.rs index 7d4bcca..e1fb3c7 100644 --- a/crates/imcp2-core/src/e2e_canister_tools.rs +++ b/crates/imcp2-core/src/e2e_canister_tools.rs @@ -1537,13 +1537,13 @@ async fn a_read_that_cannot_run_says_what_to_fix() { ); assert!(msg.contains("query failed"), "the replica's rejection is surfaced: {msg}"); - // --- the guard's interface scope on a canister that publishes none --- - // A certified absence: with no interface to read, the name scopes decide - // and nothing else does, so an update to the opaque canister goes through: - // `greet` is a query method, and the Internet Computer lets a query method - // be called as an update. - let result = h - .call( + // --- 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, @@ -1552,55 +1552,59 @@ async fn a_read_that_cannot_run_says_what_to_fix() { "app_url": declares_all, }), ) - .await; - assert_eq!( - ok_text(&result), - "(\"x\")", - "no interface: nothing for the interface scope to refuse on" + .await, ); - // --- a read that FAILS is not an absent interface --- + 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. That call is refused with a retry message — the guard cannot - // check the canister, and a failed read must not be the way past the check - // — and the wording is the proof: sent on, the call would have failed at - // the replica, and the tool would report "call failed". + // 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_phantom = "https://errors-phantom.e2e.test"; - webfixture::serve(declares_phantom, webfixture::Site::declaring(&[phantom])); + 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": phantom.to_text(), + "canister_id": garbled.to_text(), "method": "greet", "args": "(\"x\")", - "app_url": declares_phantom, + "app_url": declares_more, }), ) .await, ); - assert!(msg.contains("could not be read"), "a failed read refuses the call: {msg}"); - assert!(msg.contains("does not mean this particular call moves assets"), "{msg}"); + 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}"); - // A caller-supplied interface is consulted only because the canister - // publishes none — and it can only ADD a refusal: declare ICRC-1 methods - // for the same canister and the same call is refused as a financial one. let msg = refusal( &h.call( "canister_update_call", serde_json::json!({ - "canister_id": opaque, + "canister_id": phantom.to_text(), "method": "greet", "args": "(\"x\")", - "app_url": declares_all, - "candid": LEDGER_DID, + "app_url": declares_more, }), ) .await, ); - assert!(msg.contains("whose Candid interface declares"), "{msg}"); - assert!(msg.contains("ICRC-1 ledger methods"), "{msg}"); + 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}"); // --- canister_query needs exactly one of `method` and `oql` --- let msg = refusal( diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index 4e1462c..e26fefc 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 { @@ -660,10 +659,12 @@ 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. - // Read with the outcome kept, not through resolve_did: the gate below - // must tell a FAILED read from a canister that publishes none. + // 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 / @@ -674,28 +675,41 @@ impl IcCanisterTools { // (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. What the read - // established decides the rest (per review): a read that FAILED — a - // transport, HTTP or certificate error — refuses the call with a retry - // message, since the guard cannot tell a ledger from an app without the - // interface and a failed read must not be the way past it; a canister - // that publishes none is checked by the name scopes above only, with - // the caller-supplied `candid` consulted in its place — so a supplied - // text can only ADD a refusal, never hide one; and published text the - // parser cannot read fails OPEN the same way: the guard is a guardrail, - // not a seal. - if let calls::CandidServiceRead::Failed(error) = &read { - return Ok(err(compliance::unchecked_update_refusal(&method, error))); - } - let did = read.or_provided(candid.as_deref()); - if let Some(refusal) = - did.as_deref().and_then(calls::declared_methods).and_then(|declared| { - compliance::disallowed_update_on_ledger_interface(&method, &declared) - }) + // 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::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(did.as_deref(), &method, &args) { + let arg_bytes = match calls::encode_args(Some(&did), &method, &args) { Ok(b) => b, Err(e) => return Ok(err(e)), }; @@ -710,7 +724,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/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 1e0fa28..3617726 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -206,9 +206,9 @@ transactions:** methods (`notify_top_up`, `notify_create_canister`, `notify_mint_cycles`, `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, and refused rather - than sent when that interface cannot be read; a canister that publishes no - interface is checked by name only — and to the + 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 @@ -443,9 +443,9 @@ next edit of the listing. > 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 (a read that fails refuses -> the call, retryably, rather than sending it unchecked; a canister that -> publishes no interface is checked by name only), so a call it refuses is +> 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 @@ -472,11 +472,12 @@ tool initiates or executes a transfer of the user's funds — financial ledger 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: the guard -covers the standardized ledger surface and every canister that publishes an -ICRC interface; a canister that publishes no interface is checked by method -name only, and a call whose interface read fails is refused rather than sent -unchecked. +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 44d1583..e57f690 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -166,9 +166,9 @@ submission states, not from a narrower reading: the cycles-minting canister's funding-completion methods on those 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; a call whose interface read fails is - refused rather than sent unchecked, and a canister that publishes no - interface is checked by name only), and every update call on the + 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. From f1630309538d106c642d34749540340ed6b0cecd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:48:48 +0000 Subject: [PATCH 6/7] Describe the interface scope's evidence as the published interface only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #213 (Copilot) caught a sentence the must-publish rule had left behind: the compliance module doc still said the interface scope reads the canister's `candid:service` "else the caller-supplied `candid`" through `resolve_did`, which no longer holds — the update path takes no fallback and refuses a canister that publishes none. The sentence now names the published interface as the only evidence, the pre-network call-site comment no longer points at `resolve_did`, and the calls module doc says the caller-supplied definition is a query's. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 --- crates/imcp2-core/src/calls.rs | 3 ++- crates/imcp2-core/src/compliance.rs | 8 ++++---- crates/imcp2-core/src/tools.rs | 4 +++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/imcp2-core/src/calls.rs b/crates/imcp2-core/src/calls.rs index 9c4679f..6aa2b54 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 diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index e78b672..ec5dede 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -68,10 +68,10 @@ //! method's. //! * **Financial canisters detected from the canister itself, refused //! entirely.** Once the interface is in hand — the canister's own -//! `candid:service`, else the caller-supplied `candid`, which -//! `resolve_did` consults only when the canister publishes none, so it can -//! only ADD a refusal — a canister whose declared interface provides ANY -//! `icrc1_*` / `icrc2_*` method (a query such as `icrc1_balance_of` +//! 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 diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index e26fefc..7419d78 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -628,7 +628,9 @@ impl IcCanisterTools { // 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 resolve_did has read it. + // 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. From 55f2f6fccb924a9e95674ab2c1993795e48f107e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:16:39 +0000 Subject: [PATCH 7/7] Say only what a missing-interface refusal knows, and scope the syntax guide Adversarial review of the must-publish rule found three wording defects, none with a runtime effect: - The served Candid syntax guide (`candid_syntax_guide`, the `candid://textual-syntax` resource) still said `canister_update_call` falls back to encoding arguments by inference from the literals when the interface cannot be read. That case is now a refusal, so the fallback paragraph is scoped to `canister_query`, and the guide says an update call always has the interface or is not sent. - The sentence every variant of `unchecked_update_refusal` shared said the refusal is "about the missing interface", which a FAILED read does not establish; it now says "about the check that could not run". - `NotPublished` asserted "no `candid:service` metadata" for bytes that are not text, and "this one does not" publish about an id that may hold no canister at all: a certified absence is also what the state tree says of an id in a subnet's range that no canister occupies. Bytes that are not text are now their own read outcome, `NotText`, refused as `Unreadable` (published, unreadable), and the `NotPublished` text says the canister publishes none or no canister exists at this id. Tests pin the new wording, and the PocketIC suite gains the in-range nonexistent canister (installed, stopped and deleted): refused as a certified absence, never reached. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 --- crates/imcp2-core/src/calls.rs | 21 ++++++++++------ crates/imcp2-core/src/compliance.rs | 25 ++++++++++++------- crates/imcp2-core/src/e2e_canister_tools.rs | 25 +++++++++++++++++++ crates/imcp2-core/src/tools.rs | 6 +++++ .../static/candid-textual-syntax.md | 12 ++++++--- 5 files changed, 69 insertions(+), 20 deletions(-) diff --git a/crates/imcp2-core/src/calls.rs b/crates/imcp2-core/src/calls.rs index 6aa2b54..bf5f68f 100644 --- a/crates/imcp2-core/src/calls.rs +++ b/crates/imcp2-core/src/calls.rs @@ -403,9 +403,14 @@ pub async fn candid_service(agent: &Agent, canister: Principal) -> Option Option { match self { Self::Published(did) => Some(did), - Self::NotPublished | Self::Failed(_) => None, + Self::NotText | Self::NotPublished | Self::Failed(_) => None, } } @@ -450,7 +455,7 @@ pub(crate) fn classify_candid_service_read( match result { Ok(bytes) => match String::from_utf8(bytes) { Ok(did) => CandidServiceRead::Published(did), - Err(_) => CandidServiceRead::NotPublished, + Err(_) => CandidServiceRead::NotText, }, Err(AgentError::LookupPathAbsent(_)) => CandidServiceRead::NotPublished, Err(e) => CandidServiceRead::Failed(e.to_string()), @@ -2132,14 +2137,14 @@ mod tests { // 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 a property of the canister, like an absent interface, not of - // the read. + // 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])), NotPublished, "not text: nothing to read"); + assert_eq!(classify(Ok(vec![0xff, 0xfe])), NotText, "published, but not text"); assert_eq!( classify(Err(AgentError::LookupPathAbsent(vec![]))), NotPublished, @@ -2172,6 +2177,8 @@ mod tests { 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); diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index ec5dede..8adf6e2 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -657,11 +657,12 @@ pub fn disallowed_update_on_ledger_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), or - /// publishes bytes that are not text. + /// 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 text this server's parser cannot read: not valid - /// Candid, no service declared, or over the size / depth limits (CWE-674). + /// 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), @@ -676,13 +677,15 @@ pub enum MissingInterface { 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)".to_string(), + "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; this one does not.", + interface.", ), MissingInterface::Unreadable => ( "the canister's published Candid interface could not be read by this server (not \ - valid Candid, no service declared, or over its size or depth limits)" + 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.", ), @@ -697,8 +700,8 @@ pub fn unchecked_update_refusal(method: &str, missing: &MissingInterface) -> Str 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 missing interface, so it does not mean this particular call moves assets. \ - {next} {NEUTRAL_REDIRECT}" + about the check that could not run, so it does not mean this particular call moves \ + assets. {next} {NEUTRAL_REDIRECT}" ) } @@ -1190,11 +1193,15 @@ mod tests { 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}"); } diff --git a/crates/imcp2-core/src/e2e_canister_tools.rs b/crates/imcp2-core/src/e2e_canister_tools.rs index e1fb3c7..d7b62ba 100644 --- a/crates/imcp2-core/src/e2e_canister_tools.rs +++ b/crates/imcp2-core/src/e2e_canister_tools.rs @@ -1605,6 +1605,31 @@ async fn a_read_that_cannot_run_says_what_to_fix() { 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( diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index 7419d78..3563003 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -688,6 +688,12 @@ impl IcCanisterTools { // 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, diff --git a/crates/imcp2-core/static/candid-textual-syntax.md b/crates/imcp2-core/static/candid-textual-syntax.md index 52a5acf..0746dd3 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