rewritable 0.3.0 → 0.5.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.
@@ -0,0 +1,115 @@
1
+ // Self-containment guard for `rwa create` output (design 2026-05-31 §4.5).
2
+ //
3
+ // A created rewritable must open and run with ZERO external RUNTIME dependencies
4
+ // (Invariant 1 — "send the file, they have everything"). The create-path prompt
5
+ // forbids runtime CDN references; this is the code-level tripwire behind that
6
+ // prompt (defense in depth, Rule 5): the model can't ship a CDN tag even if it
7
+ // ignores the instruction.
8
+ //
9
+ // ALLOWLIST, not a scheme-denylist (a denylist misses protocol-relative //host
10
+ // and unknown schemes). A URL is self-contained iff it is one of:
11
+ // • empty / a #fragment (no resource)
12
+ // • data:… (inlined bytes)
13
+ // • an authority-less relative path (no scheme, no leading //)
14
+ // • mailto: / tel: (non-fetching; a click handler, not a load)
15
+ // Everything else — http(s), protocol-relative //, ftp/ws/blob/javascript, any
16
+ // other scheme — triggers (or implies) an external load and is rejected.
17
+ //
18
+ // SCOPE (honest, Rule 12): a STATIC markup/CSS scan. It covers the attribute and
19
+ // CSS fetch surface enumerated below; it does NOT inspect inline-JS runtime calls
20
+ // (fetch()/XHR/import()/new Image().src). Those remain prompt-governed for v1 —
21
+ // named here, not silently passed.
22
+
23
+ import { CliError } from './edit.mjs';
24
+
25
+ // Schemes that name an action handler rather than a resource load. A link to one
26
+ // of these does not fetch bytes when the file opens, so it does not break
27
+ // self-containment.
28
+ const NON_FETCHING_SCHEMES = new Set(['mailto', 'tel']);
29
+
30
+ /**
31
+ * Is this attribute/CSS value an external runtime fetch?
32
+ * @param {string} raw — a single URL value (already unquoted/trimmed by the caller)
33
+ * @returns {boolean}
34
+ */
35
+ function isExternalFetch(raw) {
36
+ const s = String(raw == null ? '' : raw).trim();
37
+ if (s === '' || s.startsWith('#')) return false; // no resource
38
+ if (/^data:/i.test(s)) return false; // inlined bytes
39
+ if (s.startsWith('//')) return true; // protocol-relative → network
40
+ const m = s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/); // leading scheme?
41
+ if (!m) return false; // authority-less relative → local
42
+ return !NON_FETCHING_SCHEMES.has(m[1].toLowerCase());
43
+ }
44
+
45
+ // URL-bearing HTML attributes. `data` is the <object data=> URL attribute; the
46
+ // (?<![-\w]) lookbehind keeps it from matching data-* custom attributes (and keeps
47
+ // `href`/`src` from matching inside longer names). srcset is handled separately
48
+ // because its value is a comma-separated "url descriptor" list, not a bare URL.
49
+ const URL_ATTR_RE = /(?<![-\w])(src|href|xlink:href|poster|data)\s*=\s*("([^"]*)"|'([^']*)')/gi;
50
+ const SRCSET_RE = /(?<![-\w])srcset\s*=\s*("([^"]*)"|'([^']*)')/gi;
51
+ // CSS url(...) — bare, single-, or double-quoted — anywhere (inline <style> or style=).
52
+ const CSS_URL_RE = /url\(\s*("([^"]*)"|'([^']*)'|([^)'"]*))\s*\)/gi;
53
+ // CSS @import "x" / @import 'x' (the @import url(...) form is caught by CSS_URL_RE).
54
+ const CSS_IMPORT_RE = /@import\s+("([^"]*)"|'([^']*)')/gi;
55
+
56
+ /**
57
+ * Find every external runtime reference in an HTML body. Pure; never throws.
58
+ * @param {string} html — the document body (INLINE_DOC text)
59
+ * @returns {Array<{url: string, kind: string}>} one entry per external ref
60
+ */
61
+ export function findExternalRefs(html) {
62
+ const text = String(html == null ? '' : html);
63
+ const refs = [];
64
+ let m;
65
+
66
+ URL_ATTR_RE.lastIndex = 0;
67
+ while ((m = URL_ATTR_RE.exec(text))) {
68
+ const val = m[3] != null ? m[3] : m[4];
69
+ if (isExternalFetch(val)) refs.push({ url: val.trim(), kind: `attr:${m[1].toLowerCase()}` });
70
+ }
71
+
72
+ SRCSET_RE.lastIndex = 0;
73
+ while ((m = SRCSET_RE.exec(text))) {
74
+ // SRCSET_RE has no leading name group, so the quoted-value inner groups are
75
+ // m[2] (double) / m[3] (single) — unlike URL_ATTR_RE which is shifted by +1.
76
+ const list = (m[2] != null ? m[2] : m[3]) || '';
77
+ // Each candidate is "url [descriptor]"; the URL is the first whitespace-delimited token.
78
+ for (const entry of list.split(',')) {
79
+ const url = entry.trim().split(/\s+/)[0];
80
+ if (isExternalFetch(url)) refs.push({ url, kind: 'attr:srcset' });
81
+ }
82
+ }
83
+
84
+ CSS_URL_RE.lastIndex = 0;
85
+ while ((m = CSS_URL_RE.exec(text))) {
86
+ const val = m[2] != null ? m[2] : (m[3] != null ? m[3] : m[4]);
87
+ if (isExternalFetch(val)) refs.push({ url: val.trim(), kind: 'css:url' });
88
+ }
89
+
90
+ CSS_IMPORT_RE.lastIndex = 0;
91
+ while ((m = CSS_IMPORT_RE.exec(text))) {
92
+ // Same group layout as SRCSET_RE: no name group, so inner = m[2]/m[3].
93
+ const val = m[2] != null ? m[2] : m[3];
94
+ if (isExternalFetch(val)) refs.push({ url: val.trim(), kind: 'css:import' });
95
+ }
96
+
97
+ return refs;
98
+ }
99
+
100
+ /**
101
+ * Assert that an HTML body is self-contained. No-op on clean input; on any
102
+ * external runtime reference, throws CliError(4, 'not_self_contained') so the
103
+ * CLI surfaces exit code 4 (agent) — the created artifact is never written.
104
+ * @param {string} html — the document body to check
105
+ * @throws {CliError} exitCode 4 / subcode 'not_self_contained'
106
+ */
107
+ export function assertSelfContained(html) {
108
+ const refs = findExternalRefs(html);
109
+ if (refs.length === 0) return;
110
+ throw new CliError(4, 'not_self_contained', {
111
+ count: refs.length,
112
+ refs: refs.slice(0, 10), // cap the detail payload; the count is exact
113
+ reason: 'created artifact must have no external runtime dependencies (no CDN/remote src/href, @import, url(), or srcset)',
114
+ });
115
+ }
@@ -0,0 +1,227 @@
1
+ // Foundational skill-manifest logic for the v0.8 skill layer.
2
+ // Spec: docs/specs/re-write-able-actions-spec-v0.8.md §3 (skillId, signature, install gates), §4 (permission grammar), §8 (parseSkillZone).
3
+ // SYNCHRONOUS (node:crypto) so it slots into the sync self-description projection without rippling
4
+ // async through the deep-equal-pinned 4-site mirror. The seed mirrors this LOGIC with async WebCrypto,
5
+ // caching `verified` at boot so its sync describe() reports the cached result. No external deps.
6
+ import { createHash, createPublicKey, verify as edVerify } from 'node:crypto';
7
+
8
+ const enc = new TextEncoder();
9
+ const NUL = Buffer.from([0]);
10
+ // SPKI DER prefix for an Ed25519 public key (wraps a raw 32-byte key into a KeyObject-importable form).
11
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
12
+
13
+ function sha256(buf) {
14
+ return createHash('sha256').update(buf).digest();
15
+ }
16
+
17
+ /** §3.2 skillId = base64url(sha256(name ‖ 0x00 ‖ author_pubkey)). */
18
+ export function skillId(name, authorPubkey) {
19
+ return sha256(Buffer.concat([Buffer.from(enc.encode(String(name))), NUL, Buffer.from(enc.encode(String(authorPubkey)))]))
20
+ .toString('base64url');
21
+ }
22
+
23
+ /** §3.3 canonical manifest: stable-key-ordered over the signed fields; excludes signature + code. */
24
+ export function canonicalManifest(manifest) {
25
+ const m = manifest || {};
26
+ return JSON.stringify({
27
+ author_pubkey: m.author_pubkey ?? null,
28
+ kind: m.kind ?? null,
29
+ name: m.name ?? null,
30
+ permissions: Array.isArray(m.permissions) ? m.permissions : [],
31
+ version: m.version ?? null,
32
+ });
33
+ }
34
+
35
+ /** §3.3 signing message bytes = sha256(canonicalManifest ‖ 0x00 ‖ code). */
36
+ export function signingMessage(manifest, code) {
37
+ return sha256(Buffer.concat([Buffer.from(enc.encode(canonicalManifest(manifest))), NUL, Buffer.from(enc.encode(String(code ?? '')))]));
38
+ }
39
+
40
+ const VAULT_NS = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
41
+
42
+ /** §4 permission grammar — the two shipped tiers (network:, vault:). Throws on invalid/unknown. */
43
+ export function parsePermission(p) {
44
+ const s = String(p);
45
+ const i = s.indexOf(':');
46
+ if (i < 0) throw new Error(`invalid permission (no tier): ${s}`);
47
+ const tier = s.slice(0, i);
48
+ const value = s.slice(i + 1);
49
+ if (tier === 'network') {
50
+ if (value === '*') return { tier, value };
51
+ if (value.startsWith('**.') || value.startsWith('*.')) {
52
+ if (value.slice(value.indexOf('.') + 1).includes('*')) throw new Error(`invalid network pattern: ${value}`);
53
+ return { tier, value };
54
+ }
55
+ if (value.includes('*')) throw new Error(`invalid network pattern (left-unanchored wildcard): ${value}`);
56
+ if (!value) throw new Error('invalid network pattern (empty)');
57
+ return { tier, value };
58
+ }
59
+ if (tier === 'vault') {
60
+ if (value.length > 64 || !VAULT_NS.test(value)) throw new Error(`invalid vault namespace: ${value}`);
61
+ return { tier, value };
62
+ }
63
+ throw new Error(`unknown_permission_tier: ${tier}`);
64
+ }
65
+
66
+ /** §4/§5a — does a `network:` host pattern admit a request host? The bridge's per-call
67
+ * enforcement (mirrored verbatim in the seed). Left-anchored: `*.` = one label, `**.` =
68
+ * base + any depth, `*` = catch-all, else exact. Validate the pattern with parsePermission first. */
69
+ export function matchNetworkOrigin(pattern, host) {
70
+ if (pattern === '*') return true;
71
+ if (pattern.startsWith('**.')) {
72
+ const base = pattern.slice(3);
73
+ return host === base || host.endsWith('.' + base);
74
+ }
75
+ if (pattern.startsWith('*.')) {
76
+ const label = pattern.slice(2);
77
+ if (!host.endsWith('.' + label)) return false;
78
+ const prefix = host.slice(0, host.length - label.length - 1);
79
+ return prefix.length > 0 && !prefix.includes('.'); // exactly one label
80
+ }
81
+ return host === pattern;
82
+ }
83
+
84
+ /** §6 — does a skill's permission set grant a vault namespace? Exact vault:<ns> match.
85
+ * Pure; the bridge's per-call vault gate (mirrored in the seed). */
86
+ export function vaultNamespaceAllowed(permissions, ns) {
87
+ const perms = Array.isArray(permissions) ? permissions : [];
88
+ return perms.indexOf('vault:' + ns) !== -1;
89
+ }
90
+
91
+ /** §1/§3 — render one permission as plain-English dialog prose (the trust-anchor content). */
92
+ export function permissionToProse(perm) {
93
+ const s = String(perm);
94
+ if (s.startsWith('network:')) {
95
+ const v = s.slice(8);
96
+ if (v === '*') return 'Make network requests to ANY domain on the internet — the runtime cannot tell you where this skill sends data. Review the code carefully.';
97
+ if (v.startsWith('**.')) return `Make network requests to ${v.slice(3)} and any subdomain at any depth — broad; review whether the skill needs this.`;
98
+ if (v.startsWith('*.')) return `Make network requests to any direct subdomain of ${v.slice(2)} (such as api.${v.slice(2)}).`;
99
+ return `Make network requests to ${v}.`;
100
+ }
101
+ if (s.startsWith('vault:')) {
102
+ const v = s.slice(6);
103
+ if (v === '*') return 'Read and write credentials stored under ANY vault namespace — every credential you have stored. Use only for vault administration.';
104
+ return `Read and write credentials stored under \`${v}\`.`;
105
+ }
106
+ return s;
107
+ }
108
+
109
+ /** §3.7/E — the compound-risk callout when vault + network co-occur, else null. */
110
+ export function compoundRisk(permissions) {
111
+ 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:'));
114
+ 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.';
115
+ return null;
116
+ }
117
+
118
+ /** §3/§4.1 — advisory capability-scan notes (NEVER an auto-reject; structural enforcement is the wall). */
119
+ export function capabilityScan(code) {
120
+ const c = String(code || '');
121
+ const notes = [];
122
+ if (/\beval\s*\(/.test(c)) notes.push('Uses eval() — dynamic code execution. Review what is being evaluated.');
123
+ if (/\bFunction\s*\(/.test(c)) notes.push('Uses the Function constructor — dynamic code execution. Review what is being constructed.');
124
+ if (/\b(setTimeout|setInterval)\s*\(\s*['"`]/.test(c)) notes.push('Calls setTimeout/setInterval with a string argument — review what is being scheduled.');
125
+ if (/\b(globalThis|self|window)\s*\[/.test(c)) notes.push('Uses dynamic property indexing on a global — can reach APIs the permission manifest does not constrain. Review the code.');
126
+ if (/\bimport\s*\(/.test(c)) notes.push('Uses dynamic import() — can load remote code and reach the network outside the permission manifest.');
127
+ return notes;
128
+ }
129
+
130
+ /** Levenshtein edit distance (Wagner-Fischer) — for §2.3 lookalike-source detection. */
131
+ export function levenshtein(a, b) {
132
+ a = String(a); b = String(b);
133
+ const m = a.length, n = b.length;
134
+ if (!m) return n; if (!n) return m;
135
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
136
+ for (let i = 1; i <= m; i++) {
137
+ const cur = [i];
138
+ 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));
139
+ prev = cur;
140
+ }
141
+ return prev[n];
142
+ }
143
+
144
+ /** §3.4 install gates. Pure; takes the verification result so it stays synchronous. */
145
+ export function validateInstall(envelope, { signed, verified } = {}) {
146
+ const skill = (envelope && envelope.skill) || {};
147
+ const perms = Array.isArray(skill.permissions) ? skill.permissions : [];
148
+ const errors = [];
149
+ // F9: reject a non-array permissions field rather than silently coercing to []
150
+ // (the signing canon would normalize it to [] → confused-deputy signature).
151
+ if (skill.permissions != null && !Array.isArray(skill.permissions)) errors.push('invalid_permission');
152
+ // F8: a NUL in the name makes skillId(name‖0x00‖pubkey) ambiguous — reject it.
153
+ if (/\0/.test(String(skill.name == null ? '' : skill.name))) errors.push('invalid_skill_id');
154
+ for (const p of perms) {
155
+ try { parsePermission(p); }
156
+ catch (e) { errors.push(/unknown_permission_tier/.test(e.message) ? 'unknown_permission_tier' : 'invalid_permission'); }
157
+ }
158
+ if (skill.kind === 'compute' && perms.length > 0) errors.push('compute_with_permissions');
159
+ if (!signed && perms.length > 0) errors.push('unsigned_with_permissions');
160
+ if (skill.kind === 'tool' && !verified) errors.push('unsigned_capability');
161
+ return { ok: errors.length === 0, errors };
162
+ }
163
+
164
+ /** §3.3 signature verification — Ed25519 over signingMessage(manifest‖code). Sync (node:crypto).
165
+ * Seed mirror uses async WebCrypto Ed25519 over the identical message; result is the same boolean. */
166
+ export function verifyEnvelope(envelope) {
167
+ const sig = envelope && envelope.signature;
168
+ if (!sig) return { signed: false, verified: false };
169
+ const skill = envelope.skill || {};
170
+ try {
171
+ const raw = Buffer.from(skill.author_pubkey, 'base64');
172
+ const key = createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
173
+ const verified = edVerify(null, signingMessage(skill, skill.code), key, Buffer.from(sig, 'base64'));
174
+ return { signed: true, verified: !!verified };
175
+ } catch {
176
+ return { signed: true, verified: false };
177
+ }
178
+ }
179
+
180
+ /** Does an open tag carry `data-rwa-frozen` as a real attribute NAME (not a substring like
181
+ * data-rwa-frozen-note= or class="…data-rwa-frozen")? Mirrors the seed's tagHasFrozenAttr —
182
+ * trust-read MUST match the write-time frozen guard or a lookalike attribute forges trust. */
183
+ function tagHasFrozenAttr(openTag) {
184
+ const am = /^<[a-zA-Z][a-zA-Z0-9]*((?:\s[^>]*)?)\/?>$/.exec(openTag);
185
+ if (!am) return false;
186
+ const attrRe = /([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/g;
187
+ let a;
188
+ while ((a = attrRe.exec(am[1])) !== null) if (a[1] === 'data-rwa-frozen') return true;
189
+ return false;
190
+ }
191
+
192
+ /** Locate the inner HTML of the agent-unreachable `<div data-rwa-frozen id="rwa-skills">` zone.
193
+ * Only this zone is trusted (§8): a skill <script> elsewhere in the editable doc is ignored.
194
+ * STRICT data-rwa-frozen attribute-name check (not substring) so a lookalike cannot forge trust.
195
+ * Safe with a flat scan because envelopes are base64 (no </div> in the content). */
196
+ function extractRwaSkillsZone(doc) {
197
+ const open = /<div\b[^>]*\bid="rwa-skills"[^>]*>/i.exec(String(doc || ''));
198
+ if (!open || !tagHasFrozenAttr(open[0])) return null;
199
+ const start = open.index + open[0].length;
200
+ const end = doc.indexOf('</div>', start);
201
+ return end < 0 ? null : doc.slice(start, end);
202
+ }
203
+
204
+ /** §8 static projection: parse installed skills from the frozen zone, re-verify each signature.
205
+ * Each block is base64(JSON(envelope)). Returns [{skillId,kind,name,verified,provenance:'installed'}]. */
206
+ export function parseSkillZone(doc) {
207
+ const zone = extractRwaSkillsZone(doc);
208
+ if (!zone) return [];
209
+ const blocks = [...zone.matchAll(/<script\s+type="application\/rwa-skill\+json">([\s\S]*?)<\/script>/g)];
210
+ const out = [];
211
+ for (const m of blocks) {
212
+ let envelope;
213
+ try { envelope = JSON.parse(Buffer.from(m[1].trim(), 'base64').toString('utf8')); }
214
+ catch { continue; } // malformed block → skip (never blocks siblings)
215
+ const skill = envelope && envelope.skill;
216
+ if (!skill || typeof skill.name !== 'string') continue;
217
+ const { verified } = verifyEnvelope(envelope);
218
+ out.push({
219
+ skillId: skillId(skill.name, skill.author_pubkey),
220
+ kind: skill.kind,
221
+ name: skill.name,
222
+ verified,
223
+ provenance: 'installed',
224
+ });
225
+ }
226
+ return out;
227
+ }