Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ 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 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
… as whole words, with no exemptions) on every canister, and refuses every update call to a
canister whose own Candid interface declares ICRC-1 or ICRC-2 methods — every
ICRC ledger and the ICP ledger, detected from the canister itself — or that is
one of the financial-service canisters it carries. An update call needs the
Expand Down Expand Up @@ -173,7 +172,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?` | 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 — 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) |
| `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, 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 — with no exemptions, so `transfer_ownership` is refused too); 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 |
Expand Down
102 changes: 35 additions & 67 deletions crates/imcp2-core/src/compliance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@
//! 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
//! work, with no exemption list: a name that carries a keyword is refused
//! whatever the app means by it (the ownership-handover names exempted at
//! first had no verified canister behind them and went, per review).
//! 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
Expand Down Expand Up @@ -145,8 +146,11 @@
//! 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`,
//! says so rather than classifying the call, and no exemption list (a
//! literal list of control-handover names, `transfer_ownership` and its
//! camelCase spelling, was carried at first; no mainnet interface
//! declaring either could be found, so it went — a name with no source
//! does not get in). 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
Expand Down Expand Up @@ -546,29 +550,6 @@ const FINANCIAL_KEYWORDS: &[&str] = &[
"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. 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",
];

/// 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` →
Expand Down Expand Up @@ -604,12 +585,11 @@ fn name_words(method: &str) -> Vec<String> {
words
}

/// The first financial keyword `method` carries as a whole word, unless the
/// name is one of the literal [`EXEMPT_METHODS`].
/// The first financial keyword `method` carries as a whole word. There is no
/// exemption list: `transfer_ownership` is refused like `transfer_tokens`,
/// since the guard cannot tell a control handover from a value movement by
/// the name, and no canister has been found that needs the distinction.
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))
Expand Down Expand Up @@ -1010,8 +990,8 @@ mod tests {

// 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
// is not that keyword, and `approve`/`send`/`claim` are deliberately not
// keywords. (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
Expand All @@ -1035,8 +1015,6 @@ mod tests {
"get_buyer",
"swapped",
"trader",
"transfer_ownership",
"transferOwnership",
] {
assert!(
disallowed_update_method(&any_canister(), method).is_none(),
Expand Down Expand Up @@ -1265,11 +1243,14 @@ mod tests {
}

// A keyword matches a whole word only — in any spelling convention — and
// the literal exemptions are the one way around it.
// nothing is exempt: an ownership handover is refused like any other
// `transfer`.
#[test]
fn financial_keyword_matches_whole_words_only() {
for (method, keyword) in [
("swapTokens", "swap"),
("transfer_ownership", "transfer"),
("transferOwnership", "transfer"),
("withdraw_all", "withdraw"),
("icrc1_transfer", "transfer"),
("Transfer", "transfer"),
Expand Down Expand Up @@ -1301,8 +1282,6 @@ mod tests {
"send",
"claim",
"process_payment",
"transfer_ownership",
"transferOwnership",
"",
] {
assert_eq!(financial_keyword(method), None, "{method}");
Expand Down Expand Up @@ -1333,29 +1312,30 @@ mod tests {
}
}

// Exemptions are literal: the exact spellings pass, anything else that
// contains the keyword does not.
// No name is exempt: the ownership-handover spellings once carried as
// exemptions are refused by the `transfer` keyword like every other
// spelling (per review — no canister declaring them could be found, and a
// name with no source does not get in).
#[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"] {
fn no_name_is_exempt() {
for method in [
"transfer_ownership",
"transferOwnership",
"Transfer_Ownership",
"transfer_ownership_v2",
"transfer_owner",
] {
let msg = disallowed_update_method(&any_canister(), method)
.unwrap_or_else(|| panic!("{method} is not literally exempt"));
.unwrap_or_else(|| panic!("{method} must be refused: nothing is exempt"));
assert!(msg.contains(NAME_RULE), "{msg}");
}
}

// The lists stay well-formed: keywords unique, lowercase, alphabetic, and
// The keyword list stays well-formed: 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.
// name).
#[test]
fn keyword_and_exemption_lists_are_well_formed() {
fn keyword_list_is_well_formed() {
let mut keywords: Vec<&str> = FINANCIAL_KEYWORDS.to_vec();
for keyword in &keywords {
assert!(
Expand All @@ -1370,18 +1350,6 @@ mod tests {
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:
Expand Down
Loading
Loading