rewritable 0.8.1 → 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 +606 -17
- 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) ========================
|
|
@@ -5110,11 +5119,30 @@ window.submitLens = submitLens;
|
|
|
5110
5119
|
// workspaces (audit R7) can carry richer identifiers (e.g. signed source
|
|
5111
5120
|
// keys per the action-spec) without changing the rwa_hist schema.
|
|
5112
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;
|
|
5113
5125
|
const backend = sessionStorage.getItem(RWA.K_BACKEND) || 'openrouter';
|
|
5114
5126
|
if (backend === 'bridge') return 'bridge:claude-p';
|
|
5115
5127
|
if (backend === 'bridge-session') return 'bridge:claude-session';
|
|
5116
5128
|
return sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
|
|
5117
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
|
+
}
|
|
5118
5146
|
|
|
5119
5147
|
// Resolve the user-selected backend into a transport config.
|
|
5120
5148
|
// kind:'openai_compat' covers openrouter/ollama/lmstudio — same wire protocol,
|
|
@@ -6316,6 +6344,17 @@ let activeView = null;
|
|
|
6316
6344
|
// runtimeDescribe()/runtimeListSkills(). The agent can never write the zone (it is
|
|
6317
6345
|
// data-rwa-frozen); only the runtime rewrites it (increment 7, registry-aware commit).
|
|
6318
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;
|
|
6319
6358
|
|
|
6320
6359
|
const RWA_VIEW_RESERVED_IDS = ['rwa-doc-mount', 'rwa-lens', 'rwa-runtime'];
|
|
6321
6360
|
const RWA_VIEW_RESERVED_MARKERS = ['rwa:frozen:begin', 'rwa:frozen:end', 'data-rwa-frozen'];
|
|
@@ -6487,6 +6526,62 @@ async function _skVerify(env) {
|
|
|
6487
6526
|
return !!(await crypto.subtle.verify({ name: 'Ed25519' }, key, _skFromB64(sig), await _skSigningMessage(skill, skill.code)));
|
|
6488
6527
|
} catch (_) { return false; } // Ed25519 unsupported / bad bytes → verified:false (honest)
|
|
6489
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
|
+
}
|
|
6490
6585
|
// Locate the inner HTML of the agent-unreachable <div data-rwa-frozen id="rwa-skills">
|
|
6491
6586
|
// in the DOC TEXT (boot has the doc string before render). Only the frozen zone is
|
|
6492
6587
|
// trusted; base64 envelopes contain no </div> so a flat scan is safe.
|
|
@@ -6522,6 +6617,87 @@ async function readTrustworthySkills(doc) {
|
|
|
6522
6617
|
}
|
|
6523
6618
|
return out;
|
|
6524
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
|
+
}
|
|
6525
6701
|
function runtimeListSkills() {
|
|
6526
6702
|
return Array.from(installedSkills.values()).map(s => ({ skillId: s.skillId, kind: s.kind, name: s.name, verified: s.verified }));
|
|
6527
6703
|
}
|
|
@@ -6595,6 +6771,25 @@ function _skVaultAllowed(skill, ns) {
|
|
|
6595
6771
|
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
6596
6772
|
return perms.indexOf('vault:' + ns) !== -1;
|
|
6597
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
|
+
}
|
|
6598
6793
|
|
|
6599
6794
|
// ─── Install dialog (shannon, v0.8 §1) — the trust anchor. Mirrors of the cli
|
|
6600
6795
|
// consent helpers (permissionToProse/compoundRisk/capabilityScan/levenshtein). ──────
|
|
@@ -6613,12 +6808,32 @@ function _skPermProse(perm) {
|
|
|
6613
6808
|
if (v === '*') return 'Read and write credentials stored under ANY vault namespace — every credential you have stored. Use only for vault administration.';
|
|
6614
6809
|
return 'Read and write credentials stored under ' + v + '.';
|
|
6615
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
|
+
}
|
|
6616
6825
|
return s;
|
|
6617
6826
|
}
|
|
6618
6827
|
function _skCompoundRisk(perms) {
|
|
6619
6828
|
perms = Array.isArray(perms) ? perms : [];
|
|
6620
|
-
|
|
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)
|
|
6621
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.';
|
|
6622
6837
|
return null;
|
|
6623
6838
|
}
|
|
6624
6839
|
function _skCapabilityScan(code) {
|
|
@@ -6647,6 +6862,26 @@ function _skLevenshtein(a, b) {
|
|
|
6647
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; }
|
|
6648
6863
|
return prev[n];
|
|
6649
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)); }
|
|
6650
6885
|
// §3.4 install gates (mirror cli validateInstall).
|
|
6651
6886
|
const _SK_VAULT_NS = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
|
|
6652
6887
|
// §4 permission grammar (mirror cli parsePermission) — validates the VALUE, not
|
|
@@ -6670,6 +6905,32 @@ function _skParsePermission(p) {
|
|
|
6670
6905
|
if (value.length > 64 || !_SK_VAULT_NS.test(value)) throw new Error(`invalid vault namespace: ${value}`);
|
|
6671
6906
|
return { tier, value };
|
|
6672
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
|
+
}
|
|
6673
6934
|
throw new Error(`unknown_permission_tier: ${tier}`);
|
|
6674
6935
|
}
|
|
6675
6936
|
function _skValidateInstall(skill, vr) {
|
|
@@ -6684,11 +6945,19 @@ function _skValidateInstall(skill, vr) {
|
|
|
6684
6945
|
// F7: validate each permission's VALUE via the grammar, not just the tier.
|
|
6685
6946
|
for (const p of perms) {
|
|
6686
6947
|
try { _skParsePermission(p); }
|
|
6687
|
-
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
|
+
}
|
|
6688
6954
|
}
|
|
6689
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');
|
|
6690
6958
|
if (!vr.signed && perms.length > 0) errors.push('unsigned_with_permissions');
|
|
6691
|
-
|
|
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');
|
|
6692
6961
|
return { ok: errors.length === 0, errors };
|
|
6693
6962
|
}
|
|
6694
6963
|
// §1 — the structured trust info the dialog renders.
|
|
@@ -6696,22 +6965,54 @@ async function runtimeReviewSkill(envelope) {
|
|
|
6696
6965
|
const skill = (envelope && envelope.skill) || {};
|
|
6697
6966
|
const perms = Array.isArray(skill.permissions) ? skill.permissions : [];
|
|
6698
6967
|
const vr = { signed: !!(envelope && envelope.signature), verified: await _skVerify(envelope) };
|
|
6699
|
-
let
|
|
6968
|
+
let levMatch = null, skelMatch = null;
|
|
6700
6969
|
for (const s of installedSkills.values()) {
|
|
6970
|
+
if (!s.manifest || s.manifest.author_pubkey === skill.author_pubkey) continue; // same author ≠ impersonation
|
|
6701
6971
|
const d = _skLevenshtein(s.name, skill.name);
|
|
6702
6972
|
// A DIFFERENT key with the same/near name is impersonation. Exact-name (d===0) ALWAYS fires —
|
|
6703
6973
|
// it's the strongest spoof and the §3.3 case lookalike is responsible for; a near miss (1–2 edits)
|
|
6704
6974
|
// fires only when both names are long enough that 1–2 edits is "similar", not "unrelated".
|
|
6705
6975
|
const exact = d === 0, near = d >= 1 && d <= 2 && String(skill.name).length >= 4 && String(s.name).length >= 4;
|
|
6706
|
-
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
|
+
}
|
|
6707
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);
|
|
6708
7007
|
return {
|
|
6709
7008
|
name: skill.name, version: skill.version, kind: skill.kind,
|
|
6710
7009
|
purpose: skill.description || '(no description provided)',
|
|
6711
7010
|
author_pubkey: skill.author_pubkey, signed: vr.signed, verified: vr.verified,
|
|
6712
7011
|
permissions: perms.map(p => ({ perm: p, prose: _skPermProse(p) })),
|
|
6713
7012
|
compoundRisk: _skCompoundRisk(perms), scanNotes: _skCapabilityScan(skill.code),
|
|
6714
|
-
lookalike,
|
|
7013
|
+
lookalike, lookalikeKind, lookalikeBlock,
|
|
7014
|
+
priorNames: nameInfo.priorNames, nameChange: nameInfo.nameChange,
|
|
7015
|
+
gates: _skValidateInstall(skill, vr), update,
|
|
6715
7016
|
};
|
|
6716
7017
|
}
|
|
6717
7018
|
// §7 — serialize the registry into the frozen #rwa-skills zone. CANONICAL: sorted by
|
|
@@ -6751,6 +7052,10 @@ function _skSkillsRegion() {
|
|
|
6751
7052
|
async function runtimeInstallSkill(envelope) {
|
|
6752
7053
|
const review = await runtimeReviewSkill(envelope);
|
|
6753
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'] };
|
|
6754
7059
|
const forbidden = _skCodeForbidden((envelope.skill || {}).code);
|
|
6755
7060
|
if (forbidden) return { ok: false, errors: [forbidden] };
|
|
6756
7061
|
const skill = envelope.skill, id = await _skSkillId(skill.name, skill.author_pubkey);
|
|
@@ -6762,6 +7067,9 @@ async function runtimeInstallSkill(envelope) {
|
|
|
6762
7067
|
if (prev) installedSkills.set(id, prev); else installedSkills.delete(id);
|
|
6763
7068
|
return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
|
|
6764
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);
|
|
6765
7073
|
return { ok: true, skillId: id };
|
|
6766
7074
|
}
|
|
6767
7075
|
// §7 — remove a skill + persist the emptied/updated zone (same rollback discipline).
|
|
@@ -6777,6 +7085,126 @@ async function runtimeUninstallSkill(skillId) {
|
|
|
6777
7085
|
}
|
|
6778
7086
|
return { ok: true };
|
|
6779
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
|
+
}
|
|
6780
7208
|
// §1 — the install dialog (trust anchor). Renders the review and resolves with the user's choice.
|
|
6781
7209
|
function showSkillInstallDialog(envelope) {
|
|
6782
7210
|
return runtimeReviewSkill(envelope).then(rv => new Promise(resolve => {
|
|
@@ -6789,11 +7217,45 @@ function showSkillInstallDialog(envelope) {
|
|
|
6789
7217
|
const authorHtml = rv.signed
|
|
6790
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.')
|
|
6791
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;
|
|
6792
7252
|
card.innerHTML =
|
|
6793
7253
|
'<h2 style="margin:0 0 .2em;font-size:1.25rem">Install ' + _skEsc(rv.name) + '?</h2>' +
|
|
6794
7254
|
'<p style="margin:.2em 0 1em;color:#444"><strong>What it claims to do:</strong> ' + _skEsc(rv.purpose) + '</p>' +
|
|
6795
7255
|
'<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + '</p>' +
|
|
6796
|
-
|
|
7256
|
+
lookalikeHtml +
|
|
7257
|
+
nameChangeHtml +
|
|
7258
|
+
updHtml +
|
|
6797
7259
|
'<p style="margin:1em 0 .2em"><strong>What it can do on your machine</strong></p>' + permsHtml +
|
|
6798
7260
|
(rv.compoundRisk ? '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠ ' + _skEsc(rv.compoundRisk) + '</p>' : '') +
|
|
6799
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>' : '') +
|
|
@@ -6801,7 +7263,7 @@ function showSkillInstallDialog(envelope) {
|
|
|
6801
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>') +
|
|
6802
7264
|
'<div style="display:flex;gap:10px;margin-top:1.2em;justify-content:flex-end">' +
|
|
6803
7265
|
'<button data-act="cancel" style="padding:9px 16px;border:1px solid #ccc;border-radius:10px;background:#fff;cursor:pointer">Cancel</button>' +
|
|
6804
|
-
(
|
|
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>' : '') +
|
|
6805
7267
|
'</div>';
|
|
6806
7268
|
overlay.appendChild(card); document.body.appendChild(overlay);
|
|
6807
7269
|
const close = (r) => { overlay.remove(); resolve(r); };
|
|
@@ -6809,10 +7271,49 @@ function showSkillInstallDialog(envelope) {
|
|
|
6809
7271
|
const ib = card.querySelector('[data-act=install]'); if (ib) ib.onclick = async () => close(await runtimeInstallSkill(envelope));
|
|
6810
7272
|
}));
|
|
6811
7273
|
}
|
|
6812
|
-
//
|
|
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.
|
|
6813
7314
|
function runtimePromptInstall() {
|
|
6814
|
-
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.rwa-skill.json,application/json,.json';
|
|
6815
|
-
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); };
|
|
6816
7317
|
inp.click();
|
|
6817
7318
|
}
|
|
6818
7319
|
|
|
@@ -6851,6 +7352,9 @@ const SKILL_WORKER_PROLOGUE = `(function(){
|
|
|
6851
7352
|
function _installBridge(){
|
|
6852
7353
|
RUNTIME.fetch=function(url,opts){ return _bridge('bridge:fetch',{ url:String(url), opts:_serializeOpts(opts) }); };
|
|
6853
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)}); } };
|
|
6854
7358
|
}
|
|
6855
7359
|
self.onmessage=function(e){
|
|
6856
7360
|
var msg=e.data; if(!msg) return;
|
|
@@ -6870,9 +7374,18 @@ const SKILL_WORKER_PROLOGUE = `(function(){
|
|
|
6870
7374
|
// bridged fetch/vault with per-call origin/namespace enforcement on THIS (main) thread.
|
|
6871
7375
|
// Per-invocation identity_tag binds responses; 5s timeout; spawn -> invoke -> terminate.
|
|
6872
7376
|
// (Vault: increment 8. The worker-scoped CSP that walls remote import() is the frozen-<head> 7b meta.)
|
|
6873
|
-
function runtimeInvokeSkill(skillId, input) {
|
|
7377
|
+
function runtimeInvokeSkill(skillId, input, opts) {
|
|
6874
7378
|
const skill = installedSkills.get(skillId);
|
|
6875
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
|
+
}
|
|
6876
7389
|
const isTool = skill.kind === 'tool';
|
|
6877
7390
|
// §3.4/Inv 20 gate at the capability boundary: a `tool` that did not re-verify from the bytes
|
|
6878
7391
|
// (unsigned, or signed-then-tampered → boot re-verify flips verified:false) must NOT reach the
|
|
@@ -6915,7 +7428,10 @@ function runtimeInvokeSkill(skillId, input) {
|
|
|
6915
7428
|
}
|
|
6916
7429
|
if (msg.type === 'bridge:vault') {
|
|
6917
7430
|
const pl = msg.payload || {};
|
|
6918
|
-
|
|
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
|
|
6919
7435
|
try {
|
|
6920
7436
|
if (pl.op === 'get') reply(msg.id, true, { result: await runtimeVaultGet(pl.ns, pl.key) });
|
|
6921
7437
|
else if (pl.op === 'has') reply(msg.id, true, { result: await runtimeVaultHas(pl.ns, pl.key) });
|
|
@@ -6924,6 +7440,55 @@ function runtimeInvokeSkill(skillId, input) {
|
|
|
6924
7440
|
} catch (e) { reply(msg.id, false, { error: String(e && e.message || 'vault_error') }); }
|
|
6925
7441
|
return;
|
|
6926
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
|
+
}
|
|
6927
7492
|
};
|
|
6928
7493
|
w.onerror = (e) => finish(reject, new Error('runtime_error: ' + (e.message || '')));
|
|
6929
7494
|
w.postMessage({ type: 'init', identity_tag, bridged: isTool });
|
|
@@ -6970,6 +7535,15 @@ function runtimeDescribe() {
|
|
|
6970
7535
|
affordances.push({ kind: s.kind, name: s.name, skillId: s.skillId, provenance: 'installed', verified: s.verified || false });
|
|
6971
7536
|
seen.add(key);
|
|
6972
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
|
+
}
|
|
6973
7547
|
return {
|
|
6974
7548
|
rwa: 'self-description/1',
|
|
6975
7549
|
source: 'live',
|
|
@@ -7394,7 +7968,7 @@ async function modify(instr, lensMeta = null, opts = null) {
|
|
|
7394
7968
|
const frozenZones = extractFrozenZones(cur);
|
|
7395
7969
|
|
|
7396
7970
|
const messages = [
|
|
7397
|
-
{ role: 'system', content:
|
|
7971
|
+
{ role: 'system', content: resolveSystemPrompt() },
|
|
7398
7972
|
{ role: 'user', content: buildUserPrompt(instr, cur, frozenZones) }
|
|
7399
7973
|
];
|
|
7400
7974
|
|
|
@@ -7581,7 +8155,7 @@ async function modifyViaBridge(instr, lensMeta = null) {
|
|
|
7581
8155
|
const vOpts = { assets: vimg.assets, orphans: vimg.orphans };
|
|
7582
8156
|
const frozenZones = extractFrozenZones(cur);
|
|
7583
8157
|
|
|
7584
|
-
const prompt =
|
|
8158
|
+
const prompt = resolveSystemPrompt() + '\n\n' + buildUserPrompt(instr, cur, frozenZones) +
|
|
7585
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' +
|
|
7586
8160
|
'{"tool":"apply_dsl_plan","envelope":{"version":"rwa-edit-dsl/1","ops":[/* per the rules above */]}}\n\n' +
|
|
7587
8161
|
'{"tool":"apply_edits","envelope":{"version":"rwa-edit/1","edits":[{"find":"...","replace":"..."}]}}\n\n' +
|
|
@@ -8117,6 +8691,19 @@ document.addEventListener('keydown', e => {
|
|
|
8117
8691
|
const skMap = await readTrustworthySkills(doc);
|
|
8118
8692
|
installedSkills.clear();
|
|
8119
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 });
|
|
8120
8707
|
} catch (_) { /* no installed skills */ }
|
|
8121
8708
|
await _vaultReimportSession().catch(() => {}); // v0.8 §6 — restore an unlocked vault within the tab session
|
|
8122
8709
|
// Spec §7: expose the public runtime API. Only constructed on the success
|
|
@@ -8160,6 +8747,8 @@ document.addEventListener('keydown', e => {
|
|
|
8160
8747
|
showInstallDialog: showSkillInstallDialog, // v0.8 §1 — render the consent dialog for an envelope → resolves with the choice
|
|
8161
8748
|
promptInstall: runtimePromptInstall, // v0.8 §1.3 — pick a .rwa-skill.json → show the dialog
|
|
8162
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)
|
|
8163
8752
|
};
|
|
8164
8753
|
// `status` is a getter so each read returns a fresh snapshot of
|
|
8165
8754
|
// dirty/fsa/storage; an enumerable data prop would let stale references
|