chainfess 0.0.0-stage → 0.1.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/bin/chainfess.mjs +7 -0
- package/fixtures/demo/rpc.json +50 -0
- package/fixtures/demo/session.jsonl +8 -0
- package/package.json +11 -4
- package/src/audit.mjs +344 -0
- package/src/blocks.mjs +127 -0
- package/src/canonical.mjs +19 -0
- package/src/classify.mjs +111 -0
- package/src/cli.mjs +231 -0
- package/src/hook.mjs +202 -0
- package/src/keccak.mjs +75 -0
- package/src/ledger.mjs +216 -0
- package/src/parse.mjs +621 -0
- package/src/pubkey.mjs +5 -0
- package/src/replay.mjs +67 -0
- package/src/report.mjs +273 -0
- package/src/rpc.mjs +225 -0
- package/src/sign.mjs +139 -0
package/src/blocks.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Timestamp to block mapping (BRIEF §3.3). k-ary search with 8 probes per request.
|
|
2
|
+
export const BLOCK_TIME = 0.104;
|
|
3
|
+
export const MARGIN_START = 30;
|
|
4
|
+
export const MARGIN_END = 120;
|
|
5
|
+
const WIDEN = 200;
|
|
6
|
+
|
|
7
|
+
export class PrunedError extends Error {
|
|
8
|
+
constructor(oldestSeen) {
|
|
9
|
+
super(`the RPC does not serve blocks that old (oldest seen ${oldestSeen})`);
|
|
10
|
+
this.name = 'PrunedError';
|
|
11
|
+
this.oldestSeen = oldestSeen;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const hex = (n) => '0x' + n.toString(16);
|
|
16
|
+
|
|
17
|
+
export function createHeaderReader(rpc, cache = new Map()) {
|
|
18
|
+
let oldest = Infinity;
|
|
19
|
+
const state = { nonMonotonic: false };
|
|
20
|
+
async function read(ns) {
|
|
21
|
+
const need = [...new Set(ns)].filter((n) => !cache.has(n));
|
|
22
|
+
if (need.length) {
|
|
23
|
+
const res = await rpc.batch(need.map((n) => ['eth_getBlockByNumber', [hex(n), false]]), { size: 8 });
|
|
24
|
+
for (let i = 0; i < need.length; i++) {
|
|
25
|
+
const n = need[i];
|
|
26
|
+
// One behind-the-load-balancer node can answer null once; two nulls mean the block is not served.
|
|
27
|
+
const blk = res[i] || await rpc.call('eth_getBlockByNumber', [hex(n), false]);
|
|
28
|
+
if (!blk) throw new PrunedError(oldest === Infinity ? 'none' : oldest);
|
|
29
|
+
oldest = Math.min(oldest, n);
|
|
30
|
+
cache.set(n, Number(blk.timestamp));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return ns.map((n) => cache.get(n));
|
|
34
|
+
}
|
|
35
|
+
return { read, state, cache };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** First block with timestamp >= t, searching (lo, hi] where ts(hi) >= t. Returns null if t is past latest. */
|
|
39
|
+
async function firstAtOrAfter(reader, t, latest, latestTs, { hintLo = null, hintHi = null } = {}) {
|
|
40
|
+
if (latestTs < t) return null;
|
|
41
|
+
let hi = latest;
|
|
42
|
+
if (hintHi !== null && hintHi < latest) {
|
|
43
|
+
const [ts] = await reader.read([hintHi]);
|
|
44
|
+
if (ts >= t) hi = hintHi;
|
|
45
|
+
}
|
|
46
|
+
const span = Math.ceil(((latestTs - t) / BLOCK_TIME) * 1.3) + 2;
|
|
47
|
+
let lo = Math.max(0, latest - span);
|
|
48
|
+
if (hintLo !== null && hintLo > lo && hintLo < hi) lo = hintLo;
|
|
49
|
+
let [tsLo] = await reader.read([lo]);
|
|
50
|
+
while (tsLo >= t && lo > 0) {
|
|
51
|
+
hi = lo;
|
|
52
|
+
lo = Math.max(0, lo - Math.max(1000, (latest - lo)));
|
|
53
|
+
[tsLo] = await reader.read([lo]);
|
|
54
|
+
}
|
|
55
|
+
if (tsLo >= t) return lo; // lo == 0: t is before genesis
|
|
56
|
+
let round = 0;
|
|
57
|
+
while (hi - lo > 1) {
|
|
58
|
+
// Block time is close to constant, so aim at the interpolated spot; every other round is even, so the worst case stays log9.
|
|
59
|
+
const probes = [];
|
|
60
|
+
const add = (p) => {
|
|
61
|
+
const q = Math.floor(p);
|
|
62
|
+
if (q > lo && q < hi && !probes.includes(q)) probes.push(q);
|
|
63
|
+
};
|
|
64
|
+
if (round++ % 2 === 0) {
|
|
65
|
+
const [tsHi] = await reader.read([hi]);
|
|
66
|
+
const est = lo + ((t - tsLo) / Math.max(1, tsHi - tsLo)) * (hi - lo);
|
|
67
|
+
const d = Math.max(50, (hi - lo) / 40);
|
|
68
|
+
for (const f of [-1, -0.2, -0.04, -0.008, 0.008, 0.04, 0.2, 1]) add(est + f * d);
|
|
69
|
+
probes.sort((x, y) => x - y);
|
|
70
|
+
}
|
|
71
|
+
if (!probes.length) {
|
|
72
|
+
const step = (hi - lo) / 9;
|
|
73
|
+
for (let i = 1; i <= 8; i++) add(lo + step * i);
|
|
74
|
+
}
|
|
75
|
+
if (!probes.length) break;
|
|
76
|
+
const ts = await reader.read(probes);
|
|
77
|
+
let seq = tsLo;
|
|
78
|
+
let nextLo = lo;
|
|
79
|
+
let nextHi = hi;
|
|
80
|
+
let settled = false;
|
|
81
|
+
for (let i = 0; i < probes.length; i++) {
|
|
82
|
+
if (ts[i] < seq) reader.state.nonMonotonic = true;
|
|
83
|
+
seq = ts[i];
|
|
84
|
+
if (!settled) {
|
|
85
|
+
if (ts[i] >= t) { nextHi = probes[i]; settled = true; } else { nextLo = probes[i]; tsLo = ts[i]; }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
lo = nextLo;
|
|
89
|
+
hi = nextHi;
|
|
90
|
+
}
|
|
91
|
+
return hi;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* tStartMs/tEndMs: session span in ms. Returns inclusive blocks and notes.
|
|
96
|
+
* Margins are 0 for times given explicitly by --from/--to.
|
|
97
|
+
*/
|
|
98
|
+
export async function mapRange({ rpc, tStartMs, tEndMs, latest, latestTs, cache, marginStart = MARGIN_START, marginEnd = MARGIN_END }) {
|
|
99
|
+
const reader = createHeaderReader(rpc, cache);
|
|
100
|
+
reader.cache.set(latest, latestTs);
|
|
101
|
+
const notes = [];
|
|
102
|
+
const a = Math.ceil(tStartMs / 1000 - marginStart);
|
|
103
|
+
const b = Math.floor(tEndMs / 1000 + marginEnd);
|
|
104
|
+
let startBlock = await firstAtOrAfter(reader, a, latest, latestTs);
|
|
105
|
+
if (startBlock === null) {
|
|
106
|
+
notes.push('session starts after the latest block; clock ahead');
|
|
107
|
+
startBlock = latest;
|
|
108
|
+
} else if (startBlock === 0 && a < reader.cache.get(0)) {
|
|
109
|
+
notes.push('session start is before block 0; clamped');
|
|
110
|
+
}
|
|
111
|
+
let endBlock;
|
|
112
|
+
if (latestTs <= b) {
|
|
113
|
+
endBlock = latest;
|
|
114
|
+
notes.push('session still running or clock ahead');
|
|
115
|
+
} else {
|
|
116
|
+
const est = startBlock + Math.ceil(((b - a) / BLOCK_TIME) * 1.5) + 100;
|
|
117
|
+
const first = await firstAtOrAfter(reader, b + 1, latest, latestTs, { hintLo: startBlock, hintHi: est });
|
|
118
|
+
endBlock = first === null ? latest : Math.max(startBlock, first - 1);
|
|
119
|
+
if (first === 0) { endBlock = 0; notes.push('session end is before block 0; clamped'); }
|
|
120
|
+
}
|
|
121
|
+
if (reader.state.nonMonotonic) {
|
|
122
|
+
startBlock = Math.max(0, startBlock - WIDEN);
|
|
123
|
+
endBlock = Math.min(latest, endBlock + WIDEN);
|
|
124
|
+
notes.push('block timestamps not in order near this range; range widened by 200 blocks each side');
|
|
125
|
+
}
|
|
126
|
+
return { startBlock, endBlock, notes };
|
|
127
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Canonical JSON: keys sorted at every depth, no whitespace. Used for signing and verifying.
|
|
2
|
+
export function canonicalJSON(value) {
|
|
3
|
+
return JSON.stringify(sortKeys(value));
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function sortKeys(v) {
|
|
7
|
+
if (v === null || typeof v !== 'object') {
|
|
8
|
+
if (typeof v === 'number' && !Number.isFinite(v)) throw new TypeError('canonicalJSON: non-finite number');
|
|
9
|
+
if (typeof v === 'bigint') throw new TypeError('canonicalJSON: bigint not allowed (use strings)');
|
|
10
|
+
return v;
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(v)) return v.map((x) => (x === undefined ? null : sortKeys(x)));
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const k of Object.keys(v).sort()) {
|
|
15
|
+
if (v[k] === undefined) continue;
|
|
16
|
+
out[k] = sortKeys(v[k]);
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
package/src/classify.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The ten rows of BRIEF §2.3. Pure. Only rows 6 and 7 are findings.
|
|
2
|
+
export const STATUS = {
|
|
3
|
+
PRE: 'pre-session', POST: 'post-session', PENDING: 'pending', ACCOUNTED: 'accounted', PARTIAL: 'partial',
|
|
4
|
+
UNACCOUNTED: 'unaccounted', FOREIGN: 'foreign-signer', RECENT: 'recent-other', OBSERVED: 'observed',
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const WINDOW_BEFORE_MS = 5000;
|
|
8
|
+
export const WINDOW_AFTER_MS = 60000;
|
|
9
|
+
|
|
10
|
+
/** W(id) of §2.1. An unmatched tool_use runs to the end of the session. */
|
|
11
|
+
export function inWindow(tool, timestampSec, tEndMs) {
|
|
12
|
+
if (!tool || tool.start === null || tool.start === undefined || timestampSec === null || timestampSec === undefined) return false;
|
|
13
|
+
const t = timestampSec * 1000;
|
|
14
|
+
const end = tool.end !== null && tool.end !== undefined ? tool.end + WINDOW_AFTER_MS : tEndMs;
|
|
15
|
+
return t >= tool.start - WINDOW_BEFORE_MS && t <= end;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Earliest structured occurrence; a tool_result occurrence is what can tie a tx to a tool call. */
|
|
19
|
+
function toolOccurrence(occ) {
|
|
20
|
+
if (occ && occ.first && occ.first.toolId && (occ.first.kind === 'tool_result' || occ.first.kind === 'tool-results')) return occ.first;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A sending pattern explains the call's own send, not everything its output prints.
|
|
26
|
+
* `cast send … && cast block latest` must not turn a block dump into findings, so a call is left out of
|
|
27
|
+
* row 7 when its output prints a tx of a registered wallet (that is the send) or txs of more than two senders.
|
|
28
|
+
*/
|
|
29
|
+
export function dumpCalls(items, ctx) {
|
|
30
|
+
const senders = new Map();
|
|
31
|
+
const own = new Set();
|
|
32
|
+
for (const t of items) {
|
|
33
|
+
const occ = toolOccurrence(ctx.occurrence(t.hash));
|
|
34
|
+
if (!occ) continue;
|
|
35
|
+
const tool = ctx.tool(occ.toolId);
|
|
36
|
+
if (!tool || !inWindow(tool, t.timestamp, ctx.tEnd)) continue;
|
|
37
|
+
if (ctx.wallets.has(t.from)) { own.add(occ.toolId); continue; }
|
|
38
|
+
if (!senders.has(occ.toolId)) senders.set(occ.toolId, new Set());
|
|
39
|
+
senders.get(occ.toolId).add(t.from);
|
|
40
|
+
}
|
|
41
|
+
const out = new Set(own);
|
|
42
|
+
for (const [id, set] of senders) if (set.size > 2) out.add(id);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function classify(tx, ctx) {
|
|
47
|
+
const mine = ctx.wallets.has(tx.from);
|
|
48
|
+
const occ = ctx.occurrence(tx.hash);
|
|
49
|
+
const row = (status, extra = {}) => ({ status, finding: status === STATUS.UNACCOUNTED || status === STATUS.FOREIGN, source: null, pattern: null, ...extra });
|
|
50
|
+
|
|
51
|
+
if (mine && tx.block !== null && tx.block < ctx.startBlock) return row(STATUS.PRE, sourceOf(occ, ctx));
|
|
52
|
+
if (tx.block !== null && tx.block > ctx.endBlock) return row(STATUS.POST, sourceOf(occ, ctx));
|
|
53
|
+
if (tx.block === null) return row(STATUS.PENDING, sourceOf(occ, ctx));
|
|
54
|
+
|
|
55
|
+
if (mine) {
|
|
56
|
+
if (occ && (occ.first || occ.other || occ.broadcast || occ.bare)) return row(STATUS.ACCOUNTED, sourceOf(occ, ctx));
|
|
57
|
+
const part = ctx.partial(tx.hash);
|
|
58
|
+
if (part) return row(STATUS.PARTIAL, { source: { kind: displayKind(part), line: part.line }, pattern: null });
|
|
59
|
+
return row(STATUS.UNACCOUNTED);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const toolOcc = toolOccurrence(occ);
|
|
63
|
+
const src = sourceOf(occ, ctx);
|
|
64
|
+
if (toolOcc) {
|
|
65
|
+
const tool = ctx.tool(toolOcc.toolId);
|
|
66
|
+
if (inWindow(tool, tx.timestamp, ctx.tEnd)) {
|
|
67
|
+
const isDump = ctx.dumps && ctx.dumps.has(toolOcc.toolId);
|
|
68
|
+
if (tool.pattern && !isDump) return row(STATUS.FOREIGN, { ...src, pattern: tool.pattern });
|
|
69
|
+
return row(STATUS.RECENT, src);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return row(STATUS.OBSERVED, src);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function displayKind(o) {
|
|
76
|
+
if (o.origin === 'subagent') return 'subagent';
|
|
77
|
+
if (o.kind === 'other') return 'text';
|
|
78
|
+
return o.kind;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sourceOf(occ, ctx) {
|
|
82
|
+
if (!occ) return { source: null, pattern: null };
|
|
83
|
+
const o = occ.first || occ.other || occ.bare || occ.broadcast;
|
|
84
|
+
if (!o) return { source: null, pattern: null };
|
|
85
|
+
const tool = o.toolId ? ctx.tool(o.toolId) : null;
|
|
86
|
+
return { source: { kind: displayKind(o), line: o.line }, pattern: tool && tool.pattern ? tool.pattern : null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** mentioned-in-text flag of §2.4. Display only, never a status. */
|
|
90
|
+
export function mentionFlag(hash, ctx) {
|
|
91
|
+
const matches = (set) => {
|
|
92
|
+
for (const key of set) {
|
|
93
|
+
const [p, s] = key.split('|');
|
|
94
|
+
if (hash.startsWith('0x' + p) && (!s || hash.endsWith(s))) return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
};
|
|
98
|
+
for (const f of ctx.finals) if (f && (f.full.has(hash) || matches(f.partials))) return 'final';
|
|
99
|
+
if (ctx.textFull.has(hash) || matches(ctx.textPartials)) return 'text';
|
|
100
|
+
return 'none';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Prefix and suffix, or an 8+ hex prefix when the log gave no suffix. */
|
|
104
|
+
export function partialMatch(hash, partials) {
|
|
105
|
+
for (const [key, occ] of partials) {
|
|
106
|
+
const [p, s] = key.split('|');
|
|
107
|
+
if (!hash.startsWith('0x' + p)) continue;
|
|
108
|
+
if (s ? hash.endsWith(s) : p.length >= 8) return occ;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Command line entry. Exit codes (BRIEF §2.3): 0 clean, 1 findings, 2 input error, 3 RPC unavailable, 4 partial scan.
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { auditReport, Declined, InputError } from './audit.mjs';
|
|
8
|
+
import { renderText } from './report.mjs';
|
|
9
|
+
import { RpcUnavailableError } from './rpc.mjs';
|
|
10
|
+
import { PrunedError } from './blocks.mjs';
|
|
11
|
+
import { createReplay } from './replay.mjs';
|
|
12
|
+
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
export const DEMO_DIR = join(HERE, '..', 'fixtures', 'demo');
|
|
15
|
+
|
|
16
|
+
const HELP = `chainfess: your agent says X, the chain says Y.
|
|
17
|
+
Lists every transaction a coding agent's session log does not account for.
|
|
18
|
+
|
|
19
|
+
chainfess demo [--json]
|
|
20
|
+
Bundled fixture, recorded RPC replies, no wallet, no network.
|
|
21
|
+
|
|
22
|
+
chainfess audit --wallet 0x… [--log file]… [--all-sessions] [--rpc url] [--chain-id n]
|
|
23
|
+
[--from ISO] [--to ISO] [--from-block n] [--to-block n] [--max-hours h]
|
|
24
|
+
[--yes] [--json] [--no-subagents] [--quiet] [--debug]
|
|
25
|
+
Default log: the latest Claude Code session for this directory.
|
|
26
|
+
--no-subagents skips subagent transcripts only; the main session's saved tool outputs are still read.
|
|
27
|
+
A 32-byte value goes to the RPC only with a tx-hash label or read command beside it; the rest stays local.
|
|
28
|
+
|
|
29
|
+
chainfess sign --burn-tx 0x… --wallet 0x… [--log file]… [--server url]
|
|
30
|
+
chainfess verify <statement.json> [--pubkey file]
|
|
31
|
+
chainfess hook install --wallet 0x… | uninstall | status
|
|
32
|
+
|
|
33
|
+
Exit codes: 0 no findings, 1 findings, 2 input error, 3 RPC unavailable, 4 partial scan.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const AUDIT_OPTIONS = {
|
|
37
|
+
wallet: { type: 'string', multiple: true },
|
|
38
|
+
log: { type: 'string', multiple: true },
|
|
39
|
+
'all-sessions': { type: 'boolean' },
|
|
40
|
+
rpc: { type: 'string' },
|
|
41
|
+
'chain-id': { type: 'string' },
|
|
42
|
+
from: { type: 'string' },
|
|
43
|
+
to: { type: 'string' },
|
|
44
|
+
'from-block': { type: 'string' },
|
|
45
|
+
'to-block': { type: 'string' },
|
|
46
|
+
'max-hours': { type: 'string' },
|
|
47
|
+
yes: { type: 'boolean' },
|
|
48
|
+
json: { type: 'boolean' },
|
|
49
|
+
'no-subagents': { type: 'boolean' },
|
|
50
|
+
quiet: { type: 'boolean' },
|
|
51
|
+
debug: { type: 'boolean' },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function defaultIo() {
|
|
55
|
+
return {
|
|
56
|
+
out: (s) => process.stdout.write(s),
|
|
57
|
+
err: (s) => process.stderr.write(s),
|
|
58
|
+
isTTY: !!process.stdout.isTTY,
|
|
59
|
+
errTTY: !!process.stderr.isTTY,
|
|
60
|
+
stdinTTY: !!process.stdin.isTTY,
|
|
61
|
+
env: process.env,
|
|
62
|
+
cwd: process.cwd(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const useColor = (io) => io.isTTY && !io.env.NO_COLOR;
|
|
67
|
+
|
|
68
|
+
function confirmer(io) {
|
|
69
|
+
if (!io.stdinTTY) return null;
|
|
70
|
+
return (question) => new Promise((resolve) => {
|
|
71
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
72
|
+
rl.question(`${question} [y/N] `, (a) => { rl.close(); resolve(/^y(es)?$/i.test(a.trim())); });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function auditOptsFrom(values, io) {
|
|
77
|
+
return {
|
|
78
|
+
wallets: values.wallet, logs: values.log, allSessions: values['all-sessions'],
|
|
79
|
+
rpc: values.rpc, chainId: values['chain-id'],
|
|
80
|
+
from: values.from, to: values.to, fromBlock: values['from-block'], toBlock: values['to-block'],
|
|
81
|
+
maxHours: values['max-hours'], yes: values.yes, noSubagents: values['no-subagents'],
|
|
82
|
+
cwd: io.cwd,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function printDebug(io, audit) {
|
|
87
|
+
const s = audit.rpcStats;
|
|
88
|
+
io.err(`debug: ${s.requests} RPC requests (${Object.entries(s.byMethod).map(([m, c]) => `${m}:${c}`).join(', ')}), fallback ${s.fallbackUsed ? "yes" : "no"}, throttle signals ${s.throttles}\n`);
|
|
89
|
+
io.err(`debug: candidates ${audit.candidates.hashes.length} looked up, ${audit.candidates.localOnly} local only, ${audit.candidates.notLookedUp} over limit; partial ${audit.sink.partials.size}; tool calls ${audit.sink.tools.size}\n`);
|
|
90
|
+
io.err(`debug: planned ${audit.ledger.plannedBlocks} blocks, scanned ${audit.ledger.scannedBlocks}, total ${audit.seconds.toFixed(1)} s\n`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function cmdAudit(argv, io, extra = {}) {
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = parseArgs({ args: argv, options: AUDIT_OPTIONS, allowPositionals: false });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
io.err(`${e.message}\n`);
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
const v = parsed.values;
|
|
102
|
+
const showProgress = io.errTTY && !v.quiet && !v.json;
|
|
103
|
+
let lastLen = 0;
|
|
104
|
+
const progress = showProgress
|
|
105
|
+
? (msg, transient) => {
|
|
106
|
+
io.err(`\r${msg.padEnd(lastLen)}${transient ? '' : '\n'}`);
|
|
107
|
+
lastLen = transient ? msg.length : 0;
|
|
108
|
+
}
|
|
109
|
+
: () => {};
|
|
110
|
+
try {
|
|
111
|
+
const { report, audit } = await auditReport({
|
|
112
|
+
...auditOptsFrom(v, io), progress, confirm: confirmer(io), ...extra,
|
|
113
|
+
});
|
|
114
|
+
if (lastLen) io.err('\r' + ' '.repeat(lastLen) + '\r');
|
|
115
|
+
if (v.json) io.out(JSON.stringify(report, null, 2) + '\n');
|
|
116
|
+
else io.out(renderText(report, { color: useColor(io), quiet: v.quiet }));
|
|
117
|
+
if (v.debug) printDebug(io, audit);
|
|
118
|
+
return report.exitCode;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return reportError(e, io);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function reportError(e, io) {
|
|
125
|
+
if (e instanceof InputError || e instanceof Declined) { io.err(`${e.message}\n`); return 2; }
|
|
126
|
+
if (e instanceof RpcUnavailableError) { io.err(`${e.message}\n`); return 3; }
|
|
127
|
+
if (e instanceof PrunedError) { io.err(`${e.message}\n`); return 3; }
|
|
128
|
+
io.err(`unexpected error: ${e && e.message ? e.message : 'unknown'}\n`);
|
|
129
|
+
return 3;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function cmdDemo(argv, io) {
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = parseArgs({ args: argv, options: { json: { type: 'boolean' } }, allowPositionals: false });
|
|
136
|
+
} catch (e) {
|
|
137
|
+
io.err(`${e.message}\n`);
|
|
138
|
+
return 2;
|
|
139
|
+
}
|
|
140
|
+
const world = JSON.parse(readFileSync(join(DEMO_DIR, 'rpc.json'), 'utf8'));
|
|
141
|
+
const { fetchImpl } = createReplay(world);
|
|
142
|
+
const { report } = await auditReport({
|
|
143
|
+
wallets: world.demoWallets, logs: [join(DEMO_DIR, 'session.jsonl')], cwd: DEMO_DIR,
|
|
144
|
+
fetchImpl, paceMs: 0, fallback: false, demo: true, noBroadcast: true, now: Date.parse(world.demoNow),
|
|
145
|
+
sleep: async () => {},
|
|
146
|
+
});
|
|
147
|
+
if (parsed.values.json) io.out(JSON.stringify(report, null, 2) + '\n');
|
|
148
|
+
else io.out(renderText(report, { color: useColor(io) }));
|
|
149
|
+
return report.exitCode;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function cmdSign(argv, io) {
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = parseArgs({
|
|
156
|
+
args: argv,
|
|
157
|
+
options: { ...AUDIT_OPTIONS, 'burn-tx': { type: 'string' }, server: { type: 'string' }, out: { type: 'string' } },
|
|
158
|
+
allowPositionals: false,
|
|
159
|
+
});
|
|
160
|
+
} catch (e) {
|
|
161
|
+
io.err(`${e.message}\n`);
|
|
162
|
+
return 2;
|
|
163
|
+
}
|
|
164
|
+
const mod = await loadSign(io);
|
|
165
|
+
if (!mod) return 2;
|
|
166
|
+
const v = parsed.values;
|
|
167
|
+
return mod.runSign({
|
|
168
|
+
...auditOptsFrom(v, io), burnTx: v['burn-tx'], server: v.server, outFile: v.out,
|
|
169
|
+
out: (s) => io.out(s + '\n'), err: (s) => io.err(s + '\n'),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function cmdVerify(argv, io) {
|
|
174
|
+
let parsed;
|
|
175
|
+
try {
|
|
176
|
+
parsed = parseArgs({ args: argv, options: { pubkey: { type: 'string' } }, allowPositionals: true });
|
|
177
|
+
} catch (e) {
|
|
178
|
+
io.err(`${e.message}\n`);
|
|
179
|
+
return 2;
|
|
180
|
+
}
|
|
181
|
+
const mod = await loadSign(io);
|
|
182
|
+
if (!mod) return 2;
|
|
183
|
+
return mod.runVerify(parsed.positionals[0], {
|
|
184
|
+
pubkey: parsed.values.pubkey ?? null, out: (s) => io.out(s + '\n'), err: (s) => io.err(s + '\n'),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function loadSign(io) {
|
|
189
|
+
try {
|
|
190
|
+
return await import('./sign.mjs');
|
|
191
|
+
} catch {
|
|
192
|
+
io.err('this install of chainfess has no sign module (src/sign.mjs).\n');
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function cmdHook(argv, io) {
|
|
198
|
+
let mod;
|
|
199
|
+
try {
|
|
200
|
+
mod = await import('./hook.mjs');
|
|
201
|
+
} catch {
|
|
202
|
+
io.err('this install of chainfess has no hook module (src/hook.mjs).\n');
|
|
203
|
+
return 2;
|
|
204
|
+
}
|
|
205
|
+
return mod.runHook(argv, io);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function main(argv, ioOverride = {}) {
|
|
209
|
+
const io = { ...defaultIo(), ...ioOverride };
|
|
210
|
+
const [cmd, ...rest] = argv;
|
|
211
|
+
switch (cmd) {
|
|
212
|
+
case 'audit': return cmdAudit(rest, io);
|
|
213
|
+
case 'demo': return cmdDemo(rest, io);
|
|
214
|
+
case 'sign': return cmdSign(rest, io);
|
|
215
|
+
case 'verify': return cmdVerify(rest, io);
|
|
216
|
+
case 'hook': return cmdHook(rest, io);
|
|
217
|
+
case undefined:
|
|
218
|
+
case 'help':
|
|
219
|
+
case '--help':
|
|
220
|
+
case '-h':
|
|
221
|
+
io.out(HELP);
|
|
222
|
+
return cmd === undefined ? 2 : 0;
|
|
223
|
+
case '--version':
|
|
224
|
+
case '-v':
|
|
225
|
+
io.out('0.1.0\n');
|
|
226
|
+
return 0;
|
|
227
|
+
default:
|
|
228
|
+
io.err(`unknown command: ${String(cmd).slice(0, 40)}\n${HELP}`);
|
|
229
|
+
return 2;
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/hook.mjs
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// `chainfess hook install|uninstall|status` and the SessionEnd command itself (BRIEF claim 16, SPIKE).
|
|
2
|
+
// The hook must never block or fail a session: `hook run` hands off to a detached process and exits 0.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
|
|
10
|
+
const BIN = fileURLToPath(new URL('../bin/chainfess.mjs', import.meta.url));
|
|
11
|
+
const MARKER = 'chainfess-session-end-hook';
|
|
12
|
+
const VERSION = '0.1.0';
|
|
13
|
+
const STDIN_WAIT_MS = 2000;
|
|
14
|
+
|
|
15
|
+
function paths(io) {
|
|
16
|
+
const env = io.env || process.env;
|
|
17
|
+
const home = io.home || env.HOME || homedir();
|
|
18
|
+
const base = env.CLAUDE_CONFIG_DIR || join(home, '.claude');
|
|
19
|
+
return { settings: join(base, 'settings.json'), reports: join(home, '.chainfess', 'reports') };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Under npx the package lives in a cache that can be pruned, so the hook names a pinned npx command instead. */
|
|
23
|
+
function runner(binPath = BIN, nodePath = process.execPath) {
|
|
24
|
+
if (binPath.includes('/_npx/')) return `npx --yes chainfess@${VERSION}`;
|
|
25
|
+
return `"${nodePath}" "${binPath}"`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function hookCommand(wallets, binPath, nodePath) {
|
|
29
|
+
const flags = wallets.map((w) => `--wallet ${w}`).join(' ');
|
|
30
|
+
return `${runner(binPath, nodePath)} hook run ${flags} --tag ${MARKER} || true`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isOurs = (h) => h && typeof h.command === 'string' && h.command.includes(MARKER);
|
|
34
|
+
|
|
35
|
+
function readSettings(file) {
|
|
36
|
+
if (!existsSync(file)) return {};
|
|
37
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
38
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw new Error('not a JSON object');
|
|
39
|
+
return doc;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeSettings(file, doc, hadFile) {
|
|
43
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
44
|
+
let backup = null;
|
|
45
|
+
if (hadFile) {
|
|
46
|
+
backup = `${file}.chainfess-backup-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
47
|
+
copyFileSync(file, backup);
|
|
48
|
+
}
|
|
49
|
+
const tmp = `${file}.chainfess-tmp-${process.pid}`;
|
|
50
|
+
writeFileSync(tmp, JSON.stringify(doc, null, 2) + '\n');
|
|
51
|
+
renameSync(tmp, file);
|
|
52
|
+
return backup;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Pure: settings without our hook. Other hooks, even in the same group, stay. */
|
|
56
|
+
export function withoutOurs(doc) {
|
|
57
|
+
const next = { ...doc };
|
|
58
|
+
const list = next.hooks && next.hooks.SessionEnd;
|
|
59
|
+
if (!Array.isArray(list)) return next;
|
|
60
|
+
const groups = [];
|
|
61
|
+
for (const g of list) {
|
|
62
|
+
if (!g || !Array.isArray(g.hooks)) { groups.push(g); continue; }
|
|
63
|
+
const kept = g.hooks.filter((h) => !isOurs(h));
|
|
64
|
+
if (kept.length) groups.push(kept.length === g.hooks.length ? g : { ...g, hooks: kept });
|
|
65
|
+
}
|
|
66
|
+
const hooks = { ...next.hooks };
|
|
67
|
+
if (groups.length) hooks.SessionEnd = groups; else delete hooks.SessionEnd;
|
|
68
|
+
if (Object.keys(hooks).length) next.hooks = hooks; else delete next.hooks;
|
|
69
|
+
return next;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function withOurs(doc, command) {
|
|
73
|
+
const next = withoutOurs(doc);
|
|
74
|
+
if (next.hooks !== undefined && (typeof next.hooks !== 'object' || Array.isArray(next.hooks))) throw new Error('"hooks" is not an object');
|
|
75
|
+
const hooks = { ...(next.hooks || {}) };
|
|
76
|
+
if (hooks.SessionEnd !== undefined && !Array.isArray(hooks.SessionEnd)) throw new Error('"hooks.SessionEnd" is not a list');
|
|
77
|
+
hooks.SessionEnd = [...(hooks.SessionEnd || []), { hooks: [{ type: 'command', command }] }];
|
|
78
|
+
return { ...next, hooks };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function install(argv, io) {
|
|
82
|
+
let values;
|
|
83
|
+
try {
|
|
84
|
+
({ values } = parseArgs({ args: argv, options: { wallet: { type: 'string', multiple: true } }, allowPositionals: false }));
|
|
85
|
+
} catch (e) { io.err(`${e.message}\n`); return 2; }
|
|
86
|
+
const wallets = values.wallet || [];
|
|
87
|
+
if (!wallets.length) { io.err('usage: chainfess hook install --wallet 0x… [--wallet 0x…]\n'); return 2; }
|
|
88
|
+
for (const w of wallets) if (!/^0x[0-9a-fA-F]{40}$/.test(w)) { io.err('not a wallet address\n'); return 2; }
|
|
89
|
+
const { settings, reports } = paths(io);
|
|
90
|
+
const had = existsSync(settings);
|
|
91
|
+
let doc;
|
|
92
|
+
try { doc = readSettings(settings); } catch (e) { io.err(`${settings} could not be read (${e.message}); not touching it.\n`); return 2; }
|
|
93
|
+
let next;
|
|
94
|
+
try { next = withOurs(doc, hookCommand(wallets, io.binPath, io.nodePath)); } catch (e) { io.err(`${settings}: ${e.message}; not touching it.\n`); return 2; }
|
|
95
|
+
const backup = writeSettings(settings, next, had);
|
|
96
|
+
io.out(`SessionEnd hook installed in ${settings}\n`);
|
|
97
|
+
if (backup) io.out(`backup: ${backup}\n`);
|
|
98
|
+
io.out(`each session end audits ${wallets.join(', ')} in the background; reports go to ${reports}\n`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function uninstall(io) {
|
|
103
|
+
const { settings } = paths(io);
|
|
104
|
+
if (!existsSync(settings)) { io.out('no settings file; nothing to remove.\n'); return 0; }
|
|
105
|
+
let doc;
|
|
106
|
+
try { doc = readSettings(settings); } catch (e) { io.err(`${settings} could not be read (${e.message}); not touching it.\n`); return 2; }
|
|
107
|
+
const before = JSON.stringify(doc);
|
|
108
|
+
const next = withoutOurs(doc);
|
|
109
|
+
if (JSON.stringify(next) === before) { io.out('hook was not installed.\n'); return 0; }
|
|
110
|
+
const backup = writeSettings(settings, next, true);
|
|
111
|
+
io.out(`hook removed from ${settings}\nbackup: ${backup}\n`);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function status(io) {
|
|
116
|
+
const { settings, reports } = paths(io);
|
|
117
|
+
let doc = {};
|
|
118
|
+
try { doc = readSettings(settings); } catch (e) { io.err(`${settings} could not be read (${e.message}).\n`); return 2; }
|
|
119
|
+
const ours = [];
|
|
120
|
+
for (const g of (doc.hooks && Array.isArray(doc.hooks.SessionEnd) ? doc.hooks.SessionEnd : [])) {
|
|
121
|
+
for (const h of (g && Array.isArray(g.hooks) ? g.hooks : [])) if (isOurs(h)) ours.push(h.command);
|
|
122
|
+
}
|
|
123
|
+
if (!ours.length) { io.out('hook: not installed\n'); return 0; }
|
|
124
|
+
const wallets = [...new Set(ours.flatMap((c) => c.match(/0x[0-9a-fA-F]{40}/g) || []))];
|
|
125
|
+
io.out(`hook: installed (SessionEnd) in ${settings}\nwallets: ${wallets.join(', ')}\n`);
|
|
126
|
+
let files = [];
|
|
127
|
+
try { files = readdirSync(reports).filter((f) => f.endsWith('.json')); } catch { /* none yet */ }
|
|
128
|
+
io.out(`reports: ${files.length} in ${reports}\n`);
|
|
129
|
+
if (files.length) {
|
|
130
|
+
const newest = files.map((f) => join(reports, f)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
131
|
+
try {
|
|
132
|
+
const r = JSON.parse(readFileSync(newest, 'utf8'));
|
|
133
|
+
io.out(`latest: ${newest}: ${r.summary.findings} finding(s), ${r.summary.accounted} accounted\n`);
|
|
134
|
+
} catch { io.out(`latest: ${newest}\n`); }
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readStdin(stream, waitMs) {
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
let buf = '';
|
|
142
|
+
const done = () => { stream.removeAllListeners(); if (stream.pause) stream.pause(); clearTimeout(timer); resolve(buf); };
|
|
143
|
+
const timer = setTimeout(done, waitMs);
|
|
144
|
+
stream.setEncoding('utf8');
|
|
145
|
+
stream.on('data', (d) => { buf += d; if (buf.length > 1048576) done(); });
|
|
146
|
+
stream.on('end', done);
|
|
147
|
+
stream.on('error', done);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The hook command. Always returns 0. */
|
|
152
|
+
async function run(argv, io) {
|
|
153
|
+
try {
|
|
154
|
+
const { values } = parseArgs({ args: argv, options: { wallet: { type: 'string', multiple: true }, tag: { type: 'string' } }, allowPositionals: false });
|
|
155
|
+
const payload = JSON.parse(await readStdin(io.stdin || process.stdin, STDIN_WAIT_MS));
|
|
156
|
+
const log = payload.transcript_path;
|
|
157
|
+
if (typeof log !== 'string' || !log.endsWith('.jsonl') || !existsSync(log)) return 0;
|
|
158
|
+
const id = String(payload.session_id || 'session').replace(/[^A-Za-z0-9-]/g, '-');
|
|
159
|
+
const cwd = typeof payload.cwd === 'string' && existsSync(payload.cwd) ? payload.cwd : (io.home || homedir());
|
|
160
|
+
const { reports } = paths(io);
|
|
161
|
+
const args = [BIN, 'hook', 'worker', '--log', log, '--id', id, '--reports', reports];
|
|
162
|
+
for (const w of values.wallet || []) args.push('--wallet', w);
|
|
163
|
+
const child = spawn(process.execPath, args, { cwd, detached: true, stdio: 'ignore', env: io.env || process.env });
|
|
164
|
+
child.on('error', () => {});
|
|
165
|
+
child.unref();
|
|
166
|
+
} catch { /* a hook never fails a session */ }
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Runs the audit, detached from the session. Failures only ever mean no report file. */
|
|
171
|
+
async function worker(argv, io) {
|
|
172
|
+
try {
|
|
173
|
+
const { values } = parseArgs({
|
|
174
|
+
args: argv,
|
|
175
|
+
options: { wallet: { type: 'string', multiple: true }, log: { type: 'string' }, id: { type: 'string' }, reports: { type: 'string' } },
|
|
176
|
+
allowPositionals: false,
|
|
177
|
+
});
|
|
178
|
+
const { auditReport } = await import('./audit.mjs');
|
|
179
|
+
const { renderText } = await import('./report.mjs');
|
|
180
|
+
const { report } = await auditReport({
|
|
181
|
+
wallets: values.wallet, logs: [values.log], cwd: io.cwd || process.cwd(), yes: true, ...(io.auditExtra || {}),
|
|
182
|
+
});
|
|
183
|
+
mkdirSync(values.reports, { recursive: true, mode: 0o700 });
|
|
184
|
+
writeFileSync(join(values.reports, `${values.id}.json`), JSON.stringify(report, null, 2) + '\n', { mode: 0o600 });
|
|
185
|
+
writeFileSync(join(values.reports, `${values.id}.txt`), renderText(report, {}), { mode: 0o600 });
|
|
186
|
+
} catch { /* nothing to write */ }
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function runHook(argv, io = {}) {
|
|
191
|
+
const [sub, ...rest] = argv;
|
|
192
|
+
switch (sub) {
|
|
193
|
+
case 'install': return install(rest, io);
|
|
194
|
+
case 'uninstall': return uninstall(io);
|
|
195
|
+
case 'status': return status(io);
|
|
196
|
+
case 'run': return run(rest, io);
|
|
197
|
+
case 'worker': return worker(rest, io);
|
|
198
|
+
default:
|
|
199
|
+
io.err('usage: chainfess hook install --wallet 0x… | uninstall | status\n');
|
|
200
|
+
return 2;
|
|
201
|
+
}
|
|
202
|
+
}
|