diff --git a/README.md b/README.md index 99b2188..80824d6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/crates/imcp2-core/src/compliance.rs b/crates/imcp2-core/src/compliance.rs index e8f0fcc..56d2c80 100644 --- a/crates/imcp2-core/src/compliance.rs +++ b/crates/imcp2-core/src/compliance.rs @@ -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 @@ -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 @@ -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` → @@ -604,12 +585,11 @@ fn name_words(method: &str) -> Vec { 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)) @@ -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 @@ -1035,8 +1015,6 @@ mod tests { "get_buyer", "swapped", "trader", - "transfer_ownership", - "transferOwnership", ] { assert!( disallowed_update_method(&any_canister(), method).is_none(), @@ -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"), @@ -1301,8 +1282,6 @@ mod tests { "send", "claim", "process_payment", - "transfer_ownership", - "transferOwnership", "", ] { assert_eq!(financial_keyword(method), None, "{method}"); @@ -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!( @@ -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: diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs index d0215a8..50c774b 100644 --- a/crates/imcp2-core/src/tools.rs +++ b/crates/imcp2-core/src/tools.rs @@ -2214,12 +2214,17 @@ fn identity_annotation(target: &IdentityTarget) -> String { /// usable for financial transactions, which is the one thing it must not /// suggest. `financial_policy_is_a_server_instruction_not_a_description` /// holds that line across every served description, and the directive scan -/// covers the schemas too. What the paragraph does NOT do is restate -/// [`crate::compliance`]'s method families and canister scopes: that list would -/// have to be kept in sync forever, and a refused call already gets a refusal -/// accurate for its own scope. Neither surface names a venue for a refused -/// operation. -const SERVER_INSTRUCTIONS: &str = "Tools for the Internet Computer: read what a canister offers, read and write its data, and act as the user's own identity at an app.\n\nCANISTERS. Each canister publishes a Candid interface declaring its methods and their types. Method arguments and replies are textual Candid, the `(...)` syntax, e.g. `(record { owner = principal \"aaaaa-aa\"; amount = 5 : nat })`; candid_syntax_guide returns that syntax; the full type system is served as an MCP resource, `candid://reference`. Some canisters additionally declare an OQL surface. This server reads those through OQL and refuses a Candid `method` query on them; update calls are unaffected. icp_oql_guide documents the dialect. IC how-to guides are served as `skill://` resources.\n\nAPPS AND IDENTITY. An app is a website backed by canisters. Internet Identity gives the user a different principal at each app, derived from that app's derivation origin, which is not always the app's visible URL; open_app resolve it, and the tools that act as the user take the resolved value. Within one app the user may hold several accounts.\n\nDECLARED CANISTERS. Every tool that reaches a canister — get_canister_candid, get_canister_api_doc, get_canister_oql_schema, canister_query and canister_update_call — works only on a canister the app that owns it declares at /.well-known/ic-architecture (https://docs.internetcomputer.org/guides/frontends/service-discoverability/), which is how its operators opt in to being read and operated by agents. `app_url` names that app and open_app returns it, alongside which of the app's canisters carry that declaration (marked [declared]); where a call carries both an `app_url` and a derivation origin, the two must belong to the same app. An app that publishes no manifest is still resolved and discovered, but none of its canisters can be read or called here: say that, and what its operators would publish, rather than retrying.\n\nSESSIONS. Calls that act as the user are signed with the Internet Identity credential this connection was authorized with. Internet Identity offers two access levels at that point: \"Questions only\", where reads work and state-changing calls are rejected by the network, and \"Actions & questions\", which permits both.\n\nFINANCIAL TRANSACTIONS ARE NOT SUPPORTED, to protect the user: do not use canister_update_call to move assets. Value-moving methods, neuron management, and every update call to a known wallet, ledger, exchange or staking canister are refused before they reach the network; that guard is a safeguard, not a complete filter, so treat this policy, rather than the absence of a refusal, as the limit. For financial operations (token transfers, spending approvals, payments, trades), the user works outside this connector, in a trusted interface they control.\n\nBuilding and deploying canisters happens in the user's own environment with the icp CLI; no tool here creates, funds or deploys one."; +/// covers the schemas too. The paragraph describes the three checks an update +/// call goes through — by method name, by destination, and by the canister's +/// own published interface — and states the one thing none of them judges, so +/// a reader knows what the guard covers without being told it is incomplete +/// (per directory review). It does NOT restate [`crate::compliance`]'s keyword +/// list, markers and canister ids: that copy would have to be kept in sync +/// forever, and a refused call already gets a refusal accurate for its own +/// scope. The three example keywords it names are pinned to the guard by the +/// same test, which also pins that it mentions no exemption (the keyword check +/// has none). Neither surface names a venue for a refused operation. +const SERVER_INSTRUCTIONS: &str = "Tools for the Internet Computer: read what a canister offers, read and write its data, and act as the user's own identity at an app.\n\nCANISTERS. Each canister publishes a Candid interface declaring its methods and their types. Method arguments and replies are textual Candid, the `(...)` syntax, e.g. `(record { owner = principal \"aaaaa-aa\"; amount = 5 : nat })`; candid_syntax_guide returns that syntax; the full type system is served as an MCP resource, `candid://reference`. Some canisters additionally declare an OQL surface. This server reads those through OQL and refuses a Candid `method` query on them; update calls are unaffected. icp_oql_guide documents the dialect. IC how-to guides are served as `skill://` resources.\n\nAPPS AND IDENTITY. An app is a website backed by canisters. Internet Identity gives the user a different principal at each app, derived from that app's derivation origin, which is not always the app's visible URL; open_app resolve it, and the tools that act as the user take the resolved value. Within one app the user may hold several accounts.\n\nDECLARED CANISTERS. Every tool that reaches a canister — get_canister_candid, get_canister_api_doc, get_canister_oql_schema, canister_query and canister_update_call — works only on a canister the app that owns it declares at /.well-known/ic-architecture (https://docs.internetcomputer.org/guides/frontends/service-discoverability/), which is how its operators opt in to being read and operated by agents. `app_url` names that app and open_app returns it, alongside which of the app's canisters carry that declaration (marked [declared]); where a call carries both an `app_url` and a derivation origin, the two must belong to the same app. An app that publishes no manifest is still resolved and discovered, but none of its canisters can be read or called here: say that, and what its operators would publish, rather than retrying.\n\nSESSIONS. Calls that act as the user are signed with the Internet Identity credential this connection was authorized with. Internet Identity offers two access levels at that point: \"Questions only\", where reads work and state-changing calls are rejected by the network, and \"Actions & questions\", which permits both.\n\nFINANCIAL TRANSACTIONS ARE NOT SUPPORTED, to protect the user: do not use canister_update_call to move assets. Before an update call is sent it is checked three ways. By method name, on every canister: the ICRC ledger methods, neuron management, and any name carrying a financial keyword as a whole word (transfer, withdraw, swap and the like) are refused. By destination: every update call to the known wallet, ledger, exchange and staking canisters is refused. By the canister's own published Candid interface, read before anything is encoded: a canister that declares ICRC-1 or ICRC-2 methods is a financial service and receives no update call, and a canister whose interface is missing or unreadable receives none either. A refusal says which check applied and claims nothing beyond it. Not judged: a bespoke value-moving method under an unrelated name on a canister whose interface is not a ledger's. For financial operations (token transfers, spending approvals, payments, trades), the user works outside this connector, in a trusted interface they control.\n\nBuilding and deploying canisters happens in the user's own environment with the icp CLI; no tool here creates, funds or deploys one."; impl ServerHandler for IcTools { async fn list_tools( @@ -2809,16 +2814,22 @@ mod tests { // canister_update_call's description reads as a hint that the tool is // usable for financial transactions, which is the one thing it must not // suggest. Pin both sides — no tool description carries financial - // language, and the instructions state the denial, the limit of the guard - // that backs it, and the redirect. The refused method families are - // deliberately NOT restated here or in the instructions: they were a copy - // of `compliance` that had to be kept in sync, and an attempted call is - // refused with a message accurate for its own scope. What the instructions - // must not do is promise more than the guard delivers, so the - // safeguard-not-a-filter clause is pinned in its place. Neither surface - // names a venue for a refused operation: metadata answering a refused - // financial operation with a specific transactional service would read as - // a redirect from one such route to another. + // language, and the instructions state the denial, the three checks that + // enforce it (by name, by destination, by the canister's own interface), + // the one thing those checks do not judge, and the redirect. Directory + // review asked that the instructions describe what the guard covers + // rather than call it incomplete, so the old "a safeguard, not a complete + // filter" clause is pinned OUT. The keyword list, markers and canister ids + // are deliberately NOT restated in the instructions: they were a copy of + // `compliance` that had to be kept in sync, and an attempted call is + // refused with a message accurate for its own scope — the three example + // keywords the paragraph does name are checked against the guard here, and + // so is the absence of any exemption: the keyword check has none, so the + // paragraph must not mention one (an earlier version named + // `transfer_ownership` as exempt, which the guard no longer honours). + // Neither surface names a venue for a refused operation: metadata + // answering a refused financial operation with a specific transactional + // service would read as a redirect from one such route to another. #[test] fn financial_policy_is_a_server_instruction_not_a_description() { for tool in super::IcTools::all_tools() { @@ -2828,9 +2839,38 @@ mod tests { } let ins = super::SERVER_INSTRUCTIONS; assert!(ins.contains("FINANCIAL TRANSACTIONS ARE NOT SUPPORTED, to protect the user")); + for check in ["By method name", "By destination", "published Candid interface"] { + assert!(ins.contains(check), "the instructions must describe the {check} check: {ins}"); + } + assert!(ins.contains("Not judged:"), "the instructions must state the limit: {ins}"); + assert!( + !ins.contains("not a complete filter") && !ins.contains("safeguard"), + "the instructions describe what the guard covers rather than calling it \ + incomplete (per directory review): {ins}" + ); + // The example keywords the paragraph names are real: each is refused + // by the guard on an arbitrary canister. + for keyword in ["transfer", "withdraw", "swap"] { + assert!(ins.contains(keyword), "the paragraph names {keyword}: {ins}"); + assert!( + crate::compliance::disallowed_update_method( + &candid::Principal::management_canister(), + keyword + ) + .is_some(), + "{keyword} is named in the instructions but not refused by the guard" + ); + } + // ...and the paragraph names no exemption, because the guard has none: + // the ownership-handover name once mentioned as exempt is refused. + assert!(!ins.contains("exempt"), "the keyword check has no exemption: {ins}"); assert!( - ins.contains("a safeguard, not a complete filter"), - "the instructions must not present the guard as complete coverage: {ins}" + crate::compliance::disallowed_update_method( + &candid::Principal::management_canister(), + "transfer_ownership" + ) + .is_some(), + "transfer_ownership must be refused: nothing is exempt from the keyword check" ); assert!(ins.contains("outside this connector, in a trusted interface they control")); assert!(!ins.contains("oisy.com"), "the instructions name no venue: {ins}"); diff --git a/docs/anthropic-directory-submission.md b/docs/anthropic-directory-submission.md index 9e9d055..a361f74 100644 --- a/docs/anthropic-directory-submission.md +++ b/docs/anthropic-directory-submission.md @@ -206,8 +206,8 @@ transactions:** methods (`notify_top_up`, `notify_create_canister`, `notify_mint_cycles`, `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, + `exchange`, `pay`, `buy`, `deposit`, …, 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; an update call needs @@ -219,10 +219,15 @@ transactions:** venue (a refused canister-creation or funding-completion call points at the user-run icp CLI). The policy is stated in the server-level instructions — the field the directories scan — where it covers the whole surface at once, - and a unit test holds it there. What those instructions state is the policy - itself, not its implementation: the method families and canister scopes are - in the guard and in the refusal an attempted call receives, so the - instructions carry no copy of that list to keep in sync. + and a unit test holds it there. Those instructions state the policy and + describe the three checks that enforce it — by method name, by destination, + and by the canister's own published interface — plus the one thing none of + them judges, so a reader knows what the guard covers without being told it + is incomplete (the review asked for exactly that). The keyword list, the + interface markers and the canister ids are in the guard and in the refusal + an attempted call receives, so the instructions carry no copy of them to + keep in sync; the three example keywords they do name are pinned to the + guard by the same unit test. - The README and the server instructions both state explicitly that financial transactions are not supported. The landing page is no longer one of them: #165 moved it to , diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md index 3700cea..0ad2773 100644 --- a/docs/openai-directory-submission.md +++ b/docs/openai-directory-submission.md @@ -166,8 +166,7 @@ submission states, not from a narrower reading: the cycles-minting canister's funding-completion methods on those 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 + `deposit`, …, with no exemptions) 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; an update call needs that published interface, so a canister that publishes none, or @@ -184,11 +183,13 @@ submission states, not from a narrower reading: canister, a URL with no Internet-Computer evidence). The financial-transactions policy is a separate, server-wide matter and is stated where the scan reads it: in the server-level instructions, covering - the whole surface at once. Those instructions state the policy, not its - implementation — the method families and canister scopes live in the guard - and in the refusal each attempted call receives, rather than in a copy that - would have to be kept in sync. So no description promises a behavior - the tool does not have, and no refusal is hidden. + the whole surface at once. Those instructions state the policy and describe + the three checks that enforce it (by method name, by destination, by the + canister's own published interface) and the one thing none of them judges; + the keyword list, the interface markers and the canister ids live in the + guard and in the refusal each attempted call receives, rather than in a + copy that would have to be kept in sync. So no description promises a + behavior the tool does not have, and no refusal is hidden. - **The commerce and speculation rules have nothing to attach to.** The plugin sells nothing (no physical goods, no digital goods or subscriptions, no checkout) and offers no speculation product: no trading,