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/src/identity.mjs CHANGED
@@ -14,12 +14,12 @@
14
14
  // doc.test.mjs). Drift fails loudly. KEEP IN STEP with tools/self-description.mjs.
15
15
 
16
16
  import { tagHasFrozenAttr } from './apply-edits.mjs';
17
- import { parseSkillZone } from './skill-manifest.mjs';
17
+ import { parseSkillZone, parseAgentZone } from './skill-manifest.mjs';
18
18
 
19
19
  export const SCHEMA_TAG = 'self-description/1';
20
20
  // Mirror of tools/self-description.mjs AFFORDANCE_KINDS / PROVENANCES — used by the
21
21
  // declared-projection conformance gate (declaredIsConforming). Keep in step.
22
- export const AFFORDANCE_KINDS = ['view', 'edit-surface', 'tool', 'compute', 'hook'];
22
+ export const AFFORDANCE_KINDS = ['view', 'edit-surface', 'tool', 'compute', 'hook', 'agent'];
23
23
  export const PROVENANCES = ['first-party', 'installed'];
24
24
 
25
25
  // kind -> registered provider bundle (spec §4). Each provider is {kind,name,label};
@@ -86,6 +86,7 @@ export function buildSelfDescription({ doc, uuid, kind, frozenZones }) {
86
86
  const affordances = [
87
87
  ...(KIND_PROVIDERS[kind] || []).map((p) => ({ ...p, provenance: 'first-party' })),
88
88
  ...parseSkillZone(doc),
89
+ ...parseAgentZone(doc), // I12/SD-04 — installed agents (kind:'agent', name:role)
89
90
  ];
90
91
  return {
91
92
  rwa: SCHEMA_TAG,
@@ -0,0 +1,206 @@
1
+ // `rwa install <skill.rwa-skill.json> <skill-host.html>` (v0.9 open-items spec §3 / I11).
2
+ //
3
+ // The offline, headless counterpart of the seed's interactive install dialog
4
+ // (seeds/rewritable.html showSkillInstallDialog / runtimeInstallSkill). It gates a
5
+ // skill envelope through the SAME trust checks as the seed — Ed25519 signature
6
+ // verify, validateInstall (unsigned-capability / compute-with-perms / permission
7
+ // grammar), and the dynamic-import() hard-reject — then splices the verified
8
+ // envelope into the frozen `<div data-rwa-frozen id="rwa-skills">` zone inside
9
+ // INLINE_DOC and re-bakes the file atomically.
10
+ //
11
+ // The CLI is the sole AUDITED exception to runtime-sole-writer (Invariant 19/39):
12
+ // applyEdits would REJECT a frozen-zone write, so install does a direct zone splice
13
+ // + replaceInlineDoc re-bake — writing the byte-identical zone form the seed's
14
+ // runtimeRegionCommit produces (skillId-sorted, base64(JSON(envelope)) blocks), so
15
+ // it re-verifies at the seed's boot. There is no dialog to consent in, so an
16
+ // explicit `--yes` is required; gate failures are final and `--yes` cannot override
17
+ // them.
18
+ //
19
+ // buildSkillZone here is a hand-mirror of the seed's buildSkillZone (there is no CLI
20
+ // zone-builder elsewhere — parseSkillZone in skill-manifest.mjs only READS). Same
21
+ // mirror discipline as cli/src/apply-edits.mjs mirrors the seed apply path.
22
+
23
+ import { readFile } from 'node:fs/promises';
24
+ import { skillId, verifyEnvelope, validateInstall, parseSkillZone, levenshtein, skeletonDistance, normalizeName } from './skill-manifest.mjs';
25
+ import { extractInlineDoc, replaceInlineDoc } from './seed.mjs';
26
+ import { tagHasFrozenAttr } from './apply-edits.mjs';
27
+ import { CliError } from './edit.mjs';
28
+ import { atomicWrite } from './atomic-write.mjs';
29
+
30
+ const SKILL_BLOCK_RE = /<script\s+type="application\/rwa-skill\+json">([\s\S]*?)<\/script>/g;
31
+
32
+ /** Mirror of the seed's _skCodeForbidden — refuse dynamic import() before any install.
33
+ * The seed enforces this at install AND invoke; the CLI enforces it at install (it never
34
+ * invokes). A code-loading channel the bridge/CSP can't see, so it is refused outright. */
35
+ export function codeForbidden(code) {
36
+ return /\bimport\s*\(/.test(String(code || '')) ? 'dynamic_import_forbidden' : null;
37
+ }
38
+
39
+ /** Hand-mirror of the seed buildSkillZone: CANONICAL — sorted by skillId so the bytes are
40
+ * install-order-independent; each envelope re-emitted as utf-8 base64 inside its <script>
41
+ * block (matches parseSkillZone's read format) so it re-verifies at the seed's boot. */
42
+ export function buildSkillZone(envelopes) {
43
+ const blocks = envelopes
44
+ .map((e) => ({ id: skillId(e.skill.name, e.skill.author_pubkey), e }))
45
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
46
+ .map((x) => '<script type="application/rwa-skill+json">' + Buffer.from(JSON.stringify(x.e)).toString('base64') + '</script>')
47
+ .join('');
48
+ return '<div data-rwa-frozen id="rwa-skills">' + blocks + '</div>';
49
+ }
50
+
51
+ /** Locate the frozen #rwa-skills zone — mirror of the seed's _skSkillsRegion.select():
52
+ * base64 content has no '<', so the first </div> after the open tag is the real close.
53
+ * STRICT data-rwa-frozen attribute-NAME check (mirror of the trust-read extractRwaSkillsZone):
54
+ * refuse to write into an editable lookalike `<div id="rwa-skills">` BEFORE any write, so the
55
+ * caller gets a clean no_skill_zone (exit 2) instead of a stray inert splice + a later
56
+ * durability throw. The kind gate already restricts to skill-host; this is defence in depth. */
57
+ function locateZone(doc) {
58
+ const open = /<div\b[^>]*\bid="rwa-skills"[^>]*>/i.exec(doc);
59
+ if (!open || !tagHasFrozenAttr(open[0])) return null;
60
+ const close = doc.indexOf('</div>', open.index + open[0].length);
61
+ if (close < 0) return null;
62
+ return { start: open.index, innerStart: open.index + open[0].length, innerEnd: close, end: close + 6 };
63
+ }
64
+
65
+ /** The FULL existing envelopes in the zone (parseSkillZone returns projections only — we need
66
+ * the envelopes to merge). Malformed blocks are skipped (never block siblings). */
67
+ function zoneEnvelopes(doc) {
68
+ const z = locateZone(doc);
69
+ if (!z) return null;
70
+ const out = [];
71
+ for (const m of doc.slice(z.innerStart, z.innerEnd).matchAll(SKILL_BLOCK_RE)) {
72
+ try { out.push(JSON.parse(Buffer.from(m[1].trim(), 'base64').toString('utf8'))); } catch { /* skip */ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** Non-blocking lookalike scan — mirror of the seed runtimeReviewSkill (seeds/rewritable.html
78
+ * ~6700-6706): a DIFFERENT author key bearing an exact (d===0) or near (Levenshtein 1-2, both
79
+ * names ≥4 chars) name is impersonation. The trust anchor is the KEY, not the name (Invariant
80
+ * 10), so this only WARNS — install still proceeds (Invariant 23, non-blocking). */
81
+ function scanLookalike(existing, skill) {
82
+ for (const e of existing) {
83
+ const es = e.skill || {};
84
+ const d = levenshtein(es.name, skill.name);
85
+ const exact = d === 0;
86
+ const near = d >= 1 && d <= 2 && String(skill.name).length >= 4 && String(es.name).length >= 4;
87
+ if (es.author_pubkey !== skill.author_pubkey && (exact || near)) return es.name;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /** I5 (v0.9 §4) — Unicode-confusable (skeleton) scan. Catches homoglyph squatting that ASCII
93
+ * Levenshtein misses: a name whose RFC 7954 skeleton folds (≤1 edit) to a DIFFERENT author's
94
+ * installed name renders identically to a human. Returns the matched installed name or null.
95
+ * The discriminator is `skeleton < normalized-Levenshtein`: confusable folding must have
96
+ * COLLAPSED a real byte difference (cross-script glyphs). An honest ASCII near-miss (skeleton ==
97
+ * Levenshtein) is NOT a homoglyph — it stays the non-blocking Levenshtein warning (Invariant 10,
98
+ * and the I5 acceptance: "ASCII exact name, diff key → warning, install allowed"). Same author
99
+ * (a rebrand) never matches — restyling your own name is not impersonation. */
100
+ function scanSkeleton(existing, skill) {
101
+ for (const e of existing) {
102
+ const es = e.skill || {};
103
+ if (es.author_pubkey === skill.author_pubkey) continue;
104
+ const sd = skeletonDistance(es.name, skill.name);
105
+ const ld = levenshtein(normalizeName(es.name), normalizeName(skill.name));
106
+ if (sd <= 1 && sd < ld) return es.name; // folding made them confusable → impersonation
107
+ }
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * Pure core: gate an envelope, merge it into the host's INLINE_DOC zone, return the new doc body.
113
+ * No file I/O — installSkillFile owns that.
114
+ * @returns {{ newDoc:string, result:object, changed:boolean }}
115
+ * @throws {CliError} 3 envelope_error (gates) · 1 usage_error (no consent) · 2 file_error (no zone)
116
+ */
117
+ export function installEnvelopeIntoDoc(inlineDoc, envelope, { consent } = {}) {
118
+ if (!envelope || envelope.format !== 'rwa-skill/1' || !envelope.skill || typeof envelope.skill.name !== 'string') {
119
+ throw new CliError(3, 'malformed_envelope', {});
120
+ }
121
+ const skill = envelope.skill;
122
+ const { signed, verified } = verifyEnvelope(envelope);
123
+ // Trust gates FIRST and FINAL — a gate failure throws before the consent check, so --yes
124
+ // (or its absence) can never turn a refused skill into an installed one.
125
+ const gate = validateInstall(envelope, { signed, verified });
126
+ if (!gate.ok) throw new CliError(3, gate.errors[0], { errors: gate.errors });
127
+ const forbidden = codeForbidden(skill.code);
128
+ if (forbidden) throw new CliError(3, forbidden, {});
129
+ // Consent: no dialog to review in → an explicit --yes is the offline review signal.
130
+ if (!consent) throw new CliError(1, 'interactive_install_deferred', { skill: skill.name });
131
+
132
+ const existing = zoneEnvelopes(inlineDoc);
133
+ if (existing === null) throw new CliError(2, 'no_skill_zone', {}); // not a skill-host body
134
+ const id = skillId(skill.name, skill.author_pubkey);
135
+ // I5 (v0.9 §4) — Unicode-confusable HARD block. A signed skill (it carries capability to
136
+ // escalate) whose name skeleton-folds to a DIFFERENT author's installed skill is impersonation:
137
+ // refuse before any code is registered. Unsigned skills can't escalate → warn only (below).
138
+ const skeletonMatch = scanSkeleton(existing, skill);
139
+ if (skeletonMatch && signed) throw new CliError(3, 'lookalike_skeleton_blocked', { match: skeletonMatch });
140
+ const lookalike = scanLookalike(existing, skill) || skeletonMatch; // non-blocking warning (Inv 10/23)
141
+ // I5 — name_history (CLI: registry-derived, dateless). Names this author (same pubkey) has already
142
+ // published in the host's zone, other than the incoming one — surfaces a same-key rename heads-up.
143
+ const priorNames = [...new Set(existing
144
+ .filter((e) => (e.skill || {}).author_pubkey === skill.author_pubkey && (e.skill || {}).name !== skill.name)
145
+ .map((e) => e.skill.name))];
146
+ const prevIdx = existing.findIndex((e) => skillId(e.skill.name, e.skill.author_pubkey) === id);
147
+ const prev = prevIdx >= 0 ? existing[prevIdx] : null;
148
+
149
+ // Same id + byte-identical envelope → already installed, no write.
150
+ if (prev && JSON.stringify(prev) === JSON.stringify(envelope)) {
151
+ return { newDoc: inlineDoc, changed: false, result: { skillId: id, name: skill.name, kind: skill.kind, verified, provenance: 'installed', status: 'already_installed', lookalike, priorNames } };
152
+ }
153
+
154
+ const merged = prev ? existing.map((e, i) => (i === prevIdx ? envelope : e)) : existing.concat([envelope]);
155
+ const z = locateZone(inlineDoc);
156
+ const newDoc = inlineDoc.slice(0, z.start) + buildSkillZone(merged) + inlineDoc.slice(z.end);
157
+
158
+ let update;
159
+ if (prev) {
160
+ const oldP = Array.isArray(prev.skill.permissions) ? prev.skill.permissions : [];
161
+ const newP = Array.isArray(skill.permissions) ? skill.permissions : [];
162
+ const oS = new Set(oldP), nS = new Set(newP);
163
+ update = { isUpdate: true, added: newP.filter((p) => !oS.has(p)), removed: oldP.filter((p) => !nS.has(p)) };
164
+ }
165
+ return { newDoc, changed: true, result: { skillId: id, name: skill.name, kind: skill.kind, verified, provenance: 'installed', status: prev ? 'updated' : 'installed', lookalike, priorNames, ...(update ? { update } : {}) } };
166
+ }
167
+
168
+ /**
169
+ * Read an envelope + a skill-host container, gate + splice + write atomically, re-parse for durability.
170
+ * @returns {Promise<object>} { skillId, name, kind, verified, provenance, status, update? }
171
+ * @throws {CliError} 1 usage_error · 2 file_error · 3 envelope_error
172
+ */
173
+ export async function installSkillFile(envPath, hostPath, { consent } = {}) {
174
+ let envText;
175
+ try { envText = await readFile(envPath, 'utf8'); }
176
+ catch (e) { throw new CliError(2, e && e.code === 'ENOENT' ? 'not_found' : 'read_error', { path: envPath, errno: e && e.code }); }
177
+ let hostBytes;
178
+ try { hostBytes = await readFile(hostPath, 'utf8'); }
179
+ catch (e) { throw new CliError(2, e && e.code === 'ENOENT' ? 'not_found' : 'read_error', { path: hostPath, errno: e && e.code }); }
180
+
181
+ let envelope;
182
+ try { envelope = JSON.parse(envText); }
183
+ catch { throw new CliError(3, 'invalid_json', { path: envPath }); }
184
+
185
+ let inlineDoc;
186
+ try { inlineDoc = extractInlineDoc(hostBytes); }
187
+ catch { throw new CliError(2, 'not_a_rewritable', { path: hostPath }); }
188
+
189
+ // Kind gate — only a skill-host carries the frozen skill zone (mirror of detectProductKind).
190
+ const km = hostBytes.match(/const PRODUCT_KIND = '([^']*)';/);
191
+ const kind = km ? km[1] : null;
192
+ if (kind !== 'skill-host') throw new CliError(2, 'wrong_kind', { kind, expected: 'skill-host' });
193
+
194
+ const { newDoc, changed, result } = installEnvelopeIntoDoc(inlineDoc, envelope, { consent });
195
+ if (changed) {
196
+ await atomicWrite(hostPath, replaceInlineDoc(hostBytes, newDoc));
197
+ // Durability re-parse — the bytes on disk must contain the verified skill (Rule 12).
198
+ let reread;
199
+ try { reread = await readFile(hostPath, 'utf8'); }
200
+ catch (e) { throw new CliError(2, 'install_not_durable', { skillId: result.skillId, message: e && e.message }); }
201
+ if (!parseSkillZone(extractInlineDoc(reread)).some((p) => p.skillId === result.skillId)) {
202
+ throw new CliError(2, 'install_not_durable', { skillId: result.skillId });
203
+ }
204
+ }
205
+ return result;
206
+ }
@@ -60,6 +60,33 @@ export function parsePermission(p) {
60
60
  if (value.length > 64 || !VAULT_NS.test(value)) throw new Error(`invalid vault namespace: ${value}`);
61
61
  return { tier, value };
62
62
  }
63
+ if (tier === 'bus') {
64
+ // §5 (I1): topic 1–96 chars, must start alphanumeric, charset [A-Za-z0-9:_./%-], and NOT
65
+ // a runtime-reserved prefix (rwa_/rwa:/skills:/workspace: are the substrate's own channels).
66
+ if (!value || value.length > 96 || !/^[A-Za-z0-9][A-Za-z0-9:_./%-]*$/.test(value) || /^(?:rwa[:_]|skills:|workspace:)/.test(value))
67
+ throw new Error(`invalid bus topic: ${value}`);
68
+ return { tier, value };
69
+ }
70
+ if (tier === 'fsa') {
71
+ // §6 (I3): relative OPFS scope — lowercase [a-z0-9_/-], start+end alphanumeric/underscore,
72
+ // ≤128 chars, no leading/trailing slash, no '.'/'..' (excluded by charset), not _rwa/-prefixed.
73
+ if (!value || value.length > 128 || /^_rwa(?:\/|$)/.test(value) || !/^[a-z0-9_](?:[a-z0-9_/-]*[a-z0-9_])?$/.test(value))
74
+ throw new Error(`invalid fsa scope: ${value}`);
75
+ return { tier, value };
76
+ }
77
+ if (tier === 'idb') {
78
+ // §7 (I4): store name ^[A-Za-z0-9_][A-Za-z0-9_-]{0,62}$ (≤64 octets, no wildcards); never a
79
+ // reserved rwa_* store, and never the vault store — distinct subcodes so the dialog can explain.
80
+ if (/^rwa_/.test(value)) throw new Error(value === 'rwa_vault' ? 'idb_vault_store_forbidden' : 'idb_reserved_store');
81
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_-]{0,62}$/.test(value)) throw new Error(`invalid idb store: ${value}`);
82
+ return { tier, value };
83
+ }
84
+ if (tier === 'hook') {
85
+ // §9 (I8): lifecycle event, exact-match enum, no wildcards. An UNKNOWN event is treated as an
86
+ // unknown tier (unknown_permission_tier) per the spec, so install rejects it the same way.
87
+ if (value === 'on-commit' || value === 'on-open' || value === 'on-mode-change') return { tier, value };
88
+ throw new Error(`unknown_permission_tier: hook event ${value}`);
89
+ }
63
90
  throw new Error(`unknown_permission_tier: ${tier}`);
64
91
  }
65
92
 
@@ -103,15 +130,35 @@ export function permissionToProse(perm) {
103
130
  if (v === '*') return 'Read and write credentials stored under ANY vault namespace — every credential you have stored. Use only for vault administration.';
104
131
  return `Read and write credentials stored under \`${v}\`.`;
105
132
  }
133
+ if (s.startsWith('bus:')) {
134
+ return `Send and receive messages on the \`${s.slice(4)}\` channel shared with other rewritables on this machine.`;
135
+ }
136
+ if (s.startsWith('fsa:')) {
137
+ return `Read and write files under \`${s.slice(4)}\` in this document's private storage.`;
138
+ }
139
+ if (s.startsWith('idb:')) {
140
+ return `Read and write the \`${s.slice(4)}\` data store in this document's database.`;
141
+ }
142
+ if (s.startsWith('hook:')) {
143
+ const ev = s.slice(5);
144
+ 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;
145
+ return `Run automatically ${when} (no network or credential access).`;
146
+ }
106
147
  return s;
107
148
  }
108
149
 
109
150
  /** §3.7/E — the compound-risk callout when vault + network co-occur, else null. */
110
151
  export function compoundRisk(permissions) {
111
152
  const perms = Array.isArray(permissions) ? permissions : [];
112
- const hasVault = perms.some(p => String(p).startsWith('vault:'));
113
- const hasNetwork = perms.some(p => String(p).startsWith('network:'));
153
+ const has = (t) => perms.some(p => String(p).startsWith(t + ':'));
154
+ const hasVault = has('vault'), hasNetwork = has('network'), hasBus = has('bus'), hasFsa = has('fsa'), hasIdb = has('idb');
114
155
  if (hasVault && hasNetwork) 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.';
156
+ if (hasBus && (hasVault || hasNetwork)) 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.`;
157
+ if ((hasFsa || hasIdb) && (hasNetwork || hasVault || hasBus)) {
158
+ const store = hasFsa ? 'read and write files in this document' : 'read and write this document\'s stored data';
159
+ const sink = hasNetwork ? 'make network requests' : hasVault ? 'read your stored credentials' : 'message other rewritables on this machine';
160
+ return `This skill can ${store} AND ${sink}. Together these let it move your local data off this document — intentionally or by mistake. Install only if you fully trust this author.`;
161
+ }
115
162
  return null;
116
163
  }
117
164
 
@@ -141,6 +188,143 @@ export function levenshtein(a, b) {
141
188
  return prev[n];
142
189
  }
143
190
 
191
+ // I5 (v0.9 §4) — Unicode-confusable skeleton. NFKC + toLowerCase fold case, fullwidth forms,
192
+ // ligatures, and mathematical-alphanumeric letters to ASCII; this baked table folds the
193
+ // CROSS-SCRIPT homoglyphs NFKC leaves alone (Cyrillic, Greek, Armenian, a few Latin-extended).
194
+ // Deliberately CURATED, not the full UTS #39 confusables.txt: every entry maps a non-ASCII
195
+ // glyph that renders ~identically to an ASCII letter. ASCII→ASCII is NEVER folded (so legit
196
+ // distinct names like "tool"/"toml" stay distinct — no false collisions). Extensible: add a row.
197
+ // Keys are post-NFKC-lowercase codepoints. Mirror of the seed's _SK_CONFUSABLES.
198
+ const CONFUSABLES = {
199
+ // Cyrillic → Latin
200
+ 'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c',
201
+ 'у': 'y', 'х': 'x', 'к': 'k', 'ѕ': 's', 'і': 'i',
202
+ 'ј': 'j', 'ԁ': 'd', 'һ': 'h', 'ԛ': 'q', 'ԝ': 'w',
203
+ 'ѵ': 'v', 'ӏ': 'l', 'ɠ': 'g',
204
+ // Greek → Latin
205
+ 'α': 'a', 'ο': 'o', 'ρ': 'p', 'ε': 'e', 'ι': 'i',
206
+ 'κ': 'k', 'ν': 'v', 'υ': 'u', 'χ': 'x', 'τ': 't',
207
+ 'ϲ': 'c', 'ϳ': 'j',
208
+ // Armenian → Latin
209
+ 'օ': 'o', 'ո': 'n',
210
+ // Latin-extended / IPA homoglyphs NFKC leaves alone
211
+ 'ı': 'i', 'ɑ': 'a', 'ɡ': 'g',
212
+ };
213
+
214
+ /** NFKC-fold + lowercase a name before any lookalike comparison (UTS #36). */
215
+ export function normalizeName(s) {
216
+ return String(s == null ? '' : s).normalize('NFKC').toLowerCase();
217
+ }
218
+
219
+ /** Confusable skeleton: normalize, then map each homoglyph to its ASCII prototype.
220
+ * Two names with an equal skeleton render identically to a human (the trust-anchor risk). */
221
+ export function skeleton(s) {
222
+ let out = '';
223
+ for (const ch of normalizeName(s)) out += (CONFUSABLES[ch] || ch);
224
+ return out;
225
+ }
226
+
227
+ /** Edit distance between two names' skeletons. 0 = perfect homoglyph; ≤1 = homoglyph + one typo. */
228
+ export function skeletonDistance(a, b) {
229
+ return levenshtein(skeleton(a), skeleton(b));
230
+ }
231
+
232
+ // ── I12 (v0.9 §12) — rwa-agent/1: a role-scoped, signed agent identity (role + system_prompt +
233
+ // vault_namespace_set; NO code field). Parallels the skill canon; the seed mirrors this logic.
234
+ const ROLE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; // ≤64, lowercase a-z0-9-_, leading alphanumeric
235
+
236
+ /** Canonical agent manifest: stable-key-ordered over the signed fields; excludes the signature. */
237
+ export function canonicalAgent(agent) {
238
+ const a = agent || {};
239
+ return JSON.stringify({
240
+ author_pubkey: a.author_pubkey ?? null,
241
+ description: a.description ?? null,
242
+ role: a.role ?? null,
243
+ system_prompt: a.system_prompt ?? null,
244
+ vault_namespace_set: Array.isArray(a.vault_namespace_set) ? a.vault_namespace_set : [],
245
+ version: a.version ?? null,
246
+ });
247
+ }
248
+
249
+ /** Agent signing message bytes = sha256(canonicalAgent). Agents have no code field. */
250
+ export function agentSigningMessage(agent) {
251
+ return sha256(Buffer.from(enc.encode(canonicalAgent(agent))));
252
+ }
253
+
254
+ /** agentId = base64url(sha256(role ‖ 0x00 ‖ author_pubkey)). Same role from different keys differ. */
255
+ export function agentId(role, authorPubkey) {
256
+ return sha256(Buffer.concat([Buffer.from(enc.encode(String(role))), NUL, Buffer.from(enc.encode(String(authorPubkey)))]))
257
+ .toString('base64url');
258
+ }
259
+
260
+ /** Ed25519 verify over agentSigningMessage. Mirrors verifyEnvelope; the seed uses async WebCrypto. */
261
+ export function verifyAgentEnvelope(envelope) {
262
+ const sig = envelope && envelope.signature;
263
+ if (!sig) return { signed: false, verified: false };
264
+ const agent = envelope.agent || {};
265
+ try {
266
+ const raw = Buffer.from(agent.author_pubkey, 'base64');
267
+ const key = createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
268
+ const verified = edVerify(null, agentSigningMessage(agent), key, Buffer.from(sig, 'base64'));
269
+ return { signed: true, verified: !!verified };
270
+ } catch {
271
+ return { signed: true, verified: false };
272
+ }
273
+ }
274
+
275
+ // A system_prompt is a runtime literal — reject anything that could break out of the template or
276
+ // inject document markers (backtick, ${, <DOC>/<\/DOC>) → agent_prompt_injection_risk.
277
+ function agentPromptInjectionRisk(s) {
278
+ const p = String(s ?? '');
279
+ return p.includes('`') || p.includes('${') || /<\/?DOC>/i.test(p);
280
+ }
281
+
282
+ /** §12 agent install gates. Pure; takes the verification result so it stays synchronous. */
283
+ export function validateAgentInstall(envelope, { signed, verified } = {}) {
284
+ const agent = (envelope && envelope.agent) || {};
285
+ const errors = [];
286
+ // A NUL in the role makes agentId(role‖0x00‖pubkey) ambiguous — reject (mirrors F8).
287
+ if (/\0/.test(String(agent.role == null ? '' : agent.role))) errors.push('invalid_agent_id');
288
+ if (typeof agent.role !== 'string' || !ROLE_RE.test(agent.role)) errors.push('invalid_role');
289
+ // A missing/non-string OR injection-bearing prompt is rejected under the same gate.
290
+ if (typeof agent.system_prompt !== 'string' || agentPromptInjectionRisk(agent.system_prompt)) errors.push('agent_prompt_injection_risk');
291
+ const set = agent.vault_namespace_set;
292
+ if (set != null && !Array.isArray(set)) errors.push('invalid_permission');
293
+ for (const p of (Array.isArray(set) ? set : [])) {
294
+ try {
295
+ if (parsePermission(p).tier !== 'vault') errors.push('invalid_permission'); // vault_namespace_set is vault-only
296
+ } catch (e) {
297
+ errors.push(/unknown_permission_tier/.test(e.message) ? 'unknown_permission_tier' : 'invalid_permission');
298
+ }
299
+ }
300
+ if (!signed) errors.push('unsigned_agent'); // unsigned agents are rejected at install (verified gates activation)
301
+ return { ok: errors.length === 0, errors };
302
+ }
303
+
304
+ // §12 inter-agent bus message: {type:'request'|'response', id, from_role, to_role, payload} on
305
+ // agents:* topics. Data-model only — the request→response choreography (wait/timeout) is the
306
+ // conductor's responsibility, correlated by `id` (the requester's UUID, echoed by the responder).
307
+ export function validateAgentMessage(m) {
308
+ const errors = [];
309
+ m = m || {};
310
+ if (m.type !== 'request' && m.type !== 'response') errors.push('invalid_type');
311
+ if (typeof m.id !== 'string' || !m.id) errors.push('invalid_id');
312
+ if (typeof m.from_role !== 'string' || !ROLE_RE.test(m.from_role)) errors.push('invalid_from_role');
313
+ if (typeof m.to_role !== 'string' || !ROLE_RE.test(m.to_role)) errors.push('invalid_to_role');
314
+ if (!('payload' in m)) errors.push('missing_payload');
315
+ return { ok: errors.length === 0, errors };
316
+ }
317
+
318
+ /** Build (and validate) an inter-agent bus message envelope. Throws invalid_agent_message if the
319
+ * shape is bad. The caller supplies the correlation id (a fresh UUID for a request; the request's
320
+ * id echoed for a response). */
321
+ export function agentMessage(type, fromRole, toRole, payload, id) {
322
+ const m = { type, id, from_role: fromRole, to_role: toRole, payload };
323
+ const v = validateAgentMessage(m);
324
+ if (!v.ok) throw new Error('invalid_agent_message: ' + v.errors.join(','));
325
+ return m;
326
+ }
327
+
144
328
  /** §3.4 install gates. Pure; takes the verification result so it stays synchronous. */
145
329
  export function validateInstall(envelope, { signed, verified } = {}) {
146
330
  const skill = (envelope && envelope.skill) || {};
@@ -153,11 +337,20 @@ export function validateInstall(envelope, { signed, verified } = {}) {
153
337
  if (/\0/.test(String(skill.name == null ? '' : skill.name))) errors.push('invalid_skill_id');
154
338
  for (const p of perms) {
155
339
  try { parsePermission(p); }
156
- catch (e) { errors.push(/unknown_permission_tier/.test(e.message) ? 'unknown_permission_tier' : 'invalid_permission'); }
340
+ catch (e) {
341
+ const m = e.message;
342
+ if (/unknown_permission_tier/.test(m)) errors.push('unknown_permission_tier');
343
+ else if (m === 'idb_reserved_store' || m === 'idb_vault_store_forbidden') errors.push(m); // §7 distinct subcodes
344
+ else errors.push('invalid_permission');
345
+ }
157
346
  }
158
347
  if (skill.kind === 'compute' && perms.length > 0) errors.push('compute_with_permissions');
348
+ // §9 (I8): a hook is compute-only — only hook:<event> perms are allowed; any other tier (a real
349
+ // capability) is rejected as compute_with_permissions (no network/vault/escalation in a hook).
350
+ if (skill.kind === 'hook' && perms.some((p) => { try { return parsePermission(p).tier !== 'hook'; } catch { return false; } })) errors.push('compute_with_permissions');
159
351
  if (!signed && perms.length > 0) errors.push('unsigned_with_permissions');
160
- if (skill.kind === 'tool' && !verified) errors.push('unsigned_capability');
352
+ // Tools AND hooks carry capability (a hook runs autonomously on events) → must be signed+verified.
353
+ if ((skill.kind === 'tool' || skill.kind === 'hook') && !verified) errors.push('unsigned_capability');
161
354
  return { ok: errors.length === 0, errors };
162
355
  }
163
356
 
@@ -225,3 +418,38 @@ export function parseSkillZone(doc) {
225
418
  }
226
419
  return out;
227
420
  }
421
+
422
+ /** Locate the frozen `<div data-rwa-frozen id="rwa-agents">` zone (mirrors extractRwaSkillsZone). */
423
+ function extractRwaAgentsZone(doc) {
424
+ const open = /<div\b[^>]*\bid="rwa-agents"[^>]*>/i.exec(String(doc || ''));
425
+ if (!open || !tagHasFrozenAttr(open[0])) return null;
426
+ const start = open.index + open[0].length;
427
+ const end = doc.indexOf('</div>', start);
428
+ return end < 0 ? null : doc.slice(start, end);
429
+ }
430
+
431
+ /** §12 / SD-04: parse installed agents from the frozen zone, re-verify each signature. Returns
432
+ * [{agentId, kind:'agent', name:role, verified, provenance:'installed'}] — an installed agent is
433
+ * an affordance the container offers (a role you can act under), mirroring parseSkillZone. */
434
+ export function parseAgentZone(doc) {
435
+ const zone = extractRwaAgentsZone(doc);
436
+ if (!zone) return [];
437
+ const blocks = [...zone.matchAll(/<script\s+type="application\/rwa-agent\+json">([\s\S]*?)<\/script>/g)];
438
+ const out = [];
439
+ for (const m of blocks) {
440
+ let envelope;
441
+ try { envelope = JSON.parse(Buffer.from(m[1].trim(), 'base64').toString('utf8')); }
442
+ catch { continue; } // malformed block → skip (never blocks siblings)
443
+ const agent = envelope && envelope.agent;
444
+ if (!agent || typeof agent.role !== 'string') continue;
445
+ const { verified } = verifyAgentEnvelope(envelope);
446
+ out.push({
447
+ agentId: agentId(agent.role, agent.author_pubkey),
448
+ kind: 'agent',
449
+ name: agent.role,
450
+ verified,
451
+ provenance: 'installed',
452
+ });
453
+ }
454
+ return out;
455
+ }