Skip to content

feat(be,fe): read-only mode via delegations with queries-only permissions - #4016

Merged
nmattia merged 21 commits into
mainfrom
claude/wonderful-sagan-boufou
Jul 3, 2026
Merged

nmattia merged 21 commits into
mainfrom
claude/wonderful-sagan-boufou

Conversation

@aterga

@aterga aterga commented Jun 12, 2026 •

Copy link
Copy Markdown
Collaborator

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 optional permissions = "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 (/call requests) authenticated through such a delegation. Enforcement is not up to any app or canister.

Changes

Delegation signing & types (backend)

  • Delegation gains an optional permissions field (opt text in candid; Option in the Rust interface). "queries" restricts the delegation to query calls; absent means unrestricted ("all"), per the spec.
  • add_delegation_signature folds an optional permissions value into the representation-independent hash, so the value is covered by the canister signature (byte-identical to the upstream helper when permissions is absent).
  • A new candid type Permissions = variant { queries; all } (mirroring the ICP protocol's request-delegation permission values) is the request-side knob. prepare_account_delegation / get_account_delegation gain a trailing permissions : opt Permissions; queries yields a delegation carrying permissions = "queries". An omitted arg (or all) means unrestricted, preserving the original argument-count behavior (see "Backwards compatibility"). Internally the wire enum maps to a DelegationAccess domain enum at the boundary (DelegationAccess::from), so no blind booleans cross a function signature.
  • Regenerated candid bindings (internet_identity_idl.js, internet_identity_types.d.ts).

Reusable UI + the three sign-in flows (frontend)

  • New AccessLevelToggle.svelte (checkbox + tooltip, bindable accessLevel, prompt prop 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).
  • A typed AccessLevel = "read-only" | "full-access" union threads through authorizationStore and the flows; the candid permissions : opt Permissions argument ({ 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_access gains permissions : 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 endpoint mcp_prepare_account_delegation); the read-only restriction is applied to the per-app delegations the server mints, by reading the persisted flag.
  • Storage unified: the Principal → AnchorNumber reverse index and the read-only flag collapse into one Principal → 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 permissions argument is optional and an omitted value (or all) 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 absent permissions field. 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 permissions field, and no released agent stack does yet (checked ic-agent / ic-transport-types through 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

  • End-to-end ingress enforcement (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 carry permissions. Asserted for both mint paths (account delegations, serving /continue and /cli; MCP per-app delegations):
    • a queries-only delegation authenticates query calls and executes as the delegated principal;
    • a queries-only delegation is rejected at ingress for update calls with the protocol error ("Update calls are not permitted: a delegation restricts the sender to query calls") — before any canister code runs;
    • an unrestricted delegation authenticates both query and update calls (also confirming fix(state-machine-tests): include subnet type in delegation certificates ic#10226's subnet_type cert check doesn't affect pocket-ic v15's canister-sig delegations);
    • fail closed: resubmitting a queries-only delegation with the permissions field stripped from the envelope is rejected as an invalid signature — omitting the restriction cannot escalate access.
  • Persistence & format (backend units): mcp_read_only_grant_persists_across_upgrade (a read-only grant still mints queries-only after a canister upgrade); StorableMcpAccess round-trip + pinned wire-format; a pinned signable-hash vector for the permissions = "queries" delegation, plus a check that the absent-permissions message is byte-identical to the upstream helper.
  • Delegation mint semantics (impersonating transport): read-only account delegation carries permissions = "queries" and the signature binds to it (an unrestricted lookup yields NoSuchDelegation); an omitted arg yields unrestricted; read-only / full-access MCP grants mint queries-only / unrestricted per-app delegations respectively. verify_delegation folds both permissions and targets into the representation-independent hash.
  • Frontend units: toPermissionsArg mapping (read-only → { queries }, full-access → { all }); the AccessLevelToggle prompt×checked mapping (all combinations); authorizationStore records the access level; the /cli and /mcp authorize helpers send the correct permissions variant and keep the MCP standing delegation full-access. Plus e2e assertions that each flow's checkbox starts in its documented default state.
  • The accounts::, mcp::, session_delegation::, and delegation_ingress:: integration suites pass against the built wasm (on pocket-ic-server 15.0.0).
  • Static checks clean: cargo clippy --all-targets, tsc --noEmit / svelte-check (0 errors), eslint, prettier, and the candid interface check.

Bindings note: the pinned didc release used by npm run generate is currently unreachable from this environment, so the regenerated internet_identity_idl.js / internet_identity_types.d.ts were produced by hand and validated (tsc + parsing the idl); a npm run generate pass on main will confirm they match canonically.

🤖 Generated with Claude Code

https://claude.ai/code/session_018gHRjyFYqumMxAMwbPRLuN

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.
Comment thread src/internet_identity/src/main.rs Resolved
Comment thread src/internet_identity/src/main.rs Resolved
claude added 3 commits June 23, 2026 21:57
…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
@aterga aterga changed the title feat: read-only mode via delegations with queries-only permissions feat(be,fe): read-only mode via delegations with queries-only permissions Jun 30, 2026
claude added 2 commits June 30, 2026 11:40
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-level read_only flag 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.

Comment thread src/canister_tests/src/framework.rs Resolved
Comment thread src/internet_identity/src/main.rs Resolved
…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
@unixway

unixway commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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 prepare_account_delegation / get_account_delegation encode the previous 5-argument form. With the new trailing opt bool, those calls still decode successfully, but read_only = null now defaults to true. That silently changes the delegation from the previous unrestricted behavior to query-only. This is a breaking change: existing canister integrations may keep "working" at the Candid layer while failing later when they try to use the delegation for update calls.

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 permissions as part of the delegation while old verifiers reconstruct the previous message, those canisters will reject valid delegations. For immutable or hard-to-upgrade canisters, this is not recoverable after an II mainnet upgrade.

The backward-compatible behavior should be:

  • omitted read_only / null preserves the old unrestricted delegation behavior;
  • read_only = opt true explicitly opts into query-only delegations;
  • the existing signed delegation verification semantics remain valid for existing callers.

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.

claude added 2 commits June 30, 2026 14:36
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
@aterga
aterga marked this pull request as ready for review June 30, 2026 15:08
@aterga
aterga requested a review from a team as a code owner June 30, 2026 15:08
@zeropath-ai

zeropath-ai Bot commented Jun 30, 2026 •

Copy link
Copy Markdown

✅ No security or compliance issues detected. Reviewed everything up to 2290448.

Security Overview
Detected Code Changes

The diff is too large to display a summary of code changes.

@aterga
aterga requested a review from MRmarioruci June 30, 2026 15:14
@aterga

aterga commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for raising these concerns, @unixway,

Old callers of prepare_account_delegation / get_account_delegation encode the previous 5-argument form. With the new trailing opt bool, those calls still decode successfully, but read_only = null now defaults to true. That silently changes the delegation from the previous unrestricted behavior to query-only. This is a breaking change: existing canister integrations may keep "working" at the Candid layer while failing later when they try to use the delegation for update calls.

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).

Some deployed canisters verify II delegations inside wasm

Could you clarify what you mean by "inside wasm," please?

Delegations are normally verified by the ICP protocol.

For context, the new (optional) permissions extension is going to be supported by all the ICP subnets ETA = next Monday.

claude added 3 commits June 30, 2026 15:42
…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
nmattia pushed a commit that referenced this pull request Jul 2, 2026
# 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).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 28 changed files in this pull request and generated 1 comment.

…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 32 changed files in this pull request and generated 3 comments.

Comment thread src/internet_identity/Cargo.toml Resolved
Comment thread src/internet_identity/Cargo.toml Resolved
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.
@aterga
aterga requested a review from nmattia July 2, 2026 13:43
Comment thread src/internet_identity/src/delegation.rs Resolved
Comment thread src/internet_identity/Cargo.toml Resolved
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.
@aterga
aterga requested a review from nmattia July 2, 2026 16:29

@nmattia nmattia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@aterga
aterga enabled auto-merge (squash) July 3, 2026 10:47
claude and others added 3 commits July 3, 2026 11:28
…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.
@nmattia
nmattia disabled auto-merge July 3, 2026 14:09
@nmattia
nmattia merged commit fc8ef26 into main Jul 3, 2026
46 checks passed
@nmattia
nmattia deleted the claude/wonderful-sagan-boufou branch July 3, 2026 14:09
aterga pushed a commit that referenced this pull request Jul 3, 2026
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
basvandijk pushed a commit that referenced this pull request Jul 3, 2026
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants