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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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() {