rewritable 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/rwa.mjs +66 -0
- package/package.json +1 -1
- package/seeds/rewritable.html +619 -18
- package/src/identity.mjs +3 -2
- package/src/install.mjs +206 -0
- package/src/skill-manifest.mjs +232 -4
package/seeds/rewritable.html
CHANGED
|
@@ -407,7 +407,7 @@ window.ANCHORABLE_TAGS = ANCHORABLE_TAGS; // expose for tests
|
|
|
407
407
|
// ─── Storage ────────────────────────────────────────────────────────
|
|
408
408
|
const RWA = {
|
|
409
409
|
DB:'rwa_'+DOC_UUID, KEY:'self',
|
|
410
|
-
DOC:'rwa_doc', UNDO:'rwa_undo', HIST:'rwa_hist', FSA:'rwa_fsa', STATE:'rwa_state', VAULT:'rwa_vault',
|
|
410
|
+
DOC:'rwa_doc', UNDO:'rwa_undo', HIST:'rwa_hist', FSA:'rwa_fsa', STATE:'rwa_state', VAULT:'rwa_vault', SOURCES:'rwa_sources', HOOK_LOG:'rwa_hook_log',
|
|
411
411
|
UNDO_CAP:10, HIST_CAP:1000, NUDGE_THRESHOLD:5,
|
|
412
412
|
K_API:'rwa_apikey', K_MODEL:'rwa_model', K_BACKEND:'rwa_backend',
|
|
413
413
|
// Per-backend base-URL overrides for local OpenAI-compatible servers.
|
|
@@ -486,7 +486,7 @@ try {
|
|
|
486
486
|
const canonLF = s => s == null ? '' : String(s).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
487
487
|
|
|
488
488
|
let _db;
|
|
489
|
-
const REQUIRED_STORES = [RWA.DOC, RWA.UNDO, RWA.HIST, RWA.FSA, RWA.STATE, RWA.VAULT];
|
|
489
|
+
const REQUIRED_STORES = [RWA.DOC, RWA.UNDO, RWA.HIST, RWA.FSA, RWA.STATE, RWA.VAULT, RWA.SOURCES, RWA.HOOK_LOG];
|
|
490
490
|
// User-declared store registry. Persisted to RWA.STATE so declarations survive
|
|
491
491
|
// reload. The openDB() upgrade handler creates declared stores on every bump.
|
|
492
492
|
// Populated at bootstrap via loadUserStoreDecls(); mutated by runtime.db.open.
|
|
@@ -924,6 +924,15 @@ function emitRuntimeEvent(event, payload) {
|
|
|
924
924
|
for (const cb of [...runtimeEvents[event]]) {
|
|
925
925
|
try { cb(payload); } catch (_) { /* swallow user-cb errors */ }
|
|
926
926
|
}
|
|
927
|
+
// I8 (v0.9 §9) — fire installed hooks for the mapped lifecycle event. 'modify' (an edit commit,
|
|
928
|
+
// carrying instruction/lensMeta) → on-commit; 'mode' → on-mode-change (with the previous mode).
|
|
929
|
+
// Fire-and-forget: wrapped so a firing failure can never propagate into the commit/mode path.
|
|
930
|
+
try {
|
|
931
|
+
if (typeof fireHooks === 'function') {
|
|
932
|
+
if (event === 'modify') fireHooks('on-commit', { event: 'on-commit', instruction: payload && payload.instruction, lensMeta: payload && payload.lensMeta });
|
|
933
|
+
else if (event === 'mode') { const previous = _lastHookMode; _lastHookMode = payload && payload.mode; fireHooks('on-mode-change', { event: 'on-mode-change', mode: payload && payload.mode, previous }); }
|
|
934
|
+
}
|
|
935
|
+
} catch (_) { /* hook firing never breaks the emitter */ }
|
|
927
936
|
}
|
|
928
937
|
|
|
929
938
|
// === Cross-container bus (workspace-presence subset) ========================
|
|
@@ -1329,9 +1338,21 @@ function workspacePeerKnown(peer, known) {
|
|
|
1329
1338
|
return !!(peer && ((peer.uuid && known.has('uuid:' + peer.uuid)) || (peer.file && known.has('file:' + peer.file))));
|
|
1330
1339
|
}
|
|
1331
1340
|
|
|
1341
|
+
function safeWorkspaceHref(url) {
|
|
1342
|
+
// peer.url arrives over the public presence bus from another page in the origin,
|
|
1343
|
+
// so it is untrusted. escRuntimeHtml blocks attribute breakout but NOT a
|
|
1344
|
+
// `javascript:`/`data:` scheme, which would execute on click. Resolve and accept
|
|
1345
|
+
// only navigable web/file schemes; fall back to '#' for anything else or unparsable.
|
|
1346
|
+
try {
|
|
1347
|
+
const u = new URL(url, location.href);
|
|
1348
|
+
if (u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'file:') return u.href;
|
|
1349
|
+
} catch (_) {}
|
|
1350
|
+
return '#';
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1332
1353
|
function workspacePresenceCard(peer, known) {
|
|
1333
1354
|
const isKnown = workspacePeerKnown(peer, known);
|
|
1334
|
-
const href = peer.url
|
|
1355
|
+
const href = safeWorkspaceHref(peer.url);
|
|
1335
1356
|
const aff = peer.affordances && peer.affordances.length ? peer.affordances.map(a => a.kind).join(', ') : 'baseline';
|
|
1336
1357
|
return '<a class="rwa-ws-card rwa-ws-live-card" href="' + escRuntimeHtml(href) + '">' +
|
|
1337
1358
|
'<span class="rwa-ws-kind">' + escRuntimeHtml(peer.kind || 'document') + '</span>' +
|
|
@@ -5098,11 +5119,30 @@ window.submitLens = submitLens;
|
|
|
5098
5119
|
// workspaces (audit R7) can carry richer identifiers (e.g. signed source
|
|
5099
5120
|
// keys per the action-spec) without changing the rwa_hist schema.
|
|
5100
5121
|
function getActiveActor() {
|
|
5122
|
+
// I12 — when a multi-agent role is active, commits attribute to it (agents:${role}); the role is
|
|
5123
|
+
// the actor of record (rendered as a history badge). Else fall back to the backend/model string.
|
|
5124
|
+
if (activeAgentRole) return 'agents:' + activeAgentRole;
|
|
5101
5125
|
const backend = sessionStorage.getItem(RWA.K_BACKEND) || 'openrouter';
|
|
5102
5126
|
if (backend === 'bridge') return 'bridge:claude-p';
|
|
5103
5127
|
if (backend === 'bridge-session') return 'bridge:claude-session';
|
|
5104
5128
|
return sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
|
|
5105
5129
|
}
|
|
5130
|
+
// I12 — the modify() system prompt, keyed by the active agent's role. An active (verified) agent's
|
|
5131
|
+
// system_prompt swaps the per-kind FRAMING; the shared SYSTEM_PROMPT_RULES (tool protocol, frozen-
|
|
5132
|
+
// zone rules, data-rwa-id) are always appended so editing still works. No agent → the singleton.
|
|
5133
|
+
function resolveSystemPrompt() {
|
|
5134
|
+
if (activeAgentRole) {
|
|
5135
|
+
const rec = Array.from(installedAgents.values()).find(a => a.role === activeAgentRole && a.verified);
|
|
5136
|
+
if (rec && typeof rec.manifest.system_prompt === 'string') return rec.manifest.system_prompt + '\n' + SYSTEM_PROMPT_RULES;
|
|
5137
|
+
}
|
|
5138
|
+
return SYSTEM_PROMPT;
|
|
5139
|
+
}
|
|
5140
|
+
// I12 — the role-scoped vault gate: exact vault:<ns> membership in the agent's vault_namespace_set
|
|
5141
|
+
// (mirrors _skVaultAllowed against the agent record instead of the skill's permissions).
|
|
5142
|
+
function _agVaultAllowed(agent, ns) {
|
|
5143
|
+
const set = (agent && Array.isArray(agent.vault_namespace_set)) ? agent.vault_namespace_set : [];
|
|
5144
|
+
return set.indexOf('vault:' + ns) !== -1;
|
|
5145
|
+
}
|
|
5106
5146
|
|
|
5107
5147
|
// Resolve the user-selected backend into a transport config.
|
|
5108
5148
|
// kind:'openai_compat' covers openrouter/ollama/lmstudio — same wire protocol,
|
|
@@ -6304,6 +6344,17 @@ let activeView = null;
|
|
|
6304
6344
|
// runtimeDescribe()/runtimeListSkills(). The agent can never write the zone (it is
|
|
6305
6345
|
// data-rwa-frozen); only the runtime rewrites it (increment 7, registry-aware commit).
|
|
6306
6346
|
const installedSkills = new Map();
|
|
6347
|
+
// I12 (v0.9 §12) — installed AGENT registry. Map<agentId, {agentId, role, verified, manifest,
|
|
6348
|
+
// envelope}>. Populated at boot from the frozen #rwa-agents zone (readTrustworthyAgents). Identity
|
|
6349
|
+
// is the author key; activeAgentRole is the in-tab active role (null = none; not persisted — boot
|
|
6350
|
+
// starts with no agent active per §12).
|
|
6351
|
+
const installedAgents = new Map();
|
|
6352
|
+
let activeAgentRole = null;
|
|
6353
|
+
// I8 (v0.9 §9) — hook firing state. activeHooks holds skillIds currently executing (re-entrancy
|
|
6354
|
+
// guard, Inv 23); _lastHookMode tracks the prior mode for on-mode-change's `previous`; _hookSeq
|
|
6355
|
+
// makes rwa_hook_log keys unique within a millisecond.
|
|
6356
|
+
const activeHooks = new Set();
|
|
6357
|
+
let _lastHookMode = null, _hookSeq = 0;
|
|
6307
6358
|
|
|
6308
6359
|
const RWA_VIEW_RESERVED_IDS = ['rwa-doc-mount', 'rwa-lens', 'rwa-runtime'];
|
|
6309
6360
|
const RWA_VIEW_RESERVED_MARKERS = ['rwa:frozen:begin', 'rwa:frozen:end', 'data-rwa-frozen'];
|
|
@@ -6475,6 +6526,62 @@ async function _skVerify(env) {
|
|
|
6475
6526
|
return !!(await crypto.subtle.verify({ name: 'Ed25519' }, key, _skFromB64(sig), await _skSigningMessage(skill, skill.code)));
|
|
6476
6527
|
} catch (_) { return false; } // Ed25519 unsupported / bad bytes → verified:false (honest)
|
|
6477
6528
|
}
|
|
6529
|
+
// ── I12 (v0.9 §12) — rwa-agent/1 canon (LIVE mirror of cli/src/skill-manifest.mjs canonicalAgent/
|
|
6530
|
+
// agentSigningMessage/agentId/verifyAgentEnvelope/validateAgentInstall). An agent is a role-scoped,
|
|
6531
|
+
// signed identity (role + system_prompt + vault_namespace_set; NO code). Same canon as the CLI so a
|
|
6532
|
+
// seed-live verify == the CLI-static verify for the same bytes.
|
|
6533
|
+
const _AG_ROLE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
6534
|
+
function _agCanonicalAgent(a) { a = a || {}; return JSON.stringify({ author_pubkey: a.author_pubkey ?? null, description: a.description ?? null, role: a.role ?? null, system_prompt: a.system_prompt ?? null, vault_namespace_set: Array.isArray(a.vault_namespace_set) ? a.vault_namespace_set : [], version: a.version ?? null }); }
|
|
6535
|
+
async function _agSigningMessage(a) { return _skSha256(_skUtf8(_agCanonicalAgent(a))); }
|
|
6536
|
+
async function _agAgentId(role, pubkey) { return _skB64url(await _skSha256(_skConcat(_skUtf8(String(role)), _skNUL, _skUtf8(String(pubkey))))); }
|
|
6537
|
+
async function _agVerify(env) {
|
|
6538
|
+
const sig = env && env.signature;
|
|
6539
|
+
if (!sig) return false;
|
|
6540
|
+
const agent = env.agent || {};
|
|
6541
|
+
try {
|
|
6542
|
+
const key = await crypto.subtle.importKey('raw', _skFromB64(agent.author_pubkey), { name: 'Ed25519' }, false, ['verify']);
|
|
6543
|
+
return !!(await crypto.subtle.verify({ name: 'Ed25519' }, key, _skFromB64(sig), await _agSigningMessage(agent)));
|
|
6544
|
+
} catch (_) { return false; }
|
|
6545
|
+
}
|
|
6546
|
+
function _agPromptInjectionRisk(s) { const p = String(s ?? ''); return p.includes('`') || p.includes('${') || /<\/?DOC>/i.test(p); }
|
|
6547
|
+
function _agValidateInstall(agent, signed) {
|
|
6548
|
+
agent = agent || {}; const errors = [];
|
|
6549
|
+
if (/\0/.test(String(agent.role == null ? '' : agent.role))) errors.push('invalid_agent_id');
|
|
6550
|
+
if (typeof agent.role !== 'string' || !_AG_ROLE_RE.test(agent.role)) errors.push('invalid_role');
|
|
6551
|
+
if (typeof agent.system_prompt !== 'string' || _agPromptInjectionRisk(agent.system_prompt)) errors.push('agent_prompt_injection_risk');
|
|
6552
|
+
const set = agent.vault_namespace_set;
|
|
6553
|
+
if (set != null && !Array.isArray(set)) errors.push('invalid_permission');
|
|
6554
|
+
for (const p of (Array.isArray(set) ? set : [])) {
|
|
6555
|
+
try { if (_skParsePermission(p).tier !== 'vault') errors.push('invalid_permission'); }
|
|
6556
|
+
catch (e) { errors.push(/unknown_permission_tier/.test(e.message) ? 'unknown_permission_tier' : 'invalid_permission'); }
|
|
6557
|
+
}
|
|
6558
|
+
if (!signed) errors.push('unsigned_agent');
|
|
6559
|
+
return { ok: errors.length === 0, errors };
|
|
6560
|
+
}
|
|
6561
|
+
// §12 inter-agent bus message shape (mirror of cli validateAgentMessage/agentMessage). Data-model
|
|
6562
|
+
// only; the request→response choreography is the conductor's job, correlated by id.
|
|
6563
|
+
function _agValidateMessage(m) {
|
|
6564
|
+
m = m || {}; const errors = [];
|
|
6565
|
+
if (m.type !== 'request' && m.type !== 'response') errors.push('invalid_type');
|
|
6566
|
+
if (typeof m.id !== 'string' || !m.id) errors.push('invalid_id');
|
|
6567
|
+
if (typeof m.from_role !== 'string' || !_AG_ROLE_RE.test(m.from_role)) errors.push('invalid_from_role');
|
|
6568
|
+
if (typeof m.to_role !== 'string' || !_AG_ROLE_RE.test(m.to_role)) errors.push('invalid_to_role');
|
|
6569
|
+
if (!('payload' in m)) errors.push('missing_payload');
|
|
6570
|
+
return { ok: errors.length === 0, errors };
|
|
6571
|
+
}
|
|
6572
|
+
function _agBuildMessage(type, fromRole, toRole, payload, id) {
|
|
6573
|
+
const m = { type, id, from_role: fromRole, to_role: toRole, payload };
|
|
6574
|
+
const v = _agValidateMessage(m);
|
|
6575
|
+
if (!v.ok) throw new RwaEditError('invalid_agent_message', null, { errors: v.errors });
|
|
6576
|
+
return m;
|
|
6577
|
+
}
|
|
6578
|
+
// runtime.agents.message — convenience builder from the ACTIVE role. A request mints a fresh
|
|
6579
|
+
// correlation id; a response echoes the request's id (pass it as `id`). The caller publishes it
|
|
6580
|
+
// over the bus (a conductor skill with bus:agents:* permission), per §12.
|
|
6581
|
+
function runtimeAgentMessage(type, toRole, payload, id) {
|
|
6582
|
+
if (!activeAgentRole) throw new RwaEditError('no_active_agent', null, {});
|
|
6583
|
+
return _agBuildMessage(type, activeAgentRole, toRole, payload, id || (type === 'request' ? crypto.randomUUID() : undefined));
|
|
6584
|
+
}
|
|
6478
6585
|
// Locate the inner HTML of the agent-unreachable <div data-rwa-frozen id="rwa-skills">
|
|
6479
6586
|
// in the DOC TEXT (boot has the doc string before render). Only the frozen zone is
|
|
6480
6587
|
// trusted; base64 envelopes contain no </div> so a flat scan is safe.
|
|
@@ -6510,6 +6617,87 @@ async function readTrustworthySkills(doc) {
|
|
|
6510
6617
|
}
|
|
6511
6618
|
return out;
|
|
6512
6619
|
}
|
|
6620
|
+
// I12 (v0.9 §12) — the frozen #rwa-agents zone (coexists with #rwa-skills). Same strict
|
|
6621
|
+
// frozen-attribute read as _skExtractZone so an agent-committed lookalike div can't forge an agent.
|
|
6622
|
+
function _agExtractZone(doc) {
|
|
6623
|
+
const open = /<div\b[^>]*\bid="rwa-agents"[^>]*>/i.exec(String(doc || ''));
|
|
6624
|
+
if (!open || !tagHasFrozenAttr(open[0])) return null;
|
|
6625
|
+
const start = open.index + open[0].length;
|
|
6626
|
+
const end = doc.indexOf('</div>', start);
|
|
6627
|
+
return end < 0 ? null : doc.slice(start, end);
|
|
6628
|
+
}
|
|
6629
|
+
// Parse installed agents from the frozen zone, re-verifying each signature. Soft-fail. Returns a
|
|
6630
|
+
// Map<agentId, {agentId, role, verified, manifest, envelope}>. Mirrors readTrustworthySkills.
|
|
6631
|
+
async function readTrustworthyAgents(doc) {
|
|
6632
|
+
const out = new Map();
|
|
6633
|
+
const zone = _agExtractZone(doc);
|
|
6634
|
+
if (!zone) return out;
|
|
6635
|
+
const blocks = zone.matchAll(/<script\s+type="application\/rwa-agent\+json">([\s\S]*?)<\/script>/g);
|
|
6636
|
+
for (const m of blocks) {
|
|
6637
|
+
let env;
|
|
6638
|
+
try { env = JSON.parse(new TextDecoder().decode(_skFromB64(m[1].trim()))); } catch (_) { continue; }
|
|
6639
|
+
const agent = env && env.agent;
|
|
6640
|
+
if (!agent || typeof agent.role !== 'string') continue;
|
|
6641
|
+
const verified = await _agVerify(env);
|
|
6642
|
+
const id = await _agAgentId(agent.role, agent.author_pubkey);
|
|
6643
|
+
out.set(id, { agentId: id, role: agent.role, verified, manifest: agent, envelope: env });
|
|
6644
|
+
}
|
|
6645
|
+
return out;
|
|
6646
|
+
}
|
|
6647
|
+
// I5 (v0.9 §4) — per-author source index (rwa_sources, keyed by public key):
|
|
6648
|
+
// { pubkey, count, first_seen, name_history:[{ name, first_seen_at_name }] }
|
|
6649
|
+
// name_history is APPEND-ONLY and holds one entry per DISTINCT name a key has published, in first-
|
|
6650
|
+
// seen order (Invariant 22). It anchors author identity across renames: the install dialog shows a
|
|
6651
|
+
// same-key name change ("previously published [old]"). DATES are best-effort — the frozen-zone bytes
|
|
6652
|
+
// carry no timestamps (determinism), so a cleared-IDB reload reconciles names from the in-file
|
|
6653
|
+
// manifests with the reconcile time as first_seen_at_name. Identity itself is always the key.
|
|
6654
|
+
async function _skSourceGet(pubkey) {
|
|
6655
|
+
if (!pubkey) return null;
|
|
6656
|
+
try { return (await idbGet(RWA.SOURCES, pubkey)) || null; } catch (_) { return null; }
|
|
6657
|
+
}
|
|
6658
|
+
// Append (name, now) for a key, creating the record if absent. Idempotent per distinct name.
|
|
6659
|
+
async function _skSourceRecord(pubkey, name, at) {
|
|
6660
|
+
if (!pubkey || !name) return;
|
|
6661
|
+
const now = at || Date.now();
|
|
6662
|
+
let rec = await _skSourceGet(pubkey);
|
|
6663
|
+
if (!rec) rec = { pubkey, count: 0, first_seen: now, name_history: [] };
|
|
6664
|
+
if (!Array.isArray(rec.name_history)) rec.name_history = [];
|
|
6665
|
+
if (!rec.name_history.some(e => e.name === name)) rec.name_history.push({ name, first_seen_at_name: now });
|
|
6666
|
+
rec.count = (rec.count | 0) + 1;
|
|
6667
|
+
try { await idbPut(RWA.SOURCES, rec, pubkey); } catch (_) { /* best-effort; never blocks an install */ }
|
|
6668
|
+
}
|
|
6669
|
+
// Boot reconcile: ensure every (pubkey, name) present in the live registry has a name_history entry.
|
|
6670
|
+
// Rebuilds from the in-file manifests so an IDB-cleared reload still knows the recorded renames.
|
|
6671
|
+
async function runtimeBuildSourceIndex() {
|
|
6672
|
+
const now = Date.now(), byKey = new Map();
|
|
6673
|
+
for (const s of installedSkills.values()) {
|
|
6674
|
+
const pk = s.manifest && s.manifest.author_pubkey;
|
|
6675
|
+
if (!pk || !s.name) continue;
|
|
6676
|
+
if (!byKey.has(pk)) byKey.set(pk, new Set());
|
|
6677
|
+
byKey.get(pk).add(s.name);
|
|
6678
|
+
}
|
|
6679
|
+
for (const [pk, names] of byKey) {
|
|
6680
|
+
let rec = await _skSourceGet(pk);
|
|
6681
|
+
if (!rec) rec = { pubkey: pk, count: 0, first_seen: now, name_history: [] };
|
|
6682
|
+
if (!Array.isArray(rec.name_history)) rec.name_history = [];
|
|
6683
|
+
const known = new Set(rec.name_history.map(e => e.name));
|
|
6684
|
+
let changed = false;
|
|
6685
|
+
for (const nm of names) if (!known.has(nm)) { rec.name_history.push({ name: nm, first_seen_at_name: now }); changed = true; }
|
|
6686
|
+
if ((rec.count | 0) < names.size) { rec.count = names.size; changed = true; }
|
|
6687
|
+
if (changed) { try { await idbPut(RWA.SOURCES, rec, pk); } catch (_) {} }
|
|
6688
|
+
}
|
|
6689
|
+
}
|
|
6690
|
+
// What prior names has this author (key) published, other than the incoming one? Returns the prior
|
|
6691
|
+
// names (newest-first) + the most-recent prior, for the dialog's rename note. A rename surfaces only
|
|
6692
|
+
// when the incoming name is genuinely NEW for the key (so a same-name re-install / I10 update is not
|
|
6693
|
+
// a "rename").
|
|
6694
|
+
async function _skNameChange(pubkey, name) {
|
|
6695
|
+
const rec = await _skSourceGet(pubkey);
|
|
6696
|
+
const hist = (rec && Array.isArray(rec.name_history)) ? rec.name_history : [];
|
|
6697
|
+
const priorNames = hist.filter(e => e.name !== name).map(e => ({ name: e.name, date: e.first_seen_at_name })).reverse();
|
|
6698
|
+
const isRename = priorNames.length > 0 && !hist.some(e => e.name === name);
|
|
6699
|
+
return { priorNames, nameChange: isRename ? { prev: priorNames[0] } : null };
|
|
6700
|
+
}
|
|
6513
6701
|
function runtimeListSkills() {
|
|
6514
6702
|
return Array.from(installedSkills.values()).map(s => ({ skillId: s.skillId, kind: s.kind, name: s.name, verified: s.verified }));
|
|
6515
6703
|
}
|
|
@@ -6583,6 +6771,25 @@ function _skVaultAllowed(skill, ns) {
|
|
|
6583
6771
|
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
6584
6772
|
return perms.indexOf('vault:' + ns) !== -1;
|
|
6585
6773
|
}
|
|
6774
|
+
// §5 (I1) — the bridge's per-call bus gate: exact-string bus:<topic> match, no wildcards.
|
|
6775
|
+
function _skBusAllowed(skill, topic) {
|
|
6776
|
+
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
6777
|
+
return perms.indexOf('bus:' + topic) !== -1;
|
|
6778
|
+
}
|
|
6779
|
+
// §6 (I3) — the bridge's per-call fs gate: the (already traversal-checked) path must fall under
|
|
6780
|
+
// a declared fsa:<scope> subtree (left-anchored prefix, no wildcards).
|
|
6781
|
+
function _skFsAllowed(skill, path) {
|
|
6782
|
+
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
6783
|
+
for (const p of perms) {
|
|
6784
|
+
if (String(p).startsWith('fsa:')) { const scope = String(p).slice(4); if (path === scope || path.startsWith(scope + '/')) return true; }
|
|
6785
|
+
}
|
|
6786
|
+
return false;
|
|
6787
|
+
}
|
|
6788
|
+
// §7 (I4) — the bridge's per-call idb gate: exact-string idb:<store> match, no wildcards.
|
|
6789
|
+
function _skIdbAllowed(skill, store) {
|
|
6790
|
+
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
6791
|
+
return perms.indexOf('idb:' + store) !== -1;
|
|
6792
|
+
}
|
|
6586
6793
|
|
|
6587
6794
|
// ─── Install dialog (shannon, v0.8 §1) — the trust anchor. Mirrors of the cli
|
|
6588
6795
|
// consent helpers (permissionToProse/compoundRisk/capabilityScan/levenshtein). ──────
|
|
@@ -6601,12 +6808,32 @@ function _skPermProse(perm) {
|
|
|
6601
6808
|
if (v === '*') return 'Read and write credentials stored under ANY vault namespace — every credential you have stored. Use only for vault administration.';
|
|
6602
6809
|
return 'Read and write credentials stored under ' + v + '.';
|
|
6603
6810
|
}
|
|
6811
|
+
if (s.startsWith('bus:')) {
|
|
6812
|
+
return 'Send and receive messages on the "' + s.slice(4) + '" channel shared with other rewritables on this machine.';
|
|
6813
|
+
}
|
|
6814
|
+
if (s.startsWith('fsa:')) {
|
|
6815
|
+
return 'Read and write files under "' + s.slice(4) + '" in this document\'s private storage.';
|
|
6816
|
+
}
|
|
6817
|
+
if (s.startsWith('idb:')) {
|
|
6818
|
+
return 'Read and write the "' + s.slice(4) + '" data store in this document\'s database.';
|
|
6819
|
+
}
|
|
6820
|
+
if (s.startsWith('hook:')) {
|
|
6821
|
+
const ev = s.slice(5);
|
|
6822
|
+
const when = ev === 'on-commit' ? 'every time the document is saved' : ev === 'on-open' ? 'every time the document opens' : ev === 'on-mode-change' ? 'every time you switch modes' : ev;
|
|
6823
|
+
return 'Run automatically ' + when + ' (no network or credential access).';
|
|
6824
|
+
}
|
|
6604
6825
|
return s;
|
|
6605
6826
|
}
|
|
6606
6827
|
function _skCompoundRisk(perms) {
|
|
6607
6828
|
perms = Array.isArray(perms) ? perms : [];
|
|
6608
|
-
|
|
6829
|
+
const has = (t) => perms.some(p => String(p).startsWith(t + ':'));
|
|
6830
|
+
const hasVault = has('vault'), hasNetwork = has('network'), hasBus = has('bus'), hasFsa = has('fsa'), hasIdb = has('idb');
|
|
6831
|
+
if (hasVault && hasNetwork)
|
|
6609
6832
|
return 'This skill can both read your stored credentials AND make network requests. A skill with this combination can send credentials to its allowed destination — intentionally or by mistake. Install only if you fully trust this author.';
|
|
6833
|
+
if (hasBus && (hasVault || hasNetwork))
|
|
6834
|
+
return 'This skill can message other rewritables on this machine AND ' + (hasVault ? 'read your stored credentials' : 'make network requests') + '. Together these let it coordinate a multi-step action across your workspace — intentionally or by mistake. Install only if you fully trust this author.';
|
|
6835
|
+
if ((hasFsa || hasIdb) && (hasNetwork || hasVault || hasBus))
|
|
6836
|
+
return 'This skill can ' + (hasFsa ? 'read and write files in this document' : 'read and write this document\'s stored data') + ' AND ' + (hasNetwork ? 'make network requests' : hasVault ? 'read your stored credentials' : 'message other rewritables on this machine') + '. Together these let it move your local data off this document — intentionally or by mistake. Install only if you fully trust this author.';
|
|
6610
6837
|
return null;
|
|
6611
6838
|
}
|
|
6612
6839
|
function _skCapabilityScan(code) {
|
|
@@ -6635,6 +6862,26 @@ function _skLevenshtein(a, b) {
|
|
|
6635
6862
|
for (let i = 1; i <= m; i++) { const cur = [i]; for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = cur; }
|
|
6636
6863
|
return prev[n];
|
|
6637
6864
|
}
|
|
6865
|
+
// I5 (v0.9 §4) — Unicode-confusable skeleton. NFKC + toLowerCase fold case, fullwidth forms,
|
|
6866
|
+
// ligatures, and mathematical-alphanumeric letters to ASCII; this baked table folds the
|
|
6867
|
+
// CROSS-SCRIPT homoglyphs NFKC leaves alone (Cyrillic, Greek, Armenian, a few Latin-extended).
|
|
6868
|
+
// CURATED, not the full UTS #39 confusables.txt: every entry maps a non-ASCII glyph that renders
|
|
6869
|
+
// ~identically to an ASCII letter. ASCII→ASCII is NEVER folded (legit "tool"/"toml" stay distinct).
|
|
6870
|
+
// Byte-mirror of cli/src/skill-manifest.mjs CONFUSABLES. Keys are post-NFKC-lowercase codepoints.
|
|
6871
|
+
const _SK_CONFUSABLES = {
|
|
6872
|
+
'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c',
|
|
6873
|
+
'у': 'y', 'х': 'x', 'к': 'k', 'ѕ': 's', 'і': 'i',
|
|
6874
|
+
'ј': 'j', 'ԁ': 'd', 'һ': 'h', 'ԛ': 'q', 'ԝ': 'w',
|
|
6875
|
+
'ѵ': 'v', 'ӏ': 'l', 'ɠ': 'g',
|
|
6876
|
+
'α': 'a', 'ο': 'o', 'ρ': 'p', 'ε': 'e', 'ι': 'i',
|
|
6877
|
+
'κ': 'k', 'ν': 'v', 'υ': 'u', 'χ': 'x', 'τ': 't',
|
|
6878
|
+
'ϲ': 'c', 'ϳ': 'j',
|
|
6879
|
+
'օ': 'o', 'ո': 'n',
|
|
6880
|
+
'ı': 'i', 'ɑ': 'a', 'ɡ': 'g',
|
|
6881
|
+
};
|
|
6882
|
+
function _skNormalize(s) { return String(s == null ? '' : s).normalize('NFKC').toLowerCase(); }
|
|
6883
|
+
function _skSkeleton(s) { let out = ''; for (const ch of _skNormalize(s)) out += (_SK_CONFUSABLES[ch] || ch); return out; }
|
|
6884
|
+
function _skSkeletonDistance(a, b) { return _skLevenshtein(_skSkeleton(a), _skSkeleton(b)); }
|
|
6638
6885
|
// §3.4 install gates (mirror cli validateInstall).
|
|
6639
6886
|
const _SK_VAULT_NS = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
|
|
6640
6887
|
// §4 permission grammar (mirror cli parsePermission) — validates the VALUE, not
|
|
@@ -6658,6 +6905,32 @@ function _skParsePermission(p) {
|
|
|
6658
6905
|
if (value.length > 64 || !_SK_VAULT_NS.test(value)) throw new Error(`invalid vault namespace: ${value}`);
|
|
6659
6906
|
return { tier, value };
|
|
6660
6907
|
}
|
|
6908
|
+
if (tier === 'bus') {
|
|
6909
|
+
// §5 (I1): topic 1–96 chars, start alphanumeric, charset [A-Za-z0-9:_./%-], NOT a
|
|
6910
|
+
// runtime-reserved prefix (rwa_/rwa:/skills:/workspace:). Mirror of cli parsePermission.
|
|
6911
|
+
if (!value || value.length > 96 || !/^[A-Za-z0-9][A-Za-z0-9:_./%-]*$/.test(value) || /^(?:rwa[:_]|skills:|workspace:)/.test(value))
|
|
6912
|
+
throw new Error(`invalid bus topic: ${value}`);
|
|
6913
|
+
return { tier, value };
|
|
6914
|
+
}
|
|
6915
|
+
if (tier === 'fsa') {
|
|
6916
|
+
// §6 (I3): relative OPFS scope — lowercase [a-z0-9_/-], start+end alphanumeric/underscore,
|
|
6917
|
+
// ≤128 chars, no '.'/'..' (excluded by charset), not _rwa/-prefixed. Mirror of cli.
|
|
6918
|
+
if (!value || value.length > 128 || /^_rwa(?:\/|$)/.test(value) || !/^[a-z0-9_](?:[a-z0-9_/-]*[a-z0-9_])?$/.test(value))
|
|
6919
|
+
throw new Error(`invalid fsa scope: ${value}`);
|
|
6920
|
+
return { tier, value };
|
|
6921
|
+
}
|
|
6922
|
+
if (tier === 'idb') {
|
|
6923
|
+
// §7 (I4): store ^[A-Za-z0-9_][A-Za-z0-9_-]{0,62}$ (≤64 octets, no wildcards); never rwa_*
|
|
6924
|
+
// (reserved) or rwa_vault — distinct subcodes so the dialog can explain. Mirror of cli.
|
|
6925
|
+
if (/^rwa_/.test(value)) throw new Error(value === 'rwa_vault' ? 'idb_vault_store_forbidden' : 'idb_reserved_store');
|
|
6926
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_-]{0,62}$/.test(value)) throw new Error(`invalid idb store: ${value}`);
|
|
6927
|
+
return { tier, value };
|
|
6928
|
+
}
|
|
6929
|
+
if (tier === 'hook') {
|
|
6930
|
+
// §9 (I8): lifecycle event, exact enum, no wildcards. Unknown event → unknown_permission_tier.
|
|
6931
|
+
if (value === 'on-commit' || value === 'on-open' || value === 'on-mode-change') return { tier, value };
|
|
6932
|
+
throw new Error(`unknown_permission_tier: hook event ${value}`);
|
|
6933
|
+
}
|
|
6661
6934
|
throw new Error(`unknown_permission_tier: ${tier}`);
|
|
6662
6935
|
}
|
|
6663
6936
|
function _skValidateInstall(skill, vr) {
|
|
@@ -6672,11 +6945,19 @@ function _skValidateInstall(skill, vr) {
|
|
|
6672
6945
|
// F7: validate each permission's VALUE via the grammar, not just the tier.
|
|
6673
6946
|
for (const p of perms) {
|
|
6674
6947
|
try { _skParsePermission(p); }
|
|
6675
|
-
catch (e) {
|
|
6948
|
+
catch (e) {
|
|
6949
|
+
const m = e.message;
|
|
6950
|
+
if (/unknown_permission_tier/.test(m)) errors.push('unknown_permission_tier');
|
|
6951
|
+
else if (m === 'idb_reserved_store' || m === 'idb_vault_store_forbidden') errors.push(m); // §7 distinct subcodes
|
|
6952
|
+
else errors.push('invalid_permission');
|
|
6953
|
+
}
|
|
6676
6954
|
}
|
|
6677
6955
|
if (skill.kind === 'compute' && perms.length > 0) errors.push('compute_with_permissions');
|
|
6956
|
+
// §9 (I8): a hook is compute-only — only hook:<event> perms; any other tier → compute_with_permissions.
|
|
6957
|
+
if (skill.kind === 'hook' && perms.some(p => { try { return _skParsePermission(p).tier !== 'hook'; } catch (_) { return false; } })) errors.push('compute_with_permissions');
|
|
6678
6958
|
if (!vr.signed && perms.length > 0) errors.push('unsigned_with_permissions');
|
|
6679
|
-
|
|
6959
|
+
// Tools AND hooks carry capability (a hook runs autonomously) → must be signed+verified.
|
|
6960
|
+
if ((skill.kind === 'tool' || skill.kind === 'hook') && !vr.verified) errors.push('unsigned_capability');
|
|
6680
6961
|
return { ok: errors.length === 0, errors };
|
|
6681
6962
|
}
|
|
6682
6963
|
// §1 — the structured trust info the dialog renders.
|
|
@@ -6684,22 +6965,54 @@ async function runtimeReviewSkill(envelope) {
|
|
|
6684
6965
|
const skill = (envelope && envelope.skill) || {};
|
|
6685
6966
|
const perms = Array.isArray(skill.permissions) ? skill.permissions : [];
|
|
6686
6967
|
const vr = { signed: !!(envelope && envelope.signature), verified: await _skVerify(envelope) };
|
|
6687
|
-
let
|
|
6968
|
+
let levMatch = null, skelMatch = null;
|
|
6688
6969
|
for (const s of installedSkills.values()) {
|
|
6970
|
+
if (!s.manifest || s.manifest.author_pubkey === skill.author_pubkey) continue; // same author ≠ impersonation
|
|
6689
6971
|
const d = _skLevenshtein(s.name, skill.name);
|
|
6690
6972
|
// A DIFFERENT key with the same/near name is impersonation. Exact-name (d===0) ALWAYS fires —
|
|
6691
6973
|
// it's the strongest spoof and the §3.3 case lookalike is responsible for; a near miss (1–2 edits)
|
|
6692
6974
|
// fires only when both names are long enough that 1–2 edits is "similar", not "unrelated".
|
|
6693
6975
|
const exact = d === 0, near = d >= 1 && d <= 2 && String(skill.name).length >= 4 && String(s.name).length >= 4;
|
|
6694
|
-
if (
|
|
6976
|
+
if (!levMatch && (exact || near)) levMatch = s.name;
|
|
6977
|
+
// I5 — Unicode-confusable (skeleton) match. Fires only when confusable folding COLLAPSED a real
|
|
6978
|
+
// byte difference (sd < normalized-Levenshtein) — i.e. a cross-script homoglyph that renders
|
|
6979
|
+
// identically — NOT on an honest ASCII near-miss (where sd === ld). That distinction is what lets
|
|
6980
|
+
// an exact ASCII name from a different key WARN (Invariant 10) while a homoglyph BLOCKS.
|
|
6981
|
+
if (!skelMatch) {
|
|
6982
|
+
const sd = _skSkeletonDistance(s.name, skill.name);
|
|
6983
|
+
const ld = _skLevenshtein(_skNormalize(s.name), _skNormalize(skill.name));
|
|
6984
|
+
if (sd <= 1 && sd < ld) skelMatch = s.name;
|
|
6985
|
+
}
|
|
6695
6986
|
}
|
|
6987
|
+
// skeleton (homoglyph) takes precedence over a plain Levenshtein warn; a signed skeleton match is
|
|
6988
|
+
// a hard BLOCK (the skill carries capability to escalate), an unsigned one only warns.
|
|
6989
|
+
const lookalike = skelMatch || levMatch;
|
|
6990
|
+
const lookalikeKind = skelMatch ? 'skeleton' : (levMatch ? 'levenshtein' : null);
|
|
6991
|
+
const lookalikeBlock = !!(skelMatch && vr.signed);
|
|
6992
|
+
// I10 (Shape C) — the update delta. If this skillId is already installed, surface the
|
|
6993
|
+
// permission change (added/removed, order-invariant case-sensitive set ops) so the dialog
|
|
6994
|
+
// can show it and require explicit re-affirmation — no silent permission escalation on update.
|
|
6995
|
+
let update = { isUpdate: false, added: [], removed: [], changed: false };
|
|
6996
|
+
try {
|
|
6997
|
+
const prevS = installedSkills.get(await _skSkillId(skill.name, skill.author_pubkey));
|
|
6998
|
+
if (prevS) {
|
|
6999
|
+
const oldPerms = (prevS.manifest && Array.isArray(prevS.manifest.permissions)) ? prevS.manifest.permissions : [];
|
|
7000
|
+
const oldSet = new Set(oldPerms), newSet = new Set(perms);
|
|
7001
|
+
const added = perms.filter(p => !oldSet.has(p)), removed = oldPerms.filter(p => !newSet.has(p));
|
|
7002
|
+
update = { isUpdate: true, added: added.map(p => ({ perm: p, prose: _skPermProse(p) })), removed: removed.map(p => ({ perm: p, prose: _skPermProse(p) })), changed: added.length > 0 || removed.length > 0 };
|
|
7003
|
+
}
|
|
7004
|
+
} catch (_) { /* a malformed name/id can't match an install → treat as fresh; gates reject it downstream */ }
|
|
7005
|
+
// I5 — per-author name_history: surface a same-key rename so identity reads across name changes.
|
|
7006
|
+
const nameInfo = await _skNameChange(skill.author_pubkey, skill.name);
|
|
6696
7007
|
return {
|
|
6697
7008
|
name: skill.name, version: skill.version, kind: skill.kind,
|
|
6698
7009
|
purpose: skill.description || '(no description provided)',
|
|
6699
7010
|
author_pubkey: skill.author_pubkey, signed: vr.signed, verified: vr.verified,
|
|
6700
7011
|
permissions: perms.map(p => ({ perm: p, prose: _skPermProse(p) })),
|
|
6701
7012
|
compoundRisk: _skCompoundRisk(perms), scanNotes: _skCapabilityScan(skill.code),
|
|
6702
|
-
lookalike,
|
|
7013
|
+
lookalike, lookalikeKind, lookalikeBlock,
|
|
7014
|
+
priorNames: nameInfo.priorNames, nameChange: nameInfo.nameChange,
|
|
7015
|
+
gates: _skValidateInstall(skill, vr), update,
|
|
6703
7016
|
};
|
|
6704
7017
|
}
|
|
6705
7018
|
// §7 — serialize the registry into the frozen #rwa-skills zone. CANONICAL: sorted by
|
|
@@ -6739,6 +7052,10 @@ function _skSkillsRegion() {
|
|
|
6739
7052
|
async function runtimeInstallSkill(envelope) {
|
|
6740
7053
|
const review = await runtimeReviewSkill(envelope);
|
|
6741
7054
|
if (!review.gates.ok) return { ok: false, errors: review.gates.errors };
|
|
7055
|
+
// I5 — a signed skill whose name skeleton-folds to a DIFFERENT author's installed skill is a
|
|
7056
|
+
// homoglyph impersonation: refuse before any code is registered (Invariant 21). Unsigned skills
|
|
7057
|
+
// can't escalate, so review.lookalikeBlock is false for them — they only warn in the dialog.
|
|
7058
|
+
if (review.lookalikeBlock) return { ok: false, errors: ['lookalike_skeleton_blocked'] };
|
|
6742
7059
|
const forbidden = _skCodeForbidden((envelope.skill || {}).code);
|
|
6743
7060
|
if (forbidden) return { ok: false, errors: [forbidden] };
|
|
6744
7061
|
const skill = envelope.skill, id = await _skSkillId(skill.name, skill.author_pubkey);
|
|
@@ -6750,6 +7067,9 @@ async function runtimeInstallSkill(envelope) {
|
|
|
6750
7067
|
if (prev) installedSkills.set(id, prev); else installedSkills.delete(id);
|
|
6751
7068
|
return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
|
|
6752
7069
|
}
|
|
7070
|
+
// I5 — record this (key, name) in the per-author name_history (best-effort; never fails the
|
|
7071
|
+
// install). On a same-key rename the new name is appended; identity stays anchored on the key.
|
|
7072
|
+
await _skSourceRecord(skill.author_pubkey, skill.name);
|
|
6753
7073
|
return { ok: true, skillId: id };
|
|
6754
7074
|
}
|
|
6755
7075
|
// §7 — remove a skill + persist the emptied/updated zone (same rollback discipline).
|
|
@@ -6765,6 +7085,126 @@ async function runtimeUninstallSkill(skillId) {
|
|
|
6765
7085
|
}
|
|
6766
7086
|
return { ok: true };
|
|
6767
7087
|
}
|
|
7088
|
+
// ── I12 (v0.9 §12) — agent registry ops. The #rwa-agents zone is built/committed exactly like
|
|
7089
|
+
// #rwa-skills (skillId-sorted base64 envelopes, runtimeRegionCommit reachability:'frozen'); the two
|
|
7090
|
+
// zones coexist and the region primitive commits each atomically. insertAt is the no-zone fallback
|
|
7091
|
+
// (a fresh skill-host has no agents zone yet → the first install appends one).
|
|
7092
|
+
function buildAgentZone(agents) {
|
|
7093
|
+
const blocks = Array.from(agents.values())
|
|
7094
|
+
.filter(a => a.envelope)
|
|
7095
|
+
.sort((a, b) => (a.agentId < b.agentId ? -1 : a.agentId > b.agentId ? 1 : 0))
|
|
7096
|
+
.map(a => '<script type="application/rwa-agent+json">' + _skB64(_skUtf8(JSON.stringify(a.envelope))) + '<\/script>')
|
|
7097
|
+
.join('');
|
|
7098
|
+
return '<div data-rwa-frozen id="rwa-agents">' + blocks + '</div>';
|
|
7099
|
+
}
|
|
7100
|
+
function _agAgentsRegion() {
|
|
7101
|
+
return {
|
|
7102
|
+
frozenId: 'rwa-agents',
|
|
7103
|
+
select(doc) {
|
|
7104
|
+
const open = /<div\b[^>]*\bid="rwa-agents"[^>]*>/i.exec(doc);
|
|
7105
|
+
if (!open) return null;
|
|
7106
|
+
const close = doc.indexOf('</div>', open.index + open[0].length);
|
|
7107
|
+
return close < 0 ? null : [open.index, close + 6];
|
|
7108
|
+
},
|
|
7109
|
+
insertAt(doc) { return doc.length; },
|
|
7110
|
+
build() { return buildAgentZone(installedAgents); },
|
|
7111
|
+
};
|
|
7112
|
+
}
|
|
7113
|
+
function runtimeListAgents() {
|
|
7114
|
+
return Array.from(installedAgents.values()).map(a => ({ role: a.role, author_pubkey: a.manifest && a.manifest.author_pubkey, verified: a.verified }));
|
|
7115
|
+
}
|
|
7116
|
+
function runtimeAgentActive() {
|
|
7117
|
+
if (!activeAgentRole) return null;
|
|
7118
|
+
const rec = Array.from(installedAgents.values()).find(a => a.role === activeAgentRole);
|
|
7119
|
+
return rec ? { role: rec.role, author_pubkey: rec.manifest && rec.manifest.author_pubkey } : null;
|
|
7120
|
+
}
|
|
7121
|
+
// Switch the active role. The trust anchor is the signature: an unverified (tampered) agent can be
|
|
7122
|
+
// installed but NEVER activated (unverified_agent), so a tampered prompt can't drive modify().
|
|
7123
|
+
function runtimeSetActiveAgent(role) {
|
|
7124
|
+
if (role == null) { activeAgentRole = null; return; }
|
|
7125
|
+
const rec = Array.from(installedAgents.values()).find(a => a.role === role);
|
|
7126
|
+
if (!rec) throw new RwaEditError('agent_not_found', null, { role });
|
|
7127
|
+
if (!rec.verified) throw new RwaEditError('unverified_agent', null, { role });
|
|
7128
|
+
activeAgentRole = role;
|
|
7129
|
+
}
|
|
7130
|
+
// §12 — validate + verify + register an agent, then persist the #rwa-agents zone (same discipline as
|
|
7131
|
+
// runtimeInstallSkill: rollback the in-memory map on a persist failure). A signed-but-unverified
|
|
7132
|
+
// agent registers (verified:false) but can't activate.
|
|
7133
|
+
async function runtimeInstallAgent(envelope) {
|
|
7134
|
+
const agent = (envelope && envelope.agent) || {};
|
|
7135
|
+
const signed = !!(envelope && envelope.signature);
|
|
7136
|
+
const gate = _agValidateInstall(agent, signed);
|
|
7137
|
+
if (!gate.ok) return { ok: false, errors: gate.errors };
|
|
7138
|
+
const verified = await _agVerify(envelope);
|
|
7139
|
+
const id = await _agAgentId(agent.role, agent.author_pubkey);
|
|
7140
|
+
const prev = installedAgents.get(id);
|
|
7141
|
+
installedAgents.set(id, { agentId: id, role: agent.role, verified, manifest: agent, envelope });
|
|
7142
|
+
try {
|
|
7143
|
+
await runtimeRegionCommit({ regions: [_agAgentsRegion()], actor: prev ? 'agent:update' : 'agent:install', reachability: 'frozen' });
|
|
7144
|
+
} catch (e) {
|
|
7145
|
+
if (prev) installedAgents.set(id, prev); else installedAgents.delete(id);
|
|
7146
|
+
return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
|
|
7147
|
+
}
|
|
7148
|
+
return { ok: true, agentId: id, verified };
|
|
7149
|
+
}
|
|
7150
|
+
async function runtimeUninstallAgent(agentId) {
|
|
7151
|
+
const prev = installedAgents.get(agentId);
|
|
7152
|
+
if (!prev) return { ok: false, errors: ['not_installed'] };
|
|
7153
|
+
installedAgents.delete(agentId);
|
|
7154
|
+
if (activeAgentRole && prev.role === activeAgentRole && !Array.from(installedAgents.values()).some(a => a.role === activeAgentRole)) activeAgentRole = null;
|
|
7155
|
+
try {
|
|
7156
|
+
await runtimeRegionCommit({ regions: [_agAgentsRegion()], actor: 'agent:uninstall', reachability: 'frozen' });
|
|
7157
|
+
} catch (e) {
|
|
7158
|
+
installedAgents.set(agentId, prev);
|
|
7159
|
+
return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
|
|
7160
|
+
}
|
|
7161
|
+
return { ok: true };
|
|
7162
|
+
}
|
|
7163
|
+
// ── I8 (v0.9 §9) — hook firing. Installed `hook` skills that declare hook:<event> run (compute-only,
|
|
7164
|
+
// no bridge) when the mapped lifecycle event fires. Fire-and-forget: never blocks/throws into the
|
|
7165
|
+
// emitter (Inv 24); deterministic skillId order; re-entrancy-guarded (Inv 23); every run logged to
|
|
7166
|
+
// rwa_hook_log (Inv 25). A hook must be verified (boot re-check) to fire.
|
|
7167
|
+
function _hooksForEvent(hookEvent) {
|
|
7168
|
+
return Array.from(installedSkills.values())
|
|
7169
|
+
.filter(s => s && s.kind === 'hook' && s.verified && Array.isArray(s.manifest && s.manifest.permissions) && s.manifest.permissions.indexOf('hook:' + hookEvent) !== -1)
|
|
7170
|
+
.sort((a, b) => (a.skillId < b.skillId ? -1 : a.skillId > b.skillId ? 1 : 0));
|
|
7171
|
+
}
|
|
7172
|
+
function _hookLog(entry) {
|
|
7173
|
+
return idbPut(RWA.HOOK_LOG, entry, entry.ts + '-' + (_hookSeq++)).catch(() => {});
|
|
7174
|
+
}
|
|
7175
|
+
function fireHooks(hookEvent, input) {
|
|
7176
|
+
for (const h of _hooksForEvent(hookEvent)) {
|
|
7177
|
+
if (activeHooks.has(h.skillId)) continue; // a hook can't re-fire its own event
|
|
7178
|
+
activeHooks.add(h.skillId);
|
|
7179
|
+
const t0 = Date.now(), sid = h.skillId;
|
|
7180
|
+
let p; try { p = Promise.resolve(runtimeInvokeSkill(sid, input)); } catch (e) { p = Promise.reject(e); }
|
|
7181
|
+
p.then(
|
|
7182
|
+
result => _hookLog({ skillId: sid, event: hookEvent, ts: t0, input, result, duration: Date.now() - t0 }),
|
|
7183
|
+
err => _hookLog({ skillId: sid, event: hookEvent, ts: t0, input, error: String((err && err.message) || err), duration: Date.now() - t0 })
|
|
7184
|
+
).then(() => activeHooks.delete(sid), () => activeHooks.delete(sid));
|
|
7185
|
+
}
|
|
7186
|
+
}
|
|
7187
|
+
// The audit trail. getAll in key (≈ chronological) order; never throws.
|
|
7188
|
+
async function runtimeHookLog() {
|
|
7189
|
+
try {
|
|
7190
|
+
const db = await openDB();
|
|
7191
|
+
return await new Promise((res) => { const tx = db.transaction(RWA.HOOK_LOG); const r = tx.objectStore(RWA.HOOK_LOG).getAll(); r.onsuccess = () => res(r.result || []); tx.onerror = () => res([]); });
|
|
7192
|
+
} catch (_) { return []; }
|
|
7193
|
+
}
|
|
7194
|
+
// Boot prune: drop entries older than 30d, then cap to the 1000 most recent.
|
|
7195
|
+
async function _hookLogPrune() {
|
|
7196
|
+
try {
|
|
7197
|
+
const db = await openDB();
|
|
7198
|
+
const read = (fn) => new Promise((res) => { const tx = db.transaction(RWA.HOOK_LOG); const r = tx.objectStore(RWA.HOOK_LOG)[fn](); r.onsuccess = () => res(r.result || []); tx.onerror = () => res([]); });
|
|
7199
|
+
const keys = await read('getAllKeys'), vals = await read('getAll');
|
|
7200
|
+
const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
|
|
7201
|
+
const pairs = keys.map((k, i) => ({ k, ts: (vals[i] && vals[i].ts) || 0 }));
|
|
7202
|
+
const fresh = pairs.filter(p => p.ts >= cutoff);
|
|
7203
|
+
const drop = pairs.filter(p => p.ts < cutoff).map(p => p.k)
|
|
7204
|
+
.concat(fresh.length > 1000 ? fresh.sort((a, b) => a.ts - b.ts).slice(0, fresh.length - 1000).map(p => p.k) : []);
|
|
7205
|
+
for (const k of drop) await idbDel(RWA.HOOK_LOG, k);
|
|
7206
|
+
} catch (_) {}
|
|
7207
|
+
}
|
|
6768
7208
|
// §1 — the install dialog (trust anchor). Renders the review and resolves with the user's choice.
|
|
6769
7209
|
function showSkillInstallDialog(envelope) {
|
|
6770
7210
|
return runtimeReviewSkill(envelope).then(rv => new Promise(resolve => {
|
|
@@ -6777,11 +7217,45 @@ function showSkillInstallDialog(envelope) {
|
|
|
6777
7217
|
const authorHtml = rv.signed
|
|
6778
7218
|
? (rv.verified ? 'Signed by key <code>' + _skEsc(String(rv.author_pubkey).slice(0, 12)) + '…</code>' : '⚠ Signature does NOT verify — the code may have been modified.')
|
|
6779
7219
|
: '⚠ Unsigned — the runtime can\'t verify the author or that the code is unmodified.';
|
|
7220
|
+
// I10 (Shape C) — on an update whose permissions changed, surface the delta and make the
|
|
7221
|
+
// affirm button explicitly about the NEW permissions. No silent escalation: the install
|
|
7222
|
+
// call still fires only on the click below.
|
|
7223
|
+
const upd = rv.update || { isUpdate: false, changed: false, added: [], removed: [] };
|
|
7224
|
+
const updHtml = (upd.isUpdate && upd.changed)
|
|
7225
|
+
? '<div style="background:#fef3c7;border-radius:8px;padding:10px 12px;margin:.8em 0"><strong>You already installed this skill. This version changes what it can do:</strong>'
|
|
7226
|
+
+ (upd.added.length ? '<p style="margin:.6em 0 .2em;color:#b45309"><strong>+ Newly requested permissions</strong></p><ul style="margin:.2em 0;padding-left:1.2em">' + upd.added.map(p => '<li>' + _skEsc(p.prose) + '</li>').join('') + '</ul>' : '')
|
|
7227
|
+
+ (upd.removed.length ? '<p style="margin:.6em 0 .2em;color:#6b7280"><strong>− No longer requested</strong></p><ul style="margin:.2em 0;padding-left:1.2em;color:#6b7280">' + upd.removed.map(p => '<li>' + _skEsc(p.prose) + '</li>').join('') + '</ul>' : '')
|
|
7228
|
+
+ '</div>'
|
|
7229
|
+
: '';
|
|
7230
|
+
const affirmText = (upd.isUpdate && upd.changed) ? 'I have reviewed the new permissions and want to update this skill'
|
|
7231
|
+
: upd.isUpdate ? 'Update this skill'
|
|
7232
|
+
: 'I have reviewed this skill and want to install it';
|
|
7233
|
+
// I5 — tiered lookalike notice. A signed homoglyph (lookalikeBlock) is a hard impersonation
|
|
7234
|
+
// block (red, install suppressed); an unsigned homoglyph (kind:'skeleton') is a severe warning;
|
|
7235
|
+
// an honest ASCII near-miss (kind:'levenshtein') is the existing amber name-collision warning.
|
|
7236
|
+
const lookalikeHtml = rv.lookalikeBlock
|
|
7237
|
+
? '<p style="background:#fee2e2;border:1px solid #ef4444;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠️ This name uses characters that look identical to <strong>' + _skEsc(rv.lookalike) + '</strong> from a DIFFERENT author. This is a known impersonation pattern, so installation is blocked. Identity is the key, not the name.</p>'
|
|
7238
|
+
: rv.lookalikeKind === 'skeleton'
|
|
7239
|
+
? '<p style="background:#fef3c7;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠️ This name uses characters that look identical to <strong>' + _skEsc(rv.lookalike) + '</strong>, which you installed from a DIFFERENT key. Review the key carefully — identity is the key, not the name.</p>'
|
|
7240
|
+
: rv.lookalike
|
|
7241
|
+
? '<p style="background:#fef3c7;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠ This name closely matches <strong>' + _skEsc(rv.lookalike) + '</strong>, which you installed from a DIFFERENT key. The author is identified by the key, not the name.</p>'
|
|
7242
|
+
: '';
|
|
7243
|
+
// I5 — same-key rename note (informational, non-permission). Suppressed under a homoglyph block
|
|
7244
|
+
// (impersonation supersedes). Anchors identity on the key: "the author is identified by the key".
|
|
7245
|
+
const nc = rv.nameChange;
|
|
7246
|
+
const nameChangeHtml = (nc && nc.prev && !rv.lookalikeBlock)
|
|
7247
|
+
? '<p style="background:#eff6ff;border-radius:8px;padding:8px 10px;margin:.6em 0">ℹ️ This author previously published a skill named <strong>' + _skEsc(nc.prev.name) + '</strong>'
|
|
7248
|
+
+ (typeof nc.prev.date === 'number' ? ' on ' + _skEsc(new Date(nc.prev.date).toLocaleDateString()) : '')
|
|
7249
|
+
+ '. The current name is <strong>' + _skEsc(rv.name) + '</strong>. The author is identified by the key, not the name.</p>'
|
|
7250
|
+
: '';
|
|
7251
|
+
const canInstall = rv.gates.ok && !rv.lookalikeBlock;
|
|
6780
7252
|
card.innerHTML =
|
|
6781
7253
|
'<h2 style="margin:0 0 .2em;font-size:1.25rem">Install ' + _skEsc(rv.name) + '?</h2>' +
|
|
6782
7254
|
'<p style="margin:.2em 0 1em;color:#444"><strong>What it claims to do:</strong> ' + _skEsc(rv.purpose) + '</p>' +
|
|
6783
7255
|
'<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + '</p>' +
|
|
6784
|
-
|
|
7256
|
+
lookalikeHtml +
|
|
7257
|
+
nameChangeHtml +
|
|
7258
|
+
updHtml +
|
|
6785
7259
|
'<p style="margin:1em 0 .2em"><strong>What it can do on your machine</strong></p>' + permsHtml +
|
|
6786
7260
|
(rv.compoundRisk ? '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠ ' + _skEsc(rv.compoundRisk) + '</p>' : '') +
|
|
6787
7261
|
(rv.scanNotes.length ? '<p style="margin:1em 0 .2em"><strong>Notes from the runtime\'s code review</strong></p><ul style="margin:.2em 0;padding-left:1.2em;color:#555">' + rv.scanNotes.map(n => '<li>' + _skEsc(n) + '</li>').join('') + '</ul>' : '') +
|
|
@@ -6789,7 +7263,7 @@ function showSkillInstallDialog(envelope) {
|
|
|
6789
7263
|
(rv.gates.ok ? '' : '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">Cannot install: ' + _skEsc(rv.gates.errors.join(', ')) + '</p>') +
|
|
6790
7264
|
'<div style="display:flex;gap:10px;margin-top:1.2em;justify-content:flex-end">' +
|
|
6791
7265
|
'<button data-act="cancel" style="padding:9px 16px;border:1px solid #ccc;border-radius:10px;background:#fff;cursor:pointer">Cancel</button>' +
|
|
6792
|
-
(
|
|
7266
|
+
(canInstall ? '<button data-act="install" style="padding:9px 16px;border:none;border-radius:10px;background:var(--gray-900,#111);color:#fff;cursor:pointer">' + _skEsc(affirmText) + '</button>' : '') +
|
|
6793
7267
|
'</div>';
|
|
6794
7268
|
overlay.appendChild(card); document.body.appendChild(overlay);
|
|
6795
7269
|
const close = (r) => { overlay.remove(); resolve(r); };
|
|
@@ -6797,10 +7271,49 @@ function showSkillInstallDialog(envelope) {
|
|
|
6797
7271
|
const ib = card.querySelector('[data-act=install]'); if (ib) ib.onclick = async () => close(await runtimeInstallSkill(envelope));
|
|
6798
7272
|
}));
|
|
6799
7273
|
}
|
|
6800
|
-
//
|
|
7274
|
+
// I12 (v0.9 §12) — the AGENT install dialog (SHOULD): role + author key + vault namespaces +
|
|
7275
|
+
// a system-prompt preview (≤200 chars). The install button is offered only for a VERIFIED agent
|
|
7276
|
+
// whose record passes the gates; a tampered (unverified) or gate-failing record shows the reason
|
|
7277
|
+
// and suppresses install. The trust anchor is the key, not the role.
|
|
7278
|
+
function showAgentInstallDialog(envelope) {
|
|
7279
|
+
const agent = (envelope && envelope.agent) || {};
|
|
7280
|
+
const signed = !!(envelope && envelope.signature);
|
|
7281
|
+
return Promise.resolve(_agVerify(envelope)).then(verified => new Promise(resolve => {
|
|
7282
|
+
const gates = _agValidateInstall(agent, signed);
|
|
7283
|
+
const prev = document.getElementById('rwa-agent-install'); if (prev) prev.remove();
|
|
7284
|
+
const overlay = document.createElement('div'); overlay.id = 'rwa-agent-install';
|
|
7285
|
+
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;';
|
|
7286
|
+
const card = document.createElement('div');
|
|
7287
|
+
card.style.cssText = 'background:#fff;max-width:560px;width:92%;max-height:86vh;overflow:auto;border-radius:18px;padding:26px 28px;font:14px/1.5 var(--font-ui,system-ui);box-shadow:0 12px 48px rgba(0,0,0,.28);';
|
|
7288
|
+
const nsSet = Array.isArray(agent.vault_namespace_set) ? agent.vault_namespace_set : [];
|
|
7289
|
+
const nsHtml = nsSet.length ? '<ul style="margin:.4em 0;padding-left:1.2em">' + nsSet.map(n => '<li>' + _skEsc(n) + '</li>').join('') + '</ul>' : '<p style="color:#666">No credential namespaces — this role reads/writes no vault entries.</p>';
|
|
7290
|
+
const promptPreview = String(agent.system_prompt == null ? '' : agent.system_prompt).slice(0, 200);
|
|
7291
|
+
const authorHtml = signed
|
|
7292
|
+
? (verified ? 'Signed by key <code>' + _skEsc(String(agent.author_pubkey).slice(0, 12)) + '…</code>' : '⚠ Signature does NOT verify — this record was modified. It cannot be activated.')
|
|
7293
|
+
: '⚠ Unsigned — agents must be signed; this cannot be installed.';
|
|
7294
|
+
const canInstall = gates.ok && verified;
|
|
7295
|
+
card.innerHTML =
|
|
7296
|
+
'<h2 style="margin:0 0 .2em;font-size:1.25rem">Install agent role “' + _skEsc(agent.role) + '”?</h2>' +
|
|
7297
|
+
'<p style="margin:.2em 0 1em;color:#444"><strong>Purpose.</strong> ' + _skEsc(agent.description || '(no description provided)') + '</p>' +
|
|
7298
|
+
'<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + ' <em>The author is identified by the key, not the role name.</em></p>' +
|
|
7299
|
+
'<p style="margin:1em 0 .2em"><strong>Credentials this role can reach</strong></p>' + nsHtml +
|
|
7300
|
+
'<p style="margin:1em 0 .2em"><strong>Role instructions (preview)</strong></p><pre style="white-space:pre-wrap;background:#f6f6f6;border-radius:8px;padding:8px 10px;margin:.2em 0;color:#333;font:12px/1.5 var(--font-mono,monospace)">' + _skEsc(promptPreview) + (String(agent.system_prompt || '').length > 200 ? '…' : '') + '</pre>' +
|
|
7301
|
+
'<p style="margin:1.2em 0 .4em;color:#444"><strong>An active role drives edits with its own instructions and reaches only its declared credentials.</strong> The runtime shows you what the role <em>can</em> do, not whether it <em>should</em>.</p>' +
|
|
7302
|
+
(gates.ok ? '' : '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">Cannot install: ' + _skEsc(gates.errors.join(', ')) + '</p>') +
|
|
7303
|
+
'<div style="display:flex;gap:10px;margin-top:1.2em;justify-content:flex-end">' +
|
|
7304
|
+
'<button data-act="cancel" style="padding:9px 16px;border:1px solid #ccc;border-radius:10px;background:#fff;cursor:pointer">Cancel</button>' +
|
|
7305
|
+
(canInstall ? '<button data-act="install" style="padding:9px 16px;border:none;border-radius:10px;background:var(--gray-900,#111);color:#fff;cursor:pointer">I have reviewed this role and want to install it</button>' : '') +
|
|
7306
|
+
'</div>';
|
|
7307
|
+
overlay.appendChild(card); document.body.appendChild(overlay);
|
|
7308
|
+
const close = (r) => { overlay.remove(); resolve(r); };
|
|
7309
|
+
card.querySelector('[data-act=cancel]').onclick = () => close({ ok: false, errors: ['cancelled'] });
|
|
7310
|
+
const ib = card.querySelector('[data-act=install]'); if (ib) ib.onclick = async () => close(await runtimeInstallAgent(envelope));
|
|
7311
|
+
}));
|
|
7312
|
+
}
|
|
7313
|
+
// §1.3 / §12 — install trigger: pick a .rwa-skill.json OR .rwa-agent.json, parse, dispatch by format.
|
|
6801
7314
|
function runtimePromptInstall() {
|
|
6802
|
-
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.rwa-skill.json,application/json,.json';
|
|
6803
|
-
inp.onchange = () => { const f = inp.files && inp.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { let env; try { env = JSON.parse(rd.result); } catch (_) { setStatus && setStatus('err', 'invalid
|
|
7315
|
+
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.rwa-skill.json,.rwa-agent.json,application/json,.json';
|
|
7316
|
+
inp.onchange = () => { const f = inp.files && inp.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { let env; try { env = JSON.parse(rd.result); } catch (_) { setStatus && setStatus('err', 'invalid skill/agent JSON'); return; } if (env && env.format === 'rwa-agent/1') showAgentInstallDialog(env); else showSkillInstallDialog(env); }; rd.readAsText(f); };
|
|
6804
7317
|
inp.click();
|
|
6805
7318
|
}
|
|
6806
7319
|
|
|
@@ -6839,6 +7352,9 @@ const SKILL_WORKER_PROLOGUE = `(function(){
|
|
|
6839
7352
|
function _installBridge(){
|
|
6840
7353
|
RUNTIME.fetch=function(url,opts){ return _bridge('bridge:fetch',{ url:String(url), opts:_serializeOpts(opts) }); };
|
|
6841
7354
|
RUNTIME.vault={ get:function(ns,k){ return _bridge('bridge:vault',{op:'get',ns:ns,key:k}); }, set:function(ns,k,v){ return _bridge('bridge:vault',{op:'set',ns:ns,key:k,val:v}); }, has:function(ns,k){ return _bridge('bridge:vault',{op:'has',ns:ns,key:k}); } };
|
|
7355
|
+
RUNTIME.bus={ publish:function(topic,message){ return _bridge('bridge:bus:publish',{ topic:String(topic), message:message }); } };
|
|
7356
|
+
RUNTIME.fs={ read:function(p){ return _bridge('bridge:fs',{op:'read',path:String(p)}); }, write:function(p,d){ return _bridge('bridge:fs',{op:'write',path:String(p),data:d}); }, del:function(p){ return _bridge('bridge:fs',{op:'del',path:String(p)}); }, list:function(p){ return _bridge('bridge:fs',{op:'list',path:String(p)}); } };
|
|
7357
|
+
RUNTIME.db={ get:function(s,k){ return _bridge('bridge:idb',{op:'get',store:String(s),key:k}); }, put:function(s,k,v){ return _bridge('bridge:idb',{op:'put',store:String(s),key:k,value:v}); }, del:function(s,k){ return _bridge('bridge:idb',{op:'del',store:String(s),key:k}); }, all:function(s){ return _bridge('bridge:idb',{op:'all',store:String(s)}); } };
|
|
6842
7358
|
}
|
|
6843
7359
|
self.onmessage=function(e){
|
|
6844
7360
|
var msg=e.data; if(!msg) return;
|
|
@@ -6858,9 +7374,18 @@ const SKILL_WORKER_PROLOGUE = `(function(){
|
|
|
6858
7374
|
// bridged fetch/vault with per-call origin/namespace enforcement on THIS (main) thread.
|
|
6859
7375
|
// Per-invocation identity_tag binds responses; 5s timeout; spawn -> invoke -> terminate.
|
|
6860
7376
|
// (Vault: increment 8. The worker-scoped CSP that walls remote import() is the frozen-<head> 7b meta.)
|
|
6861
|
-
function runtimeInvokeSkill(skillId, input) {
|
|
7377
|
+
function runtimeInvokeSkill(skillId, input, opts) {
|
|
6862
7378
|
const skill = installedSkills.get(skillId);
|
|
6863
7379
|
if (!skill) return Promise.reject(new Error('skill_not_found'));
|
|
7380
|
+
// I12 — run under a role: vault ops gate on the AGENT's vault_namespace_set (the role is the
|
|
7381
|
+
// capability boundary for this invoke). Resolve BEFORE any Worker spawns so a bad role fails fast.
|
|
7382
|
+
let agentRec = null;
|
|
7383
|
+
const agentRole = opts && opts.agentRole;
|
|
7384
|
+
if (agentRole != null) {
|
|
7385
|
+
agentRec = Array.from(installedAgents.values()).find(a => a.role === agentRole) || null;
|
|
7386
|
+
if (!agentRec) return Promise.reject(new Error('agent_not_found'));
|
|
7387
|
+
if (!agentRec.verified) return Promise.reject(new Error('unverified_agent'));
|
|
7388
|
+
}
|
|
6864
7389
|
const isTool = skill.kind === 'tool';
|
|
6865
7390
|
// §3.4/Inv 20 gate at the capability boundary: a `tool` that did not re-verify from the bytes
|
|
6866
7391
|
// (unsigned, or signed-then-tampered → boot re-verify flips verified:false) must NOT reach the
|
|
@@ -6903,7 +7428,10 @@ function runtimeInvokeSkill(skillId, input) {
|
|
|
6903
7428
|
}
|
|
6904
7429
|
if (msg.type === 'bridge:vault') {
|
|
6905
7430
|
const pl = msg.payload || {};
|
|
6906
|
-
|
|
7431
|
+
// I12 — under an active role the AGENT's vault_namespace_set is the gate (role-scoped);
|
|
7432
|
+
// otherwise the skill's own vault: permissions. Either way an out-of-scope ns is denied.
|
|
7433
|
+
const vaultOk = agentRec ? _agVaultAllowed(agentRec.manifest, pl.ns) : _skVaultAllowed(skill, pl.ns);
|
|
7434
|
+
if (!vaultOk) { reply(msg.id, false, { error: 'vault_namespace_denied' }); return; } // §6 per-call gate
|
|
6907
7435
|
try {
|
|
6908
7436
|
if (pl.op === 'get') reply(msg.id, true, { result: await runtimeVaultGet(pl.ns, pl.key) });
|
|
6909
7437
|
else if (pl.op === 'has') reply(msg.id, true, { result: await runtimeVaultHas(pl.ns, pl.key) });
|
|
@@ -6912,6 +7440,55 @@ function runtimeInvokeSkill(skillId, input) {
|
|
|
6912
7440
|
} catch (e) { reply(msg.id, false, { error: String(e && e.message || 'vault_error') }); }
|
|
6913
7441
|
return;
|
|
6914
7442
|
}
|
|
7443
|
+
if (msg.type === 'bridge:bus:publish') {
|
|
7444
|
+
const pl = msg.payload || {};
|
|
7445
|
+
if (!_skBusAllowed(skill, pl.topic)) { reply(msg.id, false, { error: 'bus_topic_denied' }); return; } // §5 per-call gate
|
|
7446
|
+
// Inv 23: bound the payload (structured-clone-safe + ≤65536 bytes via the JSON proxy)
|
|
7447
|
+
// so a skill can't flood subscribers. A non-serializable payload fails closed.
|
|
7448
|
+
let enc; try { enc = JSON.stringify(pl.message); } catch (_) { reply(msg.id, false, { error: 'bus_error' }); return; }
|
|
7449
|
+
if (enc != null && enc.length > 65536) { reply(msg.id, false, { error: 'bus_error' }); return; }
|
|
7450
|
+
try { runtimeBusPublish(pl.topic, pl.message); reply(msg.id, true, { result: true }); }
|
|
7451
|
+
catch (_) { reply(msg.id, false, { error: 'bus_error' }); }
|
|
7452
|
+
return;
|
|
7453
|
+
}
|
|
7454
|
+
if (msg.type === 'bridge:fs') {
|
|
7455
|
+
const pl = msg.payload || {}, op = pl.op, p = pl.path;
|
|
7456
|
+
// Reject traversal/invalid paths BEFORE the scope check (mirror of assertUserFsPath); a
|
|
7457
|
+
// declared scope is a left-anchored prefix, so a '..'-free path is safe to literal-match.
|
|
7458
|
+
if (typeof p !== 'string' || !p || p.startsWith('/') || p.startsWith('_rwa/') || p.split('/').some(s => s === '.' || s === '..')) { reply(msg.id, false, { error: 'fs_path_denied' }); return; }
|
|
7459
|
+
if (!_skFsAllowed(skill, p)) { reply(msg.id, false, { error: 'fs_permission_denied' }); return; } // §6 per-call gate
|
|
7460
|
+
try {
|
|
7461
|
+
let result;
|
|
7462
|
+
if (op === 'read') result = await runtimeFsRead(p);
|
|
7463
|
+
else if (op === 'write') { await runtimeFsWrite(p, pl.data); result = true; }
|
|
7464
|
+
else if (op === 'del') { await runtimeFsDel(p); result = true; }
|
|
7465
|
+
else if (op === 'list') result = await runtimeFsList(p);
|
|
7466
|
+
else { reply(msg.id, false, { error: 'fs_path_invalid' }); return; }
|
|
7467
|
+
reply(msg.id, true, { result });
|
|
7468
|
+
} catch (e) {
|
|
7469
|
+
const m = String(e && e.message || ''), nm = e && e.name;
|
|
7470
|
+
const code = (nm === 'QuotaExceededError' || /quota/i.test(m)) ? 'fs_quota_exceeded'
|
|
7471
|
+
: (nm === 'SecurityError' || /OPFS is not available|not supported/i.test(m)) ? 'fs_unsupported'
|
|
7472
|
+
: (nm === 'NotFoundError' || /no file at/i.test(m)) ? 'fs_path_not_found'
|
|
7473
|
+
: 'fs_write_failed';
|
|
7474
|
+
reply(msg.id, false, { error: code });
|
|
7475
|
+
}
|
|
7476
|
+
return;
|
|
7477
|
+
}
|
|
7478
|
+
if (msg.type === 'bridge:idb') {
|
|
7479
|
+
const pl = msg.payload || {}, op = pl.op, store = pl.store;
|
|
7480
|
+
if (!_skIdbAllowed(skill, store)) { reply(msg.id, false, { error: 'idb_store_denied' }); return; } // §7 per-call gate
|
|
7481
|
+
try {
|
|
7482
|
+
let result;
|
|
7483
|
+
if (op === 'get') result = await runtimeDbGet(store, pl.key);
|
|
7484
|
+
else if (op === 'put') { await runtimeDbPut(store, pl.key, pl.value); result = true; }
|
|
7485
|
+
else if (op === 'del') { await runtimeDbDel(store, pl.key); result = true; }
|
|
7486
|
+
else if (op === 'all') result = await runtimeDbAll(store);
|
|
7487
|
+
else { reply(msg.id, false, { error: 'invalid_argument' }); return; }
|
|
7488
|
+
reply(msg.id, true, { result });
|
|
7489
|
+
} catch (e) { reply(msg.id, false, { error: String(e && e.message || 'idb_error') }); }
|
|
7490
|
+
return;
|
|
7491
|
+
}
|
|
6915
7492
|
};
|
|
6916
7493
|
w.onerror = (e) => finish(reject, new Error('runtime_error: ' + (e.message || '')));
|
|
6917
7494
|
w.postMessage({ type: 'init', identity_tag, bridged: isTool });
|
|
@@ -6958,6 +7535,15 @@ function runtimeDescribe() {
|
|
|
6958
7535
|
affordances.push({ kind: s.kind, name: s.name, skillId: s.skillId, provenance: 'installed', verified: s.verified || false });
|
|
6959
7536
|
seen.add(key);
|
|
6960
7537
|
}
|
|
7538
|
+
// I12/SD-04 — union INSTALLED agents (kind:'agent', name:role). Same shape as the static
|
|
7539
|
+
// parseAgentZone projection so live == static (cross-surface affordance convergence).
|
|
7540
|
+
for (const a of installedAgents.values()) {
|
|
7541
|
+
if (!a || typeof a.role !== 'string') continue;
|
|
7542
|
+
const key = 'agent\0' + a.role;
|
|
7543
|
+
if (seen.has(key)) continue;
|
|
7544
|
+
affordances.push({ kind: 'agent', name: a.role, agentId: a.agentId, provenance: 'installed', verified: a.verified || false });
|
|
7545
|
+
seen.add(key);
|
|
7546
|
+
}
|
|
6961
7547
|
return {
|
|
6962
7548
|
rwa: 'self-description/1',
|
|
6963
7549
|
source: 'live',
|
|
@@ -7382,7 +7968,7 @@ async function modify(instr, lensMeta = null, opts = null) {
|
|
|
7382
7968
|
const frozenZones = extractFrozenZones(cur);
|
|
7383
7969
|
|
|
7384
7970
|
const messages = [
|
|
7385
|
-
{ role: 'system', content:
|
|
7971
|
+
{ role: 'system', content: resolveSystemPrompt() },
|
|
7386
7972
|
{ role: 'user', content: buildUserPrompt(instr, cur, frozenZones) }
|
|
7387
7973
|
];
|
|
7388
7974
|
|
|
@@ -7569,7 +8155,7 @@ async function modifyViaBridge(instr, lensMeta = null) {
|
|
|
7569
8155
|
const vOpts = { assets: vimg.assets, orphans: vimg.orphans };
|
|
7570
8156
|
const frozenZones = extractFrozenZones(cur);
|
|
7571
8157
|
|
|
7572
|
-
const prompt =
|
|
8158
|
+
const prompt = resolveSystemPrompt() + '\n\n' + buildUserPrompt(instr, cur, frozenZones) +
|
|
7573
8159
|
'\n\nThis backend is single-shot (no tool-calling protocol). Output ONLY a single JSON envelope as your last response, no markdown fences, no commentary, no preamble. The envelope MUST be one of these three exact shapes:\n\n' +
|
|
7574
8160
|
'{"tool":"apply_dsl_plan","envelope":{"version":"rwa-edit-dsl/1","ops":[/* per the rules above */]}}\n\n' +
|
|
7575
8161
|
'{"tool":"apply_edits","envelope":{"version":"rwa-edit/1","edits":[{"find":"...","replace":"..."}]}}\n\n' +
|
|
@@ -8105,6 +8691,19 @@ document.addEventListener('keydown', e => {
|
|
|
8105
8691
|
const skMap = await readTrustworthySkills(doc);
|
|
8106
8692
|
installedSkills.clear();
|
|
8107
8693
|
skMap.forEach((v, k) => installedSkills.set(k, v));
|
|
8694
|
+
// I12 — rebuild the agent registry from the frozen #rwa-agents zone (re-verify each). No agent
|
|
8695
|
+
// is active by default; an unverified (tampered) agent is registered but can't activate.
|
|
8696
|
+
const agMap = await readTrustworthyAgents(doc);
|
|
8697
|
+
installedAgents.clear();
|
|
8698
|
+
agMap.forEach((v, k) => installedAgents.set(k, v));
|
|
8699
|
+
// I5 — reconcile the per-author name_history (rwa_sources) from the in-file manifests, so a
|
|
8700
|
+
// fresh load (or an IDB-cleared one) still knows the renames recorded in the frozen zone.
|
|
8701
|
+
await runtimeBuildSourceIndex();
|
|
8702
|
+
// I8 — seed the mode tracker for on-mode-change's `previous`, prune the hook log, then fire
|
|
8703
|
+
// on-open hooks (fire-and-forget so a hook can never slow first paint; results logged).
|
|
8704
|
+
_lastHookMode = rwaMode;
|
|
8705
|
+
await _hookLogPrune();
|
|
8706
|
+
fireHooks('on-open', { event: 'on-open', docUuid: DOC_UUID });
|
|
8108
8707
|
} catch (_) { /* no installed skills */ }
|
|
8109
8708
|
await _vaultReimportSession().catch(() => {}); // v0.8 §6 — restore an unlocked vault within the tab session
|
|
8110
8709
|
// Spec §7: expose the public runtime API. Only constructed on the success
|
|
@@ -8148,6 +8747,8 @@ document.addEventListener('keydown', e => {
|
|
|
8148
8747
|
showInstallDialog: showSkillInstallDialog, // v0.8 §1 — render the consent dialog for an envelope → resolves with the choice
|
|
8149
8748
|
promptInstall: runtimePromptInstall, // v0.8 §1.3 — pick a .rwa-skill.json → show the dialog
|
|
8150
8749
|
vault: { get: runtimeVaultGet, set: runtimeVaultSet, has: runtimeVaultHas, namespaces: runtimeVaultNamespaces, unlock: runtimeVaultUnlock, lock: runtimeVaultLock, isLocked: runtimeVaultIsLocked }, // v0.8 §6
|
|
8750
|
+
agents: { list: runtimeListAgents, active: runtimeAgentActive, setActive: runtimeSetActiveAgent, install: runtimeInstallAgent, uninstall: runtimeUninstallAgent, message: runtimeAgentMessage, showInstallDialog: showAgentInstallDialog }, // v0.9 §12 — multi-agent roles
|
|
8751
|
+
hookLog: runtimeHookLog, // v0.9 §9 — the hook audit trail (rwa_hook_log)
|
|
8151
8752
|
};
|
|
8152
8753
|
// `status` is a getter so each read returns a fresh snapshot of
|
|
8153
8754
|
// dirty/fsa/storage; an enumerable data prop would let stale references
|