From 879a5ea923d38a95315f29e63cff43cd11b0bc9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 02:39:00 +0000 Subject: [PATCH 01/31] =?UTF-8?q?feat(inspector):=20Phase=201=20Capsule=20?= =?UTF-8?q?Inspector=20=E2=80=94=20read-only=20object-centered=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Borrow Self's mirrors/Morphic UX, re-secured with ElastOS's zero-ambient- authority model. Adds a read-only, permissioned introspection surface that makes the existing security guarantees visible: one screen per capsule showing identity/DID, manifest, affordances, required vs. granted (and denied) capabilities, storage namespaces, Carrier endpoints, provenance, audit log, and running processes. This Phase-1 starter is fully additive — no changes to the trusted core: - docs/CAPSULE_INSPECTOR.md: why/how/what, security invariants, phasing, and the read-only elastos://inspect/* wire contract (backed by existing CapsuleManager + CapabilityManager + AuditLog; no new state). - capsules/capsule-inspector: WASM app capsule (UI) matching the browser capsule conventions. Requires only elastos://inspect/read. Renders live data via the runtime bridge when present; falls back to sample data otherwise so the surface renders standalone for review. Next (not in this commit): the runtime-side read-only handler backing elastos://inspect/*, then Phase 2 (invoke/revoke from the UI). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- capsules/capsule-inspector/Cargo.lock | 121 +++++++ capsules/capsule-inspector/Cargo.toml | 19 + capsules/capsule-inspector/capsule.json | 17 + .../capsule-inspector/inspector/index.html | 34 ++ .../capsule-inspector/inspector/inspector.js | 328 ++++++++++++++++++ .../capsule-inspector/inspector/style.css | 135 +++++++ capsules/capsule-inspector/wasm/main.rs | 20 ++ docs/CAPSULE_INSPECTOR.md | 159 +++++++++ 8 files changed, 833 insertions(+) create mode 100644 capsules/capsule-inspector/Cargo.lock create mode 100644 capsules/capsule-inspector/Cargo.toml create mode 100644 capsules/capsule-inspector/capsule.json create mode 100644 capsules/capsule-inspector/inspector/index.html create mode 100644 capsules/capsule-inspector/inspector/inspector.js create mode 100644 capsules/capsule-inspector/inspector/style.css create mode 100644 capsules/capsule-inspector/wasm/main.rs create mode 100644 docs/CAPSULE_INSPECTOR.md diff --git a/capsules/capsule-inspector/Cargo.lock b/capsules/capsule-inspector/Cargo.lock new file mode 100644 index 00000000..e73c548c --- /dev/null +++ b/capsules/capsule-inspector/Cargo.lock @@ -0,0 +1,121 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "capsule-inspector" +version = "0.1.0" +dependencies = [ + "elastos-guest", +] + +[[package]] +name = "elastos-guest" +version = "0.1.0" +dependencies = [ + "libc", + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/capsules/capsule-inspector/Cargo.toml b/capsules/capsule-inspector/Cargo.toml new file mode 100644 index 00000000..7b932641 --- /dev/null +++ b/capsules/capsule-inspector/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "capsule-inspector" +version = "0.1.0" +edition = "2021" +description = "ElastOS Capsule Inspector capsule" +license = "MIT" + +[[bin]] +name = "capsule-inspector" +path = "wasm/main.rs" + +[dependencies] +elastos-guest = { path = "../../elastos/crates/elastos-guest" } + +[profile.release] +opt-level = "s" +lto = true + +[workspace] diff --git a/capsules/capsule-inspector/capsule.json b/capsules/capsule-inspector/capsule.json new file mode 100644 index 00000000..582f9561 --- /dev/null +++ b/capsules/capsule-inspector/capsule.json @@ -0,0 +1,17 @@ +{ + "schema": "elastos.capsule/v1", + "name": "capsule-inspector", + "version": "0.1.0", + "description": "Object-centered, read-only inspector for live capsules: identity, affordances, capabilities, provenance, and audit in one view", + "author": "elastos", + "role": "app", + "type": "wasm", + "entrypoint": "capsule-inspector.wasm", + "capabilities": [ + "elastos://inspect/read" + ], + "resources": { + "memory_mb": 32, + "gpu": false + } +} diff --git a/capsules/capsule-inspector/inspector/index.html b/capsules/capsule-inspector/inspector/index.html new file mode 100644 index 00000000..4e75d95c --- /dev/null +++ b/capsules/capsule-inspector/inspector/index.html @@ -0,0 +1,34 @@ + + + + + + Capsule Inspector + + + +
+
+ +

Capsule Inspector

+
+
+ sample data + read-only · permissioned mirror · audited +
+
+ +
+ + + + +
+
+ + + + diff --git a/capsules/capsule-inspector/inspector/inspector.js b/capsules/capsule-inspector/inspector/inspector.js new file mode 100644 index 00000000..4d86720a --- /dev/null +++ b/capsules/capsule-inspector/inspector/inspector.js @@ -0,0 +1,328 @@ +// Capsule Inspector (Phase 1) — read-only object-centered view. +// +// Data flow: the UI reads `elastos://inspect/*` (capability +// `elastos://inspect/read`) through the runtime bridge when present. When the +// bridge is absent (e.g. opened standalone in a plain browser for design +// review), it falls back to SAMPLE_DATA so the surface always renders. No +// write/sign/launch path exists anywhere in this capsule. + +"use strict"; + +// --------------------------------------------------------------------------- +// Live data source: elastos://inspect/* via the runtime bridge. +// Returns null when no bridge is available so the UI can fall back to samples. +// --------------------------------------------------------------------------- +async function inspectInvoke(operation, body) { + const bridge = window.elastos && window.elastos.inspect; + if (!bridge || typeof bridge.invoke !== "function") return null; + try { + // bridge.invoke(uri, operation, body) -> parsed JSON + return await bridge.invoke("elastos://inspect", operation, body || {}); + } catch (err) { + console.warn("inspect bridge error:", err); + return null; + } +} + +async function loadCapsuleList() { + const live = await inspectInvoke("capsules", {}); + if (live && Array.isArray(live.capsules)) { + setSourceBadge(true); + return live.capsules; + } + setSourceBadge(false); + return SAMPLE_DATA.map((c) => ({ + id: c.id, name: c.name, role: c.role, type: c.type, state: c.state, + })); +} + +async function loadCapsuleDetail(id) { + const live = await inspectInvoke("capsule", { id }); + if (live && live.id) return live; + return SAMPLE_DATA.find((c) => c.id === id) || null; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- +function el(tag, attrs, children) { + const node = document.createElement(tag); + if (attrs) { + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") node.className = v; + else if (k === "text") node.textContent = v; + else node.setAttribute(k, v); + } + } + for (const child of children || []) { + node.appendChild(typeof child === "string" ? document.createTextNode(child) : child); + } + return node; +} + +function setSourceBadge(isLive) { + const badge = document.getElementById("source-badge"); + badge.textContent = isLive ? "live" : "sample data"; + badge.className = "badge " + (isLive ? "badge-live" : "badge-sample"); +} + +function fmtTime(ts) { + if (!ts) return "—"; + return new Date(ts * 1000).toISOString().replace("T", " ").slice(0, 19); +} + +function fmtUptime(s) { + if (!s && s !== 0) return "—"; + const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} + +function card(title, body) { + return el("div", { class: "card" }, [el("h3", { text: title }), body]); +} + +function kv(pairs) { + const dl = el("dl", { class: "kv" }); + for (const [k, v] of pairs) { + dl.appendChild(el("dt", { text: k })); + dl.appendChild(el("dd", { class: "mono" }, [String(v == null ? "—" : v)])); + } + return dl; +} + +function renderList(capsules, activeId, onSelect) { + const list = document.getElementById("capsule-list"); + list.innerHTML = ""; + for (const c of capsules) { + const item = el("li", { + class: "capsule-item" + (c.id === activeId ? " active" : ""), + }, [ + el("div", {}, [ + el("div", { class: "ci-name", text: c.name }), + el("div", { class: "ci-role", text: `${c.role} · ${c.type}` }), + ]), + el("span", { class: "state-pill state-" + (c.state || "stopped") }), + ]); + item.addEventListener("click", () => onSelect(c.id)); + list.appendChild(item); + } +} + +function renderAffordances(affordances) { + const wrap = el("div"); + for (const a of affordances || []) { + wrap.appendChild(el("div", { class: "row" }, [ + el("span", { class: "mono grow", text: `${a.interface} · ${a.id}` }), + el("span", { class: "tag tag-" + a.risk, text: a.risk }), + el("span", { class: "tag tag-" + a.approval, text: "approval: " + a.approval }), + el("span", { class: "tag", text: "audit: " + a.audit }), + ])); + } + if (!wrap.childNodes.length) wrap.appendChild(el("div", { class: "note", text: "No declared affordances." })); + return wrap; +} + +function renderRequired(caps) { + const wrap = el("div"); + for (const r of caps || []) { + wrap.appendChild(el("div", { class: "row" }, [el("span", { class: "mono", text: r })])); + } + return wrap; +} + +function renderGranted(grants) { + const wrap = el("div"); + for (const g of grants || []) { + wrap.appendChild(el("div", { class: "row" }, [ + el("span", { class: "mono grow", text: `${g.resource} · ${g.action}` }), + g.granted + ? el("span", { class: "pill-ok", text: "✓ granted" }) + : el("span", { class: "pill-deny", text: "✗ denied" }), + el("span", { class: "tag mono", text: g.token_id || "" }), + ])); + } + return wrap; +} + +function renderAudit(audit) { + const wrap = el("div"); + const counts = (audit && audit.counts) || {}; + wrap.appendChild(el("div", { class: "note", text: + `today: ${counts.total_today ?? 0} calls · ${counts.user_approved ?? 0} user-approved · ${counts.denied ?? 0} denied` })); + for (const e of (audit && audit.recent) || []) { + wrap.appendChild(el("div", { class: "audit-line" }, [ + el("span", { class: "ts", text: fmtTime(e.ts) }), + el("span", { class: "mono", text: e.event }), + el("span", { class: "mono", text: e.detail }), + el("span", { class: e.success ? "pill-ok" : "pill-deny", text: e.success ? "ok" : "blocked" }), + ])); + } + return wrap; +} + +function renderProcesses(procs) { + const wrap = el("div"); + for (const p of procs || []) { + wrap.appendChild(el("div", { class: "row" }, [ + el("span", { class: "mono grow", text: `${p.kind} ${p.instance}` }), + el("span", { class: "tag", text: `${p.memory_mb} MB` }), + el("span", { class: "tag", text: "up " + fmtUptime(p.uptime_s) }), + ])); + } + if (!wrap.childNodes.length) wrap.appendChild(el("div", { class: "note", text: "No running instances." })); + return wrap; +} + +function renderDetail(c) { + const detail = document.getElementById("detail"); + detail.innerHTML = ""; + if (!c) { + detail.appendChild(el("div", { class: "detail-empty", text: "Select a capsule to inspect." })); + return; + } + + detail.appendChild(el("div", { class: "detail-head" }, [ + el("h2", { text: c.name }), + el("span", { class: "ver", text: "v" + (c.version || "?") }), + el("span", { class: "tag", text: c.role }), + el("span", { class: "tag", text: c.type }), + ])); + detail.appendChild(el("p", { class: "detail-sub", text: c.description || "" })); + + const id = c.identity || {}; + const prov = c.provenance || {}; + const carrier = c.carrier || {}; + + // 1 identity + 2 manifest + 8 provenance + const topGrid = el("div", { class: "grid2" }, [ + card("Identity / DID", kv([ + ["DID", id.did], ["CID", id.cid], ["trust level", id.trust_level], + ["signed", id.signature_present ? "yes" : "no"], ["signed by", id.signed_by], + ])), + card("Manifest", kv([ + ["schema", c.manifest && c.manifest.schema], ["role", c.role], + ["type", c.type], ["entrypoint", c.manifest && c.manifest.entrypoint], + ["author", c.author], + ])), + ]); + detail.appendChild(topGrid); + + // 3 affordances + detail.appendChild(card("Affordances (slots / messages)", renderAffordances(c.affordances))); + + // 4 + 5 capabilities + detail.appendChild(el("div", { class: "grid2" }, [ + card("Required capabilities", renderRequired(c.required_capabilities)), + card("Granted capabilities", renderGranted(c.granted_capabilities)), + ])); + + // 6 storage + 7 carrier + 8 provenance + detail.appendChild(el("div", { class: "grid2" }, [ + card("Storage namespaces", kv((c.storage_namespaces || []).map((s, i) => [`ns ${i + 1}`, s]))), + card("Carrier endpoints", kv([ + ["enabled", carrier.enabled ? "yes" : "no"], + ["peers", carrier.peers], ["endpoint", (carrier.endpoints || [])[0]], + ])), + ])); + detail.appendChild(card("Provenance", kv([ + ["signed by", prov.signed_by], ["version", prov.version], + ["installed", fmtTime(prov.installed_at)], ["CID", prov.cid], + ]))); + + // 9 audit + processes + detail.appendChild(card("Audit log", renderAudit(c.audit))); + detail.appendChild(card("Running processes", renderProcesses(c.processes))); +} + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- +let state = { capsules: [], activeId: null }; + +async function selectCapsule(id) { + state.activeId = id; + renderList(state.capsules, id, selectCapsule); + renderDetail(await loadCapsuleDetail(id)); +} + +async function boot() { + state.capsules = await loadCapsuleList(); + renderList(state.capsules, null, selectCapsule); + if (state.capsules.length) selectCapsule(state.capsules[0].id); + else renderDetail(null); +} + +document.addEventListener("DOMContentLoaded", boot); + +// --------------------------------------------------------------------------- +// Sample data — mirrors the elastos://inspect/* contract (docs/CAPSULE_INSPECTOR.md). +// Used only when no runtime bridge is present. +// --------------------------------------------------------------------------- +const SAMPLE_DATA = [ + { + id: "cap_chat_room_01", name: "chat-room", version: "0.1.0", role: "shell", type: "wasm", state: "running", + description: "Peer-to-peer chat room over Carrier gossip.", author: "elastos", + identity: { did: "did:key:z6MkChatRoomDeviceKeyExample", cid: "bafychatroom...", trust_level: "verified", signature_present: true, signed_by: "gateway-did" }, + manifest: { schema: "elastos.capsule/v1", entrypoint: "chat.wasm" }, + affordances: [ + { interface: "elastos.chat/v1", id: "send", risk: "write", approval: "user", audit: "event", description: "Send a message" }, + { interface: "elastos.chat/v1", id: "history", risk: "read", approval: "none", audit: "summary", description: "Read history" }, + ], + required_capabilities: ["elastos://carrier/*", "elastos://storage/chat"], + granted_capabilities: [ + { resource: "elastos://carrier/*", action: "message", granted: true, token_id: "tok_c1a2", expiry: 1781990400 }, + { resource: "elastos://storage/chat", action: "write", granted: true, token_id: "tok_d3f4" }, + { resource: "elastos://did/*", action: "read", granted: false }, + ], + storage_namespaces: ["localhost://WebSpaces/chat-room/"], + carrier: { enabled: true, endpoints: ["gossip://iroh/abcd…"], peers: 1 }, + provenance: { signed_by: "gateway-did", version: "0.1.0", installed_at: 1781817600, cid: "bafychatroom..." }, + audit: { counts: { total_today: 14, user_approved: 2, denied: 1 }, recent: [ + { ts: 1781990100, event: "capability.use", detail: "carrier/* message", success: true }, + { ts: 1781990060, event: "capability.use", detail: "storage/chat write", success: true }, + { ts: 1781990050, event: "capability.denied", detail: "did/* read", success: false }, + ] }, + processes: [{ kind: "wasm", instance: "#4", memory_mb: 12, uptime_s: 10800 }], + }, + { + id: "cap_wallet_provider_01", name: "wallet-provider", version: "0.1.0", role: "provider", type: "microvm", state: "running", + description: "Wallet proof bindings, approvals, and audit. Holds keys; exposes only typed affordances.", author: "elastos", + identity: { did: "did:key:z6MkWalletProviderExample", cid: "bafywallet...", trust_level: "system", signature_present: true, signed_by: "gateway-did" }, + manifest: { schema: "elastos.capsule/v1", entrypoint: "rootfs.ext4" }, + affordances: [ + { interface: "elastos.wallet/v1", id: "accounts", risk: "read", approval: "none", audit: "summary", description: "List accounts" }, + { interface: "elastos.wallet/v1", id: "sign", risk: "sign", approval: "user", audit: "full", description: "Sign a transaction" }, + { interface: "elastos.wallet/v1", id: "delete", risk: "privileged", approval: "user", audit: "full", description: "Delete account" }, + ], + required_capabilities: ["elastos://wallet/*"], + granted_capabilities: [ + { resource: "elastos://wallet/*", action: "read", granted: true, token_id: "tok_w9z0" }, + ], + storage_namespaces: ["localhost://WebSpaces/wallet/"], + carrier: { enabled: false, endpoints: [], peers: 0 }, + provenance: { signed_by: "gateway-did", version: "0.1.0", installed_at: 1781731200, cid: "bafywallet..." }, + audit: { counts: { total_today: 7, user_approved: 3, denied: 0 }, recent: [ + { ts: 1781989000, event: "capability.use", detail: "wallet/* read accounts", success: true }, + { ts: 1781988500, event: "affordance.sign", detail: "sign tx (user approved)", success: true }, + ] }, + processes: [{ kind: "microvm", instance: "vm#2", memory_mb: 64, uptime_s: 25200 }], + }, + { + id: "cap_capsule_inspector_01", name: "capsule-inspector", version: "0.1.0", role: "app", type: "wasm", state: "running", + description: "This inspector — subject to the same rules it reveals.", author: "elastos", + identity: { did: "did:key:z6MkInspectorExample", cid: "bafyinspector...", trust_level: "verified", signature_present: true, signed_by: "gateway-did" }, + manifest: { schema: "elastos.capsule/v1", entrypoint: "capsule-inspector.wasm" }, + affordances: [], + required_capabilities: ["elastos://inspect/read"], + granted_capabilities: [ + { resource: "elastos://inspect/read", action: "read", granted: true, token_id: "tok_i0n1" }, + ], + storage_namespaces: [], + carrier: { enabled: false, endpoints: [], peers: 0 }, + provenance: { signed_by: "gateway-did", version: "0.1.0", installed_at: 1781990000, cid: "bafyinspector..." }, + audit: { counts: { total_today: 3, user_approved: 0, denied: 0 }, recent: [ + { ts: 1781990200, event: "capability.use", detail: "inspect/read capsules", success: true }, + ] }, + processes: [{ kind: "wasm", instance: "#1", memory_mb: 9, uptime_s: 200 }], + }, +]; diff --git a/capsules/capsule-inspector/inspector/style.css b/capsules/capsule-inspector/inspector/style.css new file mode 100644 index 00000000..3bbb945c --- /dev/null +++ b/capsules/capsule-inspector/inspector/style.css @@ -0,0 +1,135 @@ +:root { + --bg: #0b0e14; + --panel: #11151f; + --panel-2: #161b27; + --line: #232a3a; + --text: #e6e9ef; + --muted: #8b94a7; + --accent: #5cc8ff; + --ok: #54d18c; + --warn: #ffb454; + --deny: #ff6b6b; + --sign: #c39bff; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +.brand { display: flex; align-items: center; gap: 10px; } +.brand h1 { font-size: 15px; font-weight: 600; margin: 0; letter-spacing: 0.2px; } +.dot { + width: 10px; height: 10px; border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 12px var(--accent); +} + +.meta { display: flex; align-items: center; gap: 12px; } +.hint { color: var(--muted); font-size: 12px; } + +.badge { + font-size: 11px; padding: 2px 8px; border-radius: 999px; + border: 1px solid var(--line); color: var(--muted); +} +.badge-sample { color: var(--warn); border-color: #4a3a1f; background: #1d160a; } +.badge-live { color: var(--ok); border-color: #1f3a2a; background: #0a1d12; } + +.layout { + display: grid; + grid-template-columns: 280px 1fr; + height: calc(100% - 53px); +} + +.sidebar { + border-right: 1px solid var(--line); + background: var(--panel); + overflow-y: auto; +} +.sidebar-head { + padding: 12px 16px 8px; + font-size: 11px; text-transform: uppercase; letter-spacing: 1px; + color: var(--muted); +} +.capsule-list { list-style: none; margin: 0; padding: 0 8px 16px; } +.capsule-item { + padding: 10px 12px; border-radius: 8px; cursor: pointer; + display: flex; align-items: center; justify-content: space-between; +} +.capsule-item:hover { background: var(--panel-2); } +.capsule-item.active { background: var(--panel-2); outline: 1px solid var(--line); } +.capsule-item .ci-name { font-weight: 600; } +.capsule-item .ci-role { color: var(--muted); font-size: 12px; } +.state-pill { + width: 8px; height: 8px; border-radius: 50%; + background: var(--muted); +} +.state-running { background: var(--ok); box-shadow: 0 0 8px var(--ok); } +.state-suspended { background: var(--warn); } +.state-stopped { background: var(--deny); } + +.detail { padding: 22px 26px; overflow-y: auto; } +.detail-empty { color: var(--muted); padding-top: 40px; text-align: center; } + +.detail-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; } +.detail-head h2 { margin: 0; font-size: 22px; } +.detail-head .ver { color: var(--muted); } +.detail-sub { color: var(--muted); margin: 0 0 20px; } + +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 14px 16px; + margin-bottom: 14px; +} +.card h3 { + margin: 0 0 10px; font-size: 12px; text-transform: uppercase; + letter-spacing: 1px; color: var(--muted); font-weight: 600; +} +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } + +.kv { display: grid; grid-template-columns: 150px 1fr; gap: 4px 12px; } +.kv dt { color: var(--muted); } +.kv dd { margin: 0; word-break: break-all; } + +.row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); } +.row:last-child { border-bottom: none; } +.row .grow { flex: 1; } +.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; } + +.tag { + font-size: 11px; padding: 1px 7px; border-radius: 6px; + border: 1px solid var(--line); color: var(--muted); +} +.tag-read { color: var(--accent); border-color: #1f3344; } +.tag-write { color: var(--warn); border-color: #4a3a1f; } +.tag-sign, .tag-payment, .tag-rights, .tag-privileged, .tag-actuator { color: var(--sign); border-color: #382a4a; } +.tag-user { color: var(--warn); } +.tag-none { color: var(--muted); } + +.granted { color: var(--ok); } +.denied { color: var(--deny); } +.pill-ok { color: var(--ok); } +.pill-deny { color: var(--deny); } + +.audit-line { display: grid; grid-template-columns: 84px 150px 1fr auto; gap: 10px; padding: 4px 0; font-size: 12.5px; } +.audit-line .ts { color: var(--muted); } +.note { + margin-top: 4px; font-size: 12px; color: var(--muted); + border-left: 2px solid var(--line); padding-left: 10px; +} diff --git a/capsules/capsule-inspector/wasm/main.rs b/capsules/capsule-inspector/wasm/main.rs new file mode 100644 index 00000000..1ab093ae --- /dev/null +++ b/capsules/capsule-inspector/wasm/main.rs @@ -0,0 +1,20 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +// Capsule Inspector (Phase 1): a read-only, object-centered view of live +// capsules. The WASM entrypoint announces the capsule; the inspector UI is +// served from `inspector/` and reads `elastos://inspect/*` (capability +// `elastos://inspect/read`). Holds no ambient authority and no write effect. +fn main() { + let info = elastos_guest::CapsuleInfo::from_env(); + let launched_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + + eprintln!( + "capsule-inspector launched: name={} id={} ts={}", + info.name(), + info.id(), + launched_at + ); +} diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md new file mode 100644 index 00000000..65be08f3 --- /dev/null +++ b/docs/CAPSULE_INSPECTOR.md @@ -0,0 +1,159 @@ +# Capsule Inspector + +> An object-centered, read-only introspection surface for ElastOS. +> Self's *mirrors* and *Morphic*, wrapped in our zero-ambient-authority model. + +## Why + +A computer should let you open up the living thing you're using and understand +exactly what it *is*, what it *can do*, what it's *allowed* to do, and where it +*came from* — without fear, and without having to trust a claim you can't see. + +ElastOS already enforces a strong security model (no ambient authority, +capability-scoped provider calls, signed audit). But today that security is +**invisible**: a user installs a capsule and has no surface to see that it +*cannot* touch their keys, or what it has actually been doing. The Inspector +makes the existing guarantees **visible and demonstrable**. + +This is also the human-facing answer to a gap `state.md` already records: + +> System ... does not yet expose the fuller `elastos://` provider/object/ +> capability discovery contract. + +## How (the borrowed idea, re-secured) + +Self is a live-object world: every running thing is an object you can inspect +and poke through *mirrors*, arranged in a direct-manipulation desktop +(*Morphic*). Self trusts everyone in one process. **We trust no one and prove +it.** So we keep Self's object-world UX and re-implement reflection as a +**permissioned mirror**: + +| Self idea | ElastOS realization | +| -------------------- | ---------------------------------------------------- | +| Live object | Capsule (signed, stateful, running) | +| Slots / messages | Affordances (`interfaces[].methods` in capsule.json) | +| Mirror (reflection) | Capability-gated, audited read-only inspect view | +| Morphic world | The Inspector UI (PC2 surface) | +| Transporter (export) | Signed capsule packaging (already exists) | + +Everything the Inspector shows is data the **trusted core already owns** +(manifests, capability grants, audit log, running instances, provenance). The +Inspector is therefore a *read-only projection* — not a new architecture. + +## What + +A read-only view, one screen per capsule, of nine fields: + +1. **identity / DID** — capsule id, device/account DID where relevant +2. **manifest** — schema, role, type, entrypoint, version +3. **affordances** — declared interface methods + risk / approval / audit class +4. **required capabilities** — what the manifest asks for +5. **granted capabilities** — what was actually granted (and what was *denied*) +6. **storage namespaces** — `localhost://WebSpaces/...` scopes +7. **carrier endpoints** — gossip/peer endpoints, peer count +8. **provenance** — signature, signer DID, version, install time, CID +9. **logs / audit + running processes** — recent audited calls, live instances + +## Security invariants (non-negotiable) + +- **Read-only.** The inspect surface exposes *no* write/sign/launch effect. +- **No new trust.** The Inspector capsule holds zero ambient authority and + itself appears in the Inspector, subject to the same rules it reveals. +- **Permissioned mirror.** Every inspect call is capability-gated + (`elastos://inspect/read`) and audited like any other provider call. +- **Scope-bound.** A principal can only inspect capsules within its own + session / grant scope. Out-of-scope inspection is denied *and* audited. + +## Phasing + +| Phase | What | Core impact | +| ----- | ------------------------------------------------------------ | ---------------------- | +| **1** | Read-only Inspector UI + `elastos://inspect/*` contract | None (additive) | +| 2 | Invoke an affordance / revoke a grant from the UI | Light (wire existing) | +| 3 | Morphic-style direct manipulation: drag, re-wire providers | Composition layer | +| 4 | Transparent stored-vs-computed affordance backing (the Self | Deeper ABI work | +| | "you can't tell if it's stored or computed" dream) | | + +Phases 1–2 deliver ~90% of the "see my system as living objects" experience for +~10% of the cost, with no rewrite. Phases 3–4 are where rewrite risk lives — +deliberately deferred. + +## Phase 1 in this branch + +This branch contains the Phase-1 starter: + +- `capsules/capsule-inspector/` — a WASM **app** capsule (UI) that renders the + nine-field view. It requires only `elastos://inspect/read`. Ships with sample + data so the UI renders standalone; calls the live provider when present. +- This doc — the architecture, contract, and phasing. + +The remaining Phase-1 piece is the runtime-side read-only handler that backs +`elastos://inspect/*` from `CapsuleManager` + `AuditLog`. It is additive and +read-only; see the contract below. + +## Wire contract: `elastos://inspect/*` (read-only) + +All operations are `read`. Responses are JSON. + +### `elastos://inspect/capsules` — list + +```json +{ + "capsules": [ + { "id": "...", "name": "chat-room", "role": "shell", "type": "wasm", "state": "running" } + ] +} +``` + +### `elastos://inspect/capsule` (body: `{ "id": "..." }`) — detail + +```json +{ + "id": "cap_chat_room_01", + "name": "chat-room", + "version": "0.1.0", + "role": "shell", + "type": "wasm", + "description": "Peer-to-peer chat room", + "author": "elastos", + "identity": { + "did": "did:key:z6Mk...", + "cid": "bafy...", + "trust_level": "verified", + "signature_present": true, + "signed_by": "gateway-did" + }, + "manifest": { "schema": "elastos.capsule/v1", "entrypoint": "chat.wasm" }, + "affordances": [ + { "interface": "elastos.chat/v1", "id": "send", "risk": "write", + "approval": "user", "audit": "event", "description": "Send a message" }, + { "interface": "elastos.chat/v1", "id": "history", "risk": "read", + "approval": "none", "audit": "summary", "description": "Read history" } + ], + "required_capabilities": ["elastos://carrier/*", "elastos://storage/chat"], + "granted_capabilities": [ + { "resource": "elastos://carrier/*", "action": "message", "granted": true, + "token_id": "tok_...", "expiry": 1781990400 }, + { "resource": "elastos://did/*", "action": "read", "granted": false } + ], + "storage_namespaces": ["localhost://WebSpaces/chat-room/"], + "carrier": { "enabled": true, "endpoints": ["gossip://..."], "peers": 1 }, + "provenance": { "signed_by": "gateway-did", "version": "0.1.0", + "installed_at": 1781817600, "cid": "bafy..." }, + "audit": { + "counts": { "total_today": 14, "user_approved": 2, "denied": 1 }, + "recent": [ + { "ts": 1781990100, "event": "capability.use", "detail": "carrier/* message", "success": true }, + { "ts": 1781990050, "event": "capability.denied", "detail": "did/* read", "success": false } + ] + }, + "processes": [ + { "kind": "wasm", "instance": "#4", "memory_mb": 12, "uptime_s": 10800 } + ] +} +``` + +The runtime-side handler maps these fields from existing sources: +`CapsuleManager::list()` / `get()` (id, manifest, state, cid, trust_level), +`CapabilityManager` (granted/denied tokens), and `AuditLog::memory_buffer` +(recent events + counts). No new state is introduced. From ef0b19d0a363a880bcc4984740b84dee202bca48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 02:49:59 +0000 Subject: [PATCH 02/31] fix(inspect): close inspect privilege-escalation with a tested two-tier scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the Phase-1 starter found a critical design hole: the runtime's full capsule inventory, capability grants, and audit log are shell-only, but the Inspector requested a flat `inspect/read` and the contract returned all capsules. Backing that literally would let any app enumerate every other capsule's powers and history — the opposite of this feature's purpose. Fix lands the security-critical authorization decision in the trusted core as a pure, unit-tested unit, ahead of the async handler wiring: - elastos-runtime::inspect — InspectScope + authorize_view. Two tiers: `elastos://inspect/all` (System, shell/System only) vs `inspect/read` (SelfOnly). Fails closed: no capability and not shell => denied. 10 unit tests cover the escalation guard, fail-closed, and grant precedence. - capsule.json: full-view product surface now requests `inspect/all` (System-granted), not a flat read. - docs: real Security model section (threat, two tiers, fail-closed, least privilege) + scope/denial semantics in the wire contract + suggested least-invasive integration point (existing ResourceRequest path). - UI: scope badge (system vs self-only) so the view is honest about what it shows; fmtTime hardened against non-numeric input. Verified: `cargo test -p elastos-runtime inspect::` — 10 passed; full crate compiles clean. UI passes `node --check`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- capsules/capsule-inspector/capsule.json | 2 +- .../capsule-inspector/inspector/index.html | 1 + .../capsule-inspector/inspector/inspector.js | 13 +- docs/CAPSULE_INSPECTOR.md | 66 +++++-- .../crates/elastos-runtime/src/inspect/mod.rs | 169 ++++++++++++++++++ elastos/crates/elastos-runtime/src/lib.rs | 1 + 6 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 elastos/crates/elastos-runtime/src/inspect/mod.rs diff --git a/capsules/capsule-inspector/capsule.json b/capsules/capsule-inspector/capsule.json index 582f9561..99186365 100644 --- a/capsules/capsule-inspector/capsule.json +++ b/capsules/capsule-inspector/capsule.json @@ -8,7 +8,7 @@ "type": "wasm", "entrypoint": "capsule-inspector.wasm", "capabilities": [ - "elastos://inspect/read" + "elastos://inspect/all" ], "resources": { "memory_mb": 32, diff --git a/capsules/capsule-inspector/inspector/index.html b/capsules/capsule-inspector/inspector/index.html index 4e75d95c..3208b399 100644 --- a/capsules/capsule-inspector/inspector/index.html +++ b/capsules/capsule-inspector/inspector/index.html @@ -13,6 +13,7 @@

Capsule Inspector

+ scope: — sample data read-only · permissioned mirror · audited
diff --git a/capsules/capsule-inspector/inspector/inspector.js b/capsules/capsule-inspector/inspector/inspector.js index 4d86720a..4604f6b3 100644 --- a/capsules/capsule-inspector/inspector/inspector.js +++ b/capsules/capsule-inspector/inspector/inspector.js @@ -28,9 +28,13 @@ async function loadCapsuleList() { const live = await inspectInvoke("capsules", {}); if (live && Array.isArray(live.capsules)) { setSourceBadge(true); + // Scope is reported by the runtime handler ("system" | "self"). + setScopeBadge(live.scope || "system"); return live.capsules; } setSourceBadge(false); + // Sample data illustrates the privileged System view. + setScopeBadge("system"); return SAMPLE_DATA.map((c) => ({ id: c.id, name: c.name, role: c.role, type: c.type, state: c.state, })); @@ -66,8 +70,15 @@ function setSourceBadge(isLive) { badge.className = "badge " + (isLive ? "badge-live" : "badge-sample"); } +function setScopeBadge(scope) { + const badge = document.getElementById("scope-badge"); + const isSystem = scope === "system"; + badge.textContent = "scope: " + (isSystem ? "system (all capsules)" : "self only"); + badge.className = "badge " + (isSystem ? "badge-live" : "badge-sample"); +} + function fmtTime(ts) { - if (!ts) return "—"; + if (typeof ts !== "number" || !isFinite(ts) || ts <= 0) return "—"; return new Date(ts * 1000).toISOString().replace("T", " ").slice(0, 19); } diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index 65be08f3..b5d6f976 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -54,15 +54,46 @@ A read-only view, one screen per capsule, of nine fields: 8. **provenance** — signature, signer DID, version, install time, CID 9. **logs / audit + running processes** — recent audited calls, live instances -## Security invariants (non-negotiable) +## Security model (non-negotiable) + +### Threat + +A *system-wide* inspect view aggregates every capsule's manifest, capability +grants, and audit trail. In the runtime today this god-view is **shell-only** +(`request_handler.rs` — `ListCapsules`, `GrantCapability`, `RevokeCapability`, +`Launch`/`Stop` all reject non-shell callers). If the inspect surface returned +that view to any holder of a flat `inspect` capability, **any installed app +could enumerate every other capsule's powers and history** — an information- +disclosure / privilege-escalation hole, and the exact opposite of what this +feature exists to demonstrate. Visibility must therefore be *scoped*. + +### Two tiers + +| Capability | Scope | Who holds it | Sees | +| ------------------------ | ------------ | --------------------- | ---------------------------- | +| `elastos://inspect/all` | **System** | shell / System surface | every capsule | +| `elastos://inspect/read` | **SelfOnly** | any ordinary capsule | only its own capsule record | + +Shell callers are always treated as **System** scope, matching existing +orchestrator privilege. A caller holding neither capability (and not the shell) +is denied — the gate **fails closed**. + +This decision is implemented as a pure, unit-tested unit in the trusted core: +`elastos-runtime::inspect` (`authorize_view`, `InspectScope`). The runtime-side +handler MUST call `authorize_view` before returning any per-capsule detail. + +### Invariants - **Read-only.** The inspect surface exposes *no* write/sign/launch effect. - **No new trust.** The Inspector capsule holds zero ambient authority and itself appears in the Inspector, subject to the same rules it reveals. -- **Permissioned mirror.** Every inspect call is capability-gated - (`elastos://inspect/read`) and audited like any other provider call. -- **Scope-bound.** A principal can only inspect capsules within its own - session / grant scope. Out-of-scope inspection is denied *and* audited. +- **Permissioned mirror.** Every inspect call is capability-gated and audited + like any other provider call. +- **Scope-bound, fail-closed.** A caller sees only what its scope allows; + out-of-scope inspection is denied *and* audited. No capability ⇒ no data. +- **Least privilege.** The full-view product surface (this Inspector) requests + `elastos://inspect/all`, which only the System surface can grant — it is a + System-trusted surface, not a freely distributable app. ## Phasing @@ -82,18 +113,29 @@ deliberately deferred. This branch contains the Phase-1 starter: -- `capsules/capsule-inspector/` — a WASM **app** capsule (UI) that renders the - nine-field view. It requires only `elastos://inspect/read`. Ships with sample - data so the UI renders standalone; calls the live provider when present. -- This doc — the architecture, contract, and phasing. +- `elastos-runtime::inspect` — the scope/authorization core (`authorize_view`, + `InspectScope`) with unit tests. Pure logic, landed and tested ahead of the + handler so the security invariant is provable in isolation. +- `capsules/capsule-inspector/` — a WASM capsule (UI) that renders the + nine-field view. As the full-view product surface it requests + `elastos://inspect/all` (System-granted). Ships with sample data so the UI + renders standalone; calls the live provider when present. +- This doc — the architecture, security model, contract, and phasing. The remaining Phase-1 piece is the runtime-side read-only handler that backs -`elastos://inspect/*` from `CapsuleManager` + `AuditLog`. It is additive and -read-only; see the contract below. +`elastos://inspect/*` from `CapsuleManager` + `CapabilityManager` + +`AuditLog::recent_events`, gated by `inspect::authorize_view` and auditing +denials. It is additive and read-only; see the contract below. Suggested +integration point: route `elastos://inspect/*` through the existing +`ResourceRequest` path in `RequestHandler` (no new protocol variant needed). ## Wire contract: `elastos://inspect/*` (read-only) -All operations are `read`. Responses are JSON. +All operations are `read`. Responses are JSON. Every response is **filtered by +the caller's scope** (see Security model): `System` callers see all capsules; +`SelfOnly` callers see only their own record. A request for a capsule outside +the caller's scope returns `{ "error": "out_of_scope" }` and emits an audit +event — it never silently returns empty. ### `elastos://inspect/capsules` — list diff --git a/elastos/crates/elastos-runtime/src/inspect/mod.rs b/elastos/crates/elastos-runtime/src/inspect/mod.rs new file mode 100644 index 00000000..dd60ab32 --- /dev/null +++ b/elastos/crates/elastos-runtime/src/inspect/mod.rs @@ -0,0 +1,169 @@ +//! Inspect access control — the security core of the Capsule Inspector. +//! +//! The Inspector exposes a read-only, object-centered view of live capsules +//! (see `docs/CAPSULE_INSPECTOR.md`). A *system* view aggregates every +//! capsule's manifest, capability grants, and audit trail, so handing it to an +//! ordinary app would be an information-disclosure / privilege-escalation hole +//! — the precise opposite of what this surface exists to demonstrate. +//! +//! Visibility is therefore scoped, and this module owns that decision as a +//! pure, testable unit. The runtime-side inspect handler MUST call +//! [`authorize_view`] before returning any per-capsule detail, and MUST audit +//! denials. Keeping the decision here (no async, no I/O) lets us prove the +//! invariant in isolation, ahead of the handler wiring. +//! +//! ## Two tiers +//! +//! - [`INSPECT_ALL`] (`elastos://inspect/all`): the privileged, system-wide +//! view. Granted only to the shell / System surface. +//! - [`INSPECT_READ`] (`elastos://inspect/read`): the self-only view. An +//! ordinary capsule holding this may inspect *itself* and nothing else. +//! +//! Shell callers are always treated as [`InspectScope::System`], matching the +//! existing orchestrator privilege used for `ListCapsules`, `GrantCapability`, +//! and friends in the request handler. + +/// Capability resource that grants the privileged, system-wide inspect view. +pub const INSPECT_ALL: &str = "elastos://inspect/all"; + +/// Capability resource that grants the self-only inspect view. +pub const INSPECT_READ: &str = "elastos://inspect/read"; + +/// The visibility a caller is entitled to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InspectScope { + /// May inspect every capsule (shell / System only). + System, + /// May inspect only its own capsule record. + SelfOnly, +} + +impl InspectScope { + /// Derive the scope a caller is entitled to. + /// + /// Shell callers always receive [`InspectScope::System`]. Otherwise the + /// scope is the strongest one implied by the caller's *granted* inspect + /// capabilities. Returns `None` when the caller holds no inspect + /// capability at all — in which case no inspect data may be returned. + pub fn from_grants(is_shell: bool, granted: I) -> Option + where + I: IntoIterator, + S: AsRef, + { + if is_shell { + return Some(InspectScope::System); + } + + let mut scope = None; + for resource in granted { + match resource.as_ref() { + // The privileged grant always wins; nothing can widen it + // further, so we can return immediately. + INSPECT_ALL => return Some(InspectScope::System), + INSPECT_READ => scope = Some(InspectScope::SelfOnly), + _ => {} + } + } + scope + } + + /// Whether a caller with this scope may inspect `target`, given the + /// caller's own capsule id. + pub fn can_view(self, caller: &str, target: &str) -> bool { + match self { + InspectScope::System => true, + InspectScope::SelfOnly => caller == target, + } + } +} + +/// Decide whether `caller` may inspect `target`. +/// +/// This is the single authorization gate the inspect handler must call before +/// returning any per-capsule detail. It fails closed: a caller with no inspect +/// capability (and that is not the shell) is denied. Denials are the handler's +/// responsibility to audit. +pub fn authorize_view(is_shell: bool, caller: &str, target: &str, granted: &[String]) -> bool { + match InspectScope::from_grants(is_shell, granted.iter()) { + Some(scope) => scope.can_view(caller, target), + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALICE: &str = "cap_alice"; + const BOB: &str = "cap_bob"; + + #[test] + fn shell_always_gets_system_scope_even_without_grants() { + let scope = InspectScope::from_grants(true, Vec::::new()); + assert_eq!(scope, Some(InspectScope::System)); + } + + #[test] + fn inspect_all_grants_system_scope() { + let scope = InspectScope::from_grants(false, [INSPECT_ALL.to_string()]); + assert_eq!(scope, Some(InspectScope::System)); + } + + #[test] + fn inspect_read_grants_self_only_scope() { + let scope = InspectScope::from_grants(false, [INSPECT_READ.to_string()]); + assert_eq!(scope, Some(InspectScope::SelfOnly)); + } + + #[test] + fn no_inspect_capability_yields_no_scope() { + let scope = InspectScope::from_grants(false, ["elastos://storage/foo".to_string()]); + assert_eq!(scope, None); + } + + #[test] + fn inspect_all_wins_regardless_of_order() { + let forward = + InspectScope::from_grants(false, [INSPECT_READ.to_string(), INSPECT_ALL.to_string()]); + let reverse = + InspectScope::from_grants(false, [INSPECT_ALL.to_string(), INSPECT_READ.to_string()]); + assert_eq!(forward, Some(InspectScope::System)); + assert_eq!(reverse, Some(InspectScope::System)); + } + + #[test] + fn system_scope_can_view_any_target() { + assert!(InspectScope::System.can_view(ALICE, ALICE)); + assert!(InspectScope::System.can_view(ALICE, BOB)); + } + + #[test] + fn self_only_scope_can_view_self_but_not_others() { + assert!(InspectScope::SelfOnly.can_view(ALICE, ALICE)); + assert!(!InspectScope::SelfOnly.can_view(ALICE, BOB)); + } + + #[test] + fn authorize_view_self_only_capsule_sees_only_itself() { + let grants = vec![INSPECT_READ.to_string()]; + // Alice inspecting herself: allowed. + assert!(authorize_view(false, ALICE, ALICE, &grants)); + // Alice inspecting Bob: denied — this is the privilege-escalation guard. + assert!(!authorize_view(false, ALICE, BOB, &grants)); + } + + #[test] + fn authorize_view_system_surface_sees_everything() { + let grants = vec![INSPECT_ALL.to_string()]; + assert!(authorize_view(false, "system", BOB, &grants)); + // Shell needs no explicit grant. + assert!(authorize_view(true, "shell", BOB, &[])); + } + + #[test] + fn authorize_view_fails_closed_without_capability() { + // No inspect capability and not the shell: denied even for self. + assert!(!authorize_view(false, ALICE, ALICE, &[])); + assert!(!authorize_view(false, ALICE, BOB, &[])); + } +} diff --git a/elastos/crates/elastos-runtime/src/lib.rs b/elastos/crates/elastos-runtime/src/lib.rs index f7bc52f7..ea71f6ed 100644 --- a/elastos/crates/elastos-runtime/src/lib.rs +++ b/elastos/crates/elastos-runtime/src/lib.rs @@ -19,6 +19,7 @@ pub mod bootstrap; pub mod capability; pub mod capsule; pub mod handler; +pub mod inspect; pub mod messaging; pub mod primitives; pub mod provider; From 741ca98e78097fcacf658ccb230dab03914c2a26 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 03:06:19 +0000 Subject: [PATCH 03/31] feat(inspect): live read-only elastos://inspect/* handler + conformance tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Phase 1 end-to-end. Backs the inspect surface in the trusted core, served on the existing ResourceRequest path (no new protocol variant): - RequestHandler::handle_inspect — dispatches inspect/capsules (System list), inspect/capsule{id} (System detail), inspect/self (self detail). Scope is shell => System, else derived from the validated capability token's grant pattern via inspect::InspectScope. Out-of-scope detail reads are denied and audited (AuditEvent::Custom "inspect.out_of_scope"). - build_capsule_view projects CapsuleManager + AuditLog::recent_events into the documented contract (identity, manifest, affordances, required + audit-derived granted capabilities, storage, carrier, provenance, audit, processes). Read only; unknown fields are null rather than fabricated. - inspect::scope_for_grant aligns the two tiers to real capability patterns: elastos://inspect/* (System) vs elastos://inspect/self (SelfOnly). The capability layer enforces the boundary first (a self grant cannot match a system URI); authorize_view is defense in depth. - 4 handler conformance tests: shell lists with system scope; non-shell w/o token denied; self-only token cannot reach system endpoints (the escalation guard); unknown endpoint -> not_found. Plus 11 inspect unit tests. - UI bridge call + manifest + docs aligned to the inspect/* / inspect/self endpoints and grant patterns. Verified: cargo test -p elastos-runtime inspect — 15 passed; lib compiles clean. UI passes node --check. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- capsules/capsule-inspector/capsule.json | 2 +- .../capsule-inspector/inspector/inspector.js | 22 +- capsules/capsule-inspector/wasm/main.rs | 2 +- docs/CAPSULE_INSPECTOR.md | 58 ++- .../src/handler/request_handler.rs | 428 +++++++++++++++++- .../crates/elastos-runtime/src/inspect/mod.rs | 79 ++-- 6 files changed, 526 insertions(+), 65 deletions(-) diff --git a/capsules/capsule-inspector/capsule.json b/capsules/capsule-inspector/capsule.json index 99186365..98468059 100644 --- a/capsules/capsule-inspector/capsule.json +++ b/capsules/capsule-inspector/capsule.json @@ -8,7 +8,7 @@ "type": "wasm", "entrypoint": "capsule-inspector.wasm", "capabilities": [ - "elastos://inspect/all" + "elastos://inspect/*" ], "resources": { "memory_mb": 32, diff --git a/capsules/capsule-inspector/inspector/inspector.js b/capsules/capsule-inspector/inspector/inspector.js index 4604f6b3..b45ff310 100644 --- a/capsules/capsule-inspector/inspector/inspector.js +++ b/capsules/capsule-inspector/inspector/inspector.js @@ -1,7 +1,7 @@ // Capsule Inspector (Phase 1) — read-only object-centered view. // -// Data flow: the UI reads `elastos://inspect/*` (capability -// `elastos://inspect/read`) through the runtime bridge when present. When the +// Data flow: the UI reads the `elastos://inspect/*` endpoints (System-scope +// capability `elastos://inspect/*`) through the runtime bridge when present. When the // bridge is absent (e.g. opened standalone in a plain browser for design // review), it falls back to SAMPLE_DATA so the surface always renders. No // write/sign/launch path exists anywhere in this capsule. @@ -12,12 +12,14 @@ // Live data source: elastos://inspect/* via the runtime bridge. // Returns null when no bridge is available so the UI can fall back to samples. // --------------------------------------------------------------------------- -async function inspectInvoke(operation, body) { +// Mirrors the runtime ResourceRequest: a read against an `elastos://inspect/*` +// URI with params. The host bridge validates the capability token and routes +// to RequestHandler::handle_inspect. Returns null when no bridge is present. +async function inspectRead(uri, params) { const bridge = window.elastos && window.elastos.inspect; if (!bridge || typeof bridge.invoke !== "function") return null; try { - // bridge.invoke(uri, operation, body) -> parsed JSON - return await bridge.invoke("elastos://inspect", operation, body || {}); + return await bridge.invoke(uri, "read", params || {}); } catch (err) { console.warn("inspect bridge error:", err); return null; @@ -25,7 +27,7 @@ async function inspectInvoke(operation, body) { } async function loadCapsuleList() { - const live = await inspectInvoke("capsules", {}); + const live = await inspectRead("elastos://inspect/capsules", {}); if (live && Array.isArray(live.capsules)) { setSourceBadge(true); // Scope is reported by the runtime handler ("system" | "self"). @@ -41,7 +43,7 @@ async function loadCapsuleList() { } async function loadCapsuleDetail(id) { - const live = await inspectInvoke("capsule", { id }); + const live = await inspectRead("elastos://inspect/capsule", { id }); if (live && live.id) return live; return SAMPLE_DATA.find((c) => c.id === id) || null; } @@ -324,15 +326,15 @@ const SAMPLE_DATA = [ identity: { did: "did:key:z6MkInspectorExample", cid: "bafyinspector...", trust_level: "verified", signature_present: true, signed_by: "gateway-did" }, manifest: { schema: "elastos.capsule/v1", entrypoint: "capsule-inspector.wasm" }, affordances: [], - required_capabilities: ["elastos://inspect/read"], + required_capabilities: ["elastos://inspect/*"], granted_capabilities: [ - { resource: "elastos://inspect/read", action: "read", granted: true, token_id: "tok_i0n1" }, + { resource: "elastos://inspect/*", action: "read", granted: true, token_id: "tok_i0n1" }, ], storage_namespaces: [], carrier: { enabled: false, endpoints: [], peers: 0 }, provenance: { signed_by: "gateway-did", version: "0.1.0", installed_at: 1781990000, cid: "bafyinspector..." }, audit: { counts: { total_today: 3, user_approved: 0, denied: 0 }, recent: [ - { ts: 1781990200, event: "capability.use", detail: "inspect/read capsules", success: true }, + { ts: 1781990200, event: "capability.use", detail: "inspect/* capsules", success: true }, ] }, processes: [{ kind: "wasm", instance: "#1", memory_mb: 9, uptime_s: 200 }], }, diff --git a/capsules/capsule-inspector/wasm/main.rs b/capsules/capsule-inspector/wasm/main.rs index 1ab093ae..b2c0491e 100644 --- a/capsules/capsule-inspector/wasm/main.rs +++ b/capsules/capsule-inspector/wasm/main.rs @@ -3,7 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; // Capsule Inspector (Phase 1): a read-only, object-centered view of live // capsules. The WASM entrypoint announces the capsule; the inspector UI is // served from `inspector/` and reads `elastos://inspect/*` (capability -// `elastos://inspect/read`). Holds no ambient authority and no write effect. +// `elastos://inspect/*`). Holds no ambient authority and no write effect. fn main() { let info = elastos_guest::CapsuleInfo::from_env(); let launched_at = SystemTime::now() diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index b5d6f976..cb7138e7 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -69,14 +69,18 @@ feature exists to demonstrate. Visibility must therefore be *scoped*. ### Two tiers -| Capability | Scope | Who holds it | Sees | -| ------------------------ | ------------ | --------------------- | ---------------------------- | -| `elastos://inspect/all` | **System** | shell / System surface | every capsule | -| `elastos://inspect/read` | **SelfOnly** | any ordinary capsule | only its own capsule record | - -Shell callers are always treated as **System** scope, matching existing -orchestrator privilege. A caller holding neither capability (and not the shell) -is denied — the gate **fails closed**. +| Capability grant | Scope | Reaches | Sees | +| ----------------------- | ------------ | ----------------------------------------- | --------------------------- | +| `elastos://inspect/*` | **System** | `inspect/capsules`, `inspect/capsule`, `inspect/self` | every capsule | +| `elastos://inspect/self`| **SelfOnly** | `inspect/self` only | only its own capsule record | + +The tier is enforced first by the **capability layer itself**: validation +matches the requested URI against the token's resource *pattern*, so a +`elastos://inspect/self` grant (no wildcard) can never satisfy a request to +`elastos://inspect/capsules`. `crate::inspect::authorize_view` is the +defense-in-depth gate on top. Shell callers are always **System**, matching +existing orchestrator privilege. A caller holding neither grant (and not the +shell) is denied — the gate **fails closed**. This decision is implemented as a pure, unit-tested unit in the trusted core: `elastos-runtime::inspect` (`authorize_view`, `InspectScope`). The runtime-side @@ -92,7 +96,7 @@ handler MUST call `authorize_view` before returning any per-capsule detail. - **Scope-bound, fail-closed.** A caller sees only what its scope allows; out-of-scope inspection is denied *and* audited. No capability ⇒ no data. - **Least privilege.** The full-view product surface (this Inspector) requests - `elastos://inspect/all`, which only the System surface can grant — it is a + `elastos://inspect/*`, which only the System surface can grant — it is a System-trusted surface, not a freely distributable app. ## Phasing @@ -114,21 +118,21 @@ deliberately deferred. This branch contains the Phase-1 starter: - `elastos-runtime::inspect` — the scope/authorization core (`authorize_view`, - `InspectScope`) with unit tests. Pure logic, landed and tested ahead of the - handler so the security invariant is provable in isolation. -- `capsules/capsule-inspector/` — a WASM capsule (UI) that renders the - nine-field view. As the full-view product surface it requests - `elastos://inspect/all` (System-granted). Ships with sample data so the UI - renders standalone; calls the live provider when present. + `InspectScope`, `scope_for_grant`) with unit tests. Pure logic, provable in + isolation. +- `RequestHandler::handle_inspect` — the runtime-side read-only handler backing + `elastos://inspect/*`. It projects `CapsuleManager` + `AuditLog::recent_events` + into the contract below, gated per request by `inspect::InspectScope`, and + audits out-of-scope denials. Served on the existing `ResourceRequest` path — + no new protocol variant. Conformance test: + `tests/inspect_conformance.rs` proves a self-only caller cannot read another + capsule. +- `capsules/capsule-inspector/` — a WASM capsule (UI) rendering the nine-field + view. As the full-view product surface it requests `elastos://inspect/*` + (System-granted). Ships with sample data so the UI renders standalone; uses + the live handler when present. - This doc — the architecture, security model, contract, and phasing. -The remaining Phase-1 piece is the runtime-side read-only handler that backs -`elastos://inspect/*` from `CapsuleManager` + `CapabilityManager` + -`AuditLog::recent_events`, gated by `inspect::authorize_view` and auditing -denials. It is additive and read-only; see the contract below. Suggested -integration point: route `elastos://inspect/*` through the existing -`ResourceRequest` path in `RequestHandler` (no new protocol variant needed). - ## Wire contract: `elastos://inspect/*` (read-only) All operations are `read`. Responses are JSON. Every response is **filtered by @@ -137,17 +141,23 @@ the caller's scope** (see Security model): `System` callers see all capsules; the caller's scope returns `{ "error": "out_of_scope" }` and emits an audit event — it never silently returns empty. -### `elastos://inspect/capsules` — list +### `elastos://inspect/capsules` — list (System scope) ```json { + "scope": "system", "capsules": [ { "id": "...", "name": "chat-room", "role": "shell", "type": "wasm", "state": "running" } ] } ``` -### `elastos://inspect/capsule` (body: `{ "id": "..." }`) — detail +### `elastos://inspect/self` — detail of the calling capsule (any scope) + +Returns the same detail shape as below, for `caller == target`. This is the +endpoint a `SelfOnly` capsule uses to introspect itself. + +### `elastos://inspect/capsule` (params: `{ "id": "..." }`) — detail (System scope) ```json { diff --git a/elastos/crates/elastos-runtime/src/handler/request_handler.rs b/elastos/crates/elastos-runtime/src/handler/request_handler.rs index 74fe903c..3f9e9323 100755 --- a/elastos/crates/elastos-runtime/src/handler/request_handler.rs +++ b/elastos/crates/elastos-runtime/src/handler/request_handler.rs @@ -14,7 +14,7 @@ use elastos_namespace::ContentUri; use crate::capability::token::{Action, ResourceId, TokenConstraints as InternalConstraints}; use crate::capability::CapabilityManager; -use crate::capsule::{prepare_fetched_capsule, CapsuleId, CapsuleManager}; +use crate::capsule::{prepare_fetched_capsule, CapsuleId, CapsuleInfo, CapsuleManager}; use crate::content::ContentResolver; use crate::messaging::Message; use crate::messaging::MessageChannel; @@ -626,6 +626,14 @@ impl RequestHandler { ); } + // Read-only Capsule Inspector surface. Served directly here (not via the + // provider registry) because it projects runtime-owned state — capsule + // manifests, capability grants, audit — under a scoped, fail-closed + // authorization gate. See `crate::inspect` and docs/CAPSULE_INSPECTOR.md. + if uri == "elastos://inspect" || uri.starts_with("elastos://inspect/") { + return self.handle_inspect(from, uri, params, token).await; + } + let resource_action = match action.to_lowercase().as_str() { "read" => ResourceAction::Read, "write" => ResourceAction::Write, @@ -830,6 +838,284 @@ impl RequestHandler { capsule_count: running.len(), } } + + // ===== Capsule Inspector (read-only) ===== + + /// Dispatch an `elastos://inspect/*` request under a scoped, fail-closed + /// authorization gate. Every operation is read-only. + async fn handle_inspect( + &self, + from: &CapsuleId, + uri: &str, + params: Option, + token: Option, + ) -> RuntimeResponse { + use crate::capability::token::CapabilityToken; + use crate::inspect::{self, InspectScope}; + + // Determine the caller's inspect scope. Shell is System by existing + // orchestrator privilege; every other caller must present a valid + // inspect capability token, and the grant pattern fixes the tier. + let is_shell = self.is_shell(from).await; + let scope = if is_shell { + InspectScope::System + } else { + let token_str = match token.as_deref() { + Some(t) if !t.is_empty() => t, + _ => { + return RuntimeResponse::error( + "missing_token", + "Capability token required for inspect access", + ) + } + }; + // Authoritative capability check: the token must grant the + // requested URI (a self-only grant cannot satisfy a system URI). + if let Err(e) = self.validate_token(token_str, from, Action::Read, uri).await { + return e; + } + // Defense in depth: classify the granted pattern into a scope. + let granted = match CapabilityToken::from_base64(token_str) { + Ok(t) => vec![t.resource().as_str().to_string()], + Err(_) => { + return RuntimeResponse::error( + "invalid_token", + "Failed to decode capability token", + ) + } + }; + match inspect::InspectScope::from_grants(false, granted.iter()) { + Some(s) => s, + None => { + return RuntimeResponse::error( + "permission_denied", + "Capability does not grant an inspect scope", + ) + } + } + }; + + let endpoint = uri + .strip_prefix("elastos://inspect") + .unwrap_or("") + .trim_start_matches('/'); + + match endpoint { + "capsules" => self.inspect_list(scope, from).await, + "self" => self.inspect_detail(scope, from, from.as_str()).await, + "capsule" => { + match params + .as_ref() + .and_then(|p| p.get("id")) + .and_then(|v| v.as_str()) + { + Some(id) => self.inspect_detail(scope, from, id).await, + None => RuntimeResponse::error( + "invalid_input", + "inspect/capsule requires an \"id\" parameter", + ), + } + } + _ => RuntimeResponse::error("not_found", "Unknown inspect endpoint"), + } + } + + /// List capsules visible under the caller's scope. + async fn inspect_list( + &self, + scope: crate::inspect::InspectScope, + from: &CapsuleId, + ) -> RuntimeResponse { + use crate::inspect::InspectScope; + + let mut capsules = Vec::new(); + for id in self.capsule_manager.list().await { + if !scope.can_view(from.as_str(), id.as_str()) { + continue; + } + if let Some(info) = self.capsule_manager.get(&id).await { + capsules.push(serde_json::json!({ + "id": id.to_string(), + "name": info.manifest.name, + "role": serde_json::to_value(&info.manifest.role).ok(), + "type": serde_json::to_value(&info.manifest.capsule_type).ok(), + "state": format!("{:?}", info.state).to_lowercase(), + })); + } + } + + let scope_label = match scope { + InspectScope::System => "system", + InspectScope::SelfOnly => "self", + }; + RuntimeResponse::ok_with_data(serde_json::json!({ + "scope": scope_label, + "capsules": capsules, + })) + } + + /// Return the full inspector view of a single capsule, gated by scope. + /// Out-of-scope requests are denied and audited. + async fn inspect_detail( + &self, + scope: crate::inspect::InspectScope, + from: &CapsuleId, + target: &str, + ) -> RuntimeResponse { + use crate::primitives::audit::AuditEvent; + + if !scope.can_view(from.as_str(), target) { + self._audit_log.emit(AuditEvent::Custom { + event_type: "inspect.out_of_scope".to_string(), + details: serde_json::json!({ "caller": from.as_str(), "target": target }), + }); + return RuntimeResponse::error("out_of_scope", "Caller may not inspect this capsule"); + } + + let mut info = None; + for id in self.capsule_manager.list().await { + if id.as_str() == target { + info = self.capsule_manager.get(&id).await; + break; + } + } + match info { + Some(info) => RuntimeResponse::ok_with_data(self.build_capsule_view(&info)), + None => RuntimeResponse::error("not_found", "No such capsule"), + } + } + + /// Project a capsule's runtime-owned state into the inspector contract + /// (see docs/CAPSULE_INSPECTOR.md). Read-only; surfaces only what the + /// runtime actually knows and leaves unknown fields null. + fn build_capsule_view(&self, info: &CapsuleInfo) -> serde_json::Value { + use serde_json::{json, Value}; + + fn field(v: &Value, key: &str) -> Value { + v.get(key).cloned().unwrap_or(Value::Null) + } + + let id = info.id.to_string(); + let cid = info.cid.clone(); + let manifest = serde_json::to_value(&info.manifest).unwrap_or_else(|_| json!({})); + + // Affordances: flatten declared interface methods. + let mut affordances = Vec::new(); + if let Some(interfaces) = manifest.get("interfaces").and_then(|v| v.as_array()) { + for iface in interfaces { + let iface_id = field(iface, "id"); + if let Some(methods) = iface.get("methods").and_then(|v| v.as_array()) { + for m in methods { + affordances.push(json!({ + "interface": iface_id, + "id": field(m, "id"), + "risk": field(m, "risk"), + "approval": field(m, "approval"), + "audit": field(m, "audit"), + "description": field(m, "description"), + })); + } + } + } + } + + // Capability grants + recent activity, derived from the audit log — + // the runtime's authoritative record of what this capsule did. + let events = self._audit_log.recent_events(500); + let mut recent = Vec::new(); + let mut grants: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let (mut total, mut denied) = (0u64, 0u64); + for ev in &events { + let v = match serde_json::to_value(ev) { + Ok(v) => v, + Err(_) => continue, + }; + if v.get("capsule_id").and_then(|c| c.as_str()) != Some(id.as_str()) { + continue; + } + total += 1; + let etype = v.get("type").and_then(|t| t.as_str()).unwrap_or("").to_string(); + let resource = v.get("resource").and_then(|r| r.as_str()).map(str::to_string); + let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("").to_string(); + let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true); + if !success { + denied += 1; + } + if let Some(res) = &resource { + let key = format!("{} {}", res, action); + let entry = grants.entry(key).or_insert(true); + if etype == "capability_use" && !success { + *entry = false; + } + } + if recent.len() < 20 { + recent.push(json!({ + "ts": v.get("timestamp").and_then(|t| t.get("unix_secs")).cloned(), + "event": etype, + "detail": resource + .map(|r| format!("{} {}", r, action)) + .unwrap_or_default(), + "success": success, + })); + } + } + let granted_capabilities: Vec = grants + .into_iter() + .map(|(key, ok)| { + let mut parts = key.splitn(2, ' '); + let resource = parts.next().unwrap_or(""); + let action = parts.next().unwrap_or(""); + json!({ "resource": resource, "action": action, "granted": ok }) + }) + .collect(); + + let signature_present = manifest + .get("signature") + .map(|s| s.is_string()) + .unwrap_or(false); + + json!({ + "id": id, + "name": field(&manifest, "name"), + "version": field(&manifest, "version"), + "role": field(&manifest, "role"), + "type": field(&manifest, "type"), + "description": field(&manifest, "description"), + "author": field(&manifest, "author"), + "identity": { + "did": Value::Null, + "cid": cid, + "trust_level": serde_json::to_value(&info.trust_level).ok(), + "signature_present": signature_present, + "signed_by": Value::Null, + }, + "manifest": { + "schema": field(&manifest, "schema"), + "entrypoint": field(&manifest, "entrypoint"), + }, + "affordances": affordances, + "required_capabilities": field(&manifest, "capabilities"), + "granted_capabilities": granted_capabilities, + "storage_namespaces": manifest.pointer("/permissions/storage").cloned().unwrap_or(Value::Null), + "carrier": { + "enabled": manifest.pointer("/permissions/carrier").cloned().unwrap_or(Value::Null), + "endpoints": [], + "peers": 0, + }, + "provenance": { + "signed_by": Value::Null, + "version": field(&manifest, "version"), + "installed_at": Value::Null, + "cid": info.cid.clone(), + "signature_present": signature_present, + }, + "audit": { + "counts": { "total": total, "denied": denied }, + "recent": recent, + }, + "processes": [], + }) + } } #[cfg(test)] @@ -933,6 +1219,146 @@ mod tests { (handler, shell_id) } + /// Like `create_test_handler`, but also returns the capability manager so + /// inspect conformance tests can mint scoped tokens. + async fn create_test_handler_with_caps() -> (RequestHandler, CapsuleId, Arc) { + let compute = Arc::new(MockComputeProvider); + let store = Arc::new(CapabilityStore::new()); + let audit_log = Arc::new(AuditLog::new()); + let metrics = Arc::new(MetricsManager::new()); + let capability_manager = + Arc::new(CapabilityManager::new(store, audit_log.clone(), metrics.clone())); + let capsule_manager = Arc::new(CapsuleManager::new( + compute, + capability_manager.clone(), + metrics.clone(), + audit_log.clone(), + )); + let message_channel = Arc::new(MessageChannel::new( + capability_manager.clone(), + metrics.clone(), + audit_log.clone(), + )); + let content_resolver = Arc::new(ContentResolver::new( + ResolverConfig::default(), + audit_log.clone(), + Arc::new(NullFetcher), + )); + let handler = RequestHandler::new( + capsule_manager, + capability_manager.clone(), + message_channel, + content_resolver, + audit_log, + "0.1.0".to_string(), + None, + ); + let shell_id = CapsuleId::new(); + handler.set_shell(shell_id.clone()).await; + (handler, shell_id, capability_manager) + } + + fn inspect_request( + uri: &str, + token: Option, + params: Option, + ) -> RuntimeRequest { + RuntimeRequest::ResourceRequest { + uri: uri.to_string(), + action: "read".to_string(), + params, + token, + } + } + + #[tokio::test] + async fn inspect_shell_can_list_with_system_scope() { + let (handler, shell_id) = create_test_handler().await; + let resp = handler + .handle(&shell_id, inspect_request("elastos://inspect/capsules", None, None)) + .await; + match resp { + RuntimeResponse::Ok { data: Some(data) } => { + assert_eq!(data["scope"], "system"); + assert!(data["capsules"].is_array()); + } + other => panic!("expected Ok with data, got {:?}", other), + } + } + + #[tokio::test] + async fn inspect_non_shell_without_token_is_denied() { + let (handler, _shell) = create_test_handler().await; + let caller = CapsuleId::new(); + let resp = handler + .handle(&caller, inspect_request("elastos://inspect/capsules", None, None)) + .await; + match resp { + RuntimeResponse::Error { code, .. } => assert_eq!(code, "missing_token"), + other => panic!("expected missing_token error, got {:?}", other), + } + } + + #[tokio::test] + async fn inspect_self_only_token_cannot_reach_system_endpoints() { + // The privilege-escalation guard at the handler boundary: a self-only + // grant must not let a capsule enumerate or read other capsules. + let (handler, shell_id, caps) = create_test_handler_with_caps().await; + let caller = CapsuleId::new(); + let self_token = caps + .grant( + caller.as_str(), + ResourceId::new("elastos://inspect/self"), + Action::Read, + InternalConstraints::default(), + None, + ) + .to_base64() + .expect("encode token"); + + // Read another capsule via the system detail endpoint: denied. + let resp = handler + .handle( + &caller, + inspect_request( + "elastos://inspect/capsule", + Some(self_token.clone()), + Some(serde_json::json!({ "id": shell_id.to_string() })), + ), + ) + .await; + match resp { + // Blocked at the capability layer: a self-only pattern cannot match + // a system URI. (inspect::can_view is the defense-in-depth gate.) + RuntimeResponse::Error { code, .. } => assert_eq!(code, "permission_denied"), + other => panic!("expected permission_denied, got {:?}", other), + } + + // Enumerate all capsules: also denied. + let resp = handler + .handle( + &caller, + inspect_request("elastos://inspect/capsules", Some(self_token), None), + ) + .await; + assert!( + matches!(resp, RuntimeResponse::Error { .. }), + "self-only grant must not list all capsules" + ); + } + + #[tokio::test] + async fn inspect_unknown_endpoint_is_not_found() { + let (handler, shell_id) = create_test_handler().await; + let resp = handler + .handle(&shell_id, inspect_request("elastos://inspect/bogus", None, None)) + .await; + match resp { + RuntimeResponse::Error { code, .. } => assert_eq!(code, "not_found"), + other => panic!("expected not_found, got {:?}", other), + } + } + #[tokio::test] async fn test_ping() { let (handler, shell_id) = create_test_handler().await; diff --git a/elastos/crates/elastos-runtime/src/inspect/mod.rs b/elastos/crates/elastos-runtime/src/inspect/mod.rs index dd60ab32..d8c9cb55 100644 --- a/elastos/crates/elastos-runtime/src/inspect/mod.rs +++ b/elastos/crates/elastos-runtime/src/inspect/mod.rs @@ -10,24 +10,29 @@ //! pure, testable unit. The runtime-side inspect handler MUST call //! [`authorize_view`] before returning any per-capsule detail, and MUST audit //! denials. Keeping the decision here (no async, no I/O) lets us prove the -//! invariant in isolation, ahead of the handler wiring. +//! invariant in isolation, independent of the handler wiring. //! -//! ## Two tiers +//! ## Two tiers (encoded as capability grant patterns) //! -//! - [`INSPECT_ALL`] (`elastos://inspect/all`): the privileged, system-wide -//! view. Granted only to the shell / System surface. -//! - [`INSPECT_READ`] (`elastos://inspect/read`): the self-only view. An -//! ordinary capsule holding this may inspect *itself* and nothing else. +//! - [`INSPECT_SYSTEM`] (`elastos://inspect/*`): the privileged, system-wide +//! view. This wildcard pattern is what lets a caller reach the system +//! endpoints (`elastos://inspect/capsules`, `.../capsule`). Granted only to +//! the shell / System surface. +//! - [`INSPECT_SELF`] (`elastos://inspect/self`): the self-only view. A caller +//! holding this may reach only `elastos://inspect/self` and sees only its +//! own capsule record. //! -//! Shell callers are always treated as [`InspectScope::System`], matching the -//! existing orchestrator privilege used for `ListCapsules`, `GrantCapability`, -//! and friends in the request handler. +//! Because capability validation matches the requested URI against the token's +//! resource *pattern*, a self-only token (`elastos://inspect/self`, no +//! wildcard) cannot satisfy a request to `elastos://inspect/capsules` — so the +//! tier boundary is enforced by the existing capability layer, and +//! [`authorize_view`] is the defense-in-depth gate on top. -/// Capability resource that grants the privileged, system-wide inspect view. -pub const INSPECT_ALL: &str = "elastos://inspect/all"; +/// Capability grant pattern for the privileged, system-wide inspect view. +pub const INSPECT_SYSTEM: &str = "elastos://inspect/*"; -/// Capability resource that grants the self-only inspect view. -pub const INSPECT_READ: &str = "elastos://inspect/read"; +/// Capability grant pattern for the self-only inspect view. +pub const INSPECT_SELF: &str = "elastos://inspect/self"; /// The visibility a caller is entitled to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -38,6 +43,17 @@ pub enum InspectScope { SelfOnly, } +/// Map a single granted capability resource to the inspect scope it confers. +/// +/// Returns `None` for any resource that is not an inspect grant. +pub fn scope_for_grant(resource: &str) -> Option { + match resource { + INSPECT_SYSTEM => Some(InspectScope::System), + INSPECT_SELF => Some(InspectScope::SelfOnly), + _ => None, + } +} + impl InspectScope { /// Derive the scope a caller is entitled to. /// @@ -56,12 +72,11 @@ impl InspectScope { let mut scope = None; for resource in granted { - match resource.as_ref() { - // The privileged grant always wins; nothing can widen it - // further, so we can return immediately. - INSPECT_ALL => return Some(InspectScope::System), - INSPECT_READ => scope = Some(InspectScope::SelfOnly), - _ => {} + match scope_for_grant(resource.as_ref()) { + // System is the strongest tier; nothing widens it further. + Some(InspectScope::System) => return Some(InspectScope::System), + Some(InspectScope::SelfOnly) => scope = Some(InspectScope::SelfOnly), + None => {} } } scope @@ -97,6 +112,14 @@ mod tests { const ALICE: &str = "cap_alice"; const BOB: &str = "cap_bob"; + #[test] + fn scope_for_grant_maps_known_patterns() { + assert_eq!(scope_for_grant(INSPECT_SYSTEM), Some(InspectScope::System)); + assert_eq!(scope_for_grant(INSPECT_SELF), Some(InspectScope::SelfOnly)); + assert_eq!(scope_for_grant("elastos://inspect/capsules"), None); + assert_eq!(scope_for_grant("elastos://storage/foo"), None); + } + #[test] fn shell_always_gets_system_scope_even_without_grants() { let scope = InspectScope::from_grants(true, Vec::::new()); @@ -104,14 +127,14 @@ mod tests { } #[test] - fn inspect_all_grants_system_scope() { - let scope = InspectScope::from_grants(false, [INSPECT_ALL.to_string()]); + fn system_grant_yields_system_scope() { + let scope = InspectScope::from_grants(false, [INSPECT_SYSTEM.to_string()]); assert_eq!(scope, Some(InspectScope::System)); } #[test] - fn inspect_read_grants_self_only_scope() { - let scope = InspectScope::from_grants(false, [INSPECT_READ.to_string()]); + fn self_grant_yields_self_only_scope() { + let scope = InspectScope::from_grants(false, [INSPECT_SELF.to_string()]); assert_eq!(scope, Some(InspectScope::SelfOnly)); } @@ -122,11 +145,11 @@ mod tests { } #[test] - fn inspect_all_wins_regardless_of_order() { + fn system_grant_wins_regardless_of_order() { let forward = - InspectScope::from_grants(false, [INSPECT_READ.to_string(), INSPECT_ALL.to_string()]); + InspectScope::from_grants(false, [INSPECT_SELF.to_string(), INSPECT_SYSTEM.to_string()]); let reverse = - InspectScope::from_grants(false, [INSPECT_ALL.to_string(), INSPECT_READ.to_string()]); + InspectScope::from_grants(false, [INSPECT_SYSTEM.to_string(), INSPECT_SELF.to_string()]); assert_eq!(forward, Some(InspectScope::System)); assert_eq!(reverse, Some(InspectScope::System)); } @@ -145,7 +168,7 @@ mod tests { #[test] fn authorize_view_self_only_capsule_sees_only_itself() { - let grants = vec![INSPECT_READ.to_string()]; + let grants = vec![INSPECT_SELF.to_string()]; // Alice inspecting herself: allowed. assert!(authorize_view(false, ALICE, ALICE, &grants)); // Alice inspecting Bob: denied — this is the privilege-escalation guard. @@ -154,7 +177,7 @@ mod tests { #[test] fn authorize_view_system_surface_sees_everything() { - let grants = vec![INSPECT_ALL.to_string()]; + let grants = vec![INSPECT_SYSTEM.to_string()]; assert!(authorize_view(false, "system", BOB, &grants)); // Shell needs no explicit grant. assert!(authorize_view(true, "shell", BOB, &[])); From 7d85ec9e24d1c26d7b664e5bd4ddd325a69ed783 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 03:24:57 +0000 Subject: [PATCH 04/31] test(inspect): prove contract fidelity + no-authority-leak; doc the ocap reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the test gap on build_capsule_view — a security-relevant projection that previously had no output coverage. Grounded in PRINCIPLES (#16, #12, #7) and the bearer-token capability model confirmed in the capability store. - inspect_detail_renders_contract_without_leaking_authority: launches a real capsule and asserts the nine-field contract is faithful (affordances, required capabilities, storage namespaces, signature_present) AND that the raw manifest signature and any bearer "token" never appear in the output (Principle #16: UI surfaces must not expose bearer tokens or mutation handles — now enforced by test, not prose). - inspect_self_returns_callers_own_record: any capsule (human-driven or agent) can introspect itself via inspect/self with a minimal self grant (Principle #7: humans and agents share one authority model). - Test helper now exposes the capsule manager so tests can launch + inspect. - docs: explain why granted_capabilities is observed-from-audit — ElastOS capabilities are bearer-token object-capabilities with no central per-capsule grant registry, so the audit log is the authoritative safe-to-display source (Principle #12: docs/code/tests agree). Verified: cargo test -p elastos-runtime --lib — 274 passed; 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/CAPSULE_INSPECTOR.md | 21 +++- .../src/handler/request_handler.rs | 119 +++++++++++++++++- 2 files changed, 131 insertions(+), 9 deletions(-) diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index cb7138e7..649ebc50 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -206,6 +206,21 @@ endpoint a `SelfOnly` capsule uses to introspect itself. ``` The runtime-side handler maps these fields from existing sources: -`CapsuleManager::list()` / `get()` (id, manifest, state, cid, trust_level), -`CapabilityManager` (granted/denied tokens), and `AuditLog::memory_buffer` -(recent events + counts). No new state is introduced. +`CapsuleManager::list()` / `get()` (id, manifest, state, cid, trust_level) and +`AuditLog::recent_events` (recent events + counts). No new state is introduced. + +### Why `granted_capabilities` is observed, not enumerated + +ElastOS capabilities are **bearer-token object-capabilities**: a grant is an +unforgeable signed token held by the grantee, validated by signature + +revocation epoch + revoked-set. The runtime keeps **no central per-capsule +registry of active grants** (only a revoked-set and use-counts). So the +authoritative, safe-to-display record of authority is the **audit log** — what +was actually granted and used. `granted_capabilities` is therefore *observed +from audit*, by design, not read from a token table. + +This is also why Principle #16 (UI Surfaces Must Not Be Authority) is load +bearing here: the inspector projects an allow-listed set of safe fields and +**never** echoes a bearer token, a raw signature, or any mutation handle. The +raw manifest `signature` is reduced to `signature_present: true`. This is +enforced by test (`inspect_detail_renders_contract_without_leaking_authority`). diff --git a/elastos/crates/elastos-runtime/src/handler/request_handler.rs b/elastos/crates/elastos-runtime/src/handler/request_handler.rs index 3f9e9323..f05d1e95 100755 --- a/elastos/crates/elastos-runtime/src/handler/request_handler.rs +++ b/elastos/crates/elastos-runtime/src/handler/request_handler.rs @@ -1219,9 +1219,11 @@ mod tests { (handler, shell_id) } - /// Like `create_test_handler`, but also returns the capability manager so - /// inspect conformance tests can mint scoped tokens. - async fn create_test_handler_with_caps() -> (RequestHandler, CapsuleId, Arc) { + /// Like `create_test_handler`, but also returns the capability and capsule + /// managers so inspect conformance tests can mint scoped tokens and launch + /// real capsules to introspect. + async fn create_test_handler_with_caps( + ) -> (RequestHandler, CapsuleId, Arc, Arc) { let compute = Arc::new(MockComputeProvider); let store = Arc::new(CapabilityStore::new()); let audit_log = Arc::new(AuditLog::new()); @@ -1245,7 +1247,7 @@ mod tests { Arc::new(NullFetcher), )); let handler = RequestHandler::new( - capsule_manager, + capsule_manager.clone(), capability_manager.clone(), message_channel, content_resolver, @@ -1255,7 +1257,112 @@ mod tests { ); let shell_id = CapsuleId::new(); handler.set_shell(shell_id.clone()).await; - (handler, shell_id, capability_manager) + (handler, shell_id, capability_manager, capsule_manager) + } + + /// A manifest with affordances, a required capability, a storage namespace, + /// and a (sensitive) signature — used to prove the inspector renders the + /// contract faithfully and never echoes the raw signature. + fn probe_manifest() -> elastos_common::CapsuleManifest { + serde_json::from_value(serde_json::json!({ + "schema": "elastos.capsule/v1", + "version": "0.1.0", + "name": "probe", + "role": "app", + "type": "wasm", + "entrypoint": "probe.wasm", + "capabilities": ["elastos://storage/probe"], + "interfaces": [{ + "id": "elastos.probe/v1", + "version": "1", + "methods": [{ + "id": "ping", "risk": "read", "approval": "none", "audit": "summary" + }] + }], + "permissions": { "storage": ["localhost://WebSpaces/probe/"] }, + "signature": "SECRET_SIGNATURE_MUST_NOT_LEAK" + })) + .expect("probe manifest deserializes") + } + + #[tokio::test] + async fn inspect_detail_renders_contract_without_leaking_authority() { + let (handler, shell_id, _caps, capsule_manager) = create_test_handler_with_caps().await; + let id = capsule_manager + .launch_local(std::path::Path::new("."), probe_manifest(), TrustLevel::Trusted) + .await + .expect("launch probe"); + + let resp = handler + .handle( + &shell_id, + inspect_request( + "elastos://inspect/capsule", + None, + Some(serde_json::json!({ "id": id.to_string() })), + ), + ) + .await; + + let data = match resp { + RuntimeResponse::Ok { data: Some(data) } => data, + other => panic!("expected Ok with data, got {:?}", other), + }; + + // Faithful projection of manifest-declared facts. + assert_eq!(data["affordances"][0]["id"], "ping"); + assert_eq!(data["affordances"][0]["risk"], "read"); + assert_eq!(data["affordances"][0]["interface"], "elastos.probe/v1"); + assert_eq!(data["required_capabilities"][0], "elastos://storage/probe"); + assert_eq!(data["storage_namespaces"][0], "localhost://WebSpaces/probe/"); + assert_eq!(data["identity"]["signature_present"], true); + + // Principle #16: UI surfaces must not expose bearer tokens or mutation + // handles. The raw signature is reduced to a boolean and never echoed, + // and no bearer "token" field appears anywhere in the projection. + let serialized = serde_json::to_string(&data).unwrap(); + assert!( + !serialized.contains("SECRET_SIGNATURE_MUST_NOT_LEAK"), + "raw signature leaked into inspect output" + ); + assert!( + !serialized.contains("\"token\""), + "bearer token field leaked into inspect output" + ); + } + + #[tokio::test] + async fn inspect_self_returns_callers_own_record() { + // Principle #7: any capsule (human-driven or agent) can introspect + // itself with a minimal self grant — the same authority model for both. + let (handler, _shell, caps, capsule_manager) = create_test_handler_with_caps().await; + let id = capsule_manager + .launch_local(std::path::Path::new("."), probe_manifest(), TrustLevel::Trusted) + .await + .expect("launch probe"); + + let self_token = caps + .grant( + id.as_str(), + ResourceId::new("elastos://inspect/self"), + Action::Read, + InternalConstraints::default(), + None, + ) + .to_base64() + .expect("encode token"); + + let resp = handler + .handle(&id, inspect_request("elastos://inspect/self", Some(self_token), None)) + .await; + + match resp { + RuntimeResponse::Ok { data: Some(data) } => { + assert_eq!(data["id"], id.to_string()); + assert_eq!(data["name"], "probe"); + } + other => panic!("expected Ok with own record, got {:?}", other), + } } fn inspect_request( @@ -1303,7 +1410,7 @@ mod tests { async fn inspect_self_only_token_cannot_reach_system_endpoints() { // The privilege-escalation guard at the handler boundary: a self-only // grant must not let a capsule enumerate or read other capsules. - let (handler, shell_id, caps) = create_test_handler_with_caps().await; + let (handler, shell_id, caps, _capsule_manager) = create_test_handler_with_caps().await; let caller = CapsuleId::new(); let self_token = caps .grant( From f7d191f2aedf7061a8ffae22f825aa7ff11564ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 03:33:42 +0000 Subject: [PATCH 05/31] =?UTF-8?q?feat(inspect):=20Phase=202=20=E2=80=94=20?= =?UTF-8?q?write-gated,=20audited=20capability=20revocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glass box can now act, in the fail-safe direction: revoke a capability, which only ever reduces authority. Grounded in PRINCIPLES (#3, #16, #11) and the bearer-token model. - New endpoint elastos://inspect/revoke (mutation). Requires a Write inspect capability at System scope (or shell); revokes a token by id via CapabilityManager::revoke and emits an inspect.revoke audit event. - Read vs write separation is enforced by the capability *action* dimension, not just the resource: handle_inspect now selects required_action = Write for revoke, Read otherwise. A read-only inspect grant can never drive a mutation. - Self-only scope is rejected for revoke (defense in depth on top of the action gate). - 4 conformance tests: read-only token cannot revoke (the crux — and the victim stays valid); shell can revoke (victim then fails validation); non-shell Write+System operator can revoke; malformed id -> invalid_token_id. - docs: revoke endpoint + read/write tier in the security model and contract. - UI: inspectRevoke bridge stub, intentionally not wired to the token-free read view (Principle #16) — a dedicated System admin surface supplies the id. Verified: cargo test -p elastos-runtime --lib — 278 passed; 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- .../capsule-inspector/inspector/inspector.js | 12 + docs/CAPSULE_INSPECTOR.md | 19 ++ .../src/handler/request_handler.rs | 245 +++++++++++++++++- 3 files changed, 267 insertions(+), 9 deletions(-) diff --git a/capsules/capsule-inspector/inspector/inspector.js b/capsules/capsule-inspector/inspector/inspector.js index b45ff310..a2786db8 100644 --- a/capsules/capsule-inspector/inspector/inspector.js +++ b/capsules/capsule-inspector/inspector/inspector.js @@ -48,6 +48,18 @@ async function loadCapsuleDetail(id) { return SAMPLE_DATA.find((c) => c.id === id) || null; } +// Phase 2 (write): revoke a capability by token id. This is a System-admin +// mutation gated by a *write* inspect capability (`elastos://inspect/*` with +// the write action). It is intentionally NOT driven from the read view above — +// read summaries never carry bearer token ids (Principle #16). A dedicated +// System admin surface supplies the token id and a write-scoped token. +async function inspectRevoke(tokenId) { + const bridge = window.elastos && window.elastos.inspect; + if (!bridge || typeof bridge.invoke !== "function") return null; + // Mutating call: the host bridge must attach a write-scoped inspect token. + return await bridge.invoke("elastos://inspect/revoke", "write", { token_id: tokenId }); +} + // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index 649ebc50..06bb7f70 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -82,6 +82,16 @@ defense-in-depth gate on top. Shell callers are always **System**, matching existing orchestrator privilege. A caller holding neither grant (and not the shell) is denied — the gate **fails closed**. +### Read vs. write (Phase 2) + +Read endpoints require a `Read` inspect capability. The one mutating endpoint, +`elastos://inspect/revoke`, requires a **`Write`** inspect capability at +**System** scope. The two are separated by the *action* dimension of the +capability, not just the resource: a read-only inspect grant (`Read`) can never +satisfy a write endpoint, so the Inspector's normal read surface can never +drive a mutation (Principles #3, #16). Revocation only ever *reduces* authority +(fail-safe direction) and is audited (`inspect.revoke`). + This decision is implemented as a pure, unit-tested unit in the trusted core: `elastos-runtime::inspect` (`authorize_view`, `InspectScope`). The runtime-side handler MUST call `authorize_view` before returning any per-capsule detail. @@ -209,6 +219,15 @@ The runtime-side handler maps these fields from existing sources: `CapsuleManager::list()` / `get()` (id, manifest, state, cid, trust_level) and `AuditLog::recent_events` (recent events + counts). No new state is introduced. +### `elastos://inspect/revoke` (params: `{ "token_id": "<32 hex>" }`) — write + +The one mutating endpoint. Requires a **`Write`** inspect capability at +**System** scope (or the shell). Revokes the capability token by id via +`CapabilityManager::revoke`, reducing authority only. Returns `Ok` on success; +`permission_denied` for a read-only or self-only caller; `invalid_token_id` for +a malformed id. The action is audited (`inspect.revoke`) in addition to the +capability manager's own revocation audit. + ### Why `granted_capabilities` is observed, not enumerated ElastOS capabilities are **bearer-token object-capabilities**: a grant is an diff --git a/elastos/crates/elastos-runtime/src/handler/request_handler.rs b/elastos/crates/elastos-runtime/src/handler/request_handler.rs index f05d1e95..d41cf1de 100755 --- a/elastos/crates/elastos-runtime/src/handler/request_handler.rs +++ b/elastos/crates/elastos-runtime/src/handler/request_handler.rs @@ -842,7 +842,12 @@ impl RequestHandler { // ===== Capsule Inspector (read-only) ===== /// Dispatch an `elastos://inspect/*` request under a scoped, fail-closed - /// authorization gate. Every operation is read-only. + /// authorization gate. + /// + /// Read endpoints (`capsules`, `capsule`, `self`) require a `Read` inspect + /// capability; the write endpoint (`revoke`) requires a `Write` inspect + /// capability. The action dimension keeps the two strictly separated: a + /// read-only inspect grant can never drive a mutation (Principles #3, #16). async fn handle_inspect( &self, from: &CapsuleId, @@ -853,9 +858,23 @@ impl RequestHandler { use crate::capability::token::CapabilityToken; use crate::inspect::{self, InspectScope}; + let endpoint = uri + .strip_prefix("elastos://inspect") + .unwrap_or("") + .trim_start_matches('/'); + + // `revoke` mutates authority and demands a Write inspect capability; + // everything else is read-only. + let required_action = if endpoint == "revoke" { + Action::Write + } else { + Action::Read + }; + // Determine the caller's inspect scope. Shell is System by existing // orchestrator privilege; every other caller must present a valid - // inspect capability token, and the grant pattern fixes the tier. + // inspect capability token for the required action, and the grant + // pattern fixes the tier. let is_shell = self.is_shell(from).await; let scope = if is_shell { InspectScope::System @@ -870,8 +889,9 @@ impl RequestHandler { } }; // Authoritative capability check: the token must grant the - // requested URI (a self-only grant cannot satisfy a system URI). - if let Err(e) = self.validate_token(token_str, from, Action::Read, uri).await { + // requested URI *and* action. A read grant fails a write endpoint; + // a self-only grant cannot satisfy a system URI. + if let Err(e) = self.validate_token(token_str, from, required_action, uri).await { return e; } // Defense in depth: classify the granted pattern into a scope. @@ -895,11 +915,6 @@ impl RequestHandler { } }; - let endpoint = uri - .strip_prefix("elastos://inspect") - .unwrap_or("") - .trim_start_matches('/'); - match endpoint { "capsules" => self.inspect_list(scope, from).await, "self" => self.inspect_detail(scope, from, from.as_str()).await, @@ -916,10 +931,75 @@ impl RequestHandler { ), } } + "revoke" => self.inspect_revoke(scope, from, params).await, _ => RuntimeResponse::error("not_found", "Unknown inspect endpoint"), } } + /// Revoke a capability by token id. Write-gated and System-scoped: only a + /// holder of a `Write` inspect capability at System scope (or the shell) + /// reaches here. Revocation only ever *reduces* authority and is audited. + async fn inspect_revoke( + &self, + scope: crate::inspect::InspectScope, + from: &CapsuleId, + params: Option, + ) -> RuntimeResponse { + use crate::capability::token::TokenId; + use crate::inspect::InspectScope; + use crate::primitives::audit::AuditEvent; + + // A self-only inspect grant must never drive a system mutation. + if scope != InspectScope::System { + return RuntimeResponse::error( + "permission_denied", + "Revoke requires system-scope inspect authority", + ); + } + + let token_id = match params + .as_ref() + .and_then(|p| p.get("token_id")) + .and_then(|v| v.as_str()) + { + Some(id) => id, + None => { + return RuntimeResponse::error( + "invalid_input", + "inspect/revoke requires a \"token_id\" parameter", + ) + } + }; + + // Parse the 32-hex-char token id (same contract as RevokeCapability). + let parsed = match hex::decode(token_id) { + Ok(bytes) if bytes.len() == 16 => { + let mut arr = [0u8; 16]; + arr.copy_from_slice(&bytes); + TokenId::from_bytes(arr) + } + _ => { + return RuntimeResponse::error( + "invalid_token_id", + "Token ID must be 32 hex characters", + ) + } + }; + + self.capability_manager + .revoke(parsed, "Revoked via inspector") + .await; + + // Audit who drove the revoke (the revoke itself is also audited by the + // capability manager). + self._audit_log.emit(AuditEvent::Custom { + event_type: "inspect.revoke".to_string(), + details: serde_json::json!({ "caller": from.as_str(), "token_id": token_id }), + }); + + RuntimeResponse::ok() + } + /// List capsules visible under the caller's scope. async fn inspect_list( &self, @@ -1365,6 +1445,153 @@ mod tests { } } + #[tokio::test] + async fn inspect_revoke_rejects_read_only_token() { + // The crux of the read/write separation: a System *read* inspect grant + // must never be able to drive a mutation. The action dimension blocks + // it at the capability layer. + let (handler, _shell, caps, _cm) = create_test_handler_with_caps().await; + let caller = CapsuleId::new(); + let victim_capsule = CapsuleId::new(); + let victim = caps.grant( + victim_capsule.as_str(), + ResourceId::new("elastos://storage/x"), + Action::Read, + InternalConstraints::default(), + None, + ); + let read_token = caps + .grant( + caller.as_str(), + ResourceId::new("elastos://inspect/*"), + Action::Read, + InternalConstraints::default(), + None, + ) + .to_base64() + .expect("encode read token"); + + let resp = handler + .handle( + &caller, + inspect_request( + "elastos://inspect/revoke", + Some(read_token), + Some(serde_json::json!({ "token_id": victim.id().to_string() })), + ), + ) + .await; + match resp { + RuntimeResponse::Error { code, .. } => assert_eq!(code, "permission_denied"), + other => panic!("read token must not revoke, got {:?}", other), + } + + // Victim capability is still valid — the revoke did not happen. + let res = ResourceId::new("elastos://storage/x"); + assert!(caps + .validate(&victim, victim_capsule.as_str(), Action::Read, &res, None) + .await + .is_ok()); + } + + #[tokio::test] + async fn inspect_shell_can_revoke_token() { + let (handler, shell_id, caps, _cm) = create_test_handler_with_caps().await; + let victim_capsule = CapsuleId::new(); + let res = ResourceId::new("elastos://storage/x"); + let victim = caps.grant( + victim_capsule.as_str(), + res.clone(), + Action::Read, + InternalConstraints::default(), + None, + ); + // Sanity: valid before revoke. + assert!(caps + .validate(&victim, victim_capsule.as_str(), Action::Read, &res, None) + .await + .is_ok()); + + let resp = handler + .handle( + &shell_id, + inspect_request( + "elastos://inspect/revoke", + None, + Some(serde_json::json!({ "token_id": victim.id().to_string() })), + ), + ) + .await; + assert!(matches!(resp, RuntimeResponse::Ok { .. }), "shell revoke should succeed"); + + // The capability is now revoked and fails validation. + assert!(caps + .validate(&victim, victim_capsule.as_str(), Action::Read, &res, None) + .await + .is_err()); + } + + #[tokio::test] + async fn inspect_write_token_at_system_scope_can_revoke() { + // A non-shell System operator holding a Write inspect grant can revoke. + let (handler, _shell, caps, _cm) = create_test_handler_with_caps().await; + let operator = CapsuleId::new(); + let victim_capsule = CapsuleId::new(); + let res = ResourceId::new("elastos://storage/x"); + let victim = caps.grant( + victim_capsule.as_str(), + res.clone(), + Action::Read, + InternalConstraints::default(), + None, + ); + let write_token = caps + .grant( + operator.as_str(), + ResourceId::new("elastos://inspect/*"), + Action::Write, + InternalConstraints::default(), + None, + ) + .to_base64() + .expect("encode write token"); + + let resp = handler + .handle( + &operator, + inspect_request( + "elastos://inspect/revoke", + Some(write_token), + Some(serde_json::json!({ "token_id": victim.id().to_string() })), + ), + ) + .await; + assert!(matches!(resp, RuntimeResponse::Ok { .. }), "write-scope revoke should succeed"); + assert!(caps + .validate(&victim, victim_capsule.as_str(), Action::Read, &res, None) + .await + .is_err()); + } + + #[tokio::test] + async fn inspect_revoke_rejects_bad_token_id() { + let (handler, shell_id, _caps, _cm) = create_test_handler_with_caps().await; + let resp = handler + .handle( + &shell_id, + inspect_request( + "elastos://inspect/revoke", + None, + Some(serde_json::json!({ "token_id": "not-hex" })), + ), + ) + .await; + match resp { + RuntimeResponse::Error { code, .. } => assert_eq!(code, "invalid_token_id"), + other => panic!("expected invalid_token_id, got {:?}", other), + } + } + fn inspect_request( uri: &str, token: Option, From f6c89b40ba13f108772ce579e2d7010add774760 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 03:51:30 +0000 Subject: [PATCH 06/31] feat(inspect): Carrier-shaped host adapter + two-transport architecture doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a Carrier/Principles alignment review (#4, #9, #7, #12, #10). - UI bridge rewritten as a Carrier-shaped host adapter: one inspectInvoke(operation, payload) targeting the runtime's node-local control API (POST /api/provider/inspect/ + x-elastos-home-token), exactly as the library/browser capsules call providers. HTTP is the transport adapter BELOW the capsule contract (CARRIER.md "Where HTTP Fits" #1), not something the capsule "knows"; swapping transport needs no UI change. Degrades to sample data when no token/bridge is present. - docs: new "Transports & Carrier alignment" section recording the two transports (capsule carrier_invoke path — implemented/tested; browser control-API path — not yet wired) feeding ONE authority decision (crate::inspect), and the honest finding that the gateway dispatches via ProviderRegistry::send_raw with GatewayState holding only the registry — so the browser path needs inspect exposed as a registry provider, which also converges on one canonical path (#10) and lets handle_inspect retire. No runtime behaviour change; capsule/carrier path unchanged (278 lib tests still green). UI passes node --check. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- .../capsule-inspector/inspector/inspector.js | 84 ++++++++++++------- docs/CAPSULE_INSPECTOR.md | 36 ++++++++ 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/capsules/capsule-inspector/inspector/inspector.js b/capsules/capsule-inspector/inspector/inspector.js index a2786db8..019d46ec 100644 --- a/capsules/capsule-inspector/inspector/inspector.js +++ b/capsules/capsule-inspector/inspector/inspector.js @@ -9,30 +9,54 @@ "use strict"; // --------------------------------------------------------------------------- -// Live data source: elastos://inspect/* via the runtime bridge. -// Returns null when no bridge is available so the UI can fall back to samples. +// Host adapter for the inspect surface. +// +// The capsule-facing contract is Carrier-shaped (Principle #4): a provider +// scheme (`inspect`), an operation, and a payload. The transport underneath is +// an adapter *below* the capsule contract — here the runtime's node-local +// control API (CARRIER.md "Where HTTP Fits" #1), the same door the `library` +// and `browser` capsules use. Swapping that transport (postMessage, native +// host, in-process) must not change capsule code, so all calls go through one +// `inspectInvoke(operation, payload)`. // --------------------------------------------------------------------------- -// Mirrors the runtime ResourceRequest: a read against an `elastos://inspect/*` -// URI with params. The host bridge validates the capability token and routes -// to RequestHandler::handle_inspect. Returns null when no bridge is present. -async function inspectRead(uri, params) { - const bridge = window.elastos && window.elastos.inspect; - if (!bridge || typeof bridge.invoke !== "function") return null; - try { - return await bridge.invoke(uri, "read", params || {}); - } catch (err) { - console.warn("inspect bridge error:", err); - return null; +const HOME_TOKEN = new URLSearchParams(globalThis.location?.search || "").get("home_token") || ""; + +async function inspectInvoke(operation, payload) { + // Prefer an injected host bridge (native/postMessage) when present. + const bridge = globalThis.elastos && globalThis.elastos.inspect; + if (bridge && typeof bridge.invoke === "function") { + return await bridge.invoke("elastos://inspect", operation, payload || {}); + } + // Otherwise use the node-local control API adapter, exactly as library does: + // POST /api/provider/inspect/ with the signed home launch token. The + // gateway derives identity from that token (never from page input) and maps + // the authenticated app to an inspect scope before dispatching. + if (!HOME_TOKEN) return null; + const res = await fetch("/api/provider/inspect/" + encodeURIComponent(operation), { + method: "POST", + headers: { "content-type": "application/json", "x-elastos-home-token": HOME_TOKEN }, + body: JSON.stringify(payload || {}), + }); + const envelope = await res.json().catch(() => ({})); + if (!res.ok || envelope.status === "error") { + throw new Error( + envelope.message || envelope.error || `inspect ${operation} failed: ${res.status}` + ); } + return envelope.data || envelope; } async function loadCapsuleList() { - const live = await inspectRead("elastos://inspect/capsules", {}); - if (live && Array.isArray(live.capsules)) { - setSourceBadge(true); - // Scope is reported by the runtime handler ("system" | "self"). - setScopeBadge(live.scope || "system"); - return live.capsules; + try { + const live = await inspectInvoke("capsules", {}); + if (live && Array.isArray(live.capsules)) { + setSourceBadge(true); + // Scope is reported by the runtime ("system" | "self"). + setScopeBadge(live.scope || "system"); + return live.capsules; + } + } catch (err) { + console.warn("inspect capsules failed, showing sample:", err); } setSourceBadge(false); // Sample data illustrates the privileged System view. @@ -43,21 +67,21 @@ async function loadCapsuleList() { } async function loadCapsuleDetail(id) { - const live = await inspectRead("elastos://inspect/capsule", { id }); - if (live && live.id) return live; + try { + const live = await inspectInvoke("capsule", { id }); + if (live && live.id) return live; + } catch (err) { + console.warn("inspect capsule failed, showing sample:", err); + } return SAMPLE_DATA.find((c) => c.id === id) || null; } -// Phase 2 (write): revoke a capability by token id. This is a System-admin -// mutation gated by a *write* inspect capability (`elastos://inspect/*` with -// the write action). It is intentionally NOT driven from the read view above — -// read summaries never carry bearer token ids (Principle #16). A dedicated -// System admin surface supplies the token id and a write-scoped token. +// Phase 2 (write): revoke a capability by token id. A System-admin mutation +// requiring a *write* inspect capability. Intentionally NOT driven from the +// read view — read summaries never carry bearer token ids (Principle #16); a +// dedicated System admin surface supplies the id and a write-scoped token. async function inspectRevoke(tokenId) { - const bridge = window.elastos && window.elastos.inspect; - if (!bridge || typeof bridge.invoke !== "function") return null; - // Mutating call: the host bridge must attach a write-scoped inspect token. - return await bridge.invoke("elastos://inspect/revoke", "write", { token_id: tokenId }); + return await inspectInvoke("revoke", { token_id: tokenId }); } // --------------------------------------------------------------------------- diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index 06bb7f70..9e7dd0f5 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -143,6 +143,42 @@ This branch contains the Phase-1 starter: the live handler when present. - This doc — the architecture, security model, contract, and phasing. +## Transports & Carrier alignment + +Per Principle #4 and `CARRIER.md`, the **capsule-facing contract is +Carrier-shaped**: a target (`elastos://inspect`), an operation, a payload, a +capability, and audit. The transport *underneath* is an adapter **below** the +capsule contract — `CARRIER.md` "Where HTTP Fits" explicitly classifies the +node-local HTTP control API as control-plane plumbing, *not* the Carrier +substrate, and Principle #4 lists "local loopback, HTTP, WebSocket, +postMessage, stdio, or in-process calls" as host adapters below the contract. +So using HTTP as the *transport* is aligned; what would violate alignment is a +capsule that *knows host routes*. The Inspector UI never does: all calls go +through one Carrier-shaped `inspectInvoke(operation, payload)`; swapping the +transport requires no UI change. + +There are two transports to the **one** authority decision +(`crate::inspect::InspectScope`), satisfying Principle #7 (every path enforces +the same authority boundary): + +| Caller | Transport (adapter) | Front door | Identity / scope | +| --- | --- | --- | --- | +| WASM / microVM capsule, agent | serial Carrier bridge → `carrier_invoke` | `RequestHandler::handle_inspect` | capability token → scope | +| Browser-hosted UI (this capsule) | node-local control API (`POST /api/provider/inspect/` + `x-elastos-home-token`) | gateway → provider registry | signed home launch token → app → scope | + +**Status (Principle #12 honesty):** the capsule/`carrier_invoke` path is +implemented and tested (`RequestHandler::handle_inspect`). The browser path is +**not yet wired in the runtime**: the gateway dispatches `/api/provider//` +via `ProviderRegistry::send_raw`, and `GatewayState` holds only the provider +registry (no `CapsuleManager`/`AuditLog`). So serving the browser Inspector +requires exposing inspect as a **registry provider** (`elastos://inspect/*`) +that holds `CapsuleManager` + `AuditLog` and delegates the decision to +`crate::inspect`. That provider would also let `RequestHandler::route_to_provider` +reach the same code, converging on **one canonical path** (Principle #10) — at +which point the bespoke `handle_inspect` intercept can retire. The UI adapter +above already targets this `/api/provider/inspect/` contract and degrades +to sample data until the provider exists. + ## Wire contract: `elastos://inspect/*` (read-only) All operations are `read`. Responses are JSON. Every response is **filtered by From dfa70f453ee264fae3b6acf88e526aed2cf2ba3d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 04:09:51 +0000 Subject: [PATCH 07/31] =?UTF-8?q?docs(inspect):=20correct=20host-bridge=20?= =?UTF-8?q?plan=20=E2=80=94=20gateway=20cannot=20reach=20CapsuleManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracing the real wiring before building disproved the recommended plan. There is no single capsule world: elastos-runtime::CapsuleManager (rich, reached by RequestHandler::handle_inspect), elastos-server::Runtime/RunningCapsuleInfo (thin), and the gateway (only Arc). elastos-server has zero CapsuleManager references; the gateway is started with only the registry. Therefore: a supervisor-registered provider cannot hold the managers; the handle_inspect intercept must not be retired (it is the only rich path); and "one canonical path" is a cross-process bridging effort, not a provider registration. Doc updated with evidence and three owner-decision options (registry view now / keep capsule-agent path / bridge gateway->runtime). No code changed; the tested capsule/agent inspect path is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/CAPSULE_INSPECTOR.md | 52 ++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index 9e7dd0f5..2394bdec 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -166,18 +166,48 @@ the same authority boundary): | WASM / microVM capsule, agent | serial Carrier bridge → `carrier_invoke` | `RequestHandler::handle_inspect` | capability token → scope | | Browser-hosted UI (this capsule) | node-local control API (`POST /api/provider/inspect/` + `x-elastos-home-token`) | gateway → provider registry | signed home launch token → app → scope | -**Status (Principle #12 honesty):** the capsule/`carrier_invoke` path is +**Status (Principle #12 honesty).** The capsule/`carrier_invoke` path is implemented and tested (`RequestHandler::handle_inspect`). The browser path is -**not yet wired in the runtime**: the gateway dispatches `/api/provider//` -via `ProviderRegistry::send_raw`, and `GatewayState` holds only the provider -registry (no `CapsuleManager`/`AuditLog`). So serving the browser Inspector -requires exposing inspect as a **registry provider** (`elastos://inspect/*`) -that holds `CapsuleManager` + `AuditLog` and delegates the decision to -`crate::inspect`. That provider would also let `RequestHandler::route_to_provider` -reach the same code, converging on **one canonical path** (Principle #10) — at -which point the bespoke `handle_inspect` intercept can retire. The UI adapter -above already targets this `/api/provider/inspect/` contract and degrades -to sample data until the provider exists. +**not yet wired, and is not a simple "add a provider" away** — a deeper +architecture finding blocks the naive convergence: + +There is **no single capsule world**. Three separate domains exist, and the +gateway can reach none of the rich one: + +| Domain | Holds | Reached by | Capsule detail | +| --- | --- | --- | --- | +| `elastos-runtime::CapsuleManager` | full manifest, capabilities, trust, audit | `RequestHandler::handle_inspect` | rich (the 9 fields) | +| `elastos-server::Runtime` (`RunningCapsuleInfo`) | id, name, status, type | the supervisor | thin | +| **Gateway** (browser front door) | **only `Arc`** | the browser UI | none (provider schemes) | + +Evidence: `elastos-server` has zero references to `CapsuleManager`; the gateway +is started with only the registry (`supervisor.rs` → `start_gateway_server(addr, +Some(registry), …)`); `GatewayState` holds only `provider_registry`. + +Consequences for the plan: + +- A registry provider registered **in the supervisor cannot hold a + `CapsuleManager`/`AuditLog`** — they don't exist in that process. +- The `handle_inspect` intercept **must not be retired**: it is the only path + that reaches the rich `CapsuleManager` inspect (serving capsules/agents via + the IO/carrier bridge). +- "One canonical path" (#10) is a real cross-process bridging effort, not a + provider registration. + +Open options (owner decision): + +1. **Registry view (feasible now):** a browser-facing inspect that enumerates + what the gateway actually has — registered providers/schemes and their + status from `ProviderRegistry`. Honest but a *services* view, not the rich + capsule view. +2. **Keep rich inspect as the capsule/agent path** (already built/tested) and + defer the browser bridge. +3. **Bridge gateway → runtime** so the browser can reach the rich + `CapsuleManager` inspect (the real convergence; needs the process-topology + decision first). + +The UI adapter targets the Carrier-shaped `inspect/` contract and degrades +to sample data until one of these lands. ## Wire contract: `elastos://inspect/*` (read-only) From 261d77a5f53fff5c3190e217d24a066d251667b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 04:56:14 +0000 Subject: [PATCH 08/31] feat(inspect): product-side inspect provider on the shared ProviderRegistry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the inspect surface where the PC2 product actually runs. Both product transports (capsule carrier_bridge and the browser gateway) dispatch through ProviderRegistry::send_raw, so one `inspect` scheme provider serves both — the real one-canonical-path convergence (#10). - elastos-server::inspect_provider: Provider impl (scheme "inspect") with ops capsules (System list) and capsule (System detail). Rich nine-field projection from the manifest; reuses elastos_runtime::inspect::InspectScope. Data read via an InspectSource trait (impl for runtime::Runtime), decoupling the provider from the server's capsule tracking. 4 unit tests incl. the #16 no-leak guarantee (raw signature / bearer token never echoed). - runtime::RunningCapsuleInfo retains the launch manifest (one construction site updated) so the rich projection has real data. - serve_cmd registers the provider on the shared registry (the one used by the carrier bridge AND handed to the supervisor/gateway) on the single-VM serve path, where running_capsules is populated — so it works end-to-end there. Honest gap (documented): the multi-capsule/browser path does not populate runtime::running_capsules, so inspect returns [] there until the server's capsule tracking is unified into InspectSource. Projection/scope/no-leak/ transport wiring are done; data-source unification is the next step. Verified: cargo test -p elastos-server inspect_provider — 4 passed; full crate compiles clean (730 other tests unaffected by the RunningCapsuleInfo change). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/CAPSULE_INSPECTOR.md | 41 +- .../elastos-server/src/inspect_provider.rs | 358 ++++++++++++++++++ elastos/crates/elastos-server/src/lib.rs | 1 + elastos/crates/elastos-server/src/runtime.rs | 5 + .../crates/elastos-server/src/serve_cmd.rs | 17 + 5 files changed, 410 insertions(+), 12 deletions(-) create mode 100644 elastos/crates/elastos-server/src/inspect_provider.rs diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index 2394bdec..d8ee0f7c 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -194,20 +194,37 @@ Consequences for the plan: - "One canonical path" (#10) is a real cross-process bridging effort, not a provider registration. -Open options (owner decision): - -1. **Registry view (feasible now):** a browser-facing inspect that enumerates - what the gateway actually has — registered providers/schemes and their - status from `ProviderRegistry`. Honest but a *services* view, not the rich - capsule view. -2. **Keep rich inspect as the capsule/agent path** (already built/tested) and - defer the browser bridge. -3. **Bridge gateway → runtime** so the browser can reach the rich - `CapsuleManager` inspect (the real convergence; needs the process-topology - decision first). +### Product-side provider (built) + +The product home of inspect is now `elastos-server::inspect_provider` — an +`inspect` scheme provider on the **shared `ProviderRegistry`** that both product +transports dispatch through (`carrier_bridge` for capsules, the gateway for the +browser), so one provider serves both — the real one-canonical-path +convergence. It reuses `elastos_runtime::inspect::InspectScope` for the scope +label and enforces the #16 no-leak guarantee (proven by test: +`capsule_detail_renders_contract_without_leaking_authority`). Capsule data is +read through an `InspectSource` trait (implemented for `runtime::Runtime`) so +the provider is decoupled from where the server tracks capsules. + +Ops implemented: `capsules` (System list), `capsule` (System detail, rich +nine-field projection from the retained manifest). Deferred: `self` (needs +caller-identity injection) and `revoke` (needs the gateway capability plane). + +**Wired + populated:** the single-VM-capsule serve path +(`elastos serve `) registers the provider on the shared registry and +its `running_capsules` source is populated, so inspect works end-to-end there. + +**Remaining data-source work (the honest gap):** the multi-capsule product +path does not populate `runtime::running_capsules` (capsules there are launched +as registry providers or supervisor/shell processes, and `register_capsule` is +only called on the single-VM path). So on the main/browser path the provider +returns an empty list until the server's capsule tracking is unified — e.g. +aggregating `ProviderRegistry` schemes and supervisor-launched capsules into +`InspectSource`. That unification is the next concrete step; the projection, +scope, no-leak, and transport wiring are done. The UI adapter targets the Carrier-shaped `inspect/` contract and degrades -to sample data until one of these lands. +to sample data until the data source is populated on the browser path. ## Wire contract: `elastos://inspect/*` (read-only) diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs new file mode 100644 index 00000000..1b985419 --- /dev/null +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -0,0 +1,358 @@ +//! Capsule Inspector provider (`elastos://inspect/*`). +//! +//! This is the product-side home of the Capsule Inspector. Both product +//! transports — the browser gateway and the capsule `carrier_invoke` bridge — +//! dispatch resource calls through `ProviderRegistry::send_raw(scheme, …)`, so +//! a single provider registered for the `inspect` scheme serves both (the +//! "one canonical path", Principle #10). +//! +//! The authority *decision* is the shared, transport-agnostic core in +//! `elastos_runtime::inspect` (`InspectScope`). Access is gated upstream — the +//! gateway app-allow-list for the browser, the capability-resource contract for +//! capsules — exactly as every other provider scheme is gated; this provider is +//! a read-only projection over runtime-owned state. +//! +//! Security (Principle #16): the projection allow-lists safe fields and never +//! echoes a bearer token, a raw signature, or any mutation handle. The raw +//! manifest `signature` is reduced to `signature_present`. +//! +//! Data is read through the [`InspectSource`] trait so the provider is +//! decoupled from where the server tracks capsules; `runtime::Runtime` +//! implements it. + +use std::sync::Weak; + +use async_trait::async_trait; +use elastos_common::CapsuleManifest; +use elastos_runtime::inspect::InspectScope; +use elastos_runtime::provider::{Provider, ProviderError, ResourceRequest, ResourceResponse}; +use serde_json::{json, Value}; + +/// One inspectable capsule, as seen by the provider. +#[derive(Debug, Clone)] +pub struct InspectEntry { + pub id: String, + pub name: String, + pub status: String, + pub capsule_type: String, + /// Manifest the capsule was launched with, when retained. + pub manifest: Option, +} + +/// Read-only source of inspectable capsules. Decouples the provider from the +/// server's (currently fragmented) capsule tracking. +#[async_trait] +pub trait InspectSource: Send + Sync { + async fn inspect_list(&self) -> Vec; + async fn inspect_get(&self, id: &str) -> Option; +} + +pub struct InspectProvider { + source: Weak, +} + +impl InspectProvider { + pub fn new(source: Weak) -> Self { + Self { source } + } + + fn scope_label(scope: InspectScope) -> &'static str { + match scope { + InspectScope::System => "system", + InspectScope::SelfOnly => "self", + } + } + + /// Project a capsule into the inspector wire contract (see + /// docs/CAPSULE_INSPECTOR.md). Read-only; unknown fields are null rather + /// than fabricated, and no bearer token / raw signature is ever included. + fn project(entry: &InspectEntry) -> Value { + fn field(v: &Value, key: &str) -> Value { + v.get(key).cloned().unwrap_or(Value::Null) + } + + // Serialize the manifest once and pick allow-listed fields. The raw + // `signature` is deliberately *not* surfaced — only its presence. + let manifest = entry + .manifest + .as_ref() + .and_then(|m| serde_json::to_value(m).ok()) + .unwrap_or_else(|| json!({})); + + let signature_present = manifest + .get("signature") + .map(|s| s.is_string()) + .unwrap_or(false); + + // Affordances: flatten declared interface methods. + let mut affordances = Vec::new(); + if let Some(interfaces) = manifest.get("interfaces").and_then(|v| v.as_array()) { + for iface in interfaces { + let iface_id = field(iface, "id"); + if let Some(methods) = iface.get("methods").and_then(|v| v.as_array()) { + for m in methods { + affordances.push(json!({ + "interface": iface_id, + "id": field(m, "id"), + "risk": field(m, "risk"), + "approval": field(m, "approval"), + "audit": field(m, "audit"), + "description": field(m, "description"), + })); + } + } + } + } + + json!({ + "id": entry.id, + "name": entry.name, + "version": field(&manifest, "version"), + "role": field(&manifest, "role"), + "type": field(&manifest, "type"), + "description": field(&manifest, "description"), + "author": field(&manifest, "author"), + "identity": { + "did": Value::Null, + "cid": Value::Null, + "trust_level": Value::Null, + "signature_present": signature_present, + "signed_by": Value::Null, + }, + "manifest": { + "schema": field(&manifest, "schema"), + "entrypoint": field(&manifest, "entrypoint"), + }, + "affordances": affordances, + "required_capabilities": field(&manifest, "capabilities"), + // Bearer-token object-capabilities have no central per-capsule grant + // registry; observed grants come from the audit plane (not yet wired + // into this provider). Empty rather than fabricated. + "granted_capabilities": Value::Array(vec![]), + "storage_namespaces": manifest + .pointer("/permissions/storage") + .cloned() + .unwrap_or(Value::Null), + "carrier": { + "enabled": manifest.pointer("/permissions/carrier").cloned().unwrap_or(Value::Null), + "endpoints": [], + "peers": 0, + }, + "provenance": { + "signed_by": Value::Null, + "version": field(&manifest, "version"), + "installed_at": Value::Null, + "cid": Value::Null, + "signature_present": signature_present, + }, + "audit": { "counts": { "total": 0, "denied": 0 }, "recent": [] }, + "processes": [{ "kind": entry.capsule_type, "status": entry.status }], + }) + } + + async fn handle_op(&self, request: &Value) -> Value { + let source = match self.source.upgrade() { + Some(s) => s, + None => return provider_error("unavailable", "inspect source is gone"), + }; + + match request.get("op").and_then(Value::as_str).unwrap_or("") { + // System-scope list. Upstream (gateway allow-list / capability + // contract) gates who may reach this op. + "capsules" => { + let entries = source.inspect_list().await; + let capsules: Vec = entries + .iter() + .map(|e| { + json!({ + "id": e.id, + "name": e.name, + "role": e.manifest.as_ref().map(|m| serde_json::to_value(&m.role).ok()), + "type": e.capsule_type, + "state": e.status, + }) + }) + .collect(); + json!({ + "status": "ok", + "data": { "scope": Self::scope_label(InspectScope::System), "capsules": capsules }, + }) + } + // System-scope detail. + "capsule" => match request.get("id").and_then(Value::as_str) { + Some(id) => match source.inspect_get(id).await { + Some(entry) => json!({ "status": "ok", "data": Self::project(&entry) }), + None => provider_error("not_found", "no such capsule"), + }, + None => provider_error("invalid_request", "inspect/capsule requires an \"id\""), + }, + other => provider_error("unknown_op", &format!("unknown inspect op: {other}")), + } + } +} + +#[async_trait] +impl Provider for InspectProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "inspect provider uses raw operations; route via send_raw".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["inspect"] + } + + fn name(&self) -> &'static str { + "inspect-provider" + } + + async fn send_raw(&self, request: &Value) -> Result { + Ok(self.handle_op(request).await) + } +} + +fn provider_error(code: &str, message: &str) -> Value { + json!({ "status": "error", "code": code, "message": message }) +} + +/// The server's running-capsule registry is one inspect source. (Browser-hosted +/// apps and registered provider schemes are additional sources to aggregate as +/// the server's capsule tracking is unified.) +#[async_trait] +impl InspectSource for crate::runtime::Runtime { + async fn inspect_list(&self) -> Vec { + self.list_capsules() + .await + .into_iter() + .map(running_to_entry) + .collect() + } + + async fn inspect_get(&self, id: &str) -> Option { + self.get_capsule(id).await.map(running_to_entry) + } +} + +fn running_to_entry(info: crate::runtime::RunningCapsuleInfo) -> InspectEntry { + InspectEntry { + id: info.id, + name: info.name, + status: info.status, + capsule_type: format!("{:?}", info.capsule_type).to_lowercase(), + manifest: Some(*info.manifest), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + fn probe_manifest() -> CapsuleManifest { + serde_json::from_value(json!({ + "schema": "elastos.capsule/v1", + "version": "0.1.0", + "name": "probe", + "role": "app", + "type": "wasm", + "entrypoint": "probe.wasm", + "capabilities": ["elastos://storage/probe"], + "interfaces": [{ + "id": "elastos.probe/v1", + "version": "1", + "methods": [{ "id": "ping", "risk": "read", "approval": "none", "audit": "summary" }] + }], + "permissions": { "storage": ["localhost://WebSpaces/probe/"] }, + "signature": "SECRET_SIGNATURE_MUST_NOT_LEAK" + })) + .expect("probe manifest deserializes") + } + + struct MockSource { + entries: Vec, + } + + #[async_trait] + impl InspectSource for MockSource { + async fn inspect_list(&self) -> Vec { + self.entries.clone() + } + async fn inspect_get(&self, id: &str) -> Option { + self.entries.iter().find(|e| e.id == id).cloned() + } + } + + fn provider_with_probe() -> (InspectProvider, Arc) { + let source: Arc = Arc::new(MockSource { + entries: vec![InspectEntry { + id: "cap_probe_1".to_string(), + name: "probe".to_string(), + status: "running".to_string(), + capsule_type: "wasm".to_string(), + manifest: Some(probe_manifest()), + }], + }); + // Keep the Arc alive in the caller; provider holds a Weak. + (InspectProvider::new(Arc::downgrade(&source)), source) + } + + #[tokio::test] + async fn capsules_lists_with_system_scope() { + let (provider, _src) = provider_with_probe(); + let resp = provider + .send_raw(&json!({ "op": "capsules" })) + .await + .unwrap(); + assert_eq!(resp["status"], "ok"); + assert_eq!(resp["data"]["scope"], "system"); + assert_eq!(resp["data"]["capsules"][0]["id"], "cap_probe_1"); + assert_eq!(resp["data"]["capsules"][0]["name"], "probe"); + } + + #[tokio::test] + async fn capsule_detail_renders_contract_without_leaking_authority() { + let (provider, _src) = provider_with_probe(); + let resp = provider + .send_raw(&json!({ "op": "capsule", "id": "cap_probe_1" })) + .await + .unwrap(); + assert_eq!(resp["status"], "ok"); + let data = &resp["data"]; + assert_eq!(data["affordances"][0]["id"], "ping"); + assert_eq!(data["affordances"][0]["risk"], "read"); + assert_eq!(data["required_capabilities"][0], "elastos://storage/probe"); + assert_eq!(data["storage_namespaces"][0], "localhost://WebSpaces/probe/"); + assert_eq!(data["identity"]["signature_present"], true); + + // Principle #16: never echo the raw signature or any bearer token. + let serialized = serde_json::to_string(data).unwrap(); + assert!( + !serialized.contains("SECRET_SIGNATURE_MUST_NOT_LEAK"), + "raw signature leaked into inspect output" + ); + assert!( + !serialized.contains("\"token\""), + "bearer token field leaked into inspect output" + ); + } + + #[tokio::test] + async fn capsule_detail_unknown_id_is_not_found() { + let (provider, _src) = provider_with_probe(); + let resp = provider + .send_raw(&json!({ "op": "capsule", "id": "nope" })) + .await + .unwrap(); + assert_eq!(resp["status"], "error"); + assert_eq!(resp["code"], "not_found"); + } + + #[tokio::test] + async fn unknown_op_is_rejected() { + let (provider, _src) = provider_with_probe(); + let resp = provider.send_raw(&json!({ "op": "revoke" })).await.unwrap(); + assert_eq!(resp["status"], "error"); + assert_eq!(resp["code"], "unknown_op"); + } +} diff --git a/elastos/crates/elastos-server/src/lib.rs b/elastos/crates/elastos-server/src/lib.rs index b1fa0cdf..873d8509 100644 --- a/elastos/crates/elastos-server/src/lib.rs +++ b/elastos/crates/elastos-server/src/lib.rs @@ -18,6 +18,7 @@ pub mod fetcher; pub mod gateway_cmd; pub mod host_lock; pub mod init; +pub mod inspect_provider; pub mod ipfs; pub mod library; pub mod local_http; diff --git a/elastos/crates/elastos-server/src/runtime.rs b/elastos/crates/elastos-server/src/runtime.rs index 7a0ade6c..c731f52f 100644 --- a/elastos/crates/elastos-server/src/runtime.rs +++ b/elastos/crates/elastos-server/src/runtime.rs @@ -24,6 +24,11 @@ pub struct RunningCapsuleInfo { pub status: String, /// Capsule type (WASM, MicroVM) pub capsule_type: CapsuleType, + /// Manifest the capsule was launched with. Retained so introspection + /// surfaces (the inspect provider) can project capabilities, affordances, + /// and provenance without re-reading capsule.json. Boxed to keep the + /// struct lean. + pub manifest: Box, /// Handle for stopping the capsule (optional, not all capsules have handles) pub handle: Option, } diff --git a/elastos/crates/elastos-server/src/serve_cmd.rs b/elastos/crates/elastos-server/src/serve_cmd.rs index a09ae9fc..e415f44e 100644 --- a/elastos/crates/elastos-server/src/serve_cmd.rs +++ b/elastos/crates/elastos-server/src/serve_cmd.rs @@ -157,6 +157,7 @@ pub async fn run_serve( name: handle.manifest.name.clone(), status: "running".to_string(), capsule_type: handle.manifest.capsule_type.clone(), + manifest: Box::new(handle.manifest.clone()), handle: Some(handle.clone()), }; runtime_arc.register_capsule(capsule_info).await; @@ -192,6 +193,22 @@ pub async fn run_serve( let identity_state = infra.identity_state; let host_helpers = infra.host_helpers; + // Register the read-only Capsule Inspector on the shared + // provider registry — the one reached by both the API/gateway + // and the capsule carrier bridge. Its data source is this + // runtime's running-capsule registry. + { + let source: Arc = + runtime_arc.clone(); + provider_registry + .register(Arc::new( + elastos_server::inspect_provider::InspectProvider::new( + Arc::downgrade(&source), + ), + )) + .await; + } + let api_handle = tokio::spawn({ let runtime = runtime_arc.clone(); let session_registry = session_registry.clone(); From 9c8078a6c06c6433b5cd2e4ab190a8683c2d25a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 05:03:33 +0000 Subject: [PATCH 09/31] feat(inspect): unify inspect data sources; browser path now lists services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composable InspectSource so the main/browser product path shows real capsules, not an empty list. - RuntimeInspectSource: the server Runtime's running-capsule registry (rich, manifest-backed; populated on the single-VM serve path). - RegistryInspectSource: registered provider schemes from ProviderRegistry (the running provider services; always populated on the main path). Thin — id = provider:, manifest None. - AggregateInspectSource: unions sources, de-dups by id — the unification. - Provider now holds a strong Arc; each source holds a Weak reference to its backing object, so registering on the registry never forms a reference cycle. - serve_cmd: single-VM path wires RuntimeInspectSource; main path wires Aggregate[Runtime, Registry] on the shared registry the supervisor/gateway use — so the browser Inspector lists running services end-to-end. Honest remaining enrichment (documented): provider-scheme entries are thin (registry carries no per-provider manifest; schemes() omits sub-providers). Full nine-field provider detail needs a catalog source reading capsule.json. Verified: cargo test -p elastos-server inspect_provider — 6 passed (incl. registry + aggregate + no-leak); lib + binary compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/CAPSULE_INSPECTOR.md | 39 ++- .../elastos-server/src/inspect_provider.rs | 280 +++++++++++++----- .../crates/elastos-server/src/serve_cmd.rs | 31 +- 3 files changed, 265 insertions(+), 85 deletions(-) diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index d8ee0f7c..00e17b1a 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -210,18 +210,33 @@ Ops implemented: `capsules` (System list), `capsule` (System detail, rich nine-field projection from the retained manifest). Deferred: `self` (needs caller-identity injection) and `revoke` (needs the gateway capability plane). -**Wired + populated:** the single-VM-capsule serve path -(`elastos serve `) registers the provider on the shared registry and -its `running_capsules` source is populated, so inspect works end-to-end there. - -**Remaining data-source work (the honest gap):** the multi-capsule product -path does not populate `runtime::running_capsules` (capsules there are launched -as registry providers or supervisor/shell processes, and `register_capsule` is -only called on the single-VM path). So on the main/browser path the provider -returns an empty list until the server's capsule tracking is unified — e.g. -aggregating `ProviderRegistry` schemes and supervisor-launched capsules into -`InspectSource`. That unification is the next concrete step; the projection, -scope, no-leak, and transport wiring are done. +**Data sources (unified via `InspectSource`).** The provider reads through the +`InspectSource` trait; sources are composable: + +- `RuntimeInspectSource` — the server `Runtime`'s running-capsule registry + (capsules launched with a retained manifest → rich detail). Populated on the + single-VM serve path. +- `RegistryInspectSource` — the registered provider schemes from + `ProviderRegistry` (the running provider capsules/services). Always populated + on the main product path. Thin (`id = provider:`, no manifest yet). +- `AggregateInspectSource` — unions sources and de-dups by id. + +**Wired on both serve paths:** + +- Single-VM serve (`elastos serve `): `RuntimeInspectSource` → rich, + populated end-to-end. +- Main product path: `Aggregate[RuntimeInspectSource, RegistryInspectSource]` + on the shared registry the supervisor/gateway use — so the **browser + Inspector now lists the running provider services**. + +**Remaining enrichment (honest gap):** provider-scheme entries are thin +(`manifest = None`) because the registry does not carry per-provider manifests, +and `ProviderRegistry::schemes()` lists main providers only (sub-providers and +their manifests are not yet enumerated). Restoring the full nine-field detail +for provider capsules means adding a catalog-backed source that reads each +installed capsule's `capsule.json`. The projection, scope, no-leak, transport +wiring, and source aggregation are done; richer provider metadata is the next +refinement. The UI adapter targets the Carrier-shaped `inspect/` contract and degrades to sample data until the data source is populated on the browser path. diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index 1b985419..41792af2 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -16,18 +16,21 @@ //! echoes a bearer token, a raw signature, or any mutation handle. The raw //! manifest `signature` is reduced to `signature_present`. //! -//! Data is read through the [`InspectSource`] trait so the provider is -//! decoupled from where the server tracks capsules; `runtime::Runtime` -//! implements it. +//! Data is read through the [`InspectSource`] trait. The provider holds a +//! strong `Arc`; each concrete source holds only a `Weak` +//! reference to the heavy runtime object it reads from, so registering the +//! provider on the registry never creates a reference cycle. -use std::sync::Weak; +use std::sync::{Arc, Weak}; use async_trait::async_trait; use elastos_common::CapsuleManifest; use elastos_runtime::inspect::InspectScope; -use elastos_runtime::provider::{Provider, ProviderError, ResourceRequest, ResourceResponse}; +use elastos_runtime::provider::{Provider, ProviderError, ProviderRegistry, ResourceRequest, ResourceResponse}; use serde_json::{json, Value}; +use crate::runtime::Runtime; + /// One inspectable capsule, as seen by the provider. #[derive(Debug, Clone)] pub struct InspectEntry { @@ -40,19 +43,147 @@ pub struct InspectEntry { } /// Read-only source of inspectable capsules. Decouples the provider from the -/// server's (currently fragmented) capsule tracking. +/// server's (fragmented) capsule tracking, and lets sources be aggregated. #[async_trait] pub trait InspectSource: Send + Sync { async fn inspect_list(&self) -> Vec; async fn inspect_get(&self, id: &str) -> Option; } +// ── Sources ───────────────────────────────────────────────────────── + +/// Source backed by the server `Runtime`'s running-capsule registry (the +/// capsules launched with a retained manifest — e.g. the single-VM serve path). +pub struct RuntimeInspectSource { + runtime: Weak, +} + +impl RuntimeInspectSource { + pub fn new(runtime: Weak) -> Self { + Self { runtime } + } +} + +fn running_to_entry(info: crate::runtime::RunningCapsuleInfo) -> InspectEntry { + InspectEntry { + id: info.id, + name: info.name, + status: info.status, + capsule_type: format!("{:?}", info.capsule_type).to_lowercase(), + manifest: Some(*info.manifest), + } +} + +#[async_trait] +impl InspectSource for RuntimeInspectSource { + async fn inspect_list(&self) -> Vec { + match self.runtime.upgrade() { + Some(rt) => rt.list_capsules().await.into_iter().map(running_to_entry).collect(), + None => Vec::new(), + } + } + + async fn inspect_get(&self, id: &str) -> Option { + self.runtime.upgrade()?.get_capsule(id).await.map(running_to_entry) + } +} + +/// Source backed by the `ProviderRegistry`: the registered provider schemes +/// (the running provider capsules/services). Always populated on the main +/// product path. Thin — the registry does not carry per-provider manifests, so +/// these entries have no manifest (affordances/capabilities are empty until a +/// catalog/manifest source enriches them). +pub struct RegistryInspectSource { + registry: Weak, +} + +impl RegistryInspectSource { + pub fn new(registry: Weak) -> Self { + Self { registry } + } + + fn scheme_entry(scheme: String) -> InspectEntry { + InspectEntry { + id: format!("provider:{scheme}"), + name: scheme, + status: "running".to_string(), + capsule_type: "provider".to_string(), + manifest: None, + } + } +} + +#[async_trait] +impl InspectSource for RegistryInspectSource { + async fn inspect_list(&self) -> Vec { + match self.registry.upgrade() { + Some(reg) => { + let mut schemes = reg.schemes().await; + schemes.sort(); + schemes.into_iter().map(Self::scheme_entry).collect() + } + None => Vec::new(), + } + } + + async fn inspect_get(&self, id: &str) -> Option { + let scheme = id.strip_prefix("provider:")?; + let reg = self.registry.upgrade()?; + if reg.has_provider(scheme).await { + Some(Self::scheme_entry(scheme.to_string())) + } else { + None + } + } +} + +/// Aggregates several sources into one. This is the unification point: the main +/// product path composes the runtime source and the registry source so the +/// browser Inspector shows every capsule any source knows about. De-duplicates +/// by id (first source wins). +pub struct AggregateInspectSource { + sources: Vec>, +} + +impl AggregateInspectSource { + pub fn new(sources: Vec>) -> Self { + Self { sources } + } +} + +#[async_trait] +impl InspectSource for AggregateInspectSource { + async fn inspect_list(&self) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for source in &self.sources { + for entry in source.inspect_list().await { + if seen.insert(entry.id.clone()) { + out.push(entry); + } + } + } + out + } + + async fn inspect_get(&self, id: &str) -> Option { + for source in &self.sources { + if let Some(entry) = source.inspect_get(id).await { + return Some(entry); + } + } + None + } +} + +// ── Provider ──────────────────────────────────────────────────────── + pub struct InspectProvider { - source: Weak, + source: Arc, } impl InspectProvider { - pub fn new(source: Weak) -> Self { + pub fn new(source: Arc) -> Self { Self { source } } @@ -151,23 +282,20 @@ impl InspectProvider { } async fn handle_op(&self, request: &Value) -> Value { - let source = match self.source.upgrade() { - Some(s) => s, - None => return provider_error("unavailable", "inspect source is gone"), - }; - match request.get("op").and_then(Value::as_str).unwrap_or("") { // System-scope list. Upstream (gateway allow-list / capability // contract) gates who may reach this op. "capsules" => { - let entries = source.inspect_list().await; - let capsules: Vec = entries + let capsules: Vec = self + .source + .inspect_list() + .await .iter() .map(|e| { json!({ "id": e.id, "name": e.name, - "role": e.manifest.as_ref().map(|m| serde_json::to_value(&m.role).ok()), + "role": e.manifest.as_ref().and_then(|m| serde_json::to_value(&m.role).ok()), "type": e.capsule_type, "state": e.status, }) @@ -180,7 +308,7 @@ impl InspectProvider { } // System-scope detail. "capsule" => match request.get("id").and_then(Value::as_str) { - Some(id) => match source.inspect_get(id).await { + Some(id) => match self.source.inspect_get(id).await { Some(entry) => json!({ "status": "ok", "data": Self::project(&entry) }), None => provider_error("not_found", "no such capsule"), }, @@ -216,38 +344,9 @@ fn provider_error(code: &str, message: &str) -> Value { json!({ "status": "error", "code": code, "message": message }) } -/// The server's running-capsule registry is one inspect source. (Browser-hosted -/// apps and registered provider schemes are additional sources to aggregate as -/// the server's capsule tracking is unified.) -#[async_trait] -impl InspectSource for crate::runtime::Runtime { - async fn inspect_list(&self) -> Vec { - self.list_capsules() - .await - .into_iter() - .map(running_to_entry) - .collect() - } - - async fn inspect_get(&self, id: &str) -> Option { - self.get_capsule(id).await.map(running_to_entry) - } -} - -fn running_to_entry(info: crate::runtime::RunningCapsuleInfo) -> InspectEntry { - InspectEntry { - id: info.id, - name: info.name, - status: info.status, - capsule_type: format!("{:?}", info.capsule_type).to_lowercase(), - manifest: Some(*info.manifest), - } -} - #[cfg(test)] mod tests { use super::*; - use std::sync::Arc; fn probe_manifest() -> CapsuleManifest { serde_json::from_value(json!({ @@ -283,24 +382,23 @@ mod tests { } } - fn provider_with_probe() -> (InspectProvider, Arc) { - let source: Arc = Arc::new(MockSource { - entries: vec![InspectEntry { - id: "cap_probe_1".to_string(), - name: "probe".to_string(), - status: "running".to_string(), - capsule_type: "wasm".to_string(), - manifest: Some(probe_manifest()), - }], - }); - // Keep the Arc alive in the caller; provider holds a Weak. - (InspectProvider::new(Arc::downgrade(&source)), source) + fn probe_entry() -> InspectEntry { + InspectEntry { + id: "cap_probe_1".to_string(), + name: "probe".to_string(), + status: "running".to_string(), + capsule_type: "wasm".to_string(), + manifest: Some(probe_manifest()), + } + } + + fn provider_with_probe() -> InspectProvider { + InspectProvider::new(Arc::new(MockSource { entries: vec![probe_entry()] })) } #[tokio::test] async fn capsules_lists_with_system_scope() { - let (provider, _src) = provider_with_probe(); - let resp = provider + let resp = provider_with_probe() .send_raw(&json!({ "op": "capsules" })) .await .unwrap(); @@ -312,8 +410,7 @@ mod tests { #[tokio::test] async fn capsule_detail_renders_contract_without_leaking_authority() { - let (provider, _src) = provider_with_probe(); - let resp = provider + let resp = provider_with_probe() .send_raw(&json!({ "op": "capsule", "id": "cap_probe_1" })) .await .unwrap(); @@ -339,8 +436,7 @@ mod tests { #[tokio::test] async fn capsule_detail_unknown_id_is_not_found() { - let (provider, _src) = provider_with_probe(); - let resp = provider + let resp = provider_with_probe() .send_raw(&json!({ "op": "capsule", "id": "nope" })) .await .unwrap(); @@ -350,9 +446,61 @@ mod tests { #[tokio::test] async fn unknown_op_is_rejected() { - let (provider, _src) = provider_with_probe(); - let resp = provider.send_raw(&json!({ "op": "revoke" })).await.unwrap(); + let resp = provider_with_probe() + .send_raw(&json!({ "op": "revoke" })) + .await + .unwrap(); assert_eq!(resp["status"], "error"); assert_eq!(resp["code"], "unknown_op"); } + + #[tokio::test] + async fn registry_source_lists_registered_schemes() { + // A real registry with a registered provider scheme appears in inspect. + let registry = Arc::new(ProviderRegistry::new()); + registry.register(Arc::new(MockSchemeProvider)).await; + let source = RegistryInspectSource::new(Arc::downgrade(®istry)); + + let entries = source.inspect_list().await; + assert!(entries.iter().any(|e| e.name == "wallet" && e.id == "provider:wallet")); + assert!(source.inspect_get("provider:wallet").await.is_some()); + assert!(source.inspect_get("provider:nope").await.is_none()); + } + + #[tokio::test] + async fn aggregate_source_unions_and_dedups() { + let a: Arc = Arc::new(MockSource { entries: vec![probe_entry()] }); + let b: Arc = Arc::new(MockSource { + entries: vec![ + probe_entry(), // duplicate id — should be deduped + InspectEntry { + id: "provider:wallet".to_string(), + name: "wallet".to_string(), + status: "running".to_string(), + capsule_type: "provider".to_string(), + manifest: None, + }, + ], + }); + let agg = AggregateInspectSource::new(vec![a, b]); + let entries = agg.inspect_list().await; + assert_eq!(entries.len(), 2, "duplicate ids must be deduped"); + assert!(agg.inspect_get("provider:wallet").await.is_some()); + } + + // Minimal provider used only to register a "wallet" scheme in the registry. + struct MockSchemeProvider; + + #[async_trait] + impl Provider for MockSchemeProvider { + async fn handle(&self, _r: ResourceRequest) -> Result { + Err(ProviderError::Provider("unused".into())) + } + fn schemes(&self) -> Vec<&'static str> { + vec!["wallet"] + } + fn name(&self) -> &'static str { + "mock-wallet" + } + } } diff --git a/elastos/crates/elastos-server/src/serve_cmd.rs b/elastos/crates/elastos-server/src/serve_cmd.rs index e415f44e..3cd7660e 100644 --- a/elastos/crates/elastos-server/src/serve_cmd.rs +++ b/elastos/crates/elastos-server/src/serve_cmd.rs @@ -198,14 +198,11 @@ pub async fn run_serve( // and the capsule carrier bridge. Its data source is this // runtime's running-capsule registry. { - let source: Arc = - runtime_arc.clone(); + use elastos_server::inspect_provider as ip; + let source: Arc = + Arc::new(ip::RuntimeInspectSource::new(Arc::downgrade(&runtime_arc))); provider_registry - .register(Arc::new( - elastos_server::inspect_provider::InspectProvider::new( - Arc::downgrade(&source), - ), - )) + .register(Arc::new(ip::InspectProvider::new(source))) .await; } @@ -303,6 +300,26 @@ pub async fn run_serve( .await; let runtime = Arc::new(runtime); + + // Register the read-only Capsule Inspector on the shared provider registry + // (the same Arc handed to the supervisor/gateway and the carrier bridge). + // The source aggregates this runtime's running capsules with the registered + // provider schemes, so the browser Inspector shows what the product knows. + { + use elastos_server::inspect_provider as ip; + let runtime_src: Arc = + Arc::new(ip::RuntimeInspectSource::new(Arc::downgrade(&runtime))); + let registry_src: Arc = Arc::new(ip::RegistryInspectSource::new( + Arc::downgrade(&infra.provider_registry), + )); + let source: Arc = + Arc::new(ip::AggregateInspectSource::new(vec![runtime_src, registry_src])); + infra + .provider_registry + .register(Arc::new(ip::InspectProvider::new(source))) + .await; + } + let docs_dir = std::env::current_dir().ok().and_then(|d| { let docs = d.join(".."); if docs.join("ROADMAP.md").exists() { From df037025faa6a0b505dd42b21b3281fd715c2903 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 05:32:00 +0000 Subject: [PATCH 10/31] =?UTF-8?q?feat(inspect):=20CatalogInspectSource=20?= =?UTF-8?q?=E2=80=94=20rich,=20manifest-backed=20product=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the browser/product inspect from thin provider-scheme entries to the full nine-field view by reading installed capsule manifests from disk. - CatalogInspectSource reads /capsules//capsule.json, projects the full manifest (capabilities, affordances, provenance), and marks each capsule running when the scheme it `provides` is registered live, else installed. id = capsule:; path-traversal ids rejected. - serve_cmd main path now aggregates [RuntimeInspectSource, CatalogInspectSource] on the shared registry, so the browser Inspector shows installed capsules with real manifests + running status. - RegistryInspectSource kept as an available source (built-in schemes) but no longer the default; catalog supersedes it with rich data. Tests (+2, 8 total): catalog reads a manifest richly and never leaks the raw signature (#16); marks running when the provided scheme is registered; rejects path-traversal ids. Verified: cargo test -p elastos-server inspect_provider — 8 passed; lib + binary compile clean. Honest remaining: schemes() omits sub-providers (running-status may miss them); live audit/grant aggregation into the projection still pending. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/CAPSULE_INSPECTOR.md | 32 ++-- .../elastos-server/src/inspect_provider.rs | 163 +++++++++++++++++- .../crates/elastos-server/src/serve_cmd.rs | 11 +- 3 files changed, 188 insertions(+), 18 deletions(-) diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index 00e17b1a..ddf1bdc1 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -216,27 +216,33 @@ caller-identity injection) and `revoke` (needs the gateway capability plane). - `RuntimeInspectSource` — the server `Runtime`'s running-capsule registry (capsules launched with a retained manifest → rich detail). Populated on the single-VM serve path. +- `CatalogInspectSource` — the installed-capsule catalog on disk + (`/capsules//capsule.json`). Reads each capsule's **full + manifest** (rich nine-field detail) and marks it `running` when the scheme it + `provides` is registered live, else `installed`. This is the rich, + manifest-backed source for the product. (`id = capsule:`, with + path-traversal ids rejected.) - `RegistryInspectSource` — the registered provider schemes from - `ProviderRegistry` (the running provider capsules/services). Always populated - on the main product path. Thin (`id = provider:`, no manifest yet). + `ProviderRegistry` (thin, `id = provider:`). Available as a source + for built-in schemes that have no on-disk capsule; not in the default + aggregate. - `AggregateInspectSource` — unions sources and de-dups by id. **Wired on both serve paths:** - Single-VM serve (`elastos serve `): `RuntimeInspectSource` → rich, populated end-to-end. -- Main product path: `Aggregate[RuntimeInspectSource, RegistryInspectSource]` +- Main product path: `Aggregate[RuntimeInspectSource, CatalogInspectSource]` on the shared registry the supervisor/gateway use — so the **browser - Inspector now lists the running provider services**. - -**Remaining enrichment (honest gap):** provider-scheme entries are thin -(`manifest = None`) because the registry does not carry per-provider manifests, -and `ProviderRegistry::schemes()` lists main providers only (sub-providers and -their manifests are not yet enumerated). Restoring the full nine-field detail -for provider capsules means adding a catalog-backed source that reads each -installed capsule's `capsule.json`. The projection, scope, no-leak, transport -wiring, and source aggregation are done; richer provider metadata is the next -refinement. + Inspector lists installed capsules with their full manifests** and running + status. + +**Remaining enrichment (honest gap):** `ProviderRegistry::schemes()` lists main +providers only (sub-providers are not enumerated), so running-status detection +in the catalog source is by `provides`-scheme match and may miss sub-provider +schemes; and live audit/grant aggregation into the projection is still pending. +Projection, scope, no-leak, transport wiring, source aggregation, and the rich +manifest view are done. The UI adapter targets the Carrier-shaped `inspect/` contract and degrades to sample data until the data source is populated on the browser path. diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index 41792af2..65fea088 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -21,6 +21,7 @@ //! reference to the heavy runtime object it reads from, so registering the //! provider on the registry never creates a reference cycle. +use std::path::PathBuf; use std::sync::{Arc, Weak}; use async_trait::async_trait; @@ -137,8 +138,99 @@ impl InspectSource for RegistryInspectSource { } } +/// Source backed by the installed-capsule catalog on disk: +/// `/capsules//capsule.json`. Reads each capsule's full +/// manifest (rich detail: capabilities, affordances, provenance) and marks it +/// `running` when the scheme it `provides` is registered in the live registry, +/// else `installed`. This is the rich, manifest-backed source for the product. +pub struct CatalogInspectSource { + capsules_dir: PathBuf, + registry: Weak, +} + +impl CatalogInspectSource { + pub fn new(capsules_dir: PathBuf, registry: Weak) -> Self { + Self { capsules_dir, registry } + } + + /// The scheme a provider capsule serves, parsed from `provides` + /// (e.g. `elastos://wallet/*` → `wallet`). + fn provided_scheme(manifest: &CapsuleManifest) -> Option { + manifest + .provides + .as_ref()? + .strip_prefix("elastos://") + .and_then(|rest| rest.split('/').next()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + } + + async fn running_schemes(&self) -> std::collections::HashSet { + match self.registry.upgrade() { + Some(reg) => reg.schemes().await.into_iter().collect(), + None => std::collections::HashSet::new(), + } + } + + async fn read_entry( + &self, + name: &str, + running: &std::collections::HashSet, + ) -> Option { + let path = self.capsules_dir.join(name).join("capsule.json"); + let data = tokio::fs::read_to_string(&path).await.ok()?; + let manifest: CapsuleManifest = serde_json::from_str(&data).ok()?; + let is_running = Self::provided_scheme(&manifest) + .map(|s| running.contains(&s)) + .unwrap_or(false); + Some(InspectEntry { + id: format!("capsule:{name}"), + name: manifest.name.clone(), + status: if is_running { "running" } else { "installed" }.to_string(), + capsule_type: format!("{:?}", manifest.capsule_type).to_lowercase(), + manifest: Some(manifest), + }) + } +} + +#[async_trait] +impl InspectSource for CatalogInspectSource { + async fn inspect_list(&self) -> Vec { + let running = self.running_schemes().await; + let mut out = Vec::new(); + if let Ok(mut rd) = tokio::fs::read_dir(&self.capsules_dir).await { + while let Ok(Some(dir_entry)) = rd.next_entry().await { + let is_dir = dir_entry + .file_type() + .await + .map(|t| t.is_dir()) + .unwrap_or(false); + if !is_dir { + continue; + } + if let Some(name) = dir_entry.file_name().to_str() { + if let Some(entry) = self.read_entry(name, &running).await { + out.push(entry); + } + } + } + } + out + } + + async fn inspect_get(&self, id: &str) -> Option { + let name = id.strip_prefix("capsule:")?; + // Reject path traversal in the id. + if name.contains('/') || name.contains("..") { + return None; + } + let running = self.running_schemes().await; + self.read_entry(name, &running).await + } +} + /// Aggregates several sources into one. This is the unification point: the main -/// product path composes the runtime source and the registry source so the +/// product path composes the runtime, catalog, and/or registry sources so the /// browser Inspector shows every capsule any source knows about. De-duplicates /// by id (first source wins). pub struct AggregateInspectSource { @@ -488,6 +580,75 @@ mod tests { assert!(agg.inspect_get("provider:wallet").await.is_some()); } + fn write_capsule(capsules_dir: &std::path::Path, dir_name: &str, manifest: &Value) { + let dir = capsules_dir.join(dir_name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("capsule.json"), + serde_json::to_vec_pretty(manifest).unwrap(), + ) + .unwrap(); + } + + #[tokio::test] + async fn catalog_reads_installed_manifest_richly_without_leaking() { + let tmp = tempfile::tempdir().unwrap(); + let capsules_dir = tmp.path().join("capsules"); + write_capsule( + &capsules_dir, + "probe", + &serde_json::to_value(probe_manifest()).unwrap(), + ); + + let registry = Arc::new(ProviderRegistry::new()); + let source = CatalogInspectSource::new(capsules_dir, Arc::downgrade(®istry)); + + let entries = source.inspect_list().await; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, "capsule:probe"); + assert_eq!(entries[0].status, "installed"); // nothing registered + + // Detail is rich and never leaks the raw signature. + let provider = InspectProvider::new(Arc::new(source)); + let resp = provider + .send_raw(&json!({ "op": "capsule", "id": "capsule:probe" })) + .await + .unwrap(); + assert_eq!(resp["data"]["affordances"][0]["id"], "ping"); + assert_eq!(resp["data"]["required_capabilities"][0], "elastos://storage/probe"); + let serialized = serde_json::to_string(&resp).unwrap(); + assert!(!serialized.contains("SECRET_SIGNATURE_MUST_NOT_LEAK")); + } + + #[tokio::test] + async fn catalog_marks_running_when_provided_scheme_registered() { + let tmp = tempfile::tempdir().unwrap(); + let capsules_dir = tmp.path().join("capsules"); + write_capsule( + &capsules_dir, + "wallet-provider", + &json!({ + "schema": "elastos.capsule/v1", + "version": "0.1.0", + "name": "wallet-provider", + "role": "app", + "type": "wasm", + "entrypoint": "wallet.wasm", + "provides": "elastos://wallet/*" + }), + ); + + let registry = Arc::new(ProviderRegistry::new()); + registry.register(Arc::new(MockSchemeProvider)).await; // registers scheme "wallet" + let source = CatalogInspectSource::new(capsules_dir, Arc::downgrade(®istry)); + + let entry = source.inspect_get("capsule:wallet-provider").await.unwrap(); + assert_eq!(entry.status, "running"); + + // Path-traversal ids are rejected. + assert!(source.inspect_get("capsule:../etc").await.is_none()); + } + // Minimal provider used only to register a "wallet" scheme in the registry. struct MockSchemeProvider; diff --git a/elastos/crates/elastos-server/src/serve_cmd.rs b/elastos/crates/elastos-server/src/serve_cmd.rs index 3cd7660e..9ba3f9ce 100644 --- a/elastos/crates/elastos-server/src/serve_cmd.rs +++ b/elastos/crates/elastos-server/src/serve_cmd.rs @@ -303,17 +303,20 @@ pub async fn run_serve( // Register the read-only Capsule Inspector on the shared provider registry // (the same Arc handed to the supervisor/gateway and the carrier bridge). - // The source aggregates this runtime's running capsules with the registered - // provider schemes, so the browser Inspector shows what the product knows. + // The source aggregates this runtime's running capsules with the rich + // installed-capsule catalog (/capsules//capsule.json), so + // the browser Inspector shows the full manifest-backed view of what the + // product knows. { use elastos_server::inspect_provider as ip; let runtime_src: Arc = Arc::new(ip::RuntimeInspectSource::new(Arc::downgrade(&runtime))); - let registry_src: Arc = Arc::new(ip::RegistryInspectSource::new( + let catalog_src: Arc = Arc::new(ip::CatalogInspectSource::new( + data_dir.join("capsules"), Arc::downgrade(&infra.provider_registry), )); let source: Arc = - Arc::new(ip::AggregateInspectSource::new(vec![runtime_src, registry_src])); + Arc::new(ip::AggregateInspectSource::new(vec![runtime_src, catalog_src])); infra .provider_registry .register(Arc::new(ip::InspectProvider::new(source))) From 6fa942b41de45b310e7d73f40f8c790125b653d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 05:53:55 +0000 Subject: [PATCH 11/31] feat(inspect): sub-provider coverage + live per-capsule audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both remaining enrichments. Sub-provider coverage (#1): - ProviderRegistry::sub_provider_schemes() (elastos-runtime, additive) lists the elastos:// sub-provider schemes (did/key/peer/…) that schemes() omits. - RegistryInspectSource lists main + sub schemes; CatalogInspectSource's running-status detection now matches sub-provider-backed schemes too. Live per-capsule audit (#2): - AuditSource trait + AuthAuditSource: reads the signed runtime audit log (RuntimeAuditEventV1 in auth state), correlates by capsule_id (capsule name), and fills the detail view's audit section — recent events (newest-first, capped) + total/denied counts. Runs on a blocking task off the async workers. - InspectProvider gains an optional audit source (with_audit builder); project renders the live audit. Records expose only safe fields (ts/event/reason/ success) — no signatures or handles (#16). - serve_cmd wires AuthAuditSource(data_dir) on both serve paths. Honest remaining: granted_capabilities stays empty — bearer-token caps have no central registry and RuntimeAuditEventV1 has no resource/action, so observed grants need a capability-event source that records them. Verified: cargo test -p elastos-server inspect_provider — 10 passed (incl. live audit + sub-provider tests, #16 no-leak preserved); cargo test -p elastos-runtime --lib — 278 passed (additive registry method, no regression). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/CAPSULE_INSPECTOR.md | 28 ++- .../elastos-runtime/src/provider/registry.rs | 8 + .../elastos-server/src/inspect_provider.rs | 183 ++++++++++++++++-- .../crates/elastos-server/src/serve_cmd.rs | 10 +- 4 files changed, 207 insertions(+), 22 deletions(-) diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index ddf1bdc1..c27aec22 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -223,11 +223,20 @@ caller-identity injection) and `revoke` (needs the gateway capability plane). manifest-backed source for the product. (`id = capsule:`, with path-traversal ids rejected.) - `RegistryInspectSource` — the registered provider schemes from - `ProviderRegistry` (thin, `id = provider:`). Available as a source - for built-in schemes that have no on-disk capsule; not in the default - aggregate. + `ProviderRegistry`, including **sub-provider schemes** (`did`, `key`, `peer`, + …) via `ProviderRegistry::sub_provider_schemes()`. Thin + (`id = provider:`); a source for built-in schemes with no on-disk + capsule; not in the default aggregate. - `AggregateInspectSource` — unions sources and de-dups by id. +**Live audit.** The provider takes an optional `AuditSource`. `AuthAuditSource` +reads the signed runtime audit log (`RuntimeAuditEventV1` in the auth state), +correlates events by `capsule_id` (the capsule name), and fills the detail +view's `audit` section — recent events (newest-first, capped) plus `total` and +`denied` counts. Wired on both serve paths. Reads run on a blocking task so the +async workers aren't stalled. Records are projected to safe fields only +(timestamp, event type, reason, success) — no signatures or handles (#16). + **Wired on both serve paths:** - Single-VM serve (`elastos serve `): `RuntimeInspectSource` → rich, @@ -237,12 +246,13 @@ caller-identity injection) and `revoke` (needs the gateway capability plane). Inspector lists installed capsules with their full manifests** and running status. -**Remaining enrichment (honest gap):** `ProviderRegistry::schemes()` lists main -providers only (sub-providers are not enumerated), so running-status detection -in the catalog source is by `provides`-scheme match and may miss sub-provider -schemes; and live audit/grant aggregation into the projection is still pending. -Projection, scope, no-leak, transport wiring, source aggregation, and the rich -manifest view are done. +**Remaining enrichment (honest gap):** `granted_capabilities` is still empty on +the product path. ElastOS capabilities are bearer tokens with no central +per-capsule registry, and `RuntimeAuditEventV1` carries no resource/action — so +deriving the observed grant list needs a capability-event source that records +resource + action. Everything else is done: projection, scope, no-leak, +transport wiring, source aggregation, rich manifest detail, sub-provider +running-status coverage, and live audit (recent + counts). The UI adapter targets the Carrier-shaped `inspect/` contract and degrades to sample data until the data source is populated on the browser path. diff --git a/elastos/crates/elastos-runtime/src/provider/registry.rs b/elastos/crates/elastos-runtime/src/provider/registry.rs index 603adb83..0a56546f 100755 --- a/elastos/crates/elastos-runtime/src/provider/registry.rs +++ b/elastos/crates/elastos-runtime/src/provider/registry.rs @@ -692,6 +692,14 @@ impl ProviderRegistry { providers.keys().cloned().collect() } + /// List all registered `elastos://` sub-provider schemes (e.g. `did`, + /// `key`, `peer`). These are dispatched hierarchically and are not included + /// in [`schemes`](Self::schemes), which lists only top-level providers. + pub async fn sub_provider_schemes(&self) -> Vec { + let sub = self.sub_providers.read().await; + sub.keys().cloned().collect() + } + /// Check if a scheme has a registered provider pub async fn has_provider(&self, scheme: &str) -> bool { let providers = self.providers.read().await; diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index 65fea088..a4ad6d11 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -51,6 +51,83 @@ pub trait InspectSource: Send + Sync { async fn inspect_get(&self, id: &str) -> Option; } +/// A single audited event, projected for display (no signatures/handles). +#[derive(Debug, Clone)] +pub struct AuditRecord { + pub ts: u64, + pub event: String, + pub detail: String, + pub success: bool, +} + +/// A capsule's recent audit activity. +#[derive(Debug, Clone, Default)] +pub struct CapsuleAudit { + pub total: u64, + pub denied: u64, + pub recent: Vec, +} + +/// Read-only source of per-capsule audit activity. Optional on the provider; +/// when absent, the audit section is empty. +#[async_trait] +pub trait AuditSource: Send + Sync { + /// Audit for `capsule_key`, newest-first, capped at `recent_limit`. + async fn for_capsule(&self, capsule_key: &str, recent_limit: usize) -> CapsuleAudit; +} + +/// Audit source backed by the signed runtime audit log in the auth state +/// (`RuntimeAuditEventV1`). Correlates by `capsule_id`. +pub struct AuthAuditSource { + data_dir: PathBuf, +} + +impl AuthAuditSource { + pub fn new(data_dir: PathBuf) -> Self { + Self { data_dir } + } +} + +#[async_trait] +impl AuditSource for AuthAuditSource { + async fn for_capsule(&self, capsule_key: &str, recent_limit: usize) -> CapsuleAudit { + let data_dir = self.data_dir.clone(); + let key = capsule_key.to_string(); + // Auth state load is blocking std::fs; keep it off the async worker. + tokio::task::spawn_blocking(move || { + let state = match crate::auth::load_auth_state(&data_dir) { + Ok(state) => state, + Err(_) => return CapsuleAudit::default(), + }; + let mut total = 0u64; + let mut denied = 0u64; + let mut recent = Vec::new(); + // Newest-first. + for event in state.audit.iter().rev() { + if event.capsule_id.as_deref() != Some(key.as_str()) { + continue; + } + total += 1; + let success = matches!(event.result.as_str(), "ok" | "success" | "allowed"); + if !success { + denied += 1; + } + if recent.len() < recent_limit { + recent.push(AuditRecord { + ts: event.occurred_at, + event: event.event_type.clone(), + detail: event.reason.clone(), + success, + }); + } + } + CapsuleAudit { total, denied, recent } + }) + .await + .unwrap_or_default() + } +} + // ── Sources ───────────────────────────────────────────────────────── /// Source backed by the server `Runtime`'s running-capsule registry (the @@ -120,7 +197,9 @@ impl InspectSource for RegistryInspectSource { match self.registry.upgrade() { Some(reg) => { let mut schemes = reg.schemes().await; + schemes.extend(reg.sub_provider_schemes().await); schemes.sort(); + schemes.dedup(); schemes.into_iter().map(Self::scheme_entry).collect() } None => Vec::new(), @@ -130,11 +209,9 @@ impl InspectSource for RegistryInspectSource { async fn inspect_get(&self, id: &str) -> Option { let scheme = id.strip_prefix("provider:")?; let reg = self.registry.upgrade()?; - if reg.has_provider(scheme).await { - Some(Self::scheme_entry(scheme.to_string())) - } else { - None - } + let known = + reg.has_provider(scheme).await || reg.sub_provider_schemes().await.iter().any(|s| s == scheme); + known.then(|| Self::scheme_entry(scheme.to_string())) } } @@ -167,7 +244,12 @@ impl CatalogInspectSource { async fn running_schemes(&self) -> std::collections::HashSet { match self.registry.upgrade() { - Some(reg) => reg.schemes().await.into_iter().collect(), + Some(reg) => { + let mut set: std::collections::HashSet = + reg.schemes().await.into_iter().collect(); + set.extend(reg.sub_provider_schemes().await); + set + } None => std::collections::HashSet::new(), } } @@ -272,11 +354,18 @@ impl InspectSource for AggregateInspectSource { pub struct InspectProvider { source: Arc, + audit: Option>, } impl InspectProvider { pub fn new(source: Arc) -> Self { - Self { source } + Self { source, audit: None } + } + + /// Attach a per-capsule audit source so detail views show live activity. + pub fn with_audit(mut self, audit: Arc) -> Self { + self.audit = Some(audit); + self } fn scope_label(scope: InspectScope) -> &'static str { @@ -289,7 +378,7 @@ impl InspectProvider { /// Project a capsule into the inspector wire contract (see /// docs/CAPSULE_INSPECTOR.md). Read-only; unknown fields are null rather /// than fabricated, and no bearer token / raw signature is ever included. - fn project(entry: &InspectEntry) -> Value { + fn project(entry: &InspectEntry, audit: Value) -> Value { fn field(v: &Value, key: &str) -> Value { v.get(key).cloned().unwrap_or(Value::Null) } @@ -368,11 +457,27 @@ impl InspectProvider { "cid": Value::Null, "signature_present": signature_present, }, - "audit": { "counts": { "total": 0, "denied": 0 }, "recent": [] }, + "audit": audit, "processes": [{ "kind": entry.capsule_type, "status": entry.status }], }) } + /// Build the audit section for a capsule from the audit source (keyed by + /// the capsule name, which is how runtime audit events record `capsule_id`). + /// Empty when no audit source is attached. + async fn audit_value(&self, entry: &InspectEntry) -> Value { + let Some(audit) = &self.audit else { + return json!({ "counts": { "total": 0, "denied": 0 }, "recent": [] }); + }; + let a = audit.for_capsule(&entry.name, 20).await; + let recent: Vec = a + .recent + .iter() + .map(|r| json!({ "ts": r.ts, "event": r.event, "detail": r.detail, "success": r.success })) + .collect(); + json!({ "counts": { "total": a.total, "denied": a.denied }, "recent": recent }) + } + async fn handle_op(&self, request: &Value) -> Value { match request.get("op").and_then(Value::as_str).unwrap_or("") { // System-scope list. Upstream (gateway allow-list / capability @@ -401,7 +506,10 @@ impl InspectProvider { // System-scope detail. "capsule" => match request.get("id").and_then(Value::as_str) { Some(id) => match self.source.inspect_get(id).await { - Some(entry) => json!({ "status": "ok", "data": Self::project(&entry) }), + Some(entry) => { + let audit = self.audit_value(&entry).await; + json!({ "status": "ok", "data": Self::project(&entry, audit) }) + } None => provider_error("not_found", "no such capsule"), }, None => provider_error("invalid_request", "inspect/capsule requires an \"id\""), @@ -649,7 +757,60 @@ mod tests { assert!(source.inspect_get("capsule:../etc").await.is_none()); } - // Minimal provider used only to register a "wallet" scheme in the registry. + #[tokio::test] + async fn detail_includes_live_audit_when_source_attached() { + struct MockAudit; + #[async_trait] + impl AuditSource for MockAudit { + async fn for_capsule(&self, key: &str, _limit: usize) -> CapsuleAudit { + if key == "probe" { + CapsuleAudit { + total: 3, + denied: 1, + recent: vec![AuditRecord { + ts: 100, + event: "capability.use".to_string(), + detail: "did read".to_string(), + success: true, + }], + } + } else { + CapsuleAudit::default() + } + } + } + + let provider = InspectProvider::new(Arc::new(MockSource { entries: vec![probe_entry()] })) + .with_audit(Arc::new(MockAudit)); + let resp = provider + .send_raw(&json!({ "op": "capsule", "id": "cap_probe_1" })) + .await + .unwrap(); + assert_eq!(resp["data"]["audit"]["counts"]["total"], 3); + assert_eq!(resp["data"]["audit"]["counts"]["denied"], 1); + assert_eq!(resp["data"]["audit"]["recent"][0]["event"], "capability.use"); + assert_eq!(resp["data"]["audit"]["recent"][0]["success"], true); + } + + #[tokio::test] + async fn registry_source_includes_sub_providers() { + let registry = Arc::new(ProviderRegistry::new()); + // "did" is a reserved sub-provider scheme. + registry + .register_sub_provider("did", Arc::new(MockSchemeProvider)) + .await + .unwrap(); + let source = RegistryInspectSource::new(Arc::downgrade(®istry)); + + let entries = source.inspect_list().await; + assert!( + entries.iter().any(|e| e.name == "did" && e.id == "provider:did"), + "sub-provider scheme must be listed" + ); + assert!(source.inspect_get("provider:did").await.is_some()); + } + + // Minimal provider used only to register a scheme in the registry. struct MockSchemeProvider; #[async_trait] diff --git a/elastos/crates/elastos-server/src/serve_cmd.rs b/elastos/crates/elastos-server/src/serve_cmd.rs index 9ba3f9ce..625f0f1d 100644 --- a/elastos/crates/elastos-server/src/serve_cmd.rs +++ b/elastos/crates/elastos-server/src/serve_cmd.rs @@ -201,8 +201,11 @@ pub async fn run_serve( use elastos_server::inspect_provider as ip; let source: Arc = Arc::new(ip::RuntimeInspectSource::new(Arc::downgrade(&runtime_arc))); + let audit = Arc::new(ip::AuthAuditSource::new(data_dir.clone())); provider_registry - .register(Arc::new(ip::InspectProvider::new(source))) + .register(Arc::new( + ip::InspectProvider::new(source).with_audit(audit), + )) .await; } @@ -317,9 +320,12 @@ pub async fn run_serve( )); let source: Arc = Arc::new(ip::AggregateInspectSource::new(vec![runtime_src, catalog_src])); + let audit = Arc::new(ip::AuthAuditSource::new(data_dir.clone())); infra .provider_registry - .register(Arc::new(ip::InspectProvider::new(source))) + .register(Arc::new( + ip::InspectProvider::new(source).with_audit(audit), + )) .await; } From 04039d36a397be4e61248f8f43d269bd6519e771 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 05:57:19 +0000 Subject: [PATCH 12/31] feat(inspect): surface typed affordance schemas (metadata-driven reflection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Rong Chen's emphasis that Self missed metadata-driven reflection: the manifest's affordances already carry typed input_schema/output_schema, but the projection dropped them. Surface them so an affordance is a machine-readable interface contract (risk/approval/audit + input/output schema), not just a display label — the basis for typed, location-agnostic, capability-gated invocation over Carrier (the modern CAR direction). - inspect_provider: affordance projection includes input_schema/output_schema. - test asserts the typed schema is surfaced; #16 no-leak preserved. - docs: lineage note — capsule granularity (not objects), OS-level reflection, metadata-driven contract; CAR connection; metadata-driven invocation flagged as a deeper direction to plan, not assume. Verified: cargo test -p elastos-server inspect_provider — 10 passed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/CAPSULE_INSPECTOR.md | 20 +++++++++++++++++-- .../elastos-server/src/inspect_provider.rs | 16 ++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index c27aec22..3a9bbf4a 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -40,6 +40,20 @@ Everything the Inspector shows is data the **trusted core already owns** (manifests, capability grants, audit log, running instances, provenance). The Inspector is therefore a *read-only projection* — not a new architecture. +### Lineage note: granularity, OS perspective, metadata-driven reflection + +Self's two limits (per Rong Chen, and why Sun/IBM passed on it) are corrected +here by design: the unit of reflection is the **capsule**, not the object +(right granularity); reflection is an **OS-level** capability-gated surface, not +an in-process toy; and the surface is **metadata-driven** — the manifest's +typed interface (affordance `risk`/`approval`/`audit` + `input_schema`/ +`output_schema`) is the machine-readable contract. The inspector projects that +metadata; the same metadata is the basis for typed, **location-agnostic**, +capability-gated invocation over Carrier — the modern realization of Elastos's +Component Assembly Runtime (CAR) idea. Today we surface the typed contract; +metadata-*driven invocation/marshalling* (and cross-language interop) is the +deeper CAR-scale direction, to be planned, not assumed. + ## What A read-only view, one screen per capsule, of nine fields: @@ -302,9 +316,11 @@ endpoint a `SelfOnly` capsule uses to introspect itself. "manifest": { "schema": "elastos.capsule/v1", "entrypoint": "chat.wasm" }, "affordances": [ { "interface": "elastos.chat/v1", "id": "send", "risk": "write", - "approval": "user", "audit": "event", "description": "Send a message" }, + "approval": "user", "audit": "event", "description": "Send a message", + "input_schema": { "type": "object" }, "output_schema": { "type": "object" } }, { "interface": "elastos.chat/v1", "id": "history", "risk": "read", - "approval": "none", "audit": "summary", "description": "Read history" } + "approval": "none", "audit": "summary", "description": "Read history", + "input_schema": null, "output_schema": null } ], "required_capabilities": ["elastos://carrier/*", "elastos://storage/chat"], "granted_capabilities": [ diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index a4ad6d11..62256f69 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -403,6 +403,11 @@ impl InspectProvider { let iface_id = field(iface, "id"); if let Some(methods) = iface.get("methods").and_then(|v| v.as_array()) { for m in methods { + // The typed interface contract (metadata-driven + // reflection): risk/approval/audit class plus the + // input/output schemas that describe how to invoke the + // affordance — the basis for typed, location-agnostic, + // capability-gated calls, not just display. affordances.push(json!({ "interface": iface_id, "id": field(m, "id"), @@ -410,6 +415,8 @@ impl InspectProvider { "approval": field(m, "approval"), "audit": field(m, "audit"), "description": field(m, "description"), + "input_schema": field(m, "input_schema"), + "output_schema": field(m, "output_schema"), })); } } @@ -560,7 +567,11 @@ mod tests { "interfaces": [{ "id": "elastos.probe/v1", "version": "1", - "methods": [{ "id": "ping", "risk": "read", "approval": "none", "audit": "summary" }] + "methods": [{ + "id": "ping", "risk": "read", "approval": "none", "audit": "summary", + "input_schema": { "type": "object" }, + "output_schema": { "type": "string" } + }] }], "permissions": { "storage": ["localhost://WebSpaces/probe/"] }, "signature": "SECRET_SIGNATURE_MUST_NOT_LEAK" @@ -618,6 +629,9 @@ mod tests { let data = &resp["data"]; assert_eq!(data["affordances"][0]["id"], "ping"); assert_eq!(data["affordances"][0]["risk"], "read"); + // Typed interface contract is surfaced (metadata-driven reflection). + assert_eq!(data["affordances"][0]["input_schema"]["type"], "object"); + assert_eq!(data["affordances"][0]["output_schema"]["type"], "string"); assert_eq!(data["required_capabilities"][0], "elastos://storage/probe"); assert_eq!(data["storage_namespaces"][0], "localhost://WebSpaces/probe/"); assert_eq!(data["identity"]["signature_present"], true); From 997569cc02dd498546ebaf29366aced6f5ec8911 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 07:12:34 +0000 Subject: [PATCH 13/31] feat(invoke): metadata-driven invocation planner + Rong brief MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prototype of the reflective kernel Rong calls "metadata-driven reflection" (CAR): the bridge from inspect (read the typed contract) to invoke (act on it). - elastos-runtime::invoke — pure, transport-agnostic planner. Given an affordance's typed metadata it (1) validates call args against input_schema (minimal type + required-field check) and (2) derives the policy gate: capability Action from risk class, plus approval + audit modes. Mirrors crate::inspect as a decision core; dispatch/marshalling/Carrier transport are intentionally out of scope (architecture to be planned with Rong). 6 tests. - docs/INSPECTOR_BRIEF.md — one-page, shareable status + direction: the synthesis (Self UX x CAR metadata x capability/Carrier security), how it fixes Self's three gaps, what is built/tested today, the prototype, the inspect->invoke->location-agnostic->cross-language roadmap (for his input), honest gaps, and the time/commercialization framing. Verified: cargo test -p elastos-runtime invoke:: — 6 passed; 278 others unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/INSPECTOR_BRIEF.md | 73 +++++++ .../crates/elastos-runtime/src/invoke/mod.rs | 196 ++++++++++++++++++ elastos/crates/elastos-runtime/src/lib.rs | 1 + 3 files changed, 270 insertions(+) create mode 100644 docs/INSPECTOR_BRIEF.md create mode 100644 elastos/crates/elastos-runtime/src/invoke/mod.rs diff --git a/docs/INSPECTOR_BRIEF.md b/docs/INSPECTOR_BRIEF.md new file mode 100644 index 00000000..1185eaff --- /dev/null +++ b/docs/INSPECTOR_BRIEF.md @@ -0,0 +1,73 @@ +# Capsule Inspector & Metadata-Driven Reflection — Brief for Rong + +*One-page status + direction. Reflects what is built and tested today.* + +## Thesis + +Take Self's one durable idea — a **live system you can open, inspect, and act +on** — and ground it in the things Self lacked and Elastos pioneered: +**capability security, signed provenance, and metadata-driven reflection (CAR)**. + +> **Self's UX × CAR's metadata reflection × ElastOS capability/Carrier security.** + +This directly addresses the three reasons Self stalled at Sun/IBM: +**(1) granularity** — the unit is the **capsule**, not the object; +**(2) OS perspective** — reflection is an OS-level, capability-gated runtime +surface, not an in-process toy; **(3) metadata-driven reflection** — the +capsule manifest's **typed interface** is the machine-readable contract. + +## Built and tested today (on the product path) + +- **Capsule Inspector** — read-only, object-centered view of every capsule: + identity, manifest, **typed affordances** (risk/approval/audit + + input/output schema), required capabilities, storage, Carrier, provenance, + **live audit**, running status. +- **One canonical path** — served as an `inspect` provider on the shared + `ProviderRegistry`, reached by *both* the browser gateway and the capsule + Carrier bridge (no parallel trust system). +- **Security is the product** — capability-gated, fail-closed, scope-tiered; + the surface never leaks a bearer token or raw signature (enforced by test). +- **Rich data sources** — installed-capsule manifests + runtime instances + + registered (incl. sub-provider) schemes + the signed runtime audit log. +- **Quality bar** — pure decision cores (`inspect`, `invoke`) with exhaustive + unit tests; full crates compile clean; ~290 tests green; nothing fabricated + (honest gaps are documented, not hidden). + +## Prototype in hand (today's increment) + +A **metadata-driven invocation planner** (`elastos-runtime::invoke`): given an +affordance's typed metadata, it validates the call arguments against the +`input_schema` and derives the policy gate (capability action + approval + +audit) — *the metadata drives the call*. Pure and transport-agnostic; the +reflective kernel a real invoker would call. + +## Direction (for your input — not assumed) + +The kernel above is the bridge from **inspect → invoke → CAR**: + +1. **Metadata-driven typed invoke** — runtime uses the affordance schema to + validate/gate/marshal a capability-checked call. *(planner built)* +2. **Location-agnostic** — the same typed call routed locally or to a remote + peer over Carrier, unchanged (Principle #4 = CAR's location transparency). +3. **Cross-language interop** — one metadata contract spanning WASM, microVM, + and native capsules (CAR's JS/Java/Python ↔ C/C++ goal). + +Steps 2–3 are foundational enough to be **architecture decisions for you**, not +a fait accompli. The brief is to walk in aligned, with the kernel demonstrable. + +## Honest gaps + +- `granted_capabilities` is empty — bearer-token caps have no central registry; + surfacing observed grants needs a capability-event source recording + resource+action. +- Provider-scheme detail is thin where no on-disk manifest exists. +- Invocation dispatch/marshalling/Carrier transport are intentionally unbuilt. + +## Why now (time & commercialization) + +We are **not** re-implementing Self; we have extracted its value and added the +security/decentralization layer the 1990s projects never had — that layer is +the moat and the commercial wedge: **agent-safe computing** (let humans and AI +agents act through capability-bounded, audited, inspectable, revocable +capsules). That story is what attracts partner investment, which is what buys +the time to do the deeper CAR work properly. diff --git a/elastos/crates/elastos-runtime/src/invoke/mod.rs b/elastos/crates/elastos-runtime/src/invoke/mod.rs new file mode 100644 index 00000000..23b32c1b --- /dev/null +++ b/elastos/crates/elastos-runtime/src/invoke/mod.rs @@ -0,0 +1,196 @@ +//! Metadata-driven invocation planning (prototype). +//! +//! Given a capsule's typed affordance metadata, derive how a call must be +//! validated and gated *before* any dispatch: +//! +//! 1. validate the arguments against the affordance's declared `input_schema`; +//! 2. derive the policy gate — the capability [`Action`] it requires, whether +//! it needs approval, and how it must be audited — from `risk`/`approval`/ +//! `audit`. +//! +//! The metadata *drives* both. This is the reflective kernel behind Elastos's +//! Component Assembly Runtime (CAR) idea — "metadata-driven reflection" — and +//! the bridge from inspection (read the contract) to invocation (act on it). +//! +//! Pure and transport-agnostic by design: argument marshalling, dispatch, and +//! the location-agnostic Carrier transport are deliberately out of scope here +//! (that architecture is to be planned, not assumed). This is the decision core +//! a future invoker would call, mirroring [`crate::inspect`]. + +use elastos_common::{ + AffordanceApprovalMode, AffordanceAuditMode, AffordanceRisk, CapsuleAffordanceDescriptor, +}; +use serde_json::Value; + +use crate::capability::token::Action; + +/// Why a proposed invocation is rejected before dispatch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InvokeError { + /// Arguments did not match the declared input `type`. + InputTypeMismatch { expected: String }, + /// A required input field was missing. + MissingRequiredField(String), +} + +/// The validated plan for an invocation: the capability action it requires, +/// whether it needs approval, and how it must be audited. Derived entirely from +/// the affordance metadata — a dispatcher MUST enforce all three. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvocationPlan { + pub capability_action: Action, + pub approval: AffordanceApprovalMode, + pub audit: AffordanceAuditMode, +} + +/// Map an affordance risk class to the capability action it requires. Read-only +/// affordances need `Read`; anything that mutates or actuates needs a stronger +/// action so the capability layer gates it correctly (fail-closed by class). +pub fn required_action(risk: &AffordanceRisk) -> Action { + match risk { + AffordanceRisk::Read => Action::Read, + AffordanceRisk::Write => Action::Write, + AffordanceRisk::Launch | AffordanceRisk::Actuator => Action::Execute, + AffordanceRisk::Payment | AffordanceRisk::Rights | AffordanceRisk::Privileged => { + Action::Admin + } + } +} + +/// Minimal, metadata-driven validation of `args` against an `input_schema`. +/// +/// Prototype scope: checks the top-level JSON `type` and any `required` fields — +/// the subset that demonstrates schema-driven marshalling. A full JSON Schema +/// validator is intentionally out of scope. An absent schema means "untyped": +/// nothing to check. +pub fn validate_input(input_schema: Option<&Value>, args: &Value) -> Result<(), InvokeError> { + let Some(schema) = input_schema else { + return Ok(()); + }; + + if let Some(expected) = schema.get("type").and_then(Value::as_str) { + let ok = match expected { + "object" => args.is_object(), + "array" => args.is_array(), + "string" => args.is_string(), + "number" => args.is_number(), + "integer" => args.is_i64() || args.is_u64(), + "boolean" => args.is_boolean(), + "null" => args.is_null(), + _ => true, // unknown type keyword: don't reject in the prototype + }; + if !ok { + return Err(InvokeError::InputTypeMismatch { + expected: expected.to_string(), + }); + } + } + + if let Some(required) = schema.get("required").and_then(Value::as_array) { + let obj = args.as_object(); + for field in required { + if let Some(name) = field.as_str() { + let present = obj.map(|o| o.contains_key(name)).unwrap_or(false); + if !present { + return Err(InvokeError::MissingRequiredField(name.to_string())); + } + } + } + } + + Ok(()) +} + +/// Plan an invocation from the affordance metadata and proposed arguments: +/// validate the input shape, then derive the capability/approval/audit gate. +/// Returns the plan a dispatcher must enforce; it does not itself dispatch. +pub fn plan( + affordance: &CapsuleAffordanceDescriptor, + args: &Value, +) -> Result { + validate_input(affordance.input_schema.as_ref(), args)?; + Ok(InvocationPlan { + capability_action: required_action(&affordance.risk), + approval: affordance.approval.clone(), + audit: affordance.audit.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn affordance(value: serde_json::Value) -> CapsuleAffordanceDescriptor { + serde_json::from_value(value).expect("affordance descriptor") + } + + #[test] + fn risk_maps_to_capability_action() { + assert_eq!(required_action(&AffordanceRisk::Read), Action::Read); + assert_eq!(required_action(&AffordanceRisk::Write), Action::Write); + assert_eq!(required_action(&AffordanceRisk::Launch), Action::Execute); + assert_eq!(required_action(&AffordanceRisk::Actuator), Action::Execute); + assert_eq!(required_action(&AffordanceRisk::Payment), Action::Admin); + assert_eq!(required_action(&AffordanceRisk::Rights), Action::Admin); + assert_eq!(required_action(&AffordanceRisk::Privileged), Action::Admin); + } + + #[test] + fn plan_derives_gate_for_read_affordance() { + let a = affordance(json!({ + "id": "history", "risk": "read", "approval": "none", "audit": "summary" + })); + let plan = plan(&a, &json!({})).unwrap(); + assert_eq!(plan.capability_action, Action::Read); + assert_eq!(plan.approval, AffordanceApprovalMode::None); + assert_eq!(plan.audit, AffordanceAuditMode::Summary); + } + + #[test] + fn plan_derives_user_approval_for_payment() { + let a = affordance(json!({ + "id": "pay", "risk": "payment", "approval": "user", "audit": "full" + })); + let plan = plan(&a, &json!({ "amount": 10 })).unwrap(); + assert_eq!(plan.capability_action, Action::Admin); + assert_eq!(plan.approval, AffordanceApprovalMode::User); + assert_eq!(plan.audit, AffordanceAuditMode::Full); + } + + #[test] + fn input_schema_required_field_is_enforced() { + let a = affordance(json!({ + "id": "send", "risk": "write", "approval": "user", "audit": "event", + "input_schema": { "type": "object", "required": ["to", "body"] } + })); + // Missing "body". + let err = plan(&a, &json!({ "to": "alice" })).unwrap_err(); + assert_eq!(err, InvokeError::MissingRequiredField("body".to_string())); + // Complete args pass and yield the write gate. + let ok = plan(&a, &json!({ "to": "alice", "body": "hi" })).unwrap(); + assert_eq!(ok.capability_action, Action::Write); + } + + #[test] + fn input_schema_type_mismatch_is_rejected() { + let a = affordance(json!({ + "id": "send", "risk": "write", "approval": "none", "audit": "none", + "input_schema": { "type": "object" } + })); + let err = plan(&a, &json!("not-an-object")).unwrap_err(); + assert_eq!( + err, + InvokeError::InputTypeMismatch { expected: "object".to_string() } + ); + } + + #[test] + fn untyped_affordance_accepts_any_args() { + let a = affordance(json!({ + "id": "ping", "risk": "read", "approval": "none", "audit": "none" + })); + assert!(plan(&a, &json!({ "anything": [1, 2, 3] })).is_ok()); + assert!(plan(&a, &json!("scalar")).is_ok()); + } +} diff --git a/elastos/crates/elastos-runtime/src/lib.rs b/elastos/crates/elastos-runtime/src/lib.rs index ea71f6ed..e8b7ec57 100644 --- a/elastos/crates/elastos-runtime/src/lib.rs +++ b/elastos/crates/elastos-runtime/src/lib.rs @@ -20,6 +20,7 @@ pub mod capability; pub mod capsule; pub mod handler; pub mod inspect; +pub mod invoke; pub mod messaging; pub mod primitives; pub mod provider; From 4ba5e02afc92dffd925ab2e73c0a58a27e68b797 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 08:13:01 +0000 Subject: [PATCH 14/31] fix(inspect): wire inspect into the gateway allow-list + end-to-end verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live verification found a real gap registration had hidden: the browser path 404'd. gateway_provider_proxy's allowed_apps match had no `inspect` case, so POST /api/provider/inspect/ fell through to "Gateway provider not found" before reaching the registry. Fixed + verified end-to-end. - gateway_provider_proxy: add an `inspect` scheme arm — read ops (capsules, capsule) allowed for the System operator app (SYSTEM_CAPSULE_ID); write ops (revoke) intentionally not exposed through the browser proxy. - gateway_tests/inspect.rs (NEW): HTTP-level tests through the real gateway_router — no token => 403; System token => 200 listing the installed capsule (full leg: token validate -> allow-list -> registry -> provider); a non-System (Library) token is rejected. - inspect_provider: registry_dispatch_reaches_inspect_provider test proving the ProviderRegistry::send_raw("inspect", ..) leg both transports converge on. Security review (separate pass): clean — no HIGH/MEDIUM findings; path traversal, scope/authz, #16 no-leak, and read/write revoke separation all sound. Verified: cargo test -p elastos-server inspect_provider (11) + inspect_capsules (2) — all green; compiles clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- .../src/api/gateway_provider_proxy.rs | 14 +++ .../src/api/gateway_tests/inspect.rs | 111 ++++++++++++++++++ .../src/api/gateway_tests/mod.rs | 1 + .../elastos-server/src/inspect_provider.rs | 18 +++ 4 files changed, 144 insertions(+) create mode 100644 elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs diff --git a/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs b/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs index eac1ec81..1d1c2967 100644 --- a/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs +++ b/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs @@ -1342,6 +1342,20 @@ pub(super) async fn gateway_provider_proxy( .into_response() } }, + "inspect" => match op.as_str() { + // Read-only Capsule Inspector. Full-view inspect is a System + // operator surface (System scope); the provider is read-only and + // gated. Write ops (e.g. revoke) are intentionally not exposed + // through the browser proxy. + "capsules" | "capsule" => &[SYSTEM_CAPSULE_ID], + _ => { + return ( + StatusCode::NOT_FOUND, + "Gateway provider operation not found", + ) + .into_response() + } + }, _ => return (StatusCode::NOT_FOUND, "Gateway provider not found").into_response(), }; let context = diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs b/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs new file mode 100644 index 00000000..14066b92 --- /dev/null +++ b/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs @@ -0,0 +1,111 @@ +use super::*; + +/// Gateway state whose registry has the inspect provider registered, backed by +/// a catalog source pointing at a seeded installed-capsule directory. +async fn inspect_test_state(dir: &std::path::Path) -> GatewayState { + seed_test_browser_capsules(dir); + let registry = Arc::new(ProviderRegistry::new()); + + // Seed an installed capsule manifest the catalog source will read from + // /capsules//capsule.json. + let capsule_dir = dir.join("capsules").join("probe-capsule"); + std::fs::create_dir_all(&capsule_dir).unwrap(); + std::fs::write( + capsule_dir.join("capsule.json"), + serde_json::to_vec(&json!({ + "schema": "elastos.capsule/v1", + "version": "0.1.0", + "name": "probe-capsule", + "role": "app", + "type": "wasm", + "entrypoint": "probe.wasm", + "capabilities": ["elastos://storage/probe"] + })) + .unwrap(), + ) + .unwrap(); + + let source: Arc = + Arc::new(crate::inspect_provider::CatalogInspectSource::new( + dir.join("capsules"), + Arc::downgrade(®istry), + )); + registry + .register(Arc::new(crate::inspect_provider::InspectProvider::new(source))) + .await; + + GatewayState { + provider_registry: Some(registry), + identity_manager: Arc::new(std::sync::OnceLock::new()), + cache_dir: dir.to_path_buf(), + data_dir: dir.to_path_buf(), + } +} + +#[tokio::test] +async fn inspect_capsules_requires_token_and_lists_installed_capsule() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(inspect_test_state(dir.path()).await); + + // No home launch token: rejected before reaching the provider. + let denied = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/inspect/capsules") + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + + // A System-operator token reaches the inspect provider end-to-end. + let token = issue_home_launch_token(dir.path(), SYSTEM_CAPSULE_ID).unwrap(); + let ok = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/inspect/capsules") + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(ok.status(), StatusCode::OK); + let body = axum::body::to_bytes(ok.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8_lossy(&body); + // The installed capsule made it through the full browser leg. + assert!( + text.contains("probe-capsule"), + "inspect/capsules did not list the installed capsule: {text}" + ); +} + +#[tokio::test] +async fn inspect_capsules_rejects_non_system_app() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(inspect_test_state(dir.path()).await); + + // A token for a different app must not be authorized for inspect. + let token = issue_home_launch_token(dir.path(), LIBRARY_CAPSULE_ID).unwrap(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/inspect/capsules") + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(resp.status(), StatusCode::OK, "non-System app must not inspect"); +} diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs b/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs index 61849b36..7e349998 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs @@ -377,6 +377,7 @@ mod documents; #[path = "../gateway_browser_route_tests.rs"] mod gateway_browser_route_tests; mod home_system; +mod inspect; mod library; mod marketplace; mod recovery; diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index 62256f69..3fc753ef 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -806,6 +806,24 @@ mod tests { assert_eq!(resp["data"]["audit"]["recent"][0]["success"], true); } + #[tokio::test] + async fn registry_dispatch_reaches_inspect_provider() { + // The leg both product transports converge on: ProviderRegistry::send_raw + // by scheme must resolve to the registered inspect provider. + let registry = Arc::new(ProviderRegistry::new()); + registry + .register(Arc::new(InspectProvider::new(Arc::new(MockSource { + entries: vec![probe_entry()], + })))) + .await; + let resp = registry + .send_raw("inspect", &json!({ "op": "capsules" })) + .await + .expect("registry dispatch"); + assert_eq!(resp["status"], "ok"); + assert_eq!(resp["data"]["capsules"][0]["id"], "cap_probe_1"); + } + #[tokio::test] async fn registry_source_includes_sub_providers() { let registry = Arc::new(ProviderRegistry::new()); From 9ae8e12a0cf320ed896c1e226962566922f5a2fe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 08:44:53 +0000 Subject: [PATCH 15/31] =?UTF-8?q?feat(inspect):=20invoke=20preview=20+=20p?= =?UTF-8?q?rovenance=20CID=20+=20carrier-leg=20naming=20(=E2=86=9250%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone push: inspect complete on both transports, with content provenance, and the glass box can now *preview* action. 1. Carrier-leg consistency: build_capability_resource now maps `inspect` to a concrete elastos://inspect/ resource (was the malformed `inspect://*` default), so the capsule/carrier transport gates inspect identically to the gateway/handler path. + unit test. 2. Metadata-driven invoke PREVIEW: new read-only `inspect/plan` op exposes the elastos-runtime::invoke planner — validates args against the affordance's input_schema and returns the capability/approval/audit gate the call would require. No effect dispatched (that architecture is Rong's to shape). + 3 tests (valid gate, type-mismatch, unknown affordance). 3. Provenance enrichment: CatalogInspectSource attaches the content CID from /components.json; project() surfaces it in identity + provenance (Principle #15). + test. Verified: cargo test -p elastos-server inspect — 18 passed (provider 15 + naming 1 + gateway HTTP 2); compiles clean. Honest remaining: live granted_capabilities (needs capability-use events with resource+action), real invoke dispatch, location-agnostic over Carrier, cross-language — all Rong-call / Tier 3+. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/CAPSULE_INSPECTOR.md | 29 ++- .../elastos-server/src/inspect_provider.rs | 199 +++++++++++++++++- .../elastos-server/src/provider_resource.rs | 18 ++ 3 files changed, 237 insertions(+), 9 deletions(-) diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index 3a9bbf4a..58143ec6 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -232,10 +232,11 @@ caller-identity injection) and `revoke` (needs the gateway capability plane). single-VM serve path. - `CatalogInspectSource` — the installed-capsule catalog on disk (`/capsules//capsule.json`). Reads each capsule's **full - manifest** (rich nine-field detail) and marks it `running` when the scheme it - `provides` is registered live, else `installed`. This is the rich, - manifest-backed source for the product. (`id = capsule:`, with - path-traversal ids rejected.) + manifest** (rich nine-field detail), marks it `running` when the scheme it + `provides` is registered live (incl. sub-provider schemes), else `installed`, + and attaches the **content CID** from `/components.json` as the + provenance anchor (Principle #15). (`id = capsule:`, path-traversal ids + rejected.) - `RegistryInspectSource` — the registered provider schemes from `ProviderRegistry`, including **sub-provider schemes** (`did`, `key`, `peer`, …) via `ProviderRegistry::sub_provider_schemes()`. Thin @@ -358,6 +359,26 @@ The one mutating endpoint. Requires a **`Write`** inspect capability at a malformed id. The action is audited (`inspect.revoke`) in addition to the capability manager's own revocation audit. +### `elastos://inspect/plan` (params: `{ "id", "interface", "method", "args" }`) — read + +Metadata-driven invocation **preview** (read-only dry-run; dispatches no +effect). Looks up the affordance's typed metadata, validates `args` against its +`input_schema`, and returns the gate the call *would* require: + +```json +{ "valid": true, "capability_action": "write", "approval": "user", "audit": "event" } +``` + +or, when the args don't satisfy the contract: + +```json +{ "valid": false, "error": "missing_required_field", "field": "body" } +``` + +This is the reflective half of the CAR invoke kernel (`elastos-runtime::invoke`). +Effect *dispatch* (and the location-agnostic Carrier / cross-language transport) +is intentionally not implemented — that architecture is to be planned. + ### Why `granted_capabilities` is observed, not enumerated ElastOS capabilities are **bearer-token object-capabilities**: a grant is an diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index 3fc753ef..f8ac0a9e 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -25,8 +25,9 @@ use std::path::PathBuf; use std::sync::{Arc, Weak}; use async_trait::async_trait; -use elastos_common::CapsuleManifest; +use elastos_common::{CapsuleAffordanceDescriptor, CapsuleManifest}; use elastos_runtime::inspect::InspectScope; +use elastos_runtime::invoke::{self, InvokeError}; use elastos_runtime::provider::{Provider, ProviderError, ProviderRegistry, ResourceRequest, ResourceResponse}; use serde_json::{json, Value}; @@ -41,6 +42,9 @@ pub struct InspectEntry { pub capsule_type: String, /// Manifest the capsule was launched with, when retained. pub manifest: Option, + /// Content identity (IPFS CID) from the installed-capsule catalog, when + /// known — the provenance anchor (Principle #15). + pub cid: Option, } /// Read-only source of inspectable capsules. Decouples the provider from the @@ -149,6 +153,7 @@ fn running_to_entry(info: crate::runtime::RunningCapsuleInfo) -> InspectEntry { status: info.status, capsule_type: format!("{:?}", info.capsule_type).to_lowercase(), manifest: Some(*info.manifest), + cid: None, } } @@ -187,6 +192,7 @@ impl RegistryInspectSource { status: "running".to_string(), capsule_type: "provider".to_string(), manifest: None, + cid: None, } } } @@ -254,10 +260,35 @@ impl CatalogInspectSource { } } + /// Content identities (capsule name → CID) from the installed-capsule + /// catalog (`/components.json`). Best-effort: empty if absent. + async fn catalog_cids(&self) -> std::collections::HashMap { + let Some(path) = self + .capsules_dir + .parent() + .map(|p| p.join("components.json")) + else { + return std::collections::HashMap::new(); + }; + let Ok(data) = tokio::fs::read_to_string(&path).await else { + return std::collections::HashMap::new(); + }; + match serde_json::from_str::(&data) { + Ok(manifest) => manifest + .capsules + .into_iter() + .filter(|(_, entry)| !entry.cid.is_empty()) + .map(|(name, entry)| (name, entry.cid)) + .collect(), + Err(_) => std::collections::HashMap::new(), + } + } + async fn read_entry( &self, name: &str, running: &std::collections::HashSet, + cid: Option, ) -> Option { let path = self.capsules_dir.join(name).join("capsule.json"); let data = tokio::fs::read_to_string(&path).await.ok()?; @@ -271,6 +302,7 @@ impl CatalogInspectSource { status: if is_running { "running" } else { "installed" }.to_string(), capsule_type: format!("{:?}", manifest.capsule_type).to_lowercase(), manifest: Some(manifest), + cid, }) } } @@ -279,6 +311,7 @@ impl CatalogInspectSource { impl InspectSource for CatalogInspectSource { async fn inspect_list(&self) -> Vec { let running = self.running_schemes().await; + let cids = self.catalog_cids().await; let mut out = Vec::new(); if let Ok(mut rd) = tokio::fs::read_dir(&self.capsules_dir).await { while let Ok(Some(dir_entry)) = rd.next_entry().await { @@ -291,7 +324,8 @@ impl InspectSource for CatalogInspectSource { continue; } if let Some(name) = dir_entry.file_name().to_str() { - if let Some(entry) = self.read_entry(name, &running).await { + let cid = cids.get(name).cloned(); + if let Some(entry) = self.read_entry(name, &running, cid).await { out.push(entry); } } @@ -307,7 +341,8 @@ impl InspectSource for CatalogInspectSource { return None; } let running = self.running_schemes().await; - self.read_entry(name, &running).await + let cid = self.catalog_cids().await.get(name).cloned(); + self.read_entry(name, &running, cid).await } } @@ -395,6 +430,7 @@ impl InspectProvider { .get("signature") .map(|s| s.is_string()) .unwrap_or(false); + let cid = entry.cid.clone(); // Affordances: flatten declared interface methods. let mut affordances = Vec::new(); @@ -433,7 +469,7 @@ impl InspectProvider { "author": field(&manifest, "author"), "identity": { "did": Value::Null, - "cid": Value::Null, + "cid": cid.clone(), "trust_level": Value::Null, "signature_present": signature_present, "signed_by": Value::Null, @@ -461,7 +497,7 @@ impl InspectProvider { "signed_by": Value::Null, "version": field(&manifest, "version"), "installed_at": Value::Null, - "cid": Value::Null, + "cid": cid.clone(), "signature_present": signature_present, }, "audit": audit, @@ -521,9 +557,83 @@ impl InspectProvider { }, None => provider_error("invalid_request", "inspect/capsule requires an \"id\""), }, + // Metadata-driven invocation *preview* (read-only, dry-run): given a + // capsule + interface + method + args, validate the args against the + // affordance's input_schema and derive the capability/approval/audit + // gate the call would require. Dispatches NO effect — this is the + // reflective half of the CAR invoke kernel. + "plan" => self.handle_plan(request).await, other => provider_error("unknown_op", &format!("unknown inspect op: {other}")), } } + + async fn handle_plan(&self, request: &Value) -> Value { + let (id, interface, method) = match ( + request.get("id").and_then(Value::as_str), + request.get("interface").and_then(Value::as_str), + request.get("method").and_then(Value::as_str), + ) { + (Some(id), Some(i), Some(m)) => (id, i, m), + _ => { + return provider_error( + "invalid_request", + "inspect/plan requires \"id\", \"interface\", and \"method\"", + ) + } + }; + let args = request.get("args").cloned().unwrap_or(json!({})); + + let entry = match self.source.inspect_get(id).await { + Some(entry) => entry, + None => return provider_error("not_found", "no such capsule"), + }; + let affordance = match entry + .manifest + .as_ref() + .and_then(|m| find_affordance(m, interface, method)) + { + Some(a) => a, + None => return provider_error("not_found", "no such affordance"), + }; + + match invoke::plan(&affordance, &args) { + Ok(plan) => json!({ + "status": "ok", + "data": { + "valid": true, + // The gate the runtime would enforce for this call. + "capability_action": plan.capability_action.to_string(), + "approval": serde_json::to_value(&plan.approval).ok(), + "audit": serde_json::to_value(&plan.audit).ok(), + } + }), + // The query succeeded; the proposed args do not satisfy the contract. + Err(InvokeError::MissingRequiredField(field)) => json!({ + "status": "ok", + "data": { "valid": false, "error": "missing_required_field", "field": field } + }), + Err(InvokeError::InputTypeMismatch { expected }) => json!({ + "status": "ok", + "data": { "valid": false, "error": "input_type_mismatch", "expected": expected } + }), + } + } +} + +/// Look up an affordance descriptor in a manifest by interface id + method id. +fn find_affordance( + manifest: &CapsuleManifest, + interface: &str, + method: &str, +) -> Option { + manifest + .interfaces + .iter() + .find(|iface| iface.id == interface)? + .methods + .iter() + .find(|m| m.id == method) + .cloned() } #[async_trait] @@ -600,6 +710,7 @@ mod tests { status: "running".to_string(), capsule_type: "wasm".to_string(), manifest: Some(probe_manifest()), + cid: None, } } @@ -693,6 +804,7 @@ mod tests { status: "running".to_string(), capsule_type: "provider".to_string(), manifest: None, + cid: None, }, ], }); @@ -806,6 +918,49 @@ mod tests { assert_eq!(resp["data"]["audit"]["recent"][0]["success"], true); } + #[tokio::test] + async fn plan_previews_gate_for_valid_call() { + let resp = provider_with_probe() + .send_raw(&json!({ + "op": "plan", "id": "cap_probe_1", + "interface": "elastos.probe/v1", "method": "ping", "args": {} + })) + .await + .unwrap(); + assert_eq!(resp["status"], "ok"); + assert_eq!(resp["data"]["valid"], true); + assert_eq!(resp["data"]["capability_action"], "read"); + assert_eq!(resp["data"]["approval"], "none"); + } + + #[tokio::test] + async fn plan_reports_input_type_mismatch() { + // ping declares input_schema {type:object}; a scalar must fail validation. + let resp = provider_with_probe() + .send_raw(&json!({ + "op": "plan", "id": "cap_probe_1", + "interface": "elastos.probe/v1", "method": "ping", "args": "scalar" + })) + .await + .unwrap(); + assert_eq!(resp["status"], "ok"); + assert_eq!(resp["data"]["valid"], false); + assert_eq!(resp["data"]["error"], "input_type_mismatch"); + } + + #[tokio::test] + async fn plan_unknown_affordance_is_not_found() { + let resp = provider_with_probe() + .send_raw(&json!({ + "op": "plan", "id": "cap_probe_1", + "interface": "elastos.probe/v1", "method": "nope", "args": {} + })) + .await + .unwrap(); + assert_eq!(resp["status"], "error"); + assert_eq!(resp["code"], "not_found"); + } + #[tokio::test] async fn registry_dispatch_reaches_inspect_provider() { // The leg both product transports converge on: ProviderRegistry::send_raw @@ -842,6 +997,40 @@ mod tests { assert!(source.inspect_get("provider:did").await.is_some()); } + #[tokio::test] + async fn catalog_surfaces_content_cid_from_components_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let capsules_dir = tmp.path().join("capsules"); + write_capsule( + &capsules_dir, + "probe", + &serde_json::to_value(probe_manifest()).unwrap(), + ); + // Seed the installed-capsule catalog with a content CID (provenance). + std::fs::write( + tmp.path().join("components.json"), + serde_json::to_vec(&json!({ + "external": {}, + "profiles": {}, + "capsules": { "probe": { "cid": "bafyprobecid", "sha256": "deadbeef", "size": 0 } } + })) + .unwrap(), + ) + .unwrap(); + + let registry = Arc::new(ProviderRegistry::new()); + let provider = InspectProvider::new(Arc::new(CatalogInspectSource::new( + capsules_dir, + Arc::downgrade(®istry), + ))); + let resp = provider + .send_raw(&json!({ "op": "capsule", "id": "capsule:probe" })) + .await + .unwrap(); + assert_eq!(resp["data"]["provenance"]["cid"], "bafyprobecid"); + assert_eq!(resp["data"]["identity"]["cid"], "bafyprobecid"); + } + // Minimal provider used only to register a scheme in the registry. struct MockSchemeProvider; diff --git a/elastos/crates/elastos-server/src/provider_resource.rs b/elastos/crates/elastos-server/src/provider_resource.rs index cb54c2f4..1ffe6f3c 100644 --- a/elastos/crates/elastos-server/src/provider_resource.rs +++ b/elastos/crates/elastos-server/src/provider_resource.rs @@ -84,6 +84,10 @@ pub fn build_capability_resource( "wallet" => wallet_resource(op, request), "content" => content_resource(op), "did" | "peer" => Ok(format!("elastos://{scheme}/*")), + // Read-only Capsule Inspector. Use the concrete requested resource so it + // validates against an `elastos://inspect/*` capability the same way the + // gateway/handler path does (consistent across both transports). + "inspect" => Ok(format!("elastos://inspect/{op}")), _ => Ok(format!("{scheme}://*")), } } @@ -268,6 +272,20 @@ mod tests { ); } + #[test] + fn inspect_resource_uses_concrete_elastos_uri() { + // Consistent with the gateway/handler path: a concrete elastos://inspect + // resource that validates against an `elastos://inspect/*` capability. + assert_eq!( + build_capability_resource("inspect", "capsules", &serde_json::json!({})).unwrap(), + "elastos://inspect/capsules" + ); + assert_eq!( + build_capability_resource("inspect", "capsule", &serde_json::json!({})).unwrap(), + "elastos://inspect/capsule" + ); + } + #[test] fn ai_resource_without_backend() { let request = serde_json::json!({"op": "list_backends"}); From f25e330ad40f85f53be684a5a0cf77a785ee0113 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 08:53:25 +0000 Subject: [PATCH 16/31] =?UTF-8?q?feat(inspect):=20carrier-leg=20e2e=20veri?= =?UTF-8?q?fication=20+=20browser=20plan=20reachability=20(=E2=86=9265%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both product transports now proven end-to-end, and the read-only invoke preview is reachable from the browser. - carrier_bridge test: a capability-gated carrier_invoke("elastos://inspect/ capsules") validates the inspect capability and reaches the provider; rejected without a token. The capsule/agent transport is now verified e2e, matching the gateway/browser leg — both converge on the same provider via the Carrier-shaped capability call. - gateway allow-list: add `plan` to the inspect read ops (System scope) so the browser Inspector can use the metadata-driven invocation preview. plan is read-only and returns only a gate descriptor (no token/handle, #16). - docs: both transports marked verified; read ops (capsules/capsule/plan) on both, write (revoke) runtime-only. Security/principles check (this push): #16 holds (plan returns requirements not grants); hostile `op` cannot escape the inspect/* grant (ResourceId::matches rejects ".."); provenance CID read from a fixed path. Carrier framework: the capsule contract stays Carrier-shaped; HTTP/stdio are adapters below it. Verified: cargo test -p elastos-server inspect (19) + the carrier e2e test — all green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/CAPSULE_INSPECTOR.md | 13 ++- .../src/api/gateway_provider_proxy.rs | 7 +- .../elastos-server/src/carrier_bridge.rs | 90 +++++++++++++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index 58143ec6..1248cc0c 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -175,10 +175,15 @@ There are two transports to the **one** authority decision (`crate::inspect::InspectScope`), satisfying Principle #7 (every path enforces the same authority boundary): -| Caller | Transport (adapter) | Front door | Identity / scope | -| --- | --- | --- | --- | -| WASM / microVM capsule, agent | serial Carrier bridge → `carrier_invoke` | `RequestHandler::handle_inspect` | capability token → scope | -| Browser-hosted UI (this capsule) | node-local control API (`POST /api/provider/inspect/` + `x-elastos-home-token`) | gateway → provider registry | signed home launch token → app → scope | +| Caller | Transport (adapter) | Front door | Identity / scope | Verified | +| --- | --- | --- | --- | --- | +| WASM / microVM capsule, agent | Carrier bridge → `carrier_invoke` | `carrier_bridge` → capability check → provider registry | capability token → resource | ✅ e2e test (`carrier_invoke_reaches_inspect_provider_with_capability`) | +| Browser-hosted UI | node-local control API (`POST /api/provider/inspect/` + `x-elastos-home-token`) | gateway allow-list → provider registry | signed home launch token → app → System | ✅ e2e test (gateway router, signed token) | + +Both transports converge on `ProviderRegistry::send_raw("inspect", …)` → the +inspect provider, and both are now verified end-to-end by test. Read ops +(`capsules`, `capsule`, `plan`) are exposed on both; the write op (`revoke`, +runtime path) is not exposed through the browser proxy. **Status (Principle #12 honesty).** The capsule/`carrier_invoke` path is implemented and tested (`RequestHandler::handle_inspect`). The browser path is diff --git a/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs b/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs index 1d1c2967..c657001a 100644 --- a/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs +++ b/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs @@ -1345,9 +1345,10 @@ pub(super) async fn gateway_provider_proxy( "inspect" => match op.as_str() { // Read-only Capsule Inspector. Full-view inspect is a System // operator surface (System scope); the provider is read-only and - // gated. Write ops (e.g. revoke) are intentionally not exposed - // through the browser proxy. - "capsules" | "capsule" => &[SYSTEM_CAPSULE_ID], + // gated. `plan` is the read-only invocation preview (no effect). + // Write ops (e.g. revoke) are intentionally not exposed through the + // browser proxy. + "capsules" | "capsule" | "plan" => &[SYSTEM_CAPSULE_ID], _ => { return ( StatusCode::NOT_FOUND, diff --git a/elastos/crates/elastos-server/src/carrier_bridge.rs b/elastos/crates/elastos-server/src/carrier_bridge.rs index 48f4e8f3..b32306da 100644 --- a/elastos/crates/elastos-server/src/carrier_bridge.rs +++ b/elastos/crates/elastos-server/src/carrier_bridge.rs @@ -1252,6 +1252,96 @@ mod tests { assert!(!is_runtime_control_request("request_capability")); } + // End-to-end over the Carrier bridge: a capsule's capability-gated + // carrier_invoke("elastos://inspect/capsules") must validate the inspect + // capability and reach the inspect provider; without a token it is rejected. + #[tokio::test] + async fn carrier_invoke_reaches_inspect_provider_with_capability() { + use crate::inspect_provider::{CatalogInspectSource, InspectProvider, InspectSource}; + + let tmp = tempfile::tempdir().unwrap(); + let capsule_dir = tmp.path().join("capsules").join("probe"); + std::fs::create_dir_all(&capsule_dir).unwrap(); + std::fs::write( + capsule_dir.join("capsule.json"), + serde_json::to_vec(&serde_json::json!({ + "schema": "elastos.capsule/v1", "version": "0.1.0", "name": "probe", + "role": "app", "type": "wasm", "entrypoint": "probe.wasm" + })) + .unwrap(), + ) + .unwrap(); + + let audit_log = Arc::new(elastos_runtime::primitives::audit::AuditLog::new()); + let store = Arc::new(elastos_runtime::capability::CapabilityStore::new()); + let metrics = Arc::new(elastos_runtime::primitives::metrics::MetricsManager::new()); + let capability_manager = Arc::new(elastos_runtime::capability::CapabilityManager::new( + store, + audit_log.clone(), + metrics, + )); + let registry = Arc::new(elastos_runtime::provider::ProviderRegistry::new()); + let source: Arc = Arc::new(CatalogInspectSource::new( + tmp.path().join("capsules"), + Arc::downgrade(®istry), + )); + registry + .register(Arc::new(InspectProvider::new(source))) + .await; + + // System inspect capability granted to the calling capsule. + let token = encode_bridge_capability_token(&capability_manager.grant( + "test-capsule", + ResourceId::new("elastos://inspect/*"), + Action::Read, + TokenConstraints::default(), + None, + )); + + let ctx = Some(BridgeContext { + provider_registry: registry, + capability_manager: capability_manager.clone(), + pending_store: Arc::new( + elastos_runtime::capability::pending::PendingRequestStore::new(audit_log), + ), + capsule_id: "test-capsule".to_string(), + principal_id: None, + data_dir: None, + }); + + // With a valid capability: reaches the provider. + let line = serde_json::json!({ + "id": 1, + "request": { + "type": "carrier_invoke", + "uri": "elastos://inspect/capsules", + "operation": "capsules", + "token": token, + } + }) + .to_string(); + let resp = handle_request(&line, &ctx).await.unwrap(); + assert_eq!(resp["response"]["type"], "carrier_result"); + assert_eq!(resp["response"]["result"]["status"], "ok"); + assert!( + resp["response"]["result"].to_string().contains("probe"), + "carrier inspect did not reach the provider: {resp}" + ); + + // Without a token: rejected before dispatch. + let line_no_token = serde_json::json!({ + "id": 2, + "request": { + "type": "carrier_invoke", + "uri": "elastos://inspect/capsules", + "operation": "capsules", + } + }) + .to_string(); + let denied = handle_request(&line_no_token, &ctx).await.unwrap(); + assert_eq!(denied["response"]["code"], "missing_token"); + } + #[test] fn carrier_invoke_dispatch_uses_uri_resource_contract() { let dispatch = carrier_invoke_dispatch( From 78bbf4b4a677c770a29796d2daa472ae2e29bedc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 09:31:01 +0000 Subject: [PATCH 17/31] feat(inspect): surface provider authority + DDRM integration plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-branch analysis vs feat/ddrm-hardening-and-creator-parity showed the Inspector is the governance/visibility layer over DDRM's providers — but it was under-representing them: DDRM providers (key/decrypt/rights/encrypt/publish/ chain) express their powers via manifest `authority.capabilities[].operations`, not `interfaces[].methods`, which our projection dropped. - inspect_provider project(): surface declarative provider `authority` (reason + capabilities[resource/actions/operations] + audit_events). Now the Inspector shows what a provider can actually do (e.g. key release, decrypt render, rights decisions). Allow-listed, declarative-only — no secrets/handles (#16); + test (key-provider authority surfaced, signature still not leaked). - docs/INSPECT_DDRM_MERGE_NOTES.md: integration plan. No model divergence; merge order (DDRM first, inspect on top); conflict matrix (3 LOW, 1 HIGH); the HIGH is a SILENT semantic break — DDRM's required_action_for(op) omits inspect ops → defaults Admin → fail-closes our Read carrier path; required reconciliation documented. Coordination gap: v0.5/Anders line is not on the remote. Security: declarative authority metadata only; #16 preserved (tested). No DDRM file overlap in this change (project() is ours). Verified: cargo test -p elastos-server inspect — 20 passed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/INSPECT_DDRM_MERGE_NOTES.md | 81 +++++++++++++++++++ .../elastos-server/src/inspect_provider.rs | 66 +++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 docs/INSPECT_DDRM_MERGE_NOTES.md diff --git a/docs/INSPECT_DDRM_MERGE_NOTES.md b/docs/INSPECT_DDRM_MERGE_NOTES.md new file mode 100644 index 00000000..d277b7a7 --- /dev/null +++ b/docs/INSPECT_DDRM_MERGE_NOTES.md @@ -0,0 +1,81 @@ +# Inspect ↔ DDRM integration notes + +How `feat/capsule-inspector` connects with `feat/ddrm-hardening-and-creator-parity`. +Both branch from `main` (= `0.4.0`). Cross-branch analysis, non-destructive. + +## Verdict + +**No model divergence.** DDRM adds no competing inspect/introspection surface and +does not restructure the capability or `ProviderRegistry` model the Inspector +depends on. The Inspector is cleanly the **governance/visibility layer over +DDRM's providers** — it reflects their authority/affordances and previews the +gate a call would require, adding no provider authority of its own. This is the +agent-safe-computing commercial wedge, made concrete over real DDRM powers +(key release, decrypt render, rights decisions, chain broadcast). + +**Merge order:** land DDRM first (hardening base + provider fleet), then +rebase/merge `feat/capsule-inspector` on top. + +## Conflict matrix (3 LOW, 1 HIGH) + +| File | Risk | Why | +| --- | --- | --- | +| `runtime.rs` | NONE | DDRM does not touch it; our `RunningCapsuleInfo.manifest` field is sole. | +| `gateway_provider_proxy.rs` | NONE | DDRM does not touch it; our `inspect` allow-list arm is sole. | +| `provider/registry.rs` | LOW | Additive & ~220 lines apart: DDRM appends `encrypt`/`publish`/`media` to `RESERVED_SUB_NAMES`; we add `sub_provider_schemes()`. (Bonus: those new sub-names become discoverable through our method for free.) | +| `provider_resource.rs` | LOW | DDRM adds a new fn `required_action_for(op)` above the match + an `Action` import; we add one `"inspect"` arm inside `build_capability_resource`. Different regions; only the `use` line needs a trivial textual merge. | +| `carrier_bridge.rs` | **HIGH** | See below — silent semantic break. | + +## The HIGH conflict (must fix by hand at merge — it auto-merges *green* but breaks at runtime) + +DDRM rewrote the carrier capability gate in `handle_request`: instead of +`validate(…, token.action(), …)` it computes +`required_action_for(&dispatch.operation)` and validates against *that*. +DDRM's `required_action_for` map does **not** list inspect ops, so they hit its +`_ => Action::Admin` fail-closed default. + +Consequence after merge: a carrier `inspect` call gated at `Read` (our model, +our test) is required to be `Admin` → **`capability_denied`**. This breaks the +live carrier inspect leg *and* our test +`carrier_invoke_reaches_inspect_provider_with_capability`. The two edits sit +~430 lines apart, so **git auto-merges without a conflict marker** — the break +is silent. + +**Required reconciliation (in `required_action_for`):** + +```rust +"capsules" | "capsule" | "plan" | "self" => Action::Read, +"revoke" => Action::Write, +``` + +Plus reconcile the duplicate `use …provider_resource::{…}` import. + +## Synergy: DDRM providers the Inspector now reflects + +DDRM provider capsules express powers via `authority.capabilities[]` +(resource / actions / operations) + `audit_events` — **not** `interfaces[].methods`. +Our projection now surfaces `authority` (this commit), so these are visible: + +- **key-provider** `elastos://key/*` — ops `status`, **`release`**; audits `key.release.denied`. +- **decrypt-provider** `elastos://decrypt/*` — ops `open_session`, **`render`**. +- **rights-provider** `elastos://rights/*` — `has_access_by_content_id`, `can_stream`, `can_download`. +- **encrypt-provider** `elastos://encrypt/*` — **`seal`**. +- **publish-provider** `elastos://publish/*` — **`prepare_publish`**. +- **chain-provider** `elastos://chain/*` — **`broadcast_transaction`**, `node_lifecycle`. + +Auto-pickup confirmed: `CatalogInspectSource` reads each capsule's `capsule.json` +and `RegistryInspectSource` lists `schemes() ∪ sub_provider_schemes()`, so DDRM's +providers and sub-names appear with no inspect-side code change. + +## Follow-ups this implies + +- **At merge:** apply the `required_action_for` reconciliation above (HIGH). +- **Invoke-dispatch (the wedge's "act" step):** build it to consult DDRM's + `required_action_for` as the authoritative op→action classifier, so the + planner's gate matches what the carrier bridge enforces. Best done *after* the + DDRM merge so that map is present. +- **`build_capsule_view` parity:** the embedded-runtime projection + (`request_handler.rs`) should mirror the new `authority` field for #12 parity. +- **Coordination gap:** the v0.5 / "v2" line (Anders) is **not on the remote**, + so it could not be diffed. Push it (or a snapshot) to enable the same + connection check before it and these two branches converge. diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index f8ac0a9e..954d863d 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -432,6 +432,23 @@ impl InspectProvider { .unwrap_or(false); let cid = entry.cid.clone(); + // Provider authority — the declarative powers a provider capsule is + // authorized for (resource/actions/operations + audit events). DDRM and + // other provider capsules express their real powers here, not via + // interface methods, so surfacing it is what makes the Inspector show + // what a provider can actually do (e.g. key release, decrypt render, + // rights decisions). Declarative metadata only — no secrets/handles. + let authority = manifest + .get("authority") + .map(|a| { + json!({ + "reason": a.get("reason").cloned().unwrap_or(Value::Null), + "capabilities": a.get("capabilities").cloned().unwrap_or(Value::Null), + "audit_events": a.get("audit_events").cloned().unwrap_or(Value::Null), + }) + }) + .unwrap_or(Value::Null); + // Affordances: flatten declared interface methods. let mut affordances = Vec::new(); if let Some(interfaces) = manifest.get("interfaces").and_then(|v| v.as_array()) { @@ -479,6 +496,8 @@ impl InspectProvider { "entrypoint": field(&manifest, "entrypoint"), }, "affordances": affordances, + // Provider powers (declarative authority), for provider capsules. + "authority": authority, "required_capabilities": field(&manifest, "capabilities"), // Bearer-token object-capabilities have no central per-capsule grant // registry; observed grants come from the audit plane (not yet wired @@ -1031,6 +1050,53 @@ mod tests { assert_eq!(resp["data"]["identity"]["cid"], "bafyprobecid"); } + #[tokio::test] + async fn detail_surfaces_provider_authority() { + // A provider capsule (DDRM-style) expresses its powers via `authority`, + // not interface methods — the Inspector must surface them. + let manifest = serde_json::from_value::(json!({ + "schema": "elastos.capsule/v1", + "version": "0.1.0", + "name": "key-provider", + "role": "provider", + "type": "microvm", + "entrypoint": "rootfs.ext4", + "provides": "elastos://key/*", + "authority": { + "reason": "Runtime key-release boundary for protected content", + "capabilities": [ + { "resource": "elastos://key/*", "actions": ["read"], + "operations": ["status", "release"] } + ], + "audit_events": ["key.status", "key.release.denied"] + }, + "signature": "SECRET_SIGNATURE_MUST_NOT_LEAK" + })) + .expect("provider manifest deserializes"); + + let entry = InspectEntry { + id: "capsule:key-provider".to_string(), + name: "key-provider".to_string(), + status: "running".to_string(), + capsule_type: "microvm".to_string(), + manifest: Some(manifest), + cid: None, + }; + let provider = InspectProvider::new(Arc::new(MockSource { entries: vec![entry] })); + let resp = provider + .send_raw(&json!({ "op": "capsule", "id": "capsule:key-provider" })) + .await + .unwrap(); + let data = &resp["data"]; + assert_eq!(data["authority"]["capabilities"][0]["resource"], "elastos://key/*"); + assert_eq!(data["authority"]["capabilities"][0]["operations"][1], "release"); + assert_eq!(data["authority"]["audit_events"][1], "key.release.denied"); + // #16: the raw signature is still never echoed. + assert!(!serde_json::to_string(data) + .unwrap() + .contains("SECRET_SIGNATURE_MUST_NOT_LEAK")); + } + // Minimal provider used only to register a scheme in the registry. struct MockSchemeProvider; From 28ed9b9211e29c3cd2a1a0e716c7bdbcf4516ab1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 09:33:07 +0000 Subject: [PATCH 18/31] feat(inspect-ui): render provider authority in the glass box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend-only (no Rust build, no DDRM file overlap). The Inspector UI now shows a "Provider authority (powers)" card — reason + each capability's resource/actions/operations + audit events — so an operator visually sees what a provider capsule can actually do (e.g. wallet sign, and DDRM's key release / decrypt render / rights decisions once those manifests are present). Makes the agent-safe-governance wedge demoable. Sample wallet-provider enriched with authority so it renders standalone. Verified: node --check inspector.js. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- .../capsule-inspector/inspector/inspector.js | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/capsules/capsule-inspector/inspector/inspector.js b/capsules/capsule-inspector/inspector/inspector.js index 019d46ec..3e5cb48f 100644 --- a/capsules/capsule-inspector/inspector/inspector.js +++ b/capsules/capsule-inspector/inspector/inspector.js @@ -171,6 +171,31 @@ function renderAffordances(affordances) { return wrap; } +// Provider authority — the declarative powers a provider capsule is authorized +// for. This is what makes a provider's real capabilities (key release, decrypt +// render, rights decisions, chain broadcast) visible in the glass box. +function renderAuthority(authority) { + const wrap = el("div"); + if (authority.reason) { + wrap.appendChild(el("div", { class: "note", text: authority.reason })); + } + for (const cap of authority.capabilities || []) { + const ops = (cap.operations || []).join(", "); + const acts = (cap.actions || []).join(", "); + wrap.appendChild(el("div", { class: "row" }, [ + el("span", { class: "mono grow", text: cap.resource }), + el("span", { class: "tag tag-sign", text: acts }), + el("span", { class: "tag mono", text: ops }), + ])); + } + for (const ev of authority.audit_events || []) { + wrap.appendChild(el("div", { class: "row" }, [ + el("span", { class: "mono", text: "audit: " + ev }), + ])); + } + return wrap; +} + function renderRequired(caps) { const wrap = el("div"); for (const r of caps || []) { @@ -259,6 +284,11 @@ function renderDetail(c) { // 3 affordances detail.appendChild(card("Affordances (slots / messages)", renderAffordances(c.affordances))); + // Provider powers (for provider capsules that declare authority). + if (c.authority && (c.authority.capabilities || c.authority.reason)) { + detail.appendChild(card("Provider authority (powers)", renderAuthority(c.authority))); + } + // 4 + 5 capabilities detail.appendChild(el("div", { class: "grid2" }, [ card("Required capabilities", renderRequired(c.required_capabilities)), @@ -344,6 +374,13 @@ const SAMPLE_DATA = [ { interface: "elastos.wallet/v1", id: "delete", risk: "privileged", approval: "user", audit: "full", description: "Delete account" }, ], required_capabilities: ["elastos://wallet/*"], + authority: { + reason: "Holds wallet keys; validates proof bindings and signs approved transactions without exposing key material.", + capabilities: [ + { resource: "elastos://wallet/*", actions: ["read", "sign"], operations: ["accounts", "sign", "delete"] }, + ], + audit_events: ["wallet.sign.denied", "wallet.delete"], + }, granted_capabilities: [ { resource: "elastos://wallet/*", action: "read", granted: true, token_id: "tok_w9z0" }, ], From b0938f0b30ec70d5bdf780a9a0d52ccc4f90b96d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 09:54:46 +0000 Subject: [PATCH 19/31] feat(inspect): build_capsule_view authority parity (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the provider-authority projection in the embedded-runtime path (RequestHandler::build_capsule_view) so both projections — product (inspect_provider) and embedded/agent (handle_inspect) — surface provider `authority` consistently (Principle #12: docs/code/tests agree). Same allow-listed, declarative-only shape (reason + capabilities + audit_events); #16 preserved. DDRM does not touch request_handler.rs — no conflict. Verified: cargo test -p elastos-runtime inspect — 21 passed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- .../elastos-runtime/src/handler/request_handler.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/elastos/crates/elastos-runtime/src/handler/request_handler.rs b/elastos/crates/elastos-runtime/src/handler/request_handler.rs index d41cf1de..217c8acc 100755 --- a/elastos/crates/elastos-runtime/src/handler/request_handler.rs +++ b/elastos/crates/elastos-runtime/src/handler/request_handler.rs @@ -1154,6 +1154,19 @@ impl RequestHandler { .map(|s| s.is_string()) .unwrap_or(false); + // Provider authority — declarative powers a provider capsule is + // authorized for (parity with the product-side inspect provider). + let authority = manifest + .get("authority") + .map(|a| { + json!({ + "reason": a.get("reason").cloned().unwrap_or(Value::Null), + "capabilities": a.get("capabilities").cloned().unwrap_or(Value::Null), + "audit_events": a.get("audit_events").cloned().unwrap_or(Value::Null), + }) + }) + .unwrap_or(Value::Null); + json!({ "id": id, "name": field(&manifest, "name"), @@ -1174,6 +1187,7 @@ impl RequestHandler { "entrypoint": field(&manifest, "entrypoint"), }, "affordances": affordances, + "authority": authority, "required_capabilities": field(&manifest, "capabilities"), "granted_capabilities": granted_capabilities, "storage_namespaces": manifest.pointer("/permissions/storage").cloned().unwrap_or(Value::Null), From ddf4040ec0f236d9f04a173fbeb24473b122493c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 10:10:16 +0000 Subject: [PATCH 20/31] docs(merge): validate DDRM integration by dry-run; correct the recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real merge dry-run (isolated worktree, nothing pushed) of feat/capsule-inspector onto the DDRM tip confirmed GO-WITH-NOTES and corrected the reconciliation list: - The merge auto-merges with ZERO conflict markers — including carrier_bridge.rs (the HIGH one). So the reconciliation is mandatory AND invisible to git. - The required_action_for inspect-ops fix is empirically proven: omitting it fails the carrier inspect test (capability_denied); restoring it passes. - NEW finding the static analysis missed: DDRM added an `audit_log` field to the shared GatewayState struct, so our gateway_tests/inspect.rs literal must add `audit_log: Arc::new(OnceLock::new())` or the lib-test target won't compile. - After both fixes: build OK; 20 inspect tests + carrier e2e + runtime tests pass. - registry.rs / carrier validate block / use-lines need no manual merge. Merge notes updated with the validated 2-step recipe. Our branch is unchanged by the dry-run (separate worktree). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/INSPECT_DDRM_MERGE_NOTES.md | 44 +++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/INSPECT_DDRM_MERGE_NOTES.md b/docs/INSPECT_DDRM_MERGE_NOTES.md index d277b7a7..325f6616 100644 --- a/docs/INSPECT_DDRM_MERGE_NOTES.md +++ b/docs/INSPECT_DDRM_MERGE_NOTES.md @@ -3,19 +3,55 @@ How `feat/capsule-inspector` connects with `feat/ddrm-hardening-and-creator-parity`. Both branch from `main` (= `0.4.0`). Cross-branch analysis, non-destructive. -## Verdict +## Verdict — GO-WITH-NOTES (validated by merge dry-run) **No model divergence.** DDRM adds no competing inspect/introspection surface and does not restructure the capability or `ProviderRegistry` model the Inspector depends on. The Inspector is cleanly the **governance/visibility layer over -DDRM's providers** — it reflects their authority/affordances and previews the +DDRM's providers** — reflecting their authority/affordances and previewing the gate a call would require, adding no provider authority of its own. This is the -agent-safe-computing commercial wedge, made concrete over real DDRM powers -(key release, decrypt render, rights decisions, chain broadcast). +agent-safe-computing commercial wedge, over real DDRM powers (key release, +decrypt render, rights decisions, chain broadcast). **Merge order:** land DDRM first (hardening base + provider fleet), then rebase/merge `feat/capsule-inspector` on top. +### Dry-run result (isolated worktree, nothing pushed) + +A real `git merge --no-commit` of our branch onto the DDRM tip was performed in +an isolated worktree, reconciled, built, and tested. Outcome: + +- **The merge auto-merges with ZERO conflict markers** — *every* file, including + the HIGH `carrier_bridge.rs`. Git will report success. **The reconciliation + below is therefore mandatory *and invisible to git* — it must be applied by + hand even though nothing conflicts.** +- After applying the recipe: `cargo build -p elastos-server` succeeds; **20 + inspect tests + the carrier e2e test + runtime inspect/invoke tests all pass.** +- The silent carrier-gate break was **empirically confirmed**: removing the + `required_action_for` inspect arm makes the carrier inspect test fail + `capability_denied`; restoring it passes. + +### Mandatory reconciliation recipe (apply in order at the real merge) + +1. **(critical, silent)** In `provider_resource.rs`, add to `required_action_for` + *before* the `_ => Action::Admin` default: + ```rust + "capsules" | "capsule" | "plan" | "self" => Action::Read, + "revoke" => Action::Write, + ``` + Without this, DDRM's op→action gate fail-closes the carrier inspect leg to + `Admin`. Git does **not** flag it. +2. **(compile-break, NOT previously documented)** DDRM added a new field + `audit_log: Arc>>` to the shared `GatewayState` + (`api/gateway.rs`). Our `api/gateway_tests/inspect.rs` constructs + `GatewayState { … }` literally and must add + `audit_log: Arc::new(std::sync::OnceLock::new()),` (the idiom DDRM uses in its + other gateway test constructors). Otherwise the lib-test target won't compile. +3. **No manual action needed** for `registry.rs`, the `carrier_bridge.rs` validate + block, and all `use`-line merges — git auto-merges these correctly, keeping + both sides' additions (the earlier note implying manual import reconciliation + was over-cautious). + ## Conflict matrix (3 LOW, 1 HIGH) | File | Risk | Why | From a4a032b8d52f7703b2a8a07d3d78f25552491b5d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 10:29:31 +0000 Subject: [PATCH 21/31] feat(inspect): preview the gate a provider operation would require MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend inspect/plan with a second reflective mode. Where the existing mode previews an interfaces[].methods affordance, the new "operation" mode reflects a provider capsule's authority.capabilities[] — how DDRM providers (key release, decrypt render, rights, chain broadcast) actually declare their powers — and returns the exact capability tuple a call would demand: the resource, the full set of actions a caller's capability must cover, and the audit events emitted. Dispatches nothing; derived entirely from the capsule's own metadata, so a preview can never under-state the gate the runtime later enforces. This is the agent-safe-computing wedge made tangible: "before I let this run, show me precisely what authority it asks for." - elastos-runtime::invoke: plan_provider_operation() + ProviderOperationPlan; parse_action() maps declared action strings fail-closed (unknown keyword is an error, never a silent drop that would weaken the gate). 4 unit tests. - elastos-common: re-export ProviderAuthority / ProviderCapabilitySchema. - inspect_provider: handle_plan now routes interface+method vs operation (never mixed; neither -> invalid_request, fail-closed). 3 handler tests. - inspector UI: each declared operation gets a "preview gate" button rendering the tuple inline; offline twin (localPlanOperation) mirrors the server contract so the demo works with sample data. - docs: CAPSULE_INSPECTOR.md plan wire-contract documents both modes. Gating unchanged: still the System-scoped, read-only "plan" op (no new op, no bypass). build OK; invoke 10 + inspect 23 + 751 server lib tests; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- .../capsule-inspector/inspector/inspector.js | 98 +++++++++- .../capsule-inspector/inspector/style.css | 15 ++ docs/CAPSULE_INSPECTOR.md | 37 +++- elastos/crates/elastos-common/src/lib.rs | 3 +- .../crates/elastos-runtime/src/invoke/mod.rs | 138 ++++++++++++++ .../elastos-server/src/inspect_provider.rs | 175 ++++++++++++++++-- 6 files changed, 433 insertions(+), 33 deletions(-) diff --git a/capsules/capsule-inspector/inspector/inspector.js b/capsules/capsule-inspector/inspector/inspector.js index 3e5cb48f..80340641 100644 --- a/capsules/capsule-inspector/inspector/inspector.js +++ b/capsules/capsule-inspector/inspector/inspector.js @@ -84,6 +84,15 @@ async function inspectRevoke(tokenId) { return await inspectInvoke("revoke", { token_id: tokenId }); } +// Reflective preview (read-only, dispatches nothing): ask the runtime what +// capability gate a provider *operation* would require. This is the agent-safe +// wedge made tangible — "before I let this run, show me exactly what authority +// it asks for." The answer is derived from the capsule's own `authority` +// metadata, so it can never under-state the gate the runtime later enforces. +async function planOperation(id, operation) { + return await inspectInvoke("plan", { id, operation }); +} + // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- @@ -174,19 +183,34 @@ function renderAffordances(affordances) { // Provider authority — the declarative powers a provider capsule is authorized // for. This is what makes a provider's real capabilities (key release, decrypt // render, rights decisions, chain broadcast) visible in the glass box. -function renderAuthority(authority) { +function renderAuthority(authority, capsuleId) { const wrap = el("div"); if (authority.reason) { wrap.appendChild(el("div", { class: "note", text: authority.reason })); } for (const cap of authority.capabilities || []) { - const ops = (cap.operations || []).join(", "); const acts = (cap.actions || []).join(", "); - wrap.appendChild(el("div", { class: "row" }, [ - el("span", { class: "mono grow", text: cap.resource }), - el("span", { class: "tag tag-sign", text: acts }), - el("span", { class: "tag mono", text: ops }), - ])); + // One row per operation so each declared power can be previewed on its own. + for (const op of cap.operations || []) { + const result = el("div", { class: "plan-result" }); + const previewBtn = el("button", { class: "btn-preview", text: "preview gate" }); + previewBtn.addEventListener("click", () => + runOperationPreview(capsuleId, op, result, previewBtn)); + wrap.appendChild(el("div", { class: "row" }, [ + el("span", { class: "mono grow", text: op }), + el("span", { class: "tag mono", text: cap.resource }), + el("span", { class: "tag tag-sign", text: acts }), + previewBtn, + ])); + wrap.appendChild(result); + } + // A capability block with no operations is still worth showing. + if (!(cap.operations || []).length) { + wrap.appendChild(el("div", { class: "row" }, [ + el("span", { class: "mono grow", text: cap.resource }), + el("span", { class: "tag tag-sign", text: acts }), + ])); + } } for (const ev of authority.audit_events || []) { wrap.appendChild(el("div", { class: "row" }, [ @@ -196,6 +220,64 @@ function renderAuthority(authority) { return wrap; } +// Offline twin of the server's plan_provider_operation: reflect the sample +// capsule's authority to derive the same gate when no live runtime is present. +// Mirrors the server contract exactly so sample and live render identically. +function localPlanOperation(capsuleId, operation) { + const capsule = SAMPLE_DATA.find((c) => c.id === capsuleId); + const authority = capsule && capsule.authority; + if (!authority) return null; + const block = (authority.capabilities || []).find((cap) => + (cap.operations || []).includes(operation)); + if (!block) return { valid: false, error: "unknown_operation", operation }; + return { + valid: true, + kind: "operation", + resource: block.resource, + capability_actions: block.actions || [], + audit_events: authority.audit_events || [], + }; +} + +// Run a read-only gate preview for one provider operation and render the exact +// capability tuple it would require, inline beneath the operation row. +async function runOperationPreview(capsuleId, operation, target, btn) { + if (!capsuleId) return; + btn.disabled = true; + target.innerHTML = ""; + try { + let plan; + try { + plan = await planOperation(capsuleId, operation); + } catch (liveErr) { + // No live runtime (sample/offline): derive the same preview from the + // capsule's in-memory authority metadata so the demo still works. + plan = localPlanOperation(capsuleId, operation); + if (!plan) throw liveErr; + } + if (plan && plan.valid) { + const actions = (plan.capability_actions || []).join(" + "); + target.appendChild(el("div", { class: "plan-ok" }, [ + el("span", { class: "note", text: "requires a capability covering" }), + el("span", { class: "mono", text: plan.resource }), + el("span", { class: "tag tag-sign", text: "action: " + actions }), + ])); + for (const ev of plan.audit_events || []) { + target.appendChild(el("div", { class: "audit-line" }, [ + el("span", { class: "mono", text: "audits: " + ev }), + ])); + } + } else { + const why = (plan && plan.error) || "unknown"; + target.appendChild(el("div", { class: "plan-deny", text: "no preview: " + why })); + } + } catch (err) { + target.appendChild(el("div", { class: "plan-deny", text: "preview failed: " + err.message })); + } finally { + btn.disabled = false; + } +} + function renderRequired(caps) { const wrap = el("div"); for (const r of caps || []) { @@ -286,7 +368,7 @@ function renderDetail(c) { // Provider powers (for provider capsules that declare authority). if (c.authority && (c.authority.capabilities || c.authority.reason)) { - detail.appendChild(card("Provider authority (powers)", renderAuthority(c.authority))); + detail.appendChild(card("Provider authority (powers)", renderAuthority(c.authority, c.id))); } // 4 + 5 capabilities diff --git a/capsules/capsule-inspector/inspector/style.css b/capsules/capsule-inspector/inspector/style.css index 3bbb945c..e94675c4 100644 --- a/capsules/capsule-inspector/inspector/style.css +++ b/capsules/capsule-inspector/inspector/style.css @@ -133,3 +133,18 @@ html, body { margin-top: 4px; font-size: 12px; color: var(--muted); border-left: 2px solid var(--line); padding-left: 10px; } + +/* Read-only gate preview for a provider operation. */ +.btn-preview { + font: inherit; font-size: 11.5px; cursor: pointer; + color: var(--accent); background: transparent; + border: 1px solid #1f3344; border-radius: 5px; padding: 2px 9px; +} +.btn-preview:hover { background: #11202c; } +.btn-preview:disabled { opacity: 0.5; cursor: default; } +.plan-result:not(:empty) { + margin: 2px 0 8px 10px; padding-left: 10px; + border-left: 2px solid #1f3344; +} +.plan-ok { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 3px 0; } +.plan-deny { color: var(--deny); font-size: 12px; padding: 3px 0; } diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index 1248cc0c..a93aab1b 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -364,14 +364,21 @@ The one mutating endpoint. Requires a **`Write`** inspect capability at a malformed id. The action is audited (`inspect.revoke`) in addition to the capability manager's own revocation audit. -### `elastos://inspect/plan` (params: `{ "id", "interface", "method", "args" }`) — read +### `elastos://inspect/plan` — read (two reflective modes, never mixed) Metadata-driven invocation **preview** (read-only dry-run; dispatches no -effect). Looks up the affordance's typed metadata, validates `args` against its +effect). It answers, *before* anything runs, "what authority would this ask +for?" — derived entirely from the capsule's own metadata, so a preview can +never under-state the gate the runtime later enforces. This is the agent-safe +wedge made concrete and the reflective half of the CAR invoke kernel +(`elastos-runtime::invoke`). + +**Mode A — affordance** (params: `{ "id", "interface", "method", "args" }`). +Looks up the `interfaces[].methods` affordance, validates `args` against its `input_schema`, and returns the gate the call *would* require: ```json -{ "valid": true, "capability_action": "write", "approval": "user", "audit": "event" } +{ "valid": true, "kind": "affordance", "capability_action": "write", "approval": "user", "audit": "event" } ``` or, when the args don't satisfy the contract: @@ -380,9 +387,29 @@ or, when the args don't satisfy the contract: { "valid": false, "error": "missing_required_field", "field": "body" } ``` -This is the reflective half of the CAR invoke kernel (`elastos-runtime::invoke`). +**Mode B — provider operation** (params: `{ "id", "operation" }`). Provider +capsules (DDRM: key release, decrypt render, rights, chain broadcast) express +their powers via `authority.capabilities[]`, not interface methods. This mode +reflects that block to return the exact capability tuple — resource + the full +set of actions a caller's capability must cover — plus the audit events the +provider emits: + +```json +{ "valid": true, "kind": "operation", "resource": "elastos://key/*", + "capability_actions": ["execute"], "audit_events": ["key.release.denied", "key.release.granted"] } +``` + +The action set is surfaced **whole**, never collapsed to one, and an action +keyword the capability layer doesn't recognise is a hard `manifest_error` +(fail-closed) rather than a silent drop — the preview cannot under-state the +gate. An operation no capability block declares returns +`{ "valid": false, "error": "unknown_operation" }`. + Effect *dispatch* (and the location-agnostic Carrier / cross-language transport) -is intentionally not implemented — that architecture is to be planned. +is intentionally not implemented — that architecture is to be planned. When the +DDRM branch lands, dispatch should consult its `required_action_for` op→action +map as the authoritative classifier so the planner's preview and the carrier +bridge's enforcement agree by construction. ### Why `granted_capabilities` is observed, not enumerated diff --git a/elastos/crates/elastos-common/src/lib.rs b/elastos/crates/elastos-common/src/lib.rs index ae54e0d5..8cc1fbb5 100644 --- a/elastos/crates/elastos-common/src/lib.rs +++ b/elastos/crates/elastos-common/src/lib.rs @@ -12,7 +12,8 @@ pub use error::{ElastosError, Result}; pub use manifest::{ AffordanceApprovalMode, AffordanceAuditMode, AffordanceRisk, CapsuleAffordanceDescriptor, CapsuleInterfaceDescriptor, CapsuleManifest, CapsuleRequirement, CapsuleRole, CapsuleType, - MicroVmConfig, Permissions, RequirementKind, ResourceLimits, SCHEMA_V1, + MicroVmConfig, Permissions, ProviderAuthority, ProviderCapabilitySchema, RequirementKind, + ResourceLimits, SCHEMA_V1, }; pub use timestamp::{SecureTimestamp, CLOCK_SKEW_TOLERANCE_SECS}; pub use types::{CapsuleId, CapsuleStatus}; diff --git a/elastos/crates/elastos-runtime/src/invoke/mod.rs b/elastos/crates/elastos-runtime/src/invoke/mod.rs index 23b32c1b..2a440659 100644 --- a/elastos/crates/elastos-runtime/src/invoke/mod.rs +++ b/elastos/crates/elastos-runtime/src/invoke/mod.rs @@ -19,6 +19,7 @@ use elastos_common::{ AffordanceApprovalMode, AffordanceAuditMode, AffordanceRisk, CapsuleAffordanceDescriptor, + ProviderAuthority, }; use serde_json::Value; @@ -31,6 +32,11 @@ pub enum InvokeError { InputTypeMismatch { expected: String }, /// A required input field was missing. MissingRequiredField(String), + /// The operation is not declared by any of the provider's capability blocks. + UnknownOperation(String), + /// The manifest declares an action string the capability layer does not know. + /// Surfaced (not silently dropped) so the gate is never under-stated. + UnknownDeclaredAction(String), } /// The validated plan for an invocation: the capability action it requires, @@ -116,6 +122,69 @@ pub fn plan( }) } +/// The capability gate a provider *operation* would require, derived from the +/// capsule's self-describing `authority` metadata — the resource it touches, the +/// action(s) a caller's capability must cover, and the audit events it emits. +/// +/// This is the provider-side twin of [`InvocationPlan`]: where `plan` reflects an +/// `interfaces[].methods` affordance, this reflects an `authority.capabilities[]` +/// operation (how DDRM providers — key release, decrypt, rights, chain — declare +/// their powers). It dispatches nothing; it answers "what authority would this +/// ask for?" straight from the manifest, so the preview can never under-state a +/// gate the runtime would later enforce. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderOperationPlan { + /// The capability resource URI the operation acts on (e.g. `elastos://key/*`). + pub resource: String, + /// Every action a caller's capability must cover for this resource. Surfaced + /// as the full set (not collapsed to one) so nothing the manifest demands is + /// hidden from the reviewer. + pub actions: Vec, + /// The audit events this provider declares it emits. + pub audit_events: Vec, +} + +/// Parse a manifest-declared action string into a capability [`Action`], +/// fail-closed: an unrecognised keyword is an error, never a silent no-op. +fn parse_action(s: &str) -> Result { + match s { + "read" => Ok(Action::Read), + "write" => Ok(Action::Write), + "execute" => Ok(Action::Execute), + "message" => Ok(Action::Message), + "delete" => Ok(Action::Delete), + "admin" => Ok(Action::Admin), + other => Err(InvokeError::UnknownDeclaredAction(other.to_string())), + } +} + +/// Preview the capability gate a provider `operation` would require, by reflecting +/// the capsule's `authority` metadata. Finds the capability block that declares +/// the operation and returns its resource + required actions + the authority's +/// audit events. Dispatches nothing. +pub fn plan_provider_operation( + authority: &ProviderAuthority, + operation: &str, +) -> Result { + let capability = authority + .capabilities + .iter() + .find(|cap| cap.operations.iter().any(|op| op == operation)) + .ok_or_else(|| InvokeError::UnknownOperation(operation.to_string()))?; + + let actions = capability + .actions + .iter() + .map(|a| parse_action(a)) + .collect::, _>>()?; + + Ok(ProviderOperationPlan { + resource: capability.resource.clone(), + actions, + audit_events: authority.audit_events.clone(), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -125,6 +194,10 @@ mod tests { serde_json::from_value(value).expect("affordance descriptor") } + fn authority(value: serde_json::Value) -> ProviderAuthority { + serde_json::from_value(value).expect("provider authority") + } + #[test] fn risk_maps_to_capability_action() { assert_eq!(required_action(&AffordanceRisk::Read), Action::Read); @@ -193,4 +266,69 @@ mod tests { assert!(plan(&a, &json!({ "anything": [1, 2, 3] })).is_ok()); assert!(plan(&a, &json!("scalar")).is_ok()); } + + #[test] + fn provider_op_plan_reflects_the_authority_block() { + // Mirrors DDRM's key-provider: status is Read, release is Execute, on the + // same elastos://key/* resource, with declared audit events. + let auth = authority(json!({ + "reason": "release content keys to authorized renderers", + "capabilities": [ + { "resource": "elastos://key/*", "actions": ["read"], "operations": ["status"] }, + { "resource": "elastos://key/*", "actions": ["execute"], "operations": ["release"] } + ], + "audit_events": ["key.release.denied", "key.release.granted"] + })); + + let release = plan_provider_operation(&auth, "release").unwrap(); + assert_eq!(release.resource, "elastos://key/*"); + assert_eq!(release.actions, vec![Action::Execute]); + assert!(release.audit_events.iter().any(|e| e == "key.release.denied")); + + let status = plan_provider_operation(&auth, "status").unwrap(); + assert_eq!(status.actions, vec![Action::Read]); + } + + #[test] + fn provider_op_plan_rejects_unknown_operation() { + let auth = authority(json!({ + "reason": "x", "audit_events": ["a"], + "capabilities": [ + { "resource": "elastos://key/*", "actions": ["read"], "operations": ["status"] } + ] + })); + assert_eq!( + plan_provider_operation(&auth, "release").unwrap_err(), + InvokeError::UnknownOperation("release".to_string()) + ); + } + + #[test] + fn provider_op_plan_fails_closed_on_unknown_action() { + // A manifest action keyword the capability layer does not know must error, + // never be silently dropped (which would under-state the gate). + let auth = authority(json!({ + "reason": "x", "audit_events": ["a"], + "capabilities": [ + { "resource": "elastos://key/*", "actions": ["teleport"], "operations": ["release"] } + ] + })); + assert_eq!( + plan_provider_operation(&auth, "release").unwrap_err(), + InvokeError::UnknownDeclaredAction("teleport".to_string()) + ); + } + + #[test] + fn provider_op_plan_surfaces_full_action_set() { + let auth = authority(json!({ + "reason": "x", "audit_events": ["a"], + "capabilities": [ + { "resource": "elastos://chain/*", "actions": ["execute", "admin"], + "operations": ["broadcast_transaction"] } + ] + })); + let plan = plan_provider_operation(&auth, "broadcast_transaction").unwrap(); + assert_eq!(plan.actions, vec![Action::Execute, Action::Admin]); + } } diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index 954d863d..63c32ac5 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -587,39 +587,58 @@ impl InspectProvider { } async fn handle_plan(&self, request: &Value) -> Value { - let (id, interface, method) = match ( - request.get("id").and_then(Value::as_str), - request.get("interface").and_then(Value::as_str), - request.get("method").and_then(Value::as_str), - ) { - (Some(id), Some(i), Some(m)) => (id, i, m), - _ => { - return provider_error( - "invalid_request", - "inspect/plan requires \"id\", \"interface\", and \"method\"", - ) - } + let id = match request.get("id").and_then(Value::as_str) { + Some(id) => id, + None => return provider_error("invalid_request", "inspect/plan requires an \"id\""), }; - let args = request.get("args").cloned().unwrap_or(json!({})); - let entry = match self.source.inspect_get(id).await { Some(entry) => entry, None => return provider_error("not_found", "no such capsule"), }; - let affordance = match entry - .manifest - .as_ref() - .and_then(|m| find_affordance(m, interface, method)) - { + let manifest = match entry.manifest.as_ref() { + Some(m) => m, + None => return provider_error("not_found", "capsule has no manifest to plan against"), + }; + + // Two reflective modes, never mixed: + // - interface/method → preview an affordance call (interfaces[].methods); + // - operation → preview a provider power (authority.capabilities[]). + match ( + request.get("interface").and_then(Value::as_str), + request.get("method").and_then(Value::as_str), + request.get("operation").and_then(Value::as_str), + ) { + (Some(interface), Some(method), None) => { + Self::plan_affordance(manifest, interface, method, request) + } + (None, None, Some(operation)) => Self::plan_operation(manifest, operation), + _ => provider_error( + "invalid_request", + "inspect/plan requires either \"interface\"+\"method\" or \"operation\"", + ), + } + } + + /// Affordance preview: validate args against the input schema and derive the + /// capability/approval/audit gate. + fn plan_affordance( + manifest: &CapsuleManifest, + interface: &str, + method: &str, + request: &Value, + ) -> Value { + let affordance = match find_affordance(manifest, interface, method) { Some(a) => a, None => return provider_error("not_found", "no such affordance"), }; + let args = request.get("args").cloned().unwrap_or(json!({})); match invoke::plan(&affordance, &args) { Ok(plan) => json!({ "status": "ok", "data": { "valid": true, + "kind": "affordance", // The gate the runtime would enforce for this call. "capability_action": plan.capability_action.to_string(), "approval": serde_json::to_value(&plan.approval).ok(), @@ -635,6 +654,48 @@ impl InspectProvider { "status": "ok", "data": { "valid": false, "error": "input_type_mismatch", "expected": expected } }), + // Affordance planning never raises the provider-authority variants. + Err(other) => provider_error("invalid_request", &format!("{other:?}")), + } + } + + /// Provider-power preview: reflect the `authority` metadata to show the exact + /// capability tuple (resource + actions) an operation demands. Read-only. + fn plan_operation(manifest: &CapsuleManifest, operation: &str) -> Value { + let authority = match manifest.authority.as_ref() { + Some(a) => a, + None => { + return provider_error( + "invalid_request", + "capsule declares no provider authority to plan against", + ) + } + }; + match invoke::plan_provider_operation(authority, operation) { + Ok(plan) => json!({ + "status": "ok", + "data": { + "valid": true, + "kind": "operation", + "resource": plan.resource, + // Every action a caller's capability must cover (full set). + "capability_actions": plan + .actions + .iter() + .map(|a| a.to_string()) + .collect::>(), + "audit_events": plan.audit_events, + } + }), + Err(InvokeError::UnknownOperation(op)) => json!({ + "status": "ok", + "data": { "valid": false, "error": "unknown_operation", "operation": op } + }), + Err(InvokeError::UnknownDeclaredAction(action)) => provider_error( + "manifest_error", + &format!("authority declares an unknown action \"{action}\""), + ), + Err(other) => provider_error("invalid_request", &format!("{other:?}")), } } } @@ -1097,6 +1158,82 @@ mod tests { .contains("SECRET_SIGNATURE_MUST_NOT_LEAK")); } + // A DDRM-style provider capsule whose powers live in `authority`, used to + // exercise the operation-preview leg of inspect/plan. + fn key_provider_with_release() -> InspectProvider { + let manifest = serde_json::from_value::(json!({ + "schema": "elastos.capsule/v1", + "version": "0.1.0", + "name": "key-provider", + "role": "provider", + "type": "microvm", + "entrypoint": "rootfs.ext4", + "provides": "elastos://key/*", + "authority": { + "reason": "Runtime key-release boundary for protected content", + "capabilities": [ + { "resource": "elastos://key/*", "actions": ["read"], + "operations": ["status"] }, + { "resource": "elastos://key/*", "actions": ["execute"], + "operations": ["release"] } + ], + "audit_events": ["key.release.denied", "key.release.granted"] + } + })) + .expect("provider manifest deserializes"); + let entry = InspectEntry { + id: "capsule:key-provider".to_string(), + name: "key-provider".to_string(), + status: "running".to_string(), + capsule_type: "microvm".to_string(), + manifest: Some(manifest), + cid: None, + }; + InspectProvider::new(Arc::new(MockSource { entries: vec![entry] })) + } + + #[tokio::test] + async fn plan_previews_provider_operation_gate() { + // The agent-safe wedge: preview the exact capability tuple key.release + // demands, straight from the manifest authority — dispatching nothing. + let resp = key_provider_with_release() + .send_raw(&json!({ + "op": "plan", "id": "capsule:key-provider", "operation": "release" + })) + .await + .unwrap(); + assert_eq!(resp["status"], "ok"); + assert_eq!(resp["data"]["valid"], true); + assert_eq!(resp["data"]["kind"], "operation"); + assert_eq!(resp["data"]["resource"], "elastos://key/*"); + assert_eq!(resp["data"]["capability_actions"][0], "execute"); + assert_eq!(resp["data"]["audit_events"][0], "key.release.denied"); + } + + #[tokio::test] + async fn plan_unknown_operation_reports_invalid() { + let resp = key_provider_with_release() + .send_raw(&json!({ + "op": "plan", "id": "capsule:key-provider", "operation": "self_destruct" + })) + .await + .unwrap(); + assert_eq!(resp["status"], "ok"); + assert_eq!(resp["data"]["valid"], false); + assert_eq!(resp["data"]["error"], "unknown_operation"); + } + + #[tokio::test] + async fn plan_rejects_mixed_or_empty_selector() { + // Neither interface/method nor operation → invalid_request (fail-closed). + let resp = key_provider_with_release() + .send_raw(&json!({ "op": "plan", "id": "capsule:key-provider" })) + .await + .unwrap(); + assert_eq!(resp["status"], "error"); + assert_eq!(resp["code"], "invalid_request"); + } + // Minimal provider used only to register a scheme in the registry. struct MockSchemeProvider; From 629b95c17dc895914dd4cca6f37ea6e15022d645 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 10:54:52 +0000 Subject: [PATCH 22/31] feat(inspect): enforce merge gate-contract + derive honest provenance Two parallel-safe hardening increments (no DDRM dependency). 1. Merge tripwire for the inspect op->action contract. provider_resource::inspect_op_required_action is now the single source of truth (reads = Read; revoke = Write; unknown = None, fail-closed). The carrier test carrier_inspect_ops_match_canonical_action_contract drives a real carrier_invoke per inspect op with a token minted at that action and asserts it clears the capability gate. Today our gate validates token.action() so it passes by construction; when DDRM lands and the gate becomes validate(.., required_action_for(op), ..), any inspect op the DDRM map omits fail-closes to Action::Admin and this test goes RED at merge - instead of breaking silently through git's clean auto-merge. Converts the documented reconciliation note into an enforced invariant. 2. Provenance derived honestly, never fabricated (#15, #11, #16). project() now computes a fail-closed trust_level (signed -> content-addressed -> unsigned), a non-secret 16-hex signature_fingerprint (SHA-256 over the decoded signature - identifies which signature without echoing it), the self-declared author, and a did ONLY when one genuinely exists (capsule id or did: author). A verified signer (signed_by) stays null: the manifest schema carries no signer DID/pubkey, so the author is never presented as verified. UI provenance card + docs updated. Tests: invoke 10, inspect 29 (+6), provider_resource contract pinned, carrier tripwire green; 757 server lib tests pass. New code clippy-clean. (Three pre-existing clippy nits remain in request_handler.rs / gateway_capsule_catalog.rs, outside this change surface.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- .../capsule-inspector/inspector/inspector.js | 7 +- docs/CAPSULE_INSPECTOR.md | 12 +- docs/INSPECT_DDRM_MERGE_NOTES.md | 12 +- .../elastos-server/src/carrier_bridge.rs | 91 ++++++++++++++ .../elastos-server/src/inspect_provider.rs | 118 +++++++++++++++++- .../elastos-server/src/provider_resource.rs | 38 ++++++ 6 files changed, 271 insertions(+), 7 deletions(-) diff --git a/capsules/capsule-inspector/inspector/inspector.js b/capsules/capsule-inspector/inspector/inspector.js index 80340641..b74f9e96 100644 --- a/capsules/capsule-inspector/inspector/inspector.js +++ b/capsules/capsule-inspector/inspector/inspector.js @@ -386,7 +386,12 @@ function renderDetail(c) { ])), ])); detail.appendChild(card("Provenance", kv([ - ["signed by", prov.signed_by], ["version", prov.version], + ["author (declared)", prov.author], + ["signed by (verified)", prov.signed_by], + ["trust level", prov.trust_level], + ["signature", prov.signature_present ? "present" : "none"], + ["signature fingerprint", prov.signature_fingerprint], + ["version", prov.version], ["installed", fmtTime(prov.installed_at)], ["CID", prov.cid], ]))); diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index a93aab1b..a02f498c 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -266,13 +266,23 @@ async workers aren't stalled. Records are projected to safe fields only Inspector lists installed capsules with their full manifests** and running status. +**Provenance is derived, never fabricated (#15, #11).** The projection computes +only what the evidence supports: a fail-closed `trust_level` +(`signed` → `content-addressed` → `unsigned`), a non-secret 16-hex +`signature_fingerprint` (SHA-256 over the decoded signature — identifies *which* +signature without echoing it, #16), the self-declared `author`, and a `did` +**only** when one genuinely exists (the capsule id or a `did:` author). A +*verified* signer (`signed_by`) stays `null`: the manifest schema carries no +signer DID/pubkey, so we refuse to present the author as if verified. Wiring a +real signature-verification source is the next provenance step. + **Remaining enrichment (honest gap):** `granted_capabilities` is still empty on the product path. ElastOS capabilities are bearer tokens with no central per-capsule registry, and `RuntimeAuditEventV1` carries no resource/action — so deriving the observed grant list needs a capability-event source that records resource + action. Everything else is done: projection, scope, no-leak, transport wiring, source aggregation, rich manifest detail, sub-provider -running-status coverage, and live audit (recent + counts). +running-status coverage, live audit (recent + counts), and derived provenance. The UI adapter targets the Carrier-shaped `inspect/` contract and degrades to sample data until the data source is populated on the browser path. diff --git a/docs/INSPECT_DDRM_MERGE_NOTES.md b/docs/INSPECT_DDRM_MERGE_NOTES.md index 325f6616..692cdaae 100644 --- a/docs/INSPECT_DDRM_MERGE_NOTES.md +++ b/docs/INSPECT_DDRM_MERGE_NOTES.md @@ -33,14 +33,20 @@ an isolated worktree, reconciled, built, and tested. Outcome: ### Mandatory reconciliation recipe (apply in order at the real merge) -1. **(critical, silent)** In `provider_resource.rs`, add to `required_action_for` - *before* the `_ => Action::Admin` default: +1. **(critical, silent — now ENFORCED by a tripwire)** In `provider_resource.rs`, + add to `required_action_for` *before* the `_ => Action::Admin` default: ```rust "capsules" | "capsule" | "plan" | "self" => Action::Read, "revoke" => Action::Write, ``` Without this, DDRM's op→action gate fail-closes the carrier inspect leg to - `Admin`. Git does **not** flag it. + `Admin`. Git does **not** flag it — but our branch now does. The canonical + mapping lives in one place, `provider_resource::inspect_op_required_action`, + and the test `carrier_inspect_ops_match_canonical_action_contract` + (carrier_bridge.rs) drives a real carrier call per inspect op with a token + minted at that action. The moment `required_action_for` disagrees, that test + goes red at merge instead of breaking silently at runtime. Wire DDRM's inspect + arm to delegate to `inspect_op_required_action` so the two cannot drift. 2. **(compile-break, NOT previously documented)** DDRM added a new field `audit_log: Arc>>` to the shared `GatewayState` (`api/gateway.rs`). Our `api/gateway_tests/inspect.rs` constructs diff --git a/elastos/crates/elastos-server/src/carrier_bridge.rs b/elastos/crates/elastos-server/src/carrier_bridge.rs index b32306da..c5557b12 100644 --- a/elastos/crates/elastos-server/src/carrier_bridge.rs +++ b/elastos/crates/elastos-server/src/carrier_bridge.rs @@ -1342,6 +1342,97 @@ mod tests { assert_eq!(denied["response"]["code"], "missing_token"); } + // MERGE TRIPWIRE. For every inspect op the product provider serves, a token + // minted at the *canonical* action (provider_resource::inspect_op_required_action) + // must pass the carrier capability gate and reach the provider. Today our gate + // validates token.action(), so this passes by construction — but when the DDRM + // branch lands, the gate becomes validate(.., required_action_for(op), ..). If + // that map omits an inspect op it fails closed to Action::Admin and a Read + // token is denied → this test fails LOUDLY at merge, instead of the break + // slipping through git's clean auto-merge. This converts the documented + // reconciliation note into an enforced invariant. + #[tokio::test] + async fn carrier_inspect_ops_match_canonical_action_contract() { + use crate::inspect_provider::{CatalogInspectSource, InspectProvider, InspectSource}; + use crate::provider_resource::inspect_op_required_action; + + let tmp = tempfile::tempdir().unwrap(); + let capsule_dir = tmp.path().join("capsules").join("probe"); + std::fs::create_dir_all(&capsule_dir).unwrap(); + std::fs::write( + capsule_dir.join("capsule.json"), + serde_json::to_vec(&serde_json::json!({ + "schema": "elastos.capsule/v1", "version": "0.1.0", "name": "probe", + "role": "app", "type": "wasm", "entrypoint": "probe.wasm" + })) + .unwrap(), + ) + .unwrap(); + + let audit_log = Arc::new(elastos_runtime::primitives::audit::AuditLog::new()); + let store = Arc::new(elastos_runtime::capability::CapabilityStore::new()); + let metrics = Arc::new(elastos_runtime::primitives::metrics::MetricsManager::new()); + let capability_manager = Arc::new(elastos_runtime::capability::CapabilityManager::new( + store, + audit_log.clone(), + metrics, + )); + let registry = Arc::new(elastos_runtime::provider::ProviderRegistry::new()); + let source: Arc = Arc::new(CatalogInspectSource::new( + tmp.path().join("capsules"), + Arc::downgrade(®istry), + )); + registry + .register(Arc::new(InspectProvider::new(source))) + .await; + + let ctx = Some(BridgeContext { + provider_registry: registry, + capability_manager: capability_manager.clone(), + pending_store: Arc::new( + elastos_runtime::capability::pending::PendingRequestStore::new(audit_log), + ), + capsule_id: "test-capsule".to_string(), + principal_id: None, + data_dir: None, + }); + + // Read-side ops the product provider serves. (self/revoke live on the + // embedded-handler contract, not this provider — not gate-tested here.) + let cases = [ + ("capsules", serde_json::json!({})), + ("capsule", serde_json::json!({ "id": "probe" })), + ("plan", serde_json::json!({ "id": "probe", "operation": "x" })), + ]; + + for (op, mut payload) in cases { + let action = inspect_op_required_action(op) + .unwrap_or_else(|| panic!("no canonical action for inspect op {op}")); + let token = encode_bridge_capability_token(&capability_manager.grant( + "test-capsule", + ResourceId::new(&format!("elastos://inspect/{op}")), + action, + TokenConstraints::default(), + None, + )); + let obj = payload.as_object_mut().unwrap(); + obj.insert("type".into(), serde_json::json!("carrier_invoke")); + obj.insert("uri".into(), serde_json::json!(format!("elastos://inspect/{op}"))); + obj.insert("operation".into(), serde_json::json!(op)); + obj.insert("token".into(), serde_json::json!(token)); + let line = serde_json::json!({ "id": 1, "request": payload }).to_string(); + + let resp = handle_request(&line, &ctx).await.unwrap(); + // A canonical-action token must clear the gate and reach the provider + // (business outcome may be ok/not_found/invalid_request — all are + // carrier_result; only a gate failure yields type "error"). + assert_eq!( + resp["response"]["type"], "carrier_result", + "inspect op {op} did not clear the capability gate at action {action:?}: {resp}" + ); + } + } + #[test] fn carrier_invoke_dispatch_uses_uri_resource_contract() { let dispatch = carrier_invoke_dispatch( diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index 63c32ac5..e8e6024e 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -30,9 +30,52 @@ use elastos_runtime::inspect::InspectScope; use elastos_runtime::invoke::{self, InvokeError}; use elastos_runtime::provider::{Provider, ProviderError, ProviderRegistry, ResourceRequest, ResourceResponse}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use crate::runtime::Runtime; +/// A short, non-secret provenance anchor for a manifest signature: the first 16 +/// hex chars of SHA-256 over the decoded signature bytes (raw base64 if decode +/// fails). This identifies *which* signature signed the capsule for audit +/// correlation without ever echoing the signature material itself (#16). +fn signature_fingerprint(sig_b64: &str) -> Option { + use base64::{engine::general_purpose::STANDARD as B64, Engine}; + if sig_b64.is_empty() { + return None; + } + let bytes = B64.decode(sig_b64).unwrap_or_else(|_| sig_b64.as_bytes().to_vec()); + Some(hex::encode(Sha256::digest(&bytes))[..16].to_string()) +} + +/// Derive a fail-closed trust classification from what is actually verifiable +/// about a capsule. We never claim more trust than the evidence supports: a +/// signature yields `signed`; absent that, a content address yields +/// `content-addressed`; with neither it is `unsigned`. Signer *verification* is +/// not yet wired (the manifest schema carries no signer DID/pubkey), so this is +/// presence-based, not a verified-identity claim. +fn trust_level(signature_present: bool, has_cid: bool) -> &'static str { + if signature_present { + "signed" + } else if has_cid { + "content-addressed" + } else { + "unsigned" + } +} + +/// A DID for the capsule only when one genuinely exists — the capsule id or the +/// declared author when it is a `did:` string. Never fabricated; `Null` +/// otherwise. A declared author DID is self-asserted, not verified. +fn capsule_did(entry_id: &str, author: &Value) -> Value { + if entry_id.starts_with("did:") { + return json!(entry_id); + } + match author.as_str() { + Some(a) if a.starts_with("did:") => json!(a), + _ => Value::Null, + } +} + /// One inspectable capsule, as seen by the provider. #[derive(Debug, Clone)] pub struct InspectEntry { @@ -432,6 +475,18 @@ impl InspectProvider { .unwrap_or(false); let cid = entry.cid.clone(); + // Provenance, derived honestly from what is actually present — never + // fabricated (#15: trust travels with DID/CID/hash/sig). + let author = field(&manifest, "author"); + let did = capsule_did(&entry.id, &author); + let trust = trust_level(signature_present, cid.is_some()); + let sig_fingerprint = manifest + .get("signature") + .and_then(Value::as_str) + .and_then(signature_fingerprint) + .map(Value::from) + .unwrap_or(Value::Null); + // Provider authority — the declarative powers a provider capsule is // authorized for (resource/actions/operations + audit events). DDRM and // other provider capsules express their real powers here, not via @@ -485,10 +540,12 @@ impl InspectProvider { "description": field(&manifest, "description"), "author": field(&manifest, "author"), "identity": { - "did": Value::Null, + "did": did.clone(), "cid": cid.clone(), - "trust_level": Value::Null, + "trust_level": trust, "signature_present": signature_present, + // No verified signer is available from the manifest schema; we do + // not invent one. `signed_by` stays null until verification is wired. "signed_by": Value::Null, }, "manifest": { @@ -513,11 +570,18 @@ impl InspectProvider { "peers": 0, }, "provenance": { + // Self-declared author (unverified) and the trust evidence we can + // actually derive. `signed_by` (a verified signer) is not yet + // available, so it stays null rather than echoing the author as if + // verified. + "author": author, "signed_by": Value::Null, + "trust_level": trust, "version": field(&manifest, "version"), "installed_at": Value::Null, "cid": cid.clone(), "signature_present": signature_present, + "signature_fingerprint": sig_fingerprint, }, "audit": audit, "processes": [{ "kind": entry.capsule_type, "status": entry.status }], @@ -839,6 +903,56 @@ mod tests { ); } + #[tokio::test] + async fn provenance_is_derived_honestly_not_fabricated() { + // The signed probe (id is not a DID, no cid): trust is "signed", a + // signature fingerprint is present, and we never invent a signer DID. + let resp = provider_with_probe() + .send_raw(&json!({ "op": "capsule", "id": "cap_probe_1" })) + .await + .unwrap(); + let data = &resp["data"]; + assert_eq!(data["identity"]["trust_level"], "signed"); + assert_eq!(data["identity"]["did"], Value::Null); + assert_eq!(data["provenance"]["signed_by"], Value::Null); + // A real, non-secret 16-hex fingerprint that is NOT the raw signature. + let fp = data["provenance"]["signature_fingerprint"].as_str().unwrap(); + assert_eq!(fp.len(), 16); + assert!(fp.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(fp, "SECRET_SIGNATURE_MUST_NOT_LEAK"); + } + + #[tokio::test] + async fn provenance_surfaces_did_when_genuinely_present() { + // A capsule whose id IS a DID: surface it (not fabricated — it exists). + let mut entry = probe_entry(); + entry.id = "did:elastos:abc123".to_string(); + let provider = InspectProvider::new(Arc::new(MockSource { entries: vec![entry] })); + let resp = provider + .send_raw(&json!({ "op": "capsule", "id": "did:elastos:abc123" })) + .await + .unwrap(); + assert_eq!(resp["data"]["identity"]["did"], "did:elastos:abc123"); + } + + #[test] + fn trust_level_fails_closed() { + // Never claims more trust than the evidence supports. + assert_eq!(trust_level(true, true), "signed"); + assert_eq!(trust_level(true, false), "signed"); + assert_eq!(trust_level(false, true), "content-addressed"); + assert_eq!(trust_level(false, false), "unsigned"); + } + + #[test] + fn signature_fingerprint_is_stable_and_non_empty() { + let a = signature_fingerprint("c2lnbmF0dXJl").unwrap(); + let b = signature_fingerprint("c2lnbmF0dXJl").unwrap(); + assert_eq!(a, b); + assert_eq!(a.len(), 16); + assert_eq!(signature_fingerprint(""), None); + } + #[tokio::test] async fn capsule_detail_unknown_id_is_not_found() { let resp = provider_with_probe() diff --git a/elastos/crates/elastos-server/src/provider_resource.rs b/elastos/crates/elastos-server/src/provider_resource.rs index 1ffe6f3c..2aa4340f 100644 --- a/elastos/crates/elastos-server/src/provider_resource.rs +++ b/elastos/crates/elastos-server/src/provider_resource.rs @@ -92,6 +92,30 @@ pub fn build_capability_resource( } } +/// Canonical capability action each Capsule Inspector operation requires. +/// +/// This is the **single source of truth** for the inspect op→action contract. +/// Today our carrier gate validates a token against its own action, so this is +/// authoritative documentation backed by a tripwire test. When the DDRM branch +/// lands, its `required_action_for(op)` op→action gate MUST agree with this for +/// every inspect op — otherwise inspect calls fall through to that gate's +/// `Action::Admin` fail-closed default and break silently (git auto-merges the +/// two edits without a conflict). The carrier table test +/// `carrier_inspect_ops_match_canonical_action_contract` enforces the +/// agreement: it fails loudly the moment an inspect op no longer reaches the +/// provider with a token minted at the action declared here. +/// +/// Reads are `Read`; the one mutation (`revoke`) is `Write`. `None` means the op +/// is not a recognised inspect operation. +pub fn inspect_op_required_action(op: &str) -> Option { + use elastos_runtime::capability::token::Action; + match op { + "capsules" | "capsule" | "self" | "plan" => Some(Action::Read), + "revoke" => Some(Action::Write), + _ => None, + } +} + fn validate_segment(value: &str, label: &str) -> Result<(), String> { if !value.is_empty() && value @@ -286,6 +310,20 @@ mod tests { ); } + #[test] + fn inspect_op_action_contract_is_pinned() { + use elastos_runtime::capability::token::Action; + // Reads are Read; revoke is the sole mutation (Write). DDRM's + // required_action_for MUST match this for inspect ops at merge. + assert_eq!(inspect_op_required_action("capsules"), Some(Action::Read)); + assert_eq!(inspect_op_required_action("capsule"), Some(Action::Read)); + assert_eq!(inspect_op_required_action("self"), Some(Action::Read)); + assert_eq!(inspect_op_required_action("plan"), Some(Action::Read)); + assert_eq!(inspect_op_required_action("revoke"), Some(Action::Write)); + // Unknown ops are not silently mapped — fail-closed at the caller. + assert_eq!(inspect_op_required_action("nope"), None); + } + #[test] fn ai_resource_without_backend() { let request = serde_json::json!({"op": "list_backends"}); From 4d882812adb08fd9d45488f32fd4cc5984e884a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 11:14:02 +0000 Subject: [PATCH 23/31] =?UTF-8?q?feat(inspect):=20surface=20audit=20attest?= =?UTF-8?q?ation=20(signer=20DID=20+=20signed)=20=E2=80=94=20live-data=20f?= =?UTF-8?q?idelity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signed runtime audit log carries signer_did + signature per event, but the Inspector wasn't surfacing them. Add attestation to the audit projection: each recent record now reports `signed` (a signature is present) and `signer` (the attesting DID), and counts include `attested`. This is the verified-signer evidence the audit plane actually holds (#15) — surfaced as presence + DID, with the signature material never echoed (#16). UI audit card shows the attested count and a per-event "⛓ attested" pill (signer DID on hover); sample data enriched so the offline demo shows it too. granted_capabilities remains honestly empty: RuntimeAuditEventV1 carries no resource/action, so an observed grant list cannot be derived without fabrication. Documented, not invented. Tests: inspect 29 (attestation asserted end-to-end); 757 server lib tests pass; new code clippy-clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- .../capsule-inspector/inspector/inspector.js | 20 +++++++--- .../capsule-inspector/inspector/style.css | 4 ++ docs/CAPSULE_INSPECTOR.md | 11 ++++-- .../elastos-server/src/inspect_provider.rs | 38 +++++++++++++++++-- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/capsules/capsule-inspector/inspector/inspector.js b/capsules/capsule-inspector/inspector/inspector.js index b74f9e96..b339355f 100644 --- a/capsules/capsule-inspector/inspector/inspector.js +++ b/capsules/capsule-inspector/inspector/inspector.js @@ -303,15 +303,23 @@ function renderGranted(grants) { function renderAudit(audit) { const wrap = el("div"); const counts = (audit && audit.counts) || {}; + // total/denied/attested are the live runtime counts; total_today/user_approved + // are sample-only fields, tolerated when present. + const total = counts.total ?? counts.total_today ?? 0; wrap.appendChild(el("div", { class: "note", text: - `today: ${counts.total_today ?? 0} calls · ${counts.user_approved ?? 0} user-approved · ${counts.denied ?? 0} denied` })); + `${total} events · ${counts.denied ?? 0} denied · ${counts.attested ?? 0} cryptographically attested` })); for (const e of (audit && audit.recent) || []) { - wrap.appendChild(el("div", { class: "audit-line" }, [ + const row = [ el("span", { class: "ts", text: fmtTime(e.ts) }), el("span", { class: "mono", text: e.event }), el("span", { class: "mono", text: e.detail }), el("span", { class: e.success ? "pill-ok" : "pill-deny", text: e.success ? "ok" : "blocked" }), - ])); + ]; + // Attestation: presence + signer DID (never the signature, #16). + if (e.signed) { + row.push(el("span", { class: "pill-ok mono", title: e.signer || "", text: "⛓ attested" })); + } + wrap.appendChild(el("div", { class: "audit-line audit-line-attest" }, row)); } return wrap; } @@ -474,9 +482,9 @@ const SAMPLE_DATA = [ storage_namespaces: ["localhost://WebSpaces/wallet/"], carrier: { enabled: false, endpoints: [], peers: 0 }, provenance: { signed_by: "gateway-did", version: "0.1.0", installed_at: 1781731200, cid: "bafywallet..." }, - audit: { counts: { total_today: 7, user_approved: 3, denied: 0 }, recent: [ - { ts: 1781989000, event: "capability.use", detail: "wallet/* read accounts", success: true }, - { ts: 1781988500, event: "affordance.sign", detail: "sign tx (user approved)", success: true }, + audit: { counts: { total: 7, total_today: 7, user_approved: 3, denied: 0, attested: 2 }, recent: [ + { ts: 1781989000, event: "capability.use", detail: "wallet/* read accounts", success: true, signed: true, signer: "did:elastos:gateway" }, + { ts: 1781988500, event: "affordance.sign", detail: "sign tx (user approved)", success: true, signed: true, signer: "did:elastos:gateway" }, ] }, processes: [{ kind: "microvm", instance: "vm#2", memory_mb: 64, uptime_s: 25200 }], }, diff --git a/capsules/capsule-inspector/inspector/style.css b/capsules/capsule-inspector/inspector/style.css index e94675c4..1b5999cc 100644 --- a/capsules/capsule-inspector/inspector/style.css +++ b/capsules/capsule-inspector/inspector/style.css @@ -128,6 +128,10 @@ html, body { .pill-deny { color: var(--deny); } .audit-line { display: grid; grid-template-columns: 84px 150px 1fr auto; gap: 10px; padding: 4px 0; font-size: 12.5px; } +/* Attestation variant: optional 5th cell for the signer pill. */ +.audit-line-attest { display: flex; flex-wrap: wrap; align-items: center; } +.audit-line-attest .ts { min-width: 84px; } +.audit-line-attest .grow, .audit-line-attest > span:nth-child(3) { flex: 1; } .audit-line .ts { color: var(--muted); } .note { margin-top: 4px; font-size: 12px; color: var(--muted); diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index a02f498c..45ae9305 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -252,10 +252,13 @@ caller-identity injection) and `revoke` (needs the gateway capability plane). **Live audit.** The provider takes an optional `AuditSource`. `AuthAuditSource` reads the signed runtime audit log (`RuntimeAuditEventV1` in the auth state), correlates events by `capsule_id` (the capsule name), and fills the detail -view's `audit` section — recent events (newest-first, capped) plus `total` and -`denied` counts. Wired on both serve paths. Reads run on a blocking task so the -async workers aren't stalled. Records are projected to safe fields only -(timestamp, event type, reason, success) — no signatures or handles (#16). +view's `audit` section — recent events (newest-first, capped) plus `total`, +`denied`, and `attested` counts. Wired on both serve paths. Reads run on a +blocking task so the async workers aren't stalled. Records are projected to safe +fields only (timestamp, event type, reason, success) plus **attestation** — the +*presence* of a signature (`signed`) and the attesting `signer` DID, which is +the verified-signer evidence the audit plane actually carries (#15). The +signature material itself is never echoed (#16). **Wired on both serve paths:** diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index e8e6024e..4abdbc92 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -105,6 +105,13 @@ pub struct AuditRecord { pub event: String, pub detail: String, pub success: bool, + /// Whether this event is cryptographically attested (a signature is present). + /// We surface the *fact* of attestation, never the signature itself (#16). + pub signed: bool, + /// The DID that attested the event, when present. A DID is public identity, + /// safe to surface, and is the verified-signer evidence the audit plane + /// actually carries (#15). + pub signer: Option, } /// A capsule's recent audit activity. @@ -112,6 +119,8 @@ pub struct AuditRecord { pub struct CapsuleAudit { pub total: u64, pub denied: u64, + /// How many of the recent events are cryptographically attested. + pub attested: u64, pub recent: Vec, } @@ -148,6 +157,7 @@ impl AuditSource for AuthAuditSource { }; let mut total = 0u64; let mut denied = 0u64; + let mut attested = 0u64; let mut recent = Vec::new(); // Newest-first. for event in state.audit.iter().rev() { @@ -159,16 +169,22 @@ impl AuditSource for AuthAuditSource { if !success { denied += 1; } + let signed = event.signature.is_some(); + if signed { + attested += 1; + } if recent.len() < recent_limit { recent.push(AuditRecord { ts: event.occurred_at, event: event.event_type.clone(), detail: event.reason.clone(), success, + signed, + signer: event.signer_did.clone(), }); } } - CapsuleAudit { total, denied, recent } + CapsuleAudit { total, denied, attested, recent } }) .await .unwrap_or_default() @@ -593,15 +609,21 @@ impl InspectProvider { /// Empty when no audit source is attached. async fn audit_value(&self, entry: &InspectEntry) -> Value { let Some(audit) = &self.audit else { - return json!({ "counts": { "total": 0, "denied": 0 }, "recent": [] }); + return json!({ "counts": { "total": 0, "denied": 0, "attested": 0 }, "recent": [] }); }; let a = audit.for_capsule(&entry.name, 20).await; let recent: Vec = a .recent .iter() - .map(|r| json!({ "ts": r.ts, "event": r.event, "detail": r.detail, "success": r.success })) + .map(|r| json!({ + "ts": r.ts, "event": r.event, "detail": r.detail, "success": r.success, + "signed": r.signed, "signer": r.signer, + })) .collect(); - json!({ "counts": { "total": a.total, "denied": a.denied }, "recent": recent }) + json!({ + "counts": { "total": a.total, "denied": a.denied, "attested": a.attested }, + "recent": recent, + }) } async fn handle_op(&self, request: &Value) -> Value { @@ -1087,11 +1109,14 @@ mod tests { CapsuleAudit { total: 3, denied: 1, + attested: 1, recent: vec![AuditRecord { ts: 100, event: "capability.use".to_string(), detail: "did read".to_string(), success: true, + signed: true, + signer: Some("did:elastos:gateway".to_string()), }], } } else { @@ -1108,8 +1133,13 @@ mod tests { .unwrap(); assert_eq!(resp["data"]["audit"]["counts"]["total"], 3); assert_eq!(resp["data"]["audit"]["counts"]["denied"], 1); + // Attestation fidelity: who cryptographically signed each event (#15), + // surfaced as presence + DID, never the signature itself (#16). + assert_eq!(resp["data"]["audit"]["counts"]["attested"], 1); assert_eq!(resp["data"]["audit"]["recent"][0]["event"], "capability.use"); assert_eq!(resp["data"]["audit"]["recent"][0]["success"], true); + assert_eq!(resp["data"]["audit"]["recent"][0]["signed"], true); + assert_eq!(resp["data"]["audit"]["recent"][0]["signer"], "did:elastos:gateway"); } #[tokio::test] From e4d9563b67c3baf0f7b32648fcaebe1a5e93524a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 11:30:51 +0000 Subject: [PATCH 24/31] harden(inspect): aggregate split authority blocks + edge least-privilege test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two security hardening items, all on owned code (no DDRM-risky files). 1. Provider-op preview can no longer under-state the gate. plan_provider_operation previously returned the FIRST capability block declaring an operation. A manifest that splits one operation across several blocks (e.g. release under both elastos://key/* read AND elastos://decrypt/* execute+admin) could thus hide authority the call also requires. It now aggregates EVERY matching block and returns the union of resources + actions, deduped and order-preserving, and still fails closed if ANY matching block declares an unknown action. Wire contract: data.resource (string) -> data.resources (array). UI + offline twin + docs updated to match. +2 invoke tests (split-block union; fail-closed across blocks). 2. Edge least-privilege test (#16): assert the write op `revoke` returns 404 via the browser gateway even WITH a System token — mutation stays on the capability-gated carrier/admin path, never the browser proxy. Tests: invoke 12, inspect 30, 758 server lib tests; new code clippy-clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- .../capsule-inspector/inspector/inspector.js | 19 +++- docs/CAPSULE_INSPECTOR.md | 13 ++- .../crates/elastos-runtime/src/invoke/mod.rs | 97 +++++++++++++++---- .../src/api/gateway_tests/inspect.rs | 26 +++++ .../elastos-server/src/inspect_provider.rs | 6 +- 5 files changed, 131 insertions(+), 30 deletions(-) diff --git a/capsules/capsule-inspector/inspector/inspector.js b/capsules/capsule-inspector/inspector/inspector.js index b339355f..822f1b86 100644 --- a/capsules/capsule-inspector/inspector/inspector.js +++ b/capsules/capsule-inspector/inspector/inspector.js @@ -227,14 +227,21 @@ function localPlanOperation(capsuleId, operation) { const capsule = SAMPLE_DATA.find((c) => c.id === capsuleId); const authority = capsule && capsule.authority; if (!authority) return null; - const block = (authority.capabilities || []).find((cap) => + // Aggregate ALL matching blocks (union), mirroring the server — fail-closed. + const blocks = (authority.capabilities || []).filter((cap) => (cap.operations || []).includes(operation)); - if (!block) return { valid: false, error: "unknown_operation", operation }; + if (!blocks.length) return { valid: false, error: "unknown_operation", operation }; + const resources = []; + const actions = []; + for (const b of blocks) { + if (!resources.includes(b.resource)) resources.push(b.resource); + for (const a of b.actions || []) if (!actions.includes(a)) actions.push(a); + } return { valid: true, kind: "operation", - resource: block.resource, - capability_actions: block.actions || [], + resources, + capability_actions: actions, audit_events: authority.audit_events || [], }; } @@ -257,9 +264,11 @@ async function runOperationPreview(capsuleId, operation, target, btn) { } if (plan && plan.valid) { const actions = (plan.capability_actions || []).join(" + "); + // Union of every resource the op touches (fail-closed; never just one). + const resources = (plan.resources || []).join(", "); target.appendChild(el("div", { class: "plan-ok" }, [ el("span", { class: "note", text: "requires a capability covering" }), - el("span", { class: "mono", text: plan.resource }), + el("span", { class: "mono", text: resources }), el("span", { class: "tag tag-sign", text: "action: " + actions }), ])); for (const ev of plan.audit_events || []) { diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index 45ae9305..07f7bd2e 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -408,14 +408,17 @@ set of actions a caller's capability must cover — plus the audit events the provider emits: ```json -{ "valid": true, "kind": "operation", "resource": "elastos://key/*", +{ "valid": true, "kind": "operation", "resources": ["elastos://key/*"], "capability_actions": ["execute"], "audit_events": ["key.release.denied", "key.release.granted"] } ``` -The action set is surfaced **whole**, never collapsed to one, and an action -keyword the capability layer doesn't recognise is a hard `manifest_error` -(fail-closed) rather than a silent drop — the preview cannot under-state the -gate. An operation no capability block declares returns +Both `resources` and `capability_actions` are the **union across every** +capability block that declares the operation — never just the first match — so a +manifest that splits one operation across blocks cannot hide a resource or +action the call also requires. The action set is surfaced **whole**, never +collapsed to one, and an unrecognised action keyword in *any* matching block is a +hard `manifest_error` (fail-closed) rather than a silent drop — the preview can +never under-state the gate. An operation no capability block declares returns `{ "valid": false, "error": "unknown_operation" }`. Effect *dispatch* (and the location-agnostic Carrier / cross-language transport) diff --git a/elastos/crates/elastos-runtime/src/invoke/mod.rs b/elastos/crates/elastos-runtime/src/invoke/mod.rs index 2a440659..02f01602 100644 --- a/elastos/crates/elastos-runtime/src/invoke/mod.rs +++ b/elastos/crates/elastos-runtime/src/invoke/mod.rs @@ -134,11 +134,15 @@ pub fn plan( /// gate the runtime would later enforce. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderOperationPlan { - /// The capability resource URI the operation acts on (e.g. `elastos://key/*`). - pub resource: String, - /// Every action a caller's capability must cover for this resource. Surfaced - /// as the full set (not collapsed to one) so nothing the manifest demands is - /// hidden from the reviewer. + /// Every capability resource URI the operation acts on (e.g. `elastos://key/*`). + /// Surfaced as the union across *all* capability blocks that declare the + /// operation — never just the first — so a manifest that splits an operation + /// across blocks cannot hide a resource the call also requires. + pub resources: Vec, + /// Every action a caller's capability must cover. The union across all + /// matching blocks, surfaced whole (not collapsed to one), so nothing the + /// manifest demands is hidden from the reviewer. Fail-closed: an unrecognised + /// action keyword in *any* matching block is an error, not a silent drop. pub actions: Vec, /// The audit events this provider declares it emits. pub audit_events: Vec, @@ -159,27 +163,43 @@ fn parse_action(s: &str) -> Result { } /// Preview the capability gate a provider `operation` would require, by reflecting -/// the capsule's `authority` metadata. Finds the capability block that declares -/// the operation and returns its resource + required actions + the authority's -/// audit events. Dispatches nothing. +/// the capsule's `authority` metadata. Aggregates *every* capability block that +/// declares the operation and returns the union of their resources + required +/// actions, plus the authority's audit events. Dispatches nothing. +/// +/// Fail-closed by construction: surfacing the union (not the first match) means +/// the preview can never under-state the authority a call needs, even if the +/// manifest splits one operation across several blocks. pub fn plan_provider_operation( authority: &ProviderAuthority, operation: &str, ) -> Result { - let capability = authority + let matching: Vec<&_> = authority .capabilities .iter() - .find(|cap| cap.operations.iter().any(|op| op == operation)) - .ok_or_else(|| InvokeError::UnknownOperation(operation.to_string()))?; + .filter(|cap| cap.operations.iter().any(|op| op == operation)) + .collect(); - let actions = capability - .actions - .iter() - .map(|a| parse_action(a)) - .collect::, _>>()?; + if matching.is_empty() { + return Err(InvokeError::UnknownOperation(operation.to_string())); + } + + let mut resources: Vec = Vec::new(); + let mut actions: Vec = Vec::new(); + for cap in matching { + if !resources.contains(&cap.resource) { + resources.push(cap.resource.clone()); + } + for a in &cap.actions { + let action = parse_action(a)?; // unknown keyword in any block → error + if !actions.contains(&action) { + actions.push(action); + } + } + } Ok(ProviderOperationPlan { - resource: capability.resource.clone(), + resources, actions, audit_events: authority.audit_events.clone(), }) @@ -281,7 +301,7 @@ mod tests { })); let release = plan_provider_operation(&auth, "release").unwrap(); - assert_eq!(release.resource, "elastos://key/*"); + assert_eq!(release.resources, vec!["elastos://key/*".to_string()]); assert_eq!(release.actions, vec![Action::Execute]); assert!(release.audit_events.iter().any(|e| e == "key.release.denied")); @@ -289,6 +309,47 @@ mod tests { assert_eq!(status.actions, vec![Action::Read]); } + #[test] + fn provider_op_plan_aggregates_split_blocks_fail_closed() { + // Hardening: an operation declared across MULTIPLE capability blocks must + // surface the UNION of resources and actions — never just the first match + // — so a split-privilege manifest cannot trick the preview into + // under-stating the authority a call actually requires. + let auth = authority(json!({ + "reason": "x", "audit_events": ["a"], + "capabilities": [ + { "resource": "elastos://key/*", "actions": ["read"], "operations": ["release"] }, + { "resource": "elastos://decrypt/*", "actions": ["execute", "admin"], + "operations": ["release", "render"] } + ] + })); + let plan = plan_provider_operation(&auth, "release").unwrap(); + // Both resources are surfaced (union, deduped, order-preserving). + assert_eq!( + plan.resources, + vec!["elastos://key/*".to_string(), "elastos://decrypt/*".to_string()] + ); + // The full action set across both blocks. + assert_eq!(plan.actions, vec![Action::Read, Action::Execute, Action::Admin]); + } + + #[test] + fn provider_op_plan_fails_closed_when_any_matching_block_has_unknown_action() { + // If ANY matching block declares an unrecognised action, the whole preview + // errors — we never quietly report only the blocks we understood. + let auth = authority(json!({ + "reason": "x", "audit_events": ["a"], + "capabilities": [ + { "resource": "elastos://key/*", "actions": ["read"], "operations": ["release"] }, + { "resource": "elastos://key/*", "actions": ["teleport"], "operations": ["release"] } + ] + })); + assert_eq!( + plan_provider_operation(&auth, "release").unwrap_err(), + InvokeError::UnknownDeclaredAction("teleport".to_string()) + ); + } + #[test] fn provider_op_plan_rejects_unknown_operation() { let auth = authority(json!({ diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs b/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs index 14066b92..29b9c165 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs @@ -88,6 +88,32 @@ async fn inspect_capsules_requires_token_and_lists_installed_capsule() { ); } +#[tokio::test] +async fn inspect_write_op_revoke_is_not_browser_reachable() { + // Least-privilege at the edge (#16): the write op `revoke` is deliberately + // absent from the browser allow-list, so it is unreachable through the + // gateway proxy even WITH a System operator token — mutation stays on the + // capability-gated carrier/admin path, never the browser. + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(inspect_test_state(dir.path()).await); + + let token = issue_home_launch_token(dir.path(), SYSTEM_CAPSULE_ID).unwrap(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/inspect/revoke") + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{\"token_id\":\"00000000000000000000000000000000\"}")) + .unwrap(), + ) + .await + .unwrap(); + // Not OK, and specifically not found — the op never enters the proxy. + assert_eq!(resp.status(), StatusCode::NOT_FOUND, "revoke must not be browser-reachable"); +} + #[tokio::test] async fn inspect_capsules_rejects_non_system_app() { let dir = tempfile::tempdir().unwrap(); diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index 4abdbc92..5fee9365 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -763,7 +763,9 @@ impl InspectProvider { "data": { "valid": true, "kind": "operation", - "resource": plan.resource, + // Union of every resource the op touches across all matching + // authority blocks — never just the first (fail-closed). + "resources": plan.resources, // Every action a caller's capability must cover (full set). "capability_actions": plan .actions @@ -1349,7 +1351,7 @@ mod tests { assert_eq!(resp["status"], "ok"); assert_eq!(resp["data"]["valid"], true); assert_eq!(resp["data"]["kind"], "operation"); - assert_eq!(resp["data"]["resource"], "elastos://key/*"); + assert_eq!(resp["data"]["resources"][0], "elastos://key/*"); assert_eq!(resp["data"]["capability_actions"][0], "execute"); assert_eq!(resp["data"]["audit_events"][0], "key.release.denied"); } From f8afb2fa13cc0d473b4cbe5bda79e388af0e10c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 11:33:38 +0000 Subject: [PATCH 25/31] harden(inspect): make signature fingerprint panic-proof Replace the [..16] slice on the digest hex with .chars().take(16): cannot panic regardless of the digest's hex length, removing a latent assumption that the hasher always yields >=16 hex chars. No behavior change for SHA-256. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- elastos/crates/elastos-server/src/inspect_provider.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index 5fee9365..bfecf43c 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -44,7 +44,9 @@ fn signature_fingerprint(sig_b64: &str) -> Option { return None; } let bytes = B64.decode(sig_b64).unwrap_or_else(|_| sig_b64.as_bytes().to_vec()); - Some(hex::encode(Sha256::digest(&bytes))[..16].to_string()) + // `.take(16)` rather than a `[..16]` slice: can never panic regardless of the + // digest's hex length (hex chars are single-byte ASCII, so no boundary risk). + Some(hex::encode(Sha256::digest(&bytes)).chars().take(16).collect()) } /// Derive a fail-closed trust classification from what is actually verifiable From c2e663d7dcad8dc7527116ff75796e08bb9a4089 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 13:02:37 +0000 Subject: [PATCH 26/31] docs(inspect): add Capsule Inspector testing guide A setup + manual-test guide for feat/capsule-inspector: build/test/lint commands, the two test paths (fast sample mode; live serve + System token), the manual UI checklist, and copy-paste curl security checks (no token -> 403, System -> 200, revoke -> 404, no secret leakage). Documents what is intentionally not built yet (approval loop, dispatch, empty granted_capabilities) so testers don't flag them as bugs, and flags the live System-token / UI-route step as a known frontier with a sample-mode fallback. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/INSPECTOR_TESTING.md | 161 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/INSPECTOR_TESTING.md diff --git a/docs/INSPECTOR_TESTING.md b/docs/INSPECTOR_TESTING.md new file mode 100644 index 00000000..029421e7 --- /dev/null +++ b/docs/INSPECTOR_TESTING.md @@ -0,0 +1,161 @@ +# Capsule Inspector — testing guide + +How to set up an environment and manually test the Capsule Inspector on the +`feat/capsule-inspector` branch. Pair this with `docs/CAPSULE_INSPECTOR.md` (the +design/security model) and `docs/INSPECT_DDRM_MERGE_NOTES.md` (the parallel-branch +integration plan). + +- **Branch:** `feat/capsule-inspector` +- **Setup role (Cursor agent / operator):** build, run the test suites, stand up + the runtime, and produce the access steps below. +- **Manual role (human):** click through the UI and run the security checks. + +> Keep the branch isolated: do **not** merge or pull in +> `feat/ddrm-hardening-and-creator-parity` (the two branches run in parallel), +> and do not push or open a PR while testing. + +## What you're testing + +A read-only, capability-gated, object-centered view of every capsule — its +identity/provenance, its declared powers (affordances + provider `authority`), +and its audit trail — plus a metadata-driven **gate preview**: given a provider +operation, it shows the exact capability tuple (resources + actions) and audit +events a call would require, **without executing anything**. It re-secures Self's +live-object / mirror experience under ElastOS's zero-ambient-authority model. + +Reachable through both product transports: + +| Transport | Entry | Gate | +| --- | --- | --- | +| Browser UI | `POST /api/provider/inspect/` + `x-elastos-home-token` | gateway allow-list → System operator only | +| Capsule / agent | carrier_bridge `carrier_invoke` | capability token → resource/action | + +Ops: `capsules` (list), `capsule` (detail), `plan` (gate preview) are read-only. +The write op `revoke` is **not** exposed through the browser proxy. + +## Intentionally NOT built yet (do not file these as bugs) + +- **Preview only** — there is no human-approval loop and no invoke + *dispatch*/execution. Nothing actually runs or mutates. +- **`granted_capabilities` is empty by design** — the audit event schema carries + no resource/action, so the observed-grant list is left empty rather than + fabricated (see CAPSULE_INSPECTOR.md, "observed, not enumerated"). +- **`revoke` must 404 via the browser** — mutation stays on the capability-gated + carrier/admin path. +- **Sample-data fallback** — when no live source/token is present, the browser UI + renders bundled sample data. Watch the badge: `live` vs `sample data`. + +## Setup (operator) + +```bash +git fetch origin +git checkout feat/capsule-inspector # expect HEAD f8afb2f (or later) + +# 1. Build the workspace (binary -> elastos/target/release/elastos) +cargo build --workspace --release + +# 2. Test the touched crates +cargo test -p elastos-runtime # inspect/ scope + invoke/ planner + conformance +cargo test -p elastos-server # inspect_provider, carrier e2e, gateway inspect + +# 3. Lint (a few PRE-EXISTING warnings in request_handler.rs / +# gateway_capsule_catalog.rs are known/out of scope — flag only NEW ones) +cargo clippy -p elastos-runtime -p elastos-server +``` + +Relevant tests to confirm green: + +- `elastos-runtime`: `inspect::tests::*` (scope, fail-closed), + `invoke::tests::*` (affordance + provider-op gate, split-block union), + `tests/inspect_conformance.rs` (a SelfOnly caller cannot read another capsule). +- `elastos-server`: `inspect_provider::tests::*` (projection, no-leak, provenance, + attestation, gate preview), `carrier_bridge` inspect e2e + the merge tripwire + `carrier_inspect_ops_match_canonical_action_contract`, and + `api/gateway_tests/inspect.rs` (token required, System-only, `revoke` 404). + +## Two ways to test + +### A. Fast UI smoke test — sample mode (no runtime, no token) + +The UI is static. Serve the folder and open it; the live API call fails and it +falls back to sample data (expected — badge shows `sample data`). + +```bash +cd capsules/capsule-inspector/inspector +python3 -m http.server 8099 +# open http://127.0.0.1:8099/ +``` + +This exercises the entire UI/UX: list, detail glass box, provenance card, and the +"preview gate" buttons (which compute the gate locally from the sample +authority). + +### B. Live mode — runtime + System token + +```bash +elastos serve # API binds ~http://127.0.0.1:8090 +``` + +The browser `inspect` read ops require the **System** capsule's signed +home-launch token in the `x-elastos-home-token` header. + +> **Frontier / known gap:** obtaining a real System home-launch token and the +> exact `/apps//` route the inspector UI is served from in a live `serve` +> may not be fully wired on this branch yet. If you cannot obtain a System token +> or reach the UI live, **document the blocker and fall back to sample mode (A) +> for UI testing** — the security checks below can still be run against +> `elastos serve` with curl once a token is available. (In tests, tokens are +> minted via `issue_home_launch_token(data_dir, SYSTEM_CAPSULE_ID)`; the operator +> task is to reproduce that for a live run, or report what's missing.) + +## Manual checklist (human) + +UI (sample mode is enough for all of these): + +- [ ] Capsule list loads; selecting one shows the detail "glass box." +- [ ] **Provenance card** shows a trust level (`signed` / `content-addressed` / + `unsigned`), a signature **fingerprint** (short hex), and an audit + **attested** count / signer DID. +- [ ] A provider capsule (one that declares `authority`) shows its powers, each + with a **"preview gate"** button. Clicking it reveals the required + **resources + actions + audit events** — and nothing executes. +- [ ] **No** raw signature, bearer token, or mutation handle appears anywhere in + the UI (Principle #16). + +Security (live mode, curl — substitute `$TOKEN` with a System home-launch token): + +```bash +B=http://127.0.0.1:8090 + +# No token -> 403 +curl -s -o /dev/null -w '%{http_code}\n' -X POST $B/api/provider/inspect/capsules \ + -H 'content-type: application/json' -d '{}' + +# System token -> 200 + lists capsules +curl -s -X POST $B/api/provider/inspect/capsules \ + -H "x-elastos-home-token: $TOKEN" -H 'content-type: application/json' -d '{}' + +# Gate preview (no effect) -> shows resources/actions a call would require +curl -s -X POST $B/api/provider/inspect/plan \ + -H "x-elastos-home-token: $TOKEN" -H 'content-type: application/json' \ + -d '{"id":"","operation":""}' + +# Write op revoke -> 404 (not browser-reachable) EVEN with a System token +curl -s -o /dev/null -w '%{http_code}\n' -X POST $B/api/provider/inspect/revoke \ + -H "x-elastos-home-token: $TOKEN" -H 'content-type: application/json' \ + -d '{"token_id":"00000000000000000000000000000000"}' +``` + +- [ ] no token → `403` +- [ ] non-System app token → not `200` +- [ ] System token → `200`, lists capsules +- [ ] `plan` → returns the gate (`resources`, `capability_actions`, `audit_events`), + with `kind: "operation"` for provider ops; nothing runs +- [ ] `revoke` → `404` +- [ ] no response body contains a raw signature / token / handle + +## Reporting + +Capture: build result, test + clippy output, the URLs + token steps (or the +blocker), and any UI/security deviation from the checklist. Anything intentionally +not built (see above) is not a defect. From 645d2174128c4951d5230f568060a084ac92622f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 14:13:32 +0000 Subject: [PATCH 27/31] =?UTF-8?q?style(inspect):=20satisfy=20`just=20lint`?= =?UTF-8?q?=20=E2=80=94=20rustfmt=20+=202=20clippy=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inspect feature was committed without running the project's real gate (`just lint`/`just verify`): `cargo fmt --check` failed on all 7 of our branch files, and `cargo clippy --workspace --all-targets -- -D warnings` flagged two needless borrows in our code. Bring our branch's own contribution up to the gate: - rustfmt the 7 inspect files (scoped to our files only; no `cargo fmt --all`). - clippy: `to_value(info.trust_level)` (TrustLevel is Copy) and `ResourceId::new(format!(...))` in the carrier tripwire test. No logic changes. Verified: `cargo fmt --check` passes; our crates are clippy-clean under `-D warnings`; `cargo test --workspace` is green. The only remaining clippy errors are pre-existing on main (gateway_capsule_catalog.rs field_reassign_with_default), not our work — left untouched per shared-tree discipline. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- .../src/handler/request_handler.rs | 89 +++++++++--- .../crates/elastos-runtime/src/inspect/mod.rs | 12 +- .../crates/elastos-runtime/src/invoke/mod.rs | 19 ++- .../src/api/gateway_tests/inspect.rs | 20 ++- .../elastos-server/src/carrier_bridge.rs | 12 +- .../elastos-server/src/inspect_provider.rs | 127 +++++++++++++----- .../crates/elastos-server/src/serve_cmd.rs | 14 +- 7 files changed, 221 insertions(+), 72 deletions(-) diff --git a/elastos/crates/elastos-runtime/src/handler/request_handler.rs b/elastos/crates/elastos-runtime/src/handler/request_handler.rs index 217c8acc..3f894c12 100755 --- a/elastos/crates/elastos-runtime/src/handler/request_handler.rs +++ b/elastos/crates/elastos-runtime/src/handler/request_handler.rs @@ -891,7 +891,10 @@ impl RequestHandler { // Authoritative capability check: the token must grant the // requested URI *and* action. A read grant fails a write endpoint; // a self-only grant cannot satisfy a system URI. - if let Err(e) = self.validate_token(token_str, from, required_action, uri).await { + if let Err(e) = self + .validate_token(token_str, from, required_action, uri) + .await + { return e; } // Defense in depth: classify the granted pattern into a scope. @@ -1103,7 +1106,8 @@ impl RequestHandler { // the runtime's authoritative record of what this capsule did. let events = self._audit_log.recent_events(500); let mut recent = Vec::new(); - let mut grants: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let mut grants: std::collections::BTreeMap = + std::collections::BTreeMap::new(); let (mut total, mut denied) = (0u64, 0u64); for ev in &events { let v = match serde_json::to_value(ev) { @@ -1114,9 +1118,20 @@ impl RequestHandler { continue; } total += 1; - let etype = v.get("type").and_then(|t| t.as_str()).unwrap_or("").to_string(); - let resource = v.get("resource").and_then(|r| r.as_str()).map(str::to_string); - let action = v.get("action").and_then(|a| a.as_str()).unwrap_or("").to_string(); + let etype = v + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string(); + let resource = v + .get("resource") + .and_then(|r| r.as_str()) + .map(str::to_string); + let action = v + .get("action") + .and_then(|a| a.as_str()) + .unwrap_or("") + .to_string(); let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true); if !success { denied += 1; @@ -1178,7 +1193,7 @@ impl RequestHandler { "identity": { "did": Value::Null, "cid": cid, - "trust_level": serde_json::to_value(&info.trust_level).ok(), + "trust_level": serde_json::to_value(info.trust_level).ok(), "signature_present": signature_present, "signed_by": Value::Null, }, @@ -1316,14 +1331,21 @@ mod tests { /// Like `create_test_handler`, but also returns the capability and capsule /// managers so inspect conformance tests can mint scoped tokens and launch /// real capsules to introspect. - async fn create_test_handler_with_caps( - ) -> (RequestHandler, CapsuleId, Arc, Arc) { + async fn create_test_handler_with_caps() -> ( + RequestHandler, + CapsuleId, + Arc, + Arc, + ) { let compute = Arc::new(MockComputeProvider); let store = Arc::new(CapabilityStore::new()); let audit_log = Arc::new(AuditLog::new()); let metrics = Arc::new(MetricsManager::new()); - let capability_manager = - Arc::new(CapabilityManager::new(store, audit_log.clone(), metrics.clone())); + let capability_manager = Arc::new(CapabilityManager::new( + store, + audit_log.clone(), + metrics.clone(), + )); let capsule_manager = Arc::new(CapsuleManager::new( compute, capability_manager.clone(), @@ -1383,7 +1405,11 @@ mod tests { async fn inspect_detail_renders_contract_without_leaking_authority() { let (handler, shell_id, _caps, capsule_manager) = create_test_handler_with_caps().await; let id = capsule_manager - .launch_local(std::path::Path::new("."), probe_manifest(), TrustLevel::Trusted) + .launch_local( + std::path::Path::new("."), + probe_manifest(), + TrustLevel::Trusted, + ) .await .expect("launch probe"); @@ -1408,7 +1434,10 @@ mod tests { assert_eq!(data["affordances"][0]["risk"], "read"); assert_eq!(data["affordances"][0]["interface"], "elastos.probe/v1"); assert_eq!(data["required_capabilities"][0], "elastos://storage/probe"); - assert_eq!(data["storage_namespaces"][0], "localhost://WebSpaces/probe/"); + assert_eq!( + data["storage_namespaces"][0], + "localhost://WebSpaces/probe/" + ); assert_eq!(data["identity"]["signature_present"], true); // Principle #16: UI surfaces must not expose bearer tokens or mutation @@ -1431,7 +1460,11 @@ mod tests { // itself with a minimal self grant — the same authority model for both. let (handler, _shell, caps, capsule_manager) = create_test_handler_with_caps().await; let id = capsule_manager - .launch_local(std::path::Path::new("."), probe_manifest(), TrustLevel::Trusted) + .launch_local( + std::path::Path::new("."), + probe_manifest(), + TrustLevel::Trusted, + ) .await .expect("launch probe"); @@ -1447,7 +1480,10 @@ mod tests { .expect("encode token"); let resp = handler - .handle(&id, inspect_request("elastos://inspect/self", Some(self_token), None)) + .handle( + &id, + inspect_request("elastos://inspect/self", Some(self_token), None), + ) .await; match resp { @@ -1536,7 +1572,10 @@ mod tests { ), ) .await; - assert!(matches!(resp, RuntimeResponse::Ok { .. }), "shell revoke should succeed"); + assert!( + matches!(resp, RuntimeResponse::Ok { .. }), + "shell revoke should succeed" + ); // The capability is now revoked and fails validation. assert!(caps @@ -1580,7 +1619,10 @@ mod tests { ), ) .await; - assert!(matches!(resp, RuntimeResponse::Ok { .. }), "write-scope revoke should succeed"); + assert!( + matches!(resp, RuntimeResponse::Ok { .. }), + "write-scope revoke should succeed" + ); assert!(caps .validate(&victim, victim_capsule.as_str(), Action::Read, &res, None) .await @@ -1623,7 +1665,10 @@ mod tests { async fn inspect_shell_can_list_with_system_scope() { let (handler, shell_id) = create_test_handler().await; let resp = handler - .handle(&shell_id, inspect_request("elastos://inspect/capsules", None, None)) + .handle( + &shell_id, + inspect_request("elastos://inspect/capsules", None, None), + ) .await; match resp { RuntimeResponse::Ok { data: Some(data) } => { @@ -1639,7 +1684,10 @@ mod tests { let (handler, _shell) = create_test_handler().await; let caller = CapsuleId::new(); let resp = handler - .handle(&caller, inspect_request("elastos://inspect/capsules", None, None)) + .handle( + &caller, + inspect_request("elastos://inspect/capsules", None, None), + ) .await; match resp { RuntimeResponse::Error { code, .. } => assert_eq!(code, "missing_token"), @@ -1699,7 +1747,10 @@ mod tests { async fn inspect_unknown_endpoint_is_not_found() { let (handler, shell_id) = create_test_handler().await; let resp = handler - .handle(&shell_id, inspect_request("elastos://inspect/bogus", None, None)) + .handle( + &shell_id, + inspect_request("elastos://inspect/bogus", None, None), + ) .await; match resp { RuntimeResponse::Error { code, .. } => assert_eq!(code, "not_found"), diff --git a/elastos/crates/elastos-runtime/src/inspect/mod.rs b/elastos/crates/elastos-runtime/src/inspect/mod.rs index d8c9cb55..bf8a3628 100644 --- a/elastos/crates/elastos-runtime/src/inspect/mod.rs +++ b/elastos/crates/elastos-runtime/src/inspect/mod.rs @@ -146,10 +146,14 @@ mod tests { #[test] fn system_grant_wins_regardless_of_order() { - let forward = - InspectScope::from_grants(false, [INSPECT_SELF.to_string(), INSPECT_SYSTEM.to_string()]); - let reverse = - InspectScope::from_grants(false, [INSPECT_SYSTEM.to_string(), INSPECT_SELF.to_string()]); + let forward = InspectScope::from_grants( + false, + [INSPECT_SELF.to_string(), INSPECT_SYSTEM.to_string()], + ); + let reverse = InspectScope::from_grants( + false, + [INSPECT_SYSTEM.to_string(), INSPECT_SELF.to_string()], + ); assert_eq!(forward, Some(InspectScope::System)); assert_eq!(reverse, Some(InspectScope::System)); } diff --git a/elastos/crates/elastos-runtime/src/invoke/mod.rs b/elastos/crates/elastos-runtime/src/invoke/mod.rs index 02f01602..cbc2ff04 100644 --- a/elastos/crates/elastos-runtime/src/invoke/mod.rs +++ b/elastos/crates/elastos-runtime/src/invoke/mod.rs @@ -274,7 +274,9 @@ mod tests { let err = plan(&a, &json!("not-an-object")).unwrap_err(); assert_eq!( err, - InvokeError::InputTypeMismatch { expected: "object".to_string() } + InvokeError::InputTypeMismatch { + expected: "object".to_string() + } ); } @@ -303,7 +305,10 @@ mod tests { let release = plan_provider_operation(&auth, "release").unwrap(); assert_eq!(release.resources, vec!["elastos://key/*".to_string()]); assert_eq!(release.actions, vec![Action::Execute]); - assert!(release.audit_events.iter().any(|e| e == "key.release.denied")); + assert!(release + .audit_events + .iter() + .any(|e| e == "key.release.denied")); let status = plan_provider_operation(&auth, "status").unwrap(); assert_eq!(status.actions, vec![Action::Read]); @@ -327,10 +332,16 @@ mod tests { // Both resources are surfaced (union, deduped, order-preserving). assert_eq!( plan.resources, - vec!["elastos://key/*".to_string(), "elastos://decrypt/*".to_string()] + vec![ + "elastos://key/*".to_string(), + "elastos://decrypt/*".to_string() + ] ); // The full action set across both blocks. - assert_eq!(plan.actions, vec![Action::Read, Action::Execute, Action::Admin]); + assert_eq!( + plan.actions, + vec![Action::Read, Action::Execute, Action::Admin] + ); } #[test] diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs b/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs index 29b9c165..2fb3ae66 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs @@ -31,7 +31,9 @@ async fn inspect_test_state(dir: &std::path::Path) -> GatewayState { Arc::downgrade(®istry), )); registry - .register(Arc::new(crate::inspect_provider::InspectProvider::new(source))) + .register(Arc::new(crate::inspect_provider::InspectProvider::new( + source, + ))) .await; GatewayState { @@ -105,13 +107,19 @@ async fn inspect_write_op_revoke_is_not_browser_reachable() { .uri("/api/provider/inspect/revoke") .header("x-elastos-home-token", token) .header(CONTENT_TYPE, "application/json") - .body(Body::from("{\"token_id\":\"00000000000000000000000000000000\"}")) + .body(Body::from( + "{\"token_id\":\"00000000000000000000000000000000\"}", + )) .unwrap(), ) .await .unwrap(); // Not OK, and specifically not found — the op never enters the proxy. - assert_eq!(resp.status(), StatusCode::NOT_FOUND, "revoke must not be browser-reachable"); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "revoke must not be browser-reachable" + ); } #[tokio::test] @@ -133,5 +141,9 @@ async fn inspect_capsules_rejects_non_system_app() { ) .await .unwrap(); - assert_ne!(resp.status(), StatusCode::OK, "non-System app must not inspect"); + assert_ne!( + resp.status(), + StatusCode::OK, + "non-System app must not inspect" + ); } diff --git a/elastos/crates/elastos-server/src/carrier_bridge.rs b/elastos/crates/elastos-server/src/carrier_bridge.rs index c5557b12..e0c6f750 100644 --- a/elastos/crates/elastos-server/src/carrier_bridge.rs +++ b/elastos/crates/elastos-server/src/carrier_bridge.rs @@ -1402,7 +1402,10 @@ mod tests { let cases = [ ("capsules", serde_json::json!({})), ("capsule", serde_json::json!({ "id": "probe" })), - ("plan", serde_json::json!({ "id": "probe", "operation": "x" })), + ( + "plan", + serde_json::json!({ "id": "probe", "operation": "x" }), + ), ]; for (op, mut payload) in cases { @@ -1410,14 +1413,17 @@ mod tests { .unwrap_or_else(|| panic!("no canonical action for inspect op {op}")); let token = encode_bridge_capability_token(&capability_manager.grant( "test-capsule", - ResourceId::new(&format!("elastos://inspect/{op}")), + ResourceId::new(format!("elastos://inspect/{op}")), action, TokenConstraints::default(), None, )); let obj = payload.as_object_mut().unwrap(); obj.insert("type".into(), serde_json::json!("carrier_invoke")); - obj.insert("uri".into(), serde_json::json!(format!("elastos://inspect/{op}"))); + obj.insert( + "uri".into(), + serde_json::json!(format!("elastos://inspect/{op}")), + ); obj.insert("operation".into(), serde_json::json!(op)); obj.insert("token".into(), serde_json::json!(token)); let line = serde_json::json!({ "id": 1, "request": payload }).to_string(); diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index bfecf43c..a9cb2baa 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -28,7 +28,9 @@ use async_trait::async_trait; use elastos_common::{CapsuleAffordanceDescriptor, CapsuleManifest}; use elastos_runtime::inspect::InspectScope; use elastos_runtime::invoke::{self, InvokeError}; -use elastos_runtime::provider::{Provider, ProviderError, ProviderRegistry, ResourceRequest, ResourceResponse}; +use elastos_runtime::provider::{ + Provider, ProviderError, ProviderRegistry, ResourceRequest, ResourceResponse, +}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -43,10 +45,17 @@ fn signature_fingerprint(sig_b64: &str) -> Option { if sig_b64.is_empty() { return None; } - let bytes = B64.decode(sig_b64).unwrap_or_else(|_| sig_b64.as_bytes().to_vec()); + let bytes = B64 + .decode(sig_b64) + .unwrap_or_else(|_| sig_b64.as_bytes().to_vec()); // `.take(16)` rather than a `[..16]` slice: can never panic regardless of the // digest's hex length (hex chars are single-byte ASCII, so no boundary risk). - Some(hex::encode(Sha256::digest(&bytes)).chars().take(16).collect()) + Some( + hex::encode(Sha256::digest(&bytes)) + .chars() + .take(16) + .collect(), + ) } /// Derive a fail-closed trust classification from what is actually verifiable @@ -186,7 +195,12 @@ impl AuditSource for AuthAuditSource { }); } } - CapsuleAudit { total, denied, attested, recent } + CapsuleAudit { + total, + denied, + attested, + recent, + } }) .await .unwrap_or_default() @@ -222,13 +236,22 @@ fn running_to_entry(info: crate::runtime::RunningCapsuleInfo) -> InspectEntry { impl InspectSource for RuntimeInspectSource { async fn inspect_list(&self) -> Vec { match self.runtime.upgrade() { - Some(rt) => rt.list_capsules().await.into_iter().map(running_to_entry).collect(), + Some(rt) => rt + .list_capsules() + .await + .into_iter() + .map(running_to_entry) + .collect(), None => Vec::new(), } } async fn inspect_get(&self, id: &str) -> Option { - self.runtime.upgrade()?.get_capsule(id).await.map(running_to_entry) + self.runtime + .upgrade()? + .get_capsule(id) + .await + .map(running_to_entry) } } @@ -276,8 +299,8 @@ impl InspectSource for RegistryInspectSource { async fn inspect_get(&self, id: &str) -> Option { let scheme = id.strip_prefix("provider:")?; let reg = self.registry.upgrade()?; - let known = - reg.has_provider(scheme).await || reg.sub_provider_schemes().await.iter().any(|s| s == scheme); + let known = reg.has_provider(scheme).await + || reg.sub_provider_schemes().await.iter().any(|s| s == scheme); known.then(|| Self::scheme_entry(scheme.to_string())) } } @@ -294,7 +317,10 @@ pub struct CatalogInspectSource { impl CatalogInspectSource { pub fn new(capsules_dir: PathBuf, registry: Weak) -> Self { - Self { capsules_dir, registry } + Self { + capsules_dir, + registry, + } } /// The scheme a provider capsule serves, parsed from `provides` @@ -455,7 +481,10 @@ pub struct InspectProvider { impl InspectProvider { pub fn new(source: Arc) -> Self { - Self { source, audit: None } + Self { + source, + audit: None, + } } /// Attach a per-capsule audit source so detail views show live activity. @@ -617,10 +646,12 @@ impl InspectProvider { let recent: Vec = a .recent .iter() - .map(|r| json!({ - "ts": r.ts, "event": r.event, "detail": r.detail, "success": r.success, - "signed": r.signed, "signer": r.signer, - })) + .map(|r| { + json!({ + "ts": r.ts, "event": r.event, "detail": r.detail, "success": r.success, + "signed": r.signed, "signer": r.signer, + }) + }) .collect(); json!({ "counts": { "total": a.total, "denied": a.denied, "attested": a.attested }, @@ -885,7 +916,9 @@ mod tests { } fn provider_with_probe() -> InspectProvider { - InspectProvider::new(Arc::new(MockSource { entries: vec![probe_entry()] })) + InspectProvider::new(Arc::new(MockSource { + entries: vec![probe_entry()], + })) } #[tokio::test] @@ -914,7 +947,10 @@ mod tests { assert_eq!(data["affordances"][0]["input_schema"]["type"], "object"); assert_eq!(data["affordances"][0]["output_schema"]["type"], "string"); assert_eq!(data["required_capabilities"][0], "elastos://storage/probe"); - assert_eq!(data["storage_namespaces"][0], "localhost://WebSpaces/probe/"); + assert_eq!( + data["storage_namespaces"][0], + "localhost://WebSpaces/probe/" + ); assert_eq!(data["identity"]["signature_present"], true); // Principle #16: never echo the raw signature or any bearer token. @@ -942,7 +978,9 @@ mod tests { assert_eq!(data["identity"]["did"], Value::Null); assert_eq!(data["provenance"]["signed_by"], Value::Null); // A real, non-secret 16-hex fingerprint that is NOT the raw signature. - let fp = data["provenance"]["signature_fingerprint"].as_str().unwrap(); + let fp = data["provenance"]["signature_fingerprint"] + .as_str() + .unwrap(); assert_eq!(fp.len(), 16); assert!(fp.chars().all(|c| c.is_ascii_hexdigit())); assert_ne!(fp, "SECRET_SIGNATURE_MUST_NOT_LEAK"); @@ -953,7 +991,9 @@ mod tests { // A capsule whose id IS a DID: surface it (not fabricated — it exists). let mut entry = probe_entry(); entry.id = "did:elastos:abc123".to_string(); - let provider = InspectProvider::new(Arc::new(MockSource { entries: vec![entry] })); + let provider = InspectProvider::new(Arc::new(MockSource { + entries: vec![entry], + })); let resp = provider .send_raw(&json!({ "op": "capsule", "id": "did:elastos:abc123" })) .await @@ -1007,14 +1047,18 @@ mod tests { let source = RegistryInspectSource::new(Arc::downgrade(®istry)); let entries = source.inspect_list().await; - assert!(entries.iter().any(|e| e.name == "wallet" && e.id == "provider:wallet")); + assert!(entries + .iter() + .any(|e| e.name == "wallet" && e.id == "provider:wallet")); assert!(source.inspect_get("provider:wallet").await.is_some()); assert!(source.inspect_get("provider:nope").await.is_none()); } #[tokio::test] async fn aggregate_source_unions_and_dedups() { - let a: Arc = Arc::new(MockSource { entries: vec![probe_entry()] }); + let a: Arc = Arc::new(MockSource { + entries: vec![probe_entry()], + }); let b: Arc = Arc::new(MockSource { entries: vec![ probe_entry(), // duplicate id — should be deduped @@ -1069,7 +1113,10 @@ mod tests { .await .unwrap(); assert_eq!(resp["data"]["affordances"][0]["id"], "ping"); - assert_eq!(resp["data"]["required_capabilities"][0], "elastos://storage/probe"); + assert_eq!( + resp["data"]["required_capabilities"][0], + "elastos://storage/probe" + ); let serialized = serde_json::to_string(&resp).unwrap(); assert!(!serialized.contains("SECRET_SIGNATURE_MUST_NOT_LEAK")); } @@ -1129,8 +1176,10 @@ mod tests { } } - let provider = InspectProvider::new(Arc::new(MockSource { entries: vec![probe_entry()] })) - .with_audit(Arc::new(MockAudit)); + let provider = InspectProvider::new(Arc::new(MockSource { + entries: vec![probe_entry()], + })) + .with_audit(Arc::new(MockAudit)); let resp = provider .send_raw(&json!({ "op": "capsule", "id": "cap_probe_1" })) .await @@ -1140,10 +1189,16 @@ mod tests { // Attestation fidelity: who cryptographically signed each event (#15), // surfaced as presence + DID, never the signature itself (#16). assert_eq!(resp["data"]["audit"]["counts"]["attested"], 1); - assert_eq!(resp["data"]["audit"]["recent"][0]["event"], "capability.use"); + assert_eq!( + resp["data"]["audit"]["recent"][0]["event"], + "capability.use" + ); assert_eq!(resp["data"]["audit"]["recent"][0]["success"], true); assert_eq!(resp["data"]["audit"]["recent"][0]["signed"], true); - assert_eq!(resp["data"]["audit"]["recent"][0]["signer"], "did:elastos:gateway"); + assert_eq!( + resp["data"]["audit"]["recent"][0]["signer"], + "did:elastos:gateway" + ); } #[tokio::test] @@ -1219,7 +1274,9 @@ mod tests { let entries = source.inspect_list().await; assert!( - entries.iter().any(|e| e.name == "did" && e.id == "provider:did"), + entries + .iter() + .any(|e| e.name == "did" && e.id == "provider:did"), "sub-provider scheme must be listed" ); assert!(source.inspect_get("provider:did").await.is_some()); @@ -1291,14 +1348,22 @@ mod tests { manifest: Some(manifest), cid: None, }; - let provider = InspectProvider::new(Arc::new(MockSource { entries: vec![entry] })); + let provider = InspectProvider::new(Arc::new(MockSource { + entries: vec![entry], + })); let resp = provider .send_raw(&json!({ "op": "capsule", "id": "capsule:key-provider" })) .await .unwrap(); let data = &resp["data"]; - assert_eq!(data["authority"]["capabilities"][0]["resource"], "elastos://key/*"); - assert_eq!(data["authority"]["capabilities"][0]["operations"][1], "release"); + assert_eq!( + data["authority"]["capabilities"][0]["resource"], + "elastos://key/*" + ); + assert_eq!( + data["authority"]["capabilities"][0]["operations"][1], + "release" + ); assert_eq!(data["authority"]["audit_events"][1], "key.release.denied"); // #16: the raw signature is still never echoed. assert!(!serde_json::to_string(data) @@ -1337,7 +1402,9 @@ mod tests { manifest: Some(manifest), cid: None, }; - InspectProvider::new(Arc::new(MockSource { entries: vec![entry] })) + InspectProvider::new(Arc::new(MockSource { + entries: vec![entry], + })) } #[tokio::test] diff --git a/elastos/crates/elastos-server/src/serve_cmd.rs b/elastos/crates/elastos-server/src/serve_cmd.rs index 625f0f1d..7079c41b 100644 --- a/elastos/crates/elastos-server/src/serve_cmd.rs +++ b/elastos/crates/elastos-server/src/serve_cmd.rs @@ -203,9 +203,7 @@ pub async fn run_serve( Arc::new(ip::RuntimeInspectSource::new(Arc::downgrade(&runtime_arc))); let audit = Arc::new(ip::AuthAuditSource::new(data_dir.clone())); provider_registry - .register(Arc::new( - ip::InspectProvider::new(source).with_audit(audit), - )) + .register(Arc::new(ip::InspectProvider::new(source).with_audit(audit))) .await; } @@ -318,14 +316,14 @@ pub async fn run_serve( data_dir.join("capsules"), Arc::downgrade(&infra.provider_registry), )); - let source: Arc = - Arc::new(ip::AggregateInspectSource::new(vec![runtime_src, catalog_src])); + let source: Arc = Arc::new(ip::AggregateInspectSource::new(vec![ + runtime_src, + catalog_src, + ])); let audit = Arc::new(ip::AuthAuditSource::new(data_dir.clone())); infra .provider_registry - .register(Arc::new( - ip::InspectProvider::new(source).with_audit(audit), - )) + .register(Arc::new(ip::InspectProvider::new(source).with_audit(audit))) .await; } From f32f29fba9aff42e048078967bc1b97d6054bad3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 04:30:04 +0000 Subject: [PATCH 28/31] =?UTF-8?q?test(inspect):=20KNOWN=5FGAPS=20ratchet?= =?UTF-8?q?=20registry=20=E2=80=94=20build-visible=20gaps=20as=20#[ignore]?= =?UTF-8?q?d=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the LESSONS.md flywheel pattern: turn the inspector's honest gaps into a build-visible, self-closing registry instead of prose that rots. - docs/KNOWN_GAPS.md: registry table of the 4 open gaps (granted_capabilities, verified signer/signed_by, invoke dispatch, human-approval loop) with why-open + ratchet + close criteria, plus an "enforced invariants" section (merge tripwire, no-leak, fail-closed scope) so safe-by-construction items aren't mistaken for open work. - Two #[ignore]d ratchet tests (G1 granted_capabilities, G2 verified signer): they encode the desired end-state and FAIL today, so they are non-blocking in a shared tree (skipped by default) yet flip to green the moment the gap closes (delete the #[ignore]). Verified: default run skips them (30 passed, 2 ignored); `--ignored` run shows both failing — proving they're real ratchets, not vacuous passes. - G3/G4 are registry rows only — no compiling test exists until the feature scaffold does; a fabricated one would be vacuous (default to "not a finding"). fmt --check PASS; new code clippy-clean (pre-existing gateway_capsule_catalog #4 untouched, not ours). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/KNOWN_GAPS.md | 34 ++++++++++++++++ .../elastos-server/src/inspect_provider.rs | 40 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 docs/KNOWN_GAPS.md diff --git a/docs/KNOWN_GAPS.md b/docs/KNOWN_GAPS.md new file mode 100644 index 00000000..c509914b --- /dev/null +++ b/docs/KNOWN_GAPS.md @@ -0,0 +1,34 @@ +# KNOWN_GAPS — Capsule Inspector + +Build-visible registry of what the Inspector does **not** yet assert, so gaps are +impossible to forget and "we should fix this" becomes a tracked, enforceable +contract instead of prose. (Pattern from `LESSONS.md`: *turn an audit into a +build-visible gap registry, not a doc that rots*.) + +Each **open** gap below has a `#[ignore]`d **ratchet test** that encodes the +desired end-state and **fails today** (hence ignored — non-blocking in a shared +tree). Closing a gap = wire the feature, delete the `#[ignore]`, the test goes +green, and the row moves to "Closed." + +## Open gaps + +| # | Gap | Why open | Ratchet test (`#[ignore]`d) | Close criteria | +|---|-----|----------|------------------------------|----------------| +| G1 | `granted_capabilities` is always empty | ElastOS caps are bearer tokens with no central per-capsule registry, and `RuntimeAuditEventV1` carries no resource/action — the observed-grant list can't be derived without fabrication. | `inspect_provider::tests::ratchet_granted_capabilities_populated` | A capability-event source records resource+action; projection lists observed grants; test asserts non-empty and goes green. | +| G2 | Provenance `signed_by` (verified signer) is always null | The manifest schema carries no signer DID/pubkey, so we surface presence + fingerprint + trust level but never a *verified* signer (we refuse to present the declared author as verified). | `inspect_provider::tests::ratchet_provenance_verified_signer_present` | A signature-verification source resolves the signer DID; projection fills `signed_by`; test asserts non-null. | +| G3 | Invoke **dispatch** (the "act" half) | Preview-only by design. Dispatch must consult DDRM's `required_action_for` so preview and enforcement agree by construction — best built on the merged base. **Merge-gated.** | *Pending feature scaffold* (no compiling test until the dispatch API exists — a fabricated one would be vacuous). | Dispatch lands on the unified base; a carrier e2e test proves a gated call enforces exactly the previewed gate. | +| G4 | Human-**approval** loop | The bridge from "preview the gate" → a signed, no-effect intent a human/authority approves before any dispatch. Not yet built. | *Pending feature scaffold* (added with the approval-intent decision core). | Approval-intent core + recorded approve/deny decision exist; ratchet test asserts fail-closed default-deny. | + +## Enforced invariants (the inverse — already guaranteed, not gaps) + +These are *closed by construction* and worth recording so they aren't mistaken +for open work: + +- **Merge gate-contract tripwire** — `carrier_bridge::tests::carrier_inspect_ops_match_canonical_action_contract` drives a real carrier call per inspect op at its canonical action; goes red the moment DDRM's `required_action_for` would fail-close an inspect op. Enforced now. +- **No secret/handle leakage (#16)** — `inspect_provider::tests::capsule_detail_renders_contract_without_leaking_authority` proves the raw signature / token never appear in output. +- **Two-tier scope, fail-closed (#11)** — `elastos-runtime::inspect` unit tests + `tests/inspect_conformance.rs` prove a SelfOnly caller cannot read another capsule. + +## How to use this file +- A new gap → add a row + an `#[ignore]`d ratchet test (or note "pending scaffold" if no API exists yet). +- Closing a gap → wire it, delete the `#[ignore]`, confirm the test is green, move the row to a "Closed" section (or delete it — this is memory, not an archive). +- A gap proven safe-by-construction → move it to "Enforced invariants" (confirming-safe is as important as finding-bad). diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index a9cb2baa..6ff3f615 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -986,6 +986,46 @@ mod tests { assert_ne!(fp, "SECRET_SIGNATURE_MUST_NOT_LEAK"); } + // ── KNOWN_GAPS ratchet tests ──────────────────────────────────────── + // These encode the desired end-state of an OPEN gap (see docs/KNOWN_GAPS.md) + // and fail today, so they are #[ignore]d (non-blocking in a shared tree). + // Closing a gap = wire the feature, delete the #[ignore], the test goes + // green. Run `cargo test -- --ignored` to see them fail (proving they are + // real ratchets, not vacuous passes). + + #[tokio::test] + #[ignore = "KNOWN_GAPS G1: granted_capabilities not yet wired (no resource/action in audit events); see docs/KNOWN_GAPS.md"] + async fn ratchet_granted_capabilities_populated() { + // Desired: a capsule with grants reports them. Today the projection + // always returns []. When an observed-grant source is wired, delete the + // #[ignore] and this goes green. + let resp = provider_with_probe() + .send_raw(&json!({ "op": "capsule", "id": "cap_probe_1" })) + .await + .unwrap(); + let granted = resp["data"]["granted_capabilities"].as_array().unwrap(); + assert!( + !granted.is_empty(), + "granted_capabilities should be populated once an observed-grant source exists" + ); + } + + #[tokio::test] + #[ignore = "KNOWN_GAPS G2: signer verification not yet wired (manifest has no signer DID); see docs/KNOWN_GAPS.md"] + async fn ratchet_provenance_verified_signer_present() { + // Desired: a signed capsule reports a *verified* signer. Today signed_by + // is null (we refuse to present the declared author as verified). When a + // signature-verification source is wired, delete the #[ignore]. + let resp = provider_with_probe() + .send_raw(&json!({ "op": "capsule", "id": "cap_probe_1" })) + .await + .unwrap(); + assert!( + !resp["data"]["provenance"]["signed_by"].is_null(), + "provenance.signed_by should carry a verified signer once verification is wired" + ); + } + #[tokio::test] async fn provenance_surfaces_did_when_genuinely_present() { // A capsule whose id IS a DID: surface it (not fabricated — it exists). From 54d436670966ae393e9db0536739c019b9b78a61 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 13:30:07 +0000 Subject: [PATCH 29/31] docs(roadmap): capture next-steps roadmap from the inspector branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the direction this branch is a foundation for, so the intent behind the substrate isn't lost. Frames the work as completing one control loop — reflect → preview → approve → act → audit — then putting selectable shells (including an intent-led AI shell with a contained agent capsule) on top. Contents: where we are (the built substrate); ordered roadmap (approval loop next; dispatch merge-gated on DDRM; shell-manager + selectable shells; intent-led AI shell; pluggable local/cloud intelligence; a Morphic/Godot living-object canvas — presentation only, core stays the authority); the experience we're building toward (authority made legible: trust as material, gates as visible circuits, approval as a deliberate ceremony, audit as a timeline); business model (shell tiers, DRM-self-enforced access, agent-safe enterprise wedge); and the trust/security framing (build-time vs run-time boundaries; open code != open authority; the real risks are the signing trust root, automation bias, and TCB creep — not forking). Direction, not a commitment. Honest real-vs-vision split; gaps stay tracked via the KNOWN_GAPS ratchet pattern. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/INSPECTOR_ROADMAP.md | 148 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/INSPECTOR_ROADMAP.md diff --git a/docs/INSPECTOR_ROADMAP.md b/docs/INSPECTOR_ROADMAP.md new file mode 100644 index 00000000..105f9c4e --- /dev/null +++ b/docs/INSPECTOR_ROADMAP.md @@ -0,0 +1,148 @@ +# Inspector → roadmap: where this branch is heading + +What the Capsule Inspector work on `feat/capsule-inspector` is a foundation *for*. +This is direction, not a commitment — it records the next steps we want to work +toward so the intent behind the substrate isn't lost. Pair with +`CAPSULE_INSPECTOR.md` (what's built), `KNOWN_GAPS.md` (what's not yet asserted), +and `INSPECT_DDRM_MERGE_NOTES.md` (cross-branch). + +## Where we are (the substrate already built here) + +A read-only, capability-gated, object-centred view of every capsule — identity, +powers, provenance, audit — plus a metadata-driven **gate preview** that shows the +exact capability tuple (resources + actions + audit events) an operation *would* +require, before anything runs. On the real product path (`ProviderRegistry`), +verified through both transports (browser gateway + capsule carrier bridge), with +honest provenance and attested audit. Fail-closed throughout. + +In one line: **we made the runtime's authority legible and previewable.** + +## North star: complete the loop, then put a shell on top + +The substrate is four-fifths of a control loop: + +> **reflect → preview → approve → act → audit** + +- **reflect / preview** — built (this branch). +- **approve** — next (the human-in-the-loop consent step). +- **act** — capability-gated dispatch (merge-gated on DDRM's `required_action_for`). +- **audit** — attestation surfaced (this branch); deepens as `act` lands. + +On top of that loop sits the real prize: **selectable shells**, including an +**intent-led AI shell** where a user states an outcome and a *contained* agent +manifests it through the same capability-gated path — never outside it. + +## Roadmap (ordered; parallel-safe unless noted) + +1. **Approval loop** *(parallel-safe — next).* A fail-closed approval-intent core + (mirroring `inspect`/`invoke`): given a previewed gate + requester scope, yield + `Approved | Denied | PendingApproval`, defaulting to deny. A recorded, audited + approve/deny decision carrying no token or signature material (#16). Closes + `KNOWN_GAPS` G4. +2. **Invoke dispatch — the "act"** *(merge-gated on DDRM).* Built to consult DDRM's + `required_action_for` so preview and enforcement agree by construction. Closes + `KNOWN_GAPS` G3. This is what turns "preview" into safe, gated, audited action. +3. **Selectable shells + a shell-manager** *(net-new).* The runtime already routes + everything through one capability-gated path and treats "the shell" as a + capability-scoped consumer — so multiple shells (a classic OS view, a premium + designed view, an AI shell) can coexist and be **swappable**. Needs a + shell-manager: which presentation holds which scope, and clean session handoff. +4. **Intent-led AI shell** *(net-new, depends on 1–2).* State an outcome; a + contained **agent capsule** (zero ambient authority) compiles it into capsule + operations, previews each gate, requests approval, acts, and shows the receipts. + The agent proposes; the capability layer disposes. +5. **Pluggable intelligence — local or cloud** *(design now, build with 4).* The + model's location is a privacy/capability choice, not an architecture change: + run a local LLM on your own hardware (data sovereignty — intent never leaves the + box) or grant a scoped capability to a hosted model. Same safety envelope either + way. "Bring your own intelligence." +6. **Living-object presentation (Morphic/Godot canvas)** *(net-new, presentation + only).* Render capsules as live, manipulable objects on a GPU canvas (the modern + Morphic). Presentation only — the Rust trusted core stays the authority; the + canvas is the face, never the gate. + +## The experience we're building toward + +A shift from *operating* a computer to *intending* an outcome — and being able to +**see exactly what's about to happen before it does**. + +Each thing we built becomes something the user can perceive: + +- **Trust → material.** Signed = solid; content-addressed = glass; unsigned = + translucent (our `trust_level` + `signature_fingerprint`, made visible). +- **Powers → visible ports** on each capsule (the `authority` surface). +- **A proposed action → a visible circuit of authority** drawn between capsules + (the gate preview rendered as light) — the resources and actions shown *before* + anything runs. +- **Approval → a deliberate ceremony**, heavier for irreversible "one-way doors." + The friction on irreversible acts is sacred — the one thing we never smooth away + (anti-automation-bias by design). +- **Granted capability → a token of light** flowing to the provider; **revocation → + the light snuffed out**. +- **Audit → a persistent timeline**, attested events wearing a signed halo with the + signer DID (our attestation work). +- **Scope → altitude.** System = the overworld of all capsules; SelfOnly = inside + one. The shell-swap "clicker" pulls the camera back to choose a shell. + +The honest magic: none of it is faked sparkle — **every glow is a real capability, +every halo a real signature, every snuffed thread a real revocation.** It holds up +to a skeptic because it's bound to real mechanics. + +### Illustrative user stories +- **Creator:** "release this album to paid holders, keep the masters sealed" — keys + flow only to verified holders, royalties settle, masters stay sealed; the user + *watched* it rather than trusting it. +- **Enterprise operator:** an agent proposes a multi-step migration; reversible steps + run autonomously, one-way doors are held for explicit approval, and the **attested + timeline is exported as the compliance record** — the audit *is* the interface. +- **Sovereign home user:** a **local** model organises personal data; an indicator + confirms nothing left the machine. +- **Contained agent (dogfood):** a coding agent works across capsules like a glass + engine; when it reaches beyond its grant, the gate simply **denies** — safety seen + happening in real time. + +## Business model + enterprise + +- **Shells as tiers.** Classic (free) · premium designed · AI shell (subscription). +- **Self-enforcing access.** The AI shell is itself a capsule, so access is a + capability you pay for — enforced by the **same rights/key-release machinery + (DDRM)** we govern. Pay → license token → shell unlocks. The product polices + itself with its own primitives. +- **Enterprise wedge — agent-safe computing.** Agents that act on real systems but + are capability-contained, previewable, approval-gated, and attested. The audit + trail we built is the compliance artifact. Defence in depth: even an imperfect + model is contained by the system. +- **Possible integration surface:** a hosting model where tool/agent protocols (e.g. + MCP-style servers) run as capsules and are capability-gated — "system-level safety" + complementing model-level safety. + +## Trust & security framing (why this is sound) + +Two distinct trust boundaries, not to be conflated: + +- **Build-time (authoring the trusted core):** privileged by nature — whoever builds + the enforcing core can change it. This is universal to every OS (cf. Thompson, + *Reflections on Trusting Trust*). The defence is a **minimal, auditable TCB + + reproducible, signed builds + provenance on the runtime itself**, so a user runs a + build they can verify. +- **Run-time (an agent operating inside):** hard-contained by capabilities — it + cannot rewrite the core or self-grant. This is the agent "adhering inside the + system," and it holds by design. + +On open source: **open code is not open authority.** A malicious fork endangers only +those who run unverified builds (mitigated by provenance), and it **cannot mint +authorization** — key release stays behind the real key-provider + on-chain rights +it doesn't control. Code is public; keys, signed grants, and chain-rooted rights are +not. Open barriers are auditable barriers. + +The real risks to invest against are the **signing/supply-chain trust root**, +**automation bias** in a too-smooth shell, and **TCB creep** — not forking. + +## Real vs. vision (so this doesn't rot into hype) + +- **Real today:** reflect, gate preview, fail-closed scope, attestation, one + canonical path — i.e. everything the experience is *bound to*. +- **To build:** approval loop (1), dispatch (2, merge-gated), shell-manager (3), the + AI shell + pluggable intelligence (4–5), the living-object canvas (6). +- Keep each milestone honest with the `KNOWN_GAPS` ratchet pattern: a gap is a + build-visible `#[ignore]`d test, closed by deleting the `#[ignore]`. From 1bfe59f07f8f500bcd80e0858f1fc44e088577f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 12:05:09 +0000 Subject: [PATCH 30/31] feat(approval): fail-closed approval decision core + inspect/intent preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "approve" step of the control loop (reflect → preview → APPROVE → act), parallel-safe and read-only. - elastos-runtime::approval (new, pure): `decide(mode, approver)` is fail-closed — the only path to Approved without an explicit yes is an affordance declared as needing no approval; User/RuntimePolicy default to PendingApproval; an explicit no always wins. `required_approval(actions)` scales the requirement with action strength (anything beyond read/message needs a human). 3 tests. - inspect/intent (new provider op, read-only): given a capsule + operation, derives the gate (via plan), the approval it requires, and the fail-closed default decision. Records nothing, dispatches nothing. - Gated consistently: `intent` added to the canonical op→action contract (Read) and the System-only browser allow-list. - Decisions: `revoke` and recorded approve/deny stay on the runtime/dispatch (mutation) path — the product InspectProvider remains a read-only projection. Recording pairs with dispatch (merge-gated). fmt --check PASS; targeted tests green (approval 3, inspect incl. intent 31 +2 ratchets ignored, provider_resource contract 1). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- .../elastos-runtime/src/approval/mod.rs | 132 ++++++++++++++++++ elastos/crates/elastos-runtime/src/lib.rs | 1 + .../src/api/gateway_provider_proxy.rs | 2 +- .../elastos-server/src/inspect_provider.rs | 89 ++++++++++++ .../elastos-server/src/provider_resource.rs | 3 +- 5 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 elastos/crates/elastos-runtime/src/approval/mod.rs diff --git a/elastos/crates/elastos-runtime/src/approval/mod.rs b/elastos/crates/elastos-runtime/src/approval/mod.rs new file mode 100644 index 00000000..0f1bacaa --- /dev/null +++ b/elastos/crates/elastos-runtime/src/approval/mod.rs @@ -0,0 +1,132 @@ +//! Approval decisions (prototype) — the "approve" step of the control loop. +//! +//! The bridge from *preview the gate* (`invoke`) to *act* (a future dispatcher): +//! given the approval a call requires and any recorded approver decision, yield +//! [`ApprovalDecision::Approved`] | [`Denied`](ApprovalDecision::Denied) | +//! [`PendingApproval`](ApprovalDecision::PendingApproval). +//! +//! Pure and fail-closed by design: it records nothing and dispatches nothing +//! (mirroring [`crate::inspect`] and [`crate::invoke`]). Persisting a *signed* +//! decision is a mutation and belongs with the dispatcher, not here — this is +//! only the decision logic. The single rule: **we never auto-approve what we +//! cannot evaluate.** + +use crate::capability::token::Action; +use elastos_common::AffordanceApprovalMode; +use serde::Serialize; + +/// The outcome of an approval evaluation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalDecision { + /// May proceed — either declared as needing no approval, or explicitly approved. + Approved, + /// Explicitly refused by the approver. An explicit "no" always wins. + Denied, + /// Needs an explicit human/authority decision that has not yet been given. + PendingApproval, +} + +/// Derive the approval a provider operation requires from the capability actions +/// its gate demands. Fail-closed by strength: anything that writes, mutates, +/// actuates, or administers needs explicit human approval; pure reads/messages +/// need none. (Orthogonal to the capability gate — both must pass.) +pub fn required_approval(actions: &[Action]) -> AffordanceApprovalMode { + let needs_human = actions + .iter() + .any(|a| !matches!(a, Action::Read | Action::Message)); + if needs_human { + AffordanceApprovalMode::User + } else { + AffordanceApprovalMode::None + } +} + +/// Decide whether an action may proceed, fail-closed. +/// +/// `approver` is the recorded decision, if any: `Some(true)` = approved, +/// `Some(false)` = denied, `None` = not yet decided. The *only* path to +/// `Approved` without an explicit yes is an affordance that declared it needs no +/// approval ([`AffordanceApprovalMode::None`]). `User` and `RuntimePolicy` both +/// default to [`PendingApproval`] until an explicit decision exists — +/// `RuntimePolicy` fails closed because no policy engine evaluates it yet. +pub fn decide(mode: &AffordanceApprovalMode, approver: Option) -> ApprovalDecision { + match approver { + Some(false) => ApprovalDecision::Denied, // an explicit "no" always wins + Some(true) => ApprovalDecision::Approved, // an explicit "yes" + None => match mode { + AffordanceApprovalMode::None => ApprovalDecision::Approved, + // User / RuntimePolicy need a decision we don't have — never auto-approve. + _ => ApprovalDecision::PendingApproval, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn required_approval_scales_with_action_strength() { + assert_eq!( + required_approval(&[Action::Read]), + AffordanceApprovalMode::None + ); + assert_eq!( + required_approval(&[Action::Read, Action::Message]), + AffordanceApprovalMode::None + ); + // Anything stronger than read/message demands a human. + assert_eq!( + required_approval(&[Action::Write]), + AffordanceApprovalMode::User + ); + assert_eq!( + required_approval(&[Action::Execute]), + AffordanceApprovalMode::User + ); + assert_eq!( + required_approval(&[Action::Admin]), + AffordanceApprovalMode::User + ); + assert_eq!( + required_approval(&[Action::Read, Action::Admin]), + AffordanceApprovalMode::User + ); + } + + #[test] + fn decide_fails_closed_without_an_explicit_yes() { + // The G4 invariant: User/RuntimePolicy never auto-approve. + assert_eq!( + decide(&AffordanceApprovalMode::User, None), + ApprovalDecision::PendingApproval + ); + assert_eq!( + decide(&AffordanceApprovalMode::RuntimePolicy, None), + ApprovalDecision::PendingApproval + ); + // Only an affordance that declared "no approval needed" auto-approves. + assert_eq!( + decide(&AffordanceApprovalMode::None, None), + ApprovalDecision::Approved + ); + } + + #[test] + fn explicit_decisions_are_honored() { + assert_eq!( + decide(&AffordanceApprovalMode::User, Some(true)), + ApprovalDecision::Approved + ); + assert_eq!( + decide(&AffordanceApprovalMode::User, Some(false)), + ApprovalDecision::Denied + ); + // An explicit "no" wins even where no approval was required. + assert_eq!( + decide(&AffordanceApprovalMode::None, Some(false)), + ApprovalDecision::Denied + ); + } +} diff --git a/elastos/crates/elastos-runtime/src/lib.rs b/elastos/crates/elastos-runtime/src/lib.rs index e8b7ec57..8450f070 100644 --- a/elastos/crates/elastos-runtime/src/lib.rs +++ b/elastos/crates/elastos-runtime/src/lib.rs @@ -14,6 +14,7 @@ //! The HTTP API, CLI, and capsule loading logic live in the `elastos-server` crate. //! This library is transport-agnostic — it has no HTTP framework dependencies. +pub mod approval; pub mod auth; pub mod bootstrap; pub mod capability; diff --git a/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs b/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs index c657001a..eb62253f 100644 --- a/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs +++ b/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs @@ -1348,7 +1348,7 @@ pub(super) async fn gateway_provider_proxy( // gated. `plan` is the read-only invocation preview (no effect). // Write ops (e.g. revoke) are intentionally not exposed through the // browser proxy. - "capsules" | "capsule" | "plan" => &[SYSTEM_CAPSULE_ID], + "capsules" | "capsule" | "plan" | "intent" => &[SYSTEM_CAPSULE_ID], _ => { return ( StatusCode::NOT_FOUND, diff --git a/elastos/crates/elastos-server/src/inspect_provider.rs b/elastos/crates/elastos-server/src/inspect_provider.rs index 6ff3f615..7bd11334 100644 --- a/elastos/crates/elastos-server/src/inspect_provider.rs +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -26,6 +26,7 @@ use std::sync::{Arc, Weak}; use async_trait::async_trait; use elastos_common::{CapsuleAffordanceDescriptor, CapsuleManifest}; +use elastos_runtime::approval; use elastos_runtime::inspect::InspectScope; use elastos_runtime::invoke::{self, InvokeError}; use elastos_runtime::provider::{ @@ -701,10 +702,79 @@ impl InspectProvider { // gate the call would require. Dispatches NO effect — this is the // reflective half of the CAR invoke kernel. "plan" => self.handle_plan(request).await, + // Approval-intent preview (read-only): given a provider operation, + // derive the gate (via plan) and the approval it would require, and + // show the fail-closed default decision. Records nothing, dispatches + // nothing — the "approve" step of the loop, in preview form. + "intent" => self.handle_intent(request).await, other => provider_error("unknown_op", &format!("unknown inspect op: {other}")), } } + async fn handle_intent(&self, request: &Value) -> Value { + let (id, operation) = match ( + request.get("id").and_then(Value::as_str), + request.get("operation").and_then(Value::as_str), + ) { + (Some(id), Some(op)) => (id, op), + _ => { + return provider_error( + "invalid_request", + "inspect/intent requires \"id\" and \"operation\"", + ) + } + }; + let entry = match self.source.inspect_get(id).await { + Some(entry) => entry, + None => return provider_error("not_found", "no such capsule"), + }; + let authority = match entry.manifest.as_ref().and_then(|m| m.authority.as_ref()) { + Some(a) => a, + None => { + return provider_error( + "invalid_request", + "capsule declares no provider authority to plan against", + ) + } + }; + match invoke::plan_provider_operation(authority, operation) { + Ok(plan) => { + // Derive the approval this gate requires, then the fail-closed + // default decision (no approver yet → never auto-approve a + // write/execute/admin op). + let mode = approval::required_approval(&plan.actions); + let default = approval::decide(&mode, None); + json!({ + "status": "ok", + "data": { + "valid": true, + "kind": "approval_intent", + "capsule": id, + "operation": operation, + "resources": plan.resources, + "capability_actions": plan + .actions + .iter() + .map(|a| a.to_string()) + .collect::>(), + "requires_approval": serde_json::to_value(&mode).ok(), + "default_decision": serde_json::to_value(&default).ok(), + "audit_events": plan.audit_events, + } + }) + } + Err(InvokeError::UnknownOperation(op)) => json!({ + "status": "ok", + "data": { "valid": false, "error": "unknown_operation", "operation": op } + }), + Err(InvokeError::UnknownDeclaredAction(action)) => provider_error( + "manifest_error", + &format!("authority declares an unknown action \"{action}\""), + ), + Err(other) => provider_error("invalid_request", &format!("{other:?}")), + } + } + async fn handle_plan(&self, request: &Value) -> Value { let id = match request.get("id").and_then(Value::as_str) { Some(id) => id, @@ -1465,6 +1535,25 @@ mod tests { assert_eq!(resp["data"]["audit_events"][0], "key.release.denied"); } + #[tokio::test] + async fn intent_requires_approval_and_defaults_fail_closed() { + // key.release is an Execute op → it requires User approval, and with no + // approver recorded the default decision is fail-closed (pending), never + // auto-approved. Read-only: records and dispatches nothing. + let resp = key_provider_with_release() + .send_raw(&json!({ + "op": "intent", "id": "capsule:key-provider", "operation": "release" + })) + .await + .unwrap(); + assert_eq!(resp["status"], "ok"); + assert_eq!(resp["data"]["valid"], true); + assert_eq!(resp["data"]["kind"], "approval_intent"); + assert_eq!(resp["data"]["requires_approval"], "user"); + assert_eq!(resp["data"]["default_decision"], "pending_approval"); + assert_eq!(resp["data"]["resources"][0], "elastos://key/*"); + } + #[tokio::test] async fn plan_unknown_operation_reports_invalid() { let resp = key_provider_with_release() diff --git a/elastos/crates/elastos-server/src/provider_resource.rs b/elastos/crates/elastos-server/src/provider_resource.rs index 2aa4340f..679d7e48 100644 --- a/elastos/crates/elastos-server/src/provider_resource.rs +++ b/elastos/crates/elastos-server/src/provider_resource.rs @@ -110,7 +110,7 @@ pub fn build_capability_resource( pub fn inspect_op_required_action(op: &str) -> Option { use elastos_runtime::capability::token::Action; match op { - "capsules" | "capsule" | "self" | "plan" => Some(Action::Read), + "capsules" | "capsule" | "self" | "plan" | "intent" => Some(Action::Read), "revoke" => Some(Action::Write), _ => None, } @@ -319,6 +319,7 @@ mod tests { assert_eq!(inspect_op_required_action("capsule"), Some(Action::Read)); assert_eq!(inspect_op_required_action("self"), Some(Action::Read)); assert_eq!(inspect_op_required_action("plan"), Some(Action::Read)); + assert_eq!(inspect_op_required_action("intent"), Some(Action::Read)); assert_eq!(inspect_op_required_action("revoke"), Some(Action::Write)); // Unknown ops are not silently mapped — fail-closed at the caller. assert_eq!(inspect_op_required_action("nope"), None); From 2b413b041c9f998b2ed12138ef2ccc6cc5a6ccb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 12:05:46 +0000 Subject: [PATCH 31/31] docs(approval): document inspect/intent + contract-honesty path note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CAPSULE_INSPECTOR.md: add the inspect/intent wire contract (approval-intent preview); add a "path note" clarifying revoke + self are served on the embedded RequestHandler (shell) path while the product InspectProvider is a read-only projection (capsules/capsule/plan/intent) — closes the contract-honesty gap. - KNOWN_GAPS.md: G4 decision core DONE (approval + intent, fail-closed, tested); remaining = recording a signed approve/deny, which pairs with dispatch (G3). (An orchestrator CLAUDE.md was written locally but is .gitignored by repo policy, so it stays a local contract and is not committed.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016ZKy5Cca9RzwDuLb1szdeq --- docs/CAPSULE_INSPECTOR.md | 32 ++++++++++++++++++++++++++++++++ docs/KNOWN_GAPS.md | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/CAPSULE_INSPECTOR.md b/docs/CAPSULE_INSPECTOR.md index 07f7bd2e..fac62f43 100644 --- a/docs/CAPSULE_INSPECTOR.md +++ b/docs/CAPSULE_INSPECTOR.md @@ -377,6 +377,15 @@ The one mutating endpoint. Requires a **`Write`** inspect capability at a malformed id. The action is audited (`inspect.revoke`) in addition to the capability manager's own revocation audit. +> **Path note (contract honesty):** `revoke` and `self` are served on the +> **embedded `RequestHandler` (shell/runtime) path**, which holds the +> `CapabilityManager`/runtime handles a mutation needs. The product-side +> `InspectProvider` (the `ProviderRegistry` path both transports converge on) is +> a deliberately **read-only projection** and serves only `capsules`, `capsule`, +> `plan`, and `intent`. Recording a mutation (revoke, or a recorded approval +> decision) belongs with the runtime/dispatcher, not the read-only provider — +> see the roadmap's `act` step. + ### `elastos://inspect/plan` — read (two reflective modes, never mixed) Metadata-driven invocation **preview** (read-only dry-run; dispatches no @@ -427,6 +436,29 @@ DDRM branch lands, dispatch should consult its `required_action_for` op→action map as the authoritative classifier so the planner's preview and the carrier bridge's enforcement agree by construction. +### `elastos://inspect/intent` (params: `{ "id", "operation" }`) — read + +The **approval-intent preview** — the *approve* step of the control loop, in +read-only form. Given a provider operation it derives the gate (via `plan`), the +approval that gate requires, and the **fail-closed default decision** — recording +nothing and dispatching nothing: + +```json +{ "valid": true, "kind": "approval_intent", "capsule": "capsule:key-provider", + "operation": "release", "resources": ["elastos://key/*"], + "capability_actions": ["execute"], "requires_approval": "user", + "default_decision": "pending_approval", + "audit_events": ["key.release.denied", "key.release.granted"] } +``` + +The decision core is `elastos-runtime::approval`: `required_approval(actions)` +scales the approval requirement with action strength (anything beyond +read/message needs a human), and `decide(mode, approver)` is fail-closed — the +only path to `Approved` without an explicit "yes" is an affordance declared as +needing no approval; `User`/`RuntimePolicy` default to `pending_approval`; an +explicit "no" always wins. **Recording** an approve/deny is a mutation and lives +with the runtime/dispatcher (the `act` step), not this read-only provider. + ### Why `granted_capabilities` is observed, not enumerated ElastOS capabilities are **bearer-token object-capabilities**: a grant is an diff --git a/docs/KNOWN_GAPS.md b/docs/KNOWN_GAPS.md index c509914b..87867d24 100644 --- a/docs/KNOWN_GAPS.md +++ b/docs/KNOWN_GAPS.md @@ -17,7 +17,7 @@ green, and the row moves to "Closed." | G1 | `granted_capabilities` is always empty | ElastOS caps are bearer tokens with no central per-capsule registry, and `RuntimeAuditEventV1` carries no resource/action — the observed-grant list can't be derived without fabrication. | `inspect_provider::tests::ratchet_granted_capabilities_populated` | A capability-event source records resource+action; projection lists observed grants; test asserts non-empty and goes green. | | G2 | Provenance `signed_by` (verified signer) is always null | The manifest schema carries no signer DID/pubkey, so we surface presence + fingerprint + trust level but never a *verified* signer (we refuse to present the declared author as verified). | `inspect_provider::tests::ratchet_provenance_verified_signer_present` | A signature-verification source resolves the signer DID; projection fills `signed_by`; test asserts non-null. | | G3 | Invoke **dispatch** (the "act" half) | Preview-only by design. Dispatch must consult DDRM's `required_action_for` so preview and enforcement agree by construction — best built on the merged base. **Merge-gated.** | *Pending feature scaffold* (no compiling test until the dispatch API exists — a fabricated one would be vacuous). | Dispatch lands on the unified base; a carrier e2e test proves a gated call enforces exactly the previewed gate. | -| G4 | Human-**approval** loop | The bridge from "preview the gate" → a signed, no-effect intent a human/authority approves before any dispatch. Not yet built. | *Pending feature scaffold* (added with the approval-intent decision core). | Approval-intent core + recorded approve/deny decision exist; ratchet test asserts fail-closed default-deny. | +| G4 | Human-**approval** loop — *recording* | **Decision core DONE** (`elastos-runtime::approval` + `inspect/intent` preview, fail-closed, tested). Remaining: *recording* a signed approve/deny — a mutation that pairs with dispatch (G3) on the write path. | n/a — decision core is enforced by real tests (`approval::tests::*`, `inspect_provider::tests::intent_*`). | Recorded approve/deny decision exists on the runtime/dispatch path, audited. | ## Enforced invariants (the inverse — already guaranteed, not gaps)