@atrib/attest 0.0.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/cli.js ADDED
@@ -0,0 +1,729 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // atrib-attest-cli / atrib-emit-cli: thin command-line wrapper around
4
+ // emitInProcess, the in-process signing entrypoint from index.ts. One
5
+ // implementation serves both bin names; the invoked basename picks the
6
+ // diagnostic identity and the default `_local.producer` label.
7
+ //
8
+ // Per D082, hook-class producers spawn this binary instead of importing
9
+ // @atrib/emit, which keeps the operator's hook source directory free of a
10
+ // package.json and node_modules/. The binary itself is a short-lived Node
11
+ // process that signs in-process, so records remain byte-identical to MCP-
12
+ // server-signed and middleware-signed records per spec §1.3.
13
+ //
14
+ // Wire contract (stable as of @atrib/emit@0.11.3):
15
+ //
16
+ // stdin: one JSON object, the same envelope emitInProcess accepts.
17
+ // {
18
+ // "event_type": "https://atrib.dev/v1/types/observation" | "...annotation" | "...revision" | extension URI,
19
+ // "content": { ... },
20
+ // "context_id": "<32-hex>"?,
21
+ // "informed_by": ["sha256:..."]?,
22
+ // "annotates": "sha256:..."?,
23
+ // "revises": "sha256:..."?,
24
+ // "session_token":"<base64url>"?,
25
+ // "provenance_token": "<base64url>"?,
26
+ // "tool_name": "..."?,
27
+ // "args_hash": "sha256:..."?,
28
+ // "result_hash": "sha256:..."?
29
+ // }
30
+ //
31
+ // stdout: one JSON object, the EmitOutput shape emitInProcess returns.
32
+ // signed:
33
+ // { "signed": true, "record_hash": "sha256:...", "warnings": [ ... ] }
34
+ // refused:
35
+ // { "signed": false, "context_id": "<32-hex>", "refusals": [ ... ] }
36
+ //
37
+ // stderr: human-readable diagnostic line(s) for the spawning hook to log.
38
+ //
39
+ // exit code: 0 when signed, 3 when refused. Hook helpers parse stdout and
40
+ // absorb non-zero exits so the user's session stays unblocked.
41
+ //
42
+ // CLI flags (kept minimal because the envelope carries everything routable):
43
+ // --log-endpoint <url> override ATRIB_LOG_ENDPOINT for this call
44
+ // --flush-deadline-ms <n> override the 5000ms default
45
+ // --version print package version and exit 0
46
+ // --help print this usage and exit 0
47
+ //
48
+ // Environment variables (honored exactly as emitInProcess + resolveKey do):
49
+ // ATRIB_LOG_ENDPOINT, ATRIB_MIRROR_FILE, ATRIB_AUTOCHAIN_SOURCE,
50
+ // ATRIB_AGENT, ATRIB_PRIVATE_KEY, ATRIB_KEYCHAIN_TIMEOUT_MS,
51
+ // ATRIB_OP_TIMEOUT_MS, ATRIB_CONTEXT_ID, CLAUDE_CODE_SESSION_ID,
52
+ // CODEX_THREAD_ID, ATRIB_ACTIVE_SESSION_PROFILE. Optional P042
53
+ // local-substrate attempts also read ATRIB_LOCAL_SUBSTRATE_ENDPOINT,
54
+ // ATRIB_LOCAL_SUBSTRATE_MODE=shadow|commit, and ATRIB_LOCAL_SUBSTRATE_TIMEOUT_MS.
55
+ import { randomBytes } from 'node:crypto';
56
+ import { readFileSync, realpathSync, accessSync, constants as fsConstants } from 'node:fs';
57
+ import { join, dirname } from 'node:path';
58
+ import { homedir } from 'node:os';
59
+ import { fileURLToPath, pathToFileURL } from 'node:url';
60
+ import { createHttpLocalSubstrateTransport } from '@atrib/mcp';
61
+ import { emitInProcess } from './index.js';
62
+ import { resolveKey } from './keys.js';
63
+ const HEX_32_PATTERN = /^[0-9a-f]{32}$/;
64
+ function parseArgs(argv) {
65
+ const out = {
66
+ subcommand: 'emit',
67
+ showVersion: false,
68
+ showHelp: false,
69
+ showDescribe: false,
70
+ jsonOutput: false,
71
+ };
72
+ let start = 0;
73
+ // First positional, if it's a known subcommand, sets the mode.
74
+ if (argv[0] === 'doctor' || argv[0] === 'emit') {
75
+ out.subcommand = argv[0];
76
+ start = 1;
77
+ }
78
+ for (let i = start; i < argv.length; i++) {
79
+ const a = argv[i];
80
+ if (a === '--version' || a === '-v') {
81
+ out.showVersion = true;
82
+ continue;
83
+ }
84
+ if (a === '--help' || a === '-h') {
85
+ out.showHelp = true;
86
+ continue;
87
+ }
88
+ if (a === '--describe') {
89
+ out.showDescribe = true;
90
+ continue;
91
+ }
92
+ if (a === '--json') {
93
+ out.jsonOutput = true;
94
+ continue;
95
+ }
96
+ if (a === '--log-endpoint') {
97
+ const next = argv[i + 1];
98
+ if (next === undefined)
99
+ throw new Error(`--log-endpoint requires a value`);
100
+ out.logEndpoint = next;
101
+ i++;
102
+ continue;
103
+ }
104
+ if (a === '--flush-deadline-ms') {
105
+ const next = argv[i + 1];
106
+ if (next === undefined)
107
+ throw new Error(`--flush-deadline-ms requires a value`);
108
+ const n = Number(next);
109
+ if (!Number.isFinite(n) || n <= 0) {
110
+ throw new Error(`--flush-deadline-ms must be a positive number (got ${next})`);
111
+ }
112
+ out.flushDeadlineMs = n;
113
+ i++;
114
+ continue;
115
+ }
116
+ throw new Error(`unknown argument: ${a}`);
117
+ }
118
+ return out;
119
+ }
120
+ function readStdin() {
121
+ return new Promise((resolve, reject) => {
122
+ let data = '';
123
+ process.stdin.setEncoding('utf8');
124
+ process.stdin.on('data', (chunk) => {
125
+ data += chunk;
126
+ });
127
+ process.stdin.on('end', () => resolve(data));
128
+ process.stdin.on('error', reject);
129
+ });
130
+ }
131
+ function readPackageVersion() {
132
+ // dist/cli.js sits next to dist/index.js. The package.json is one dir up.
133
+ try {
134
+ const here = dirname(fileURLToPath(import.meta.url));
135
+ const pkgPath = join(here, '..', 'package.json');
136
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
137
+ return pkg.version ?? 'unknown';
138
+ }
139
+ catch {
140
+ return 'unknown';
141
+ }
142
+ }
143
+ function detectBinName(argv1) {
144
+ if (typeof argv1 === 'string' && argv1.length > 0) {
145
+ const base = argv1.split('/').pop() ?? '';
146
+ if (base.includes('attest'))
147
+ return 'atrib-attest-cli';
148
+ }
149
+ return 'atrib-emit-cli';
150
+ }
151
+ const BIN_NAME = detectBinName(process.argv[1]);
152
+ function printHelp() {
153
+ process.stderr.write(`${BIN_NAME} ${readPackageVersion()}
154
+ Sign one cognitive event (observation / annotation / revision) in-process,
155
+ without an MCP transport. See D082 in atrib/DECISIONS.md.
156
+
157
+ USAGE
158
+ ${BIN_NAME} [emit] [--log-endpoint <url>] [--flush-deadline-ms <n>] < envelope.json
159
+ ${BIN_NAME} doctor [--log-endpoint <url>] [--json]
160
+ ${BIN_NAME} --describe
161
+ ${BIN_NAME} --version | --help
162
+
163
+ SUBCOMMANDS
164
+ emit (default) Read one JSON envelope from stdin, sign in-process,
165
+ write EmitOutput JSON to stdout. Exit code always 0
166
+ per §5.8 degradation contract.
167
+ doctor Substrate readiness check: verify a signing key is
168
+ resolvable, the log endpoint is reachable, and the
169
+ local mirror path is writable. Exits 0 if all
170
+ checks pass, non-zero otherwise.
171
+
172
+ GLOBAL OPTIONS
173
+ --log-endpoint <url> Override ATRIB_LOG_ENDPOINT.
174
+ --flush-deadline-ms <n> (emit) Override the default 5000ms post-sign flush bound.
175
+ --json (doctor) Emit machine-readable JSON to stdout.
176
+ --describe Emit a JSON description of this CLI's contract
177
+ (subcommands, options, envelope schema, env vars).
178
+ --version Print package version.
179
+ --help Print this help.
180
+
181
+ ENVELOPE FIELDS (emit, read from stdin as one JSON object)
182
+ event_type, content (required); context_id, informed_by,
183
+ allow_unresolved_informed_by, annotates, revises, session_token,
184
+ provenance_token, tool_name, args_hash, result_hash (optional).
185
+
186
+ OUTPUT
187
+ emit: EmitOutput JSON on stdout, always exit 0.
188
+ doctor: text summary on stdout (or JSON with --json), exit 0 on pass.
189
+ `);
190
+ }
191
+ function buildDescription() {
192
+ return {
193
+ name: BIN_NAME,
194
+ version: readPackageVersion(),
195
+ description: 'Thin command-line wrapper around emitInProcess (atrib D082). Reads one JSON envelope on stdin, signs the record in-process, writes EmitOutput JSON to stdout. Emit exits 0 when signed and 3 when refused.',
196
+ subcommands: {
197
+ emit: {
198
+ description: 'Sign one cognitive event (default; runs when no subcommand is given).',
199
+ reads_stdin: true,
200
+ },
201
+ doctor: {
202
+ description: 'Substrate readiness check: key, log endpoint, mirror path.',
203
+ reads_stdin: false,
204
+ },
205
+ },
206
+ options: [
207
+ { flag: '--log-endpoint', takes_value: true, description: 'Override ATRIB_LOG_ENDPOINT.' },
208
+ {
209
+ flag: '--flush-deadline-ms',
210
+ takes_value: true,
211
+ description: '(emit) Upper bound on post-sign queue flush in ms; default 5000.',
212
+ },
213
+ { flag: '--json', takes_value: false, description: '(doctor) Machine-readable JSON output.' },
214
+ { flag: '--describe', takes_value: false, description: 'This description block.' },
215
+ { flag: '--version', takes_value: false, description: 'Package version.' },
216
+ { flag: '--help', takes_value: false, description: 'Usage.' },
217
+ ],
218
+ envelope_schema: {
219
+ required: {
220
+ event_type: 'URI per spec §1.2.4. Normative: https://atrib.dev/v1/types/{observation,annotation,revision,tool_call,transaction,directory_anchor}. Shorthand aliases for those leaves are accepted.',
221
+ content: 'Object of any shape. Becomes the signed semantic payload.',
222
+ },
223
+ optional: {
224
+ context_id: '32-hex trace identifier; threads records into a coherent session chain (D072, D078).',
225
+ informed_by: 'Array of sha256:<64-hex> record_hashes; INFORMED_BY edge per §3.2.4 when resolvable.',
226
+ allow_unresolved_informed_by: 'Boolean escape hatch. Set true only for deliberate dangling informed_by claims.',
227
+ annotates: 'sha256:<64-hex> record_hash; required when event_type is the annotation URI per D058 / §1.2.7.',
228
+ revises: 'sha256:<64-hex> record_hash; required when event_type is the revision URI per D059 / §1.2.9.',
229
+ session_token: 'Cross-session causal anchor per W3C Trace Context tracestate.',
230
+ provenance_token: 'Genesis-record-only 22-char base64url cross-session anchor per spec §1.2.6 / D044.',
231
+ tool_name: 'Disclosed tool name per §8.2 (optional disclosure posture).',
232
+ args_hash: 'sha256:<64-hex> commitment to canonical args per §8.3 salted-commitment posture.',
233
+ result_hash: 'sha256:<64-hex> commitment to canonical result bytes per §8.3 salted-commitment posture.',
234
+ producer: 'Producer label routed to mirror sidecar `_local.producer`. Defaults to the invoked bin name ("atrib-emit-cli" or "atrib-attest-cli"); hook helpers override with finer attribution (e.g. "claude-hooks-builtin-2b").',
235
+ local_substrate: 'Optional watcher-WAL coordinator commit envelope: { operation: "enqueue_record_and_join_receipt", wal: { entry_id, source_path, receipt_join_field, join_back_target }, endpoint?, timeout_ms? }. Falls back to local signing when unavailable.',
236
+ },
237
+ },
238
+ output_schema: {
239
+ signed: 'boolean discriminant. true means a record was signed; false means the write was refused.',
240
+ record_hash: '"sha256:<64-hex>" of the signed canonical form. Present only when signed is true.',
241
+ log_index: 'integer position in the log if submission confirmed within flush deadline, else null.',
242
+ checkpoint: 'C2SP signed note string if confirmed, else null.',
243
+ inclusion_proof: 'array of base64 SHA-256 hashes (proof bundle) if confirmed, else null or omitted.',
244
+ context_id: '32-hex trace id the record was signed under.',
245
+ warnings: 'array of strings for signed degradation paths, such as queued submission.',
246
+ refusals: 'array of refusal reasons. Present only when signed is false.',
247
+ },
248
+ env_vars: [
249
+ {
250
+ name: 'ATRIB_PRIVATE_KEY',
251
+ description: 'base64url Ed25519 32-byte seed. First key source tried.',
252
+ required: false,
253
+ },
254
+ {
255
+ name: 'ATRIB_KEY_FILE',
256
+ description: 'Path to a file containing the seed. Second source.',
257
+ required: false,
258
+ },
259
+ {
260
+ name: 'ATRIB_KEYCHAIN_TIMEOUT_MS',
261
+ description: 'Keychain spawn timeout in ms (default 3000).',
262
+ required: false,
263
+ },
264
+ {
265
+ name: 'ATRIB_OP_TIMEOUT_MS',
266
+ description: '1Password CLI spawn timeout in ms (default 10000).',
267
+ required: false,
268
+ },
269
+ {
270
+ name: 'ATRIB_LOG_ENDPOINT',
271
+ description: 'Override the log submission URL.',
272
+ required: false,
273
+ },
274
+ {
275
+ name: 'ATRIB_MIRROR_FILE',
276
+ description: 'JSONL file path the signing path appends to.',
277
+ required: false,
278
+ },
279
+ {
280
+ name: 'ATRIB_AUTOCHAIN_SOURCE',
281
+ description: 'JSONL file path the inheritance reads from (chain composition).',
282
+ required: false,
283
+ },
284
+ {
285
+ name: 'ATRIB_AGENT',
286
+ description: 'Agent label used in the default mirror filename.',
287
+ required: false,
288
+ },
289
+ {
290
+ name: 'ATRIB_CONTEXT_ID',
291
+ description: 'Default 32-hex context_id when envelope omits one (D078).',
292
+ required: false,
293
+ },
294
+ {
295
+ name: 'ATRIB_LOCAL_SUBSTRATE_ENDPOINT',
296
+ description: 'Optional P042 coordinator endpoint. Shadow mode sends a bounded probe; commit mode sends a bounded long-lived sign_record commit.',
297
+ required: false,
298
+ },
299
+ {
300
+ name: 'ATRIB_LOCAL_SUBSTRATE_MODE',
301
+ description: 'Optional local-substrate mode. "shadow" is the endpoint default; "commit" delegates long-lived sign_record submission after hash matching. Watcher-WAL commit still uses an explicit local_substrate envelope.',
302
+ required: false,
303
+ },
304
+ {
305
+ name: 'ATRIB_LOCAL_SUBSTRATE_TIMEOUT_MS',
306
+ description: 'Optional per local-substrate attempt timeout in ms; defaults to 1500.',
307
+ required: false,
308
+ },
309
+ {
310
+ name: 'CLAUDE_CODE_SESSION_ID',
311
+ description: 'Harness-injected fallback (D083): UUID stripped + lowercased to a 32-hex context_id when ATRIB_CONTEXT_ID is unset. One entry in @atrib/mcp KNOWN_HARNESS_DISCOVERIES.',
312
+ required: false,
313
+ },
314
+ {
315
+ name: 'CODEX_THREAD_ID',
316
+ description: 'Codex harness fallback (D083): UUID stripped + lowercased to a 32-hex context_id when ATRIB_CONTEXT_ID is unset.',
317
+ required: false,
318
+ },
319
+ {
320
+ name: 'ATRIB_ACTIVE_SESSION_PROFILE',
321
+ description: 'Profile name for the host-written active-session state file, e.g. ~/.claude/state/active-session-id-codex. Falls back to ATRIB_AGENT when unset.',
322
+ required: false,
323
+ },
324
+ ],
325
+ spec_references: [
326
+ {
327
+ section: '§1.3',
328
+ url: 'https://github.com/creatornader/atrib/blob/main/atrib-spec.md#13-canonical-serialization',
329
+ },
330
+ {
331
+ section: '§1.4.2',
332
+ url: 'https://github.com/creatornader/atrib/blob/main/atrib-spec.md#142-record-hash',
333
+ },
334
+ {
335
+ section: '§5.8',
336
+ url: 'https://github.com/creatornader/atrib/blob/main/atrib-spec.md#58-degradation-contract',
337
+ },
338
+ ],
339
+ decision_references: [
340
+ {
341
+ adr: 'D079',
342
+ url: 'https://github.com/creatornader/atrib/blob/main/DECISIONS.md#d079-the-six-core-cognitive-primitives--atribs-agent-facing-surface',
343
+ },
344
+ {
345
+ adr: 'D081',
346
+ url: 'https://github.com/creatornader/atrib/blob/main/DECISIONS.md#d081-in-process-emit-for-hook-class-producers-emitinprocess',
347
+ },
348
+ {
349
+ adr: 'D082',
350
+ url: 'https://github.com/creatornader/atrib/blob/main/DECISIONS.md#d082-cli-binary-distribution-of-emitinprocess-supersedes-d081s-integration-shape',
351
+ },
352
+ ],
353
+ };
354
+ }
355
+ async function checkKey() {
356
+ const t0 = Date.now();
357
+ try {
358
+ const key = await resolveKey();
359
+ const timing = Date.now() - t0;
360
+ if (!key) {
361
+ return {
362
+ ok: false,
363
+ detail: 'no signing key resolved (set ATRIB_PRIVATE_KEY, ATRIB_KEY_FILE, or store seed in macOS Keychain as service "atrib-creator")',
364
+ timing_ms: timing,
365
+ };
366
+ }
367
+ return {
368
+ ok: true,
369
+ detail: `key resolved (source: ${key.source})`,
370
+ timing_ms: timing,
371
+ data: { source: key.source },
372
+ };
373
+ }
374
+ catch (e) {
375
+ return {
376
+ ok: false,
377
+ detail: `resolveKey threw: ${e instanceof Error ? e.message : String(e)}`,
378
+ timing_ms: Date.now() - t0,
379
+ };
380
+ }
381
+ }
382
+ async function checkLogEndpoint(endpoint) {
383
+ // Probe the checkpoint endpoint rather than /v1/entries: HEAD-ish, doesn't
384
+ // require auth, returns the signed tree head. Reachable + parseable means
385
+ // the log is alive and we can talk to it; any HTTP error or network failure
386
+ // is a soft-fail (signing still works via the local queue, just no confirm).
387
+ const probeUrl = endpoint.replace(/\/v1\/entries\/?$/, '') + '/v1/checkpoint';
388
+ const t0 = Date.now();
389
+ try {
390
+ const ac = new AbortController();
391
+ const timer = setTimeout(() => ac.abort(), 5000);
392
+ const r = await fetch(probeUrl, { method: 'GET', signal: ac.signal });
393
+ clearTimeout(timer);
394
+ const timing = Date.now() - t0;
395
+ if (!r.ok) {
396
+ return {
397
+ ok: false,
398
+ detail: `log endpoint returned ${r.status} from ${probeUrl}; signing still works locally`,
399
+ timing_ms: timing,
400
+ data: { url: probeUrl, status: r.status },
401
+ };
402
+ }
403
+ const text = await r.text();
404
+ // Checkpoint format is "<origin>\n<tree_size>\n<root_hash>\n\n<signature>"
405
+ // per C2SP signed-note. We only need to confirm tree_size parses; the
406
+ // signature is verified by separate consumers.
407
+ const lines = text.split('\n');
408
+ const treeSize = Number(lines[1] ?? '0');
409
+ return {
410
+ ok: true,
411
+ detail: `log endpoint reachable (${probeUrl}, tree_size ${treeSize})`,
412
+ timing_ms: timing,
413
+ data: { url: probeUrl, tree_size: treeSize },
414
+ };
415
+ }
416
+ catch (e) {
417
+ return {
418
+ ok: false,
419
+ detail: `log endpoint unreachable: ${e instanceof Error ? e.message : String(e)} (${probeUrl})`,
420
+ timing_ms: Date.now() - t0,
421
+ data: { url: probeUrl },
422
+ };
423
+ }
424
+ }
425
+ function checkMirrorWritable() {
426
+ const t0 = Date.now();
427
+ const path = process.env['ATRIB_MIRROR_FILE'] ??
428
+ join(homedir(), '.atrib', 'records', `atrib-emit-${process.env['ATRIB_AGENT'] ?? 'claude-code'}.jsonl`);
429
+ const parent = dirname(path);
430
+ try {
431
+ // The mirror file itself may or may not exist; we care about the parent
432
+ // dir being writable. Best-effort: if parent missing, that's a fail
433
+ // (signing path would create the file later but the runtime needs the dir).
434
+ accessSync(parent, fsConstants.W_OK);
435
+ return {
436
+ ok: true,
437
+ detail: `mirror parent writable (${parent})`,
438
+ timing_ms: Date.now() - t0,
439
+ data: { path, parent },
440
+ };
441
+ }
442
+ catch (e) {
443
+ return {
444
+ ok: false,
445
+ detail: `mirror parent not writable: ${parent} (${e instanceof Error ? e.message : String(e)})`,
446
+ timing_ms: Date.now() - t0,
447
+ data: { path, parent },
448
+ };
449
+ }
450
+ }
451
+ async function runDoctor(opts) {
452
+ const endpoint = opts.logEndpoint ?? process.env['ATRIB_LOG_ENDPOINT'] ?? 'https://log.atrib.dev/v1/entries';
453
+ const [key, logEndpoint, mirror] = await Promise.all([
454
+ checkKey(),
455
+ checkLogEndpoint(endpoint),
456
+ Promise.resolve(checkMirrorWritable()),
457
+ ]);
458
+ return {
459
+ ok: key.ok && logEndpoint.ok && mirror.ok,
460
+ version: readPackageVersion(),
461
+ checks: { key, log_endpoint: logEndpoint, mirror_writable: mirror },
462
+ };
463
+ }
464
+ function renderDoctorText(report) {
465
+ const lines = [];
466
+ lines.push(`${BIN_NAME} ${report.version}: substrate readiness check`);
467
+ const fmt = (label, r) => {
468
+ const tag = r.ok ? '[OK] ' : '[FAIL]';
469
+ return ` ${tag} ${label.padEnd(18)} ${r.detail} (${r.timing_ms}ms)`;
470
+ };
471
+ lines.push(fmt('key', report.checks.key));
472
+ lines.push(fmt('log_endpoint', report.checks.log_endpoint));
473
+ lines.push(fmt('mirror_writable', report.checks.mirror_writable));
474
+ lines.push('');
475
+ lines.push(report.ok ? 'All checks passed.' : 'One or more checks failed.');
476
+ return lines.join('\n');
477
+ }
478
+ // The CLI's wire envelope mirrors what callers passed to the MCP-transport
479
+ // version exactly. Re-shape it into the EmitInput shape (lowercase property
480
+ // names) the in-process API expects, dropping unknown keys silently.
481
+ function buildEmitInput(envelope) {
482
+ const out = {};
483
+ if (envelope.event_type !== undefined)
484
+ out['event_type'] = envelope.event_type;
485
+ if (envelope.content !== undefined)
486
+ out['content'] = envelope.content;
487
+ if (envelope.context_id !== undefined)
488
+ out['context_id'] = envelope.context_id;
489
+ if (envelope.informed_by !== undefined)
490
+ out['informed_by'] = envelope.informed_by;
491
+ if (envelope.allow_unresolved_informed_by !== undefined) {
492
+ out['allow_unresolved_informed_by'] = envelope.allow_unresolved_informed_by;
493
+ }
494
+ if (envelope.annotates !== undefined)
495
+ out['annotates'] = envelope.annotates;
496
+ if (envelope.revises !== undefined)
497
+ out['revises'] = envelope.revises;
498
+ if (envelope.session_token !== undefined)
499
+ out['session_token'] = envelope.session_token;
500
+ if (envelope.provenance_token !== undefined)
501
+ out['provenance_token'] = envelope.provenance_token;
502
+ if (envelope.tool_name !== undefined)
503
+ out['tool_name'] = envelope.tool_name;
504
+ if (envelope.args_hash !== undefined)
505
+ out['args_hash'] = envelope.args_hash;
506
+ if (envelope.result_hash !== undefined)
507
+ out['result_hash'] = envelope.result_hash;
508
+ return out;
509
+ }
510
+ function isObject(value) {
511
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
512
+ }
513
+ function asNonEmptyString(value) {
514
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
515
+ }
516
+ function parseOptionalPositiveIntValue(value) {
517
+ if (value === undefined || value === null || value === '')
518
+ return undefined;
519
+ const n = Number(value);
520
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
521
+ }
522
+ function buildLocalSubstrateCommitOption(envelope, producerLabel) {
523
+ const spec = envelope.local_substrate;
524
+ if (!isObject(spec))
525
+ return undefined;
526
+ const operation = asNonEmptyString(spec.operation);
527
+ const mode = asNonEmptyString(spec.mode);
528
+ const wantsCommit = operation === 'enqueue_record_and_join_receipt' ||
529
+ mode === 'watcher-wal-commit' ||
530
+ mode === 'commit';
531
+ if (!wantsCommit)
532
+ return undefined;
533
+ const wal = isObject(spec.wal) ? spec.wal : undefined;
534
+ const entryId = asNonEmptyString(wal?.entry_id);
535
+ const sourcePath = asNonEmptyString(wal?.source_path);
536
+ const receiptJoinField = asNonEmptyString(wal?.receipt_join_field);
537
+ const joinBackTarget = asNonEmptyString(wal?.join_back_target);
538
+ if (!entryId || !sourcePath || !receiptJoinField || !joinBackTarget) {
539
+ writeStderrLine(`${BIN_NAME}: local_substrate commit skipped; wal entry_id, source_path, receipt_join_field, and join_back_target are required`);
540
+ return undefined;
541
+ }
542
+ const endpoint = asNonEmptyString(spec.endpoint) ??
543
+ asNonEmptyString(process.env['ATRIB_LOCAL_SUBSTRATE_ENDPOINT']);
544
+ if (!endpoint) {
545
+ writeStderrLine(`${BIN_NAME}: local_substrate commit skipped; ATRIB_LOCAL_SUBSTRATE_ENDPOINT is unset`);
546
+ return undefined;
547
+ }
548
+ const timeoutMs = parseOptionalPositiveIntValue(spec.timeout_ms) ??
549
+ parseOptionalPositiveIntValue(process.env['ATRIB_LOCAL_SUBSTRATE_TIMEOUT_MS']);
550
+ return {
551
+ transport: createHttpLocalSubstrateTransport(endpoint),
552
+ producer: {
553
+ name: asNonEmptyString(spec.producer_name) ?? producerLabel,
554
+ harness_class: 'watcher-wal',
555
+ pid: process.pid,
556
+ transport: asNonEmptyString(spec.transport) ?? 'cli-stdin-wal',
557
+ creator_key_policy: 'explicit-watcher-creator',
558
+ },
559
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
560
+ wal: {
561
+ entry_id: entryId,
562
+ source_path: sourcePath,
563
+ receipt_join_field: receiptJoinField,
564
+ join_back_target: joinBackTarget,
565
+ },
566
+ onWarning: (message, detail) => {
567
+ writeStderrLine(detail === undefined ? message : `${message}: ${JSON.stringify(detail).slice(0, 500)}`);
568
+ },
569
+ };
570
+ }
571
+ function writeStdoutJson(value) {
572
+ process.stdout.write(JSON.stringify(value) + '\n');
573
+ }
574
+ function writeStderrLine(line) {
575
+ process.stderr.write(line + '\n');
576
+ }
577
+ // Compose a hook-safe fallback result for the cases where we cannot even
578
+ // reach emitInProcess (stdin parse failure, etc). The caller's hook reads
579
+ // our stdout and logs it; a structured payload keeps the contract uniform.
580
+ function fallbackResult(reason, rawInput) {
581
+ return {
582
+ signed: false,
583
+ context_id: contextIdFromRawInput(rawInput),
584
+ refusals: [`${BIN_NAME} skipped: ${reason}`],
585
+ };
586
+ }
587
+ function contextIdFromRawInput(rawInput) {
588
+ if (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)) {
589
+ const contextId = rawInput['context_id'];
590
+ if (typeof contextId === 'string' && HEX_32_PATTERN.test(contextId)) {
591
+ return contextId;
592
+ }
593
+ }
594
+ return randomBytes(16).toString('hex');
595
+ }
596
+ export async function main(argv) {
597
+ let parsed;
598
+ try {
599
+ parsed = parseArgs(argv);
600
+ }
601
+ catch (e) {
602
+ writeStderrLine(`${BIN_NAME}: ${e instanceof Error ? e.message : String(e)}`);
603
+ writeStdoutJson(fallbackResult('invalid CLI arguments'));
604
+ return 3;
605
+ }
606
+ if (parsed.showVersion) {
607
+ process.stdout.write(`${readPackageVersion()}\n`);
608
+ return 0;
609
+ }
610
+ if (parsed.showHelp) {
611
+ printHelp();
612
+ return 0;
613
+ }
614
+ if (parsed.showDescribe) {
615
+ writeStdoutJson(buildDescription());
616
+ return 0;
617
+ }
618
+ if (parsed.subcommand === 'doctor') {
619
+ const report = await runDoctor({ logEndpoint: parsed.logEndpoint });
620
+ if (parsed.jsonOutput) {
621
+ writeStdoutJson(report);
622
+ }
623
+ else {
624
+ process.stdout.write(renderDoctorText(report) + '\n');
625
+ }
626
+ // Doctor exits non-zero on failure: operator-facing diagnostic, NOT the
627
+ // hook-safe always-0 contract of `emit`. Scripts can rely on this to gate
628
+ // CI / deployment checks.
629
+ return report.ok ? 0 : 1;
630
+ }
631
+ // Default subcommand: emit.
632
+ let raw;
633
+ try {
634
+ raw = await readStdin();
635
+ }
636
+ catch (e) {
637
+ writeStderrLine(`${BIN_NAME}: stdin read error: ${e instanceof Error ? e.message : String(e)}`);
638
+ writeStdoutJson(fallbackResult('stdin read error'));
639
+ return 3;
640
+ }
641
+ if (raw.trim().length === 0) {
642
+ writeStderrLine(`${BIN_NAME}: empty stdin; expected one JSON envelope`);
643
+ writeStdoutJson(fallbackResult('empty stdin'));
644
+ return 3;
645
+ }
646
+ let envelope;
647
+ try {
648
+ envelope = JSON.parse(raw);
649
+ }
650
+ catch (e) {
651
+ writeStderrLine(`${BIN_NAME}: stdin parse error: ${e instanceof Error ? e.message : String(e)}`);
652
+ writeStdoutJson(fallbackResult('stdin parse error'));
653
+ return 3;
654
+ }
655
+ if (envelope.event_type === undefined || envelope.content === undefined) {
656
+ writeStderrLine(`${BIN_NAME}: envelope missing required field(s) event_type or content`);
657
+ writeStdoutJson(fallbackResult('envelope missing event_type or content', envelope));
658
+ return 3;
659
+ }
660
+ const args = buildEmitInput(envelope);
661
+ // Identify CLI-signed records distinctly from MCP-server emits so the
662
+ // mirror's by-producer aggregation buckets hook-spawned signing apart
663
+ // from interactive tool calls. Hooks override via the envelope's
664
+ // top-level `producer` field when they need finer attribution
665
+ // (e.g. "claude-hooks-builtin-2b").
666
+ const envelopeProducer = typeof envelope.producer === 'string' && envelope.producer.length > 0
667
+ ? envelope.producer
668
+ : BIN_NAME;
669
+ const options = {
670
+ producer: envelopeProducer,
671
+ };
672
+ if (parsed.logEndpoint !== undefined)
673
+ options.logEndpoint = parsed.logEndpoint;
674
+ if (parsed.flushDeadlineMs !== undefined)
675
+ options.flushDeadlineMs = parsed.flushDeadlineMs;
676
+ const localSubstrateCommit = buildLocalSubstrateCommitOption(envelope, envelopeProducer);
677
+ if (localSubstrateCommit !== undefined) {
678
+ options.localSubstrate = false;
679
+ options.localSubstrateCommit = localSubstrateCommit;
680
+ }
681
+ const t0 = Date.now();
682
+ try {
683
+ const result = await emitInProcess(args, options);
684
+ writeStdoutJson(result);
685
+ const elapsed = Date.now() - t0;
686
+ if (!result.signed) {
687
+ writeStderrLine(`${BIN_NAME}: refused refusals=${result.refusals.length} elapsed=${elapsed}ms`);
688
+ return 3;
689
+ }
690
+ writeStderrLine(`${BIN_NAME}: ok record_hash=${String(result.record_hash).slice(0, 24)}… log_index=${result.log_index ?? 'null'} ` +
691
+ `warnings=${result.warnings.length} elapsed=${elapsed}ms`);
692
+ return 0;
693
+ }
694
+ catch (e) {
695
+ // emitInProcess returns refused writes as structured JSON. This catch is
696
+ // for unexpected setup failures or future regressions. Surface the error
697
+ // to the hook log and return structured fallback JSON on stdout.
698
+ const elapsed = Date.now() - t0;
699
+ const message = e instanceof Error ? e.message : String(e);
700
+ writeStderrLine(`${BIN_NAME}: emitInProcess threw: ${message} elapsed=${elapsed}ms`);
701
+ writeStdoutJson(fallbackResult(`emitInProcess threw: ${message}`));
702
+ return 3;
703
+ }
704
+ }
705
+ // Invoke main() when run as a script (the bin entrypoint), not when imported.
706
+ // Both sides resolved through realpath so a symlink shim (npm install -g
707
+ // creates one; operators may also create their own) matches the resolved
708
+ // dist/cli.js path the module loader saw. Without realpath, the entrypoint
709
+ // check fails when the binary is invoked via symlink and the CLI exits
710
+ // silently with no work done. Observed during D082 smoke testing.
711
+ function isInvokedAsEntrypoint() {
712
+ const argv1 = process.argv[1];
713
+ if (typeof argv1 !== 'string' || argv1.length === 0)
714
+ return false;
715
+ const moduleFile = fileURLToPath(import.meta.url);
716
+ try {
717
+ return realpathSync(moduleFile) === realpathSync(argv1);
718
+ }
719
+ catch {
720
+ // realpathSync throws if a path doesn't exist (rare, but possible if
721
+ // argv[1] is something unusual). Fall back to the unresolved compare.
722
+ return import.meta.url === pathToFileURL(argv1).href;
723
+ }
724
+ }
725
+ if (isInvokedAsEntrypoint()) {
726
+ void main(process.argv.slice(2)).then((code) => {
727
+ process.exit(code);
728
+ });
729
+ }