Repository navigation
feat(be,fe): read-only mode via delegations with queries-only permissions - #4016
Conversation
Adds a "Read-only mode" checkbox to the authorize continue screen. When enabled, account delegations prepared for the session carry the new permissions field set to "queries" (covered by the delegation signature), which makes the IC reject update calls authenticated through them (see dfinity/ic#10449) while query calls remain permitted. - BE: trailing read_only opt arg on prepare_account_delegation and get_account_delegation; local delegation_signature_msg helper supporting the permissions field; Delegation candid record gains permissions : opt text. - FE: checkbox wired through authorizationStore into the ICRC-34 delegation channel handler and passed to both endpoints. - Tests: integration test asserting the permissions field, signature validity, and that the signature binds to the permissions (lookup without read_only yields NoSuchDelegation). Note: returning restricted delegations to relying parties requires @icp-sdk/core to round-trip the permissions field; until then, restricted delegations fail closed on the dapp side.
…ecified
Address review on prepare/get_account_delegation: when the `read_only`
argument is omitted, the backend now defaults to a read-only
(queries-only) delegation rather than an unrestricted one, erring on the
side of least privilege. This intentionally differs from the interface
spec, where an absent `permissions` field means unrestricted ("all") for
backward compatibility.
Because the default flips to read-only, first-party callers now pass an
explicit value so they are not silently downgraded:
- FE: the ICRC-34 handler sends `[false]` for normal authorizations and
`[true]` only when the user enabled read-only mode; the CLI flow sends
`[false]` (CLI sessions are update-capable).
- Tests: the plain `prepare/get_account_delegation` helpers pass
`Some(false)` so existing tests keep exercising unrestricted
delegations; the signature-binding check now looks up with `Some(false)`
(since omitting now means read-only), and a new test locks in the
read-only default for an omitted argument.
candid docs updated to document the omitted-defaults-to-read-only behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN
…orcement The "Read-only mode" tooltip claimed "Only apps that support certified session attributes enforce this restriction", which is wrong: enforcement is protocol-level. The session delegation carries `permissions = "queries"` and the Internet Computer rejects update calls authenticated through it at ingress validation, regardless of the app or canister. Reword the tooltip accordingly and drop the stale "certified session attributes / implicit:permissions / sender_info" vocabulary from the related doc comments, which described an earlier design rather than the shipped `permissions`-field mechanism. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN
…n-boufou # Conflicts: # src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte
Surface the read-only delegation choice in all three sign-in flows via a shared component, with per-flow defaults: - /continue: full access (opt-in read-only) — unchanged default - /cli: read-only (opt-out to full) - /mcp: read-only (opt-out to full) FE: - New reusable ReadOnlyToggle component (checkbox + tooltip, bindable `checked`); ContinueView now uses it instead of the inline block. - CliAuthorizeView gains the toggle (default on) and threads readOnly through cliAuthorize to the prepare/get_account_delegation read_only arg. - McpAuthorizeView gains the toggle (default on) and threads readOnly through mcpAuthorize to mcp_set_access. MCP backend (read-only must be persisted, not stamped on the standing delegation — the server needs a full-access standing delegation to call the update endpoint mcp_prepare_account_delegation): - mcp_set_access gains `read_only : opt bool`; the flag is persisted in a new parallel principal->anchor map (memory index 27) so existing grants need no migration (absent = full access). - mcp::prepare/get_account_delegation read the persisted flag for the caller and mint queries-only per-app delegations when set. Tests: integration tests asserting a read-only MCP grant mints queries-only per-app delegations (permissions = "queries") and a full-access grant mints unrestricted ones. All 40 mcp/accounts/ session_delegation integration tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN
Apply prettier to the two files added/edited in the previous commit so `npm run format-check` (frontend-checks CI) passes. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN
There was a problem hiding this comment.
Pull request overview
Adds end-to-end support for issuing query-only (“read-only mode”) delegations by stamping delegations with the optional permissions field (set to "queries") and binding that value into the delegation signature, enabling protocol-level enforcement (IC rejects update calls authenticated via such delegations).
Changes:
- Backend: extends delegation signing/lookup to optionally bind
permissions="queries"and persists an MCP-grant-levelread_onlyflag in stable storage. - Frontend: introduces a reusable “Read-only mode” toggle and wires the chosen flag through authorization/MCP/CLI flows into the canister calls.
- Tests: adds integration coverage for permissions-bearing delegations and signature binding behavior.
Reviewed changes
Copilot reviewed 24 out of 26 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/internet_identity/tests/integration/mcp.rs | Adds MCP integration tests for read-only/full-access grants and minted per-app delegation permissions. |
| src/internet_identity/tests/integration/accounts.rs | Adds integration tests for read-only account delegations, including signature binding to permissions. |
| src/internet_identity/src/storage.rs | Persists MCP grant read-only restriction in a new stable map and exposes lookup helper. |
| src/internet_identity/src/session_delegation.rs | Updates delegation construction to include permissions: None. |
| src/internet_identity/src/openid.rs | Updates delegation construction to include permissions: None. |
| src/internet_identity/src/mcp.rs | Threads persisted MCP grant read-only flag into prepare/get account delegation paths. |
| src/internet_identity/src/main.rs | Extends canister API with trailing read_only args and sets default behavior for omitted values. |
| src/internet_identity/src/delegation.rs | Adds signature-message helper that supports optional permissions and a signing helper for read-only. |
| src/internet_identity/src/account_management.rs | Signs and serves account delegations with optional permissions-bound signatures. |
| src/internet_identity/internet_identity.did | Extends Candid types and methods with permissions and read_only args (documented semantics). |
| src/internet_identity_interface/src/internet_identity/types.rs | Adds permissions: Option<String> to the Delegation Rust interface type. |
| src/frontend/src/routes/(new-styling)/mcp/views/McpAuthorizeView.svelte | Adds read-only toggle to MCP connect UI and passes it through authorization callback. |
| src/frontend/src/routes/(new-styling)/mcp/utils.ts | Persists read-only flag with MCP access grant and keeps standing delegation unrestricted. |
| src/frontend/src/routes/(new-styling)/mcp/+page.svelte | Threads read-only choice into MCP authorize handler. |
| src/frontend/src/routes/(new-styling)/cli/views/CliAuthorizeView.svelte | Adds read-only toggle to CLI authorization UI (opt-out full access). |
| src/frontend/src/routes/(new-styling)/cli/utils.ts | Passes read-only flag into account delegation prepare/get for CLI flow. |
| src/frontend/src/routes/(new-styling)/cli/+page.svelte | Threads read-only choice into CLI authorize handler. |
| src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte | Adds read-only toggle to the standard “Continue” authorization screen. |
| src/frontend/src/routes/(new-styling)/authorize/+page.svelte | Extends authorize handler to forward read-only choice to authorizationStore. |
| src/frontend/src/lib/stores/channelHandlers/delegation.ts | Always passes explicit read_only to canister when preparing/getting delegations. |
| src/frontend/src/lib/stores/authorization.store.ts | Stores user’s read-only choice alongside account selection for delegation flow. |
| src/frontend/src/lib/generated/internet_identity_types.d.ts | Regenerates TS types to include delegation permissions and new method args. |
| src/frontend/src/lib/generated/internet_identity_idl.js | Regenerates IDL factory to include delegation permissions and new method args. |
| src/frontend/src/lib/components/ui/ReadOnlyToggle.svelte | New reusable UI component for the “Read-only mode” checkbox + tooltip. |
| src/canister_tests/src/framework.rs | Updates signature verification helper to incorporate delegation permissions. |
| src/canister_tests/src/api/internet_identity/api_v2.rs | Extends test API helpers to pass explicit read-only arg and adds read-only variants. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…t doc - framework.rs: verify_delegation now incorporates `targets` into the representation-independent hash (it claimed to but didn't). No behavior change for current tests, which all use `targets: None`, but the helper now matches the interface spec and its own comment. - mcp.rs: reword the read-only MCP test's doc comment — it claimed an unrestricted-lookup-yields-NoSuchDelegation check that the body doesn't perform (the MCP path derives read_only from the persisted grant, so it has no per-call variant to contrast). That signature-binding proof lives in the accounts test, now cross-referenced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN
|
I'm concerned that this PR changes the semantics of an existing Candid-compatible API in a way that can break already deployed canister integrations. Old callers of There is a second compatibility concern: the signed delegation format / verification preimage must not change in-place for the existing API. Some deployed canisters verify II delegations inside wasm using the existing signed message shape. If II starts signing The backward-compatible behavior should be:
If query-only delegations require a different signed format, please introduce that through a versioned delegation type or new endpoint rather than changing the meaning of existing successful calls. |
Collapse the `Principal -> AnchorNumber` reverse index and the parallel
read-only set into a single `Principal -> StorableMcpAccess { anchor_number,
read_only }` map, keyed by the standing MCP-server principal. The
`mcp_*_account_delegation` methods now recover the authorizing anchor and the
read-only restriction in one `caller_access()` lookup — the MCP server never
passes an anchor number; one grant corresponds to one identity.
The anchor stays as the grant's value (a principal is a one-way hash, and the
anchor is needed to derive each app account's principal at call time), but it's
no longer a standalone index nor duplicated across a second map.
MCP is preview-only, so the new map uses a fresh memory index (28); the old
regions (25, 27) are abandoned and any preview grants are recreated on the next
connect — no migration. No candid/frontend/test API changes; mcp + accounts
integration suites pass (34 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN
…ndid The `permissions` field comment now spells out that an absent field means unrestricted *per the interface spec* (the protocol field on a returned delegation), distinct from the `read_only` argument of prepare/get_account_delegation, which defaults to read-only when omitted. Addresses review confusion between the two "absent" semantics. Comment-only; no binding change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN
|
✅ No security or compliance issues detected. Reviewed everything up to 2290448. Security Overview
Detected Code ChangesThe diff is too large to display a summary of code changes. |
|
Thanks for raising these concerns, @unixway,
This observation is correct. Out of curiosity, could you list some existing canister integrations that would break? Regardless, we'll consider keeping the more-liberal defaults (similar to the corresponding ICP protocol extension's liberal defaults, e.g., if permissions are not specified, the delegation is treated as a write-access delegation).
Could you clarify what you mean by "inside wasm," please? Delegations are normally verified by the ICP protocol. For context, the new (optional) |
…s-compatible) Per review (thanks @unixway): an omitted `read_only` argument on prepare/get_account_delegation now defaults to an UNRESTRICTED delegation (`unwrap_or(false)`), not read-only. This preserves the original behavior for existing callers of the pre-feature 5-argument form — they keep getting update-capable delegations instead of being silently downgraded to query-only — and matches the interface spec's liberal default for an absent `permissions` field. The per-flow UI defaults are unchanged: they are always sent explicitly, so /cli and /mcp still default to read-only (opt-out) and /continue to full access (opt-in). Only callers that omit the argument entirely are affected. - main.rs: both account-delegation endpoints `unwrap_or(true)` -> `unwrap_or(false)`. - internet_identity.did: updated `read_only` arg docs; the `Delegation.permissions` field comment returns to plain "absent means unrestricted" (no spec divergence to call out anymore). - api_v2 plain helpers revert to the 5-argument form (exercise the omitted-arg path); the default test now asserts an unrestricted delegation and is renamed `should_default_to_unrestricted_account_delegation_when_unspecified`. - delegation.ts: drop the stale comment about the backend default. accounts + mcp integration suites pass (34 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN
…lows Add integration tests for the read-only delegation behavior of every flow that supports it. The account-delegation endpoints (`prepare/get_account_delegation`) back both the /continue (ICRC-34) and /cli flows, so those tests cover both: - should_issue_explicitly_unrestricted_account_delegation: explicit `read_only = false` (what the authorize flow sends for a normal sign-in and the CLI sends on opt-out) yields no `permissions` field and verifies. - should_issue_read_only_delegation_for_non_default_account: read-only works for a created (non-default) account — the /continue multi-account case — minting a `permissions = "queries"` delegation that verifies. - read_only_does_not_change_the_delegated_principal: read-only restricts call types, not identity — same account yields the same user_key either way. MCP: - mcp_regrant_updates_read_only_in_place: re-granting the same MCP-server principal with a different `read_only` updates the persisted flag in place (read-only grant mints queries-only; re-granted full access mints unrestricted). These join the existing read-only/default tests in the accounts and mcp suites. No canister code changed; tests run against the current wasm and pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN
…d delegations Close the one coverage gap a completeness audit surfaced: the MCP happy-path test used the 3-argument `mcp_set_access` (omitting `read_only`) and verified the minted per-app delegation, but never asserted it was unrestricted. Add `assert_eq!(signed.delegation.permissions, None)` so the backwards-compatible omitted-arg default is verified on the MCP flow too (mirroring `accounts::should_default_to_unrestricted_account_delegation_when_unspecified`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN
# Motivation Bump the PocketIC test harness to v15 — the first version whose bundled replica implements the request-delegation `permissions` extension (dfinity/ic#10449). This is a prerequisite for adding integration tests that exercise **read-only (queries-only) delegation enforcement** end-to-end (follow-up to #4016). Landing the bump on its own keeps that dependency/infra change reviewable in isolation, separate from the feature work. # Changes - **`Cargo.toml`**: `pocket-ic` `"9.0.2"` → `"15"`. This transitively bumps `candid` 0.10.20→0.10.31, `ic-management-canister-types` 0.3.3→0.5.0, and `ic-transport-types` 0.40→0.45 across the workspace (i.e. it touches the II canister's own dependency tree). The canister and the test framework compile with **no code changes**. - **`demos/vc_issuer/Cargo.toml`**: same `pocket-ic` `"9.0.2"` → `"15"` bump (it pins pocket-ic directly *and* path-depends on `canister_tests`, so it must move in lockstep to avoid a two-version collision), plus the `Cargo.lock` refresh. - **`scripts/test-canisters.sh`**: `POCKET_IC_SERVER_VERSION` 9.0.3 → 15.0.0 (same release-asset name; download mechanism unchanged). - **`.github/workflows/canister-tests.yml`**: the two `dfinity/pocketic` action pins 9.0.3 → 15.0.0. - **Test adaptations for v15's cycle-funding behavior** (v15 funds freshly-created canisters with a default balance; no production code is affected): - `archive::should_expose_status`: assert the status exposes a funded balance (`> 0`) instead of a hard-coded `0`. - `should_deploy_archive_with_cycles`: assert the *delta* spent on archive creation (exactly `canister_creation_cycles_cost`) rather than an absolute, base-dependent post-deploy balance. - `icrc3_test_vectors`: also strip the environment-dependent top-level `root_key_hex` and `canister_sig_pk_hex` (the latter embeds the PocketIC-assigned canister id) before comparing. The deterministic `message_hex` / `signed_message_hex` bytes are byte-identical — no II-side change. The E2E Playwright launcher pin (`icp-cli-network-launcher` v12, see the in-file comment about dfinity/ic#10226) is intentionally **left untouched** — that's a separate pocket-ic build used only by the browser suite. # Tests - `cargo check` across `internet_identity` + `canister_tests` (incl. tests): clean, no porting needed. - Full `delegation` integration suite (**37 tests**) passes locally against `pocket-ic-server 15.0.0`. - The three adapted tests above pass locally against `pocket-ic-server 15.0.0`. - CI runs the complete canister-tests suite on v15. > Note for the follow-up enforcement tests: round-tripping an II canister-signature delegation through the replica's ingress validation may interact with dfinity/ic#10226 (strict `subnet_type` cert check). That will be verified when the enforcement-test harness (`make_live` + `ic-agent`) is added. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN --- _Generated by [Claude Code](https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Round-trip II-issued delegations through a live PocketIC endpoint (`make_live`) with real signed request envelopes, exercising the replica's actual ingress validation (which the impersonating PocketIC test transport skips). PocketIC v15's replica implements the queries-only `permissions` extension (dfinity/ic#10449), so the enforcement matrix is now testable: - a queries-only delegation authenticates query calls end-to-end and executes as the delegated principal; - a queries-only delegation is rejected AT INGRESS for update calls with the replica's enforcement error ("Update calls are not permitted: a delegation restricts the sender to query calls"); - an unrestricted delegation authenticates both query and update calls (also confirming dfinity/ic#10226's strict `subnet_type` cert check does not reject pocket-ic's own canister-sig delegations); - fail closed: stripping the `permissions` field from a read-only delegation's envelope is rejected as an invalid signature; the restriction cannot be dropped to escalate access. Covered for both mint paths: account delegations (serving /continue and /cli) and MCP per-app delegations. The envelopes are hand-rolled (CBOR + ed25519 + the interface spec's representation-independent hash) because no released ic-agent / ic-transport-types release (checked through 0.47.3) can carry the delegation `permissions` field yet. New dev-dependencies: ed25519-dalek (real session keys) and reqwest (submitting the envelopes).
…fault The backend defaults an omitted read_only to unrestricted (backwards compatible) since the API revert; the standing-delegation comment still claimed the opposite. Reframe it: the explicit [false] documents intent rather than compensating for a read-only default.
Address Copilot review on the delegation_ingress harness: - reqwest: disable default features (drops the native-tls/OpenSSL stack from the test build; the make_live endpoint is plain HTTP on localhost, so no TLS backend is needed at all). - ed25519-dalek: reference the existing workspace dependency instead of a standalone version string that could drift. - submit(): give the blocking client a 60s timeout so a stalled endpoint fails the suite deterministically instead of hanging it.
Address nmattia's review: - Frontend: new `AccessLevel = "read-only" | "full-access"` union threads through the authorize store, the ICRC-34 delegation handler, and the /continue, /cli, and /mcp flows; the candid `read_only : opt bool` arg is produced only at the actor-call boundary via `toReadOnlyArg`. `authorizationStore.authorize` now requires an explicit access level (no default), so 1-click flows (SSO, OpenID, post-upgrade) pass "full-access" explicitly. - `ReadOnlyToggle` generalized into `AccessLevelToggle`: binds the `accessLevel` directly and takes a `prompt` prop deciding which opt-in the checkbox offers, so ticking the box always *changes* the flow's default. /continue and /mcp keep the "Read-only mode" checkbox; /cli now shows an opt-in "Full access" checkbox instead of a pre-checked read-only one (default unchanged: read-only). - Backend: new `DelegationAccess` enum (`Unrestricted` / `ReadOnly`) replaces `read_only: bool` through `account_management` and `mcp`; the candid boundary maps via `DelegationAccess::from_read_only_arg` (omitted still means unrestricted). The legacy `prepare/get_delegation` endpoints now read `DelegationAccess::Unrestricted` instead of a bare `false`. - `add_delegation_signature_with_permissions` merged into `add_delegation_signature`, which now takes the `permissions` value as an explicit `Option` argument instead of duplicating the helper. - Test dev-dependency `reqwest` bumped 0.12 -> 0.13.
…ned signable) Frontend (previously untested): - accessLevel unit tests: the candid-arg mapping and the toggle's prompt/level double inversion, exhaustively — a silently flipped default would grant the wrong privilege by default. The toggle mapping moves into pure helpers (`isToggleChecked` / `toggledAccessLevel`) that `AccessLevelToggle` now uses. - authorization.store test: `authorize()` records the granted access level. - cliAuthorize/mcpAuthorize tests with a recording fake actor: /cli passes the chosen level to both prepare and get; /mcp persists the chosen level on the grant while the standing delegation always stays full-access (it must remain update-capable to call the prepare endpoint). - e2e: pin the per-flow checkbox defaults in the existing happy paths — /continue "Read-only mode" unticked (full access default), /cli "Full access" unticked (read-only default), /mcp "Read-only mode" ticked (read-only default). Backend: - mcp: a read-only grant survives a canister upgrade — the restriction still applies to per-app delegations minted afterwards (losing the flag would fail open); StorableMcpAccess gains round-trip and pinned wire-format unit tests (field renumbering would decode existing grants wrongly). - delegation: pin the queries-only delegation signable (reference vector for agent implementers; drift would silently invalidate every read-only delegation) and assert the permissions-less message stays byte-identical to the upstream helper's (legacy signatures keep verifying).
… the wire
Extends the typed-access-level work to the candid boundary, per review:
the three endpoints that took a blind `read_only : opt bool`
(prepare_account_delegation, get_account_delegation, mcp_set_access) now
take `access : opt AccessLevel` where
type AccessLevel = variant { full_access; read_only };
Backwards-compatible: the argument stays optional and an omitted value
still means unrestricted, so pre-feature callers of the shorter argument
form keep working (matching the interface spec's default for an absent
`permissions` field). First-party callers always pass an explicit value.
- interface crate: new `AccessLevel` candid enum.
- delegation.rs: `From<AccessLevel>` / `From<Option<AccessLevel>>` for the
internal `DelegationAccess` (replacing `from_read_only_arg`); the domain
enum and its `permissions()` are unchanged, so account_management / mcp
are untouched below the boundary.
- main.rs: the three endpoints map the wire arg via `DelegationAccess::from`.
- regenerated bindings: `AccessLevel` in internet_identity_idl.js /
internet_identity_types.d.ts (hand-generated; the pinned didc release is
currently unreachable here — a `npm run generate` pass will confirm).
- frontend: `toReadOnlyArg` -> `toAccessLevelArg` emitting the variant;
updated the /continue, /cli, /mcp call sites and the unit tests.
- test helpers keep their `Option<bool>` ergonomics and convert to
`Option<AccessLevel>` at the candid call, so integration tests are
unchanged.
…ing the protocol
Per review: rather than an invented `access : opt AccessLevel { full_access;
read_only }`, mirror the ICP protocol's request-delegation `permissions`
vocabulary. The three endpoints (prepare_account_delegation,
get_account_delegation, mcp_set_access) now take
permissions : opt Permissions where
type Permissions = variant { queries; all };
`queries` yields a queries-only delegation (`permissions = "queries"` on the
issued delegation); `all` is unrestricted. Still optional and omitted still
means unrestricted, so the backwards-compat behavior is unchanged.
- interface crate: `AccessLevel` -> `Permissions { Queries, All }` (candid
`queries` / `all`).
- delegation.rs: `From<Permissions>` / `From<Option<Permissions>>` for the
internal `DelegationAccess` (Queries -> ReadOnly, All/omitted -> Unrestricted).
- regenerated bindings (`Permissions` variant in idl.js / types.d.ts).
- frontend: `toAccessLevelArg` -> `toPermissionsArg` emitting `{ queries }` /
`{ all }`; call sites and unit tests updated. The user-facing FE `AccessLevel`
union ("read-only" | "full-access") is unchanged — it maps to the protocol
vocabulary only at the actor-call boundary.
- test helpers keep their `Option<bool>` ergonomics (`permissions_arg`).
The returned `Delegation.permissions` stays `opt text` ("queries") — that is
the protocol wire value the canister signature covers.
Reconciles the MCP session-registration refactor (PR #4086) with the read-only-delegations feature (#4016), which independently restructured the same MCP storage and threaded a queries-only `permissions` option through the account-delegation path. Resolution: - Fold #4016's `read_only` onto the session grant: StorableMcpGrant now carries { anchor_number, expires_at_ns, read_only }; the mcp_access storable and its map are dropped. Read-only is a property of the whole session, chosen once at mcp_register and applied to every per-app delegation it mints. - mcp_register gains a `permissions : opt Permissions` argument (derived to a bool on the grant); mcp_prepare_delegation / mcp_get_delegation thread it into DelegationAccess so minted delegations carry permissions="queries". - account_management::prepare_account_delegation keeps both the refactor's max_expiration cap and #4016's access argument. - Grant map moves to memory index 29; indices 25/27/28 abandoned per #4016's convention (preview feature, no migration). - Frontend: mcpAuthorize passes the consent screen's access level to mcp_register; McpAuthorizeView's read-only toggle is preserved. - Ported #4016's delegation_ingress.rs and mcp/utils.test.ts to the registration flow; added a read-only MCP integration test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbjCCqTjsMn57fczTNGaKf
…#4087) # Motivation Read-only mode (#4016) is broken end-to-end with today's agent stack. A read-only ("queries-only") delegation is a canister signature over a representation-independent hash that **includes** `permissions = "queries"`. No released agent (`ic-agent` / `ic-transport-types`, checked through 0.47.3; `@icp-sdk/core`) has a `permissions` field on its `Delegation` wire type, and there is no `#[serde(flatten)]` catch-all — so the field is **dropped** when the delegation is re-serialized into the request envelope. The replica then recomputes the hash over `{pubkey, expiration, targets}` (no `permissions`), which the II canister never signed, and canister-signature verification fails: ``` invalid delegation chain: A canister signature in the delegation chain could not be verified: sig not found in the signature tree ``` This surfaced as **MCP connections in read-only mode failing**: the read-only grant makes the MCP server mint queries-only per-app delegations, which its agent can't use. The same fails-closed applies to read-only `/continue` and `/cli` sessions. Rather than revert the feature, gate the UI off until the agent stack preserves the delegation `permissions` field (agent-side field preservation is enough to un-break verification; the field doesn't need to be semantically interpreted by the agent — enforcement stays the replica's job). # Changes - New `READ_ONLY_MODE` feature flag in `src/frontend/src/lib/state/featureFlags.ts`, **off by default everywhere** (including `id.ai` / `beta.id.ai` — no domain override). Documented with the reason and the flip condition. - `AccessLevelToggle` is hidden in all three flows (`ContinueView`, `CliAuthorizeView`, `McpAuthorizeView`) when the flag is off. - Each flow computes an `effectiveAccessLevel` that is forced to `full-access` while the flag is off. This is the load-bearing part: `/cli` and `/mcp` default their (now-hidden) toggle to read-only, so simply hiding it would otherwise leave them minting the unusable queries-only delegations. With the flag off, every flow sends full access. - e2e assertions updated to verify the toggle is absent by default (`toHaveCount(0)`). The backend capability, the candid `Permissions` type, and all delegation tests from #4016 are unchanged and remain in place — re-enabling read-only is a one-line flip of the flag default once agents preserve the field. # Tests - `tsc --noEmit` / `svelte-check`: 0 errors. - `eslint`, `prettier`: clean. - Frontend unit tests pass (18), including the `toPermissionsArg` mapping and the `/cli` + `/mcp` authorize-helper argument tests (unaffected — they exercise the mapping directly, below the flag). - e2e specs (`cli`, `mcp`, `authorize`) updated to assert the access-level toggle is not rendered while the flag is off. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN --- _Generated by [Claude Code](https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Motivation
Let a user grant read-only (query-only) access when authorizing a relying party — a dapp (
/continue), the CLI (/cli), or an MCP server (/mcp). A read-only authorization can read on the user's behalf but cannot change state: it relies on the optionalpermissions = "queries"field on request delegations (interface-spec extension drafted in dfinity/ic#10449), which the IC enforces at the protocol level by rejecting update calls (/callrequests) authenticated through such a delegation. Enforcement is not up to any app or canister.Changes
Delegation signing & types (backend)
Delegationgains an optionalpermissionsfield (opt textin candid;Optionin the Rust interface)."queries"restricts the delegation to query calls; absent means unrestricted ("all"), per the spec.add_delegation_signaturefolds an optionalpermissionsvalue into the representation-independent hash, so the value is covered by the canister signature (byte-identical to the upstream helper whenpermissionsis absent).type Permissions = variant { queries; all }(mirroring the ICP protocol's request-delegation permission values) is the request-side knob.prepare_account_delegation/get_account_delegationgain a trailingpermissions : opt Permissions;queriesyields a delegation carryingpermissions = "queries". An omitted arg (orall) means unrestricted, preserving the original argument-count behavior (see "Backwards compatibility"). Internally the wire enum maps to aDelegationAccessdomain enum at the boundary (DelegationAccess::from), so no blind booleans cross a function signature.internet_identity_idl.js,internet_identity_types.d.ts).Reusable UI + the three sign-in flows (frontend)
AccessLevelToggle.svelte(checkbox + tooltip, bindableaccessLevel,promptprop selecting which level the checkbox opts into), used in all three flows. Per-flow defaults:/continue: full access by default ("Read-only mode" opt-in)./cli: read-only by default ("Full access" opt-in)./mcp: read-only by default ("Full access" opt-in).AccessLevel = "read-only" | "full-access"union threads throughauthorizationStoreand the flows; the candidpermissions : opt Permissionsargument ({ queries }/{ all }) is produced only at the actor-call boundary (toPermissionsArg). All first-party callers send an explicit value.MCP read-only (backend)
mcp_set_accessgainspermissions : opt Permissions, persisted with the grant (as a bool). The MCP server's standing delegation stays full-access (required so it can still call the update endpointmcp_prepare_account_delegation); the read-only restriction is applied to the per-app delegations the server mints, by reading the persisted flag.Principal → AnchorNumberreverse index and the read-only flag collapse into onePrincipal → StorableMcpAccess { anchor_number, read_only }map.caller_access()returns both in a single lookup, so the MCP server still never passes an anchor number. On a fresh memory index — MCP is preview-only, so the old regions are abandoned and any preview grants are recreated on the next connect (no migration).Backwards compatibility: the
permissionsargument is optional and an omitted value (orall) yields an unrestricted delegation, so existing callers of the pre-feature argument form keep getting update-capable delegations (no silent downgrade), matching the interface spec's default for an absentpermissionsfield. Read-only is opt-in via the argument; the per-flow UI defaults above are independent of this (always sent explicitly).Note on relying parties: returning a restricted delegation to a dapp requires the agent library to round-trip the
permissionsfield, and no released agent stack does yet (checkedic-agent/ic-transport-typesthrough 0.47.3, and@icp-sdk/core). Until then a restricted delegation fails closed on the dapp side — its signature doesn't verify without the field, which the ingress tests below prove at the replica.Tests
delegation_ingress::, new): real signed request envelopes submitted to a live PocketIC endpoint (make_live), exercising the replica's actual request authentication — which the impersonating PocketIC test transport skips. The envelopes are hand-rolled (CBOR + ed25519 + representation-independent hash) since no released agent can carrypermissions. Asserted for both mint paths (account delegations, serving/continueand/cli; MCP per-app delegations):subnet_typecert check doesn't affect pocket-ic v15's canister-sig delegations);permissionsfield stripped from the envelope is rejected as an invalid signature — omitting the restriction cannot escalate access.mcp_read_only_grant_persists_across_upgrade(a read-only grant still mints queries-only after a canister upgrade);StorableMcpAccessround-trip + pinned wire-format; a pinned signable-hash vector for thepermissions = "queries"delegation, plus a check that the absent-permissions message is byte-identical to the upstream helper.permissions = "queries"and the signature binds to it (an unrestricted lookup yieldsNoSuchDelegation); an omitted arg yields unrestricted; read-only / full-access MCP grants mint queries-only / unrestricted per-app delegations respectively.verify_delegationfolds bothpermissionsandtargetsinto the representation-independent hash.toPermissionsArgmapping (read-only → { queries },full-access → { all }); theAccessLevelToggleprompt×checked mapping (all combinations);authorizationStorerecords the access level; the/cliand/mcpauthorize helpers send the correctpermissionsvariant and keep the MCP standing delegation full-access. Plus e2e assertions that each flow's checkbox starts in its documented default state.accounts::,mcp::,session_delegation::, anddelegation_ingress::integration suites pass against the built wasm (onpocket-ic-server 15.0.0).cargo clippy --all-targets,tsc --noEmit/svelte-check(0 errors),eslint,prettier, and the candid interface check.🤖 Generated with Claude Code
https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN