brainblast 0.5.2 → 0.6.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/dist/{chunk-EYFKA33G.js → chunk-A56IF3UX.js} +137 -940
- package/dist/chunk-SC6RNNDW.js +160 -0
- package/dist/chunk-ZZ6LBZV5.js +909 -0
- package/dist/cli.js +99 -4
- package/dist/diff-PZKZYBKF.js +12 -0
- package/dist/index.d.ts +26 -1
- package/dist/index.js +33 -19
- package/dist/mcp-RUVILE2Y.js +172 -0
- package/package.json +2 -1
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PACK_MANIFEST_FILE,
|
|
3
|
+
SKIP_DIRS,
|
|
4
|
+
audit,
|
|
5
|
+
auditWithRule,
|
|
6
|
+
getWorkingTreeChanges,
|
|
7
|
+
loadPack,
|
|
8
|
+
resolveRules,
|
|
9
|
+
walk
|
|
10
|
+
} from "./chunk-A56IF3UX.js";
|
|
11
|
+
|
|
12
|
+
// src/trustGraph/directory.ts
|
|
13
|
+
import { readFileSync, existsSync } from "fs";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { parse } from "yaml";
|
|
17
|
+
var cache = null;
|
|
18
|
+
function bundledPath() {
|
|
19
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
20
|
+
const candidates = [
|
|
21
|
+
join(here, "programs", "directory.yaml"),
|
|
22
|
+
// dist/programs/directory.yaml
|
|
23
|
+
join(here, "..", "..", "programs", "directory.yaml"),
|
|
24
|
+
// src/../../programs/
|
|
25
|
+
join(here, "..", "programs", "directory.yaml")
|
|
26
|
+
// fallback
|
|
27
|
+
];
|
|
28
|
+
for (const c of candidates) {
|
|
29
|
+
if (existsSync(c)) return c;
|
|
30
|
+
}
|
|
31
|
+
return candidates[0];
|
|
32
|
+
}
|
|
33
|
+
function loadDirectory(path = bundledPath()) {
|
|
34
|
+
if (cache && path === bundledPath()) return cache;
|
|
35
|
+
const raw = parse(readFileSync(path, "utf8"));
|
|
36
|
+
if (!raw || !Array.isArray(raw.programs)) {
|
|
37
|
+
throw new Error(`invalid program directory at ${path}: missing 'programs' array`);
|
|
38
|
+
}
|
|
39
|
+
const m = /* @__PURE__ */ new Map();
|
|
40
|
+
for (const p of raw.programs) {
|
|
41
|
+
if (!p.programId || !p.name) throw new Error(`directory entry missing programId/name: ${JSON.stringify(p)}`);
|
|
42
|
+
if (m.has(p.programId)) throw new Error(`directory has duplicate programId ${p.programId}`);
|
|
43
|
+
m.set(p.programId, { ...p, provenance: { ...p.provenance ?? {}, directoryFile: path } });
|
|
44
|
+
}
|
|
45
|
+
if (path === bundledPath()) cache = m;
|
|
46
|
+
return m;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/trustGraph/base58.ts
|
|
50
|
+
var ALPHA = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
51
|
+
var MAP = {};
|
|
52
|
+
for (let i = 0; i < ALPHA.length; i++) MAP[ALPHA[i]] = i;
|
|
53
|
+
function base58Encode(bytes) {
|
|
54
|
+
let zeros = 0;
|
|
55
|
+
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
|
56
|
+
const buf = Array.from(bytes);
|
|
57
|
+
const out = [];
|
|
58
|
+
let start = zeros;
|
|
59
|
+
while (start < buf.length) {
|
|
60
|
+
let rem = 0;
|
|
61
|
+
for (let i = start; i < buf.length; i++) {
|
|
62
|
+
const acc = rem * 256 + buf[i];
|
|
63
|
+
buf[i] = Math.floor(acc / 58);
|
|
64
|
+
rem = acc % 58;
|
|
65
|
+
}
|
|
66
|
+
out.push(rem);
|
|
67
|
+
if (buf[start] === 0) start++;
|
|
68
|
+
}
|
|
69
|
+
let s = "";
|
|
70
|
+
for (let i = 0; i < zeros; i++) s += "1";
|
|
71
|
+
for (let i = out.length - 1; i >= 0; i--) s += ALPHA[out[i]];
|
|
72
|
+
return s;
|
|
73
|
+
}
|
|
74
|
+
function base58Decode(s) {
|
|
75
|
+
let zeros = 0;
|
|
76
|
+
while (zeros < s.length && s[zeros] === "1") zeros++;
|
|
77
|
+
const buf = [];
|
|
78
|
+
for (let i = zeros; i < s.length; i++) {
|
|
79
|
+
const v = MAP[s[i]];
|
|
80
|
+
if (v === void 0) throw new Error(`base58: invalid char '${s[i]}' at ${i}`);
|
|
81
|
+
let carry = v;
|
|
82
|
+
for (let j = 0; j < buf.length; j++) {
|
|
83
|
+
const acc = buf[j] * 58 + carry;
|
|
84
|
+
buf[j] = acc & 255;
|
|
85
|
+
carry = acc >>> 8;
|
|
86
|
+
}
|
|
87
|
+
while (carry > 0) {
|
|
88
|
+
buf.push(carry & 255);
|
|
89
|
+
carry >>>= 8;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const out = new Uint8Array(zeros + buf.length);
|
|
93
|
+
for (let i = 0; i < buf.length; i++) out[zeros + buf.length - 1 - i] = buf[i];
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
function isValidSolanaAddress(s) {
|
|
97
|
+
if (typeof s !== "string" || s.length < 32 || s.length > 44) return false;
|
|
98
|
+
try {
|
|
99
|
+
return base58Decode(s).length === 32;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/trustGraph/programCache.ts
|
|
106
|
+
import { readFileSync as readFileSync2, writeFileSync, mkdirSync, existsSync as existsSync2 } from "fs";
|
|
107
|
+
import { join as join2, dirname } from "path";
|
|
108
|
+
import { homedir } from "os";
|
|
109
|
+
var DEFAULT_TTL_HOURS = 168;
|
|
110
|
+
var SCHEMA_VERSION = "1.0";
|
|
111
|
+
function defaultCachePath() {
|
|
112
|
+
const envOverride = process.env["BRAINBLAST_CACHE_PATH"];
|
|
113
|
+
return envOverride ?? join2(homedir(), ".brainblast", "program-cache.json");
|
|
114
|
+
}
|
|
115
|
+
function emptyCache() {
|
|
116
|
+
return { schemaVersion: SCHEMA_VERSION, entries: {} };
|
|
117
|
+
}
|
|
118
|
+
function loadProgramCache(cachePath) {
|
|
119
|
+
const path = cachePath ?? defaultCachePath();
|
|
120
|
+
if (!existsSync2(path)) return emptyCache();
|
|
121
|
+
try {
|
|
122
|
+
const raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
123
|
+
if (raw?.schemaVersion !== SCHEMA_VERSION) {
|
|
124
|
+
return emptyCache();
|
|
125
|
+
}
|
|
126
|
+
if (!raw.entries || typeof raw.entries !== "object") return emptyCache();
|
|
127
|
+
return { schemaVersion: SCHEMA_VERSION, entries: raw.entries };
|
|
128
|
+
} catch {
|
|
129
|
+
return emptyCache();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function saveProgramCache(cache2, cachePath) {
|
|
133
|
+
const path = cachePath ?? defaultCachePath();
|
|
134
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
135
|
+
writeFileSync(path, JSON.stringify(cache2, null, 2), "utf8");
|
|
136
|
+
}
|
|
137
|
+
function getCacheEntry(cache2, programId, ttlHoursOverride) {
|
|
138
|
+
const entry = cache2.entries[programId];
|
|
139
|
+
if (!entry) return null;
|
|
140
|
+
if (isEntryExpired(entry, ttlHoursOverride)) return null;
|
|
141
|
+
return entry.program;
|
|
142
|
+
}
|
|
143
|
+
function putCacheEntry(cache2, programId, program, sourceRun, ttlHours = DEFAULT_TTL_HOURS) {
|
|
144
|
+
cache2.entries[programId] = {
|
|
145
|
+
program,
|
|
146
|
+
cachedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
147
|
+
sourceRun,
|
|
148
|
+
ttlHours
|
|
149
|
+
};
|
|
150
|
+
return cache2;
|
|
151
|
+
}
|
|
152
|
+
function getCacheEntryMeta(cache2, programId) {
|
|
153
|
+
return cache2.entries[programId] ?? null;
|
|
154
|
+
}
|
|
155
|
+
function isEntryExpired(entry, ttlHoursOverride) {
|
|
156
|
+
const ttl = ttlHoursOverride ?? entry.ttlHours ?? DEFAULT_TTL_HOURS;
|
|
157
|
+
if (ttl <= 0) return true;
|
|
158
|
+
const cachedMs = Date.parse(entry.cachedAt);
|
|
159
|
+
if (Number.isNaN(cachedMs)) return true;
|
|
160
|
+
const ageMs = Date.now() - cachedMs;
|
|
161
|
+
return ageMs >= ttl * 36e5;
|
|
162
|
+
}
|
|
163
|
+
function cacheSize(cache2, ttlHoursOverride) {
|
|
164
|
+
return Object.values(cache2.entries).filter((e) => !isEntryExpired(e, ttlHoursOverride)).length;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/trustGraph/rpc.ts
|
|
168
|
+
var BPF_UPGRADEABLE_LOADER = "BPFLoaderUpgradeab1e11111111111111111111111";
|
|
169
|
+
var BPF_LOADER_2 = "BPFLoader2111111111111111111111111111111111";
|
|
170
|
+
var NATIVE_LOADER = "NativeLoader1111111111111111111111111111111";
|
|
171
|
+
var DEFAULT_RPC = "https://api.mainnet-beta.solana.com";
|
|
172
|
+
async function rpc(method, params, opts) {
|
|
173
|
+
const url = opts.rpcUrl ?? DEFAULT_RPC;
|
|
174
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
175
|
+
const ac = new AbortController();
|
|
176
|
+
const t = setTimeout(() => ac.abort(), opts.timeoutMs ?? 1e4);
|
|
177
|
+
try {
|
|
178
|
+
const res = await fetchImpl(url, {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: { "content-type": "application/json" },
|
|
181
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
182
|
+
signal: ac.signal
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok) throw new Error(`rpc ${method}: HTTP ${res.status}`);
|
|
185
|
+
const body = await res.json();
|
|
186
|
+
if (body.error) throw new Error(`rpc ${method}: ${body.error.message}`);
|
|
187
|
+
if (body.result === void 0) throw new Error(`rpc ${method}: empty result`);
|
|
188
|
+
return body.result;
|
|
189
|
+
} finally {
|
|
190
|
+
clearTimeout(t);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function getAccountInfo(address, opts = {}) {
|
|
194
|
+
if (!isValidSolanaAddress(address)) throw new Error(`invalid Solana address: ${address}`);
|
|
195
|
+
const result = await rpc(
|
|
196
|
+
"getAccountInfo",
|
|
197
|
+
[address, { encoding: "base64", commitment: "confirmed" }],
|
|
198
|
+
opts
|
|
199
|
+
);
|
|
200
|
+
if (!result || !result.value) return null;
|
|
201
|
+
const v = result.value;
|
|
202
|
+
const [b64] = v.data;
|
|
203
|
+
return {
|
|
204
|
+
owner: v.owner,
|
|
205
|
+
data: Buffer.from(b64, "base64"),
|
|
206
|
+
executable: v.executable,
|
|
207
|
+
lamports: v.lamports
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async function probeUpgradeAuthority(programId, opts = {}) {
|
|
211
|
+
const acct = await getAccountInfo(programId, opts);
|
|
212
|
+
if (!acct) {
|
|
213
|
+
return {
|
|
214
|
+
kind: "unknown",
|
|
215
|
+
address: null,
|
|
216
|
+
source: "rpc",
|
|
217
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (acct.owner === BPF_LOADER_2 || acct.owner === NATIVE_LOADER) {
|
|
221
|
+
return {
|
|
222
|
+
kind: "renounced",
|
|
223
|
+
address: null,
|
|
224
|
+
source: "rpc",
|
|
225
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (acct.owner !== BPF_UPGRADEABLE_LOADER) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`program ${programId} is owned by ${acct.owner}, not a known loader; not a deployed program?`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (acct.data.length < 36) throw new Error(`program account too small: ${acct.data.length}`);
|
|
234
|
+
const tag = acct.data[0] | acct.data[1] << 8 | acct.data[2] << 16 | acct.data[3] << 24;
|
|
235
|
+
if (tag !== 2) throw new Error(`expected Program (tag=2) state, got tag=${tag}`);
|
|
236
|
+
const programDataAddr = base58Encode(acct.data.subarray(4, 36));
|
|
237
|
+
const pd = await getAccountInfo(programDataAddr, opts);
|
|
238
|
+
if (!pd) {
|
|
239
|
+
throw new Error(`program ${programId} ProgramData ${programDataAddr} not found`);
|
|
240
|
+
}
|
|
241
|
+
if (pd.data.length < 45) throw new Error(`ProgramData account too small: ${pd.data.length}`);
|
|
242
|
+
const pdTag = pd.data[0] | pd.data[1] << 8 | pd.data[2] << 16 | pd.data[3] << 24;
|
|
243
|
+
if (pdTag !== 3) throw new Error(`expected ProgramData (tag=3), got tag=${pdTag}`);
|
|
244
|
+
const optionTag = pd.data[12];
|
|
245
|
+
const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
246
|
+
if (optionTag === 0) {
|
|
247
|
+
return { kind: "renounced", address: null, source: "rpc", checkedAt };
|
|
248
|
+
}
|
|
249
|
+
if (optionTag !== 1) throw new Error(`unexpected Option tag in ProgramData: ${optionTag}`);
|
|
250
|
+
const authority = base58Encode(pd.data.subarray(13, 45));
|
|
251
|
+
return { kind: "unknown", address: authority, source: "rpc", checkedAt };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/trustGraph/build.ts
|
|
255
|
+
async function buildTrustGraph(programIds, opts = {}) {
|
|
256
|
+
const dir = loadDirectory(opts.directoryPath);
|
|
257
|
+
const programs = [];
|
|
258
|
+
const unresolved = [];
|
|
259
|
+
const cacheEnabled = opts.cachePath !== null;
|
|
260
|
+
const cachePathArg = opts.cachePath === null ? void 0 : opts.cachePath;
|
|
261
|
+
const cache2 = cacheEnabled ? loadProgramCache(cachePathArg) : null;
|
|
262
|
+
const newFromRpc = [];
|
|
263
|
+
const runId = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
264
|
+
const seen = /* @__PURE__ */ new Set();
|
|
265
|
+
const ordered = programIds.filter((id) => seen.has(id) ? false : (seen.add(id), true));
|
|
266
|
+
for (const id of ordered) {
|
|
267
|
+
const directoryHit = dir.get(id);
|
|
268
|
+
if (directoryHit) {
|
|
269
|
+
programs.push(directoryHit);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (cache2) {
|
|
273
|
+
const cached = getCacheEntry(cache2, id);
|
|
274
|
+
if (cached) {
|
|
275
|
+
const meta = getCacheEntryMeta(cache2, id);
|
|
276
|
+
programs.push({
|
|
277
|
+
...cached,
|
|
278
|
+
provenance: {
|
|
279
|
+
...cached.provenance ?? {},
|
|
280
|
+
notes: [
|
|
281
|
+
cached.provenance?.notes,
|
|
282
|
+
`cache-hit: cachedAt=${meta.cachedAt} sourceRun=${meta.sourceRun}`
|
|
283
|
+
].filter(Boolean).join("; ")
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (opts.probeRpc === false) {
|
|
290
|
+
unresolved.push({
|
|
291
|
+
programId: id,
|
|
292
|
+
reason: "not_in_directory_or_cache_and_rpc_disabled"
|
|
293
|
+
});
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
let authority;
|
|
297
|
+
try {
|
|
298
|
+
authority = await probeUpgradeAuthority(id, opts);
|
|
299
|
+
} catch (e) {
|
|
300
|
+
unresolved.push({ programId: id, reason: `rpc_error: ${e?.message ?? String(e)}` });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const probed = {
|
|
304
|
+
programId: id,
|
|
305
|
+
name: `Unknown program (${id.slice(0, 8)}\u2026)`,
|
|
306
|
+
kind: "app",
|
|
307
|
+
upgradeAuthority: authority,
|
|
308
|
+
verifiedBuild: { state: "unknown" },
|
|
309
|
+
audits: [],
|
|
310
|
+
parity: { mainnet: "unknown", devnet: "unknown" },
|
|
311
|
+
provenance: { rpcUrl: opts.rpcUrl, notes: "live-probed; not in curated directory" }
|
|
312
|
+
};
|
|
313
|
+
programs.push(probed);
|
|
314
|
+
newFromRpc.push(id);
|
|
315
|
+
if (cache2) {
|
|
316
|
+
putCacheEntry(cache2, id, probed, runId);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (cache2 && newFromRpc.length > 0) {
|
|
320
|
+
saveProgramCache(cache2, cachePathArg);
|
|
321
|
+
}
|
|
322
|
+
return { programs, unresolved, generatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/trustGraph/render.ts
|
|
326
|
+
function renderAuthority(p) {
|
|
327
|
+
const a = p.upgradeAuthority;
|
|
328
|
+
switch (a.kind) {
|
|
329
|
+
case "renounced":
|
|
330
|
+
return "\u{1F512} **Renounced** \u2014 program is frozen; no key can upgrade it.";
|
|
331
|
+
case "single-key":
|
|
332
|
+
return `\u26A0\uFE0F **Single key** \`${a.address}\` \u2014 one private key can replace this program at any time.`;
|
|
333
|
+
case "multisig":
|
|
334
|
+
return `\u{1F510} **Multisig** \`${a.address}\` \u2014 a threshold of signers can upgrade.`;
|
|
335
|
+
case "dao":
|
|
336
|
+
return `\u{1F3DB} **DAO** \`${a.address}\` \u2014 governance program controls upgrades.`;
|
|
337
|
+
case "unknown":
|
|
338
|
+
return a.address ? `\u2753 **Unclassified authority** \`${a.address}\` \u2014 needs research to confirm single-key vs multisig/DAO.` : "\u2753 **Unknown** \u2014 could not determine upgrade authority.";
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function renderVerified(p) {
|
|
342
|
+
const v = p.verifiedBuild;
|
|
343
|
+
switch (v.state) {
|
|
344
|
+
case "verified":
|
|
345
|
+
return `\u2705 Verified build${v.commit ? ` @ \`${v.commit.slice(0, 12)}\`` : ""} \u2014 [registry](${v.registryUrl})`;
|
|
346
|
+
case "unverified":
|
|
347
|
+
return "\u274C Unverified \u2014 on-chain bytecode does not match any source we trust.";
|
|
348
|
+
case "unknown":
|
|
349
|
+
return "\u2753 Verified-build status not checked.";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function renderAudits(p) {
|
|
353
|
+
if (!p.audits.length) return "_No audits on file._";
|
|
354
|
+
return p.audits.map((a) => `- ${a.firm} (${a.date}) \u2014 [report](${a.reportUrl})${a.auditedCommit ? ` @ \`${a.auditedCommit.slice(0, 12)}\`` : ""}`).join("\n");
|
|
355
|
+
}
|
|
356
|
+
function renderParity(p) {
|
|
357
|
+
const { mainnet, devnet, testnet, notes } = p.parity;
|
|
358
|
+
const cells = [`mainnet=\`${mainnet}\``, `devnet=\`${devnet}\``];
|
|
359
|
+
if (testnet) cells.push(`testnet=\`${testnet}\``);
|
|
360
|
+
return cells.join(" \xB7 ") + (notes ? `
|
|
361
|
+
_${notes}_` : "");
|
|
362
|
+
}
|
|
363
|
+
function renderProgram(p) {
|
|
364
|
+
return [
|
|
365
|
+
`### ${p.name}`,
|
|
366
|
+
"",
|
|
367
|
+
`\`${p.programId}\`${p.kind ? ` \xB7 kind: \`${p.kind}\`` : ""}`,
|
|
368
|
+
"",
|
|
369
|
+
`- **Upgrade authority:** ${renderAuthority(p)}`,
|
|
370
|
+
`- **Verified build:** ${renderVerified(p)}`,
|
|
371
|
+
`- **Parity:** ${renderParity(p)}`,
|
|
372
|
+
`- **Audits:**
|
|
373
|
+
${renderAudits(p).split("\n").map((l) => " " + l).join("\n")}`,
|
|
374
|
+
p.invokes && p.invokes.length ? `- **Invokes (CPI):** ${p.invokes.map((id) => `\`${id}\``).join(", ")}` : ""
|
|
375
|
+
].filter(Boolean).join("\n");
|
|
376
|
+
}
|
|
377
|
+
function renderTrustGraphMd(g) {
|
|
378
|
+
const head = [
|
|
379
|
+
"# Trust Graph",
|
|
380
|
+
"",
|
|
381
|
+
`_Generated ${g.generatedAt}._`,
|
|
382
|
+
"",
|
|
383
|
+
"Every program your code transitively invokes, with the authority that controls it, the build-verification status, and the audits we found.",
|
|
384
|
+
""
|
|
385
|
+
].join("\n");
|
|
386
|
+
const body = g.programs.map(renderProgram).join("\n\n---\n\n");
|
|
387
|
+
const tail = g.unresolved.length ? [
|
|
388
|
+
"",
|
|
389
|
+
"---",
|
|
390
|
+
"",
|
|
391
|
+
"## Unresolved",
|
|
392
|
+
"",
|
|
393
|
+
...g.unresolved.map((u) => `- \`${u.programId}\` \u2014 ${u.reason}`)
|
|
394
|
+
].join("\n") : "";
|
|
395
|
+
return head + body + tail + "\n";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// src/costAnalysis.ts
|
|
399
|
+
import { Project, SyntaxKind } from "ts-morph";
|
|
400
|
+
var LAMPORTS_PER_BYTE_YEAR = 3480;
|
|
401
|
+
var EXEMPTION_THRESHOLD = 2;
|
|
402
|
+
var OVERHEAD_BYTES = 128;
|
|
403
|
+
var LAMPORTS_PER_SOL = 1e9;
|
|
404
|
+
function rentExemptMinimum(dataLen) {
|
|
405
|
+
return (dataLen + OVERHEAD_BYTES) * LAMPORTS_PER_BYTE_YEAR * EXEMPTION_THRESHOLD;
|
|
406
|
+
}
|
|
407
|
+
function lamportsToSol(lamports) {
|
|
408
|
+
return (lamports / LAMPORTS_PER_SOL).toFixed(9).replace(/\.?0+$/, "");
|
|
409
|
+
}
|
|
410
|
+
var KNOWN_FLOWS = [
|
|
411
|
+
{
|
|
412
|
+
call: "createMint",
|
|
413
|
+
module: "@solana/spl-token",
|
|
414
|
+
accountType: "SPL Token Mint",
|
|
415
|
+
dataLen: 82,
|
|
416
|
+
recoverability: "conditionally-recoverable",
|
|
417
|
+
recoverabilityNote: "Recoverable via `closeAccount` on the mint \u2014 requires mint supply = 0 and mint authority disabled. Most production mints never meet these conditions."
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
call: "createAssociatedTokenAccount",
|
|
421
|
+
module: "@solana/spl-token",
|
|
422
|
+
accountType: "Associated Token Account (ATA)",
|
|
423
|
+
dataLen: 165,
|
|
424
|
+
recoverability: "recoverable",
|
|
425
|
+
recoverabilityNote: "Recovered by calling `closeAccount`; lamports return to the destination wallet. Requires zero token balance."
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
call: "createAssociatedTokenAccountIdempotent",
|
|
429
|
+
module: "@solana/spl-token",
|
|
430
|
+
accountType: "Associated Token Account (ATA, idempotent)",
|
|
431
|
+
dataLen: 165,
|
|
432
|
+
recoverability: "recoverable",
|
|
433
|
+
recoverabilityNote: "Recovered by calling `closeAccount`; lamports return to the destination wallet. Requires zero token balance."
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
call: "createAccount",
|
|
437
|
+
module: "@solana/spl-token",
|
|
438
|
+
accountType: "SPL Token Account (explicit)",
|
|
439
|
+
dataLen: 165,
|
|
440
|
+
recoverability: "recoverable",
|
|
441
|
+
recoverabilityNote: "Recovered by calling `closeAccount`; lamports return to the destination wallet. Requires zero token balance."
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
call: "createV1",
|
|
445
|
+
module: "@metaplex-foundation/mpl-token-metadata",
|
|
446
|
+
accountType: "Metaplex Token Metadata",
|
|
447
|
+
// Base metadata: 1(key) + 32(update_auth) + 32(mint) + 4+name + 4+symbol + 4+uri
|
|
448
|
+
// + 2(seller_fee) + 1(creators opt) + 1(primary_sale) + 1(is_mutable) ≈ 679 bytes typical
|
|
449
|
+
dataLen: 679,
|
|
450
|
+
recoverability: "non-recoverable",
|
|
451
|
+
recoverabilityNote: "Metaplex metadata accounts cannot be closed. The lamport lockup is permanent for the lifetime of the token."
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
call: "createNft",
|
|
455
|
+
module: "@metaplex-foundation/mpl-token-metadata",
|
|
456
|
+
accountType: "Metaplex NFT Metadata",
|
|
457
|
+
dataLen: 679,
|
|
458
|
+
recoverability: "non-recoverable",
|
|
459
|
+
recoverabilityNote: "Metaplex metadata accounts cannot be closed. The lamport lockup is permanent."
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
call: "createAndMint",
|
|
463
|
+
module: "@metaplex-foundation/mpl-token-metadata",
|
|
464
|
+
accountType: "Metaplex Token Metadata + Mint",
|
|
465
|
+
dataLen: 679 + 82,
|
|
466
|
+
// metadata + mint
|
|
467
|
+
recoverability: "non-recoverable",
|
|
468
|
+
recoverabilityNote: "Metadata accounts cannot be closed. Mint rent is conditionally recoverable (requires 0 supply + disabled authority)."
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
call: "createFungible",
|
|
472
|
+
module: "@metaplex-foundation/mpl-token-metadata",
|
|
473
|
+
accountType: "Metaplex Fungible Token Metadata",
|
|
474
|
+
dataLen: 679,
|
|
475
|
+
recoverability: "non-recoverable",
|
|
476
|
+
recoverabilityNote: "Metaplex metadata accounts cannot be closed. The lamport lockup is permanent."
|
|
477
|
+
}
|
|
478
|
+
];
|
|
479
|
+
var LOOP_NODE_KINDS = /* @__PURE__ */ new Set([
|
|
480
|
+
SyntaxKind.ForStatement,
|
|
481
|
+
SyntaxKind.ForOfStatement,
|
|
482
|
+
SyntaxKind.ForInStatement,
|
|
483
|
+
SyntaxKind.WhileStatement,
|
|
484
|
+
SyntaxKind.DoStatement
|
|
485
|
+
]);
|
|
486
|
+
var ARRAY_METHOD_LOOPS = /* @__PURE__ */ new Set(["map", "forEach", "flatMap", "reduce", "filter"]);
|
|
487
|
+
function isInsideLoop(node) {
|
|
488
|
+
let cur = node;
|
|
489
|
+
while (cur) {
|
|
490
|
+
const k = cur.getKind?.();
|
|
491
|
+
if (k !== void 0 && LOOP_NODE_KINDS.has(k)) {
|
|
492
|
+
return { scalable: true, note: `call is inside a ${SyntaxKind[k]} \u2014 cost scales with loop iterations` };
|
|
493
|
+
}
|
|
494
|
+
if (k === SyntaxKind.CallExpression) {
|
|
495
|
+
const expr = cur.getExpression?.();
|
|
496
|
+
if (expr?.getKind?.() === SyntaxKind.PropertyAccessExpression) {
|
|
497
|
+
const name = expr.asKind?.(SyntaxKind.PropertyAccessExpression)?.getName?.();
|
|
498
|
+
if (name && ARRAY_METHOD_LOOPS.has(name)) {
|
|
499
|
+
return { scalable: true, note: `call is inside .${name}() \u2014 cost scales with array length` };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
cur = cur.getParent?.();
|
|
504
|
+
}
|
|
505
|
+
return { scalable: false };
|
|
506
|
+
}
|
|
507
|
+
function detectPriorityFee(targetDir) {
|
|
508
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true });
|
|
509
|
+
for (const file of walk(targetDir)) {
|
|
510
|
+
const sf = project.addSourceFileAtPath(file);
|
|
511
|
+
const calls = sf.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
512
|
+
for (const ce of calls) {
|
|
513
|
+
const expr = ce.getExpression();
|
|
514
|
+
const text = expr.getText();
|
|
515
|
+
if (text.includes("setComputeUnitPrice")) {
|
|
516
|
+
return {
|
|
517
|
+
found: true,
|
|
518
|
+
file,
|
|
519
|
+
line: ce.getStartLineNumber(),
|
|
520
|
+
detail: `ComputeBudgetProgram.setComputeUnitPrice detected at ${file}:${ce.getStartLineNumber()} \u2014 priority fee configured.`
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
found: false,
|
|
527
|
+
detail: "No setComputeUnitPrice call detected. During network congestion, transactions without a priority fee may stall or be dropped. Add ComputeBudgetProgram.setComputeUnitPrice() to critical transaction paths."
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function detectAccountFlows(targetDir) {
|
|
531
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true });
|
|
532
|
+
const callIndex = new Map(KNOWN_FLOWS.map((f) => [f.call, f]));
|
|
533
|
+
const flows = [];
|
|
534
|
+
for (const file of walk(targetDir)) {
|
|
535
|
+
const sf = project.addSourceFileAtPath(file);
|
|
536
|
+
const importedModules = new Set(
|
|
537
|
+
sf.getImportDeclarations().map((d) => d.getModuleSpecifierValue())
|
|
538
|
+
);
|
|
539
|
+
for (const ce of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
540
|
+
const expr = ce.getExpression();
|
|
541
|
+
let callName = null;
|
|
542
|
+
if (expr.getKind() === SyntaxKind.Identifier) {
|
|
543
|
+
callName = expr.getText();
|
|
544
|
+
} else if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
545
|
+
callName = expr.asKind(SyntaxKind.PropertyAccessExpression).getName();
|
|
546
|
+
}
|
|
547
|
+
if (!callName) continue;
|
|
548
|
+
const known = callIndex.get(callName);
|
|
549
|
+
if (!known) continue;
|
|
550
|
+
if (!importedModules.has(known.module)) continue;
|
|
551
|
+
const lamports = rentExemptMinimum(known.dataLen);
|
|
552
|
+
const { scalable, note } = isInsideLoop(ce);
|
|
553
|
+
flows.push({
|
|
554
|
+
call: callName,
|
|
555
|
+
module: known.module,
|
|
556
|
+
accountType: known.accountType,
|
|
557
|
+
file,
|
|
558
|
+
line: ce.getStartLineNumber(),
|
|
559
|
+
dataLen: known.dataLen,
|
|
560
|
+
lamports,
|
|
561
|
+
sol: lamportsToSol(lamports),
|
|
562
|
+
recoverability: known.recoverability,
|
|
563
|
+
recoverabilityNote: known.recoverabilityNote,
|
|
564
|
+
scalable,
|
|
565
|
+
scalableNote: note
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return flows;
|
|
570
|
+
}
|
|
571
|
+
function analyzeCosts(targetDir) {
|
|
572
|
+
const accountFlows = detectAccountFlows(targetDir);
|
|
573
|
+
const priorityFee = detectPriorityFee(targetDir);
|
|
574
|
+
const staticFlows = accountFlows.filter((f) => !f.scalable);
|
|
575
|
+
const scalableFlows = accountFlows.filter((f) => f.scalable);
|
|
576
|
+
const totalLockupLamports = staticFlows.reduce((s, f) => s + f.lamports, 0);
|
|
577
|
+
return {
|
|
578
|
+
accountFlows,
|
|
579
|
+
priorityFee,
|
|
580
|
+
totalLockupLamports,
|
|
581
|
+
totalLockupSol: lamportsToSol(totalLockupLamports),
|
|
582
|
+
scalableFlows,
|
|
583
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function renderCostReportMd(r) {
|
|
587
|
+
const lines = ["## Cost & Rent Analysis\n"];
|
|
588
|
+
if (r.priorityFee.found) {
|
|
589
|
+
lines.push(`\u2705 **Priority fee configured** \u2014 \`setComputeUnitPrice\` detected.`);
|
|
590
|
+
lines.push(` ${r.priorityFee.detail}
|
|
591
|
+
`);
|
|
592
|
+
} else {
|
|
593
|
+
lines.push(`\u26A0\uFE0F **HIGH \u2014 Priority fee not configured**`);
|
|
594
|
+
lines.push(` ${r.priorityFee.detail}
|
|
595
|
+
`);
|
|
596
|
+
}
|
|
597
|
+
if (r.accountFlows.length === 0) {
|
|
598
|
+
lines.push("_No account-creation calls from tracked modules detected._\n");
|
|
599
|
+
return lines.join("\n");
|
|
600
|
+
}
|
|
601
|
+
lines.push("### Account Creation Flows\n");
|
|
602
|
+
lines.push("| Call | Account Type | Data | Lamports Locked | SOL | Recoverable? |");
|
|
603
|
+
lines.push("|------|-------------|------|-----------------|-----|--------------|");
|
|
604
|
+
for (const f of r.accountFlows) {
|
|
605
|
+
const file = f.file.split("/").slice(-2).join("/");
|
|
606
|
+
const recov = f.recoverability === "recoverable" ? "\u2705 Yes" : f.recoverability === "conditionally-recoverable" ? "\u26A0\uFE0F Conditional" : "\u274C No";
|
|
607
|
+
const scaleMark = f.scalable ? " \u{1F504}" : "";
|
|
608
|
+
lines.push(
|
|
609
|
+
`| \`${f.call}\`${scaleMark} (${file}:${f.line}) | ${f.accountType} | ${f.dataLen} B | ${f.lamports.toLocaleString()} | ${f.sol} SOL | ${recov} |`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
lines.push("");
|
|
613
|
+
const unique = /* @__PURE__ */ new Map();
|
|
614
|
+
for (const f of r.accountFlows) unique.set(f.accountType, f.recoverabilityNote);
|
|
615
|
+
lines.push("**Recoverability notes:**");
|
|
616
|
+
for (const [type, note] of unique) lines.push(`- **${type}:** ${note}`);
|
|
617
|
+
lines.push("");
|
|
618
|
+
if (r.totalLockupLamports > 0) {
|
|
619
|
+
lines.push(
|
|
620
|
+
`**Total static lockup: ${r.totalLockupLamports.toLocaleString()} lamports (~${r.totalLockupSol} SOL)**`
|
|
621
|
+
);
|
|
622
|
+
lines.push(
|
|
623
|
+
`_(Excludes ${r.scalableFlows.length} scalable flow(s) whose cost grows with N \u2014 see below.)_
|
|
624
|
+
`
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
if (r.scalableFlows.length > 0) {
|
|
628
|
+
lines.push("### Scalable Cost Flows (cost grows with N)\n");
|
|
629
|
+
for (const f of r.scalableFlows) {
|
|
630
|
+
const file = f.file.split("/").slice(-2).join("/");
|
|
631
|
+
lines.push(
|
|
632
|
+
`- **\`${f.call}\`** at \`${file}:${f.line}\` \u2014 ${f.scalableNote}
|
|
633
|
+
Per-iteration cost: ${f.lamports.toLocaleString()} lamports (${f.sol} SOL) for each ${f.accountType}.`
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
lines.push("");
|
|
637
|
+
}
|
|
638
|
+
return lines.join("\n");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// src/watch.ts
|
|
642
|
+
import { watch as fsWatch } from "fs";
|
|
643
|
+
function runIncrementalScan(targetDir, rules, emit) {
|
|
644
|
+
const start = Date.now();
|
|
645
|
+
let changedRanges;
|
|
646
|
+
try {
|
|
647
|
+
changedRanges = getWorkingTreeChanges(targetDir);
|
|
648
|
+
} catch (e) {
|
|
649
|
+
emit({ type: "scan_error", message: e?.message ?? String(e) });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (changedRanges.size === 0) {
|
|
653
|
+
emit({ type: "scan_complete", filesChanged: 0, findings: 0, durationMs: Date.now() - start });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const { checks } = audit(targetDir, rules, changedRanges);
|
|
657
|
+
let findings = 0;
|
|
658
|
+
for (const c of checks) {
|
|
659
|
+
if (c.result === "pass") continue;
|
|
660
|
+
findings++;
|
|
661
|
+
emit({
|
|
662
|
+
type: "finding",
|
|
663
|
+
ruleId: c.ruleId,
|
|
664
|
+
severity: c.severity,
|
|
665
|
+
result: c.result,
|
|
666
|
+
file: c.file,
|
|
667
|
+
line: c.line,
|
|
668
|
+
detail: c.detail,
|
|
669
|
+
...c.fix ? { fix: c.fix } : {}
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
emit({ type: "scan_complete", filesChanged: changedRanges.size, findings, durationMs: Date.now() - start });
|
|
673
|
+
}
|
|
674
|
+
function startWatch(targetDir, opts = {}) {
|
|
675
|
+
const debounceMs = opts.debounceMs ?? 300;
|
|
676
|
+
const emit = opts.emit ?? ((e) => process.stdout.write(JSON.stringify(e) + "\n"));
|
|
677
|
+
const rules = resolveRules(targetDir);
|
|
678
|
+
let timer;
|
|
679
|
+
const scheduleScan = () => {
|
|
680
|
+
if (timer) clearTimeout(timer);
|
|
681
|
+
timer = setTimeout(() => runIncrementalScan(targetDir, rules, emit), debounceMs);
|
|
682
|
+
};
|
|
683
|
+
const watcher = fsWatch(targetDir, { recursive: true }, (_event, filename) => {
|
|
684
|
+
if (!filename) return;
|
|
685
|
+
const parts = filename.split(/[\\/]/);
|
|
686
|
+
if (parts.some((p) => SKIP_DIRS.has(p))) return;
|
|
687
|
+
scheduleScan();
|
|
688
|
+
});
|
|
689
|
+
emit({ type: "watch_started", targetDir });
|
|
690
|
+
return {
|
|
691
|
+
close: () => {
|
|
692
|
+
if (timer) clearTimeout(timer);
|
|
693
|
+
watcher.close();
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/fixers/applyDiff.ts
|
|
699
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
700
|
+
function parseDiff(diff) {
|
|
701
|
+
const lines = diff.split("\n");
|
|
702
|
+
const fileLine = lines.find((l) => l.startsWith("+++ b"));
|
|
703
|
+
if (!fileLine) throw new Error("parseDiff: no '+++ b<path>' line found");
|
|
704
|
+
const filePath = fileLine.slice("+++ b".length);
|
|
705
|
+
const hunkLine = lines.find((l) => l.startsWith("@@"));
|
|
706
|
+
if (!hunkLine) throw new Error("parseDiff: no hunk header found");
|
|
707
|
+
const m = hunkLine.match(/^@@ -(\d+),(\d+) \+\d+,\d+ @@/);
|
|
708
|
+
if (!m) throw new Error(`parseDiff: unrecognized hunk header '${hunkLine}'`);
|
|
709
|
+
const oldStart = Number(m[1]);
|
|
710
|
+
const oldCount = Number(m[2]);
|
|
711
|
+
const newLines = lines.filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1));
|
|
712
|
+
return { filePath, oldStart, oldCount, newLines };
|
|
713
|
+
}
|
|
714
|
+
function applyDiffToFile(diff) {
|
|
715
|
+
const { filePath, oldStart, oldCount, newLines } = parseDiff(diff);
|
|
716
|
+
const content = readFileSync3(filePath, "utf8");
|
|
717
|
+
const fileLines = content.split("\n");
|
|
718
|
+
const removedLines = diff.split("\n").filter((l) => l.startsWith("-") && !l.startsWith("---")).map((l) => l.slice(1));
|
|
719
|
+
const actual = fileLines.slice(oldStart - 1, oldStart - 1 + oldCount);
|
|
720
|
+
if (JSON.stringify(actual) !== JSON.stringify(removedLines)) return false;
|
|
721
|
+
fileLines.splice(oldStart - 1, oldCount, ...newLines);
|
|
722
|
+
writeFileSync2(filePath, fileLines.join("\n"));
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/pack.ts
|
|
727
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
728
|
+
import { join as join3 } from "path";
|
|
729
|
+
function initPack(dir, opts) {
|
|
730
|
+
if (existsSync3(join3(dir, PACK_MANIFEST_FILE))) {
|
|
731
|
+
throw new Error(`${dir} already contains a ${PACK_MANIFEST_FILE}`);
|
|
732
|
+
}
|
|
733
|
+
const manifest = {
|
|
734
|
+
id: opts.id,
|
|
735
|
+
name: opts.name ?? opts.id,
|
|
736
|
+
version: opts.version ?? "0.1.0",
|
|
737
|
+
author: opts.author ?? "unknown",
|
|
738
|
+
...opts.description ? { description: opts.description } : {}
|
|
739
|
+
};
|
|
740
|
+
mkdirSync2(dir, { recursive: true });
|
|
741
|
+
mkdirSync2(join3(dir, "rules"), { recursive: true });
|
|
742
|
+
mkdirSync2(join3(dir, "fixtures"), { recursive: true });
|
|
743
|
+
const manifestYaml = [
|
|
744
|
+
`id: ${manifest.id}`,
|
|
745
|
+
`name: ${manifest.name}`,
|
|
746
|
+
`version: ${manifest.version}`,
|
|
747
|
+
`author: ${manifest.author}`,
|
|
748
|
+
...manifest.description ? [`description: ${manifest.description}`] : [],
|
|
749
|
+
""
|
|
750
|
+
].join("\n");
|
|
751
|
+
const manifestFile = join3(dir, PACK_MANIFEST_FILE);
|
|
752
|
+
writeFileSync3(manifestFile, manifestYaml, "utf8");
|
|
753
|
+
return manifestFile;
|
|
754
|
+
}
|
|
755
|
+
function validatePack(dir) {
|
|
756
|
+
const { manifest, rules } = loadPack(dir);
|
|
757
|
+
const fixturesRoot = join3(dir, "fixtures");
|
|
758
|
+
const ruleResults = rules.map((rule) => {
|
|
759
|
+
const ruleFixturesDir = join3(fixturesRoot, rule.id);
|
|
760
|
+
const vulnerableDir = join3(ruleFixturesDir, "vulnerable");
|
|
761
|
+
const fixedDir = join3(ruleFixturesDir, "fixed");
|
|
762
|
+
if (!existsSync3(vulnerableDir) || !existsSync3(fixedDir)) {
|
|
763
|
+
return {
|
|
764
|
+
ruleId: rule.id,
|
|
765
|
+
status: "missing-fixtures",
|
|
766
|
+
detail: `no fixtures/${rule.id}/{vulnerable,fixed}/ directory \u2014 prove gate skipped`
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
const redChecks = auditWithRule(vulnerableDir, rule);
|
|
770
|
+
const redFails = redChecks.filter((c) => c.result === "fail");
|
|
771
|
+
if (redFails.length === 0) {
|
|
772
|
+
return {
|
|
773
|
+
ruleId: rule.id,
|
|
774
|
+
status: "red-failed",
|
|
775
|
+
detail: `expected at least one FAIL against fixtures/${rule.id}/vulnerable/, got none`
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
const greenChecks = auditWithRule(fixedDir, rule);
|
|
779
|
+
const greenFails = greenChecks.filter((c) => c.result === "fail");
|
|
780
|
+
if (greenFails.length > 0) {
|
|
781
|
+
return {
|
|
782
|
+
ruleId: rule.id,
|
|
783
|
+
status: "green-failed",
|
|
784
|
+
detail: `expected no FAIL against fixtures/${rule.id}/fixed/, got ${greenFails.length}`
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
return { ruleId: rule.id, status: "ok", detail: "RED -> GREEN proven" };
|
|
788
|
+
});
|
|
789
|
+
const ok = ruleResults.every((r) => r.status === "ok" || r.status === "missing-fixtures");
|
|
790
|
+
return { manifest, rules, ruleResults, ok };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// src/telemetry.ts
|
|
794
|
+
import { createHash, randomUUID } from "crypto";
|
|
795
|
+
import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
796
|
+
import { execFileSync } from "child_process";
|
|
797
|
+
import { homedir as homedir2 } from "os";
|
|
798
|
+
import { dirname as dirname2, join as join4, resolve } from "path";
|
|
799
|
+
function sha256Hex(s) {
|
|
800
|
+
return createHash("sha256").update(s).digest("hex");
|
|
801
|
+
}
|
|
802
|
+
function isTelemetryEnabled(targetDir) {
|
|
803
|
+
const env = process.env.BRAINBLAST_TELEMETRY;
|
|
804
|
+
if (env === "1" || env === "true") return true;
|
|
805
|
+
if (env === "0" || env === "false") return false;
|
|
806
|
+
const configPath = join4(targetDir, ".agent-research", "config.json");
|
|
807
|
+
if (!existsSync4(configPath)) return false;
|
|
808
|
+
try {
|
|
809
|
+
const cfg = JSON.parse(readFileSync4(configPath, "utf8"));
|
|
810
|
+
return cfg?.telemetry === true;
|
|
811
|
+
} catch {
|
|
812
|
+
return false;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function getUserHash() {
|
|
816
|
+
const idPath = join4(homedir2(), ".brainblast", "telemetry-id");
|
|
817
|
+
let id;
|
|
818
|
+
if (existsSync4(idPath)) {
|
|
819
|
+
id = readFileSync4(idPath, "utf8").trim();
|
|
820
|
+
} else {
|
|
821
|
+
id = randomUUID();
|
|
822
|
+
mkdirSync3(dirname2(idPath), { recursive: true });
|
|
823
|
+
writeFileSync4(idPath, id, "utf8");
|
|
824
|
+
}
|
|
825
|
+
return sha256Hex(id).slice(0, 16);
|
|
826
|
+
}
|
|
827
|
+
function getRepoHash(targetDir) {
|
|
828
|
+
let key = "";
|
|
829
|
+
try {
|
|
830
|
+
key = execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
831
|
+
cwd: targetDir,
|
|
832
|
+
encoding: "utf8",
|
|
833
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
834
|
+
}).trim();
|
|
835
|
+
} catch {
|
|
836
|
+
}
|
|
837
|
+
if (!key) key = resolve(targetDir);
|
|
838
|
+
return sha256Hex(key).slice(0, 16);
|
|
839
|
+
}
|
|
840
|
+
function telemetryFilePath(targetDir) {
|
|
841
|
+
return join4(targetDir, ".agent-research", "telemetry.ndjson");
|
|
842
|
+
}
|
|
843
|
+
var DEFAULT_REGISTRY_URL = "https://registry.brainblast.tech";
|
|
844
|
+
async function submitTelemetry(targetDir, registryUrl = process.env.BRAINBLAST_REGISTRY_URL || DEFAULT_REGISTRY_URL) {
|
|
845
|
+
const file = telemetryFilePath(targetDir);
|
|
846
|
+
if (!existsSync4(file)) {
|
|
847
|
+
return { submitted: 0, accepted: 0, rejected: 0, graduations: [] };
|
|
848
|
+
}
|
|
849
|
+
const events = readFileSync4(file, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
850
|
+
if (events.length === 0) {
|
|
851
|
+
return { submitted: 0, accepted: 0, rejected: 0, graduations: [] };
|
|
852
|
+
}
|
|
853
|
+
const res = await fetch(`${registryUrl.replace(/\/$/, "")}/api/telemetry`, {
|
|
854
|
+
method: "POST",
|
|
855
|
+
headers: { "content-type": "application/json" },
|
|
856
|
+
body: JSON.stringify({ events })
|
|
857
|
+
});
|
|
858
|
+
if (!res.ok) {
|
|
859
|
+
const body = await res.text().catch(() => "");
|
|
860
|
+
throw new Error(`telemetry submit failed: ${res.status} ${res.statusText} ${body}`.trim());
|
|
861
|
+
}
|
|
862
|
+
const json = await res.json();
|
|
863
|
+
return { submitted: events.length, ...json };
|
|
864
|
+
}
|
|
865
|
+
function recordGraduationEvents(targetDir, events) {
|
|
866
|
+
if (events.length === 0) return;
|
|
867
|
+
const file = telemetryFilePath(targetDir);
|
|
868
|
+
mkdirSync3(dirname2(file), { recursive: true });
|
|
869
|
+
const repo_hash = getRepoHash(targetDir);
|
|
870
|
+
const user_hash = getUserHash();
|
|
871
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
872
|
+
const lines = events.map((e) => JSON.stringify({ ...e, repo_hash, user_hash, timestamp })).join("\n");
|
|
873
|
+
appendFileSync(file, lines + "\n", "utf8");
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
export {
|
|
877
|
+
loadDirectory,
|
|
878
|
+
base58Encode,
|
|
879
|
+
base58Decode,
|
|
880
|
+
isValidSolanaAddress,
|
|
881
|
+
DEFAULT_TTL_HOURS,
|
|
882
|
+
defaultCachePath,
|
|
883
|
+
loadProgramCache,
|
|
884
|
+
saveProgramCache,
|
|
885
|
+
getCacheEntry,
|
|
886
|
+
putCacheEntry,
|
|
887
|
+
getCacheEntryMeta,
|
|
888
|
+
isEntryExpired,
|
|
889
|
+
cacheSize,
|
|
890
|
+
buildTrustGraph,
|
|
891
|
+
renderTrustGraphMd,
|
|
892
|
+
rentExemptMinimum,
|
|
893
|
+
lamportsToSol,
|
|
894
|
+
analyzeCosts,
|
|
895
|
+
renderCostReportMd,
|
|
896
|
+
runIncrementalScan,
|
|
897
|
+
startWatch,
|
|
898
|
+
parseDiff,
|
|
899
|
+
applyDiffToFile,
|
|
900
|
+
initPack,
|
|
901
|
+
validatePack,
|
|
902
|
+
isTelemetryEnabled,
|
|
903
|
+
getUserHash,
|
|
904
|
+
getRepoHash,
|
|
905
|
+
telemetryFilePath,
|
|
906
|
+
DEFAULT_REGISTRY_URL,
|
|
907
|
+
submitTelemetry,
|
|
908
|
+
recordGraduationEvents
|
|
909
|
+
};
|