yay-layer 1.0.0-rc.1
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/CONSTITUTION.md +55 -0
- package/LICENSE +21 -0
- package/README.md +383 -0
- package/bin/yay.js +3550 -0
- package/package.json +55 -0
- package/src/adopt.js +181 -0
- package/src/adversary.js +119 -0
- package/src/analyze.js +270 -0
- package/src/assurance.js +122 -0
- package/src/attest.js +216 -0
- package/src/capability.js +59 -0
- package/src/constitution.js +162 -0
- package/src/coverage.js +77 -0
- package/src/crypto.js +78 -0
- package/src/dashboard.js +463 -0
- package/src/durable.js +152 -0
- package/src/e2e.js +67 -0
- package/src/extract.js +179 -0
- package/src/foundation.js +143 -0
- package/src/gate.js +252 -0
- package/src/grants.js +249 -0
- package/src/history.js +77 -0
- package/src/ids.js +40 -0
- package/src/manifest.js +303 -0
- package/src/map.js +1742 -0
- package/src/mutate.js +192 -0
- package/src/objects.js +31 -0
- package/src/phone.js +188 -0
- package/src/plan.js +141 -0
- package/src/policy.js +0 -0
- package/src/predicate.js +143 -0
- package/src/prove.js +876 -0
- package/src/ratify.js +28 -0
- package/src/record.js +30 -0
- package/src/reverify.js +212 -0
- package/src/roster.js +117 -0
- package/src/signer-page.js +603 -0
- package/src/specdiff.js +69 -0
- package/src/tags.js +67 -0
- package/src/testrun.js +41 -0
- package/src/util.js +234 -0
- package/src/vendor/recovery.js +217 -0
- package/src/vendor/tweetnacl.min.js +1 -0
- package/src/verify.js +560 -0
- package/standard/STANDARD.md +135 -0
package/src/mutate.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Generate small, systematic corruptions ("mutants") of a unit's source, to grade
|
|
3
|
+
// how strong its `ensures` is. Each mutant is a copy of the source with ONE edit.
|
|
4
|
+
// We locate edit sites via the AST (@babel/parser) but splice the source string
|
|
5
|
+
// directly, so no code-generator dependency is needed.
|
|
6
|
+
|
|
7
|
+
const babel = require('@babel/parser');
|
|
8
|
+
const PLUGINS = ['estree', 'typescript', 'jsx', 'decorators-legacy'];
|
|
9
|
+
|
|
10
|
+
// operator → what we flip it to (each flip is a distinct bug class)
|
|
11
|
+
const ARITH = { '+': '-', '-': '+', '*': '/', '/': '*', '%': '*' };
|
|
12
|
+
const CMP = { '<': '>=', '>': '<=', '<=': '>', '>=': '<' };
|
|
13
|
+
const EQ = { '==': '!=', '!=': '==', '===': '!==', '!==': '===' };
|
|
14
|
+
const LOGIC = { '&&': '||', '||': '&&' };
|
|
15
|
+
|
|
16
|
+
function parse(src) {
|
|
17
|
+
try {
|
|
18
|
+
return babel.parse(src, {
|
|
19
|
+
sourceType: 'unambiguous', errorRecovery: true,
|
|
20
|
+
allowReturnOutsideFunction: true, allowImportExportEverywhere: true, plugins: PLUGINS,
|
|
21
|
+
}).program;
|
|
22
|
+
} catch (_) { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mutants(src, cap = 30) {
|
|
26
|
+
const ast = parse(src);
|
|
27
|
+
if (!ast) return [];
|
|
28
|
+
const out = [];
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
const add = (start, end, rep, op) => {
|
|
31
|
+
if (start == null || end == null || start < 0 || end > src.length) return;
|
|
32
|
+
const key = start + ':' + end + ':' + rep;
|
|
33
|
+
if (seen.has(key)) return; seen.add(key);
|
|
34
|
+
out.push({ code: src.slice(0, start) + rep + src.slice(end), op });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
(function walk(node) {
|
|
38
|
+
if (!node || typeof node.type !== 'string') return;
|
|
39
|
+
const v = node.value;
|
|
40
|
+
|
|
41
|
+
if ((node.type === 'BinaryExpression' || node.type === 'LogicalExpression') && node.left && node.right) {
|
|
42
|
+
const o = node.operator;
|
|
43
|
+
const map = (o in ARITH) ? ARITH : (o in CMP) ? CMP : (o in EQ) ? EQ : (o in LOGIC) ? LOGIC : null;
|
|
44
|
+
if (map && map[o] != null && node.left.end != null && node.right.start != null) {
|
|
45
|
+
const gap = src.slice(node.left.end, node.right.start);
|
|
46
|
+
const i = gap.indexOf(o);
|
|
47
|
+
if (i >= 0) { const s = node.left.end + i; add(s, s + o.length, map[o], `${o} → ${map[o]}`); }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// literals (estree: Literal; babel: Numeric/String/BooleanLiteral)
|
|
51
|
+
if (node.type === 'Literal' || node.type === 'NumericLiteral' || node.type === 'StringLiteral' || node.type === 'BooleanLiteral') {
|
|
52
|
+
if (typeof v === 'number') add(node.start, node.end, String(v + 1), `num ${v} → ${v + 1}`);
|
|
53
|
+
else if (typeof v === 'string') add(node.start, node.end, JSON.stringify(v + 'X'), 'string +"X"');
|
|
54
|
+
else if (typeof v === 'boolean') add(node.start, node.end, String(!v), `bool → ${!v}`);
|
|
55
|
+
}
|
|
56
|
+
// template literal static parts: /pfps/ → /pfps/X
|
|
57
|
+
if (node.type === 'TemplateElement' && node.value && node.value.raw && node.start != null) {
|
|
58
|
+
add(node.start, node.end, src.slice(node.start, node.end) + 'X', 'template +"X"');
|
|
59
|
+
}
|
|
60
|
+
if (node.type === 'ReturnStatement' && node.argument && node.argument.start != null) {
|
|
61
|
+
add(node.argument.start, node.argument.end, 'null', 'return → null');
|
|
62
|
+
}
|
|
63
|
+
if (node.type === 'UnaryExpression' && node.operator === '!' && node.argument && node.argument.start != null) {
|
|
64
|
+
add(node.start, node.argument.start, '', 'drop !');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const k of Object.keys(node)) {
|
|
68
|
+
if (k === 'loc' || k === 'start' || k === 'end' || k === 'range') continue;
|
|
69
|
+
const c = node[k];
|
|
70
|
+
if (Array.isArray(c)) c.forEach(walk);
|
|
71
|
+
else if (c && typeof c.type === 'string') walk(c);
|
|
72
|
+
}
|
|
73
|
+
})(ast);
|
|
74
|
+
|
|
75
|
+
return out.slice(0, cap);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── deletion candidates (for the INERTNESS check) ───────────────────────────
|
|
79
|
+
// The dual of mutation: instead of corrupting code and asking "does the ensures
|
|
80
|
+
// notice?", we DELETE a branch and ask "does anything notice?". A branch that can be
|
|
81
|
+
// removed with every spec-derived test still passing is semantically inert under the
|
|
82
|
+
// promise — dead weight, ahead-of-spec scaffolding, or a dormant payload. Each entry
|
|
83
|
+
// is one candidate: { code, desc, line, removed } where `removed` is the deleted
|
|
84
|
+
// source (used for the throws:-guard exemption).
|
|
85
|
+
function lineOf(src, idx) { let n = 1; for (let i = 0; i < idx && i < src.length; i++) if (src[i] === '\n') n++; return n; }
|
|
86
|
+
function deletions(src, cap = 20) {
|
|
87
|
+
const ast = parse(src);
|
|
88
|
+
if (!ast) return [];
|
|
89
|
+
const out = [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const add = (start, end, rep, desc) => {
|
|
92
|
+
if (start == null || end == null || start < 0 || end > src.length || start >= end) return;
|
|
93
|
+
const key = start + ':' + end + ':' + rep;
|
|
94
|
+
if (seen.has(key)) return; seen.add(key);
|
|
95
|
+
out.push({ code: src.slice(0, start) + rep + src.slice(end), desc, line: lineOf(src, start), removed: src.slice(start, end) });
|
|
96
|
+
};
|
|
97
|
+
(function walk(node) {
|
|
98
|
+
if (!node || typeof node.type !== 'string') return;
|
|
99
|
+
if (node.type === 'IfStatement' && node.start != null) {
|
|
100
|
+
if (!node.alternate) {
|
|
101
|
+
add(node.start, node.end, '', 'delete if-branch'); // guard / early-return / dormant gate
|
|
102
|
+
} else {
|
|
103
|
+
// keep else-body only (drop the test + consequent), and keep if-only (drop else)
|
|
104
|
+
if (node.alternate.start != null) add(node.start, node.alternate.start, '', 'delete if-arm (keep else)');
|
|
105
|
+
if (node.consequent && node.consequent.end != null) add(node.consequent.end, node.end, '', 'delete else-arm');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (node.type === 'ConditionalExpression' && node.test && node.consequent && node.alternate) {
|
|
109
|
+
// t ? a : b → a and → b (removes the condition's influence entirely)
|
|
110
|
+
const a = src.slice(node.consequent.start, node.consequent.end);
|
|
111
|
+
const b = src.slice(node.alternate.start, node.alternate.end);
|
|
112
|
+
add(node.start, node.end, a, 'ternary → then-value');
|
|
113
|
+
add(node.start, node.end, b, 'ternary → else-value');
|
|
114
|
+
}
|
|
115
|
+
// `cond && doThing()` / `cond || doThing()` as a bare statement — a gated action
|
|
116
|
+
if (node.type === 'ExpressionStatement' && node.expression && node.expression.type === 'LogicalExpression') {
|
|
117
|
+
add(node.start, node.end, '', 'delete gated statement');
|
|
118
|
+
}
|
|
119
|
+
for (const k of Object.keys(node)) {
|
|
120
|
+
if (k === 'loc' || k === 'start' || k === 'end' || k === 'range') continue;
|
|
121
|
+
const c = node[k];
|
|
122
|
+
if (Array.isArray(c)) c.forEach(walk);
|
|
123
|
+
else if (c && typeof c.type === 'string') walk(c);
|
|
124
|
+
}
|
|
125
|
+
})(ast);
|
|
126
|
+
return out.slice(0, cap);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── literal harvesting (for LITERAL-SEEDED TRIGGER HUNTING) ──────────────────
|
|
130
|
+
// A dormant gate compares an input against a magic constant it hopes the tests never
|
|
131
|
+
// produce (`if (s === 'xK9!')`, `if (items.length === 9 && items[0].price === 4.44)`).
|
|
132
|
+
// Harvest those constants so the prover can feed them back in and trigger the branch.
|
|
133
|
+
// Returns [{ root, kind, key, value }] — root = the base param identifier; kind =
|
|
134
|
+
// 'eq' (param === lit) | 'prop' (param.KEY === lit) | 'length' (param.length === N) |
|
|
135
|
+
// 'index-prop' (param[I].KEY === lit). Code-derived → a RED-ONLY hunt lane (never a pass).
|
|
136
|
+
function litValue(node) {
|
|
137
|
+
if (!node) return { has: false };
|
|
138
|
+
if (node.type === 'Literal' && (typeof node.value === 'string' || typeof node.value === 'number' || typeof node.value === 'boolean')) return { has: true, value: node.value };
|
|
139
|
+
if (node.type === 'StringLiteral' || node.type === 'NumericLiteral' || node.type === 'BooleanLiteral') return { has: true, value: node.value };
|
|
140
|
+
if (node.type === 'UnaryExpression' && node.operator === '-' && node.argument && (node.argument.type === 'NumericLiteral' || node.argument.type === 'Literal') && typeof node.argument.value === 'number') return { has: true, value: -node.argument.value };
|
|
141
|
+
return { has: false };
|
|
142
|
+
}
|
|
143
|
+
function pathOf(node) {
|
|
144
|
+
if (!node) return null;
|
|
145
|
+
if (node.type === 'Identifier') return { root: node.name, kind: 'eq', key: null };
|
|
146
|
+
if (node.type === 'MemberExpression' && !node.computed && node.property && node.property.type === 'Identifier') {
|
|
147
|
+
const base = node.object;
|
|
148
|
+
if (base.type === 'Identifier') {
|
|
149
|
+
return node.property.name === 'length'
|
|
150
|
+
? { root: base.name, kind: 'length', key: 'length' }
|
|
151
|
+
: { root: base.name, kind: 'prop', key: node.property.name };
|
|
152
|
+
}
|
|
153
|
+
if (base.type === 'MemberExpression' && base.computed && base.object && base.object.type === 'Identifier'
|
|
154
|
+
&& base.property && (base.property.type === 'NumericLiteral' || base.property.type === 'Literal') && typeof base.property.value === 'number') {
|
|
155
|
+
return { root: base.object.name, kind: 'index-prop', key: { index: base.property.value, prop: node.property.name } };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function harvestLiterals(src, cap = 30) {
|
|
161
|
+
const ast = parse(src);
|
|
162
|
+
if (!ast) return [];
|
|
163
|
+
const out = [];
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
const add = (p, value) => {
|
|
166
|
+
if (!p) return;
|
|
167
|
+
const k = p.root + '|' + p.kind + '|' + JSON.stringify(p.key) + '|' + JSON.stringify(value);
|
|
168
|
+
if (seen.has(k)) return; seen.add(k);
|
|
169
|
+
out.push({ root: p.root, kind: p.kind, key: p.key, value });
|
|
170
|
+
};
|
|
171
|
+
(function walk(node) {
|
|
172
|
+
if (!node || typeof node.type !== 'string') return;
|
|
173
|
+
if (node.type === 'BinaryExpression' && /^(===|==|!==|!=)$/.test(node.operator)) {
|
|
174
|
+
let p = pathOf(node.left), l = litValue(node.right);
|
|
175
|
+
if (!p || !l.has) { p = pathOf(node.right); l = litValue(node.left); }
|
|
176
|
+
if (p && l.has) add(p, l.value);
|
|
177
|
+
}
|
|
178
|
+
if (node.type === 'SwitchStatement' && node.discriminant) {
|
|
179
|
+
const p = pathOf(node.discriminant);
|
|
180
|
+
if (p) for (const c of node.cases || []) { const l = litValue(c.test); if (l.has) add(p, l.value); }
|
|
181
|
+
}
|
|
182
|
+
for (const k of Object.keys(node)) {
|
|
183
|
+
if (k === 'loc' || k === 'start' || k === 'end' || k === 'range') continue;
|
|
184
|
+
const c = node[k];
|
|
185
|
+
if (Array.isArray(c)) c.forEach(walk);
|
|
186
|
+
else if (c && typeof c.type === 'string') walk(c);
|
|
187
|
+
}
|
|
188
|
+
})(ast);
|
|
189
|
+
return out.slice(0, cap);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { mutants, deletions, harvestLiterals };
|
package/src/objects.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Append-only, content-addressed store in `.yaylayer/objects/` — the audit core that must never
|
|
3
|
+
// blank. Objects are keyed by their own sha256 (hex), laid out git-style as objects/<first2>/<rest>,
|
|
4
|
+
// deduped (identical content stored once). Spec blocks are archived here at sign time (keyed by
|
|
5
|
+
// their specHash), so "as signed" reconstruction NEVER depends on git surviving. Committed with
|
|
6
|
+
// the repo. (P2 will store signed verification attestations here too; P4 adds the Durable code
|
|
7
|
+
// archive alongside.) Small + tiny volume, so no packing/GC needed — that's why it's bespoke here
|
|
8
|
+
// while bulk code archival (Durable) leans on git.
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { sha256 } = require('./crypto');
|
|
12
|
+
|
|
13
|
+
const YAY_DIR = '.yaylayer';
|
|
14
|
+
function objRoot(root) { return path.join(root, YAY_DIR, 'objects'); }
|
|
15
|
+
function objPath(root, hash) { const h = String(hash); return path.join(objRoot(root), h.slice(0, 2), h.slice(2)); }
|
|
16
|
+
|
|
17
|
+
// Store `content`, keyed by its sha256; returns the hash. Idempotent (write-once). Best-effort:
|
|
18
|
+
// the seal in lock.json remains the source of truth, so a failed archive write never blocks signing.
|
|
19
|
+
function putObject(root, content) {
|
|
20
|
+
const s = String(content); const h = sha256(s); const p = objPath(root, h);
|
|
21
|
+
try { fs.mkdirSync(path.dirname(p), { recursive: true }); if (!fs.existsSync(p)) fs.writeFileSync(p, s); } catch (_) { /* best effort */ }
|
|
22
|
+
return h;
|
|
23
|
+
}
|
|
24
|
+
// Read the object with this hash, but only if its content still hashes to the key (tamper check).
|
|
25
|
+
function getObject(root, hash) {
|
|
26
|
+
try { const p = objPath(root, hash); if (!fs.existsSync(p)) return null; const c = fs.readFileSync(p, 'utf8'); return sha256(c) === String(hash) ? c : null; }
|
|
27
|
+
catch (_) { return null; }
|
|
28
|
+
}
|
|
29
|
+
function hasObject(root, hash) { try { return fs.existsSync(objPath(root, hash)); } catch (_) { return false; } }
|
|
30
|
+
|
|
31
|
+
module.exports = { objRoot, objPath, putObject, getObject, hasObject };
|
package/src/phone.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Phone signing over the local network — the key lives only on the phone, the
|
|
3
|
+
// laptop only ASKS. No third-party server: the laptop runs a tiny LAN HTTP
|
|
4
|
+
// endpoint, the phone (same Wi-Fi) opens it, reviews the promises in plain
|
|
5
|
+
// language, and signs with its own key. The laptop verifies the signature
|
|
6
|
+
// against the enrolled public key and writes the seal.
|
|
7
|
+
//
|
|
8
|
+
// Wire format matches src/crypto.js exactly: public key = base64 SPKI-DER,
|
|
9
|
+
// signature = base64 raw ed25519 over canonical(approval). The phone's WebCrypto
|
|
10
|
+
// Ed25519 emits the same encodings, so it interoperates with `yay verify`.
|
|
11
|
+
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
// Stable default port so the phone page keeps one origin (→ its saved key persists).
|
|
15
|
+
// 48757 is a deliberately uncommon high port, clear of the usual dev ports
|
|
16
|
+
// (React 3000, Vite 5173, Express 8080/3000, Rails 3000, webpack 4200/8000, …) so
|
|
17
|
+
// it rarely collides. YAY_PHONE_PORT overrides it; set it to 0 to force a random port.
|
|
18
|
+
const PHONE_PORT = (process.env.YAY_PHONE_PORT !== undefined && process.env.YAY_PHONE_PORT !== '')
|
|
19
|
+
? Number(process.env.YAY_PHONE_PORT) : 48757;
|
|
20
|
+
const C = require('./crypto');
|
|
21
|
+
const { canonical } = require('./util');
|
|
22
|
+
const { signerHTML } = require('./signer-page');
|
|
23
|
+
|
|
24
|
+
function lanIP() {
|
|
25
|
+
const ifaces = os.networkInterfaces();
|
|
26
|
+
for (const name of Object.keys(ifaces)) {
|
|
27
|
+
for (const i of ifaces[name] || []) {
|
|
28
|
+
if (i.family === 'IPv4' && !i.internal) return i.address;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return '127.0.0.1';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readBody(req) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
let b = '';
|
|
37
|
+
req.on('data', (c) => { b += c; if (b.length > 1e6) req.destroy(); });
|
|
38
|
+
req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch (_) { resolve({}); } });
|
|
39
|
+
req.on('error', () => resolve({}));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function sendJSON(res, status, obj) {
|
|
43
|
+
const body = JSON.stringify(obj);
|
|
44
|
+
res.writeHead(status, { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'cache-control': 'no-store' });
|
|
45
|
+
res.end(body);
|
|
46
|
+
}
|
|
47
|
+
function sendHTML(res, html) {
|
|
48
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
49
|
+
res.end(html);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Short human-verifiable code bound to the phone's public key (MITM guard):
|
|
53
|
+
// if a different device pairs, its code won't match what the user's phone shows.
|
|
54
|
+
function confirmCode(pubB64) {
|
|
55
|
+
return String(parseInt(C.sha256('yay-pair:' + pubB64).slice(0, 8), 16) % 1000000).padStart(6, '0');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Serve a session until `done` resolves (the phone completed the action).
|
|
59
|
+
// Returns { url, port, done: Promise, close() }.
|
|
60
|
+
function serve(mode, project, sessionData, onPost, opts) {
|
|
61
|
+
let resolveDone;
|
|
62
|
+
const done = new Promise((r) => { resolveDone = r; });
|
|
63
|
+
// Final outcome the phone can poll after it submitted (pairing completes on the
|
|
64
|
+
// laptop, so the phone learns success/failure via GET /api/status). `settled`
|
|
65
|
+
// resolves once the phone has actually read that final status.
|
|
66
|
+
let final = null, resolveSettled;
|
|
67
|
+
const settled = new Promise((r) => { resolveSettled = r; });
|
|
68
|
+
const html = signerHTML({ mode, project });
|
|
69
|
+
// Auto-close if nobody completes it, so an abandoned server can't linger holding
|
|
70
|
+
// the stable port (which would push the next sign onto a new origin → lost key).
|
|
71
|
+
const timeoutMs = (opts && opts.timeoutMs) || Number(process.env.YAY_PHONE_TIMEOUT_MS) || 600000;
|
|
72
|
+
let timer = null;
|
|
73
|
+
|
|
74
|
+
const handler = async (req, res) => {
|
|
75
|
+
const url = req.url.split('?')[0];
|
|
76
|
+
if (req.method === 'OPTIONS') return sendJSON(res, 200, {});
|
|
77
|
+
if (req.method === 'GET' && (url === '/' || url === '/index.html')) return sendHTML(res, html);
|
|
78
|
+
if (req.method === 'GET' && url === '/api/session') return sendJSON(res, 200, { mode, project, ...sessionData });
|
|
79
|
+
if (req.method === 'GET' && url === '/api/status') { if (final) resolveSettled(); return sendJSON(res, 200, { final }); }
|
|
80
|
+
if (req.method === 'POST' && url === '/api/submit') {
|
|
81
|
+
const body = await readBody(req);
|
|
82
|
+
const result = onPost(body);
|
|
83
|
+
if (result && result.error) return sendJSON(res, 400, { error: result.error });
|
|
84
|
+
sendJSON(res, 200, result || { ok: true });
|
|
85
|
+
if (result && result.done) resolveDone(result.done);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
sendJSON(res, 404, { error: 'not found' });
|
|
89
|
+
};
|
|
90
|
+
// Pure-JS signer works over plain http; opt-in TLS (self-signed) encrypts transport.
|
|
91
|
+
const tls = opts && opts.tls;
|
|
92
|
+
const server = tls ? require('https').createServer({ key: tls.key, cert: tls.cert }, handler) : http.createServer(handler);
|
|
93
|
+
const scheme = tls ? 'https' : 'http';
|
|
94
|
+
|
|
95
|
+
// Bind a STABLE port so the phone URL's origin (host:port) stays constant across
|
|
96
|
+
// pair → sign sessions — otherwise localStorage (where the phone key lives) is
|
|
97
|
+
// scoped to a different origin each run and the phone "forgets" its key. Prefer
|
|
98
|
+
// PHONE_PORT, step through a few, and only fall back to a random port as a last
|
|
99
|
+
// resort (in which case the phone may need to restore from its recovery phrase).
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const mk = (port) => ({
|
|
102
|
+
url: `${scheme}://${lanIP()}:${port}`, local: `${scheme}://localhost:${port}`, port, done, settled,
|
|
103
|
+
// fellBack: we could not get the stable port → the phone address changed and
|
|
104
|
+
// its saved key may be invisible (caller warns the user).
|
|
105
|
+
fellBack: PHONE_PORT !== 0 && port !== PHONE_PORT,
|
|
106
|
+
setFinal: (f) => { final = f; },
|
|
107
|
+
close: () => { if (timer) clearTimeout(timer); server.close(); },
|
|
108
|
+
});
|
|
109
|
+
const bind = (port, triesLeft) => {
|
|
110
|
+
const onErr = (e) => {
|
|
111
|
+
if ((e.code === 'EADDRINUSE' || e.code === 'EACCES') && triesLeft > 0) bind(triesLeft === 1 ? 0 : port + 1, triesLeft - 1);
|
|
112
|
+
else reject(e);
|
|
113
|
+
};
|
|
114
|
+
server.once('error', onErr);
|
|
115
|
+
server.listen(port, '0.0.0.0', () => {
|
|
116
|
+
server.removeListener('error', onErr);
|
|
117
|
+
timer = setTimeout(() => { resolveDone({ timedOut: true }); try { server.close(); } catch (_) {} }, timeoutMs);
|
|
118
|
+
if (timer.unref) timer.unref();
|
|
119
|
+
resolve(mk(server.address().port));
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
bind(PHONE_PORT, 6);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Pairing: the phone creates its key and proves possession by signing our
|
|
127
|
+
// challenge; we return its name + public key + the confirm code for the human.
|
|
128
|
+
//
|
|
129
|
+
// When `genesis` is supplied (no signed trust root exists yet), we run
|
|
130
|
+
// PHONE-AS-GENESIS: the phone self-signs the genesis roster event, so its
|
|
131
|
+
// signature IS the trust root. The laptop never holds a key. We rebuild the
|
|
132
|
+
// event authoritatively from our own fields + the phone's name/pub, so a
|
|
133
|
+
// tampered phone cannot smuggle a different role/nonce past us.
|
|
134
|
+
async function pairOverLan({ project, tls, genesis, challenge }) {
|
|
135
|
+
challenge = challenge || (C.randomNonce() + C.randomNonce());
|
|
136
|
+
const session = genesis ? { challenge, genesis } : { challenge };
|
|
137
|
+
const s = await serve('pair', project, session, (body) => {
|
|
138
|
+
const { name, pubB64, proof } = body || {};
|
|
139
|
+
if (!name || !pubB64 || !proof) return { error: 'missing name/pubB64/proof' };
|
|
140
|
+
if (genesis) {
|
|
141
|
+
const ev = { ...genesis, name: String(name), pub: pubB64, by: String(name) };
|
|
142
|
+
if (!C.verify(canonical(ev), proof, pubB64)) return { error: 'genesis self-signature failed' };
|
|
143
|
+
const code = confirmCode(pubB64);
|
|
144
|
+
return { ok: true, code, done: { name: String(name), pubB64, code, genesisEvent: { ...ev, signature: proof } } };
|
|
145
|
+
}
|
|
146
|
+
if (!C.verify(challenge, proof, pubB64)) return { error: 'key possession proof failed' };
|
|
147
|
+
const code = confirmCode(pubB64);
|
|
148
|
+
return { ok: true, code, done: { name: String(name), pubB64, code } };
|
|
149
|
+
}, { tls });
|
|
150
|
+
return s; // { url, port, done, close }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Signing: hand the phone the unsigned approval + a plain-language summary; it
|
|
154
|
+
// signs canonical(approval) and posts the signature, which we verify.
|
|
155
|
+
async function signOverLan({ project, approval, summary, tagPool, expectPubB64, tls }) {
|
|
156
|
+
const pubs = Array.isArray(expectPubB64) ? expectPubB64 : [expectPubB64]; // identity may hold several keys
|
|
157
|
+
const s = await serve('approve', project, { approval, summary, tagPool: tagPool || [] }, (body) => {
|
|
158
|
+
// Send back (§5): the signer declined and (optionally) said what to change / which tags. No signature.
|
|
159
|
+
if (body && body.rejected) return { ok: true, done: { rejected: true, reason: String(body.reason || '').trim(), tags: Array.isArray(body.tags) ? body.tags : undefined } };
|
|
160
|
+
const { signature } = body || {};
|
|
161
|
+
if (!signature) return { error: 'missing signature' };
|
|
162
|
+
// Legacy: an older phone may still post an edited Brief; verify against — and return — it.
|
|
163
|
+
let target = approval;
|
|
164
|
+
const editedBrief = (body.brief !== undefined && approval.brief);
|
|
165
|
+
if (editedBrief) target = { ...approval, brief: { ...approval.brief, text: String(body.brief).trim() } };
|
|
166
|
+
const canon = canonical(target);
|
|
167
|
+
if (!pubs.some((pub) => C.verify(canon, signature, pub))) return { error: 'signature did not verify against any enrolled key' };
|
|
168
|
+
return { ok: true, done: editedBrief ? { signature, brief: String(body.brief).trim() } : { signature } };
|
|
169
|
+
}, { tls });
|
|
170
|
+
return s;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Roster authorization: an OWNER's phone signs a governance event (enroll / revoke
|
|
174
|
+
// / reroot) so a phone-only owner can manage the roster with no key on the laptop.
|
|
175
|
+
// The phone signs canonical(event) == eventBytes, verified against current owner keys.
|
|
176
|
+
async function authorizeOverLan({ project, event, summary, ownerPubs, tls }) {
|
|
177
|
+
const canon = canonical(event);
|
|
178
|
+
const pubs = Array.isArray(ownerPubs) ? ownerPubs : [ownerPubs];
|
|
179
|
+
const s = await serve('authorize', project, { event, summary }, (body) => {
|
|
180
|
+
const { signature } = body || {};
|
|
181
|
+
if (!signature) return { error: 'missing signature' };
|
|
182
|
+
if (!pubs.some((pub) => pub && C.verify(canon, signature, pub))) return { error: 'not signed by a current owner key on this phone' };
|
|
183
|
+
return { ok: true, done: { signature } };
|
|
184
|
+
}, { tls });
|
|
185
|
+
return s;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { pairOverLan, signOverLan, authorizeOverLan, lanIP, confirmCode };
|
package/src/plan.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// `yay plan` — an LLM synthesizes a clean, high-level SYSTEM PLAN from the specs
|
|
3
|
+
// (intent / ensures / feeds / blast-radius across all Cells). It runs at plan-time
|
|
4
|
+
// only; the result is cached to .yaylayer/plan.json and baked into the offline map
|
|
5
|
+
// as a static, presentation-grade "System Plan" poster. Needs the user's own
|
|
6
|
+
// ANTHROPIC_API_KEY (from their .env); no key, no dependency added — just fetch.
|
|
7
|
+
|
|
8
|
+
function trim(s, n) { return String(s == null ? '' : s).trim().slice(0, n || 200); }
|
|
9
|
+
|
|
10
|
+
// A compact digest of the specs to feed the model (blast-sorted, capped).
|
|
11
|
+
function buildDigest(manifest, project) {
|
|
12
|
+
const cells = manifest.cells || {};
|
|
13
|
+
const byModule = {};
|
|
14
|
+
for (const id of Object.keys(cells)) {
|
|
15
|
+
const c = cells[id];
|
|
16
|
+
if (c.contains && c.contains.length) continue; // skip container Cells
|
|
17
|
+
const m = c.module || 'other';
|
|
18
|
+
(byModule[m] = byModule[m] || []).push({
|
|
19
|
+
cell: id,
|
|
20
|
+
unit: c.unitName || '',
|
|
21
|
+
intent: trim(c.spec && c.spec.intent, 240),
|
|
22
|
+
ensures: trim(c.spec && c.spec.ensures, 160),
|
|
23
|
+
feeds: (c.feeds || []).slice(0, 8),
|
|
24
|
+
blast: c.blast || 0,
|
|
25
|
+
pure: /^yes\b/i.test((c.spec && c.spec.pure) || ''),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const modules = Object.keys(byModule).map((name) => {
|
|
29
|
+
const list = byModule[name].sort((a, b) => b.blast - a.blast);
|
|
30
|
+
return { name, total: list.length, units: list.slice(0, 40) };
|
|
31
|
+
});
|
|
32
|
+
return { project: project || 'project', modules, moduleFlows: manifest.moduleEdges || [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SYSTEM_PROMPT = [
|
|
36
|
+
'You are a systems architect preparing a single conference slide that explains a software system.',
|
|
37
|
+
'You are given a DIGEST of the project\'s formal specs: each unit has an intent (what it does), optional ensures (a postcondition), feeds (data-flow to other Cells), and blast (how many units depend on it — higher = more load-bearing).',
|
|
38
|
+
'Synthesize a clean, high-level SYSTEM PLAN. Group the modules into a few LOGICAL subsystems (not necessarily one per module) that best explain the architecture to a newcomer.',
|
|
39
|
+
'Return ONLY a JSON object, no prose, no code fences, with exactly this shape:',
|
|
40
|
+
'{',
|
|
41
|
+
' "system": string, // 2-4 sentence plain-English overview of what the whole system is and does',
|
|
42
|
+
' "subsystems": [ { "name": string, "modules": [string], "purpose": string, "role": "input"|"data"|"logic"|"output"|"ui"|"other" } ],',
|
|
43
|
+
' "flows": [ { "from": string, "to": string, "what": string } ], // subsystem-to-subsystem, "what" is a short data label',
|
|
44
|
+
' "highlights": [ string ] // 3-5 short bullets: load-bearing pieces, risks, notable design choices',
|
|
45
|
+
'}',
|
|
46
|
+
'Base everything ONLY on the digest. "from"/"to" in flows must be subsystem names you defined. Keep purposes to one line.',
|
|
47
|
+
].join('\n');
|
|
48
|
+
|
|
49
|
+
function stripToJSON(text) {
|
|
50
|
+
let t = String(text || '').trim();
|
|
51
|
+
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
52
|
+
if (fence) t = fence[1].trim();
|
|
53
|
+
const a = t.indexOf('{'), b = t.lastIndexOf('}');
|
|
54
|
+
if (a >= 0 && b > a) t = t.slice(a, b + 1);
|
|
55
|
+
return JSON.parse(t);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function callAnthropic(digest, { model, apiKey, maxTokens }) {
|
|
59
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ model, max_tokens: maxTokens || 2000, system: SYSTEM_PROMPT, messages: [{ role: 'user', content: 'DIGEST:\n' + JSON.stringify(digest) }] }),
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) throw new Error('Anthropic API ' + res.status + ': ' + (await res.text().catch(() => '')).slice(0, 240));
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
return (data && data.content && data.content[0] && data.content[0].text) || '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// OpenAI Chat Completions — also covers OpenAI-COMPATIBLE providers (Groq, Together,
|
|
70
|
+
// OpenRouter, Ollama, LM Studio, …) via baseUrl. `tokenField` handles newer OpenAI
|
|
71
|
+
// models that require max_completion_tokens instead of max_tokens.
|
|
72
|
+
async function callOpenAICompat(digest, { model, apiKey, baseUrl, maxTokens }, tokenField) {
|
|
73
|
+
const url = (baseUrl || 'https://api.openai.com/v1').replace(/\/$/, '') + '/chat/completions';
|
|
74
|
+
const body = { model, messages: [{ role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: 'DIGEST:\n' + JSON.stringify(digest) }] };
|
|
75
|
+
body[tokenField || 'max_tokens'] = maxTokens || 2000;
|
|
76
|
+
const headers = { 'content-type': 'application/json' };
|
|
77
|
+
if (apiKey) headers.authorization = 'Bearer ' + apiKey; // local servers (Ollama/LM Studio) often need no key
|
|
78
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
const errText = (await res.text().catch(() => '')).slice(0, 300);
|
|
81
|
+
// some models reject max_tokens and want max_completion_tokens — retry once
|
|
82
|
+
if (res.status === 400 && /max_completion_tokens/.test(errText) && (tokenField || 'max_tokens') === 'max_tokens') {
|
|
83
|
+
return callOpenAICompat(digest, { model, apiKey, baseUrl, maxTokens }, 'max_completion_tokens');
|
|
84
|
+
}
|
|
85
|
+
throw new Error('OpenAI-compatible API ' + res.status + ': ' + errText);
|
|
86
|
+
}
|
|
87
|
+
const data = await res.json();
|
|
88
|
+
return (data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// provider: 'anthropic' | 'openai' (openai also = any OpenAI-compatible baseUrl).
|
|
92
|
+
async function synthesize(digest, opts) {
|
|
93
|
+
if (typeof fetch !== 'function') throw new Error('global fetch unavailable — needs Node 18+');
|
|
94
|
+
const provider = opts.provider || 'anthropic';
|
|
95
|
+
const model = opts.model || (provider === 'anthropic' ? 'claude-sonnet-5' : 'gpt-4o');
|
|
96
|
+
const text = provider === 'anthropic'
|
|
97
|
+
? await callAnthropic(digest, { ...opts, model })
|
|
98
|
+
: await callOpenAICompat(digest, { ...opts, model });
|
|
99
|
+
let plan;
|
|
100
|
+
try { plan = stripToJSON(text); } catch (e) { throw new Error('model did not return valid JSON'); }
|
|
101
|
+
plan.system = trim(plan.system, 1200) || 'No overview produced.';
|
|
102
|
+
plan.subsystems = Array.isArray(plan.subsystems) ? plan.subsystems : [];
|
|
103
|
+
plan.flows = Array.isArray(plan.flows) ? plan.flows : [];
|
|
104
|
+
plan.highlights = Array.isArray(plan.highlights) ? plan.highlights : [];
|
|
105
|
+
return plan;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Generic one-shot chat (reused by the spec-only adversary). Same provider routing
|
|
109
|
+
// as the plan; returns the raw text.
|
|
110
|
+
async function chatAnthropic(system, user, { model, apiKey, maxTokens }) {
|
|
111
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
112
|
+
method: 'POST', headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ model, max_tokens: maxTokens || 1500, system, messages: [{ role: 'user', content: user }] }),
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) throw new Error('Anthropic API ' + res.status + ': ' + (await res.text().catch(() => '')).slice(0, 240));
|
|
116
|
+
const data = await res.json();
|
|
117
|
+
return (data && data.content && data.content[0] && data.content[0].text) || '';
|
|
118
|
+
}
|
|
119
|
+
async function chatOpenAICompat(system, user, { model, apiKey, baseUrl, maxTokens }, tokenField) {
|
|
120
|
+
const url = (baseUrl || 'https://api.openai.com/v1').replace(/\/$/, '') + '/chat/completions';
|
|
121
|
+
const body = { model, messages: [{ role: 'system', content: system }, { role: 'user', content: user }] };
|
|
122
|
+
body[tokenField || 'max_tokens'] = maxTokens || 1500;
|
|
123
|
+
const headers = { 'content-type': 'application/json' };
|
|
124
|
+
if (apiKey) headers.authorization = 'Bearer ' + apiKey;
|
|
125
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
const errText = (await res.text().catch(() => '')).slice(0, 300);
|
|
128
|
+
if (res.status === 400 && /max_completion_tokens/.test(errText) && (tokenField || 'max_tokens') === 'max_tokens') return chatOpenAICompat(system, user, { model, apiKey, baseUrl, maxTokens }, 'max_completion_tokens');
|
|
129
|
+
throw new Error('OpenAI-compatible API ' + res.status + ': ' + errText);
|
|
130
|
+
}
|
|
131
|
+
const data = await res.json();
|
|
132
|
+
return (data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || '';
|
|
133
|
+
}
|
|
134
|
+
async function chat(system, user, opts) {
|
|
135
|
+
if (typeof fetch !== 'function') throw new Error('global fetch unavailable — needs Node 18+');
|
|
136
|
+
const provider = opts.provider || 'anthropic';
|
|
137
|
+
const model = opts.model || (provider === 'anthropic' ? 'claude-sonnet-5' : 'gpt-4o');
|
|
138
|
+
return provider === 'anthropic' ? chatAnthropic(system, user, { ...opts, model }) : chatOpenAICompat(system, user, { ...opts, model });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { buildDigest, synthesize, SYSTEM_PROMPT, chat };
|
package/src/policy.js
ADDED
|
Binary file
|