Repository navigation
Refuse every update to a canister that provides ICRC-1/ICRC-2 methods - #213
Merged
Merged
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3
7 of 8 tasks
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues were identified for the expanded ICRC-1/ICRC-2 financial-transaction guard.
No open issues 🎉
What changed in this PR
Extends the financial-transactions guard beyond method names and curated canister IDs: canister_update_call now refuses every update when the resolved Candid interface declares an icrc1_* or icrc2_* method.
Changes:
- Adds interface-based ledger detection and pre-network refusal of all ICRC-1/ICRC-2 method names.
- Checks the interface after discoverability validation and before encoding or signing, retaining fail-open behavior for missing or unreadable interfaces.
- Adds parser and end-to-end coverage and updates user-facing policy documentation.
| File | Description |
|---|---|
| README.md | Documents interface-based ledger refusals and expanded method-name checks. |
| docs/openai-directory-submission.md | Updates the financial-guard description. |
| docs/anthropic-directory-submission.md | Documents interface detection and when each check runs. |
| crates/imcp2-core/src/tools.rs | Applies the interface guard before encoding and signing updates. |
| crates/imcp2-core/src/e2e_canister_tools.rs | Adds ledger fixtures and tests for refusals, permitted reads, and interface fallback. |
| crates/imcp2-core/src/compliance.rs | Adds ledger-prefix and interface rules with shared refusal wording and tests. |
| crates/imcp2-core/src/calls.rs | Extracts declared service methods with guarded parsing and regression tests. |
Review effort: Balanced
aterga
marked this pull request as ready for review
October 28, 2026 10:40
sea-snake
previously approved these changes
Oct 28, 2026
aterga
commented
Oct 28, 2026
…ares a ledger 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3
aterga
pushed a commit
that referenced
this pull request
Oct 28, 2026
…/guard-financial-keywords-69frs8 Carries the must-publish rule (e732ed3) and the failed-read refusal (55b1b8f) from #213 into the keyword scope's branch. Conflicts resolved by keeping both sides: the README tool row and the OpenAI submission doc carry the keyword clause and the must-publish clause; the compliance tests keep both branches' register constants and the keyword tests, with the missing-interface register check placed in the register test it belongs to. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3
… 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3
sea-snake
approved these changes
Oct 28, 2026
aterga
pushed a commit
that referenced
this pull request
Oct 28, 2026
#213 landed on main as one squash commit (434b41a), and GitHub retargeted this stacked PR to main, where the branch's copy of #213's history conflicted with the squash. This merge resolves that by taking, for every file, exactly the tree of main plus the two keyword commits (the keyword scope, and the sourced exemptions): main's own changes since the branch point (#212) are kept, and nothing of #213 is carried twice. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3
aterga
added a commit
that referenced
this pull request
Oct 28, 2026
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 * Refuse an update when the interface read fails, not only when it declares a ledger 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 * Require the canister's published interface for every update call 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 * Describe the interface scope's evidence as the published interface only 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 --------- Co-authored-by: Claude <noreply@anthropic.com>
Closed
5 of 9 tasks
aterga
pushed a commit
that referenced
this pull request
Oct 28, 2026
… still pending #213 and #214 merged on 2026-10-28: a financial canister is now detected from the interface it publishes, a published and readable interface is required for any update call, and method names carrying a financial keyword are refused everywhere. The submission log says so and lists what still stands between here and the reply on the review thread: the server instructions' "not a complete filter" clause, a release reaching production, and the reviewers' test app publishing its interface. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ui6YJGczXE8vZv2pjyxX2F
5 of 7 tasks
aterga
added a commit
that referenced
this pull request
Oct 28, 2026
…p the keyword scope's exemption list (#215) * Describe the guard's three checks in the server instructions instead of calling it incomplete Directory review asked, alongside the guard extension that landed in #213 and #214, that the server instructions "no longer describe the guard as incomplete". The financial-transactions paragraph still carried the pre-extension clause "that guard is a safeguard, not a complete filter", and the unit test pinned it. The paragraph now describes what an update call is checked against: by method name (the ICRC ledger methods, neuron management, and financial keywords as whole words, naming transfer, withdraw and swap as examples), by destination (the known financial-service canisters), and by the canister's own published Candid interface (ICRC-1/ICRC-2 methods make it a financial service; a missing or unreadable interface means no update call at all). It states the one thing none of the checks judges, a bespoke value-moving method under an unrelated name on a non-ledger canister, so the limit stays stated rather than implied away, and keeps the redirect. The keyword list, interface markers and canister ids are still not restated, for the same drift reason as before. The test now pins the three checks and the stated limit, pins the old clause out, and checks the three example keywords against the guard so the paragraph cannot name a keyword the guard does not refuse. The two submission docs that described the instructions are aligned. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PruVfYuaS7ZAgNBMTsuRP6 * Mention the control-handover exemption in the instructions' keyword check Review on #215 found the rewritten paragraph over-claimed: it said every method name carrying a financial keyword is refused, while the keyword check exempts the control-handover names (`transfer_ownership` and its camelCase spelling), which `exemptions_are_literal` pins. The paragraph now says so in general terms with the one example, and the test pins that example against the guard the same way it pins the three keywords, so the instructions cannot name an exemption the guard does not honour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PruVfYuaS7ZAgNBMTsuRP6 * Drop the keyword scope's exemption list The list held one name in two spellings, `transfer_ownership` and `transferOwnership`, kept on the OpenZeppelin `Ownable` convention alone: no mainnet interface declaring either could be found when review asked for sources. The list's own rule was that a name with no source does not get in, so it goes (at the maintainer's direction), and a name that carries a keyword is now refused whatever the app means by it. `financial_keyword` no longer consults a list; the module doc records that the exemption was tried and why it went; the tests pin the ownership spellings as refused (`no_name_is_exempt`), the keyword list's well-formedness check loses its exemption half, and the server instructions, their pinning test, the README and both directory submission docs no longer mention an exemption — the test now pins that the instructions mention none and that `transfer_ownership` is refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VP2vTGhAZfkTv1hwmKERA3 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
aterga
added a commit
that referenced
this pull request
Oct 28, 2026
All three crates share one version. The minor bump carries the financial guard's new scopes (#213, #214, #215): update calls that 0.5.1 allowed — any method name carrying a financial keyword, every update to a canister whose interface declares ICRC-1/ICRC-2 methods, and every update to a canister whose interface is missing or unreadable — are refused now, which is a behaviour change for anyone embedding the crate. Claude-Session: https://claude.ai/code/session_01PruVfYuaS7ZAgNBMTsuRP6 Co-authored-by: Claude <noreply@anthropic.com>
6 of 7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
The financial-transactions guard decided everything from the method name and the canister id, so a ledger missing from the curated finance-canister list was reachable through any method the list did not name. This change makes the guard read the canister itself: once
canister_update_callhas the canister's Candid interface in hand, a canister whose interface declares ANYicrc1_*/icrc2_*method is a financial canister and every update call to it is refused — which covers every ICRC ledger and the ICP ledger with no list to keep current. A call addressed to any othericrc1_*/icrc2_*method by name is refused before any network work as well.An update call needs the canister's published interface (per review and the maintainer's direction): a canister that publishes none (a certified absence, which also covers an id no canister occupies), one whose published bytes or text the parser cannot read, and one whose read fails are each refused before anything is encoded or signed, with a message that says which it was and what follows (publish the interface; retry only for the failed read). So the interface scope always has the interface it checks against, and no caller-supplied
candidis consulted for an update call — that argument is removed fromcanister_update_call(canister_querykeeps it; a query cannot commit state).Plan 1 of 2 (the companion change, #214, refuses keyword-named methods and is stacked on this one).
Related issues
Follows #154 and #163.
Changes
compliance.rs: a new interface scope,disallowed_update_on_ledger_interface, decided from the declared method set — anyicrc1_/icrc2_prefix (literal, underscore included, so ICRC-10 / ICRC-21 names do not match) makes the canister financial; it reuses the listed-canister destination register through a shareddestination_refusalhelper and names the standard(s) and the methods that established them. A last pre-network scope refuses a call addressed to any other ICRC-1 / ICRC-2 method by name, with a register that disclaims the call.unchecked_update_refusal, withMissingInterface(NotPublished,Unreadable,ReadFailed), is the refusal for an update whose interface is not in hand: it names the method, says which case it is and what follows, and claims only that the check could not run. Module and function docs describe the six scopes, the must-publish rule, and the remaining limit.calls.rs:read_candid_servicereturnsCandidServiceRead—Published(the.didtext),NotText(published bytes that are not text, refused as unreadable),NotPublished(ic-agent'sLookupPathAbsent, the certificate proving the path is not in the state tree) orFailed(every other error, with its text);resolve_did/candid_serviceare re-implemented on top with unchanged semantics for the read-only paths;candidis removed fromCanisterUpdateCallArgs.declared_methodsreads a.did's method names throughTypeEnv::as_service(bare service, service constructor, alias), behind the existing CWE-674 guard and deep-stack parse,Noneon unreadable text (which the update path refuses); added to the alias-chain entry-point test.tools.rs:canister_update_callreads the interface withread_candid_serviceright after the discoverability gate (reading an interface is a contact with the canister) and refuses, before encoding and signing, unless the read isPublishedand the text parses; the interface scope then runs on the read already made for encoding. The tool description says the canister must publish its interface.static/candid-textual-syntax.md(served bycandid_syntax_guideand thecandid://textual-syntaxresource): the literal-inference fallback is scoped tocanister_query; an update call always has the interface or is not sent.icrc1_transfermoves from the plainappfixture to a new declaredledgerfixture;icrc1_transferon it is refused before the network andset_messageon it is refused from its interface, each with an unchanged store and a positive control on the replica; reads of it still work; an update to the canister that publishes no interface is refused with the publish-it message, and a canister whose published interface is truncated Candid, a canister id no subnet serves, and an in-range id whose canister was deleted are each refused with their own wording — none is reached.Testing
cargo build --locked --workspace --all-targetscargo test --locked --workspace --all-targets(71 + 8 + 10 + 197 + 21)cargo fmt --all -- --check/cargo clippy --all-targets(9 pre-existing warnings, none new, with and without--features e2e)POCKET_IC_BIN=… cargo test --locked -p imcp2-core --features e2e e2e_canister_tools -- --nocapture(4 passed against PocketIC 15.0.0)npm test --prefix monitoring/mcp-status(dashboard unchanged)Checklist
Generated by Claude Code