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/durable.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// P4 — DURABLE MODE + governance. Standard mode keeps specs/attestations/grants forever (the tiny
|
|
3
|
+
// bespoke sha256 store) and leans on git for the bulk SOURCE. Durable mode additionally keeps an
|
|
4
|
+
// encrypted, self-contained archive of the signed source, so "what the code actually was when it was
|
|
5
|
+
// signed" survives even if git history is lost, rebased, or the remote disappears (regulated / audit
|
|
6
|
+
// settings). Per the locked design (D10, D12):
|
|
7
|
+
// • Trust anchor = OUR sha256 over the PLAINTEXT bytes (not git SHA-1) — content-addressed.
|
|
8
|
+
// • Source is encrypted AES-256-GCM under a PROJECT-HELD key YayLayer never stores (from
|
|
9
|
+
// $YAY_ARCHIVE_KEY or a passphrase, scrypt-derived). We hold ciphertext + clear, signed metadata.
|
|
10
|
+
// • Metadata (cell id, file, plaintext hash, size, time) stays CLEAR + signed → auditable without
|
|
11
|
+
// decrypting; only the body is sealed.
|
|
12
|
+
// • Pre-archive SECRET SCAN → refuse to seal code containing obvious secrets (they'd be preserved
|
|
13
|
+
// forever). Deletion is honest: a signed TOMBSTONE replaces a blob, never a silent rewrite.
|
|
14
|
+
//
|
|
15
|
+
// This is a self-contained encrypted store (the bespoke core extended to code). A git-bundle bulk
|
|
16
|
+
// transport is a later storage optimization; the guarantees above are what Durable mode promises.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const C = require('./crypto');
|
|
22
|
+
const { canonical } = require('./util');
|
|
23
|
+
|
|
24
|
+
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 };
|
|
25
|
+
|
|
26
|
+
function archiveDir(p) { return path.join(p.yay, 'archive'); }
|
|
27
|
+
function archiveManifestPath(p) { return path.join(p.yay, 'archive.json'); }
|
|
28
|
+
function blobPath(p, hash) { const h = String(hash); return path.join(archiveDir(p), h.slice(0, 2), h.slice(2)); }
|
|
29
|
+
|
|
30
|
+
// Resolve the project-held archive key. From $YAY_ARCHIVE_KEY (raw or base64) or a passphrase
|
|
31
|
+
// (scrypt with a per-project salt kept in the archive manifest). Returns a 32-byte Buffer. YayLayer
|
|
32
|
+
// never persists the key or passphrase.
|
|
33
|
+
function resolveKey(passphrase, salt) {
|
|
34
|
+
const env = process.env.YAY_ARCHIVE_KEY;
|
|
35
|
+
if (env) { const b = Buffer.from(env, /^[A-Za-z0-9+/=]+$/.test(env) && env.length >= 43 ? 'base64' : 'utf8'); return b.length === 32 ? b : crypto.scryptSync(env, salt || 'yay-archive', SCRYPT.keylen, SCRYPT); }
|
|
36
|
+
if (!passphrase) throw new Error('Durable archive needs a key — set $YAY_ARCHIVE_KEY or pass a passphrase (never stored by YayLayer).');
|
|
37
|
+
return crypto.scryptSync(passphrase, salt || 'yay-archive', SCRYPT.keylen, SCRYPT);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function encryptBlob(plaintext, key) {
|
|
41
|
+
const iv = crypto.randomBytes(12);
|
|
42
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
43
|
+
const ct = Buffer.concat([cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()]);
|
|
44
|
+
return { v: 1, alg: 'aes-256-gcm', iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ct: ct.toString('base64') };
|
|
45
|
+
}
|
|
46
|
+
function decryptBlob(blob, key) {
|
|
47
|
+
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(blob.iv, 'base64'));
|
|
48
|
+
d.setAuthTag(Buffer.from(blob.tag, 'base64'));
|
|
49
|
+
try { return Buffer.concat([d.update(Buffer.from(blob.ct, 'base64')), d.final()]).toString('utf8'); }
|
|
50
|
+
catch (_) { throw new Error('wrong archive key, or the blob is corrupt'); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── pre-archive secret scan ────────────────────────────────────────────────────────────────────
|
|
54
|
+
// Conservative: catch the obvious, high-confidence leaks (a false miss is better than crying wolf on
|
|
55
|
+
// every file, but these patterns are specific enough to rarely false-positive). Returns [{line,kind}].
|
|
56
|
+
const SECRET_PATTERNS = [
|
|
57
|
+
['AWS access key id', /\bAKIA[0-9A-Z]{16}\b/],
|
|
58
|
+
['private key block', /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/],
|
|
59
|
+
['generic api/secret assignment', /\b(?:api[_-]?key|secret|token|passwd|password)\b\s*[:=]\s*['"][^'"]{12,}['"]/i],
|
|
60
|
+
['Slack token', /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/],
|
|
61
|
+
['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
|
62
|
+
['bearer/JWT-ish', /\bey[JA][0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{6,}\b/],
|
|
63
|
+
];
|
|
64
|
+
function secretScan(text) {
|
|
65
|
+
const out = [];
|
|
66
|
+
const lines = String(text).split(/\r?\n/);
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
for (const [kind, re] of SECRET_PATTERNS) { if (re.test(lines[i])) out.push({ line: i + 1, kind }); }
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loadArchive(p) {
|
|
74
|
+
try { return JSON.parse(fs.readFileSync(archiveManifestPath(p), 'utf8')); } catch (_) { return null; }
|
|
75
|
+
}
|
|
76
|
+
function saveArchive(p, arc) { fs.mkdirSync(p.yay, { recursive: true }); fs.writeFileSync(archiveManifestPath(p), JSON.stringify(arc, null, 2) + '\n'); }
|
|
77
|
+
|
|
78
|
+
// Archive one plaintext body: anchor by sha256(plaintext), encrypt, write the blob. Idempotent
|
|
79
|
+
// (same content → same anchor → write-once). Returns { hash, size, existed }.
|
|
80
|
+
function putBlob(p, plaintext, key) {
|
|
81
|
+
const hash = C.sha256(String(plaintext));
|
|
82
|
+
const bp = blobPath(p, hash);
|
|
83
|
+
const existed = fs.existsSync(bp);
|
|
84
|
+
if (!existed) { fs.mkdirSync(path.dirname(bp), { recursive: true }); fs.writeFileSync(bp, JSON.stringify(encryptBlob(plaintext, key))); }
|
|
85
|
+
return { hash, size: Buffer.byteLength(plaintext, 'utf8'), existed };
|
|
86
|
+
}
|
|
87
|
+
// Read + decrypt a blob by its plaintext anchor; verifies the decrypted bytes re-hash to the anchor.
|
|
88
|
+
function getBlob(p, hash, key) {
|
|
89
|
+
const bp = blobPath(p, hash);
|
|
90
|
+
if (!fs.existsSync(bp)) return null;
|
|
91
|
+
const blob = JSON.parse(fs.readFileSync(bp, 'utf8'));
|
|
92
|
+
const pt = decryptBlob(blob, key);
|
|
93
|
+
if (C.sha256(pt) !== String(hash)) throw new Error('archive blob failed its content check (anchor mismatch) — tampered');
|
|
94
|
+
return pt;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Replace a blob with a signed TOMBSTONE — honest erasure that never rewrites history: the entry
|
|
98
|
+
// stays, its status becomes "deliberately removed" with who/when/why. The ciphertext is deleted.
|
|
99
|
+
function tombstone(p, hash, reason, signer) {
|
|
100
|
+
const bp = blobPath(p, hash);
|
|
101
|
+
try { if (fs.existsSync(bp)) fs.unlinkSync(bp); } catch (_) {}
|
|
102
|
+
return { contentHash: String(hash), status: 'deliberately removed', deletedAt: new Date().toISOString(), reason: reason || null, by: signer || null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── reconstructable snapshots (the reverify substrate) ──────────────────────────────────────────
|
|
106
|
+
// Blobs are content-addressed and never deleted, but arc.files only holds the LATEST path→hash map,
|
|
107
|
+
// so a HISTORICAL tree can't be rebuilt without an index of "which blobs composed the tree at moment N".
|
|
108
|
+
// recordSnapshot captures exactly that — the file→blob-hash map for one archived state, tied to the
|
|
109
|
+
// verification it captured (codeTreeHash + the attestation hash). This is capture-going-forward: only
|
|
110
|
+
// states archived after this exists become fully reconstructable. Append-only, deduped by codeTreeHash.
|
|
111
|
+
function recordSnapshot(p, snap) {
|
|
112
|
+
const arc = loadArchive(p);
|
|
113
|
+
if (!arc) return null;
|
|
114
|
+
arc.snapshots = arc.snapshots || [];
|
|
115
|
+
const dup = arc.snapshots.find((s) => s.codeTreeHash && s.codeTreeHash === snap.codeTreeHash && s.specSetHash === (snap.specSetHash || s.specSetHash));
|
|
116
|
+
if (dup) return dup;
|
|
117
|
+
const rec = {
|
|
118
|
+
at: snap.at || null,
|
|
119
|
+
codeTreeHash: snap.codeTreeHash || null,
|
|
120
|
+
specSetHash: snap.specSetHash || null,
|
|
121
|
+
attest: snap.attest || null,
|
|
122
|
+
capability: snap.capability || null,
|
|
123
|
+
files: snap.files || {},
|
|
124
|
+
};
|
|
125
|
+
arc.snapshots.push(rec);
|
|
126
|
+
saveArchive(p, arc);
|
|
127
|
+
return rec;
|
|
128
|
+
}
|
|
129
|
+
function listSnapshots(p) { const arc = loadArchive(p); return (arc && arc.snapshots) || []; }
|
|
130
|
+
|
|
131
|
+
// Materialize a snapshot's exact source into destDir from the encrypted blobs. Throws on a
|
|
132
|
+
// tombstoned/missing/tampered blob (getBlob anchor-checks). Returns { dir, files: [rel…] }.
|
|
133
|
+
function reconstructSnapshot(p, snap, key, destDir) {
|
|
134
|
+
const files = (snap && snap.files) || {};
|
|
135
|
+
const written = [];
|
|
136
|
+
for (const rel of Object.keys(files)) {
|
|
137
|
+
const hash = files[rel];
|
|
138
|
+
const pt = getBlob(p, hash, key);
|
|
139
|
+
if (pt == null) throw new Error('cannot reconstruct — blob ' + String(hash).slice(0, 12) + '… for ' + rel + ' is missing or tombstoned');
|
|
140
|
+
const abs = path.join(destDir, rel);
|
|
141
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
142
|
+
fs.writeFileSync(abs, pt);
|
|
143
|
+
written.push(rel);
|
|
144
|
+
}
|
|
145
|
+
return { dir: destDir, files: written };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
archiveDir, archiveManifestPath, blobPath, resolveKey, encryptBlob, decryptBlob,
|
|
150
|
+
secretScan, SECRET_PATTERNS, loadArchive, saveArchive, putBlob, getBlob, tombstone,
|
|
151
|
+
recordSnapshot, listSnapshots, reconstructSnapshot,
|
|
152
|
+
};
|
package/src/e2e.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// End-to-end encryption for the hosted relay path — TweetNaCl secretbox
|
|
3
|
+
// (XSalsa20-Poly1305), byte-for-byte compatible with the phone page's nacl.secretbox.
|
|
4
|
+
// The laptop and phone share a 32-byte key carried in the QR URL #fragment (never sent
|
|
5
|
+
// to the relay), so the relay only ever stores/forwards opaque {n,c} ciphertext.
|
|
6
|
+
const nacl = require('./vendor/tweetnacl.min.js');
|
|
7
|
+
|
|
8
|
+
const b64 = (u) => Buffer.from(u).toString('base64');
|
|
9
|
+
function b64url(u) { return b64(u).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
|
|
10
|
+
function fromB64url(s) { s = String(s).replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; return new Uint8Array(Buffer.from(s, 'base64')); }
|
|
11
|
+
|
|
12
|
+
function newKey() { return nacl.randomBytes(32); } // Uint8Array(32)
|
|
13
|
+
function newChannel() { return b64url(nacl.randomBytes(18)); } // 24 url-safe chars (matches the relay's channel regex)
|
|
14
|
+
|
|
15
|
+
// seal(key, obj) → { n, c } base64 (nonce, ciphertext+tag). open() returns the object or null.
|
|
16
|
+
function seal(key, obj) {
|
|
17
|
+
const n = nacl.randomBytes(24);
|
|
18
|
+
const c = nacl.secretbox(Buffer.from(JSON.stringify(obj), 'utf8'), n, key);
|
|
19
|
+
return { n: b64(n), c: b64(c) };
|
|
20
|
+
}
|
|
21
|
+
function open(key, blob) {
|
|
22
|
+
try {
|
|
23
|
+
const m = nacl.secretbox.open(new Uint8Array(Buffer.from(blob.c, 'base64')), new Uint8Array(Buffer.from(blob.n, 'base64')), key);
|
|
24
|
+
return m ? JSON.parse(Buffer.from(m).toString('utf8')) : null;
|
|
25
|
+
} catch (_) { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Sealed box: encrypt to a signer's EXISTING ed25519 key, converted to X25519
|
|
29
|
+
// (the standard birational map, as libsodium sealed boxes / age / Signal do). Lets us
|
|
30
|
+
// address a request so ONLY the recipient can read it — no extra key, no roster change.
|
|
31
|
+
const ll = nacl.lowlevel;
|
|
32
|
+
const gf1 = ll.gf([1]);
|
|
33
|
+
const u8 = (s) => new Uint8Array(Buffer.from(s, 'base64'));
|
|
34
|
+
// bytes → field element (little-endian, top bit cleared)
|
|
35
|
+
function unpack25519(o, n) { for (let i = 0; i < 16; i++) o[i] = n[2 * i] + (n[2 * i + 1] << 8); o[15] &= 0x7fff; }
|
|
36
|
+
// modular inverse mod 2^255-19, by exponentiation to p-2 (tweetnacl's ladder)
|
|
37
|
+
function inv25519(o, i) { const c = ll.gf(); let a; for (a = 0; a < 16; a++) c[a] = i[a]; for (a = 253; a >= 0; a--) { ll.S(c, c); if (a !== 2 && a !== 4) ll.M(c, c, i); } for (a = 0; a < 16; a++) o[a] = c[a]; }
|
|
38
|
+
// ed25519 public key (raw 32) → X25519 public key: u = (1+y)/(1-y) mod p
|
|
39
|
+
function edPubToX(edPub32) { const y = ll.gf(), a = ll.gf(), b = ll.gf(), z = new Uint8Array(32); unpack25519(y, edPub32); ll.A(a, gf1, y); ll.Z(b, gf1, y); inv25519(b, b); ll.M(a, a, b); ll.pack25519(z, a); return z; }
|
|
40
|
+
// ed25519 secret key (64 = seed||pub) → X25519 secret: clamp(SHA-512(seed)[0..32])
|
|
41
|
+
function edSecToX(edSec64) { const h = nacl.hash(edSec64.subarray(0, 32)); const s = h.slice(0, 32); s[0] &= 248; s[31] &= 127; s[31] |= 64; return s; }
|
|
42
|
+
// Roster pubs are stored as SPKI (12-byte header + 32-byte key); take the raw key.
|
|
43
|
+
function edPubRaw(spkiB64) { const uu = u8(spkiB64); return uu.length === 32 ? uu : uu.subarray(uu.length - 32); }
|
|
44
|
+
|
|
45
|
+
// Each signer's INBOX channel is a deterministic hash of their (SPKI) public key —
|
|
46
|
+
// public, so anyone can address them; only they can decrypt (below).
|
|
47
|
+
function inboxChannel(pubB64) { return b64url(nacl.hash(Buffer.from('yay-inbox:v1:' + pubB64, 'utf8')).subarray(0, 18)); }
|
|
48
|
+
|
|
49
|
+
// Seal `obj` so ONLY the holder of `pubB64`'s key can open it (anonymous sealed box:
|
|
50
|
+
// an ephemeral X25519 keypair does a box to the recipient's converted pubkey).
|
|
51
|
+
function sealTo(pubB64, obj) {
|
|
52
|
+
const xpub = edPubToX(edPubRaw(pubB64));
|
|
53
|
+
const eph = nacl.box.keyPair();
|
|
54
|
+
const n = nacl.randomBytes(24);
|
|
55
|
+
const c = nacl.box(Buffer.from(JSON.stringify(obj), 'utf8'), n, xpub, eph.secretKey);
|
|
56
|
+
return { epk: b64(eph.publicKey), n: b64(n), c: b64(c) };
|
|
57
|
+
}
|
|
58
|
+
// Open a sealed box with the recipient's ed25519 SECRET (64 bytes). Returns obj or null.
|
|
59
|
+
function openSealed(secB64, sealed) {
|
|
60
|
+
try {
|
|
61
|
+
const xsec = edSecToX(u8(secB64));
|
|
62
|
+
const m = nacl.box.open(u8(sealed.c), u8(sealed.n), u8(sealed.epk), xsec);
|
|
63
|
+
return m ? JSON.parse(Buffer.from(m).toString('utf8')) : null;
|
|
64
|
+
} catch (_) { return null; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { newKey, newChannel, b64url, fromB64url, seal, open, inboxChannel, sealTo, openSealed, edPubToX, edSecToX };
|
package/src/extract.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Find YayLayer spec blocks in source, parse their fields, and grab the code
|
|
3
|
+
// body of the unit they sit above (so verify can do static code⇔spec checks).
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const { MARK_BEGIN, MARK_END, langOf } = require('./util');
|
|
7
|
+
|
|
8
|
+
// Parse the "key: value" lines inside a spec block. Continuation lines (comment
|
|
9
|
+
// lines with no "key:") append to the previous field.
|
|
10
|
+
function parseSpec(blockLines) {
|
|
11
|
+
const fields = {};
|
|
12
|
+
let last = null;
|
|
13
|
+
for (const raw of blockLines) {
|
|
14
|
+
// Comment-agnostic: strip ANY leading comment punctuation (// # -- ; % ! ' (* <!-- *)
|
|
15
|
+
// and any trailing block-comment closer (*/ *) -->). This is what lets a spec
|
|
16
|
+
// block live in essentially any language's comments, not just // and #.
|
|
17
|
+
const line = raw
|
|
18
|
+
.replace(/^\s*(?:\/\/+|#+|--+|;+|%+|!+|'+|\(\*|<!--|\*+)\s?/, '')
|
|
19
|
+
.replace(/\s*(?:\*\/|\*\)|-->)\s*$/, '')
|
|
20
|
+
.trim();
|
|
21
|
+
if (!line || line.startsWith('∷YAY')) continue;
|
|
22
|
+
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
|
23
|
+
if (m) {
|
|
24
|
+
last = m[1].toLowerCase();
|
|
25
|
+
fields[last] = m[2].trim();
|
|
26
|
+
} else if (last) {
|
|
27
|
+
fields[last] += ' ' + line;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return fields;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The JS/TS declaration pattern (unchanged): function decls and const/let/var
|
|
34
|
+
// (arrow/function) assignments. Kept precise so JS/TS analysis behaves exactly as before.
|
|
35
|
+
const JS_DECL = /(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_$]+)|(?:export\s+)?(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=/;
|
|
36
|
+
|
|
37
|
+
// For the generic brace grabber: a keyword that introduces a unit by name, a
|
|
38
|
+
// type/container keyword, and identifier-before-`(` (a definition or a call). We
|
|
39
|
+
// EXCLUDE control-flow keywords from the identifier-before-`(` case so `if (`,
|
|
40
|
+
// `for (`, `return foo(` etc. are never mistaken for a unit.
|
|
41
|
+
const FN_KW = /\b(?:fn|def|defp|func|fun|function|sub)\s+(?:self\.)?([A-Za-z_][A-Za-z0-9_]*)/;
|
|
42
|
+
const TYPE_KW = /\b(?:class|struct|interface|enum|record|trait|impl|contract|library|module|namespace|object|protocol|actor)\s+([A-Za-z_][A-Za-z0-9_]*)/;
|
|
43
|
+
const CALLISH = /([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
|
|
44
|
+
const CONTROL = new Set(['if', 'for', 'while', 'switch', 'catch', 'foreach', 'return', 'new', 'sizeof', 'typeof', 'nameof', 'await', 'throw', 'using', 'lock', 'fixed', 'with', 'do', 'else', 'when', 'unless', 'case', 'elif', 'elsif', 'match', 'require', 'import', 'package', 'use', 'and', 'or', 'not', 'in', 'is', 'as']);
|
|
45
|
+
|
|
46
|
+
// Brace-match from line `i` to the line closing its first `{` block; returns the
|
|
47
|
+
// end line index. `body` includes the signature so file line numbers line up.
|
|
48
|
+
function braceEnd(lines, i) {
|
|
49
|
+
let depth = 0, started = false, end = i;
|
|
50
|
+
for (let j = i; j < lines.length; j++) {
|
|
51
|
+
for (const ch of lines[j]) {
|
|
52
|
+
if (ch === '{') { depth++; started = true; }
|
|
53
|
+
else if (ch === '}') { depth--; }
|
|
54
|
+
}
|
|
55
|
+
end = j;
|
|
56
|
+
if (started && depth <= 0) break;
|
|
57
|
+
if (!started && j >= i + 2) break; // no block body on this decl
|
|
58
|
+
}
|
|
59
|
+
return end;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// JS/TS grabber (declaration + brace) — unchanged behaviour.
|
|
63
|
+
function grabJsBody(lines, fromLine) {
|
|
64
|
+
for (let i = fromLine; i < Math.min(lines.length, fromLine + 6); i++) {
|
|
65
|
+
const m = lines[i].match(JS_DECL);
|
|
66
|
+
if (!m) continue;
|
|
67
|
+
const end = braceEnd(lines, i);
|
|
68
|
+
return { name: m[1] || m[2], startLine: i, body: lines.slice(i, end + 1).join('\n') };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Generic C-family grabber (Go, Java, C/C++, Kotlin, Swift, PHP, Scala, Dart, C#,
|
|
74
|
+
// Solidity, Rust, …). Body content isn't used for non-JS checks, so precision of
|
|
75
|
+
// the body doesn't matter — we only need the unit's NAME and that it EXISTS.
|
|
76
|
+
function grabGenericBraceBody(lines, fromLine) {
|
|
77
|
+
for (let i = fromLine; i < Math.min(lines.length, fromLine + 6); i++) {
|
|
78
|
+
const line = lines[i];
|
|
79
|
+
let name = null;
|
|
80
|
+
const fk = line.match(FN_KW);
|
|
81
|
+
if (fk) name = fk[1];
|
|
82
|
+
if (!name) {
|
|
83
|
+
CALLISH.lastIndex = 0; let m;
|
|
84
|
+
while ((m = CALLISH.exec(line))) { if (!CONTROL.has(m[1])) { name = m[1]; break; } }
|
|
85
|
+
}
|
|
86
|
+
if (!name) { const tk = line.match(TYPE_KW); if (tk) name = tk[1]; }
|
|
87
|
+
if (!name) continue;
|
|
88
|
+
const end = braceEnd(lines, i);
|
|
89
|
+
return { name, startLine: i, body: lines.slice(i, end + 1).join('\n') };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Indentation grabber (Python): block continues while indented deeper than the
|
|
95
|
+
// `def`/`class` line; blank lines belong to the block.
|
|
96
|
+
function grabPythonBody(lines, fromLine) {
|
|
97
|
+
const decl = /^(\s*)(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)|^(\s*)class\s+([A-Za-z_][A-Za-z0-9_]*)/;
|
|
98
|
+
for (let i = fromLine; i < Math.min(lines.length, fromLine + 6); i++) {
|
|
99
|
+
const m = lines[i].match(decl);
|
|
100
|
+
if (!m) continue;
|
|
101
|
+
const indent = (m[1] !== undefined ? m[1] : m[3]).length;
|
|
102
|
+
const name = m[2] || m[4];
|
|
103
|
+
let end = i;
|
|
104
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
105
|
+
if (!lines[j].trim()) { end = j; continue; } // blank line → still inside the block
|
|
106
|
+
const ind = (lines[j].match(/^(\s*)/)[1] || '').length;
|
|
107
|
+
if (ind <= indent) break;
|
|
108
|
+
end = j;
|
|
109
|
+
}
|
|
110
|
+
return { name, startLine: i, body: lines.slice(i, end + 1).join('\n') };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// def…end grabber (Ruby, Elixir): from `def name` to the matching `end` at the
|
|
116
|
+
// same-or-lower indent. Best-effort — body precision isn't needed for these langs.
|
|
117
|
+
function grabEndBody(lines, fromLine) {
|
|
118
|
+
const decl = /^(\s*)(?:def|defp)\s+(?:self\.)?([A-Za-z_][A-Za-z0-9_?!]*)/;
|
|
119
|
+
for (let i = fromLine; i < Math.min(lines.length, fromLine + 6); i++) {
|
|
120
|
+
const m = lines[i].match(decl);
|
|
121
|
+
if (!m) continue;
|
|
122
|
+
const indent = m[1].length;
|
|
123
|
+
const endRe = new RegExp('^\\s{0,' + indent + '}end\\b');
|
|
124
|
+
let end = i;
|
|
125
|
+
for (let j = i + 1; j < lines.length; j++) { end = j; if (endRe.test(lines[j])) break; }
|
|
126
|
+
return { name: m[2], startLine: i, body: lines.slice(i, end + 1).join('\n') };
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Grab the body of the first unit at or after `fromLine`, by body-FAMILY. JS keeps
|
|
132
|
+
// its precise grabber; css/html/other reuse it (same as before, matches nothing new).
|
|
133
|
+
function grabUnitBody(lines, fromLine, family) {
|
|
134
|
+
if (family === 'python') return grabPythonBody(lines, fromLine);
|
|
135
|
+
if (family === 'ruby') return grabEndBody(lines, fromLine);
|
|
136
|
+
if (family === 'brace') return grabGenericBraceBody(lines, fromLine);
|
|
137
|
+
return grabJsBody(lines, fromLine);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Extract every Cell from one file.
|
|
141
|
+
function extractFile(file) {
|
|
142
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
143
|
+
const lines = text.split(/\r?\n/);
|
|
144
|
+
const lang = langOf(file);
|
|
145
|
+
const cells = [];
|
|
146
|
+
for (let i = 0; i < lines.length; i++) {
|
|
147
|
+
const b = lines[i].match(MARK_BEGIN);
|
|
148
|
+
if (!b) continue;
|
|
149
|
+
const id = b[1];
|
|
150
|
+
const block = [lines[i]];
|
|
151
|
+
let end = -1;
|
|
152
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
153
|
+
block.push(lines[j]);
|
|
154
|
+
const e = lines[j].match(MARK_END);
|
|
155
|
+
if (e) { end = j; break; }
|
|
156
|
+
}
|
|
157
|
+
if (end === -1) {
|
|
158
|
+
cells.push({ id, file, startLine: i + 1, endLine: lines.length, malformed: 'missing ∷YAY-END marker' });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// normalized spec text (markers + inner lines, trailing ws stripped) → hashed later
|
|
162
|
+
const normalized = block.map((l) => l.replace(/\s+$/, '')).join('\n');
|
|
163
|
+
const spec = parseSpec(block);
|
|
164
|
+
const unit = grabUnitBody(lines, end + 1, lang);
|
|
165
|
+
cells.push({
|
|
166
|
+
id, file, startLine: i + 1, endLine: end + 1,
|
|
167
|
+
normalized, spec,
|
|
168
|
+
unitName: spec.unit || (unit && unit.name) || null,
|
|
169
|
+
detectedUnit: (unit && unit.name) || null, // the ACTUAL name found in the code (any language) — lets verify flag a spec⇔code rename in Python/Ruby/brace files too, not just via the JS AST
|
|
170
|
+
unitBody: unit ? unit.body : null,
|
|
171
|
+
unitBodyStart: unit ? unit.startLine : null,
|
|
172
|
+
unitFound: !!unit,
|
|
173
|
+
});
|
|
174
|
+
i = end;
|
|
175
|
+
}
|
|
176
|
+
return cells;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { extractFile, parseSpec, grabUnitBody };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The FOUNDATION SEAL — an owner-signed baseline of a project's fixed core, so that any
|
|
3
|
+
// change to it is REVEALED at verify time. It watches two things:
|
|
4
|
+
//
|
|
5
|
+
// 1. CONTENT drift — the hash of each sealed core file (for the AI-rule files, only the
|
|
6
|
+
// YAYLAYER block, so the owner's own surrounding notes stay free).
|
|
7
|
+
// 2. STRUCTURAL drift — the set of TRACKED files in watched zones (repo root, .github, the
|
|
8
|
+
// .yaylayer config), so a NEW or REMOVED file (a dropped payload, a rogue workflow) shows.
|
|
9
|
+
//
|
|
10
|
+
// It is CAUSE-AGNOSTIC: a hijacked AI, a disk/bit-rot corruption, a bad merge, or a fat-fingered
|
|
11
|
+
// edit all surface the same way — the current bytes no longer match what the Owner last vouched
|
|
12
|
+
// for. This is detection, not prevention: the human investigates and re-seals (owner key only).
|
|
13
|
+
//
|
|
14
|
+
// Pure + testable: the exported functions take a root and a caller-supplied list of tracked
|
|
15
|
+
// files (so the git dependency lives in the CLI, and the smoke tests can drive it directly).
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
|
|
21
|
+
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
22
|
+
|
|
23
|
+
// Owner-chosen exclusions (globs) for TRACKED files that legitimately churn inside a watched
|
|
24
|
+
// zone (e.g. a committed CHANGELOG or version file). Gitignored files (.env…) are already out
|
|
25
|
+
// (the seal watches only the tracked set), so this is only for committed-but-churning files.
|
|
26
|
+
// The list is stored INSIDE the signed seal, so an exclusion is an owner-vouched act — the AI
|
|
27
|
+
// cannot add "ignore my-payload.js" without the phone key. Each exclusion is a signed blind spot.
|
|
28
|
+
function globToRe(g) {
|
|
29
|
+
const esc = String(g).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, ' ').replace(/\*/g, '[^/]*').replace(/ /g, '.*');
|
|
30
|
+
return new RegExp('^' + esc + '$');
|
|
31
|
+
}
|
|
32
|
+
function isIgnored(rel, globs) {
|
|
33
|
+
const f = String(rel).replace(/\\/g, '/');
|
|
34
|
+
return (globs || []).some((g) => { try { return globToRe(g).test(f); } catch (_) { return false; } });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The BEGIN/END markers `yay constitution` wraps the managed rules block in. Content-sealing a
|
|
38
|
+
// rule file hashes ONLY this block, so a project's own additions around it never trip the seal.
|
|
39
|
+
const BLOCK_BEGIN = '<!-- YAYLAYER:BEGIN';
|
|
40
|
+
const BLOCK_END = '<!-- YAYLAYER:END -->';
|
|
41
|
+
function extractBlock(text) {
|
|
42
|
+
const i = text.indexOf(BLOCK_BEGIN);
|
|
43
|
+
if (i < 0) return null;
|
|
44
|
+
const j = text.indexOf(BLOCK_END, i);
|
|
45
|
+
if (j < 0) return null;
|
|
46
|
+
return text.slice(i, j + BLOCK_END.length);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// AI-rule files — content-sealed on their YAYLAYER block only (blockOnly:true).
|
|
50
|
+
const RULE_FILES = [
|
|
51
|
+
'CLAUDE.md', 'AGENTS.md', '.cursorrules', 'GEMINI.md', '.windsurfrules', '.clinerules',
|
|
52
|
+
'.github/copilot-instructions.md',
|
|
53
|
+
];
|
|
54
|
+
// Other core files — content-sealed whole (they have no managed block).
|
|
55
|
+
// `.gitignore` is sealed on purpose: it controls the structural blind spot (a rogue AI could
|
|
56
|
+
// add `payload.js` to it to make the file untracked → invisible to the zone watch), so any
|
|
57
|
+
// change to what's hidden is itself revealed and needs a re-seal.
|
|
58
|
+
const CORE_FILES = [
|
|
59
|
+
// CI gate config across the common hosts (only the ones that exist are sealed).
|
|
60
|
+
'.github/workflows/yaylayer.yml', '.gitea/workflows/yaylayer.yml', 'azure-pipelines.yml', '.gitlab-ci.yml', 'bitbucket-pipelines.yml',
|
|
61
|
+
'.yaylayerignore', '.gitignore', 'CONSTITUTION.md', 'standard/STANDARD.md',
|
|
62
|
+
// `.gitattributes` sets the ledgers' union-merge driver — sealed so a rogue change to how the
|
|
63
|
+
// append-only records merge (e.g. silencing conflicts) is itself revealed.
|
|
64
|
+
'.gitattributes',
|
|
65
|
+
];
|
|
66
|
+
// Zones whose TRACKED file-set should stay stable — a new/removed file here is revealed.
|
|
67
|
+
// Root is watched at TOP LEVEL only (depth 0); the others recurse.
|
|
68
|
+
const WATCH_ZONES = [
|
|
69
|
+
{ dir: '.', recurse: false },
|
|
70
|
+
{ dir: '.github', recurse: true },
|
|
71
|
+
{ dir: '.yaylayer', recurse: false },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// Digest one sealed file. blockOnly → hash just the YAYLAYER block. Returns null if absent
|
|
75
|
+
// (a MISSING sealed file is itself a finding, surfaced by compareSeal, not an error here).
|
|
76
|
+
function fileDigest(root, rel, blockOnly) {
|
|
77
|
+
let text;
|
|
78
|
+
try { text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch (_) { return null; }
|
|
79
|
+
if (blockOnly) { const b = extractBlock(text); return b == null ? null : sha256(b); }
|
|
80
|
+
return sha256(text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Restrict a caller-supplied tracked-file list to a zone's own files (posix-relative paths).
|
|
84
|
+
function filesInZone(tracked, zone) {
|
|
85
|
+
const prefix = zone.dir === '.' ? '' : zone.dir.replace(/\\/g, '/').replace(/\/$/, '') + '/';
|
|
86
|
+
return (tracked || []).map((f) => f.replace(/\\/g, '/')).filter((f) => {
|
|
87
|
+
if (zone.dir === '.') return !f.includes('/'); // top-level only
|
|
88
|
+
if (!f.startsWith(prefix)) return false;
|
|
89
|
+
return zone.recurse ? true : !f.slice(prefix.length).includes('/');
|
|
90
|
+
}).sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Build the seal payload the Owner signs. `tracked` is the repo's tracked-file list (e.g. from
|
|
94
|
+
// `git ls-files`); `opts.files`/`opts.zones` override the defaults (owner --add/--remove).
|
|
95
|
+
function buildSeal(root, tracked, opts = {}) {
|
|
96
|
+
const ruleFiles = opts.ruleFiles || RULE_FILES;
|
|
97
|
+
const coreFiles = opts.coreFiles || CORE_FILES;
|
|
98
|
+
const zones = opts.zones || WATCH_ZONES;
|
|
99
|
+
const ignore = opts.ignore || [];
|
|
100
|
+
const extra = opts.extra || []; // owner-added globs (`yay protect --add`): content-seal matching tracked files
|
|
101
|
+
const files = {};
|
|
102
|
+
// Only seal rule/core files that actually exist — you can't vouch for what's not there.
|
|
103
|
+
for (const rel of ruleFiles) { if (isIgnored(rel, ignore)) continue; const d = fileDigest(root, rel, true); if (d) files[rel] = { hash: d, block: true }; }
|
|
104
|
+
for (const rel of coreFiles) { if (isIgnored(rel, ignore)) continue; const d = fileDigest(root, rel, false); if (d) files[rel] = { hash: d, block: false }; }
|
|
105
|
+
// Owner-added extras: any tracked file matching an --add glob (whole-file hash).
|
|
106
|
+
if (extra.length) for (const rel of (tracked || []).map((f) => f.replace(/\\/g, '/'))) {
|
|
107
|
+
if (files[rel] || isIgnored(rel, ignore) || !isIgnored(rel, extra)) continue; // reuse glob-match via isIgnored
|
|
108
|
+
const d = fileDigest(root, rel, false); if (d) files[rel] = { hash: d, block: false };
|
|
109
|
+
}
|
|
110
|
+
const zoneSets = {};
|
|
111
|
+
for (const z of zones) zoneSets[z.dir] = { recurse: !!z.recurse, files: filesInZone(tracked, z).filter((f) => !isIgnored(f, ignore)) };
|
|
112
|
+
return { version: 1, files, zones: zoneSets, ignore, extra };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Compare the CURRENT tree against a signed seal → the findings a human should see.
|
|
116
|
+
// changed[] — a sealed file whose bytes/block no longer match
|
|
117
|
+
// missing[] — a sealed file that has vanished
|
|
118
|
+
// addedFiles[] — a tracked file that appeared in a watched zone since sealing
|
|
119
|
+
// removedFiles[] — a tracked file that vanished from a watched zone
|
|
120
|
+
function compareSeal(root, seal, tracked) {
|
|
121
|
+
const ignore = (seal && seal.ignore) || []; // the SIGNED exclusions — applied to the current tree too
|
|
122
|
+
const changed = [], missing = [];
|
|
123
|
+
for (const rel of Object.keys((seal && seal.files) || {})) {
|
|
124
|
+
const want = seal.files[rel];
|
|
125
|
+
const now = fileDigest(root, rel, !!want.block);
|
|
126
|
+
if (now == null) missing.push(rel);
|
|
127
|
+
else if (now !== want.hash) changed.push(rel);
|
|
128
|
+
}
|
|
129
|
+
const addedFiles = [], removedFiles = [];
|
|
130
|
+
// Structural check needs the tracked-file list; skip it (content-only) when it's unavailable,
|
|
131
|
+
// so we never report false "removed" for every zone file.
|
|
132
|
+
if (Array.isArray(tracked)) for (const dir of Object.keys((seal && seal.zones) || {})) {
|
|
133
|
+
const z = seal.zones[dir];
|
|
134
|
+
const before = new Set(z.files || []);
|
|
135
|
+
const after = new Set(filesInZone(tracked, { dir, recurse: z.recurse }).filter((f) => !isIgnored(f, ignore)));
|
|
136
|
+
for (const f of after) if (!before.has(f)) addedFiles.push(f);
|
|
137
|
+
for (const f of before) if (!after.has(f)) removedFiles.push(f);
|
|
138
|
+
}
|
|
139
|
+
const clean = !changed.length && !missing.length && !addedFiles.length && !removedFiles.length;
|
|
140
|
+
return { clean, changed, missing, addedFiles: addedFiles.sort(), removedFiles: removedFiles.sort() };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { buildSeal, compareSeal, fileDigest, extractBlock, filesInZone, sha256, RULE_FILES, CORE_FILES, WATCH_ZONES };
|