Restrict update calls to canisters an app declares (service discoverability) - #166
Draft
aterga wants to merge 5 commits into
Draft
Restrict update calls to canisters an app declares (service discoverability)#166aterga wants to merge 5 commits into
aterga wants to merge 5 commits into
Conversation
…bility) Reading the Internet Computer is open; writing is not. A state-changing call runs against someone's live application, and a canister being publicly callable is not a statement by its operators that they want an agent driving it. `canister_update_call` is now made ONLY to a canister the owning app DECLARES in its service-discoverability manifest at /.well-known/ic-architecture — publishing that manifest is, per the published guide, exactly how an app's operators opt in. The manifest lives at the app's origin, not on chain, so the tool takes a new `app_url` argument (open_app already returns it) naming the app whose manifest authorizes the write; it falls back to `derivation_origin` when the app serves its manifest at that same origin. A successful call echoes `declared_by`/`declared_at`, so a write's provenance is visible in the reply, not only to the gate. The new `discoverability` module holds the policy and every refusal text, alongside `compliance` (the financial-methods gate). Refusals distinguish their four causes — no origin given, origin unreachable, no manifest published, published but this canister is not in it (that one lists what the app does declare) — and each one names the standard path, links the guide so an app owner can act on a relayed refusal, and states that reads are unaffected, so an agent answers what it can rather than concluding the whole app is off limits. The module doc is explicit about what the gate is not: it is a consent-and-provenance gate, not proof of ownership (whoever controls a domain controls what its manifest says), and not a substitute for the canister's own authorization. Supporting protocol work in `discover`, needed because the gate keys on the protocol rather than on this server's earlier proposal: - /.well-known/ic-architecture is now read during discovery, ranked just under the App Connect main-backend meta and above the legacy path. The legacy /.well-known/ic-app.json is still read, at lower authority and still gate-eligible, so the apps that adopted the proposal are not cut off the day the standard path lands — the one known adopter today publishes only that path. - /.well-known/ii-derivation-origin (Layer 5) now takes precedence over the legacy manifest's `derivation_origin` field. Without it, an app that follows the published guide and pins a custom derivation origin would resolve to the wrong principal — a bug the gate would otherwise have created, since it makes the protocol the thing writes key on. - The three well-known probes on each path run concurrently, so honouring both the protocol and the legacy path costs no extra round trip. - Manifest labels fall back to the protocol's `name` when no `role` is given, so a spec-shaped manifest yields a label, not a bare principal. Everything fails closed on the misconfiguration the guide calls out as the most common one: an SPA catch-all answering /.well-known/* with index.html, which returns 200. A manifest body that is not a manifest document reads as "publishes none" rather than "declares nothing", and an HTML first line is not an origin, so a mis-served Layer 5 file leaves the app deriving against its visible origin instead of garbage. Verified against the live reference app, which today serves exactly that shape at both protocol paths. Reads are untouched: canister_query, get_canister_candid, get_canister_api_doc, the OQL surface and every discovery tool work on any canister, as before. Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB Co-authored-by: Claude <noreply@anthropic.com>
…t refusals
An independent design review of the gate confirmed the shape but found five
things worth fixing. All are small and none changes which canisters
authorize a write.
Redirect pinning. A manifest is now honoured only when the origin that
ANSWERED is the origin we PROBED. The shared redirect policy already
refuses a cross-domain hop, but it permits same-host different-port hops
and hops to global IP literals, so without this an origin could 3xx and
have another origin's declaration attributed to it -- making the
`declared_by` provenance the caller is shown a true-looking but wrong
statement. A skipped redirect is logged.
Sanitized, capped error echo. `unreachable_refusal` interpolated the raw
transport error, which for a hostile origin can carry an attacker-chosen
TLS certificate subject or redirect URL straight into the model's
context. It now goes through the same control-char scrub the manifest
labels use, capped at 200 chars (CWE-150).
Truncation no longer masquerades as absence. `manifest_canister_ids`
silently dropped entries past the 100-entry cap, so declared entry 101
drew a "not declared" refusal -- a false statement about the app. The
overflow is carried and reported, and the refusal blames the manifest's
length rather than the app's declaration.
The SPA catch-all is named, not just detected. When the protocol path
answers 2xx with something that is not a manifest, the refusal now says
what it answered with and that the usual cause is an SPA catch-all
returning index.html, with the fix (exempt /.well-known/* from the
rewrite). The guide calls this the most common failure; an operator told
only "absent" would go looking for a file that is already there. This
changes the message, not the verdict -- the shape check already rejected
the HTML.
The no-manifest refusal stops the retry loop. It previously read as
"try a different app_url", which invites an agent that already has the
right origin to burn turns re-resolving it. It now says outright that
re-running open_app will not change anything and to stop.
Also: legacy-path authorizations are logged with the origin, so the
fallback can be retired on evidence rather than on a guess (today it is
the only path that authorizes anything in the known-app registry); a note
on `mgmt_call` records why the deferred management writes sit outside
this gate and what to revisit if that group is ever served; and the
README's SSRF section now says `canister_update_call` makes an untrusted
outbound fetch, which was true the moment the gate landed and was not
stated.
New tests pin the negative space too: no gate refusal may borrow the
vocabulary of the read-only-session rejection ("reconnect", "Actions &
questions") or of the financial-methods refusal ("oisy.com"), since an
agent sent down either path would ask the user to fix something that is
not broken.
Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB
Co-authored-by: Claude <noreply@anthropic.com>
The guide says an `id` "is required and must be a canister principal". The gate only checked that it parsed as A principal, which is a weaker rule than the one the protocol states: user principals (29 bytes, 0x02), the anonymous principal (1 byte, 0x04) and the management canister aaaaa-aa (an EMPTY blob) all parse happily. The management canister is the one that matters: without this, a manifest declaring aaaaa-aa would authorize canister_update_call against it. Opaque canister ids are 10 bytes with the 0x01 type tag, verified against real ids rather than re-derived from memory, and every real app canister is one. Enforced on the gate's read of a manifest only -- the display path that feeds discovery listings is unchanged, since showing an odd declared id is informative while acting on one is not. Deliberately the spec's own type rule and not a policy list of ours: WHICH canisters an app may declare is the app's business, but WHAT KIND of principal an `id` may be is the protocol's. Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB Co-authored-by: Claude <noreply@anthropic.com>
marc0olo
pushed a commit
to dfinity/developer-docs
that referenced
this pull request
Aug 28, 2026
…CP MCP (#372) ## Summary Adds a visible disclaimer to `docs/guides/frontends/service-discoverability.md` stating that adopting the protocol opts an app in to being discovered by ICP MCP, under its terms of service and privacy policy. The protocol's whole purpose is that an agent handed only an app's URL can work the rest out unattended, so publishing the manifest is the moment the app becomes usable by agents that read it. The page did not say so. It does now, above the fold, before any of the instructions it qualifies. Wording: > **Adopting this protocol opts your app in** > > By implementing this service discoverability protocol for your app, you agree that your app should be discovered by [ICP MCP](https://internetcomputer.org/icp-mcp/), that you accept its [terms of service](https://internetcomputer.org/icp-mcp/terms/), and that you comply with its [privacy policy](https://internetcomputer.org/icp-mcp/privacy-policy/). Publishing the Layer 1 manifest at `/.well-known/ic-architecture` is the act that opts your app in: the layers are otherwise independently adoptable, and the rest of them describe how an agent works with an app once it has been discovered. Two smaller changes came out of review: the page `description` now names ICP MCP, and the opening line speaks of "an agent working through ICP MCP" rather than an abstract AI agent. ## Structural decisions - **Placement:** immediately after the intro paragraph and before `## The five layers`, so it is read before the instructions it qualifies rather than being buried under them. - **Aside type:** `:::caution` (used 11 times elsewhere in `docs/`) rather than `:::note`, since the point was that it be clearly visible, and it carries an agreement rather than an aside. - **Naming the trigger:** without the second sentence, "implementing this service discoverability protocol" sat in tension with the page's own "each layer is independently adoptable", leaving it unclear whether exposing only `getApiDoc` or an `ii-derivation-origin` also opts an app in. It does not, and naming the manifest makes the page agree with what the connector actually keys on ([dfinity/imcp2#166](dfinity/imcp2#166) gates writes on exactly that document). - **Why the consent statement is not in the frontmatter `description`:** Starlight puts that field only into the page's `description` and `og:description` meta tags, which cannot render markdown, so links there would show as literal brackets in search results and link previews. The statement lives in the aside, where it renders and where a reader sees it. - **Links:** all three target `internetcomputer.org/icp-mcp/`, verified live (HTTP 200; the pages render "ICP MCP Terms of Service" and "ICP MCP Privacy Policy"). ## Verified - `npm run build` passes on every commit; the page renders the aside as `starlight-aside--caution` with all links intact, confirmed on the deployed preview. - No em-dashes, no `dfx`, no banned vocabulary (checked against `icp-brand-voice`). - The two remaining "AI agents" in the built page come from the site-wide `agent-signaling` blockquote and llms.txt footer, injected into every page by the `agent-docs` integration; out of scope here. - `package-lock.json`, touched by `npm install` during setup, is deliberately not included. ## Note for reviewers @sea-snake approved at `b7e5e2b`. Two further commits landed after that, both addressing review comments on this PR (`d9fe5df`, `389828e`), so the approving commit is no longer the head and the approval may need refreshing. --------- Co-authored-by: Claude <noreply@anthropic.com>
…y-protocol-restrictions-njf4x3
main now ships a service-discoverability skill (#164's static bundle): the deploy-time recipe for generating /.well-known/ic-architecture with real per-environment canister IDs. That is exactly what someone who has just hit this refusal needs, and the person reading it is sometimes the one who can ship the manifest. So the refusal names skill://service-discoverability alongside the published guide, mirroring how compliance.rs points at skill://icp-cli and skill://cycles-management for the operations it declines. "Publish a manifest" is a much weaker handoff than the recipe for generating one. Also merges main, which moved under this branch while it was open. No textual conflict, but main's series touched tools.rs, IcTools::new and the README, so the merge was verified rather than assumed: it builds and the full suite passes (264 tests), and clippy is unchanged at 11. The skill and this gate agree, which is worth stating: the skill tells an app how to publish the manifest, and the gate makes publishing it the act that permits a write. Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB Co-authored-by: Claude <noreply@anthropic.com>
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
Reading the Internet Computer is open; writing is not. A state-changing call runs against someone's live application, and a canister being publicly callable is not a statement by its operators that they want an agent driving it.
canister_update_callis now made only to a canister the owning app declares in its service-discoverability manifest at/.well-known/ic-architecture. Per the published guide, publishing that manifest is exactly how an app's operators opt in: it says "these are my canisters; an agent handed my URL may work them out and use them". An app that publishes none stays fully readable and discoverable, but cannot be written to through this server.The manifest lives at the app's origin, not on chain, so
canister_update_calltakes a newapp_urlargument (open_appalready returns it) naming the app whose manifest authorizes the write, falling back toderivation_originwhen the app serves its manifest at that same origin. A successful call echoesdeclared_by/declared_at, so a write's provenance is visible in the reply, not only to the gate.Supporting protocol work in
discoverwas needed because the gate keys on the protocol rather than on this server's earlier proposal:/.well-known/ic-architectureis now read during discovery, and/.well-known/ii-derivation-origin(Layer 5) now takes precedence over the legacy manifest'sderivation_originfield. Without that second piece, an app that follows the published guide and pins a custom derivation origin would resolve to the wrong principal — a bug this change would otherwise have created, since it makes the protocol the thing writes key on.This pairs with dfinity/developer-docs#372 (merged), which states on the guide itself that publishing the Layer 1 manifest is the act that opts an app in to ICP MCP, under its terms and privacy policy. The doc and the gate now say the same thing.
What this gate is not, stated in the module doc and the README so nobody over-claims it:
canister_query,get_canister_candid,get_canister_api_doc, the OQL surface and every discovery tool are untouched.Related issues
None linked.
Changes
discoverabilitymodule — the policy and every refusal text, sitting alongsidecompliance(the financial-methods gate). Refusals distinguish their causes (no origin given / origin unreachable / no manifest published / published but this canister is not in it, which lists what the app does declare); each names the standard path, links the guide, points atskill://service-discoverabilityfor the deploy-time recipe, and states that reads are unaffected.canister_update_callgainsapp_urland returnsdeclared_by/declared_at. The gate runs after the financial-methods refusal and before fetching the target's Candid, so a canister that will not be written to is not touched at all.discoverreads/.well-known/ic-architecture(ranked just under the App Connect main-backend meta, above the legacy path) and/.well-known/ii-derivation-origin. The well-known probes on each path run concurrently, so honouring both the protocol and the legacy path costs no extra round trip. Manifest labels fall back to the protocol'snamewhen noroleis given./.well-known/*withindex.html, at 200, so status alone would not catch it). A body that is not a manifest document reads as "publishes none" rather than "declares nothing", and an HTML first line is not an origin, so a mis-served Layer 5 file leaves the app deriving against its visible origin instead of garbage. The refusal names the misconfiguration and its fix.declared_bya true-looking but wrong statement.id"must be a canister principal"; the gate enforces that (10-byte opaque,0x01tag) rather than merely "parses as a principal". Notably this stops a manifest declaring the management canisteraaaaa-aa, whose blob is empty and whichPrincipal::from_textaccepts.canister_update_callmakes an untrusted outbound fetch, which became true the moment the gate landed. The server-level instructions gain a "WRITES GO ONLY TO APPS THAT OPTED IN" paragraph. A note onmgmt_callrecords why the deferred management writes sit outside this gate.main(3473724), which moved under this branch. No textual conflict, but that series touchedtools.rs,IcTools::newand the README, so the merge was verified rather than assumed: it builds and the full suite passes.Open questions for the maintainer
Deliberately not decided in this PR:
opencloud.organdoisy.comanswer200 text/htmlon every well-known path; the only origin that authorizes anything is MULTI/DEX, and only via the legacyic-app.json. So the legacy fallback is load-bearing today — dropping it would make the write tool dead everywhere. Kept, with atracing::warnnaming the origin on every legacy authorization so retirement can be decided on evidence. When does legacy stop authorizing, and on what trigger?opencloud.orgpublishes no manifest, so the worked examples indocs/anthropic-directory-submission.mdanddocs/openai-directory-submission.md— including the "exercisecanister_update_call" checklist item — are unsatisfiable against it. Either that app publishes a manifest, or those examples move to one that does. Not edited here.mainjust re-dated it to August 28. Two specific spots indocs/icp-mcp-privacy-policy-draft.md:Legally reviewed text with a published dated version at
src/assets/privacy-policy.html; flagged, not edited.app_url=https://evil.example(whose manifest declares canister V) plusderivation_origin=https://gooddapp.comwrites to V signed as the user's good-app principal. Closing it means requiring the app origin's own resolved derivation origin to match — which needs a multi-origin set (13 of 17 known-app entries havederivation_origin != application_origin; NNS's is a different canister) or it false-refuses NNS and Oisy. Worth doing, but it is a behaviour change that deserves its own decision.manage_neuronon NNS governance is irreversible and value-moving and is not covered bycompliance.rs. A denylist would refuse it even to an app that legitimately declares it (the NNS dapp being exactly that case). This is a pre-existingcompliance.rsgap that the gate narrows but does not close.version. Currently ignored. Refusing an unknown major fails closed but bricks every write the day2.0.0ships; the spec's own evolution rule is "unknown fields must be ignored". MULTI/DEX omits the field entirely.Testing
cargo build --locked --all-targetscargo test --locked --all-targets— 264 pass, 0 fail, on the merge with currentmain. New: 14 unit tests over the refusal texts and the gate's fail-closed parsing, 3 live-network tests over the gate itself, and 2 pinning the gate's statement on the tool description, the input schema, and the server instructions. Negative pins assert no gate refusal borrows the vocabulary of the read-only-session rejection ("reconnect", "Actions & questions") or of the financial-methods refusal ("oisy.com"), since either would send the user to fix something that is not broken.cargo clippy --locked --workspace --all-targets— 11 warnings, 10 pre-existing. The one added isrepeat().take()in a new test, matching the identical idiom in the test 80 lines above it;repeat_nthere would leave two adjacent tests spelling the same thing differently./.well-known/ic-architecturewith the SPA shell today — the exact case the fail-closed tests pin.cargo fmt --all— deliberately not run:mainalready carries 491 rustfmt deviations, the house style is hand-formatted, and CI does not check it, so reformatting would bury the change.npm test --prefix monitoring/mcp-status— not applicable, the dashboard is unchanged.Checklist