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..98468059 --- /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/*" + ], + "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..3208b399 --- /dev/null +++ b/capsules/capsule-inspector/inspector/index.html @@ -0,0 +1,35 @@ + + + + + + Capsule Inspector + + + +
+
+ +

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 new file mode 100644 index 00000000..822f1b86 --- /dev/null +++ b/capsules/capsule-inspector/inspector/inspector.js @@ -0,0 +1,518 @@ +// Capsule Inspector (Phase 1) — read-only object-centered view. +// +// 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. + +"use strict"; + +// --------------------------------------------------------------------------- +// 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)`. +// --------------------------------------------------------------------------- +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() { + 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. + setScopeBadge("system"); + return SAMPLE_DATA.map((c) => ({ + id: c.id, name: c.name, role: c.role, type: c.type, state: c.state, + })); +} + +async function loadCapsuleDetail(id) { + 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. 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) { + 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 +// --------------------------------------------------------------------------- +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 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 (typeof ts !== "number" || !isFinite(ts) || ts <= 0) 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; +} + +// 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, capsuleId) { + const wrap = el("div"); + if (authority.reason) { + wrap.appendChild(el("div", { class: "note", text: authority.reason })); + } + for (const cap of authority.capabilities || []) { + const acts = (cap.actions || []).join(", "); + // 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" }, [ + el("span", { class: "mono", text: "audit: " + ev }), + ])); + } + 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; + // Aggregate ALL matching blocks (union), mirroring the server — fail-closed. + const blocks = (authority.capabilities || []).filter((cap) => + (cap.operations || []).includes(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", + resources, + capability_actions: 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(" + "); + // 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: resources }), + 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 || []) { + 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) || {}; + // 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: + `${total} events · ${counts.denied ?? 0} denied · ${counts.attested ?? 0} cryptographically attested` })); + for (const e of (audit && audit.recent) || []) { + 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; +} + +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))); + + // 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, c.id))); + } + + // 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([ + ["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], + ]))); + + // 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/*"], + 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" }, + ], + 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: 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 }], + }, + { + 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/*"], + granted_capabilities: [ + { 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/* 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..1b5999cc --- /dev/null +++ b/capsules/capsule-inspector/inspector/style.css @@ -0,0 +1,154 @@ +: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; } +/* 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); + 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/capsules/capsule-inspector/wasm/main.rs b/capsules/capsule-inspector/wasm/main.rs new file mode 100644 index 00000000..b2c0491e --- /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/*`). 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..fac62f43 --- /dev/null +++ b/docs/CAPSULE_INSPECTOR.md @@ -0,0 +1,476 @@ +# 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. + +### 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: + +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 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 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**. + +### 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. + +### 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 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/*`, which only the System surface can grant — it is a + System-trusted surface, not a freely distributable app. + +## 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: + +- `elastos-runtime::inspect` — the scope/authorization core (`authorize_view`, + `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. + +## 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 | 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 +**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. + +### 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). + +**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. +- `CatalogInspectSource` — the installed-capsule catalog on disk + (`/capsules//capsule.json`). Reads each capsule's **full + 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 + (`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`, +`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:** + +- Single-VM serve (`elastos serve `): `RuntimeInspectSource` → rich, + populated end-to-end. +- Main product path: `Aggregate[RuntimeInspectSource, CatalogInspectSource]` + on the shared registry the supervisor/gateway use — so the **browser + 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, 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. + +## Wire contract: `elastos://inspect/*` (read-only) + +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 (System scope) + +```json +{ + "scope": "system", + "capsules": [ + { "id": "...", "name": "chat-room", "role": "shell", "type": "wasm", "state": "running" } + ] +} +``` + +### `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 +{ + "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", + "input_schema": { "type": "object" }, "output_schema": { "type": "object" } }, + { "interface": "elastos.chat/v1", "id": "history", "risk": "read", + "approval": "none", "audit": "summary", "description": "Read history", + "input_schema": null, "output_schema": null } + ], + "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) 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. + +> **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 +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, "kind": "affordance", "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" } +``` + +**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", "resources": ["elastos://key/*"], + "capability_actions": ["execute"], "audit_events": ["key.release.denied", "key.release.granted"] } +``` + +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) +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. + +### `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 +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/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/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]`. 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. diff --git a/docs/INSPECT_DDRM_MERGE_NOTES.md b/docs/INSPECT_DDRM_MERGE_NOTES.md new file mode 100644 index 00000000..692cdaae --- /dev/null +++ b/docs/INSPECT_DDRM_MERGE_NOTES.md @@ -0,0 +1,123 @@ +# 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 — 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** — 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, 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 — 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 — 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 + `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 | +| --- | --- | --- | +| `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/docs/KNOWN_GAPS.md b/docs/KNOWN_GAPS.md new file mode 100644 index 00000000..87867d24 --- /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 — *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) + +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-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/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/handler/request_handler.rs b/elastos/crates/elastos-runtime/src/handler/request_handler.rs index 74fe903c..3f894c12 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,393 @@ impl RequestHandler { capsule_count: running.len(), } } + + // ===== Capsule Inspector (read-only) ===== + + /// Dispatch an `elastos://inspect/*` request under a scoped, fail-closed + /// 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, + uri: &str, + params: Option, + token: Option, + ) -> RuntimeResponse { + 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 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 + } 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 *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. + 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", + ) + } + } + }; + + 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", + ), + } + } + "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, + 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); + + // 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"), + "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, + "authority": authority, + "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 +1328,436 @@ mod tests { (handler, shell_id) } + /// 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()); + 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.clone(), + 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, 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), + } + } + + #[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, + 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, _capsule_manager) = 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 new file mode 100644 index 00000000..bf8a3628 --- /dev/null +++ b/elastos/crates/elastos-runtime/src/inspect/mod.rs @@ -0,0 +1,196 @@ +//! 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, independent of the handler wiring. +//! +//! ## Two tiers (encoded as capability grant patterns) +//! +//! - [`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. +//! +//! 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 grant pattern for the privileged, system-wide inspect view. +pub const INSPECT_SYSTEM: &str = "elastos://inspect/*"; + +/// 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)] +pub enum InspectScope { + /// May inspect every capsule (shell / System only). + System, + /// May inspect only its own capsule record. + 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. + /// + /// 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 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 + } + + /// 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 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()); + assert_eq!(scope, Some(InspectScope::System)); + } + + #[test] + fn system_grant_yields_system_scope() { + let scope = InspectScope::from_grants(false, [INSPECT_SYSTEM.to_string()]); + assert_eq!(scope, Some(InspectScope::System)); + } + + #[test] + fn self_grant_yields_self_only_scope() { + let scope = InspectScope::from_grants(false, [INSPECT_SELF.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 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()], + ); + 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_SELF.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_SYSTEM.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/invoke/mod.rs b/elastos/crates/elastos-runtime/src/invoke/mod.rs new file mode 100644 index 00000000..cbc2ff04 --- /dev/null +++ b/elastos/crates/elastos-runtime/src/invoke/mod.rs @@ -0,0 +1,406 @@ +//! 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, + ProviderAuthority, +}; +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 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, +/// 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(), + }) +} + +/// 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 { + /// 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, +} + +/// 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. 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 matching: Vec<&_> = authority + .capabilities + .iter() + .filter(|cap| cap.operations.iter().any(|op| op == operation)) + .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 { + resources, + actions, + audit_events: authority.audit_events.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") + } + + 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); + 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()); + } + + #[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.resources, vec!["elastos://key/*".to_string()]); + 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_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!({ + "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-runtime/src/lib.rs b/elastos/crates/elastos-runtime/src/lib.rs index f7bc52f7..8450f070 100644 --- a/elastos/crates/elastos-runtime/src/lib.rs +++ b/elastos/crates/elastos-runtime/src/lib.rs @@ -14,11 +14,14 @@ //! 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; pub mod capsule; pub mod handler; +pub mod inspect; +pub mod invoke; pub mod messaging; pub mod primitives; pub mod provider; 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/api/gateway_provider_proxy.rs b/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs index eac1ec81..eb62253f 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,21 @@ 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. `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" | "intent" => &[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..2fb3ae66 --- /dev/null +++ b/elastos/crates/elastos-server/src/api/gateway_tests/inspect.rs @@ -0,0 +1,149 @@ +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_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(); + 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/carrier_bridge.rs b/elastos/crates/elastos-server/src/carrier_bridge.rs index 48f4e8f3..e0c6f750 100644 --- a/elastos/crates/elastos-server/src/carrier_bridge.rs +++ b/elastos/crates/elastos-server/src/carrier_bridge.rs @@ -1252,6 +1252,193 @@ 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"); + } + + // 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 new file mode 100644 index 00000000..7bd11334 --- /dev/null +++ b/elastos/crates/elastos-server/src/inspect_provider.rs @@ -0,0 +1,1596 @@ +//! 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. 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::path::PathBuf; +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::{ + 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()); + // `.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 +/// 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 { + pub id: String, + pub name: String, + pub status: String, + 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 +/// 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; +} + +/// 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, + /// 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. +#[derive(Debug, Clone, Default)] +pub struct CapsuleAudit { + pub total: u64, + pub denied: u64, + /// How many of the recent events are cryptographically attested. + pub attested: 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 attested = 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; + } + 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, + attested, + recent, + } + }) + .await + .unwrap_or_default() + } +} + +// ── 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), + cid: None, + } +} + +#[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, + cid: 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.extend(reg.sub_provider_schemes().await); + schemes.sort(); + schemes.dedup(); + 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()?; + 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())) + } +} + +/// 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) => { + 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(), + } + } + + /// 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()?; + 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), + cid, + }) + } +} + +#[async_trait] +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 { + 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() { + let cid = cids.get(name).cloned(); + if let Some(entry) = self.read_entry(name, &running, cid).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; + let cid = self.catalog_cids().await.get(name).cloned(); + self.read_entry(name, &running, cid).await + } +} + +/// Aggregates several sources into one. This is the unification point: the main +/// 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 { + 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: Arc, + audit: Option>, +} + +impl InspectProvider { + pub fn new(source: Arc) -> Self { + 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 { + 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, audit: Value) -> 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); + 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 + // 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()) { + 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 { + // 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"), + "risk": field(m, "risk"), + "approval": field(m, "approval"), + "audit": field(m, "audit"), + "description": field(m, "description"), + "input_schema": field(m, "input_schema"), + "output_schema": field(m, "output_schema"), + })); + } + } + } + } + + 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": did.clone(), + "cid": cid.clone(), + "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": { + "schema": field(&manifest, "schema"), + "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 + // 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": { + // 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 }], + }) + } + + /// 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, "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, + "signed": r.signed, "signer": r.signer, + }) + }) + .collect(); + json!({ + "counts": { "total": a.total, "denied": a.denied, "attested": a.attested }, + "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 + // contract) gates who may reach this op. + "capsules" => { + let capsules: Vec = self + .source + .inspect_list() + .await + .iter() + .map(|e| { + json!({ + "id": e.id, + "name": e.name, + "role": e.manifest.as_ref().and_then(|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 self.source.inspect_get(id).await { + 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\""), + }, + // 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, + // 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, + None => return provider_error("invalid_request", "inspect/plan requires an \"id\""), + }; + let entry = match self.source.inspect_get(id).await { + Some(entry) => entry, + None => return provider_error("not_found", "no such capsule"), + }; + 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(), + "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 } + }), + // 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", + // 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 + .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:?}")), + } + } +} + +/// 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] +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 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + 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", + "input_schema": { "type": "object" }, + "output_schema": { "type": "string" } + }] + }], + "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 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()), + cid: None, + } + } + + fn provider_with_probe() -> InspectProvider { + InspectProvider::new(Arc::new(MockSource { + entries: vec![probe_entry()], + })) + } + + #[tokio::test] + async fn capsules_lists_with_system_scope() { + let resp = provider_with_probe() + .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 resp = provider_with_probe() + .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"); + // 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); + + // 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 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"); + } + + // ── 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). + 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() + .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 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, + cid: 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()); + } + + 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()); + } + + #[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, + 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 { + 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); + // 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] + 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 + // 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()); + // "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()); + } + + #[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"); + } + + #[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")); + } + + // 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"]["resources"][0], "elastos://key/*"); + assert_eq!(resp["data"]["capability_actions"][0], "execute"); + 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() + .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; + + #[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/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/provider_resource.rs b/elastos/crates/elastos-server/src/provider_resource.rs index cb54c2f4..679d7e48 100644 --- a/elastos/crates/elastos-server/src/provider_resource.rs +++ b/elastos/crates/elastos-server/src/provider_resource.rs @@ -84,10 +84,38 @@ 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}://*")), } } +/// 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" | "intent" => Some(Action::Read), + "revoke" => Some(Action::Write), + _ => None, + } +} + fn validate_segment(value: &str, label: &str) -> Result<(), String> { if !value.is_empty() && value @@ -268,6 +296,35 @@ 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 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("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); + } + #[test] fn ai_resource_without_backend() { let request = serde_json::json!({"op": "list_backends"}); 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..7079c41b 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,20 @@ 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. + { + 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).with_audit(audit))) + .await; + } + let api_handle = tokio::spawn({ let runtime = runtime_arc.clone(); let session_registry = session_registry.clone(); @@ -286,6 +301,32 @@ 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 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 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, + 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))) + .await; + } + let docs_dir = std::env::current_dir().ok().and_then(|d| { let docs = d.join(".."); if docs.join("ROADMAP.md").exists() {