diff --git a/README.md b/README.md
index 056d560..80659e4 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,16 @@ encoding/decoding and signing against the IC via
[`ic-agent`](https://github.com/dfinity/agent-rs). The MCP layer is the
[official Rust SDK](https://github.com/modelcontextprotocol/rust-sdk) (`rmcp`).
+**Writes go only to apps that opted in.** Reading the Internet Computer is open —
+every read tool here works on any canister. A *state-changing* call runs against
+someone's live application, so `canister_update_call` is made **only** to a
+canister the owning app declares in its
+[service-discoverability](https://docs.internetcomputer.org/guides/frontends/service-discoverability/)
+manifest at `/.well-known/ic-architecture`. Publishing that manifest is how an
+app's operators opt in; an app that publishes none stays fully readable and
+discoverable, but cannot be written to through this server. See
+[Writes are gated on the discoverability manifest](#writes-are-gated-on-the-discoverability-manifest).
+
**Not for financial operations.** IMCP2 is infrastructure tooling for reading,
building, and operating canisters — it is not a wallet or trading tool, and
financial operations (token transfers, spending approvals, payments, trades)
@@ -147,14 +157,14 @@ results).
| Tool | Args | Returns |
|------|------|---------|
| `open_app` | `app` (name **or** URL) | **One-call entry point** when a user names/links an app: resolves the Internet Identity `derivation_origin` *and* discovers the canisters behind it, together. A name or bare host is matched to the known-app registry first (so a wrong-TLD guess repairs to the canonical URL); an explicit `https://` URL is resolved as given. An unknown bare name is *refused*, and so is a URL that would need its own origin assumed as the derivation origin while showing no IC evidence (never guessed). Also probes the app's own canisters and reports per-canister `oql`/`api_doc_available` capability flags — for **up to eight** eligible canisters, with both fields *omitted* (not false) on any beyond that — plus a data-access note (which canister is read through the OQL path, and the origin that path requires). Wraps `resolve_app` + `discover_app_canisters`; no auth |
-| `discover_app_canisters` | `domain` | Canister ids behind a web domain — app-declared App Connect metadata first (`/ai-connect.html`'s `ic:canister-id` meta, `/.well-known/ic-app.json` manifest), then the frontend via `x-ic-canister-id` and backend candidates via `/env.json` + JS-bundle mining — each with provenance, its IC dashboard label/type where known, and (for the app's own canisters) `oql`/`api_doc_available` capability flags from a one-shot Candid probe |
+| `discover_app_canisters` | `domain` | Canister ids behind a web domain — app-declared metadata first (the `/.well-known/ic-architecture` service-discoverability manifest, and the same manifest at the legacy `/.well-known/ic-app.json` path), then the frontend via `x-ic-canister-id` and backend candidates via `/env.json` + JS-bundle mining — each with provenance, its IC dashboard label/type where known, and (for the app's own canisters) `oql`/`api_doc_available` capability flags from a one-shot Candid probe |
| `get_canister_candid` | `canister_id` | 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` | 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), `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` / `acted_as_principal` |
-| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_app`). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) and the NNS/SNS governance method `manage_neuron` (neuron staking and disbursement) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) and the cycles-minting canister's funding-completion methods (`notify_top_up`, `notify_create_canister`, `notify_mint_cycles`, `create_canister`) on those canisters; and **every** update call is refused on the financial-service canisters the guard carries — all to protect the user. The refusal directs the user to perform the operation outside the connector, in a trusted interface they control — or, for canister creation and funding, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. The policy is stated in the server-level instructions, deliberately not in any tool description. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` |
+| `canister_update_call` | `canister_id`, `method`, `args` (textual Candid), `app_url?`, `derivation_origin?`, `account?`, `candid?` | Make an UPDATE (state-changing) call; reply as textual Candid; anonymous, or as your account at an app (identified by its canonical II `derivation_origin`, obtained once from `open_app`/`resolve_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 [Writes are gated on the discoverability manifest](#writes-are-gated-on-the-discoverability-manifest). When `derivation_origin` is also given, the two must belong to the same app — the app at `app_url` is resolved to the derivation origin II derives its users from, and a mismatch is refused rather than signed. The reply echoes `declared_by` / `declared_at` (which origin authorized the write, and at which path). **Financial transactions are refused**: the ICRC-standard transfer/approval methods (ICRC-1/ICRC-2 and the ICRC-4/-7/-37 equivalents) and the NNS/SNS governance method `manage_neuron` (neuron staking and disbursement) are disallowed on every canister, and the ICP and cycles ledgers' own value-moving methods (the legacy `transfer`, `withdraw`, the `create_canister` spends) and the cycles-minting canister's funding-completion methods (`notify_top_up`, `notify_create_canister`, `notify_mint_cycles`, `create_canister`) on those canisters; and **every** update call is refused on the financial-service canisters the guard carries — all to protect the user. The refusal directs the user to perform the operation outside the connector, in a trusted interface they control — or, for canister creation and funding, with the [icp CLI](https://github.com/dfinity/icp-cli) in their own terminal. The policy is stated in the server-level instructions, deliberately not in any tool description. `candid` is the same `.did` fallback as on `canister_query`, used when the interface isn't published on-chain. Echoes `derived_for_origin` / `requested` / `acted_as_principal` |
| `get_app_principal` | `derivation_origin`, `account?` | The principal you act as at an app, without a call. Identify the app by its `derivation_origin` (from `open_app`/`resolve_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, last-used, and the derivation origin they were listed for. Identify the app by its `derivation_origin` (from `open_app`/`resolve_app`) |
-| `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared in `/.well-known/ic-app.json`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` |
+| `resolve_app` | `app_url` | Resolve an app URL to its Internet Identity derivation context: `application_origin`, the `derivation_origin` to use (declared by the app in `/.well-known/ii-derivation-origin`, else in the legacy `/.well-known/ic-app.json`, else a built-in known-app value, else assumed = app origin — flagged via `derivation_origin_source`: `declared`/`known`/`app_url_default`, with `application_is_ic` echoing the gateway evidence), and the app's `alternative_origins` (informational). An origin with **no IC evidence** that would need the `app_url_default` assumption is **refused** (guessed-domain guard, with a "did you mean" repair when the host resembles a well-known app). Does not return a principal (no account chosen) or require auth — pass the `derivation_origin` to `get_app_principal`/`list_app_accounts` |
| `icp_oql_guide` | — | The OQL query-surface dialect guide (for canisters where `get_canister_candid` reports `oql: true`): the JSON query object, predicate grammar, edges, and paged result shape. The entity/field names come from `get_canister_oql_schema` and queries run through `canister_query` (the `oql` argument) |
| `get_canister_oql_schema` | `canister_id`, `derivation_origin`, `account?` | The canister's OQL schema catalogue (entities, primary keys, fields, edges) as JSON — wraps its `schema` method — plus a ready-to-run `canister_query` example per entity. **`derivation_origin` is required**: this server rejects an anonymous read (for now) with guidance — its own rule, not an inference about the canister — rather than calling `schema` anonymously and returning an empty list |
@@ -182,7 +192,7 @@ Acting **for the user** at an app:
unrelated or squatted site. The tool enforces this: a bare *unknown* name is
refused (find the real URL — web-search or ask the user), and a URL that resolves
to `app_url_default` while showing **no IC evidence** (no valid `x-ic-canister-id`
- gateway header, no `ic-app.json` derivation origin) is refused too; when the host
+ gateway header, no declared derivation origin) is refused too; when the host
resembles a known app the error names it and gives the real URL (a
"did you mean" repair). For a single step, the narrower tools remain:
**`resolve_app(url)`** (origin only), **`discover_app_canisters(url)`**
@@ -199,8 +209,9 @@ Acting **for the user** at an app:
passing the `derivation_origin` — an OQL read **requires** it (an anonymous per-app
read is rejected for now, and a Candid `method` query is rejected on an OQL
canister). Otherwise pass a Candid `method`.
-7. **Act** with `canister_update_call`, passing `derivation_origin` + `account`
- to act as the user.
+7. **Act** with `canister_update_call`, passing the `app_url` from step 0–2 (it
+ gates the write on the app's declared manifest) plus `derivation_origin` +
+ `account` to act as the user.
Genuinely public reads via a `canister_query` Candid `method` query or the
public-metadata tools (`get_canister_candid`, `discover_app_canisters`) skip steps
@@ -209,42 +220,131 @@ independent of the identity steps (3/4), so they can run in parallel. Managing y
**own** canisters is not part of this connector: create and manage them with the
[`icp` CLI](https://github.com/dfinity/icp-cli) in your own terminal.
-### App-declared canister metadata (App Connect)
+### App-declared canister metadata
-Apps that adopt **Internet Computer App Connect** serve a bridge page at
-`/ai-connect.html` whose ` ` declares the app's
-**main backend** canister (spec §4.7/§6.1). Discovery reads that meta from the
-raw served markup (no JavaScript is executed) and reports it as the
-top-priority finding, labelled `main backend (App Connect)`.
+The authoritative statement about which canisters an app comprises is the one
+the app publishes itself. Two such statements are read, both from bytes the app
+serves:
-The App Connect spec **defers** multi-canister enumeration (§6.3: how an app
-lists *all* the canisters it comprises, with roles). To fill that gap, this
-server also reads a proposed convention: a `/.well-known/ic-app.json` manifest
-the app serves itself —
+**The service-discoverability manifest** (`/.well-known/ic-architecture`) —
+[Layer 1 of the protocol](https://docs.internetcomputer.org/guides/frontends/service-discoverability/)
+— enumerates *every* canister the app comprises, with roles:
```json
{
- "derivation_origin": "https://.icp0.io",
+ "version": "1.0.0",
"canisters": [
- { "id": "aaaaa-…-cai", "role": "backend", "description": "orders + inventory API" },
- { "id": "bbbbb-…-cai", "role": "ledger" }
+ { "id": "aaaaa-…-cai", "name": "backend", "role": "the backend",
+ "description": "orders + inventory API; call getApiDoc() first" },
+ { "id": "bbbbb-…-cai", "name": "frontend", "role": "the frontend" }
]
}
```
-Each entry needs an `id` (a canister principal); `role` and `description` are
-optional and become the finding's label (`role — description`). Unknown fields
-are ignored, so the format can grow. Both sources are the app's own claim about
-its composition — stronger than anything mined from client code — but an
-SPA catch-all serving HTML at these paths simply yields no findings (no meta
-tag; JSON parse fails), and every id is still validated as a principal.
+Each entry needs an `id` (a canister principal); `name`, `role`, and
+`description` are optional and become the finding's label (`role — description`,
+falling back to `name`). Unknown fields are ignored, so the format can grow.
+The write gate holds `id` to the protocol's own type rule — an entry must be a
+**canister** principal (a 10-byte opaque id) to authorize anything, so a manifest
+cannot declare a user principal, the anonymous principal, or the management
+canister `aaaaa-aa` (all of which parse as principals) and have a write follow.
+This manifest is also the **only** thing that permits a write — see
+[Writes are gated on the discoverability manifest](#writes-are-gated-on-the-discoverability-manifest).
+
+Before the protocol was published this server proposed the same document at
+`/.well-known/ic-app.json`, with an extra top-level `derivation_origin` field.
+That path is still read for discovery, at lower authority, so those apps stay
+legible — but it does not authorize a write (see below); new apps should publish
+`/.well-known/ic-architecture`
+and, if they pin a custom derivation origin, `/.well-known/ii-derivation-origin`
+(the protocol's Layer 5 — one canonical `https://host` on a single line, which
+takes precedence over the legacy field).
+
+Both are the app's own claim about its composition — stronger than anything
+mined from client code. Both also **fail closed** on the most common
+misconfiguration, an SPA catch-all serving `index.html` at these paths: the JSON
+parse fails, and the derivation-origin file's first line is not an origin. Every
+id is validated as a principal before it is kept.
+
+### Writes are gated on the discoverability manifest
+
+Reading 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. So `canister_update_call` is
+restricted to canisters an app **declares** in its `/.well-known/ic-architecture`
+manifest. Publishing that manifest is a deliberate act, and per the protocol guide
+it is exactly how an app's operators say "these are my canisters; an agent handed
+my URL may work them out and use them".
+
+**Only the standard path authorizes.** The legacy `/.well-known/ic-app.json`
+document is still read during discovery, but it does not permit a write: the apps
+serving it adopted a proposal this server made before the protocol existed, under
+different terms, and never agreed to the ones publishing the standard manifest now
+signifies. Consent that was never given cannot be inherited from a path this server
+invented. An origin serving only the older document gets a refusal of its own,
+naming the document it *does* publish — reporting it as publishing nothing would
+send its operators hunting for a file that is already there — and saying that
+serving the same JSON at `/.well-known/ic-architecture` is the whole fix.
+
+**The manifest and the identity are bound.** `app_url` picks which manifest is
+read; `derivation_origin` picks whose principal signs. Left unbound those are
+separable, and separable is exploitable: publishing a manifest is free and the
+gate deliberately does not prove ownership, so an attacker's origin could declare
+someone else's canister while the call went out under the principal the user holds
+at an app they actually trust. So when a call carries both, the app at `app_url`
+is resolved to the derivation origin Internet Identity derives its users from, and
+a call naming a different one is refused. The comparison is against what the app
+*resolves to* — its declared Layer 5 origin, else a known-app value, else its own
+origin — so the many apps whose derivation origin differs from their website (13
+of 17 in the built-in registry) still pass, and Internet Identity's own
+`ii-alternative-origins` rule is enforced on the way, so an app cannot simply
+claim another's identity to satisfy the check. An attacker's manifest can then
+only authorize writes made as the attacker's own app identity, which is worth
+nothing to them. `open_app` returns a matching pair, so the normal flow never
+trips this.
+
+The manifest lives at the app's origin, not on chain, so the tool has to be told
+which app owns the target: that is the `app_url` argument (`open_app` returns it),
+falling back to `derivation_origin` when the app serves its manifest at that same
+origin. The gate then fetches the manifest — under the same SSRF hardening and
+size caps as the rest of discovery — and refuses unless the target is listed.
+Every refusal names the standard path, links the guide, distinguishes its cause
+(no origin given / origin unreachable / no manifest published / only the legacy
+document published / published but this canister is not in it — that last one lists
+what the app *does* declare), and says
+explicitly that reads are unaffected, so an agent answers what it can rather than
+concluding the whole app is off limits. A successful call echoes `declared_by` and
+`declared_at`, so a write's provenance is visible in the reply.
+
+Three things this gate is not:
+
+- **It is not proof of ownership.** Whoever controls a domain controls what its
+ manifest says, so a manifest can name a canister its publisher does not own.
+ What the gate establishes is that *someone* published a document, at an origin
+ the caller named, claiming that canister as part of their app — and that a
+ write can be traced back to that claim. Two limits worth stating outright: an
+ anonymous write skips the identity binding (there is no app identity to
+ protect), and an authenticated one binds to the *caller's* app while still
+ reaching any victim method that accepts an arbitrary principal. Neither grants
+ a capability an attacker lacked — the IC accepts ingress from anywhere, so both
+ calls can be sent with an ordinary agent — so what the gate withholds is this
+ connector's willingness to make them **on a user's behalf**, and the binding is
+ what keeps the user's own app principals out of it. Closing the rest needs an
+ association the *target* attests to, which the protocol does not define today.
+- **It is not a substitute for the canister's own authorization.** The IC still
+ decides what the calling principal may do; this only decides what this server
+ is willing to send.
+- **It is not a read restriction.** `canister_query`, `get_canister_candid`,
+ `get_canister_api_doc`, the OQL surface and every discovery tool work on any
+ canister, exactly as before.
Discovery fetches are **SSRF-hardened** (CWE-918). Only `https` URLs with a real
host are fetched, and every outbound fetch runs under a redirect guard (a 3xx may only
go to a **globally-routable** IP or the same host, never a different private
target; capped at 10 hops) with per-body and aggregate size caps, so a hostile or
accidental large body can't exhaust memory. The untrusted **user-supplied** site
-fetches (an app origin from `discover_app_canisters`, `open_app`, or `resolve_app`)
+fetches (an app origin from `discover_app_canisters`, `open_app`, `resolve_app`, or
+the `app_url` the write gate checks in `canister_update_call`)
additionally resolve the target host up front and **pin** the connection to that
validated globally-routable address, so a name resolving to a
private/loopback/link-local address is refused and re-resolution can't rebind
@@ -252,12 +352,23 @@ mid-flight. Fixed public-host enrichment (the IC dashboard)
uses the redirect guard but is not separately address-pinned. No JavaScript is
executed, and every extracted id is validated as a principal.
-The optional top-level **`derivation_origin`** is the app's declaration of the
-Internet Identity derivation origin its frontends pin (see the identity section
-above). It is the only authoritative way for `open_app` / `resolve_app` to learn a
-**custom** derivation origin from an app URL — there is no reverse lookup from an app URL to it —
-so an app that uses one should declare it here; otherwise the connector assumes
-the derivation origin equals the application origin and flags that assumption.
+Note that the write gate makes `canister_update_call` an **outbound-fetching**
+tool: acting at an app now contacts that app's website (once per write) to read
+its manifest, where previously only the discovery/resolution tools did. A
+manifest is only honoured when the origin the connector **probed** is the origin
+that **answered** — a redirect can't let one origin borrow another's declaration
+and have `declared_by` name the wrong app.
+
+The optional top-level **`derivation_origin`** is this document's own declaration
+of the Internet Identity derivation origin an app's frontends pin (see the
+identity section above). The protocol moved that declaration to its own file, so
+**a new app should publish `/.well-known/ii-derivation-origin`** instead — one
+canonical `https://host` on a single line, which takes precedence over this field.
+The field is still read for the apps that shipped against it. Either is the only
+authoritative way for `open_app` / `resolve_app` to learn a **custom** derivation
+origin from an app URL — there is no reverse lookup from an app URL to it — so an
+app that pins one should declare it; otherwise the connector assumes the
+derivation origin equals the application origin and flags that assumption.
When the user names a **token, project, or service** rather than a website or
id, web search the canister id or ask the user for it.
@@ -266,7 +377,9 @@ inline.)
`canister_query` and `canister_update_call` run anonymously by default; pass a
`derivation_origin` to call as
-your account at that app. The server mints a **short-lived account delegation on
+your account at that app. (Anonymous still means anonymous *identity* — a write
+additionally needs an app origin, via `app_url` or `derivation_origin`, for the
+manifest check above; there is no origin-less write.) The server mints a **short-lived account delegation on
demand** using the connection's registered Internet Identity session key (see
[Domain identities](#domain-identities-on-demand)) — there is no per-app sign-in
step. `get_app_principal` returns that account's principal
@@ -291,7 +404,8 @@ token (see Auth).
> URL. A derivation origin is a *stable per-app value*, so you **resolve it once**
> and reuse it: `open_app` (or `resolve_app`) turns an app name/URL into it and
> reports how — `derivation_origin_source`: **declared**
-> (`/.well-known/ic-app.json` → `derivation_origin`), else a built-in **known-app**
+> (`/.well-known/ii-derivation-origin`, else the legacy `/.well-known/ic-app.json`
+> → `derivation_origin`), else a built-in **known-app**
> value for a few apps that pin a custom origin without declaring it (an app's
> own declaration always overrides this), else the app origin *assumed*
> (**app_url_default**). Feeding that resolved origin to an identity tool records
@@ -913,7 +1027,8 @@ mcp_get_delegation :
*domain-based* derivation: a raw `derivation_origin` is canonicalized and used
verbatim, with no recovery of a custom derivation origin from it. When an
`app_url` is passed instead, `resolve_app` resolves the derivation origin by
- precedence **declared** (`/.well-known/ic-app.json` `derivation_origin`) >
+ precedence **declared** (`/.well-known/ii-derivation-origin`, else the legacy
+ `/.well-known/ic-app.json` `derivation_origin`) >
built-in **known-app** registry > application origin, so a custom origin an app
declares (or that ships in the registry, e.g. `oisy.com`) **is** honoured, and
the app's `/.well-known/ii-alternative-origins` list is fetched and surfaced by
diff --git a/crates/imcp2-core/src/calls.rs b/crates/imcp2-core/src/calls.rs
index 353c846..91591d0 100644
--- a/crates/imcp2-core/src/calls.rs
+++ b/crates/imcp2-core/src/calls.rs
@@ -190,6 +190,18 @@ pub struct CanisterUpdateCallArgs {
/// Arguments in textual Candid syntax, e.g. `()` or `(record { owner = principal "..." })`.
#[serde(default = "default_args")]
pub args: String,
+ /// The website URL of the app that owns `canister_id` (e.g.
+ /// "https://app.example.com"), which open_app returns as `app_url`. An update
+ /// call is made only to a canister the app declares in its
+ /// service-discoverability manifest (`/.well-known/ic-architecture`), and this
+ /// is the origin that manifest is read from. Omitted, `derivation_origin` is
+ /// used as that origin instead, which is the same value when the app serves
+ /// its manifest at the origin it derives identities from. Given together with
+ /// `derivation_origin`, the two must belong to the same app: this app is
+ /// resolved to the derivation origin Internet Identity derives its users from,
+ /// and a mismatch is refused rather than signed.
+ #[serde(default)]
+ pub app_url: Option,
/// Call as the user's account at an app, identified by its exact canonical
/// Internet Identity derivation origin — not necessarily the visible URL, and
/// not an alternativeOrigins entry. open_app and resolve_app resolve an app
@@ -244,6 +256,16 @@ pub struct CanisterUpdateCallOutput {
/// Always present so a text-only client can tell an anonymous call from an
/// authenticated one.
pub is_anonymous: bool,
+ /// The app origin whose service-discoverability manifest DECLARES this
+ /// canister — the app whose published manifest authorized this write. Always
+ /// present on a successful call: without a declaration the call is refused.
+ pub declared_by: String,
+ /// The well-known path that declaration was read from. Always
+ /// `/.well-known/ic-architecture`: it is the only document that authorizes a
+ /// write, so no other value can appear on a successful call. Echoed anyway, so
+ /// a reply says where the authorization came from rather than leaving it
+ /// implied.
+ pub declared_at: String,
}
/// Arguments for `canister_query` — a READ that runs EITHER a Candid `query` method
diff --git a/crates/imcp2-core/src/discover.rs b/crates/imcp2-core/src/discover.rs
index b225342..12e9085 100644
--- a/crates/imcp2-core/src/discover.rs
+++ b/crates/imcp2-core/src/discover.rs
@@ -2,11 +2,13 @@
//! Internet Computer, folding together the patterns we've seen across apps:
//!
//! 1. **App-declared metadata** (most authoritative — the app says so):
-//! the `ic:canister-id` ` ` on `/ai-connect.html` (the App Connect
-//! bridge page, spec §4.7/§6.1 — the app's MAIN backend), and the
-//! `/.well-known/ic-app.json` manifest enumerating ALL the app's
-//! canisters with roles (our proposed convention for the spec's deferred
-//! §6.3 "multi-canister applications" — see README).
+//! the `/.well-known/ic-architecture` manifest enumerating ALL the app's
+//! canisters with roles — Layer 1 of the IC service-discoverability
+//! protocol ([`SERVICE_DISCOVERABILITY_GUIDE`]) and the ONE signal the
+//! update-call gate in `discoverability` keys on; and the legacy
+//! `/.well-known/ic-app.json` manifest of the same shape (this server's
+//! pre-protocol proposal, still read for DISCOVERY so the apps that adopted
+//! it stay legible, though it no longer authorizes a write).
//! 2. `x-ic-canister-id` response header — the frontend/asset canister. This
//! is the one universal signal (the HTTP gateway sets it).
//! 3. a runtime config asset (`/env.json`) carrying `*canister_id*` keys —
@@ -36,10 +38,10 @@ use tokio::task::JoinSet;
#[derive(Serialize, Clone, Debug)]
pub struct Found {
pub canister_id: String,
- /// A human label if one was attached (App Connect role, env.json key,
+ /// A human label if one was attached (manifest role, env.json key,
/// bundle constant name, or "frontend"); None for a bare bundle literal.
pub label: Option,
- /// Where it was found: "ai-connect.html", "ic-app.json", "header",
+ /// Where it was found: "ic-architecture", "ic-app.json", "header",
/// "env.json", "bundle:", "bundle".
pub sources: Vec,
/// IC dashboard label (e.g. "ICP Ledger"), filled in when the id is a known
@@ -57,17 +59,20 @@ pub struct Found {
pub struct DiscoveredCanister {
/// The canister's principal id.
pub canister_id: String,
- /// A human label if one was attached (App Connect role, env.json key,
+ /// A human label if one was attached (manifest role, env.json key,
/// bundle constant, or "frontend"); null for a bare bundle literal.
pub label: Option,
/// IC dashboard label (e.g. "ICP Ledger"), when the id is a known canister.
pub name: Option,
/// IC dashboard classification (e.g. "ledger"), when known.
pub kind: Option,
- /// Where it was found: "ai-connect.html" (the App Connect page's declared
- /// main canister), "ic-app.json" (the app's own canister manifest),
- /// "header", "env.json", "bundle:", or "bundle". The first two are
- /// declared by the app itself and are the most authoritative.
+ /// Where it was found: "ic-architecture" (the app's service-discoverability
+ /// manifest), "ic-app.json" (the same manifest at this server's legacy
+ /// pre-protocol path), "header", "env.json", "bundle:", or "bundle".
+ /// The first two are declared by the app itself and are the most
+ /// authoritative; only "ic-architecture" authorizes an update call, the
+ /// legacy path having been published under different terms (see
+ /// `discoverability`).
pub sources: Vec,
/// Whether this canister exposes the OQL query surface — filled in for the
/// app's OWN data canisters by a single Candid fetch during open_app /
@@ -116,7 +121,10 @@ impl From<&Found> for DiscoveredCanister {
pub fn is_app_data_candidate(c: &DiscoveredCanister) -> bool {
// Declared or mined as the app's own backend (not merely the gateway header).
let app_owned = c.sources.iter().any(|s| {
- s == "ai-connect.html" || s == "ic-app.json" || s == "env.json" || s.starts_with("bundle")
+ s == "ic-architecture"
+ || s == "ic-app.json"
+ || s == "env.json"
+ || s.starts_with("bundle")
});
// The frontend / asset canister: an explicit "frontend" label, or found ONLY
// via the gateway `x-ic-canister-id` header.
@@ -197,135 +205,65 @@ fn canisters_from_env_json(text: &str) -> Vec<(String, String)> {
out
}
-/// Pull `content` out of the first ` ` tag with the given name,
-/// reading the RAW served markup — like an App Connect connector, we fetch the
-/// page and parse it, never executing its JavaScript (spec §6.1). Tolerates
-/// attribute order and single or double quotes.
-fn parse_meta(html: &str, name: &str) -> Option {
- let bytes = html.as_bytes();
- let mut i = 0;
- while i + 5 <= bytes.len() {
- // Find the next ` .
- if !matches!(bytes.get(after), Some(b' ' | b'\t' | b'\n' | b'\r' | b'/' | b'>')) {
- i = after;
- continue;
- }
- let rest = &html[after..];
- let Some(end) = rest.find('>') else {
- // No '>' anywhere in the remainder — no complete tag can follow.
- break;
- };
- let tag = &rest[..end];
- if attr(tag, "name").as_deref() == Some(name) {
- if let Some(content) = attr(tag, "content") {
- return Some(content);
- }
- }
- i = after + end;
- }
- None
-}
-
-/// A `key="value"` (or `key='value'`) attribute inside a tag body. Scans the
-/// tag left-to-right as a sequence of attributes, consuming each quoted value
-/// whole — so a key can never be matched inside another attribute's VALUE
-/// (e.g. `data="… name='x' …"`), `data-name` can never match `name` (names
-/// compare whole, ASCII-case-insensitively per HTML), and whitespace is
-/// tolerated around the `=`. Only quoted values are returned.
-fn attr(tag: &str, key: &str) -> Option {
- let bytes = tag.as_bytes();
- let mut i = 0;
- while i < bytes.len() {
- // Skip whitespace between attributes.
- while i < bytes.len() && bytes[i].is_ascii_whitespace() {
- i += 1;
- }
- if i >= bytes.len() {
- break;
- }
- // Read one attribute name (stop at whitespace, '=', or a quote).
- let name_start = i;
- while i < bytes.len()
- && !bytes[i].is_ascii_whitespace()
- && bytes[i] != b'='
- && bytes[i] != b'"'
- && bytes[i] != b'\''
- {
- i += 1;
- }
- let name = &tag[name_start..i];
- // Optional `= value`, with whitespace tolerated around the '='.
- while i < bytes.len() && bytes[i].is_ascii_whitespace() {
- i += 1;
- }
- if i < bytes.len() && bytes[i] == b'=' {
- i += 1;
- while i < bytes.len() && bytes[i].is_ascii_whitespace() {
- i += 1;
- }
- if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
- // Quoted value: consume it whole (to the matching quote).
- let quote = bytes[i];
- let vstart = i + 1;
- let mut j = vstart;
- while j < bytes.len() && bytes[j] != quote {
- j += 1;
- }
- if j >= bytes.len() {
- return None; // unterminated quote — malformed tag, bail
- }
- if name.eq_ignore_ascii_case(key) {
- return Some(tag[vstart..j].to_string());
- }
- i = j + 1;
- } else {
- // Unquoted value: consume the token; never returned.
- while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
- i += 1;
- }
- }
- }
- // Guarantee progress on stray bytes (e.g. a bare quote at name position).
- if i == name_start {
- i += 1;
- }
- }
- None
-}
-
/// Cap on how many manifest entries we honour — an app-declared list is small;
/// this just bounds a hostile manifest.
const MAX_MANIFEST_CANISTERS: usize = 100;
-/// The `/.well-known/ic-app.json` manifest — our proposed convention for App
-/// Connect's deferred §6.3 (multi-canister applications): the app itself
-/// enumerates ALL its canisters and their roles, so an agent doesn't have to
-/// mine them out of the frontend bundle. Unknown fields are ignored
-/// (forward-compatible); entries whose `id` isn't a valid principal are
-/// dropped downstream by `add`.
+/// The published guide to the Internet Computer **service-discoverability
+/// protocol** — what an app serves so an agent handed only its URL can work out
+/// the rest. Cited verbatim in the update-call refusal (see `discoverability`)
+/// so an app owner reading it knows exactly what to adopt, and in the tool
+/// descriptions so an agent can relay it.
+pub const SERVICE_DISCOVERABILITY_GUIDE: &str =
+ "https://docs.internetcomputer.org/guides/frontends/service-discoverability/";
+
+/// **Layer 1 of the protocol**: the canister manifest an app serves at its
+/// origin, enumerating every canister it comprises and each one's role. This is
+/// the signal the update-call gate keys on — publishing it is how an app opts
+/// into being operated by this connector. Extensionless by the IC's
+/// `.well-known` convention (compare `ic-domains`, `ii-alternative-origins`),
+/// even though the body is JSON.
+pub(crate) const ARCHITECTURE_PATH: &str = "/.well-known/ic-architecture";
+
+/// The manifest path this server proposed BEFORE the protocol was published, so
+/// an agent could enumerate a multi-canister app at all. Same document shape,
+/// plus a `derivation_origin` field the protocol moved to its own
+/// [`DERIVATION_ORIGIN_PATH`] file. Still read — at lower authority than
+/// [`ARCHITECTURE_PATH`] — so the apps that adopted the proposal keep working
+/// rather than being cut off the day the standard path landed.
+pub(crate) const LEGACY_MANIFEST_PATH: &str = "/.well-known/ic-app.json";
+
+/// **Layer 5 of the protocol**: the app's Internet Identity derivation origin,
+/// as one canonical `https://host` on a single line. An ABSENT file means "derive
+/// for the visible origin itself", which is why a 404 here is not an error.
+pub(crate) const DERIVATION_ORIGIN_PATH: &str = "/.well-known/ii-derivation-origin";
+
+/// The canister manifest an app serves about itself, at either
+/// [`ARCHITECTURE_PATH`] (the protocol) or [`LEGACY_MANIFEST_PATH`] (this
+/// server's pre-protocol proposal). One `serde` shape covers both: the documents
+/// are the same object, and unknown fields are ignored either way
+/// (forward-compatible per the spec's "unknown fields must be ignored"). Entries
+/// whose `id` isn't a valid principal are dropped downstream by `add` /
+/// [`manifest_canister_ids`].
///
/// ```json
-/// { "derivation_origin": "https://.icp0.io",
+/// { "version": "1.0.0",
/// "canisters": [
-/// { "id": "aaaaa-…-cai", "role": "backend", "description": "orders API" },
-/// { "id": "bbbbb-…-cai", "role": "ledger" } ] }
+/// { "id": "aaaaa-…-cai", "name": "backend", "role": "the backend",
+/// "description": "orders API; call getApiDoc() first" },
+/// { "id": "bbbbb-…-cai", "name": "frontend", "role": "the frontend" } ] }
/// ```
///
-/// The optional top-level `derivation_origin` is the app's own declaration of
-/// the Internet Identity derivation origin its frontends pin (via
-/// `derivationOrigin` + `/.well-known/ii-alternative-origins`). It is the ONLY
-/// authoritative way to learn a custom derivation origin: there is no reverse
-/// lookup from an app URL to it (the app's own alternative-origins file lists
-/// the inverse relation, and the frontend's `derivationOrigin` config is
-/// typically minified out of reach). When absent, a consumer must fall back to
-/// the application origin and say so.
+/// `derivation_origin` is the LEGACY document's top-level declaration of the
+/// Internet Identity derivation origin the app's frontends pin (via
+/// `derivationOrigin` + `/.well-known/ii-alternative-origins`). The protocol
+/// gives that its own file ([`DERIVATION_ORIGIN_PATH`]), which takes precedence;
+/// the field is still read here for the apps that shipped against the proposal.
+/// Either way it is the ONLY authoritative way to learn a custom derivation
+/// origin: there is no reverse lookup from an app URL to it (the app's own
+/// alternative-origins file lists the inverse relation, and the frontend's
+/// `derivationOrigin` config is typically minified out of reach). When neither
+/// is present, a consumer must fall back to the application origin and say so.
#[derive(Deserialize)]
struct AppManifest {
#[serde(default)]
@@ -338,14 +276,22 @@ struct AppManifest {
struct AppManifestEntry {
#[serde(default)]
id: String,
+ /// The protocol's short label for the canister ("backend", "frontend"). Used
+ /// as the display label when `role` is absent — untrusted app text either
+ /// way, so it goes through [`clean_label`].
+ #[serde(default)]
+ name: Option,
#[serde(default)]
role: Option,
#[serde(default)]
description: Option,
}
-/// Extract `(canister_id, label)` pairs from an `/.well-known/ic-app.json`
-/// body; the label is "role — description", whichever parts are present.
+/// Extract `(canister_id, label)` pairs from a manifest body (either well-known
+/// path — the shape is identical). The label is "role — description", falling
+/// back to the protocol's `name` when no `role` is given, whichever parts are
+/// present. `role`/`name`/`description` are UNTRUSTED app text, so each is
+/// sanitized and length-capped by [`clean_label`] before it can reach a reply.
fn canisters_from_app_manifest(text: &str) -> Vec<(String, Option)> {
let Ok(m) = serde_json::from_str::(text) else {
return Vec::new();
@@ -355,7 +301,16 @@ fn canisters_from_app_manifest(text: &str) -> Vec<(String, Option)> {
.filter(|e| !e.id.trim().is_empty())
.take(MAX_MANIFEST_CANISTERS)
.map(|e| {
- let role = e.role.as_deref().map(clean_label).filter(|s| !s.is_empty());
+ // `role` is the descriptive field ("the backend"); `name` is the
+ // short handle ("backend"). Prefer the former, fall back to the
+ // latter, so a spec manifest that labels only with `name` still
+ // yields a label instead of a bare principal.
+ let role = e
+ .role
+ .as_deref()
+ .or(e.name.as_deref())
+ .map(clean_label)
+ .filter(|s| !s.is_empty());
let desc = e.description.as_deref().map(clean_label).filter(|s| !s.is_empty());
let label = match (role, desc) {
(Some(r), Some(d)) => Some(format!("{r} — {d}")),
@@ -368,6 +323,74 @@ fn canisters_from_app_manifest(text: &str) -> Vec<(String, Option)> {
.collect()
}
+/// The gate's stricter view of a manifest body: `canisters` is REQUIRED here,
+/// where [`AppManifest`] defaults it. The difference matters only to the gate:
+/// discovery just wants whatever ids it can find, but the gate must not read a
+/// body that merely happens to be a JSON object (`{}`, an error envelope, some
+/// unrelated config) as "an app that publishes a manifest and declares nothing".
+/// Unknown fields are still ignored, as the protocol requires.
+#[derive(Deserialize)]
+struct StrictManifest {
+ canisters: Vec,
+}
+
+/// The canisters a manifest body declares, as the gate reads them.
+pub(crate) struct ManifestCanisters {
+ /// The declared ids, as validated principals.
+ pub ids: Vec,
+ /// Entries past [`MAX_MANIFEST_CANISTERS`] that were not read at all. Carried
+ /// rather than dropped silently: entry 101 of an over-long manifest IS
+ /// declared by the app, so refusing it as "not declared" would be a false
+ /// statement about the app. The refusal reports the overflow instead.
+ pub omitted: usize,
+}
+
+/// Whether `p` is a CANISTER principal, which the protocol requires every
+/// manifest `id` to be ("`id` is required and must be a canister principal").
+/// Opaque ids are 10 bytes with the `0x01` type tag; that excludes user
+/// principals (29 bytes, `0x02`), the anonymous principal (1 byte, `0x04`) and —
+/// the one that matters here — the management canister `aaaaa-aa`, whose blob is
+/// EMPTY and which `Principal::from_text` otherwise accepts happily.
+///
+/// Enforcing the spec's own type rule is what keeps a manifest from declaring,
+/// and so authorizing a write to, something that is not an app canister at all.
+/// It is deliberately the spec's rule and not a policy list of our own: which
+/// canisters an app may legitimately declare is the app's business, but WHAT
+/// KIND of principal an `id` may be is the protocol's.
+fn is_canister_principal(p: &Principal) -> bool {
+ let bytes = p.as_slice();
+ bytes.len() == 10 && bytes.last() == Some(&0x01)
+}
+
+/// The canister ids a manifest body DECLARES, as validated principals — the
+/// authorization set the update-call gate checks a target against (see
+/// [`crate::discoverability`]). `None` when the body is not a manifest document
+/// at all (an SPA catch-all's HTML, a JSON array, an error envelope, an empty
+/// response), which is what lets the gate tell "this app publishes no manifest"
+/// apart from "it publishes one that doesn't list your canister" — two different
+/// things to tell an agent. An empty `ids` is a real, empty declaration.
+///
+/// Deliberately separate from [`canisters_from_app_manifest`]: that one feeds a
+/// human-readable discovery listing and keeps ids as the app spelled them,
+/// whereas a gate must compare PARSED principals, so two spellings of one id
+/// can't disagree with each other. Ids that aren't CANISTER principals (see
+/// [`is_canister_principal`]) are dropped, so a junk entry — or one naming the
+/// management canister — can never authorize anything.
+pub(crate) fn manifest_canister_ids(text: &str) -> Option {
+ let m = serde_json::from_str::(text).ok()?;
+ let omitted = m.canisters.len().saturating_sub(MAX_MANIFEST_CANISTERS);
+ Some(ManifestCanisters {
+ ids: m
+ .canisters
+ .into_iter()
+ .take(MAX_MANIFEST_CANISTERS)
+ .filter_map(|e| Principal::from_text(e.id.trim()).ok())
+ .filter(is_canister_principal)
+ .collect(),
+ omitted,
+ })
+}
+
/// Reduce a raw origin string to a canonical bare `https://host[:port]` origin,
/// accepting https with a real (tuple) host and no user-info. A scheme-less value
/// is treated as a bare host and gets `https://` prepended (so a good-faith
@@ -407,15 +430,64 @@ fn normalize_origin(raw: &str) -> Option {
Some(origin.ascii_serialization())
}
-/// The app's declared Internet Identity derivation origin, from the manifest's
-/// optional top-level `derivation_origin`, reduced to a bare `https://host[:port]`
-/// origin (a scheme-less bare host is accepted and gets `https://`). `None` if
-/// absent, blank, an explicit non-https scheme, user-info, or not a parseable URL.
+/// The app's declared Internet Identity derivation origin, from the LEGACY
+/// manifest's optional top-level `derivation_origin`, reduced to a bare
+/// `https://host[:port]` origin (a scheme-less bare host is accepted and gets
+/// `https://`). `None` if absent, blank, an explicit non-https scheme, user-info,
+/// or not a parseable URL. The protocol's own Layer 5 file
+/// ([`parse_derivation_origin_file`]) takes precedence over this.
fn declared_derivation_origin(manifest_text: &str) -> Option {
let m = serde_json::from_str::(manifest_text).ok()?;
normalize_origin(m.derivation_origin?.as_str())
}
+/// Cap on how much of [`DERIVATION_ORIGIN_PATH`] we will look at. The protocol
+/// says the body is ONE origin on ONE line; anything longer is either an SPA
+/// catch-all's HTML or a hostile body, and neither deserves a parse.
+const MAX_DERIVATION_ORIGIN_LINE: usize = 512;
+
+/// Layer 5 of the protocol: the app's derivation origin as served at
+/// [`DERIVATION_ORIGIN_PATH`] — one canonical `https://host` on a single line.
+///
+/// The documented format is enforced rather than coerced (per review): exactly
+/// ONE non-empty line, capped at [`MAX_DERIVATION_ORIGIN_LINE`], carrying an
+/// EXPLICIT `https://` URL with no path, query, fragment or user-info. This file
+/// decides which principal the user acts as, and coercion is the wrong instinct
+/// for that: reading `example.com` as an origin, or quietly discarding the `/path`
+/// off a malformed line, would turn a file its author got wrong into an
+/// authoritative identity declaration — and the resulting wrong principal fails
+/// silently, as a call that simply isn't the user. Anything that is not the
+/// documented form reads as "declares nothing", which lands the app on its own
+/// visible origin: the safe default the protocol already specifies for an absent
+/// file.
+///
+/// It is also what makes the common misconfiguration harmless: an SPA catch-all
+/// answering this path with `index.html` yields ``, not an origin.
+fn parse_derivation_origin_file(text: &str) -> Option {
+ let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
+ let line = lines.next()?;
+ // A second non-empty line means this is not the documented one-line document,
+ // and guessing which line was meant is exactly the coercion above.
+ if lines.next().is_some() || line.len() > MAX_DERIVATION_ORIGIN_LINE {
+ return None;
+ }
+ // The form is an origin, so the scheme is written out — a bare host is not it.
+ if !line.get(..8).is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://")) {
+ return None;
+ }
+ let url = url::Url::parse(line).ok()?;
+ if !url.username().is_empty()
+ || url.password().is_some()
+ || url.query().is_some()
+ || url.fragment().is_some()
+ || !matches!(url.path(), "" | "/")
+ {
+ return None;
+ }
+ let origin = url.origin();
+ origin.is_tuple().then(|| origin.ascii_serialization())
+}
+
/// Which origins Internet Identity permits to derive from this origin, from its
/// `/.well-known/ii-alternative-origins` (`{ "alternativeOrigins": [...] }`).
/// Purely informational — this is the INVERSE of "what derivation origin does
@@ -524,6 +596,31 @@ fn known_derivation_origin(host: &str) -> Option<&'static str> {
.map(|(_, origin)| *origin)
}
+/// The built-in derivation origin for a well-known app URL, if any — the
+/// registry lookup for anything derived from a URL rather than from a static
+/// registry entry.
+///
+/// A NON-DEFAULT PORT never matches, even though the registry is keyed by host.
+/// `https://oisy.com:8443` is a DIFFERENT origin from `https://oisy.com`
+/// everywhere else in this codebase — Internet Identity derives a different
+/// principal for it, and `identities::target_origin` keeps the port rather than
+/// stripping it the way it strips `:443`. Letting the port fall out of the key
+/// would let whatever answers on another port of a registered host inherit that
+/// app's identity, and at the write gate
+/// ([`crate::discoverability::bind_identity`]) that is the whole comparison: a
+/// manifest served there would authorize a write signed as the user's principal
+/// at the real app. Dropping to `AppUrlDefault` instead makes the origin stand
+/// on its own — which, being a distinct origin, then fails the binding.
+///
+/// (Reaching that case at all needs control of the registered host, so this is
+/// closing the gap rather than a live break; raised in review.)
+fn known_derivation_origin_for_url(url: &url::Url) -> Option<&'static str> {
+ if url.port().is_some() {
+ return None;
+ }
+ known_derivation_origin(&url.host_str()?.to_ascii_lowercase())
+}
+
/// A well-known IC app, for NAME → app resolution by the `icp_find_app_by_name`
/// tool. There is no on-chain directory mapping an app name to its front-end URL,
/// so this covers only a small curated set; anything else is directed to a web
@@ -677,6 +774,19 @@ async fn fetch_alternative_origins(origin: &str) -> Vec {
let origin = url.origin().ascii_serialization();
match client.get(format!("{origin}/.well-known/ii-alternative-origins")).send().await {
Ok(resp) if resp.status().is_success() => {
+ // This list AUTHORIZES: a cross-origin derivation-origin claim is
+ // accepted only because the declared origin names the app back here.
+ // A redirect target's answer is not that origin's statement, so it
+ // cannot grant the claim (per review). Empty is the fail-closed
+ // value every other failure here already returns.
+ if !answered_by(&resp, &origin) {
+ tracing::warn!(
+ probed = %origin,
+ served_from = %resp.url().origin().ascii_serialization(),
+ "ignoring an ii-alternative-origins list served by a redirect target rather than the probed origin"
+ );
+ return Vec::new();
+ }
parse_alternative_origins(&read_capped(resp, MAX_META_BYTES).await)
}
_ => Vec::new(),
@@ -711,8 +821,19 @@ fn header_is_ic_principal(headers: &reqwest::header::HeaderMap) -> bool {
/// are canonical `Url::origin().ascii_serialization()` forms, so the compare is exact
/// (host case- and default-port-normalized) rather than a host-only match.
fn ic_evidence_from(resp: &reqwest::Response, expected_origin: &str) -> bool {
- header_is_ic_principal(resp.headers())
- && resp.url().origin().ascii_serialization() == expected_origin
+ header_is_ic_principal(resp.headers()) && answered_by(resp, expected_origin)
+}
+
+/// Whether `expected_origin` is the origin that actually answered, rather than a
+/// redirect target. Every well-known fetch in this module asks this before it
+/// treats a response as a statement BY the app it probed — the shared redirect
+/// policy refuses a cross-domain hop but permits same-host different-port hops
+/// and hops to global IP literals, so a 3xx can otherwise put another origin's
+/// bytes behind this app's name. Both sides are canonical
+/// `Url::origin().ascii_serialization()` forms, so the compare is exact (host
+/// case- and default-port-normalized) rather than a host-only match.
+fn answered_by(resp: &reqwest::Response, expected_origin: &str) -> bool {
+ resp.url().origin().ascii_serialization() == expected_origin
}
/// Resolve an app URL to its Internet Identity derivation context, WITHOUT
@@ -773,9 +894,29 @@ fn decide_declared_origin(
))
}
-/// What the app's `/.well-known/ic-app.json` resolved to: its declared (and
-/// authorized) derivation origin or the application-origin default, whether the
-/// manifest response carried IC-hosting evidence (`x-ic-canister-id`), and — when
+/// The legacy manifest's `derivation_origin` field, once Layer 5 has answered
+/// without pinning one. `Ok(None)` is a real absence (no field, or nothing there
+/// to read); an unreachable probe is an `Err`, for the same reason Layer 5's is:
+/// the field might be there, and defaulting past it would derive the wrong
+/// principal for an app that pins a custom origin.
+fn declared_from_legacy(
+ legacy: &WellKnown,
+ application_origin: &str,
+) -> Result, String> {
+ match legacy {
+ WellKnown::Served(body) => Ok(declared_derivation_origin(body)),
+ WellKnown::Absent => Ok(None),
+ WellKnown::Unreachable(e) => Err(format!(
+ "could not read {LEGACY_MANIFEST_PATH} at {application_origin}: {e}. Refusing to \
+ assume this app derives against its own origin while a declaration it may publish is \
+ unreadable — this is likely transient, so retry."
+ )),
+ }
+}
+
+/// What the app's own derivation-origin declaration resolved to: its declared (and
+/// authorized) derivation origin or the application-origin default, whether either
+/// well-known probe carried IC-hosting evidence (`x-ic-canister-id`), and — when
/// an accepted CROSS-origin declaration fetched them — that origin's alt-origins
/// (reused for the display list so it isn't fetched twice).
struct DeclaredResolution {
@@ -785,50 +926,189 @@ struct DeclaredResolution {
alt_origins: Option>,
}
-impl DeclaredResolution {
- /// The application-origin default (no usable declaration), carrying whatever
- /// IC evidence the manifest response showed.
- fn app_default(application_origin: &str, ic_evidence: bool) -> Self {
- Self {
- derivation_origin: application_origin.to_string(),
- source: DerivationSource::AppUrlDefault,
+/// Cap on the Layer 5 body we buffer. The document is one line; this only has to
+/// be big enough to see the first line of whatever was served instead (an SPA
+/// catch-all's HTML), so a misconfigured app costs a few KiB, not a page.
+const MAX_DERIVATION_ORIGIN_BYTES: usize = 4 * 1024;
+
+/// One well-known probe's outcome on the IDENTITY path, where "the origin says
+/// this document is not there" and "we never got an answer" must not collapse
+/// into the same value: the first legitimately means "derive against the default"
+/// (Layer 5 specifies omitting the file), while the second means we do not know
+/// what the app declares — and defaulting on a timeout can derive, and sign as,
+/// the wrong principal.
+enum WellKnown {
+ /// A success response, with its (capped) body.
+ Served(String),
+ /// The origin said this document is NOT THERE — a 404 or 410, the only
+ /// statuses that mean it (see [`means_not_published`]). Every other
+ /// non-success status is `Unreachable`, not this: an origin declining or
+ /// failing to serve the path has not told us the app declares nothing.
+ Absent,
+ /// We could not find out. The exchange never completed (DNS, TLS, connect,
+ /// timeout, a body that died mid-read), or it completed with a status that
+ /// answers nothing (401/403, 429, 5xx…), or the document exceeded a cap that
+ /// its documented form does not bound.
+ Unreachable(String),
+}
+
+/// Fetch one `.well-known` document from the application origin, plus whether the
+/// exchange carried IC-hosting evidence. Every well-known probe on the identity
+/// path goes through here so the evidence capture, the success check, the origin
+/// pin, and the size cap can't drift apart between them.
+///
+/// A body is this origin's declaration only when THIS origin answered. The shared
+/// redirect policy refuses a cross-domain hop but permits same-host different-port
+/// hops and hops to global IP literals, so a 3xx could otherwise hand another
+/// origin's bytes to the identity path — and on Layer 5 those bytes decide which
+/// principal every call is signed as. [`fetch_declared_manifest`] already pins the
+/// manifest that way; this is the same rule on the path where getting it wrong is
+/// worse (per review). A redirected answer is `Unreachable`, not `Absent`: `Absent`
+/// means "the app declares nothing, derive against the default", and a document we
+/// deliberately ignored is not a document the app does not have.
+async fn fetch_well_known(
+ client: &reqwest::Client,
+ application_origin: &str,
+ path: &str,
+ max_bytes: usize,
+ overflow: Overflow,
+) -> (WellKnown, bool) {
+ let resp = match client.get(format!("{application_origin}{path}")).send().await {
+ Ok(resp) => resp,
+ Err(e) => return (WellKnown::Unreachable(e.to_string()), false),
+ };
+ // Captured even on a NON-success response: the IC HTTP gateway stamps
+ // `x-ic-canister-id` on everything it serves, 404s included, so an app that
+ // simply doesn't publish this document still proves it is IC-hosted here.
+ let ic_evidence = ic_evidence_from(&resp, application_origin);
+ // The origin pin comes FIRST, before the status is read as an answer at all:
+ // a 404 from a redirect target is that origin saying the document is not
+ // there, which is not the probed app declaring nothing (per review — the
+ // first cut of this check ran after the status and turned exactly that into
+ // an `Absent`, i.e. "derive against the default"). Nothing a foreign origin
+ // says about this path is an answer about this app.
+ if !answered_by(&resp, application_origin) {
+ let served_from = resp.url().origin().ascii_serialization();
+ tracing::warn!(
+ probed = %application_origin,
+ served_from = %served_from,
+ path,
+ "ignoring a well-known response served by a redirect target rather than the probed origin"
+ );
+ return (
+ WellKnown::Unreachable(format!(
+ "{path} was answered by {served_from}, not the origin that was probed"
+ )),
ic_evidence,
- alt_origins: None,
- }
+ );
}
-}
-
-/// Resolve the app's declared derivation origin from `/.well-known/ic-app.json`,
-/// authorizing a cross-origin claim against the declared origin's own
-/// `ii-alternative-origins` (the browser/II rule; the decision is
-/// [`decide_declared_origin`]). Flat, with early guards. A missing/unsuccessful/
-/// undeclared manifest legitimately yields the application-origin default (the app
-/// derives against its own origin).
+ let status = resp.status();
+ if !status.is_success() {
+ // Only a definitive "not here" may mean the app declares nothing. A 429
+ // or 5xx during an outage would otherwise fall through to the legacy
+ // field or the application-origin default and sign as a different
+ // principal (per review).
+ return if means_not_published(status) {
+ (WellKnown::Absent, ic_evidence)
+ } else {
+ (WellKnown::Unreachable(format!("HTTP {status}")), ic_evidence)
+ };
+ }
+ match read_capped_strict(resp, max_bytes).await {
+ StrictRead::Body(body) => (WellKnown::Served(body), ic_evidence),
+ StrictRead::TooLarge => match overflow {
+ Overflow::NotTheDocument => (WellKnown::Absent, ic_evidence),
+ Overflow::Unknown => (
+ WellKnown::Unreachable(format!(
+ "document is larger than the {max_bytes}-byte limit this server reads"
+ )),
+ ic_evidence,
+ ),
+ },
+ // A body that died mid-read is not a document that says nothing.
+ StrictRead::Failed(e) => (WellKnown::Unreachable(e), ic_evidence),
+ }
+}
+
+/// Resolve the app's declared derivation origin, authorizing a cross-origin claim
+/// against the declared origin's own `ii-alternative-origins` (the browser/II
+/// rule; the decision is [`decide_declared_origin`]). A declaration the app
+/// ANSWERED without providing — a 404 or 410, or a document that is not the one
+/// specified — legitimately yields the application-origin default (the app
+/// derives against its own origin); for Layer 5 that is not merely tolerated but
+/// SPECIFIED: "if you use the default, you may omit the file". A probe that did
+/// not complete is an `Err` instead: a declaration we could not read is not a
+/// declaration that is absent, and defaulting past it would derive — and sign as
+/// — a principal the app does not pin.
+///
+/// Two sources, in protocol order: the standard [`DERIVATION_ORIGIN_PATH`] file
+/// wins, and the legacy manifest's top-level `derivation_origin` fills in for the
+/// apps that shipped against this server's pre-protocol proposal. They are
+/// fetched CONCURRENTLY, so honouring both costs one round trip rather than two
+/// and an app that adopts the protocol is never the slower path.
///
/// A cross-origin claim that CANNOT be authorized is an `Err`, not a silent
/// fall-back: falling back to the application origin there would derive the WRONG
/// principal for an app that deliberately pins a custom derivation origin (and
/// would mask a spoof, a misconfiguration, or an unreachable `ii-alternative-origins`).
/// Surfacing it lets the caller refuse rather than act as an unintended identity
-/// (ICPBB-430). The manifest response doubles as IC-hosting evidence, captured for
-/// the caller's later gate.
+/// (ICPBB-430). Both responses double as IC-hosting evidence, captured for the
+/// caller's later gate.
async fn resolve_declared_origin(
client: &reqwest::Client,
application_origin: &str,
) -> Result {
- let Ok(resp) = client
- .get(format!("{application_origin}/.well-known/ic-app.json"))
- .send()
- .await
- else {
- return Ok(DeclaredResolution::app_default(application_origin, false));
+ let ((layer5, layer5_is_ic), (legacy, legacy_is_ic)) = tokio::join!(
+ fetch_well_known(
+ client,
+ application_origin,
+ DERIVATION_ORIGIN_PATH,
+ MAX_DERIVATION_ORIGIN_BYTES,
+ // One canonical origin on one line cannot exceed this cap, so a body
+ // that does is not the Layer 5 document at all.
+ Overflow::NotTheDocument,
+ ),
+ fetch_well_known(
+ client,
+ application_origin,
+ LEGACY_MANIFEST_PATH,
+ MAX_META_BYTES,
+ Overflow::Unknown,
+ ),
+ );
+ // Either probe reaching the origin and showing the gateway header is enough:
+ // the question is whether THIS origin is IC-served, not which path answered.
+ let ic_evidence = layer5_is_ic || legacy_is_ic;
+ // Precedence only means something if the higher-priority probe actually
+ // ANSWERED. A Layer 5 file we failed to fetch is not "no Layer 5 file": using
+ // the legacy field, or the application-origin default, because a request timed
+ // out would silently derive a different principal than the app pins — and
+ // this connector would sign as it. So an unreachable probe is an error, not a
+ // fallback, at each step: refusing is recoverable (the caller retries), while
+ // acting as the wrong identity is not. Both probes hit the same origin through
+ // the same client, so in practice an unreachable one means the origin is down
+ // rather than that this costs a reachable app anything.
+ let declared = match &layer5 {
+ WellKnown::Served(body) => match parse_derivation_origin_file(body) {
+ // Layer 5 answered and pinned an origin: legacy cannot override it,
+ // and its own outcome no longer matters.
+ Some(origin) => Some(origin),
+ // Answered, but not with a usable origin (the SPA catch-all serves
+ // index.html here): the legacy field may still carry one.
+ None => declared_from_legacy(&legacy, application_origin)?,
+ },
+ // The origin says there is no Layer 5 file — which the protocol
+ // SPECIFIES as "derive against the default" — so the legacy field is the
+ // only remaining source.
+ WellKnown::Absent => declared_from_legacy(&legacy, application_origin)?,
+ WellKnown::Unreachable(e) => {
+ return Err(format!(
+ "could not read {DERIVATION_ORIGIN_PATH} at {application_origin}: {e}. Refusing to \
+ derive an identity from a lower-priority source while the app's own declaration \
+ is unknown — this is likely transient, so retry."
+ ))
+ }
};
- let ic_evidence = ic_evidence_from(&resp, application_origin);
- if !resp.status().is_success() {
- return Ok(DeclaredResolution::app_default(application_origin, ic_evidence));
- }
- let text = read_capped(resp, MAX_META_BYTES).await;
- let declared = declared_derivation_origin(&text);
// The declared origin's ii-alternative-origins is the authorization list, and
// only a CROSS-origin claim needs it — no declaration and a self-declaration
@@ -883,15 +1163,15 @@ pub async fn resolve_app_identity(app_url: &str, want_alt_origins: bool) -> Resu
let client = site_client(&host, &pinned)?;
let application_origin = base_url.origin().ascii_serialization();
- // Declared derivation origin from the app's manifest. The manifest response
- // also doubles as IC-ness evidence: the IC HTTP gateway stamps
+ // Declared derivation origin from the app's own declaration — the protocol's
+ // /.well-known/ii-derivation-origin, else the legacy manifest's field. Those
+ // responses also double as IC-ness evidence: the IC HTTP gateway stamps
// `x-ic-canister-id` (a canister principal) on every response it serves
// (including 404s), so capture it here — value-validated AND attributed to this
// origin (not a redirect target), not just present — before any fallback decision.
- // Resolve (and, for a cross-origin claim, authorize) the app's declared
- // derivation origin from its manifest — see [`resolve_declared_origin`]. The
- // alt-origins of an accepted cross-origin declaration are reused for the
- // display list below so it isn't fetched twice.
+ // Resolve (and, for a cross-origin claim, authorize) the declaration — see
+ // [`resolve_declared_origin`]. The alt-origins of an accepted cross-origin
+ // declaration are reused for the display list below so it isn't fetched twice.
let resolved = resolve_declared_origin(&client, &application_origin).await?;
let mut derivation_origin = resolved.derivation_origin;
let mut derivation_origin_source = resolved.source;
@@ -902,7 +1182,7 @@ pub async fn resolve_app_identity(app_url: &str, want_alt_origins: bool) -> Resu
// well-known custom-derivation-origin apps (the app's own declaration always
// wins, so this only fills the gap for apps that haven't shipped one yet).
if derivation_origin_source == DerivationSource::AppUrlDefault {
- if let Some(known) = known_derivation_origin(&host) {
+ if let Some(known) = known_derivation_origin_for_url(&base_url) {
derivation_origin = known.to_string();
derivation_origin_source = DerivationSource::Known;
}
@@ -968,11 +1248,9 @@ pub async fn resolve_app_identity(app_url: &str, want_alt_origins: bool) -> Resu
/// the host IS a registered known-app host (nothing to repair) or resembles no
/// known app. Offline (registry lookup only).
pub fn similar_known_app(app_url: &str) -> Option {
- let host = url::Url::parse(&normalize(app_url))
- .ok()?
- .host_str()?
- .to_ascii_lowercase();
- if known_derivation_origin(&host).is_some() {
+ let url = url::Url::parse(&normalize(app_url)).ok()?;
+ let host = url.host_str()?.to_ascii_lowercase();
+ if known_derivation_origin_for_url(&url).is_some() {
return None; // a real known-app host — not a lookalike
}
// Token-window alias matching (see find_known_app): "multidex.com" tokenizes
@@ -1226,13 +1504,72 @@ fn site_client(host: &str, addrs: &[SocketAddr]) -> Result String {
+async fn read_capped(resp: reqwest::Response, max: usize) -> String {
+ // Fail-soft by design here: for the discovery crawl a half-read body is
+ // still worth mining, and there is no verdict riding on it.
+ read_capped_inner(resp, max).await.unwrap_or_else(|(partial, _)| partial)
+}
+
+/// [`read_capped`] for the callers that draw a CONCLUSION from the body, where a
+/// truncated read must not pass as a complete one (per review). A connection that
+/// drops mid-body would otherwise hand the gate a partial document, which parses
+/// as "not a manifest" and becomes the permanent "publishes no manifest; stop
+/// retrying" verdict — a false statement about an app that may serve a perfectly
+/// good manifest. Hitting the size cap is NOT an error: that is a bounded read
+/// this server chose, not a failed one.
+async fn read_capped_strict(resp: reqwest::Response, max: usize) -> StrictRead {
+ // Read ONE byte past the cap so overflow is DETECTABLE (per review): a
+ // truncated body is not a shorter document, and letting it through would draw
+ // a conclusion from bytes we chose not to read — a manifest over the cap
+ // parsing as "not JSON", or a Layer 5 file hiding a second non-empty line past
+ // it and slipping the one-line rule. What overflow MEANS differs by document,
+ // so that judgement belongs to the caller rather than here.
+ match read_capped_inner(resp, max + 1).await {
+ Ok(body) if body.len() > max => StrictRead::TooLarge,
+ Ok(body) => StrictRead::Body(body),
+ Err((_, e)) => StrictRead::Failed(e),
+ }
+}
+
+/// The outcome of a strict read, with overflow kept apart from failure because
+/// they license different conclusions.
+enum StrictRead {
+ Body(String),
+ /// More bytes arrived than the cap allows. For a document with a size-bounded
+ /// FORM — the one-line Layer 5 file — this is positive evidence that what was
+ /// served is not that document. For an open-ended one (a JSON manifest) it
+ /// only means we could not read it all.
+ TooLarge,
+ /// The transfer failed part-way.
+ Failed(String),
+}
+
+/// What an over-cap body means for a particular well-known document.
+#[derive(Clone, Copy)]
+enum Overflow {
+ /// The document's form bounds its size, so an over-cap body is NOT it: the
+ /// app declares nothing here. An SPA catch-all's index.html at the Layer 5
+ /// path is the live case — OISY serves exactly that, and treating it as a
+ /// failed check would refuse to resolve a perfectly healthy app.
+ NotTheDocument,
+ /// The document has no size bound of its own, so an over-cap body is a read
+ /// we could not finish, not a verdict.
+ Unknown,
+}
+
+/// The shared read. `Err((partial, error))` carries what had arrived before the
+/// transfer failed, so the fail-soft caller can keep it and the strict one can
+/// report the failure.
+async fn read_capped_inner(
+ mut resp: reqwest::Response,
+ max: usize,
+) -> Result {
let mut buf: Vec = Vec::new();
loop {
if buf.len() >= max {
@@ -1247,10 +1584,231 @@ async fn read_capped(mut resp: reqwest::Response, max: usize) -> String {
}
}
Ok(None) => break,
- Err(_) => break,
+ Err(e) => {
+ return Err((String::from_utf8_lossy(&buf).into_owned(), e.to_string()))
+ }
+ }
+ }
+ Ok(String::from_utf8_lossy(&buf).into_owned())
+}
+
+/// GET `url` and return up to `max` bytes of its body, distinguishing "this app
+/// does not publish this document" (`Ok(None)`) from "we could not find out"
+/// (`Err`). The discovery crawl doesn't need that distinction, but the
+/// update-call gate does: the first becomes a refusal telling the app's operators
+/// to publish a manifest and the agent to stop retrying, so only a status that
+/// really means "not here" may produce it. A 404 or 410 does. A 429 or a 5xx does
+/// NOT — an overloaded origin would otherwise be reported as an app that has not
+/// adopted the protocol (per review) — and neither does any other non-success
+/// status: a 403 on the path is the origin declining to say, not saying no.
+/// Folding "send, check status, read capped" into one future is also what lets
+/// callers `join!` several probes.
+/// Whether a non-success status is the origin saying "this document is not here"
+/// — the only answer that may become a verdict on the app — or merely a failure
+/// to find out. Kept separate from the fetch so the rule can be pinned without a
+/// network (see the tests).
+fn means_not_published(status: reqwest::StatusCode) -> bool {
+ matches!(status, reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::GONE)
+}
+
+async fn get_document(
+ client: &reqwest::Client,
+ url: &str,
+ max: usize,
+) -> Result, String> {
+ let resp = client.get(url).send().await.map_err(|e| e.to_string())?;
+ let status = resp.status();
+ if !status.is_success() {
+ return if means_not_published(status) {
+ Ok(None)
+ } else {
+ Err(format!("HTTP {status}"))
+ };
+ }
+ // Captured BEFORE the body is consumed: which origin actually answered (a
+ // redirect may have moved it) and what it claimed to be.
+ let served_from = resp.url().origin().ascii_serialization();
+ let content_type = resp
+ .headers()
+ .get(reqwest::header::CONTENT_TYPE)
+ .and_then(|v| v.to_str().ok())
+ // The essence only, lowercased: `application/json; charset=utf-8` and
+ // `application/json` are the same claim.
+ .map(|v| v.split(';').next().unwrap_or(v).trim().to_ascii_lowercase())
+ .unwrap_or_default();
+ // A manifest has no bounded form, so an over-cap body is "could not read",
+ // never "publishes none": refusing to conclude is the honest outcome.
+ let body = match read_capped_strict(resp, max).await {
+ StrictRead::Body(body) => body,
+ StrictRead::TooLarge => {
+ return Err(format!("document is larger than the {max}-byte limit this server reads"))
+ }
+ StrictRead::Failed(e) => return Err(e),
+ };
+ Ok(Some(FetchedDocument { served_from, content_type, body }))
+}
+
+/// One fetched well-known document, plus the facts a gate needs about HOW it
+/// arrived rather than only its bytes: which origin answered (so a redirect
+/// cannot let one origin borrow another's declaration) and what the response
+/// claimed to be (so a refusal can name an SPA catch-all by its signature
+/// instead of reporting a document that is right there as absent).
+pub(crate) struct FetchedDocument {
+ pub served_from: String,
+ pub content_type: String,
+ pub body: String,
+}
+
+/// [`get_document`] for the opportunistic discovery probes, where a missing
+/// document and an unreachable one are the same thing: no findings either way.
+///
+/// Discards a document a REDIRECT TARGET answered with. Discovery does not
+/// authorize anything, but it does attribute what it finds — a manifest read
+/// here is reported as declared by this app at the protocol path, the top
+/// authority tier, and the model picks a canister on that basis. A 3xx would
+/// otherwise let one origin's manifest be published under another's name, which
+/// is the same true-looking-but-wrong provenance [`fetch_declared_manifest`]
+/// pins against (per review).
+async fn fetch_success_body(client: &reqwest::Client, url: &str, max: usize) -> Option {
+ let probed = url::Url::parse(url).ok()?.origin().ascii_serialization();
+ let doc = get_document(client, url, max).await.ok().flatten()?;
+ if doc.served_from != probed {
+ tracing::warn!(
+ probed = %probed,
+ served_from = %doc.served_from,
+ "ignoring a discovery document served by a redirect target rather than the probed origin"
+ );
+ return None;
+ }
+ Some(doc.body)
+}
+
+/// An app's own declaration of the canisters it comprises, as read from its
+/// origin — what the update-call gate checks a write target against.
+pub(crate) struct DeclaredManifest {
+ /// The origin the manifest was served from (canonical `https://host[:port]`).
+ pub origin: String,
+ /// Which well-known path served it: [`ARCHITECTURE_PATH`] for the manifest
+ /// that authorizes a write, [`LEGACY_MANIFEST_PATH`] for the pre-protocol
+ /// document, which is carried only to explain a refusal.
+ pub path: &'static str,
+ /// The declared canisters, as validated principals. May legitimately be
+ /// empty: an app can publish a manifest that lists nothing.
+ pub canisters: Vec,
+ /// Declared entries past [`MAX_MANIFEST_CANISTERS`] that were not read (see
+ /// [`ManifestCanisters::omitted`]).
+ pub omitted: usize,
+}
+
+/// The outcome of probing an app origin for its canister manifest.
+pub(crate) enum ManifestProbe {
+ /// The PROTOCOL manifest was served at [`ARCHITECTURE_PATH`] and parsed —
+ /// the only document that can authorize a write.
+ Declared(DeclaredManifest),
+ /// No protocol manifest at this origin.
+ Absent {
+ /// What the PROTOCOL path answered with, when it answered 2xx with
+ /// something that is not a manifest. `Some("text/html")` is the exact
+ /// signature of the SPA catch-all the protocol guide calls out as the
+ /// most common failure, and naming it turns "your app publishes nothing"
+ /// into a refusal its operator can act on from a relayed transcript.
+ served_non_manifest: Option,
+ /// The pre-protocol document, when the origin still serves one at
+ /// [`LEGACY_MANIFEST_PATH`]. It does NOT authorize anything; it is
+ /// carried so a refusal can tell an early adopter what changed and what
+ /// to publish, rather than reporting their app as publishing nothing.
+ legacy: Option,
+ },
+}
+
+/// Fetch the service-discoverability manifest an app declares at `app_url`'s
+/// ORIGIN. Only [`ARCHITECTURE_PATH`] yields [`ManifestProbe::Declared`]:
+/// publishing there is the act that opts an app in under this connector's terms,
+/// and the operators who adopted this server's pre-protocol
+/// [`LEGACY_MANIFEST_PATH`] proposal never made that statement. The legacy
+/// document is still read, and returned alongside the absence, purely so a
+/// refusal can tell an early adopter what changed. `Err` when the origin could
+/// not be reached at all, or the URL itself is refused by the SSRF guard, so the
+/// caller can say "unknown" rather than "not adopted".
+///
+/// A document is only honoured when it came from the origin we PROBED, not from
+/// a redirect target. 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 check an origin could serve a 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.
+///
+/// Both paths are probed CONCURRENTLY: one round trip, and an app that has
+/// adopted the protocol is never the slower path. The same SSRF-pinned client and
+/// capped reads as the rest of this module — `app_url` is caller-controlled.
+pub(crate) async fn fetch_declared_manifest(app_url: &str) -> Result {
+ let base = normalize(app_url);
+ let (base_url, pinned) = resolve_public_url(&base).await?;
+ let host = base_url.host_str().unwrap_or_default().to_ascii_lowercase();
+ let client = site_client(&host, &pinned)?;
+ // Well-known paths live at the ORIGIN, never under whatever path the caller's
+ // URL carried (https://x.com/app must still probe https://x.com/.well-known/…).
+ let origin = base_url.origin().ascii_serialization();
+ let (architecture_url, legacy_url) =
+ (format!("{origin}{ARCHITECTURE_PATH}"), format!("{origin}{LEGACY_MANIFEST_PATH}"));
+ let (architecture, legacy) = tokio::join!(
+ get_document(&client, &architecture_url, MAX_META_BYTES),
+ get_document(&client, &legacy_url, MAX_META_BYTES),
+ );
+ // Only the standard path can authorize, so only ITS failure is fatal: without
+ // an answer there we cannot tell "publishes no manifest" from "we could not
+ // ask", and the first of those is a verdict on the app that tells the agent to
+ // stop retrying. A legacy probe that failed costs nothing — it can only enrich
+ // a refusal that is happening anyway.
+ if let Err(e) = &architecture {
+ return Err(format!("could not read {ARCHITECTURE_PATH} at {origin}: {e}"));
+ }
+ let mut served_non_manifest = None;
+ let (mut declared, mut legacy_declared) = (None, None);
+ for (path, doc) in [(ARCHITECTURE_PATH, &architecture), (LEGACY_MANIFEST_PATH, &legacy)] {
+ let Ok(Some(doc)) = doc else { continue };
+ if doc.served_from != origin {
+ tracing::warn!(
+ probed = %origin,
+ served_from = %doc.served_from,
+ path,
+ "ignoring a manifest served by a redirect target rather than the probed origin"
+ );
+ continue;
}
+ let Some(canisters) = manifest_canister_ids(&doc.body) else {
+ // Answered, but not with a manifest. Remember what the PROTOCOL path
+ // claimed to be so the refusal can name the misconfiguration.
+ if path == ARCHITECTURE_PATH && served_non_manifest.is_none() {
+ served_non_manifest = Some(doc.content_type.clone());
+ }
+ continue;
+ };
+ let parsed = DeclaredManifest {
+ origin: origin.clone(),
+ path,
+ canisters: canisters.ids,
+ omitted: canisters.omitted,
+ };
+ if path == ARCHITECTURE_PATH {
+ declared = Some(parsed);
+ } else {
+ legacy_declared = Some(parsed);
+ }
+ }
+ if let Some(m) = declared {
+ return Ok(ManifestProbe::Declared(m));
}
- String::from_utf8_lossy(&buf).into_owned()
+ if legacy_declared.is_some() {
+ // The population still on the pre-protocol path is what says whether
+ // that document can eventually stop being read at all, so make each one
+ // visible in the server's own logs rather than inferring it later.
+ tracing::warn!(
+ origin = %origin,
+ "origin publishes only the LEGACY manifest; it does not authorize writes"
+ );
+ }
+ Ok(ManifestProbe::Absent { served_non_manifest, legacy: legacy_declared })
}
/// Accumulator for discovered canister ids, with a hard ceiling on the number of
@@ -1332,8 +1890,8 @@ pub async fn discover(domain: &str) -> Result {
let client = site_client(&host, &pinned)?;
// Well-known paths and root-relative script paths live at the ORIGIN, not
// under whatever path the caller's URL carried (e.g. https://x.com/app must
- // probe https://x.com/ai-connect.html) — only the initial page fetch below
- // uses the URL as given.
+ // probe https://x.com/.well-known/ic-architecture) — only the initial page
+ // fetch below uses the URL as given.
let origin = base_url.origin().ascii_serialization();
let mut found = Findings::default();
@@ -1343,37 +1901,40 @@ pub async fn discover(domain: &str) -> Result {
// probed FIRST so its labels win `add`'s first-label-wins rule (a
// single-canister app's id would otherwise keep the header's generic
// "frontend" label instead of the declared one).
- // a. The App Connect bridge page's ic:canister-id meta: the app's MAIN
- // backend (spec §4.7/§6.1). Read from raw markup, no JS execution.
- // An SPA catch-all serving index.html here fails closed: no such
- // meta, no finding.
- // b. /.well-known/ic-app.json: the app's own canister manifest with
- // roles (proposed convention for the spec's deferred §6.3). A
- // catch-all HTML response fails JSON parsing → no findings.
- if let Ok(resp) = client.get(format!("{origin}/ai-connect.html")).send().await {
- if resp.status().is_success() {
- let page = read_capped(resp, MAX_META_BYTES).await;
- if let Some(id) = parse_meta(&page, "ic:canister-id") {
- found.add(
- id.trim(),
- Some("main backend (App Connect)".into()),
- "ai-connect.html".into(),
- );
- }
- }
- }
- if let Ok(resp) = client.get(format!("{origin}/.well-known/ic-app.json")).send().await {
- if resp.status().is_success() {
- let text = read_capped(resp, MAX_META_BYTES).await;
- for (id, label) in canisters_from_app_manifest(&text) {
- found.add(&id, label, "ic-app.json".into());
- }
+ // a. /.well-known/ic-architecture: the app's canister manifest with roles
+ // — Layer 1 of the service-discoverability protocol, and the ONLY
+ // source that authorizes an update call (see `discoverability`).
+ // b. /.well-known/ic-app.json: the same manifest at this server's legacy
+ // pre-protocol path, still read so early adopters keep working.
+ // Both fail closed on a catch-all HTML response — JSON parsing yields no
+ // findings.
+ //
+ // The two run CONCURRENTLY: they are independent GETs against one origin,
+ // and serializing them would put two round trips on the front of every
+ // discovery. Their findings are recorded in authority order afterwards, so
+ // `add`'s first-label-wins rule still resolves ties deterministically rather
+ // than by whichever response happened to land first.
+ let (architecture_url, legacy_url) = (
+ format!("{origin}{ARCHITECTURE_PATH}"),
+ format!("{origin}{LEGACY_MANIFEST_PATH}"),
+ );
+ let (architecture, legacy_manifest) = tokio::join!(
+ fetch_success_body(&client, &architecture_url, MAX_META_BYTES),
+ fetch_success_body(&client, &legacy_url, MAX_META_BYTES),
+ );
+ for (source, body) in [
+ ("ic-architecture", &architecture),
+ ("ic-app.json", &legacy_manifest),
+ ] {
+ let Some(text) = body.as_deref() else { continue };
+ for (id, label) in canisters_from_app_manifest(text) {
+ found.add(&id, label, source.into());
}
}
// 2. Frontend via the gateway header (and keep the HTML for bundle mining).
- // This is also the reachability gate: the two probes above are best-effort,
- // but an unreachable base is a hard error.
+ // This is also the reachability gate: the probes above are best-effort, but
+ // an unreachable base is a hard error.
let resp = client
.get(&base)
.send()
@@ -1449,12 +2010,12 @@ pub async fn discover(domain: &str) -> Result {
found.add(m.as_str(), None, "bundle".into());
}
- // Order: app-declared metadata first (App Connect main, then the manifest
- // siblings), then header (frontend), env.json, labelled bundle, bare.
+ // Order: app-declared metadata first (the protocol manifest, then its
+ // legacy sibling), then header (frontend), env.json, labelled bundle, bare.
// Authority tier of a finding (lower = more authoritative). Kept as a helper
// so the sort can compare it without cloning `canister_id` into a key.
let rank = |f: &Found| {
- if f.sources.iter().any(|s| s == "ai-connect.html") {
+ if f.sources.iter().any(|s| s == "ic-architecture") {
0
} else if f.sources.iter().any(|s| s == "ic-app.json") {
1
@@ -1844,9 +2405,10 @@ pub struct OpenAppArgs {
/// so a wrong-TLD guess repairs to the canonical URL; an explicit `https://…`
/// URL is resolved as given. Two refusals: an unknown bare name is refused with
/// instructions for finding the real URL, and a URL that would need its own
- /// origin assumed as the derivation origin (no usable declaration was read — a
- /// failed or non-success fetch, malformed JSON and an unusable declaration all
- /// count — and no registry entry) is refused when that origin shows no
+ /// origin assumed as the derivation origin (the app answered but no usable
+ /// declaration was read — a 404 or 410, malformed JSON, or a declaration this
+ /// server cannot use; a probe that did not complete is an error rather than an
+ /// assumption — and no registry entry) is refused when that origin shows no
/// Internet-Computer evidence — and that
/// evidence shows a domain is served from the Internet Computer, not that it
/// belongs to the app the user meant.
@@ -2166,7 +2728,7 @@ mod tests {
// Authority-ordered, as discover() produces: labelled tiers first, then
// a long tail of bare bundle literals.
let mut found = vec![
- mk(0, Some("main backend (App Connect)"), "ai-connect.html"),
+ mk(0, Some("backend — main canister"), "ic-architecture"),
mk(1, Some("frontend"), "header"),
mk(2, Some("IC_BACKEND_CANISTER_ID"), "bundle:IC_BACKEND_CANISTER_ID"),
];
@@ -2387,64 +2949,6 @@ mod tests {
assert_eq!(got[0].1, "backend_canister_id");
}
- // App Connect discovery metadata (spec §4.7/§6.1): the ic:canister-id meta
- // is read from the RAW markup, tolerating attribute order and quote style;
- // an SPA catch-all page without the meta yields nothing.
- #[test]
- fn parse_meta_reads_app_connect_canister_id() {
- // The shipped ai-connect.html shape (name first, double quotes).
- let page = r#"
-
- Connect "#;
- assert_eq!(
- parse_meta(page, "ic:canister-id").as_deref(),
- Some("dmp3l-2yaaa-aaaae-aamva-cai")
- );
- // Attribute order flipped + single quotes.
- let flipped = r#" "#;
- assert_eq!(parse_meta(flipped, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- // Other metas don't match; absent meta yields None.
- let other = r#" "#;
- assert_eq!(parse_meta(other, "ic:canister-id"), None);
- assert_eq!(parse_meta("no metas", "ic:canister-id"), None);
- // The right meta is found among several.
- let multi = format!("{other}\n \n{flipped}");
- assert_eq!(parse_meta(&multi, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- assert_eq!(parse_meta(&multi, "ic:network").as_deref(), Some("ic"));
- // Attribute boundaries (per review): `data-name` must NOT match `name`,
- // and whitespace around `=` is legal HTML that must still parse.
- let trap = r#" "#;
- assert_eq!(parse_meta(trap, "ic:canister-id"), None, "data-name must not match name");
- let spaced = r#" "#;
- assert_eq!(parse_meta(spaced, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- // Both shapes on one tag: the boundary-checked real `name` wins.
- let both = r#" "#;
- assert_eq!(parse_meta(both, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- // An unquoted value is not accepted (we only read the quoted shape).
- assert_eq!(attr("name=bare content=\"x\"", "name"), None);
- // Tag-name boundary (per review): `` is not a tag,
- // and must not shadow a real meta that follows it.
- let metadata = r#""#;
- assert_eq!(parse_meta(metadata, "ic:canister-id"), None, " must not match");
- let after = format!("{metadata}\n ");
- assert_eq!(parse_meta(&after, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- // A malformed, never-closed ` ' remains, so no complete tag can follow anyway).
- assert_eq!(parse_meta(" "#;
- assert_eq!(parse_meta(embedded, "ic:canister-id"), None, "key inside a value must not match");
- assert_eq!(attr(r#"data="x name='inner' y" name="real""#, "name").as_deref(), Some("real"));
- // HTML tag and attribute names are ASCII-case-insensitive (per review):
- // parses; a mixed-case still doesn't.
- let upper = r#" "#;
- assert_eq!(parse_meta(upper, "ic:canister-id").as_deref(), Some("aaaaa-aa"));
- let mixed_decoy = r#""#;
- assert_eq!(parse_meta(mixed_decoy, "ic:canister-id"), None, " must not match");
- }
-
// App-declared labels must win over the header's generic "frontend" for the
// SAME canister (single-canister apps): `add` keeps the FIRST label, so
// discover() probes the declared metadata before the header. This pins the
@@ -2453,11 +2957,11 @@ mod tests {
fn add_keeps_first_label_so_declared_probes_run_first() {
let mut found = Findings::default();
let id = "dmp3l-2yaaa-aaaae-aamva-cai";
- found.add(id, Some("main backend (App Connect)".into()), "ai-connect.html".into());
+ found.add(id, Some("backend — main canister".into()), "ic-architecture".into());
found.add(id, Some("frontend".into()), "header".into());
let f = &found.map[id];
- assert_eq!(f.label.as_deref(), Some("main backend (App Connect)"));
- assert_eq!(f.sources, vec!["ai-connect.html", "header"], "both provenances kept");
+ assert_eq!(f.label.as_deref(), Some("backend — main canister"));
+ assert_eq!(f.sources, vec!["ic-architecture", "header"], "both provenances kept");
}
// The proposed /.well-known/ic-app.json manifest: entries yield (id, label)
@@ -2508,6 +3012,187 @@ mod tests {
assert!(canisters_from_app_manifest(&long)[0].1.as_deref().unwrap().len() <= 120);
}
+ // The protocol's `name` labels a canister when no `role` is given, so a
+ // spec-shaped manifest (which uses both) still yields a readable label
+ // instead of a bare principal.
+ #[test]
+ fn manifest_labels_fall_back_to_the_protocol_name() {
+ // The example from the service-discoverability guide, verbatim.
+ let spec = r#"{
+ "version": "1.0.0",
+ "canisters": [
+ {"id": "hcv4s-uaaaa-aaabq-qaaba-cai", "name": "frontend", "role": "the frontend"},
+ {"id": "hmxr2-pqaaa-aaabq-qaaaa-cai", "name": "backend", "role": "the backend",
+ "description": "orders + inventory API; call getApiDoc() first"}
+ ]
+ }"#;
+ let got = canisters_from_app_manifest(spec);
+ assert_eq!(got[0].1.as_deref(), Some("the frontend"), "role wins over name");
+ assert_eq!(
+ got[1].1.as_deref(),
+ Some("the backend — orders + inventory API; call getApiDoc() first")
+ );
+ // `name` alone still labels the entry (role absent).
+ let name_only = r#"{"canisters":[{"id":"aaaaa-aa","name":"backend"}]}"#;
+ assert_eq!(canisters_from_app_manifest(name_only)[0].1.as_deref(), Some("backend"));
+ // …and it is sanitized like every other untrusted manifest string.
+ let sneaky = "{\"canisters\":[{\"id\":\"aaaaa-aa\",\"name\":\"ok\\u001b[31mEVIL\"}]}";
+ let label = canisters_from_app_manifest(sneaky)[0].1.clone().unwrap();
+ assert!(!label.chars().any(char::is_control), "{label:?}");
+ }
+
+ // The GATE's view of a manifest: `None` means "not a manifest document at
+ // all", which is what lets a refusal say "this app publishes none" instead of
+ // "it declares nothing". The distinction is not academic — the SPA catch-all
+ // that answers /.well-known/* with index.html (the failure the protocol guide
+ // calls out as the most common one) is exactly the case that must land in
+ // `None`, and it answers 200, so status alone would not catch it.
+ #[test]
+ fn manifest_canister_ids_are_parsed_principals_and_fail_closed() {
+ let manifest = r#"{
+ "version": "1.0.0",
+ "canisters": [
+ {"id": "hmxr2-pqaaa-aaabq-qaaaa-cai", "role": "backend"},
+ {"id": " hcv4s-uaaaa-aaabq-qaaba-cai ", "role": "frontend"},
+ {"id": "not-a-principal", "role": "junk"},
+ {"id": "aaaaa-aa", "role": "the management canister"},
+ {"id": "2vxsx-fae", "role": "the anonymous principal"}
+ ]
+ }"#;
+ let got = manifest_canister_ids(manifest).expect("a manifest document");
+ assert_eq!(
+ got.ids,
+ vec![
+ Principal::from_text("hmxr2-pqaaa-aaabq-qaaaa-cai").unwrap(),
+ Principal::from_text("hcv4s-uaaaa-aaabq-qaaba-cai").unwrap(),
+ ],
+ "ids are trimmed and parsed; a non-principal entry authorizes nothing, and \
+ neither does one naming something that is not a CANISTER principal — the \
+ management canister (an empty blob) and the anonymous principal both parse \
+ as principals, and the protocol requires an `id` to be a canister"
+ );
+ assert_eq!(got.omitted, 0, "nothing was past the cap");
+
+ // An app that publishes a manifest declaring nothing HAS adopted the
+ // protocol — that is a real, empty declaration, not a missing document.
+ let empty = manifest_canister_ids(r#"{"canisters":[]}"#).expect("a manifest document");
+ assert!(empty.ids.is_empty() && empty.omitted == 0);
+
+ // An over-long manifest is REPORTED, not silently truncated: entry 101 is
+ // declared by the app, so a "not declared" refusal about it would be a
+ // false statement — the count is carried so the refusal can say so.
+ let over = format!(
+ r#"{{"canisters":[{}]}}"#,
+ std::iter::repeat(r#"{"id":"hmxr2-pqaaa-aaabq-qaaaa-cai"}"#)
+ .take(MAX_MANIFEST_CANISTERS + 7)
+ .collect::>()
+ .join(",")
+ );
+ let over = manifest_canister_ids(&over).expect("a manifest document");
+ assert_eq!(over.ids.len(), MAX_MANIFEST_CANISTERS);
+ assert_eq!(over.omitted, 7, "the overflow is counted, not dropped silently");
+
+ // Not a manifest document → None, so the gate fails closed and reports
+ // absence rather than an empty declaration.
+ for body in [
+ "\nApp ", // the SPA catch-all
+ "{}", // a JSON object with no canisters key
+ r#"{"error":"not found"}"#, // an API error envelope
+ "[1,2,3]", // JSON, wrong shape
+ "", // empty body
+ ] {
+ assert!(
+ manifest_canister_ids(body).is_none(),
+ "must not read {body:?} as a manifest"
+ );
+ }
+ }
+
+ // Which non-success statuses may become "this app publishes no manifest" — a
+ // refusal that tells the app's operators to publish one and the agent to stop
+ // retrying. Only a definitive "not here" qualifies: an overloaded or rate-
+ // limited origin is a failure to find out, and reporting it as non-adoption
+ // would be a false statement about the app (per review).
+ #[test]
+ fn only_a_definitive_absence_reads_as_not_published() {
+ use reqwest::StatusCode;
+ for definitive in [StatusCode::NOT_FOUND, StatusCode::GONE] {
+ assert!(means_not_published(definitive), "{definitive} means not published");
+ }
+ for transient in [
+ StatusCode::TOO_MANY_REQUESTS,
+ StatusCode::INTERNAL_SERVER_ERROR,
+ StatusCode::BAD_GATEWAY,
+ StatusCode::SERVICE_UNAVAILABLE,
+ StatusCode::GATEWAY_TIMEOUT,
+ StatusCode::REQUEST_TIMEOUT,
+ // Not transient, but still not an answer: the origin is declining to
+ // say, which is not the same as saying no.
+ StatusCode::FORBIDDEN,
+ StatusCode::UNAUTHORIZED,
+ ] {
+ assert!(!means_not_published(transient), "{transient} must not read as not published");
+ }
+ }
+
+ // Layer 5: one canonical origin on one line. The SPA catch-all case matters
+ // most here — a wrongly-parsed derivation origin does not fail loudly, it
+ // silently derives the WRONG principal — so an HTML body must yield None (the
+ // app derives against its visible origin), never a garbage origin.
+ #[test]
+ fn derivation_origin_file_parses_one_line_and_fails_closed() {
+ // The guide's example.
+ assert_eq!(
+ parse_derivation_origin_file("https://hcv4s-uaaaa-aaabq-qaaba-cai.icp.net\n").as_deref(),
+ Some("https://hcv4s-uaaaa-aaabq-qaaba-cai.icp.net")
+ );
+ // Leading blank lines and surrounding whitespace are tolerated — they are
+ // not a claim about the content.
+ assert_eq!(
+ parse_derivation_origin_file("\n\n https://app.example.com \n").as_deref(),
+ Some("https://app.example.com")
+ );
+ // But the documented form is ENFORCED, not coerced (per review). This file
+ // decides which principal the user acts as, and a wrong one fails silently
+ // — as a call that simply isn't them — so a file its author got wrong reads
+ // as "declares nothing" (the app derives against its own origin, the
+ // protocol's own default for an absent file) rather than as an
+ // authoritative declaration of whatever we could salvage.
+ for malformed in [
+ "app.example.com", // bare host, not an origin
+ "https://app.example.com/path", // a URL, not an origin
+ "https://app.example.com/?x=1", // query
+ "https://app.example.com#f", // fragment
+ "https://app.example.com\nhttps://other.example", // two claims, not one
+ "https://app.example.com\nignored", // a trailing comment
+ ] {
+ assert_eq!(
+ parse_derivation_origin_file(malformed),
+ None,
+ "must not coerce {malformed:?} into a declaration"
+ );
+ }
+ // A trailing slash is the same origin written the other way, not a path.
+ assert_eq!(
+ parse_derivation_origin_file("https://app.example.com/").as_deref(),
+ Some("https://app.example.com")
+ );
+ // Fail closed: an SPA catch-all's HTML, a blank body, a non-https scheme,
+ // user-info, or an implausibly long line.
+ for body in [
+ "\n",
+ "",
+ " \n\n",
+ "http://app.example.com",
+ "ftp://app.example.com",
+ "https://u:p@app.example.com",
+ ] {
+ assert_eq!(parse_derivation_origin_file(body), None, "must reject {body:?}");
+ }
+ let long = format!("https://{}.example.com", "x".repeat(MAX_DERIVATION_ORIGIN_LINE));
+ assert_eq!(parse_derivation_origin_file(&long), None, "an over-long line is refused");
+ }
+
// The manifest's optional declared derivation origin is read and reduced to a
// bare origin; absent / blank / non-http values yield None (fail-closed).
#[test]
@@ -2662,6 +3347,33 @@ mod tests {
}
}
+ // A registered host on a NON-DEFAULT PORT is a different origin, and must not
+ // inherit the registry entry: `identities::target_origin` keeps the port (it
+ // strips only `:443`), so the write gate's identity binding compares the two as
+ // different apps — but only if the fallback stops handing out the real app's
+ // derivation origin first. Raised in review on #166.
+ #[test]
+ fn known_registry_does_not_match_a_non_default_port() {
+ let url = |u: &str| url::Url::parse(&normalize(u)).unwrap();
+ // The bare origin and its explicit default port still resolve.
+ assert_eq!(known_derivation_origin_for_url(&url("https://oisy.com")), Some("https://oisy.com"));
+ assert_eq!(known_derivation_origin_for_url(&url("https://oisy.com:443")), Some("https://oisy.com"));
+ // A non-default port does not, on any registered host.
+ for u in ["https://oisy.com:8443", "https://nns.ic0.app:8443", "https://multidex.ai:8080"] {
+ assert_eq!(
+ known_derivation_origin_for_url(&url(u)),
+ None,
+ "{u} is not the registered origin and must fall through to app_url_default",
+ );
+ }
+ // Falling through means the lookalike repair now has something to say: the
+ // agent is pointed at the canonical origin rather than left with the port.
+ assert_eq!(
+ similar_known_app("https://oisy.com:8443").map(|m| m.app_url),
+ Some("https://oisy.com".to_string()),
+ );
+ }
+
// Closure (offline): every derivation-origin VALUE in the registry is itself a
// key mapping to itself — so resolving a known app's derivation origin returns
// that same origin (source `known`), i.e. resolve_app is idempotent on it.
@@ -2734,7 +3446,10 @@ mod tests {
// presence — so an unrelated site echoing an empty/junk `x-ic-canister-id`
// can't fake IC hosting and slip past the guessed-domain guard. (The
// same-host attribution in ic_evidence_from — evidence must come from the
- // probed origin, not a redirect target — is exercised by the live tests.)
+ // probed origin, not a redirect target — is exercised by the live tests, as
+ // is the matching body pin in fetch_well_known: both need a response whose
+ // final URL differs from the probed one, and the SSRF guard refuses loopback,
+ // so neither can be driven from a local server here.)
#[test]
fn ic_gateway_header_requires_valid_principal_value() {
use reqwest::header::{HeaderMap, HeaderValue};
@@ -2840,6 +3555,13 @@ mod tests {
// Live network: a KNOWN app skips the IC probe entirely (the registry answers).
#[tokio::test]
async fn resolve_app_identity_skips_probe_for_known_apps() {
+ // Also the regression pin for over-cap Layer 5 bodies: oisy.com answers
+ // /.well-known/ii-derivation-origin with its SPA shell, which is larger
+ // than MAX_DERIVATION_ORIGIN_BYTES. That must read as "declares nothing"
+ // (Overflow::NotTheDocument — a one-line origin cannot be that big, so
+ // what was served is not the Layer 5 document), NOT as a failed check.
+ // Treating it as a failure refused to resolve a perfectly healthy app,
+ // which is how this test caught it.
let r = resolve_app_identity("oisy.com", false).await.expect("resolve");
assert_eq!(r.derivation_origin_source, DerivationSource::Known);
assert_eq!(r.application_is_ic, None, "known apps are not probed");
@@ -2891,7 +3613,7 @@ mod tests {
api_doc_available: None,
};
// App-declared / app-mined backends → candidates.
- assert!(is_app_data_candidate(&dc(None, &["ai-connect.html"], None)));
+ assert!(is_app_data_candidate(&dc(None, &["ic-architecture"], None)));
assert!(is_app_data_candidate(&dc(Some("backend"), &["ic-app.json"], None)));
assert!(is_app_data_candidate(&dc(Some("backend_canister_id"), &["env.json"], None)));
assert!(is_app_data_candidate(&dc(Some("BACKEND"), &["bundle:BACKEND"], None)));
diff --git a/crates/imcp2-core/src/discoverability.rs b/crates/imcp2-core/src/discoverability.rs
new file mode 100644
index 0000000..97fe49d
--- /dev/null
+++ b/crates/imcp2-core/src/discoverability.rs
@@ -0,0 +1,1094 @@
+//! The service-discoverability gate for the generic update-call tool.
+//!
+//! Reading the Internet Computer is open to everyone: any canister's Candid
+//! interface, metadata, and query methods are public, and this server treats
+//! them that way. WRITING is different. A state-changing call runs against
+//! someone's live application — it can create, mutate, or destroy records that
+//! app's operators are answerable for — and nothing about a canister being
+//! publicly callable means its operators want an AI agent driving it.
+//!
+//! So `canister_update_call` is restricted to canisters an app DECLARES in its
+//! **service-discoverability manifest**: the JSON document at
+//! [`ARCHITECTURE_PATH`] listing every canister the app comprises and each one's
+//! role (Layer 1 of the protocol, [`SERVICE_DISCOVERABILITY_GUIDE`]). Publishing
+//! that file is a deliberate act by the app's operators, and per the guide it is
+//! how they opt their app in: it says "these are my canisters, an agent handed my
+//! URL may work them out and use them". An app that has not published one has
+//! made no such statement, so this server does not write to it — it reads it,
+//! discovers it, and tells the agent what the app would have to publish.
+//!
+//! The gate needs to know WHICH app owns the target, because the manifest lives
+//! at the app's origin, not on chain. That is the `app_url` argument on
+//! `canister_update_call` (falling back to `derivation_origin` when the app
+//! serves its manifest there); `open_app` hands back exactly that URL alongside
+//! the canisters it discovered, so the normal flow already carries it.
+//!
+//! Three scope notes, so nobody over-claims what this gate does:
+//!
+//! * It is a **consent and provenance** gate, not a proof of ownership.
+//! Whoever controls a domain controls what its manifest says, so a manifest
+//! can name a canister its publisher does not own. What the gate guarantees
+//! is that SOMEONE published a document, at an origin the caller named,
+//! claiming that canister as part of their app — and that a write the user
+//! later questions can be traced back to that claim (the reply echoes the
+//! origin and path that authorized it). It does not, and cannot, establish
+//! that the claim was theirs to make.
+//! * It bounds the BLAST RADIUS of a confused or misled agent far more than it
+//! stops a determined attacker: an agent that has been talked into writing
+//! somewhere now has to be talked into naming an origin that declares the
+//! target as well, and the vast majority of the ~1.2M canisters on the IC are
+//! declared by no manifest at all. Two limits of that, spelled out because
+//! they are the ones a reader is most likely to assume away (per review):
+//! an ANONYMOUS write skips [`bind_identity`] entirely — there is no app
+//! identity to protect — so an attacker who declares a victim canister in
+//! their own manifest can have this connector make an anonymous call to it;
+//! and an AUTHENTICATED write binds to the attacker's own app identity while
+//! still reaching any victim method that accepts an arbitrary principal.
+//! Neither grants a capability the attacker did not already have — anyone can
+//! send either call to a public canister with an ordinary agent, since the IC
+//! accepts ingress from anywhere — so what the gate withholds is this
+//! connector's willingness to make such a call ON A USER'S BEHALF, and the
+//! binding is what keeps the user's OWN app principals out of it. Closing the
+//! rest would take an association the TARGET attests to, which the protocol
+//! does not define today (nothing a canister publishes names its app's
+//! origin); it is raised on the pull request rather than invented here.
+//! * It gates **writes only**. Reads (`canister_query`, `get_canister_candid`,
+//! the OQL surface) and discovery are unchanged on every canister: the
+//! protocol exists to make apps *more* legible to agents, and it would be a
+//! strange reading of it to make this server see less.
+//!
+//! Only [`ARCHITECTURE_PATH`] authorizes. This server proposed the same document
+//! at [`discover::LEGACY_MANIFEST_PATH`] before the protocol was published, and
+//! discovery still READS it — but it cannot authorize a write, because the
+//! operators who adopted that proposal published it against different terms and
+//! never agreed to the ones publishing the protocol manifest now signifies
+//! ( ). Consent that was never given
+//! cannot be inherited from a path this server invented, so an early adopter is
+//! refused — and told, precisely, that serving the same JSON at the standard
+//! path is all that is required.
+
+use candid::Principal;
+
+use crate::discover::{self, ARCHITECTURE_PATH, SERVICE_DISCOVERABILITY_GUIDE};
+
+/// Which argument the checked origin came from, so a refusal can name the
+/// argument the agent should actually fix.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum OriginSource {
+ /// The caller's `app_url` — the intended input.
+ AppUrl,
+ /// The caller's `derivation_origin`, used because no `app_url` was given.
+ /// Usually the same origin, but NOT always: an app that pins a custom
+ /// derivation origin serves its manifest at its application origin, so a
+ /// refusal here has to suggest passing `app_url` explicitly.
+ DerivationOrigin,
+}
+
+impl OriginSource {
+ fn arg(self) -> &'static str {
+ match self {
+ OriginSource::AppUrl => "`app_url`",
+ OriginSource::DerivationOrigin => "`derivation_origin` (no `app_url` was given)",
+ }
+ }
+}
+
+/// What authorized an update call: the app origin whose manifest declares the
+/// target canister, and the well-known path that manifest was read from. Echoed
+/// in the tool's reply so the write's provenance is visible to the user, not just
+/// to the gate.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Declaration {
+ pub origin: String,
+ pub path: &'static str,
+}
+
+/// How many declared ids a refusal lists back. Enough to pick the right canister
+/// from a real app's manifest, bounded so an app with a hundred entries can't
+/// turn one refusal into a wall of principals.
+const MAX_LISTED_DECLARED: usize = 12;
+
+/// The gate: `Ok(declaration)` when the app at `app_origin` declares
+/// `canister_id` in its service-discoverability manifest, `Err(refusal)` — the
+/// complete tool error text — in every other case. Fails CLOSED: an unreachable
+/// origin, an unparseable document, and an absent manifest all refuse.
+pub async fn authorize_update_call(
+ app_origin: &str,
+ source: OriginSource,
+ canister_id: &Principal,
+) -> Result {
+ match discover::fetch_declared_manifest(app_origin).await {
+ Ok(probe) => decide(probe, app_origin, source, canister_id),
+ Err(e) => Err(unreachable_refusal(app_origin, source, &e)),
+ }
+}
+
+/// The gate's decision, separated from the fetch that feeds it (per review). The
+/// live tests cover the YES path against a real adopter, but they depend on
+/// someone else's deploy staying up and publishing; the decision itself — a
+/// declared canister authorizes, and each way of not being declared refuses
+/// differently — is pinned here on constructed input, where no network can make
+/// it skip. It cannot be tested by pointing the fetch at a local server either:
+/// the SSRF guard resolves and pins public addresses before any request, so a
+/// loopback origin is refused before a fixture could answer.
+fn decide(
+ probe: discover::ManifestProbe,
+ app_origin: &str,
+ source: OriginSource,
+ canister_id: &Principal,
+) -> Result {
+ let manifest = match probe {
+ discover::ManifestProbe::Declared(m) => m,
+ // An origin still on the pre-protocol path gets its own refusal: it HAS
+ // published something, so reporting it as publishing nothing would send
+ // its operators looking for a file that is already there, when what they
+ // actually have to do is serve it at the standard path.
+ discover::ManifestProbe::Absent { legacy: Some(legacy), .. } => {
+ return Err(legacy_only_refusal(&legacy, canister_id, source))
+ }
+ discover::ManifestProbe::Absent { served_non_manifest, legacy: None } => {
+ return Err(no_manifest_refusal(app_origin, source, served_non_manifest.as_deref()))
+ }
+ };
+ if manifest.canisters.contains(canister_id) {
+ return Ok(Declaration { origin: manifest.origin, path: manifest.path });
+ }
+ Err(not_declared_refusal(&manifest, canister_id, source))
+}
+
+/// The identity half of the gate: the app whose manifest authorizes the write
+/// must be the app the write is SIGNED as.
+///
+/// The manifest gate alone establishes that someone published a document at the
+/// origin the caller named. It says nothing about whose identity the call goes
+/// out under, and those are separable inputs: `app_url` picks the manifest,
+/// `derivation_origin` picks the principal. Left unbound, an origin the attacker
+/// controls could declare any canister — declaring one is free, and the gate
+/// deliberately does not prove ownership — while the call was signed with the
+/// principal the user holds at a DIFFERENT app they actually trusted. Requiring
+/// the app to resolve to the identity being used removes that pairing: an
+/// attacker's manifest can only ever authorize writes made as the attacker's own
+/// app identity, which is worth nothing to them.
+///
+/// The comparison is against what the app ITSELF resolves to — its declared Layer
+/// 5 origin, else a known-app value, else its own origin — not against the app URL
+/// literally, so the many apps whose derivation origin differs from their website
+/// (13 of 17 in the built-in registry) still pass. `resolve_app_identity` also
+/// enforces Internet Identity's own rule on the way: a cross-origin declaration
+/// counts only if the declared origin authorizes this app in its
+/// `ii-alternative-origins`, so an app cannot simply claim another's identity to
+/// satisfy this check.
+///
+/// Fails CLOSED: an origin that cannot be resolved refuses, rather than being
+/// treated as a match.
+pub async fn bind_identity(
+ app_origin: &str,
+ requested_identity: &str,
+ canister_id: &Principal,
+) -> Result<(), String> {
+ let identity = discover::resolve_app_identity(app_origin, false)
+ .await
+ .map_err(|e| identity_unresolvable_refusal(app_origin, &e))?;
+ // Compare canonical forms: `requested_identity` has been through the identity
+ // path's canonicalization (which remaps the *.icp0.io / *.icp.net gateway
+ // hosts to *.ic0.app), so put the resolved value through the same one or the
+ // same app spelled two ways would read as two apps.
+ let app_identity = crate::identities::target_origin(&identity.derivation_origin);
+ if app_identity.eq_ignore_ascii_case(requested_identity) {
+ return Ok(());
+ }
+ Err(identity_mismatch_refusal(app_origin, &app_identity, requested_identity, canister_id))
+}
+
+/// Cap on any externally-influenced string a refusal echoes back. A transport
+/// error can carry a hostile origin's TLS certificate subject or redirect URL,
+/// and a refusal is text the model reads: pass it through the same control-char
+/// scrub the manifest labels use, and keep it short enough that it cannot become
+/// the bulk of the message.
+const MAX_ECHOED_CAUSE: usize = 200;
+
+/// Whether a check failure is one retrying cannot clear. Telling a caller to
+/// retry a permanent configuration is advice that cannot work, and the two need
+/// different next steps.
+///
+/// The strings matched here are OURS — the fetch helpers format `HTTP {status}`
+/// and the over-size message a few lines apart — not anything a remote origin
+/// controls, so this is coupling inside the crate rather than parsing hostile
+/// input. A typed error carried up from the fetch would be sturdier; that is a
+/// larger change than the defect warrants, and this is documented in its place
+/// (per review: the previous check looked for the word "redirect", which a
+/// stopped redirect never contains — it keeps its 3xx status).
+fn cause_is_persistent(cause: &str) -> bool {
+ // The two client statuses that DO clear on their own.
+ if cause.contains("HTTP 408") || cause.contains("HTTP 429") {
+ return false;
+ }
+ // A stopped redirect keeps its 3xx; a 4xx is the origin declining to serve
+ // the path rather than failing to; an over-size document will not shrink; and
+ // a cross-origin claim its declared origin does not authorize is a
+ // misconfiguration at the app.
+ cause.contains("HTTP 3")
+ || cause.contains("HTTP 4")
+ || cause.contains("larger than")
+ || cause.contains("ii-alternative-origins")
+}
+
+/// Scrub and cap an ORIGIN before it reaches the model. `app_url` is
+/// caller-supplied and only its origin is ever fetched, so the origin is what a
+/// refusal should name — but the value reaching a refusal has been through
+/// validation, not necessarily through origin reduction, and this module claims a
+/// bound on every externally-influenced string it echoes. Applying the same cap
+/// here makes that claim hold whatever the caller passed (per review).
+fn safe_origin(origin: &str) -> String {
+ safe_cause(origin)
+}
+
+/// Scrub and cap an error string before it reaches the model (CWE-150).
+fn safe_cause(cause: &str) -> String {
+ let scrubbed: String = cause
+ .chars()
+ .map(|c| if c.is_control() { ' ' } else { c })
+ .take(MAX_ECHOED_CAUSE)
+ .collect();
+ scrubbed.trim().to_string()
+}
+
+/// One sentence, in every refusal, stating the rule the caller just hit. Kept in
+/// one place so the policy can never be described two different ways.
+fn the_rule() -> String {
+ format!(
+ "This server makes state-changing calls ONLY to canisters an app declares in its \
+ service-discoverability manifest at {ARCHITECTURE_PATH} — publishing it is how an app's \
+ operators opt in to being discovered and operated by agents ({SERVICE_DISCOVERABILITY_GUIDE})."
+ )
+}
+
+/// One sentence, in every refusal, making clear that only WRITES are gated — so
+/// an agent doesn't conclude the whole app is off limits and give up on a
+/// question it could still answer by reading.
+const READS_ARE_FINE: &str = "Reading is unaffected: canister_query, get_canister_candid, \
+ get_canister_api_doc, the OQL tools and the discovery tools work on this canister as before, \
+ so answer what you can by reading.";
+
+/// No `app_url` and no `derivation_origin`: the gate has no origin to check, so
+/// it cannot even start. Names the missing argument and where to get it.
+pub fn missing_origin_refusal(canister_id: &Principal) -> String {
+ format!(
+ "`canister_update_call` needs to know which APP owns {canister_id} before it will write to \
+ it: pass `app_url` (the app's website URL, e.g. https://app.example.com). {} Get the URL \
+ from open_app — it returns `app_url` alongside the canisters it discovered — or from the \
+ user. {READS_ARE_FINE}",
+ the_rule()
+ )
+}
+
+/// The origin could not be reached at all. Distinct from "publishes no manifest":
+/// we do not know, so the refusal is retryable rather than a verdict on the app.
+fn unreachable_refusal(app_origin: &str, source: OriginSource, error: &str) -> String {
+ let app_origin = safe_origin(app_origin);
+ // Not every check failure clears on its own: an origin answering 401/403 on
+ // the path is denying it deliberately, a stopped redirect is a configuration
+ // this server will not follow, and an over-size document stays over-size.
+ let next = if cause_is_persistent(error) {
+ "Retrying will not clear this one: the origin is refusing to serve that path, or serving \
+ it from somewhere this server will not follow. Its operators fix it by making the \
+ manifest publicly readable at that exact path on this origin."
+ } else {
+ "This is likely transient — retry; if it persists, confirm that the origin is right."
+ };
+ format!(
+ "Could not check whether {app_origin} declares this canister: {}. {} The call is \
+ refused rather than made blind. {next} Confirm that {} names the app's real origin \
+ (open_app returns it). {READS_ARE_FINE}",
+ safe_cause(error),
+ the_rule(),
+ source.arg()
+ )
+}
+
+/// The origin answered, but serves no manifest at either well-known path. The
+/// common causes are a wrong origin and an app that simply has not adopted the
+/// protocol, so the refusal addresses both and tells the agent what to say.
+fn no_manifest_refusal(
+ app_origin: &str,
+ source: OriginSource,
+ served_non_manifest: Option<&str>,
+) -> String {
+ let app_origin = safe_origin(app_origin);
+ let mut msg = format!(
+ "{app_origin} publishes no service-discoverability manifest at {ARCHITECTURE_PATH}, so \
+ this connector will not make a state-changing call to its canisters. {} ",
+ the_rule()
+ );
+ // The origin DID answer that path, just not with a manifest. Naming what it
+ // answered with turns "your app publishes nothing" into something its
+ // operator can act on from a relayed transcript — and `text/html` is the
+ // exact signature of the SPA catch-all the guide calls the most common
+ // failure, which an operator would otherwise chase as a missing file.
+ if let Some(kind) = served_non_manifest {
+ let kind = safe_cause(kind);
+ msg.push_str(&format!(
+ "It DOES answer that path, but with {} rather than the manifest JSON — the usual cause \
+ is a single-page-app catch-all returning index.html for unknown paths, which the app \
+ fixes by exempting /.well-known/* from the SPA rewrite. ",
+ if kind.is_empty() { "a non-manifest document".to_string() } else { format!("`{kind}`") }
+ ));
+ }
+ if source == OriginSource::DerivationOrigin {
+ msg.push_str(
+ "This origin came from `derivation_origin`, which is not always where the app serves \
+ its manifest — pass the app's website URL as `app_url` and try again. ",
+ );
+ }
+ msg.push_str(&format!(
+ "Otherwise: if this is NOT the app's origin (a marketing site, a docs host, a guessed \
+ domain), pass the right `app_url` — open_app resolves one from the app's name or URL. If \
+ it IS the app's origin, the app has not adopted the protocol, and re-running open_app \
+ will not change that: STOP retrying, tell the user this write cannot be made for them \
+ here, and that the app's operators enable it by publishing the manifest \
+ ({SERVICE_DISCOVERABILITY_GUIDE}); the skill://service-discoverability resource carries \
+ the deploy-time recipe for generating it, if the user is the one who can ship it. They \
+ can also perform the action themselves in the app's own frontend. {READS_ARE_FINE}"
+ ));
+ msg
+}
+
+/// The origin still serves this server's PRE-PROTOCOL document and nothing at
+/// the standard path. It has published something, so the "publishes no manifest"
+/// verdict would be wrong and would send its operators hunting for a file that is
+/// already there. What it has not done is opt in under the terms the protocol
+/// manifest now carries, and that consent cannot be back-filled from a path this
+/// server invented — so the refusal names the document it found, says plainly
+/// that serving the same JSON at the standard path is the whole fix, and (when
+/// the older document does list the target) makes clear the refusal is about
+/// WHERE the declaration lives, not about the canister being unknown.
+fn legacy_only_refusal(
+ legacy: &discover::DeclaredManifest,
+ canister_id: &Principal,
+ source: OriginSource,
+) -> String {
+ let mut msg = format!(
+ "{} serves this connector's older, pre-protocol document at {} but publishes no \
+ service-discoverability manifest at {ARCHITECTURE_PATH}, so this connector will not make \
+ a state-changing call to its canisters. {} ",
+ legacy.origin,
+ legacy.path,
+ the_rule()
+ );
+ msg.push_str(if legacy.canisters.contains(canister_id) {
+ "That older document DOES list this canister, but it cannot authorize the write: it \
+ predates the protocol, and its publishers never accepted the terms that publishing the \
+ standard manifest now signifies (https://internetcomputer.org/icp-mcp/terms/). "
+ } else {
+ "That older document does not list this canister either. "
+ });
+ if source == OriginSource::DerivationOrigin {
+ msg.push_str(
+ "This origin came from `derivation_origin`, which is not always where the app serves \
+ its manifest — if the app publishes one elsewhere, pass that website URL as `app_url` \
+ and try again. ",
+ );
+ }
+ msg.push_str(&format!(
+ "For the app's operators the fix is small and entirely theirs to make: serve the same JSON \
+ at {ARCHITECTURE_PATH}, which is how they opt the app in \
+ ({SERVICE_DISCOVERABILITY_GUIDE}); the skill://service-discoverability resource carries \
+ the deploy-time recipe. Until they do, STOP retrying: tell the user this write cannot be \
+ made for them here, and that they can perform the action themselves in the app's own \
+ frontend. {READS_ARE_FINE}"
+ ));
+ msg
+}
+
+/// The caller named an app whose manifest authorizes the write, but asked to sign
+/// as a DIFFERENT app's identity. Without this check the manifest gate could be
+/// satisfied by an origin the attacker controls while the call went out under the
+/// principal the user holds somewhere else: any site can publish a manifest naming
+/// any canister, so a declaration only means something when it comes from the app
+/// whose identity is signing. The refusal names both origins, because which one is
+/// wrong depends on what the caller meant to do.
+fn identity_mismatch_refusal(
+ app_origin: &str,
+ app_identity: &str,
+ requested_identity: &str,
+ canister_id: &Principal,
+) -> String {
+ let (app_origin, app_identity, requested_identity) =
+ (safe_origin(app_origin), safe_origin(app_identity), safe_origin(requested_identity));
+ format!(
+ "The app at {app_origin} is not the app this call would be signed as. Internet \
+ Identity derives that app's users from {app_identity}, while this call asks to act as \
+ {requested_identity}. Writing to {canister_id} on that combination is refused: a \
+ manifest published at one origin does not authorize a write made under another app's \
+ identity, or any site could declare a canister and have this connector write to it as \
+ you, at an app you trusted for something else. If {canister_id} belongs to the app you \
+ are acting at, pass THAT app's URL as `app_url` — open_app returns the `app_url` and \
+ the `derivation_origin` of one app together, so a pair from a single open_app call \
+ always matches. If you meant to act at {app_origin} instead, pass its own derivation \
+ origin. {READS_ARE_FINE}"
+ )
+}
+
+/// The binding above could not be established at all — the app origin would not
+/// resolve to a derivation origin (unreachable, or a cross-origin declaration its
+/// declared origin does not authorize). Distinct from a mismatch: we do not know
+/// that the two disagree, only that we cannot show they agree, and the call is
+/// refused rather than made blind.
+fn identity_unresolvable_refusal(app_origin: &str, cause: &str) -> String {
+ // Not every failure here is a blip: `resolve_app_identity` also refuses a
+ // cross-origin derivation-origin declaration that the declared origin does
+ // not authorize in its ii-alternative-origins, and no amount of retrying
+ // repairs that — it is a misconfiguration (or a spoof) at the app. Telling a
+ // caller to retry it would be advice that cannot work, so the two cases get
+ // different next steps (per review).
+ let next = if cause.contains("ii-alternative-origins") {
+ "This one does not resolve itself: the app claims a derivation origin that does not \
+ authorize it back, which its operators fix by listing this origin in that file. \
+ Retrying will not change it."
+ } else if cause_is_persistent(cause) {
+ "Retrying will not clear this one either: the origin is refusing to serve the path this \
+ check reads, serving it from somewhere this server will not follow, or serving something \
+ too large to read. Its operators fix it by making that well-known path publicly readable \
+ at this exact origin."
+ } else {
+ "This is likely transient — retry; if it persists, confirm that `app_url` names the app \
+ you are acting at (open_app returns its `app_url` and `derivation_origin` together)."
+ };
+ format!(
+ "Could not establish that {} is the app this call would be signed as: {}. The call is \
+ refused rather than made blind, because a manifest only authorizes a write when it \
+ comes from the app whose identity is signing. {next} {READS_ARE_FINE}",
+ safe_origin(app_origin),
+ safe_cause(cause)
+ )
+}
+
+/// The app publishes a manifest, but this canister is not in it. The most useful
+/// thing a refusal can do here is show what the app DOES declare, so an agent
+/// that picked the wrong id out of a discovery listing can correct itself in one
+/// step instead of guessing again.
+fn not_declared_refusal(
+ manifest: &discover::DeclaredManifest,
+ canister_id: &Principal,
+ source: OriginSource,
+) -> String {
+ let listed: Vec = manifest
+ .canisters
+ .iter()
+ .take(MAX_LISTED_DECLARED)
+ .map(Principal::to_text)
+ .collect();
+ let declares = if listed.is_empty() {
+ "declares no canisters at all".to_string()
+ } else {
+ let more = manifest.canisters.len().saturating_sub(listed.len());
+ let suffix = if more > 0 { format!(" (+{more} more)") } else { String::new() };
+ format!("declares: {}{suffix}", listed.join(", "))
+ };
+ // An over-long manifest is the one case where "not declared" would be a FALSE
+ // statement about the app: entries past the read cap ARE declared, we just did
+ // not read them. So the VERDICT changes rather than being qualified after the
+ // fact — leading with "does not declare" and admitting two sentences later
+ // that we never looked is the same false assertion this handling exists to
+ // avoid (per review).
+ if manifest.omitted > 0 {
+ return format!(
+ "Could not determine whether {} declares {canister_id}: its manifest ({}) carries {} \
+ entries beyond the limit this server reads, which were NOT checked, so this connector \
+ will not make a state-changing call to it. {} Of what WAS read, it {declares}. If \
+ {canister_id} is one of the unread entries, the manifest is too long and its \
+ operators should shorten it ({SERVICE_DISCOVERABILITY_GUIDE}); if it belongs to a \
+ DIFFERENT app, pass that app's URL as `app_url` instead of {}. {READS_ARE_FINE}",
+ manifest.origin,
+ manifest.path,
+ manifest.omitted,
+ the_rule(),
+ source.arg()
+ );
+ }
+ format!(
+ "{} does not declare {canister_id} in its manifest ({}), so this connector will not make a \
+ state-changing call to it. It {declares}. {} Use one of the declared canisters for this \
+ operation. If {canister_id} really is part of this app, its operators must add it to the \
+ manifest ({SERVICE_DISCOVERABILITY_GUIDE}); if it belongs to a DIFFERENT app, pass that \
+ app's URL as `app_url` instead of {}. {READS_ARE_FINE}",
+ manifest.origin,
+ manifest.path,
+ the_rule(),
+ source.arg()
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::discover::LEGACY_MANIFEST_PATH;
+
+ fn canister(text: &str) -> Principal {
+ Principal::from_text(text).unwrap()
+ }
+ fn backend() -> Principal {
+ canister("hmxr2-pqaaa-aaabq-qaaaa-cai")
+ }
+ fn frontend() -> Principal {
+ canister("hcv4s-uaaaa-aaabq-qaaba-cai")
+ }
+ fn manifest(origin: &str, path: &'static str, ids: &[Principal]) -> discover::DeclaredManifest {
+ with_omitted(origin, path, ids, 0)
+ }
+ fn with_omitted(
+ origin: &str,
+ path: &'static str,
+ ids: &[Principal],
+ omitted: usize,
+ ) -> discover::DeclaredManifest {
+ discover::DeclaredManifest {
+ origin: origin.to_string(),
+ path,
+ canisters: ids.to_vec(),
+ omitted,
+ }
+ }
+
+ // Every MANIFEST refusal states the same rule, names the standard path, and
+ // links the guide — an app owner reading a relayed refusal must be able to act
+ // on it without anyone explaining the protocol to them first.
+ //
+ // The two identity refusals are deliberately not in this list: their repair
+ // belongs to the CALLER (pass an `app_url` and `derivation_origin` from one
+ // open_app call), not to the app's operators, and quoting the adoption rule at
+ // someone whose app already publishes a manifest would send them to fix
+ // something that is not broken. What they must say instead is pinned by
+ // `the_identity_mismatch_refusal_names_both_apps`.
+ #[test]
+ fn every_manifest_refusal_states_the_rule_and_links_the_guide() {
+ let refusals = [
+ missing_origin_refusal(&backend()),
+ unreachable_refusal("https://app.example.com", OriginSource::AppUrl, "timed out"),
+ no_manifest_refusal("https://app.example.com", OriginSource::AppUrl, None),
+ not_declared_refusal(
+ &manifest("https://app.example.com", ARCHITECTURE_PATH, &[frontend()]),
+ &backend(),
+ OriginSource::AppUrl,
+ ),
+ legacy_only_refusal(
+ &manifest("https://app.example.com", LEGACY_MANIFEST_PATH, &[backend()]),
+ &backend(),
+ OriginSource::AppUrl,
+ ),
+ ];
+ for msg in refusals {
+ assert!(msg.contains(ARCHITECTURE_PATH), "must name the standard path: {msg}");
+ assert!(
+ msg.contains(SERVICE_DISCOVERABILITY_GUIDE),
+ "must link the guide so the app can adopt it: {msg}"
+ );
+ }
+ }
+
+ // A refusal must not read as "this app is off limits": writes are gated,
+ // reads are not, and an agent that stops reading has been over-refused.
+ #[test]
+ fn refusals_keep_the_reading_path_open() {
+ for msg in [
+ missing_origin_refusal(&backend()),
+ no_manifest_refusal("https://app.example.com", OriginSource::AppUrl, None),
+ not_declared_refusal(
+ &manifest("https://app.example.com", ARCHITECTURE_PATH, &[frontend()]),
+ &backend(),
+ OriginSource::AppUrl,
+ ),
+ legacy_only_refusal(
+ &manifest("https://app.example.com", LEGACY_MANIFEST_PATH, &[backend()]),
+ &backend(),
+ OriginSource::AppUrl,
+ ),
+ identity_mismatch_refusal(
+ "https://evil.example",
+ "https://evil.example",
+ "https://gooddapp.com",
+ &backend(),
+ ),
+ identity_unresolvable_refusal("https://app.example.com", "timed out"),
+ ] {
+ assert!(msg.contains("canister_query"), "must point at the read path: {msg}");
+ }
+ }
+
+ // "Could not ask" is not "the app has not adopted the protocol". The
+ // unreachable refusal must read as retryable and must NOT accuse the app of
+ // publishing nothing — that verdict needs an answer from the origin.
+ #[test]
+ fn unreachable_is_retryable_not_a_verdict() {
+ let msg = unreachable_refusal("https://app.example.com", OriginSource::AppUrl, "timed out");
+ assert!(msg.contains("timed out"), "surfaces the cause: {msg}");
+ assert!(msg.contains("retry"), "must invite a retry: {msg}");
+ assert!(!msg.contains("publishes no"), "must not conclude absence: {msg}");
+ }
+
+ // The "no manifest" refusal separates the two real causes — wrong origin vs
+ // an app that hasn't adopted the protocol — and, when the origin came from
+ // `derivation_origin` rather than `app_url`, says so first: that is the one
+ // case where the SAME app might still pass with the right argument.
+ #[test]
+ fn no_manifest_refusal_distinguishes_wrong_origin_from_no_adoption() {
+ let from_url = no_manifest_refusal("https://app.example.com", OriginSource::AppUrl, None);
+ assert!(from_url.contains("`app_url`"), "{from_url}");
+ assert!(!from_url.contains("came from `derivation_origin`"), "{from_url}");
+
+ let from_origin =
+ no_manifest_refusal("https://app.example.com", OriginSource::DerivationOrigin, None);
+ assert!(
+ from_origin.contains("came from `derivation_origin`"),
+ "must suggest the app_url retry first: {from_origin}"
+ );
+ }
+
+ // A wrong pick out of a discovery listing is the common case, so the refusal
+ // shows what the app DOES declare — bounded, so a hundred-entry manifest
+ // can't turn one refusal into a wall of principals.
+ #[test]
+ fn not_declared_refusal_lists_what_the_app_declares() {
+ let msg = not_declared_refusal(
+ &manifest("https://app.example.com", ARCHITECTURE_PATH, &[frontend()]),
+ &backend(),
+ OriginSource::AppUrl,
+ );
+ assert!(msg.contains(&frontend().to_text()), "shows the declared id: {msg}");
+ assert!(msg.contains(&backend().to_text()), "names the refused id: {msg}");
+
+ let many: Vec = (0..30).map(|_| frontend()).collect();
+ let msg = not_declared_refusal(
+ &manifest("https://app.example.com", ARCHITECTURE_PATH, &many),
+ &backend(),
+ OriginSource::AppUrl,
+ );
+ assert_eq!(
+ msg.matches(&frontend().to_text()).count(),
+ MAX_LISTED_DECLARED,
+ "the listing is capped: {msg}"
+ );
+ assert!(msg.contains("+18 more"), "and the remainder is reported: {msg}");
+ }
+
+ // An app that publishes an EMPTY manifest has adopted the protocol and
+ // declared nothing — say that, rather than printing "declares: ".
+ #[test]
+ fn empty_manifest_is_reported_as_declaring_nothing() {
+ let msg = not_declared_refusal(
+ &manifest("https://app.example.com", ARCHITECTURE_PATH, &[]),
+ &backend(),
+ OriginSource::AppUrl,
+ );
+ assert!(msg.contains("declares no canisters at all"), "{msg}");
+ assert!(msg.contains(ARCHITECTURE_PATH), "names the path that answered: {msg}");
+ }
+
+ // A gate refusal must never borrow the vocabulary of a DIFFERENT failure.
+ // Two neighbours are dangerous here: the read-only-session rejection, whose
+ // repair is reconnecting with "Actions & questions" (an agent sent down that
+ // path would ask the user to re-authenticate for a problem authentication
+ // cannot fix), and the financial-methods refusal, whose repair is a wallet.
+ // Neither has anything to do with an app that has not published a manifest.
+ #[test]
+ fn refusals_never_borrow_another_failures_repair() {
+ for msg in [
+ missing_origin_refusal(&backend()),
+ unreachable_refusal("https://app.example.com", OriginSource::AppUrl, "timed out"),
+ no_manifest_refusal("https://app.example.com", OriginSource::AppUrl, None),
+ no_manifest_refusal("https://app.example.com", OriginSource::AppUrl, Some("text/html")),
+ not_declared_refusal(
+ &manifest("https://app.example.com", ARCHITECTURE_PATH, &[frontend()]),
+ &backend(),
+ OriginSource::AppUrl,
+ ),
+ legacy_only_refusal(
+ &manifest("https://app.example.com", LEGACY_MANIFEST_PATH, &[backend()]),
+ &backend(),
+ OriginSource::AppUrl,
+ ),
+ identity_mismatch_refusal(
+ "https://evil.example",
+ "https://evil.example",
+ "https://gooddapp.com",
+ &backend(),
+ ),
+ identity_unresolvable_refusal("https://app.example.com", "timed out"),
+ ] {
+ for wrong in [
+ "reconnect",
+ "Actions & questions",
+ "Questions only",
+ "financial",
+ "oisy.com",
+ ] {
+ assert!(!msg.contains(wrong), "must not say {wrong:?}: {msg}");
+ }
+ }
+ }
+
+ // The origin answered the protocol path, just not with a manifest. Saying so
+ // — and naming the content type — turns "your app publishes nothing" into a
+ // diagnosis its operator can act on: `text/html` at that path IS the SPA
+ // catch-all the protocol guide calls the most common failure, and an operator
+ // told only "absent" would go looking for a file that is already there.
+ #[test]
+ fn no_manifest_refusal_names_the_spa_catch_all() {
+ let msg =
+ no_manifest_refusal("https://app.example.com", OriginSource::AppUrl, Some("text/html"));
+ assert!(msg.contains("`text/html`"), "names what was served: {msg}");
+ assert!(msg.contains("single-page-app catch-all"), "names the cause: {msg}");
+ assert!(msg.contains("/.well-known/*"), "names the fix: {msg}");
+
+ // Nothing was served there at all: no diagnosis to offer, and none invented.
+ let absent = no_manifest_refusal("https://app.example.com", OriginSource::AppUrl, None);
+ assert!(!absent.contains("catch-all"), "{absent}");
+ }
+
+ // An app that publishes no manifest will still publish none after another
+ // open_app, so the refusal must break the loop rather than send the agent
+ // back to re-resolve an origin it already has right. It also points at the
+ // served how-to skill: the user hitting this refusal is sometimes the very
+ // person who can ship the manifest, and "publish a manifest" is a much
+ // weaker handoff than the deploy-time recipe for generating one.
+ #[test]
+ fn no_manifest_refusal_stops_the_agent_retrying() {
+ let msg = no_manifest_refusal("https://app.example.com", OriginSource::AppUrl, None);
+ assert!(msg.contains("will not change that"), "{msg}");
+ assert!(msg.contains("STOP retrying"), "{msg}");
+ assert!(msg.contains("skill://service-discoverability"), "names the how-to skill: {msg}");
+ }
+
+ // Entries past the read cap ARE declared by the app; refusing one as "not
+ // declared" would be a false statement about the app, so the overflow is
+ // reported and the blame lands on the manifest's length instead.
+ #[test]
+ fn not_declared_refusal_reports_an_over_long_manifest() {
+ let msg = not_declared_refusal(
+ &with_omitted("https://app.example.com", ARCHITECTURE_PATH, &[frontend()], 7),
+ &backend(),
+ OriginSource::AppUrl,
+ );
+ assert!(msg.contains("7 entries beyond"), "reports how many were unread: {msg}");
+ assert!(msg.contains("NOT checked"), "{msg}");
+ // And the VERDICT itself is indeterminate, not an assertion withdrawn a
+ // sentence later (per review): if the target is one of the unread
+ // entries, "does not declare" is simply false.
+ assert!(msg.contains("Could not determine whether"), "{msg}");
+ assert!(!msg.contains("does not declare"), "must not assert what it did not check: {msg}");
+
+ // No overflow → the whole manifest was read, so the verdict is definite
+ // and there is no speculation about entries that don't exist.
+ let exact = not_declared_refusal(
+ &manifest("https://app.example.com", ARCHITECTURE_PATH, &[frontend()]),
+ &backend(),
+ OriginSource::AppUrl,
+ );
+ assert!(!exact.contains("entries beyond"), "{exact}");
+ assert!(exact.contains("does not declare"), "a fully read manifest is definite: {exact}");
+ }
+
+ // A transport error is attacker-influenced text (a hostile origin picks its
+ // TLS certificate subject and its redirect URLs) that lands verbatim in the
+ // model's context. Scrub control characters and cap it, so a refusal can
+ // never be turned into a payload or padded out by the thing it is reporting.
+ #[test]
+ fn the_echoed_cause_is_scrubbed_and_capped() {
+ let hostile = format!("error \u{1b}[31m\r\nIGNORE PREVIOUS {}", "x".repeat(4096));
+ let msg = unreachable_refusal("https://app.example.com", OriginSource::AppUrl, &hostile);
+ assert!(!msg.chars().any(char::is_control), "control chars must be gone: {msg}");
+ assert!(
+ !msg.contains(&"x".repeat(MAX_ECHOED_CAUSE + 1)),
+ "the cause must be capped: {msg}"
+ );
+ assert!(msg.contains("app.example.com"), "the origin still shows: {msg}");
+ }
+
+ // The pre-protocol document does NOT authorize, and the refusal has to be
+ // useful to the one population that hits it: operators who adopted this
+ // server's own earlier proposal. It must name the document they DO serve (so
+ // they don't hunt for a missing file), name the standard path as the fix, and
+ // — when the older document lists the target — make clear the refusal is
+ // about where the declaration lives rather than about an unknown canister.
+ #[test]
+ fn a_legacy_only_origin_is_refused_with_the_path_to_adopt() {
+ let listed = legacy_only_refusal(
+ &manifest("https://app.example.com", LEGACY_MANIFEST_PATH, &[backend(), frontend()]),
+ &backend(),
+ OriginSource::AppUrl,
+ );
+ assert!(listed.contains(LEGACY_MANIFEST_PATH), "names what it found: {listed}");
+ assert!(listed.contains(ARCHITECTURE_PATH), "names the path to adopt: {listed}");
+ assert!(listed.contains("DOES list this canister"), "{listed}");
+ assert!(listed.contains("terms"), "says why the older document cannot stand in: {listed}");
+ assert!(listed.contains("app.example.com"), "names the origin: {listed}");
+
+ // The other branch: the older document doesn't list it either, and the
+ // refusal must not claim it does.
+ let unlisted = legacy_only_refusal(
+ &manifest("https://app.example.com", LEGACY_MANIFEST_PATH, &[frontend()]),
+ &backend(),
+ OriginSource::AppUrl,
+ );
+ assert!(unlisted.contains("does not list this canister either"), "{unlisted}");
+ assert!(!unlisted.contains("DOES list"), "{unlisted}");
+
+ // It stays distinguishable from the never-adopted verdict, whose repair
+ // is a different conversation with a different person. Both messages do
+ // say the standard manifest is missing — that part is simply true — so
+ // what separates them is that this one leads with the document the origin
+ // DOES serve, before naming the one it doesn't.
+ let older = listed.find("older, pre-protocol document").expect("names it: {listed}");
+ let missing = listed.find("publishes no service-discoverability manifest").unwrap();
+ assert!(older < missing, "the document it DOES serve comes first: {listed}");
+ }
+
+ // The gate's decision, on constructed input: the YES branch and each way of
+ // failing, with no network involved (per review — the live YES test can only
+ // skip until an app publishes the standard path, and a local fixture is
+ // unreachable behind the SSRF guard).
+ #[test]
+ fn the_decision_authorizes_only_a_declared_canister() {
+ let declared = |ids: &[Principal]| {
+ discover::ManifestProbe::Declared(manifest(
+ "https://app.example.com",
+ ARCHITECTURE_PATH,
+ ids,
+ ))
+ };
+ let call = |probe| decide(probe, "https://app.example.com", OriginSource::AppUrl, &backend());
+
+ // Declared at the standard path: authorized, and the provenance echoed
+ // back is the origin and path that authorized it.
+ let ok = call(declared(&[frontend(), backend()])).expect("a declared canister authorizes");
+ assert_eq!(ok.origin, "https://app.example.com");
+ assert_eq!(ok.path, ARCHITECTURE_PATH);
+
+ // The same manifest without this canister: refused, and the refusal shows
+ // what the app does declare.
+ let err = call(declared(&[frontend()])).expect_err("an undeclared canister is refused");
+ assert!(err.contains(&frontend().to_text()), "lists what IS declared: {err}");
+
+ // An empty manifest is adoption without declaration — still a refusal.
+ assert!(call(declared(&[])).is_err());
+
+ // No manifest at all, and the SPA catch-all variant that answers the path
+ // with something else: the never-adopted verdict, naming the catch-all
+ // when there is one to name.
+ let absent = |served: Option<&str>, legacy: Option| {
+ discover::ManifestProbe::Absent {
+ served_non_manifest: served.map(str::to_string),
+ legacy,
+ }
+ };
+ let err = call(absent(None, None)).expect_err("no manifest is refused");
+ assert!(err.contains("publishes no service-discoverability manifest"), "{err}");
+ let err = call(absent(Some("text/html"), None)).expect_err("refused");
+ assert!(err.contains("single-page-app catch-all"), "names the misconfiguration: {err}");
+
+ // The legacy document does NOT authorize, however complete it is — this is
+ // the precedence the gate turns on, so it is pinned here rather than only
+ // against a live origin that may adopt the standard path at any time.
+ let legacy = manifest("https://app.example.com", LEGACY_MANIFEST_PATH, &[backend()]);
+ let err = call(absent(None, Some(legacy))).expect_err("the legacy path must not authorize");
+ assert!(err.contains(ARCHITECTURE_PATH), "names the path to adopt: {err}");
+ assert!(err.contains("DOES list this canister"), "{err}");
+ }
+
+ // The identity binding: a manifest at one origin must not authorize a write
+ // signed as a different app. The refusal has to name both origins — which one
+ // is wrong depends on what the caller meant — and point at the one call that
+ // returns a matching pair.
+ #[test]
+ fn the_identity_mismatch_refusal_names_both_apps() {
+ let msg = identity_mismatch_refusal(
+ "https://evil.example",
+ "https://evil.example",
+ "https://gooddapp.com",
+ &backend(),
+ );
+ assert!(msg.contains("evil.example"), "names the app whose manifest was read: {msg}");
+ assert!(msg.contains("gooddapp.com"), "names the identity it would sign as: {msg}");
+ assert!(msg.contains(&backend().to_text()), "names the target: {msg}");
+ assert!(msg.contains("open_app"), "points at the call that returns a matching pair: {msg}");
+
+ // Not knowing is not the same as knowing they differ: the unresolvable
+ // refusal reads as a check that failed, not as a verdict on the caller.
+ let unresolved = identity_unresolvable_refusal("https://app.example.com", "timed out");
+ assert!(unresolved.contains("Could not establish"), "{unresolved}");
+ assert!(unresolved.contains("retry"), "{unresolved}");
+ assert!(!unresolved.contains("is not the app"), "must not assert a mismatch: {unresolved}");
+ }
+
+ // Which failures are permanent. The previous version of this check looked for
+ // the word "redirect", which a stopped redirect never contains — it keeps its
+ // 3xx status — so the case it was written for fell through to "retry" (per
+ // review). Pinned against the exact strings the fetch helpers produce.
+ #[test]
+ fn a_permanent_failure_is_not_described_as_transient() {
+ for permanent in [
+ "HTTP 302 Found", // a stopped redirect
+ "HTTP 401 Unauthorized", // the origin declining
+ "HTTP 403 Forbidden",
+ "document is larger than the 4096-byte limit this server reads",
+ "https://a.example does not authorize it in its ii-alternative-origins",
+ ] {
+ assert!(cause_is_persistent(permanent), "must not read as transient: {permanent}");
+ }
+ for transient in [
+ "HTTP 429 Too Many Requests",
+ "HTTP 408 Request Timeout",
+ "HTTP 500 Internal Server Error",
+ "HTTP 503 Service Unavailable",
+ "error sending request: operation timed out",
+ ] {
+ assert!(!cause_is_persistent(transient), "must stay retryable: {transient}");
+ }
+
+ // And the refusals say different things for the two.
+ let stopped = unreachable_refusal(
+ "https://app.example.com",
+ OriginSource::AppUrl,
+ "HTTP 302 Found",
+ );
+ assert!(stopped.contains("Retrying will not clear this one"), "{stopped}");
+ let blip =
+ unreachable_refusal("https://app.example.com", OriginSource::AppUrl, "timed out");
+ assert!(blip.contains("likely transient"), "{blip}");
+
+ // Same on the identity side, where the previous check only knew about an
+ // unauthorized cross-origin claim.
+ let denied = identity_unresolvable_refusal("https://app.example.com", "HTTP 403 Forbidden");
+ assert!(denied.contains("Retrying will not clear this one either"), "{denied}");
+ }
+
+ // Everything a refusal echoes stays bounded, whatever the caller passed: the
+ // origin is capped like the error causes are, so a valid-but-enormous argument
+ // cannot flood the reply (per review).
+ #[test]
+ fn refusals_cap_the_echoed_origin() {
+ let flood = format!("https://app.example.com/{}", "a".repeat(10_000));
+ for msg in [
+ unreachable_refusal(&flood, OriginSource::AppUrl, "timed out"),
+ no_manifest_refusal(&flood, OriginSource::AppUrl, None),
+ identity_unresolvable_refusal(&flood, "timed out"),
+ identity_mismatch_refusal(&flood, &flood, &flood, &backend()),
+ ] {
+ assert!(
+ !msg.contains(&"a".repeat(MAX_ECHOED_CAUSE + 1)),
+ "the echoed origin must be capped: {msg}"
+ );
+ assert!(msg.contains("app.example.com"), "and still name the origin: {msg}");
+ }
+ }
+
+ // Live network: the YES path, against a real adopter. Apps built with
+ // caffeine.ai publish the Layer 1 manifest, and svault.tech is one — it
+ // declares its frontend and backend at the standard path AND pins a
+ // cross-origin derivation origin, so between this test and the next the whole
+ // chain a write depends on is exercised against something real: manifest read,
+ // authorization, and an identity binding to an origin that is not the app's
+ // own. Skips if it stops publishing, rather than failing CI on someone else's
+ // deploy; the unit tests above pin the same decisions on constructed input.
+ #[tokio::test]
+ async fn authorizes_a_declared_canister_and_refuses_an_undeclared_one() {
+ const APP: &str = "https://svault.tech";
+ let Ok(discover::ManifestProbe::Declared(m)) = discover::fetch_declared_manifest(APP).await
+ else {
+ return; // unreachable, or no longer publishing
+ };
+ assert_eq!(m.path, ARCHITECTURE_PATH, "only the standard path may authorize");
+ let Some(declared) = m.canisters.first().copied() else {
+ return; // an empty manifest authorizes nothing
+ };
+ let ok = authorize_update_call(APP, OriginSource::AppUrl, &declared)
+ .await
+ .expect("a declared canister must authorize");
+ assert_eq!(ok.origin, m.origin);
+ assert_eq!(ok.path, ARCHITECTURE_PATH);
+
+ // An unrelated canister is refused at the same origin.
+ let ledger = canister("ryjl3-tyaaa-aaaaa-aaaba-cai");
+ if !m.canisters.contains(&ledger) {
+ let err = authorize_update_call(APP, OriginSource::AppUrl, &ledger)
+ .await
+ .expect_err("an undeclared canister must be refused");
+ assert!(err.contains(&ledger.to_text()), "{err}");
+ }
+ }
+
+ // Live network: the binding accepts a CROSS-ORIGIN declared identity — the
+ // case a literal `app_url == derivation_origin` comparison would have
+ // false-refused, and the reason the binding resolves the app instead. It also
+ // means Internet Identity's own authorization ran: svault.tech's declared
+ // origin lists it back in /.well-known/ii-alternative-origins, without which
+ // resolution refuses outright.
+ #[tokio::test]
+ async fn a_cross_origin_declared_identity_binds() {
+ const APP: &str = "https://svault.tech";
+ let Ok(identity) = discover::resolve_app_identity(APP, false).await else {
+ return; // unreachable
+ };
+ let canonical = crate::identities::target_origin(&identity.derivation_origin);
+ if canonical == APP {
+ return; // no longer cross-origin; nothing distinctive left to assert
+ }
+ bind_identity(APP, &canonical, &backend())
+ .await
+ .expect("an app must bind to the identity it declares, at any origin");
+
+ // And a different app's identity is still refused against it.
+ let err = bind_identity(APP, "https://oisy.com", &backend())
+ .await
+ .expect_err("a crossed pair must never bind");
+ assert!(err.contains("is not the app this call would be signed as"), "{err}");
+ }
+
+ // Live network, the case this gate deliberately gives up: an origin serving
+ // only the pre-protocol document is REFUSED, however complete that document
+ // is. MULTI/DEX is the real instance — it declares three canisters at the
+ // legacy path — so the refusal is checked against the id its own document
+ // lists, which is the exact write that used to be allowed. Skips once the
+ // origin adopts the standard path (at which point it should authorize, and
+ // the test above is the one that will say so).
+ #[tokio::test]
+ async fn a_legacy_only_origin_is_refused_live() {
+ const APP: &str = "https://multidex.ai";
+ let Ok(discover::ManifestProbe::Absent { legacy: Some(legacy), .. }) =
+ discover::fetch_declared_manifest(APP).await
+ else {
+ return; // unreachable, or it has adopted the standard path
+ };
+ let Some(declared) = legacy.canisters.first().copied() else {
+ return; // an empty legacy document proves nothing here
+ };
+ let err = authorize_update_call(APP, OriginSource::AppUrl, &declared)
+ .await
+ .expect_err("the legacy path must not authorize");
+ assert!(err.contains(ARCHITECTURE_PATH), "names the path to adopt: {err}");
+ assert!(err.contains(LEGACY_MANIFEST_PATH), "names what the origin does serve: {err}");
+ assert!(err.contains("DOES list this canister"), "{err}");
+ }
+
+ // Live network: an origin that publishes no manifest is refused with the
+ // "publishes no manifest" verdict, not an unreachable error. example.com is
+ // IANA-reserved and will never serve one.
+ #[tokio::test]
+ async fn refuses_an_origin_with_no_manifest() {
+ let err = authorize_update_call("https://example.com", OriginSource::AppUrl, &backend())
+ .await
+ .expect_err("example.com publishes no manifest");
+ assert!(err.contains("publishes no service-discoverability manifest"), "{err}");
+ }
+
+ // The SSRF guard applies here too: the gate fetches a caller-controlled
+ // origin, so a private/loopback target is refused before any request, and the
+ // refusal reads as a check failure rather than as a verdict on an app.
+ #[tokio::test]
+ async fn refuses_a_non_public_origin_without_fetching() {
+ let err = authorize_update_call("https://127.0.0.1", OriginSource::AppUrl, &backend())
+ .await
+ .expect_err("loopback must be refused");
+ assert!(err.contains("Could not check"), "{err}");
+ }
+}
diff --git a/crates/imcp2-core/src/identities.rs b/crates/imcp2-core/src/identities.rs
index e05fd5c..ef14552 100644
--- a/crates/imcp2-core/src/identities.rs
+++ b/crates/imcp2-core/src/identities.rs
@@ -465,8 +465,9 @@ pub struct ResolveAppOutput {
/// The Internet Identity derivation origin to use for this app — pass this
/// as `derivation_origin` to the identity tools.
pub derivation_origin: String,
- /// How `derivation_origin` was determined: "declared" (the app declared it
- /// in /.well-known/ic-app.json — authoritative), "known" (from the connector's
+ /// How `derivation_origin` was determined: "declared" (the app declared it,
+ /// in /.well-known/ii-derivation-origin or in the legacy /.well-known/ic-app.json
+ /// — authoritative), "known" (from the connector's
/// built-in registry of well-known custom-derivation-origin apps, used only
/// when the app declares none), or "app_url_default" (assumed to equal the
/// application origin — correct only if the app has no custom derivation
diff --git a/crates/imcp2-core/src/lib.rs b/crates/imcp2-core/src/lib.rs
index 1a74aad..6d1e975 100644
--- a/crates/imcp2-core/src/lib.rs
+++ b/crates/imcp2-core/src/lib.rs
@@ -38,6 +38,7 @@ pub mod tools;
mod calls;
mod compliance;
mod discover;
+mod discoverability;
mod management;
pub use identities::{IiInstance, SessionGauges};
diff --git a/crates/imcp2-core/src/management.rs b/crates/imcp2-core/src/management.rs
index b7ed067..0d9966a 100644
--- a/crates/imcp2-core/src/management.rs
+++ b/crates/imcp2-core/src/management.rs
@@ -315,6 +315,16 @@ async fn management_agent(ids: &Identities, session_id: &str) -> Result<(Agent,
/// A management-canister (`aaaaa-aa`) update call with the effective canister id
/// set to the TARGET — the boundary node requires this for lifecycle methods.
+///
+/// NOTE — these writes do NOT go through the service-discoverability gate that
+/// `canister_update_call` runs (see [`crate::discoverability`]). That is correct
+/// as things stand: these tools act on the USER'S OWN canisters as their
+/// management principal, not on a third party's app, and an app manifest has
+/// nothing to say about them. It is also currently moot — [`crate::IcProtocolTools`]
+/// is not part of the served composition. If that group is ever composed back
+/// in, revisit whether any of it can reach a canister the user does not control;
+/// the gate's premise is that a write to someone else's app needs that app's
+/// published consent.
async fn mgmt_call(
agent: &Agent,
target: Principal,
diff --git a/crates/imcp2-core/src/tools.rs b/crates/imcp2-core/src/tools.rs
index 13e5bf3..43027ca 100644
--- a/crates/imcp2-core/src/tools.rs
+++ b/crates/imcp2-core/src/tools.rs
@@ -17,7 +17,10 @@ use rmcp::{
schemars, ErrorData as McpError, RoleServer, ServerHandler,
};
-use crate::{calls, compliance, discover, identities, identities::Identities, management, skills};
+use crate::{
+ calls, compliance, discoverability, discoverability::OriginSource, discover, identities,
+ identities::Identities, management, skills,
+};
use std::sync::Arc;
/// Cap on the per-canister Candid probes open_app / discover_app_canisters run to
@@ -508,7 +511,7 @@ impl IcCanisterTools {
}
#[tool(
- description = "Make an update call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. Args are encoded against the method's declared Candid types, so plain literals like 42 coerce correctly without `: type` annotations. Omitting `derivation_origin` calls anonymously and needs no session; passing it calls as the user's account at that app, which requires an authenticated session and uses a short-lived account delegation derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's exact canonical Internet Identity derivation origin — not necessarily its visible URL, and not an alternative-origins entry — which open_app and resolve_app resolve from an app name or URL; this tool takes the origin itself, not a raw website URL. `account` names one of the user's accounts (list_app_accounts returns them); omitted, the app's default account is used. The result echoes `derived_for_origin`, `requested`, and `acted_as_principal`, so an origin mismatch is visible. Read-only calls — Candid query methods and OQL queries — go through canister_query. `candid` supplies the interface as `.did` text when the canister's own metadata can't be read, so args and replies stay typed.",
+ description = "Make an update call (a state-changing call) on an Internet Computer canister method, with textual Candid in and out. The call is made only to a canister the owning app declares in its service-discoverability manifest at /.well-known/ic-architecture (https://docs.internetcomputer.org/guides/frontends/service-discoverability/): `app_url` is that app's website URL, whose origin the manifest is read from, and open_app returns it. Omitted, `derivation_origin` supplies that origin, which is the same value when the app serves its manifest at the origin it derives identities from. When both are given they must belong to the same app: the app at `app_url` is resolved to the derivation origin Internet Identity derives its users from, and a call whose `derivation_origin` is a different app's is refused, so a manifest at one origin cannot authorize a write signed as another app's identity. open_app returns an `app_url` and a `derivation_origin` that match. A canister no such manifest declares is not written to, and the refusal distinguishes an unreachable origin, an app that publishes no manifest, one still serving only this connector's pre-protocol /.well-known/ic-app.json document, and a manifest that does not list this canister; reads on it are unaffected either way. The reply echoes `declared_by` and `declared_at`: the origin whose manifest authorized the call, and the path it was read from. Args are encoded against the method's declared Candid types, so plain literals like 42 coerce correctly without `: type` annotations. Omitting `derivation_origin` calls anonymously and needs no session; passing it calls as the user's account at that app, which requires an authenticated session and uses a short-lived account delegation derived on demand from this connection's standing Internet Identity credential. `derivation_origin` is the app's exact canonical Internet Identity derivation origin — not necessarily its visible URL, and not an alternative-origins entry — which open_app and resolve_app resolve from an app name or URL; this tool takes the origin itself, not a raw website URL. `account` names one of the user's accounts (list_app_accounts returns them); omitted, the app's default account is used. The result echoes `derived_for_origin`, `requested`, and `acted_as_principal`, so an origin mismatch is visible. Read-only calls — Candid query methods and OQL queries — go through canister_query. `candid` supplies the interface as `.did` text when the canister's own metadata can't be read, so args and replies stay typed.",
annotations(title = "Make a canister update call", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true),
output_schema = schema_for_output::(),
)]
@@ -518,6 +521,7 @@ impl IcCanisterTools {
canister_id,
method,
args,
+ app_url,
derivation_origin,
account,
candid,
@@ -542,6 +546,52 @@ impl IcCanisterTools {
if let Some(refusal) = compliance::disallowed_update_method(&principal, &method) {
return Ok(err(refusal));
}
+ // Resolve which principal to act as: none = anonymous; else the app's
+ // effective (canonical) II derivation origin, from the caller's explicit
+ // `derivation_origin` (obtained once via open_app / resolve_app).
+ let target = match resolve_identity_target(derivation_origin) {
+ Ok(t) => t,
+ Err(e) => return Ok(err(e)),
+ };
+ // The service-discoverability gate (see `discoverability`): a write only
+ // goes to a canister the owning app DECLARES in its manifest. Run BEFORE
+ // fetching the target's Candid and encoding args — a canister we will not
+ // write to shouldn't be touched at all, and the caller gets the actionable
+ // refusal instead of an argument error it would have to fix twice.
+ let (origin, source) = match declaration_origin(app_url, target.as_ref(), &principal) {
+ Ok(v) => v,
+ Err(refusal) => return Ok(err(refusal)),
+ };
+ // Two independent questions about the same call: does this app declare the
+ // canister, and is this app the one the call is signed as. Both fetch the
+ // app's origin, so they run CONCURRENTLY — one round trip, not two.
+ //
+ // The binding applies only when the caller named an app AND is acting as
+ // someone: with no `app_url` the manifest is read at the identity's own
+ // origin, so the two are the same app by construction, and an anonymous
+ // call carries no app identity for a foreign manifest to misuse.
+ let binding = async {
+ match (source, target.as_ref()) {
+ (OriginSource::AppUrl, Some(t)) => {
+ discoverability::bind_identity(&origin, &t.origin, &principal).await
+ }
+ _ => Ok(()),
+ }
+ };
+ let (bound, authorized) = tokio::join!(
+ binding,
+ discoverability::authorize_update_call(&origin, source, &principal)
+ );
+ // The mismatch is reported first when both fail: "this is not the app you
+ // are signing as" tells the caller something a "not declared" refusal
+ // would send them off to fix in the wrong place.
+ if let Err(refusal) = bound {
+ return Ok(err(refusal));
+ }
+ let declaration = match authorized {
+ Ok(d) => d,
+ Err(refusal) => return Ok(err(refusal)),
+ };
// The interface to encode/decode against: the canister's own
// candid:service if exposed, else the caller-supplied `candid`. Update calls
// are never redirected (OQL is read-only), so no oql_query_redirect here.
@@ -550,13 +600,6 @@ impl IcCanisterTools {
Ok(b) => b,
Err(e) => return Ok(err(e)),
};
- // Resolve which principal to act as: none = anonymous; else the app's
- // effective (canonical) II derivation origin, from the caller's explicit
- // `derivation_origin` (obtained once via open_app / resolve_app).
- let target = match resolve_identity_target(derivation_origin) {
- Ok(t) => t,
- Err(e) => return Ok(err(e)),
- };
let origin = target.as_ref().map(|t| t.origin.as_str());
let (agent, acted_as_principal) = match self
.resolve_agent(&ctx, origin, account.as_deref(), "calling")
@@ -584,10 +627,20 @@ impl IcCanisterTools {
let acted = acted_as_principal.as_deref().unwrap_or("");
blocks.push(format!("[{}]", identity_annotation(t, Some(acted))));
}
+ // The write's provenance, as its own block: which app's published manifest
+ // declared this canister, and where that manifest was read from. A user
+ // asking "why did it write there?" gets the answer in every client, not
+ // just the ones that read structured output.
+ blocks.push(format!(
+ "[declared by {} in {}]",
+ declaration.origin, declaration.path
+ ));
let output = calls::CanisterUpdateCallOutput {
canister_id, method, reply,
acted_as_principal, derived_for_origin, requested, derivation_origin_source,
is_anonymous,
+ declared_by: declaration.origin,
+ declared_at: declaration.path.to_string(),
};
Ok(ok_structured_blocks(blocks, &output))
}
@@ -1004,7 +1057,7 @@ impl IcCanisterTools {
}
#[tool(
- description = "Open an Internet Computer app in one call, given its name or its URL: resolves the app's Internet Identity derivation origin (as resolve_app does) and discovers the canisters behind it (as discover_app_canisters does) in a single step. If the user supplied only an app name, pass that name unchanged; only pass a URL supplied by the user or obtained from a verified official source, and do not construct a domain from the name. A name — or a bare host — is matched against the built-in registry of well-known apps first, so a wrong-TLD guess repairs to the canonical URL; an explicit `https://` URL is resolved as given. There is no on-chain name-to-URL directory, so an unknown bare name is refused with instructions for finding the real URL, and a URL that would need its own origin assumed as the derivation origin — no usable declaration was read from the app — a failed or non-success fetch, malformed JSON, or a declaration this server cannot use — and the registry has no entry; note that a cross-origin declaration the DECLARED origin does not authorize in its /.well-known/ii-alternative-origins is a hard refusal instead, not this assumed path — is refused when that origin shows no evidence of being an Internet Computer app, rather than resolved to a wrong identity. That evidence establishes that a domain is served from the Internet Computer, not that it is the app the user meant, which is why a constructed domain is not an acceptable input. Returns `app_url` (the one used), `derivation_origin` and its source, `alternative_origins`, and the discovered `canisters`, with provenance, labels, and per-canister `oql`/`api_doc_available` capability flags from a one-shot Candid probe of the app's own canisters — `api_doc_available` reports that a canister DECLARES the doc method get_canister_api_doc reads, not that the call returns a guide. The probe covers at most the first eight eligible canisters, so on a larger manifest the later entries carry neither flag; both are then absent rather than false, and get_canister_candid reports them for a specific canister. An app's features are reached through those canisters rather than through per-feature tools: a canister flagged `oql` is read through get_canister_oql_schema and canister_query's `oql` argument rather than a Candid data query, and both of those take the returned `derivation_origin` and reject an anonymous read — the flag reports that routing, not what the canister stores or how it gates reads. No authenticated session is required, since no principal is derived here. resolve_app and discover_app_canisters perform the two halves separately.",
+ description = "Open an Internet Computer app in one call, given its name or its URL: resolves the app's Internet Identity derivation origin (as resolve_app does) and discovers the canisters behind it (as discover_app_canisters does) in a single step. If the user supplied only an app name, pass that name unchanged; only pass a URL supplied by the user or obtained from a verified official source, and do not construct a domain from the name. A name — or a bare host — is matched against the built-in registry of well-known apps first, so a wrong-TLD guess repairs to the canonical URL; an explicit `https://` URL is resolved as given. There is no on-chain name-to-URL directory, so an unknown bare name is refused with instructions for finding the real URL, and a URL that would need its own origin assumed as the derivation origin — the app answered but no usable declaration was read from it: a 404 or 410, malformed JSON, or a declaration this server cannot use, while a probe that did not complete is an error rather than an assumption — and the registry has no entry; note that a cross-origin declaration the DECLARED origin does not authorize in its /.well-known/ii-alternative-origins is a hard refusal instead, not this assumed path — is refused when that origin shows no evidence of being an Internet Computer app, rather than resolved to a wrong identity. That evidence establishes that a domain is served from the Internet Computer, not that it is the app the user meant, which is why a constructed domain is not an acceptable input. Returns `app_url` (the one used, and the origin canister_update_call reads an app's service-discoverability manifest from), `derivation_origin` and its source, `alternative_origins`, and the discovered `canisters`, with provenance, labels, and per-canister `oql`/`api_doc_available` capability flags from a one-shot Candid probe of the app's own canisters — `api_doc_available` reports that a canister DECLARES the doc method get_canister_api_doc reads, not that the call returns a guide. The probe covers at most the first eight eligible canisters, so on a larger manifest the later entries carry neither flag; both are then absent rather than false, and get_canister_candid reports them for a specific canister. An app's features are reached through those canisters rather than through per-feature tools: a canister flagged `oql` is read through get_canister_oql_schema and canister_query's `oql` argument rather than a Candid data query, and both of those take the returned `derivation_origin` and reject an anonymous read — the flag reports that routing, not what the canister stores or how it gates reads. No authenticated session is required, since no principal is derived here. resolve_app and discover_app_canisters perform the two halves separately.",
annotations(title = "Open an app (resolve origin + discover canisters)", read_only_hint = true, destructive_hint = false, open_world_hint = true),
output_schema = schema_for_output::(),
)]
@@ -1127,7 +1180,10 @@ impl IcCanisterTools {
get_canister_api_doc to read (api_doc_available=false means no compatible method \
was detected — usually there is none, though an unparsable interface reads the \
same way). To act as the user, pass the derivation_origin \
- above to canister_query (read) and canister_update_call (write); for an OQL canister, \
+ above to canister_query (read) and canister_update_call (write) \
+ — a write also takes the app_url above, whose \
+ /.well-known/ic-architecture manifest is what authorizes it; \
+ for an OQL canister, \
call get_canister_oql_schema for the entity/field names, then canister_query with \
the `oql` argument — plus an optional account from list_app_accounts. A \"my/our…\" \
question is an AUTHENTICATED read: pass the origin.",
@@ -1149,7 +1205,7 @@ impl IcCanisterTools {
}
#[tool(
- description = "Resolve an application URL to its Internet Identity derivation context. `app_url` is a URL the caller already has — from the user, from open_app's known-app resolution, or from the app's official site; a lookalike domain is an unrelated or squatted site, and when the derivation origin would have to be assumed from the URL itself, this tool refuses an origin that shows no evidence of being an Internet Computer app rather than returning a wrong identity. Returns the `application_origin`, the `derivation_origin` the identity tools take, how it was determined (`derivation_origin_source`: \"declared\" — the app published it in /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of apps with custom derivation origins, used when no usable declaration was read; or \"app_url_default\" — no usable declaration was read and the registry has no entry, so the IC-served origin is assumed to be its own derivation origin, which holds only if the app has no custom one. Reading a declaration is fail-soft: a fetch that fails, a non-success response, malformed JSON, or an unusable declaration all take the assumed path, so these two sources mean \"none was read\", not \"none exists\". One case is NOT fail-soft: a cross-origin declaration is accepted only if the DECLARED origin authorizes this app in its /.well-known/ii-alternative-origins, and an unauthorized one is REFUSED outright rather than falling back — resolution fails instead of deriving a possibly wrong identity), and the app's `alternative_origins`, which are the inverse relation and do not identify the derivation origin. No principal is returned, since no account has been chosen: get_app_principal and list_app_accounts take the resolved origin. open_app resolves an app name as well as a URL, and also returns the app's canisters. No authenticated session is required.",
+ description = "Resolve an application URL to its Internet Identity derivation context. `app_url` is a URL the caller already has — from the user, from open_app's known-app resolution, or from the app's official site; a lookalike domain is an unrelated or squatted site, and when the derivation origin would have to be assumed from the URL itself, this tool refuses an origin that shows no evidence of being an Internet Computer app rather than returning a wrong identity. Returns the `application_origin`, the `derivation_origin` the identity tools take, how it was determined (`derivation_origin_source`: \"declared\" — the app published it, in /.well-known/ii-derivation-origin (the protocol's own file) or in the legacy /.well-known/ic-app.json, authoritative; \"known\" — from the connector's built-in registry of apps with custom derivation origins, used when no usable declaration was read; or \"app_url_default\" — no usable declaration was read and the registry has no entry, so the IC-served origin is assumed to be its own derivation origin, which holds only if the app has no custom one. Reading a declaration is fail-soft only where the app answered: a 404 or 410, malformed JSON, or an unusable declaration take the assumed path, so these two sources mean \"none was read\", not \"none exists\". A probe that did not complete — a transport failure, or any other non-success status — is an error instead, because a declaration that could not be read is not a declaration that is absent, and assuming past it would derive a different principal than the app pins. One case is NOT fail-soft: a cross-origin declaration is accepted only if the DECLARED origin authorizes this app in its /.well-known/ii-alternative-origins, and an unauthorized one is REFUSED outright rather than falling back — resolution fails instead of deriving a possibly wrong identity), and the app's `alternative_origins`, which are the inverse relation and do not identify the derivation origin. No principal is returned, since no account has been chosen: get_app_principal and list_app_accounts take the resolved origin. open_app resolves an app name as well as a URL, and also returns the app's canisters. No authenticated session is required.",
annotations(title = "Resolve an app's derivation origin", read_only_hint = true, destructive_hint = false, open_world_hint = true),
output_schema = schema_for_output::(),
)]
@@ -1203,7 +1259,7 @@ impl IcCanisterTools {
}
#[tool(
- description = "Discover the Internet Computer canisters behind a web domain (e.g. \"opencloud.org\"). `domain` is a domain, not an app name; open_app takes a name directly. When discovery succeeds, a domain with no Internet-Computer evidence yields an empty `canisters` list with a note saying so, rather than a guess; a domain that cannot be reached at all (DNS, TLS, timeout) is a plain error instead, so an empty list means no findings rather than a failed lookup — open_app and resolve_app are the tools that refuse such an origin, and then only where the derivation origin would have to be assumed from the URL itself. Returns up to 50 canister ids, with provenance, most authoritative first (unlabelled ids mined from the JS bundle are capped at 20); any id dropped by those bounds is counted in `omitted` rather than left out silently: app-declared metadata — the App Connect page's `ic:canister-id` meta at /ai-connect.html (the app's main backend) and the app's own /.well-known/ic-app.json manifest (its canisters and their roles, honoured up to the first 100 entries — a truncation there is NOT counted in `omitted`, which accounts for the output bounds only) — then the `x-ic-canister-id` header (the frontend/asset canister), an `/env.json` runtime config (e.g. `backend_canister_id`), and labelled or bare canister-id literals mined from the JS bundle. App-declared entries are the app's own claim about itself; env.json and bundle entries are mined candidates, distinguished by label (production and IC ids) and confirmable with get_canister_candid.",
+ description = "Discover the Internet Computer canisters behind a web domain (e.g. \"opencloud.org\"). `domain` is a domain, not an app name; open_app takes a name directly. When discovery succeeds, a domain with no Internet-Computer evidence yields an empty `canisters` list with a note saying so, rather than a guess; a domain that cannot be reached at all (DNS, TLS, timeout) is a plain error instead, so an empty list means no findings rather than a failed lookup — open_app and resolve_app are the tools that refuse such an origin, and then only where the derivation origin would have to be assumed from the URL itself. Returns up to 50 canister ids, with provenance, most authoritative first (unlabelled ids mined from the JS bundle are capped at 20); any id dropped by those bounds is counted in `omitted` rather than left out silently: app-declared metadata — the app's service-discoverability manifest at /.well-known/ic-architecture and the same document at the legacy /.well-known/ic-app.json path (its canisters and their roles, honoured up to the first 100 entries — a truncation there is NOT counted in `omitted`, which accounts for the output bounds only) — then the `x-ic-canister-id` header (the frontend/asset canister), an `/env.json` runtime config (e.g. `backend_canister_id`), and labelled or bare canister-id literals mined from the JS bundle. App-declared entries are the app's own claim about itself; env.json and bundle entries are mined candidates, distinguished by label (production and IC ids) and confirmable with get_canister_candid.",
annotations(title = "Discover canisters behind a domain", read_only_hint = true, destructive_hint = false, open_world_hint = true),
output_schema = schema_for_output::(),
)]
@@ -1231,9 +1287,9 @@ impl IcCanisterTools {
));
}
out.push_str(
- "\n`ai-connect.html` and `ic-app.json` entries are DECLARED by the app itself \
- (its main backend, and its own canister manifest with roles) — treat them as \
- the app's claim about its composition. The `header` (x-ic-canister-id) entry \
+ "\n`ic-architecture` and `ic-app.json` entries are DECLARED by the app itself \
+ (its own canister manifest, with roles) — treat them as the app's claim about \
+ its composition. The `header` (x-ic-canister-id) entry \
is the frontend/asset canister. Others come from env.json or the JS bundle \
and may include multiple environments (prefer the production/IC ids). A \
«name» (type) is the IC dashboard's label for that id. `[oql]`/`[api-doc]` \
@@ -1743,6 +1799,35 @@ fn resolve_identity_target(
}
}
+/// The origin whose service-discoverability manifest decides whether an update
+/// call may proceed, and which argument it came from (so a refusal can name the
+/// one to fix). Precedence: the caller's `app_url` — the argument that exists for
+/// exactly this — else the `derivation_origin`, as the caller SPELLED it.
+///
+/// Why the derivation origin's `requested` form and not its canonical `origin`:
+/// the canonical form carries the `*.icp0.io`/`*.icp.net` → `*.ic0.app` gateway
+/// remap, which exists so Internet Identity derives ONE principal across an app's
+/// gateway aliases. That is an identity concern. A manifest is an ordinary HTTP
+/// document served by whichever host the caller actually named, so remapping the
+/// host before fetching would go looking for the file on an alias the app may not
+/// serve at all — and report "publishes no manifest" about an app that does.
+///
+/// Neither argument present is a REFUSAL, not a default: with no origin there is
+/// nothing to check against, and defaulting to "allow" would be the whole gate.
+fn declaration_origin(
+ app_url: Option,
+ target: Option<&IdentityTarget>,
+ canister_id: &Principal,
+) -> Result<(String, OriginSource), String> {
+ if let Some(url) = app_url {
+ return clean_app_url(&url).and_then(|u| app_origin_of(&u)).map(|u| (u, OriginSource::AppUrl));
+ }
+ match target {
+ Some(t) => app_origin_of(&t.requested).map(|o| (o, OriginSource::DerivationOrigin)),
+ None => Err(discoverability::missing_origin_refusal(canister_id)),
+ }
+}
+
/// Append guess-repair guidance to an `app_url` resolution failure (DNS, TLS,
/// timeout, SSRF refusal): a fetch error on a domain FABRICATED from an app name
/// (e.g. "multi.dex") must redirect the caller to the name→URL tools — the same
@@ -1775,8 +1860,9 @@ fn unverified_app_url_error(application_origin: &str) -> String {
let mut msg = format!(
"{application_origin} is reachable but shows NO evidence of being an Internet Computer \
app — no valid `x-ic-canister-id` gateway header (the IC HTTP gateway sets one on every \
- response) — and its /.well-known/ic-app.json couldn't be fetched or declares no Internet \
- Identity derivation origin. Refusing to treat it as an app. "
+ response) — and neither /.well-known/ii-derivation-origin nor the legacy \
+ /.well-known/ic-app.json could be fetched or declares an Internet Identity derivation \
+ origin. Refusing to treat it as an app. "
);
if let Some(m) = discover::similar_known_app(application_origin) {
msg.push_str(&format!(
@@ -1816,14 +1902,16 @@ fn resolution_note(resolved: &discover::AppIdentity, effective: &str) -> Option<
match resolved.derivation_origin_source {
discover::DerivationSource::Declared => None,
discover::DerivationSource::Known => Some(format!(
- "This app didn't declare a derivation origin in /.well-known/ic-app.json, but it's \
+ "This app didn't declare a derivation origin in /.well-known/ii-derivation-origin \
+ (nor in the legacy /.well-known/ic-app.json), but it's \
a known app that pins a custom one, so this used the built-in value {effective}. \
The app's own declaration, if it ships one, would override this."
)),
discover::DerivationSource::AppUrlDefault => Some(format!(
"This origin showed evidence of being served from the Internet Computer (its \
responses carry the gateway's `x-ic-canister-id` header), but its \
- /.well-known/ic-app.json couldn't be fetched or declares no `derivation_origin`, \
+ /.well-known/ii-derivation-origin declares none (nor does the legacy \
+ /.well-known/ic-app.json), \
and it isn't in the built-in known-app registry — so this ASSUMED the application \
origin, canonicalized to {effective} (what II derives against). That is correct \
for apps without a custom derivation origin; if this app pins a custom one, the \
@@ -1908,6 +1996,26 @@ fn canonicalize_derivation_origin(cleaned: &str) -> Result {
/// discovery targets (the SSRF guard refuses anything else), so an `http://` URL
/// would otherwise fail with a late, indirect error — reject it here with a clear
/// message. A bare host (no scheme) is fine; `resolve_app_identity` prepends https.
+/// The ORIGIN of an already-validated app URL: scheme, host and port, with any
+/// path, query or fragment dropped — and NOT the gateway remap
+/// `canonicalize_derivation_origin` applies, which is an identity concern and
+/// would send the manifest fetch to an alias the app may not serve.
+///
+/// The manifest is read at the origin whatever the caller passed, so reducing it
+/// here keeps the value the gate checks, the value it fetches, and the value a
+/// refusal echoes identical — and bounds the last of those, since a URL's path
+/// and query are unbounded while a host is not (per review: a refusal that echoed
+/// the raw argument could be flooded through an otherwise valid URL).
+fn app_origin_of(url: &str) -> Result {
+ let candidate = if url.contains("://") { url.to_string() } else { format!("https://{url}") };
+ url::Url::parse(&candidate)
+ .ok()
+ .map(|u| u.origin())
+ .filter(url::Origin::is_tuple)
+ .map(|o| o.ascii_serialization())
+ .ok_or_else(|| format!("could not read an origin from `{url}`"))
+}
+
fn clean_app_url(raw: &str) -> Result {
let u = clean_identity_arg("app_url", raw)?;
if let Some((scheme, _)) = u.split_once("://") {
@@ -1987,6 +2095,7 @@ const SERVER_INSTRUCTIONS: &str = "Internet Computer tools: read canister interf
Tool names signal scope. The `…_app…` names (open_app, discover_app_canisters, get_app_principal, list_app_accounts, resolve_app) act on a whole app, keyed by its Internet Identity derivation origin or its URL; the `…canister…` names (get_canister_candid, get_canister_api_doc, get_canister_oql_schema, canister_query, canister_update_call) act on one canister. `icp_oql_guide` documents the OQL dialect the canister reads use. An app's features are reached through its canisters rather than through per-feature tools, and open_app resolves an app name or URL to both its derivation origin and its canisters in one call.\n\n\
An app's derivation origin is the exact origin Internet Identity derives the user's principal from. It is not necessarily the app's visible URL, and an alternative-origins entry does not identify it; open_app and resolve_app resolve it, and the identity-bearing tools take the origin itself rather than a URL. There is no on-chain name-to-URL directory: open_app matches a name against a built-in registry of well-known apps, and where the derivation origin would have to be assumed from the URL itself, open_app and resolve_app refuse an origin with no evidence of being an Internet Computer app, while discover_app_canisters returns an empty result for such a domain. This server's OQL read path requires a derivation origin and rejects an anonymous read; that is this connector's own rule, not a statement about what a canister stores or how it authorizes callers. A Candid `method` read may be anonymous. Account delegations are short-lived and derived on demand from this connection's standing Internet Identity credential, which is obtained at connect time and lasts for the chosen session duration (up to 30 days). Internet Identity's consent screen offers two access levels, and they govern the calls signed with that session's account delegation — the ones that carry a derivation origin: on a \"Questions only\" session those reads work and those update calls are rejected by the network, while \"Actions & questions\" permits both. A call made with no derivation origin is not signed with the delegation at all; it runs as the anonymous principal, so those access levels do not govern it. This server's own checks still do — the financial-transactions guard runs before any identity or network work, so a call it refuses is refused whether or not an origin was passed — and past that the canister decides whether to accept it.\n\n\
Canister values are stored in canonical, locale-neutral forms: timestamps are usually nanoseconds since the Unix epoch in UTC (IC time), and physical quantities are SI or app-defined units, which `get_canister_api_doc` documents for canisters that publish a doc.\n\n\
+ State-changing calls reach only apps that publish a service-discoverability manifest. canister_update_call is made to a canister only when the app that owns it declares that canister at /.well-known/ic-architecture (https://docs.internetcomputer.org/guides/frontends/service-discoverability/), which is how an app's operators opt in to being operated through this connector; `app_url` names the app whose manifest is read, and open_app returns it. The manifest and the identity are bound together: where a call carries both an `app_url` and a `derivation_origin`, they have to belong to the same app, so a declaration published at one origin cannot authorize a write signed as another app's identity. Reading is not gated that way: every read tool works on any canister, declared or not. An app that publishes no such manifest cannot be written to here, however its canisters are reached, and its operators are the ones who change that by publishing one. This connector's pre-protocol /.well-known/ic-app.json document is still read during discovery but does not authorize a call, since the apps serving it published it under different terms.\n\n\
FINANCIAL TRANSACTIONS ARE NOT SUPPORTED, to protect the user: do not use canister_update_call to move assets. Recognized asset-moving calls are refused before they reach the network, and the refusal says why — but 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), recommend the user performs the operation outside this connector, in a trusted interface they control.\n\n\
Compiling Motoko or Rust to Wasm happens in the client\'s own environment, and this connector serves no tools for creating, funding, deploying or managing canisters: the user does that with the icp CLI in their own terminal.";
@@ -2422,6 +2531,91 @@ mod tests {
assert_eq!(cc.destructive_hint, Some(true));
}
+ // The origin the discoverability gate checks: `app_url` is the argument that
+ // exists for it and always wins; the derivation origin fills in only when no
+ // app_url was given; neither is a REFUSAL, never an implicit allow.
+ #[test]
+ fn declaration_origin_prefers_app_url_and_refuses_with_neither() {
+ let canister = candid::Principal::from_text("hmxr2-pqaaa-aaabq-qaaaa-cai").unwrap();
+ let target = super::resolve_identity_target(Some("https://nns.ic0.app".into()))
+ .expect("valid origin")
+ .expect("some target");
+
+ // app_url wins even when a derivation origin is also present: the two are
+ // NOT interchangeable (an app can pin a derivation origin it doesn't serve
+ // its manifest from), so the explicit argument decides.
+ let (origin, source) =
+ super::declaration_origin(Some("https://app.example.com".into()), Some(&target), &canister)
+ .expect("app_url is accepted");
+ assert_eq!(origin, "https://app.example.com");
+ assert_eq!(source, super::OriginSource::AppUrl);
+
+ // No app_url: fall back to the derivation origin AS THE CALLER SPELLED IT
+ // — not the canonical form, whose *.icp0.io -> *.ic0.app gateway remap is
+ // an identity concern and would send the manifest fetch to an alias the
+ // app may not serve.
+ let gateway = super::resolve_identity_target(Some("https://x.icp0.io".into()))
+ .expect("valid origin")
+ .expect("some target");
+ assert_eq!(gateway.origin, "https://x.ic0.app", "the identity path remaps");
+ let (origin, source) =
+ super::declaration_origin(None, Some(&gateway), &canister).expect("falls back");
+ assert_eq!(origin, "https://x.icp0.io", "the manifest fetch does not");
+ assert_eq!(source, super::OriginSource::DerivationOrigin);
+
+ // Neither: refused, with the guidance that names the missing argument.
+ let e = super::declaration_origin(None, None, &canister).expect_err("must refuse");
+ assert!(e.contains("`app_url`"), "{e}");
+ assert!(e.contains(&canister.to_text()), "{e}");
+
+ // A malformed app_url is rejected by the same validation every other
+ // URL-taking argument uses, rather than being fetched.
+ assert!(super::declaration_origin(Some("http://x.example".into()), None, &canister).is_err());
+ assert!(super::declaration_origin(Some(" ".into()), None, &canister).is_err());
+ }
+
+ // The discoverability gate is the opposite of the financial policy below: it
+ // is a property of THIS tool (it needs `app_url`, and it changes what the call
+ // does), so it belongs in the description, where an agent reads it before
+ // composing the call — and in the server instructions, which frame why. Pin
+ // both, plus the rule that reads stay open, so a future edit can't quietly
+ // turn the refusal into "this app is off limits".
+ #[test]
+ fn discoverability_gate_is_stated_on_the_tool_and_server_wide() {
+ let tools = super::IcTools::all_tools();
+ let desc = tools
+ .iter()
+ .find(|t| &*t.name == "canister_update_call")
+ .and_then(|t| t.description.as_deref())
+ .expect("canister_update_call tool not found");
+ assert!(desc.contains("/.well-known/ic-architecture"), "names the manifest path: {desc}");
+ assert!(desc.contains("`app_url`"), "names the argument that carries the origin: {desc}");
+ assert!(desc.contains("reads on it are unaffected"), "says reads are unaffected: {desc}");
+
+ // The argument is really on the schema, not just in the prose.
+ let schema = tools
+ .iter()
+ .find(|t| &*t.name == "canister_update_call")
+ .map(|t| serde_json::to_string(&t.input_schema).unwrap())
+ .unwrap();
+ assert!(schema.contains("app_url"), "app_url must be a declared argument: {schema}");
+
+ let ins = super::SERVER_INSTRUCTIONS;
+ assert!(ins.contains("State-changing calls reach only apps that publish"));
+ assert!(ins.contains("/.well-known/ic-architecture"));
+ assert!(
+ ins.contains(super::discover::SERVICE_DISCOVERABILITY_GUIDE),
+ "the instructions must link the guide so an agent can relay it"
+ );
+ // A gated write must not read as "this app is off limits": the sentence
+ // that keeps the reading path open is the one thing here that stops a
+ // refusal from ending the whole conversation about an app.
+ assert!(
+ ins.contains("Reading is not gated that way"),
+ "the instructions must keep reads open: {ins}"
+ );
+ }
+
// The financial-transactions policy is a SERVER-WIDE instruction, never a
// tool-description paragraph (per review): stating it inside
// canister_update_call's description reads as a hint that the tool is
diff --git a/docs/openai-directory-submission.md b/docs/openai-directory-submission.md
index 03ce1d0..264b48c 100644
--- a/docs/openai-directory-submission.md
+++ b/docs/openai-directory-submission.md
@@ -167,8 +167,8 @@ Positive:
2. "Does the canister behind https://opencloud.org expose an API doc, and
what does its interface look like?" → interface + capability flags via
get_canister_candid / get_canister_api_doc.
-3. "What canisters are behind https://opencloud.org?" → App Connect
- discovery returns the app's canisters with provenance.
+3. "What canisters are behind https://opencloud.org?" → discovery returns
+ the app's canisters with provenance.
4. "Open opencloud.org and list my accounts there" (signed in) → resolves
the derivation origin, lists II accounts.
5. "Resolve https://opencloud.org to its Internet Identity derivation