rewritable 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/rwa.mjs +66 -0
- package/package.json +1 -1
- package/seeds/rewritable.html +619 -18
- package/src/identity.mjs +3 -2
- package/src/install.mjs +206 -0
- package/src/skill-manifest.mjs +232 -4
package/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,
|
package/src/install.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/skill-manifest.mjs
CHANGED
|
@@ -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
|
|
113
|
-
const hasNetwork =
|
|
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) {
|
|
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
|
-
|
|
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
|
+
}
|