Serve skill:// resources from a reviewed static bundle - #164
Conversation
The served resource surface no longer retrieves anything over the network: the 24 official IC skills are compiled into the binary from crates/imcp2-core/static/skills/ (bundled 2026-08-28 from the public skills registry; provenance, curation notes, and the refresh procedure are in the bundle's README), and resources/list + resources/read serve exactly that bundle. The live skills catalogue stays behind as library code for the unserved protocol tools, so the hosted and local binaries no longer construct it — and no longer read SKILLS_URL or contact skills.internetcomputer.org at all. Docs follow the binary: the README's skills section describes the reviewed, versioned bundle; the privacy policy (draft and served page) drops the skills host from the disclosed recipients and bumps the effective date; the submission doc's third-party list and prompt-injection acknowledgment now state that no instructions are retrieved over the network. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk
There was a problem hiding this comment.
Pull request overview
Moves served skill:// resources from live registry fetches to a reviewed bundle compiled into both binaries.
Changes:
- Adds 24 bundled IC skill documents.
- Serves bundled resources without runtime registry access.
- Updates constructors, privacy disclosures, and submission documentation.
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
README.md |
Documents static skill delivery. |
src/lib.rs |
Removes hosted live-catalog construction. |
src/assets/privacy-policy.html |
Updates effective date and recipients. |
docs/icp-mcp-privacy-policy-draft.md |
Updates draft privacy disclosures. |
docs/anthropic-directory-submission.md |
Updates directory attestations. |
crates/imcp2-local/src/main.rs |
Removes local live-catalog construction. |
crates/imcp2-local/src/server.rs |
Updates local test construction. |
crates/imcp2-core/src/tools.rs |
Serves bundled skill resources. |
crates/imcp2-core/src/skills.rs |
Defines bundle lookup and validation. |
crates/imcp2-core/static/skills/README.md |
Records bundle provenance and curation. |
static/skills/agent-web-identity.md |
Bundles web identity guidance. |
static/skills/caffeine-app.md |
Bundles Caffeine app guidance. |
static/skills/canhelp.md |
Bundles canister help guidance. |
static/skills/canister-security.md |
Bundles security guidance. |
static/skills/certified-variables.md |
Bundles certification guidance. |
static/skills/cloud-engine-canisters.md |
Bundles cloud-engine guidance. |
static/skills/custom-domains.md |
Bundles custom-domain guidance. |
static/skills/cycles-management.md |
Bundles cycles guidance. |
static/skills/deploy-to-cloud-engine.md |
Bundles cloud deployment guidance. |
static/skills/encrypted-maps.md |
Bundles encrypted storage guidance. |
static/skills/evm-rpc.md |
Bundles EVM RPC guidance. |
static/skills/https-outcalls.md |
Bundles HTTPS outcall guidance. |
static/skills/ic-dashboard.md |
Bundles dashboard API guidance. |
static/skills/icp-cli.md |
Bundles CLI guidance. |
static/skills/internet-identity.md |
Bundles authentication guidance. |
static/skills/migrating-motoko-actors.md |
Bundles migration guidance. |
static/skills/mops-cli.md |
Bundles Mops guidance. |
static/skills/multi-canister.md |
Bundles multi-canister guidance. |
static/skills/service-discoverability.md |
Bundles discoverability guidance. |
static/skills/stable-memory.md |
Bundles stable-memory guidance. |
static/skills/static-site.md |
Bundles static-site guidance. |
static/skills/troubleshooting-motoko-migrations.md |
Bundles migration troubleshooting. |
static/skills/vetkeys.md |
Bundles vetKeys guidance. |
static/skills/writing-motoko.md |
Bundles Motoko guidance. |
Suppressed comments (3)
crates/imcp2-core/static/skills/caffeine-app.md:434
- This second live-registry instruction reintroduces dynamic behavioral content despite the PR's static-review guarantee. Direct the client to the bundled
skill://writing-motokoresource.
8. **Writing Motoko without the `writing-motoko` skill.** Modern Caffeine Motoko uses
`mo:core`, persistent actors, and specific patterns. Guessing produces compiler
errors. Load <https://skills.internetcomputer.org/skills/writing-motoko/SKILL.md> first.
crates/imcp2-core/static/skills/caffeine-app.md:455
- This third live URL again tells the agent to bypass the reviewed bundle. Keep the required companion reference entirely within
skill://.
- **`writing-motoko`** — REQUIRED companion. Authoritative for all backend Motoko code:
<https://skills.internetcomputer.org/skills/writing-motoko/SKILL.md>. Always load it before
editing `src/backend/main.mo`.
crates/imcp2-core/static/skills/stable-memory.md:340
- The summary repeats the incorrect claim that
Bound::Unboundedprevents schema-compatibility breakage. It prevents size-bound failures, but existing CBOR still fails to deserialize after adding a required field without a default or migration.
- Implement `Storable` for custom types: define `BOUND`, `to_bytes`, `into_bytes`, and `from_bytes`. Use `ciborium::into_writer`/`ciborium::from_reader` for CBOR serialization (compact, fast). Prefer `Bound::Unbounded` -- it avoids backwards compatibility breakage when adding new fields. `Bound::Bounded` exists but is not recommended because exceeding `max_size` after a schema change breaks deserialization
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review found the bundle was not self-contained, which left the retrieval it removes still reachable from inside a served document: - Three lines of caffeine-app told the agent to load writing-motoko's SKILL.md from the registry over HTTP. A bundled instruction to fetch instructions defeats the bundle; they now point at the skill://writing-motoko resource. - Eleven companion documents that skills link to (the Motoko API reference, the asset-canister migration notes, and the rest) were not bundled, so every "see references/x.md" link was a dead end. They are bundled now, served as skill://<name>/references/<file>, and the links inside the bundled documents point at those URIs. - canhelp is dropped. It is a slash-command skill whose steps run ./scripts/*.sh from a local checkout, which no MCP client has, and its job is what get_canister_candid already does here. read_resource and list_resources now share one lookup (skills::bundled_skill_document), so what is served cannot drift from what is advertised. A test walks every bundled document and asserts the bundle is closed under its own links: nothing points at the registry, every skill:// link resolves, and every listed URI is readable. The bundle README records the two mechanical edits to the mirrored text (the link rewrite and the caffeine-app loads) so a refresh re-applies them, and states that content corrections belong upstream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk
|
CI is red on The failing check is That is a live-network test against the IC dashboard API, which is down: a direct request to the same URL returns HTTP 524 and then times out outright on three consecutive attempts from here. The There is no fix to port — the service is the fix — and I am not touching the test: making a live-network assertion pass while the network is down would mean weakening it, which is not something to do for an outage, and certainly not in this PR. The re-run is held until the dashboard answers again; a check-in is scheduled to do that and report back here only if the result is anything other than green. Generated by Claude Code |
The first pass extracted companion references by markdown-link syntax, which missed the ones written in prose — nine documents that skills tell the agent to read (icp-cli's four guides, vetkeys' ibe and bls-signing, encrypted-maps' metadata, and the binding-generation guide two other skills cite) were named but not bundled, so following that guidance still ended in resource_not_found. All of them are bundled now, and every `references/<file>.md` mention is rewritten to its `skill://` URI in prose as well as in links, inside the companions as well as the skills. The two cross-skill citations point at icp-cli's copy, which is where they said to look. The closure test now flags a bare relative mention as well as an unresolvable `skill://` link, which is what would have caught this: it was the mention form, not the link target, that the first pass got wrong. Three handoffs sent the agent to skills this bundle does not carry — cycles-management and internet-identity to wallet-integration, evm-rpc to canhelp. They now say the topic is outside the bundle, or name the tool that does the job. The README's closure claim is narrowed to what the bundle actually guarantees: references between bundled documents resolve to served resources. Ordinary external links (crates.io, GitHub, the docs site) stay as they are, and saying otherwise overstated the property. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 51 out of 51 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
crates/imcp2-core/src/skills.rs:36
- The current PR description says this table bundles 24 official skills, but it contains 23 entries (with
canhelpdeliberately excluded). Update the PR description to 23 skills plus 18 companion documents so the reviewed release inventory is accurate.
pub const BUNDLED_SKILLS: &[(&str, &str, &str)] = &[
The nested link in static-site's legacy-asset-canister companion was rewritten in the first pass and then silently reverted: the second pass re-fetched that file from the registry to pick up the companions the first pass had missed, overwriting the edit. The closure test did not notice, because it scanned for `references/` mentions and this link is a plain sibling target with no such segment. Both halves are fixed: the link points at its bundled URI again, and the test now also requires every markdown target ending in `.md` to be an absolute URI, which is the check that would have caught the revert. The bundle README no longer offers icp_lookup_canister_info_by_id as something available here — it lives on the unserved protocol router, so naming it alongside get_canister_candid described a tool no client of this bundle can call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk
The bundle test rebuilds the skill:// URIs from the same tables the handler serves them from, so it stays green if list_resources omits an entry or read_resource is wired wrong — the central path of this PR was the one thing not covered. The local crate already round-trips a real client over an in-process duplex pipe for the tool surface; the resource surface now goes through the same harness. It lists resources, reads every advertised skill:// URI and asserts each arrives non-empty, checks that a companion is reachable at the child URI its parent's links point at, and asserts that an unknown skill and an unknown companion of a real skill both come back as resource_not_found rather than a panic, a disconnect, or an empty document. No network: every resource is compiled in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 51 out of 51 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/imcp2-core/src/skills.rs:455
- The closure check still misses bare references to a companion's parent
SKILL.md, even though that filename is not a readable MCP URI. Current examples includereferences/icp-cli/canister-env-vars.md:3,binding-generation.md:91,references/vetkeys/ibe.md:158, and both static-site companions. Rewrite these to their parentskill://<name>URI and extend this check to reject bareSKILL.mdmentions; otherwise the documented guarantee that internal references resolve to served resources is not true.
// The same holds for a markdown link straight at a sibling file,
// which carries no `references/` segment to catch it by: inside a
// companion, `](migrating-from-asset-canister.md)` addresses a
// document the client cannot open either. Every markdown target
// ending in `.md` must be an absolute URI.
Two conflicts, both from #164 touching the privacy policy this branch deletes: - src/assets/privacy-policy.html: modified on main (the third-party recipients list drops skills.internetcomputer.org — skills are now served from the reviewed bundle compiled into the binary, so no network fetch to disclose), deleted here. The deletion stands; the content change is ported to the page's one home in dfinity/internetcomputer-org (the #77 re-sync branch), where the policy now lives. - docs/anthropic-directory-submission.md: both sides rewrote the "Publication venue" paragraph. Kept this branch's new-home framing, folding in main's note about what the refreshed text carries (the identifier-linkability wording and the updated third-party list). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xpg83AWtkQo34jbMxpX8WB
main now ships a service-discoverability skill (#164's static bundle): the deploy-time recipe for generating /.well-known/ic-architecture with real per-environment canister IDs. That is exactly what someone who has just hit this refusal needs, and the person reading it is sometimes the one who can ship the manifest. So the refusal names skill://service-discoverability alongside the published guide, mirroring how compliance.rs points at skill://icp-cli and skill://cycles-management for the operations it declines. "Publish a manifest" is a much weaker handoff than the recipe for generating one. Also merges main, which moved under this branch while it was open. No textual conflict, but main's series touched tools.rs, IcTools::new and the README, so the merge was verified rather than assumed: it builds and the full suite passes (264 tests), and clippy is unchanged at 11. The skill and this gate agree, which is worth stating: the skill tells an app how to publish the manifest, and the gate makes publishing it the act that permits a write. Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB Co-authored-by: Claude <noreply@anthropic.com>
…tions The base branch was rebased onto main (picking up #164, #169 and #170), so this branch's base commit no longer existed and the PR went un-mergeable. Merged the new base head rather than rewriting this branch's history. Five conflicts in tools.rs, resolved deliberately: - `discover_app_canisters`'s description: kept this branch's, which documents the empty-result behaviour for a domain with no Internet-Computer evidence. - SERVER_INSTRUCTIONS and its doc comment: kept this branch's rewrite, and adopted the base branch's corrected financial paragraph into it. That paragraph no longer says canister_update_call "refuses" financial transactions as a class — the guard is a safeguard, not a complete filter, so the policy rather than the absence of a refusal is stated as the limit. - The financial-policy test: took the base branch's corrected comment and its new assertion pinning "a safeguard, not a complete filter", so the hedge cannot be dropped by a later rewrite of this text. - The model-readable-metadata policy test block: kept this branch's; the base branch has none. Both submission documents merged cleanly: the base branch adopted the wording already reviewed here, so the two no longer diverge on the paragraph that described these instructions. Workspace green (52 + 10 + 10 + 149 + 21 + 1), clippy at the 10-warning baseline. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvwAVXfx5kLNc4SqKXkavk
…bility) (#166) * Restrict update calls to canisters an app declares (service discoverability) Reading the Internet Computer is open; writing is not. A state-changing call runs against someone's live application, and a canister being publicly callable is not a statement by its operators that they want an agent driving it. `canister_update_call` is now made ONLY to a canister the owning app DECLARES in its service-discoverability manifest at /.well-known/ic-architecture — publishing that manifest is, per the published guide, exactly how an app's operators opt in. The manifest lives at the app's origin, not on chain, so the tool takes a new `app_url` argument (open_app already returns it) naming the app whose manifest authorizes the write; it falls back to `derivation_origin` when the app serves its manifest at that same origin. A successful call echoes `declared_by`/`declared_at`, so a write's provenance is visible in the reply, not only to the gate. The new `discoverability` module holds the policy and every refusal text, alongside `compliance` (the financial-methods gate). Refusals distinguish their four causes — no origin given, origin unreachable, no manifest published, published but this canister is not in it (that one lists what the app does declare) — and each one names the standard path, links the guide so an app owner can act on a relayed refusal, and states that reads are unaffected, so an agent answers what it can rather than concluding the whole app is off limits. The module doc is explicit about what the gate is not: it is a consent-and-provenance gate, not proof of ownership (whoever controls a domain controls what its manifest says), and not a substitute for the canister's own authorization. Supporting protocol work in `discover`, needed because the gate keys on the protocol rather than on this server's earlier proposal: - /.well-known/ic-architecture is now read during discovery, ranked just under the App Connect main-backend meta and above the legacy path. The legacy /.well-known/ic-app.json is still read, at lower authority and still gate-eligible, so the apps that adopted the proposal are not cut off the day the standard path lands — the one known adopter today publishes only that path. - /.well-known/ii-derivation-origin (Layer 5) now takes precedence over the legacy manifest's `derivation_origin` field. Without it, an app that follows the published guide and pins a custom derivation origin would resolve to the wrong principal — a bug the gate would otherwise have created, since it makes the protocol the thing writes key on. - The three well-known probes on each path run concurrently, so honouring both the protocol and the legacy path costs no extra round trip. - Manifest labels fall back to the protocol's `name` when no `role` is given, so a spec-shaped manifest yields a label, not a bare principal. Everything fails closed on the misconfiguration the guide calls out as the most common one: an SPA catch-all answering /.well-known/* with index.html, which returns 200. A manifest body that is not a manifest document reads as "publishes none" rather than "declares nothing", and an HTML first line is not an origin, so a mis-served Layer 5 file leaves the app deriving against its visible origin instead of garbage. Verified against the live reference app, which today serves exactly that shape at both protocol paths. Reads are untouched: canister_query, get_canister_candid, get_canister_api_doc, the OQL surface and every discovery tool work on any canister, as before. Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB Co-authored-by: Claude <noreply@anthropic.com> * Harden the discoverability gate: redirect pinning, safe echoes, honest refusals An independent design review of the gate confirmed the shape but found five things worth fixing. All are small and none changes which canisters authorize a write. Redirect pinning. A manifest is now honoured only when the origin that ANSWERED is the origin we PROBED. The shared redirect policy already refuses a cross-domain hop, but it permits same-host different-port hops and hops to global IP literals, so without this an origin could 3xx and have another origin's declaration attributed to it -- making the `declared_by` provenance the caller is shown a true-looking but wrong statement. A skipped redirect is logged. Sanitized, capped error echo. `unreachable_refusal` interpolated the raw transport error, which for a hostile origin can carry an attacker-chosen TLS certificate subject or redirect URL straight into the model's context. It now goes through the same control-char scrub the manifest labels use, capped at 200 chars (CWE-150). Truncation no longer masquerades as absence. `manifest_canister_ids` silently dropped entries past the 100-entry cap, so declared entry 101 drew a "not declared" refusal -- a false statement about the app. The overflow is carried and reported, and the refusal blames the manifest's length rather than the app's declaration. The SPA catch-all is named, not just detected. When the protocol path answers 2xx with something that is not a manifest, the refusal now says what it answered with and that the usual cause is an SPA catch-all returning index.html, with the fix (exempt /.well-known/* from the rewrite). The guide calls this the most common failure; an operator told only "absent" would go looking for a file that is already there. This changes the message, not the verdict -- the shape check already rejected the HTML. The no-manifest refusal stops the retry loop. It previously read as "try a different app_url", which invites an agent that already has the right origin to burn turns re-resolving it. It now says outright that re-running open_app will not change anything and to stop. Also: legacy-path authorizations are logged with the origin, so the fallback can be retired on evidence rather than on a guess (today it is the only path that authorizes anything in the known-app registry); a note on `mgmt_call` records why the deferred management writes sit outside this gate and what to revisit if that group is ever served; and the README's SSRF section now says `canister_update_call` makes an untrusted outbound fetch, which was true the moment the gate landed and was not stated. New tests pin the negative space too: no gate refusal may borrow the vocabulary of the read-only-session rejection ("reconnect", "Actions & questions") or of the financial-methods refusal ("oisy.com"), since an agent sent down either path would ask the user to fix something that is not broken. Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB Co-authored-by: Claude <noreply@anthropic.com> * Hold a declared id to the protocol's canister-principal rule The guide says an `id` "is required and must be a canister principal". The gate only checked that it parsed as A principal, which is a weaker rule than the one the protocol states: user principals (29 bytes, 0x02), the anonymous principal (1 byte, 0x04) and the management canister aaaaa-aa (an EMPTY blob) all parse happily. The management canister is the one that matters: without this, a manifest declaring aaaaa-aa would authorize canister_update_call against it. Opaque canister ids are 10 bytes with the 0x01 type tag, verified against real ids rather than re-derived from memory, and every real app canister is one. Enforced on the gate's read of a manifest only -- the display path that feeds discovery listings is unchanged, since showing an odd declared id is informative while acting on one is not. Deliberately the spec's own type rule and not a policy list of ours: WHICH canisters an app may declare is the app's business, but WHAT KIND of principal an `id` may be is the protocol's. Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB Co-authored-by: Claude <noreply@anthropic.com> * Point the no-manifest refusal at the served how-to skill main now ships a service-discoverability skill (#164's static bundle): the deploy-time recipe for generating /.well-known/ic-architecture with real per-environment canister IDs. That is exactly what someone who has just hit this refusal needs, and the person reading it is sometimes the one who can ship the manifest. So the refusal names skill://service-discoverability alongside the published guide, mirroring how compliance.rs points at skill://icp-cli and skill://cycles-management for the operations it declines. "Publish a manifest" is a much weaker handoff than the recipe for generating one. Also merges main, which moved under this branch while it was open. No textual conflict, but main's series touched tools.rs, IcTools::new and the README, so the merge was verified rather than assumed: it builds and the full suite passes (264 tests), and clippy is unchanged at 11. The skill and this gate agree, which is worth stating: the skill tells an app how to publish the manifest, and the gate makes publishing it the act that permits a write. Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB Co-authored-by: Claude <noreply@anthropic.com> * Drop the ai-connect.html discovery source The App Connect bridge page is not part of the service-discoverability protocol, so discovery no longer probes /ai-connect.html or reads its ic:canister-id meta. App-declared metadata is now exactly the two manifest documents: /.well-known/ic-architecture (Layer 1, the only source that authorizes an update call) and the legacy /.well-known/ic-app.json. The HTML meta parser and its attribute tokenizer went with it: nothing else called them. The authority ranking closes the gap the removed tier left, so the protocol manifest now leads; docs and tool descriptions follow. No discovery coverage is lost in practice. Of the apps we probe, only multidex.ai actually serves the meta, and it declares that same backend id in its ic-app.json manifest; the others answer /ai-connect.html with their SPA catch-all, which never carried the meta. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB * Stop the legacy manifest path from authorizing writes Only /.well-known/ic-architecture authorizes an update call now. Publishing that manifest is the act by which an app's operators opt in, under this connector's terms; the operators who adopted this server's pre-protocol /.well-known/ic-app.json proposal published it against different terms and never agreed to those. Consent that was never given cannot be inherited from a path this server invented, so an early adopter is refused. Discovery still READS the legacy document — reads were never gated — and the gate still fetches it, but only as evidence for the refusal: an origin that serves it gets its own message naming the document it DOES publish, saying that serving the same JSON at the standard path is the whole fix, and, when the older document lists the target, making clear the refusal is about where the declaration lives rather than about an unknown canister. Reporting these apps as "publishes no manifest" would send their operators hunting for a file that is already there. ManifestProbe::Absent carries that document; only ARCHITECTURE_PATH can produce Declared. The log line changes with it: the origins still on the old path are now recorded as refused rather than as authorized, which is the same adoption signal read the other way round. Verified live: multidex.ai, the one origin that authorized anything through the legacy path, is now refused — a new live test pins exactly that, against the id its own legacy document declares. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB * Document that only the standard path authorizes a write Follows the gate change: the README's write-gate section gains the reason the legacy document cannot stand in (its publishers adopted a pre-protocol proposal under different terms), the refusal-cause list gains that case, and the app-declared-metadata section no longer reads as though the legacy path keeps writes working. The discover module's own docs say the same: the legacy manifest is read for discovery, not for authorization. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB * Bind the app manifest to the identity the write is signed as The manifest gate establishes that someone published a document at the origin the caller named. It said nothing about whose identity the call goes out under, and those were separable inputs: app_url picked the manifest, derivation_origin picked the principal. 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 was signed with the principal the user holds at an app they actually trust. Where a call carries both, the app at app_url is now resolved to the derivation origin Internet Identity derives its users from, and a call naming a different one is refused. An attacker's manifest can then 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 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 claim another's identity to satisfy the check. It fails closed — an origin that cannot be resolved refuses rather than being treated as a match — and the two probes run concurrently, so the binding costs no extra round trip. 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. Also in this commit, the review findings on the same path — the binding rests on the first of them, and the rest are one-line corrections it would have been artificial to separate: - A transport failure on the identity path no longer collapses into "not declared". fetch_well_known now distinguishes Served / Absent / Unreachable, and an unreachable probe is an error rather than a fallback: a Layer 5 file we failed to fetch is not "no Layer 5 file", and defaulting past it would derive — and sign as — a principal the app does not pin. Refusing is recoverable; acting as the wrong identity is not. - app_url is reduced to its origin before it is checked, fetched or echoed, so a refusal cannot be flooded through the unbounded path of an otherwise valid URL, and every echoed origin goes through the module's own cap. - The gate's decision is split from the fetch that feeds it. With nothing published on the standard path yet, every live test of the YES branch can only skip, and a local fixture is unreachable behind the SSRF guard — so the decision is now pinned on constructed input, including that the legacy document does not authorize. - Three resolution messages and a README paragraph still named only the legacy /.well-known/ic-app.json, which stopped being the only source of a declared derivation origin when this branch gave Layer 5 precedence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB * Fail closed on the Layer 5 format, transient statuses and declared_at Three findings from the second review pass, all on the same theme: a value we could salvage is not the same as a value the app declared. - The Layer 5 file's documented format is now ENFORCED rather than coerced. It was read through the general origin normalizer, which accepts a bare host and quietly drops a path — so `example.com` or `https://example.com/path` became an authoritative identity declaration. This file decides which principal the user acts as, and a wrong one fails silently, as a call that simply isn't them. Anything that is not exactly one non-empty line carrying an explicit https origin now reads as "declares nothing", which lands the app on its own visible origin: the default the protocol already specifies for an absent file. The legacy manifest's field keeps the looser parse it shipped with; only the protocol's own document has a published format to hold to. - A non-success status no longer collapses into "publishes no manifest". That refusal tells the app's operators to publish one and the agent to stop retrying, so only a definitive absence may produce it: 404 and 410 do, while 429 and the 5xx family are a failure to find out, and a 403 is the origin declining to say. The rule is a small pure function so it can be pinned without a network, which the SSRF guard would otherwise make impossible. The gate's probe follows: only the standard path's failure is fatal now, since a legacy probe can only enrich a refusal that is happening anyway. - `declared_at` documented a value a successful call can no longer carry. It is always the standard path, and says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB * Keep a failed read from reading as an absent declaration Third review pass, all the same shape as the second: a value we could not read is not a value the app does not have. - fetch_well_known applied the "absent" verdict to every non-success status, so a 429 or 5xx during an outage could fall through to the legacy field or the application-origin default and sign as a different principal. It now uses the same rule as the gate's probe: only 404 and 410 mean absent. - read_capped turns a mid-body transport failure into partial text, which suited the discovery crawl but let both probes classify a dropped connection as a completed non-manifest response — the permanent "publishes no manifest; stop retrying" verdict for an app that may serve a perfectly good manifest. The callers that draw a conclusion from a body now use a strict reader that propagates the failure; the opportunistic crawl keeps the fail-soft one. Hitting the size cap stays a success: that is a bounded read this server chose, not a failed one. - The over-long-manifest refusal led with "does not declare X" and admitted two sentences later that entries past the read cap were never checked. If the target is one of them, the opening clause is the false statement that handling exists to avoid, so the verdict is now indeterminate from the start. - The identity refusal told every caller to retry, including the one failure retrying cannot fix: a cross-origin derivation origin the declared origin does not authorize back. That case now gets the configuration fix instead. - Two tool descriptions still promised the old fail-soft resolution. They now distinguish a probe that answered without a usable declaration (assume) from one that did not complete (refuse). Also rewraps two refusal strings an earlier edit had joined into single 800-character source lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB * State the two limits of the ownership gap outright The module doc and README already said the gate is not proof of ownership. Review pointed at the two cases a reader is most likely to assume away, so they are now named rather than left to be inferred: an anonymous write skips the identity binding entirely, since there is no app identity to protect, and an authenticated one binds to the caller's own app while still reaching any victim method that accepts an arbitrary principal. Neither grants a capability an attacker did not already have — 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. That distinction is the honest claim, and it is now the one the docs make. Closing the remaining gap needs an association the TARGET attests to, and nothing a canister publishes today names its app's origin, so it is a protocol question rather than something to invent here. Raised on the pull request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB * Detect a truncated read, and say what overflow means per document Fifth review pass. The strict reader still passed a body that hit the size cap off as a whole one, so a conclusion could be drawn from bytes this server chose not to read: a manifest over the cap parses as "not JSON" and becomes the permanent "publishes no manifest" verdict, and a Layer 5 file could hide a second non-empty line past the cap and slip the one-line rule. It now reads one byte beyond the cap so overflow is detectable. What overflow MEANS turned out to differ by document, which the first attempt at this got wrong and a live test caught: oisy.com answers the Layer 5 path with its SPA shell, which is larger than the 4 KiB cap, so refusing on overflow stopped resolving a perfectly healthy app. For a document whose FORM bounds its size — one canonical origin on one line — an over-cap body is positive evidence that what was served is not that document, so it reads as "declares nothing" and the app lands on its own origin. For an open-ended JSON manifest there is no such inference, so overflow stays a read we could not finish. The two are now separate outcomes rather than one rule. Also from the same pass: - The `open_app` argument schema and the `resolve_app` output schema carried the claims their descriptions had already dropped: that any failed fetch counts as no declaration, and that a "declared" derivation origin comes only from the legacy path. Both are model-readable metadata and were contradicting the code. - The manifest-check refusal told every caller to retry, including the ones retrying cannot help: an origin answering 401/403 on the path is denying it deliberately, and a blocked redirect is a configuration this server will not follow. Those now get the fix instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB * Cover the gate against a real adopter, and fix the failure classifier svault.tech (built with caffeine.ai) publishes the Layer 1 manifest at /.well-known/ic-architecture and pins a CROSS-ORIGIN derivation origin at /.well-known/ii-derivation-origin. Verified live end to end: the manifest parses and declares two canisters, the identity resolves to the declared cross-origin value (so Internet Identity's own ii-alternative-origins authorization passed — that origin lists svault.tech back), the gate authorizes a declared canister, and the binding accepts the pair. That correction matters beyond the tests: the YES branch now has live coverage, and the cross-origin case is exactly the one a literal app_url-equals-derivation_origin comparison would have false-refused, which is why the binding resolves the app instead of comparing strings. The constructed-input tests stay, since the live ones depend on someone else's deploy remaining up. Also from the sixth review pass, both in the failure classifier added one commit ago: - It looked for the word "redirect", which a stopped redirect never contains — the redirect policy surfaces it as its 3xx status — so the case the check was written for fell through to "likely transient, retry". It now classifies on the status shapes the fetch helpers actually produce, with 408 and 429 kept retryable, and the identity refusal shares the same rule instead of only knowing about an unauthorized cross-origin claim. - The over-long-manifest refusal quoted the number of canister principals RETAINED as the number of entries read, which understates when a manifest carries malformed entries. It no longer quotes a number it cannot support. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB * Correct two doc comments the fail-closed change left behind Both describe code this branch changed, and both would mislead a maintainer about exactly the semantics the change exists to establish: - WellKnown::Absent still said "any non-2xx". Only 404 and 410 reach it now; every other non-success status is Unreachable, because an origin declining or failing to serve the path has not told us the app declares nothing. Both variants now say what they mean and what they exclude. - resolve_declared_origin still said a "missing/unsuccessful/undeclared" declaration yields the application-origin default. Only a declaration the app ANSWERED without providing does; a probe that did not complete is an error, since defaulting past it would derive, and sign as, a principal the app does not pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JarhapgjytMcVSCW7d8sqB * Key the known-app fallback on the origin, not the bare host A non-default port is a different origin everywhere else in this codebase — Internet Identity derives a different principal for it, and identities::target_origin keeps it rather than stripping it the way it strips :443 — but KNOWN_DERIVATION_ORIGINS is keyed by host alone, so https://oisy.com:8443 inherited https://oisy.com. At the write gate that is the whole comparison: a manifest served on that port would authorize a write signed as the user's principal at the real app. The registry stays host-keyed; the lookup for anything derived from a URL now refuses a non-default port and drops to app_url_default, which being a distinct origin then fails the binding on its own. Reaching the case needs control of the registered host, so this closes the gap rather than a live break. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Pin the identity probes to the origin that was probed fetch_declared_manifest honours a manifest only when the origin that answered is the origin that was probed. The identity path did not: only the IC-evidence capture compared the final URL, while the BODY was parsed whatever origin produced it. 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 hand another origin's bytes to Layer 5 — the document that decides which principal every call is signed as. Same rule, in the one place fetch_well_known already funnels every identity probe through. A redirected answer is Unreachable rather than Absent: Absent means "the app declares nothing, derive against the default", and a document we deliberately ignored is not one the app does not have. Neither pin can be driven from a local server (both need a final URL that differs from the probed one, and the SSRF guard refuses loopback), so both stay on the live tests, as the existing comment now records. The full suite, live tests included, is unchanged at 266. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Pin the origin before reading the status, and on the discovery probes Two gaps the previous commit left, both raised in review. The origin pin in fetch_well_known ran AFTER the status classification, so a redirect target's 404 still became Absent — "the app declares nothing, derive against the default" — which is the bug the pin exists to prevent, reached by a different route. Nothing a foreign origin says about this path is an answer about this app, so the pin now comes first and the status is only read once the probed origin is the one answering. fetch_success_body dropped served_from entirely, so discovery attributed a redirect target's manifest to the app that was probed, at the protocol path — the top authority tier, which is what the model picks a canister on. Discovery authorizes nothing, but it does make a provenance claim, and that claim was capable of being wrong in exactly the way fetch_declared_manifest already pins against. Suite unchanged at 266, live tests included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Pin the authorization list to the origin that was probed ii-alternative-origins is the list that decides whether a cross-origin derivation-origin claim is honoured, and it was the one fetch on this path still parsing whatever a successful response carried. A redirect target's answer is not the declared origin's statement, so it cannot grant the claim; it now returns the empty list every other failure here already returns, which is the fail-closed value. The rule the last two commits applied in two places is now one predicate, answered_by, shared by all three (ic_evidence_from already had it inline). Suite unchanged at 266, live tests included — a_cross_origin_declared_ identity_binds still passes, so the real cross-origin case is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: sea-snake <thomas.gladdines@dfinity.org>
Summary
The served
skill://resources are now compiled into the binary from a reviewed, versioned bundle instead of being fetched live fromskills.internetcomputer.orgat request time. Directory review treats dynamically retrieved behavioral instructions as a rejection trigger; a static bundle means what an agent reads is exactly what was reviewed at release, and the served binary no longer retrieves any instructions over the network.Related issues
None — follow-up in the directory-submission series (after #158/#161).
Changes
crates/imcp2-core/static/skills/— 23 official IC skills (SKILL.mdeach) bundled 2026-08-28 from the public skills registry, plus 18 companion documents underreferences/<skill>/<file>, served asskill://<skill>/references/<file>. The bundle README documents provenance, the curation (what was excluded and why), the local edits applied to the mirrored text, and the refresh procedure.skills.rs—BUNDLED_SKILLSandBUNDLED_SKILL_REFERENCEStables (include_str!), withbundled_skill_document()resolving askill://path for bothlist_resourcesandread_resource, so the served set cannot drift from the advertised set. Two tests: the bundle is well-formed, and it is closed under its own references — no document points at the registry, everyskill://link resolves, no companion is named by a bare relative path, and every listed URI is readable.tools.rs—IcTools(the served composition) drops itsSkillsCatalog; resources come from the bundle. The liveSkillsCatalogremains library code for the unservedIcProtocolTools, so an embedder who wants live skills still has the machinery.src/lib.rs,crates/imcp2-local/— neither binary constructs the live catalogue any more, so neither readsSKILLS_URLnor contactsskills.internetcomputer.org.Local edits to the mirrored text
The files are verbatim apart from three mechanical edits, recorded in the bundle README and re-applied on refresh: companion references rewritten to their
skill://URIs; three lines ofcaffeine-appthat told the agent to load another skill'sSKILL.mdover HTTP pointed atskill://writing-motoko; and handoffs to skills this bundle does not carry (wallet-integration,canhelp) neutralized. Content itself is not edited — corrections belong upstream in the registry, and four defects found during review were forwarded there.Testing
cargo build --locked --all-targetscargo test --locked --all-targets— green exceptdiscover::tests::search_finds_ckusdc_and_lookup_identifies_it, a live-network test failing because the IC dashboard API is down (see the comment below); unrelated to this diff and failing identically on other branchescargo clippy --all-targets— unchanged 10-warning baseline (this repo is not rustfmt-clean; no repo-widecargo fmt, matching prior PRs)npm test --prefix monitoring/mcp-status(dashboard unchanged)Checklist