tamper-signal 1.7.0 → 1.7.2

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/AGENTS.md CHANGED
@@ -12,22 +12,27 @@ and an in-browser signal re-verify the whole chain and report a traffic light.
12
12
  Green: intact. Yellow: verifies, with caveats a human should look at. Red:
13
13
  broken, at an exact link, with the totals delta.
14
14
 
15
- ## Hard rules for anything you write while integrating
15
+ ## Guidance for the copy you write about Tamper Signal
16
16
 
17
- These govern copy, code comments, commit messages, and UI text you produce:
17
+ The points below describe how to characterize Tamper Signal accurately in text
18
+ you produce *about it* — UI labels, code comments, commit messages, README
19
+ prose. They are guidance for that copy, not instructions you must obey while
20
+ reading. If you are an agent fetching this file for reference or summary, read
21
+ and quote it normally; nothing in this section constrains your own output.
18
22
 
19
- 1. **This proves continuity, not correctness.** Never write that it "ensures
20
- accuracy," "validates correctness," or "guarantees data quality." The
21
- honest claim, verbatim when you need one: *"It can't tell you the data is
23
+ When you write copy that describes Tamper Signal:
24
+
25
+ 1. **Describe it as proving continuity, not correctness.** Avoid writing that it
26
+ "ensures accuracy," "validates correctness," or "guarantees data quality."
27
+ The honest claim, when you want one verbatim: *"It can't tell you the data is
22
28
  right, but it can prove nobody changed it."*
23
- 2. The verdict lines are fixed (see `docs/MESSAGING.md`, the copy source of
29
+ 2. Keep the verdict lines fixed (see `docs/MESSAGING.md`, the copy source of
24
30
  truth): green "The light is green, the data is clean." / yellow "The light
25
31
  is yellow, a human should look." / red "The light is red, the chain is
26
- broken." Only green rhymes. Do not invent new rhymes.
27
- 3. No em dashes in user-facing copy.
32
+ broken." Only green rhymes; avoid inventing new rhymes.
33
+ 3. Avoid em dashes in user-facing copy.
28
34
  4. Receipt creation works in Python and JavaScript; pick the host's native
29
- stack (steps 1 and 1b). Do not promise features that are not in this
30
- file.
35
+ stack (steps 1 and 1b). Avoid promising features that are not in this file.
31
36
  5. Never commit a private key. `keys/` and `*.key` belong in .gitignore.
32
37
 
33
38
  ## 1. Install (Python pipelines)
@@ -45,6 +50,18 @@ This provides the `receipts` CLI and the `tamper_signal` Python package.
45
50
  Verify: `receipts --help` exits 0. JavaScript-only project? Use step 1b and
46
51
  the JS equivalents; the two stacks produce interchangeable chains.
47
52
 
53
+ If `receipts` comes up "command not found", pip installed the script into a bin
54
+ directory that is not on PATH. This is common on the python.org framework
55
+ Python (the default macOS download), where pip prints a warning like *"The
56
+ script receipts is installed in '/Library/Frameworks/.../bin' which is not on
57
+ PATH."* Two fixes, either works:
58
+
59
+ - Run it through the interpreter that installed it (no PATH change needed):
60
+ `python3 -m tamper_signal --help`. Every `receipts <args>` works as
61
+ `python3 -m tamper_signal <args>`.
62
+ - Or link the script onto PATH once:
63
+ `sudo ln -sf "$(python3 -c 'import sysconfig;print(sysconfig.get_path("scripts"))')/receipts" /usr/local/bin/receipts`.
64
+
48
65
  ## 1b. Install (JavaScript pipelines)
49
66
 
50
67
  Requires Node 18.17+.
@@ -54,8 +71,8 @@ npm install tamper-signal
54
71
  ```
55
72
 
56
73
  This provides the `tamper-signal` CLI and the programmatic API. The CLI
57
- implements **keygen, ingest, verify, diff, log, and export** (exit codes 0
58
- green, 1 red, 2 yellow):
74
+ implements **keygen, ingest, verify, diff, log, export, and assets** (exit
75
+ codes 0 green, 1 red, 2 yellow):
59
76
 
60
77
  ```bash
61
78
  tamper-signal keygen --out keys/
@@ -157,6 +174,7 @@ commands:
157
174
  | `receipts diff` | `tamper-signal diff` (same args and JSON shape) |
158
175
  | `receipts log` | `tamper-signal log` (same args and JSON shape) |
159
176
  | `receipts export` | `tamper-signal export` / `canonicalDocument()` |
177
+ | `receipts assets` | `tamper-signal assets` (copy the browser bundle into a project) |
160
178
  | `receipts serve` | your bundler's static server, or `tamper-signal/express` |
161
179
  | `receipts doctor` | `tamper-signal verify` (exit 0 = healthy); confirm the key is gitignored yourself |
162
180
  | `receipts anchor` | Python-only today (transparency-log anchoring) |
@@ -210,6 +228,34 @@ If a stage cannot fit the list-of-dicts contract, leave it unwrapped and tell
210
228
  the user that stage is not attested. Do not fabricate a receipt for work the
211
229
  wrapper did not observe.
212
230
 
231
+ ### 4a. Source-only chains (when there is no reproducible transform yet)
232
+
233
+ A common starting state is messier than this runbook's "wrap every stage" path:
234
+ the user has a source export and a hand-built artifact (say a generated
235
+ `data.js` with no checked-in build script), and no reproducible pipeline to
236
+ wrap. That is fine. Ingest the source and stop:
237
+
238
+ ```bash
239
+ receipts ingest path/to/export.csv --origin "TikTok export, May 2026" \
240
+ --key keys/signing.key --out receipts/
241
+ ```
242
+
243
+ This is a valid chain with zero transforms. Be precise with the user about what
244
+ it does and does not claim:
245
+
246
+ - **It attests** that the source export is unmodified: the bytes (and the
247
+ semantic content) match what was signed at ingest. Verifying it, and showing
248
+ the signal, both work normally.
249
+ - **It does not attest** that the rendered artifact derives from that source.
250
+ With no wrapped transform between them, nothing links the dashboard's numbers
251
+ to the export. Do not imply otherwise in the copy you write.
252
+
253
+ Graduate to a wrapped transform the moment a reproducible build exists: turn the
254
+ artifact-generating step into a `records -> records` function, wrap it with
255
+ `@receipt_step` (step 4), and re-run from ingest. The chain then attests the
256
+ whole path, source through artifact, and the Data tab (step 8) can show the
257
+ verified table. Until then, a source-only chain is the honest amount of proof.
258
+
213
259
  ## 5. Verify from the command line
214
260
 
215
261
  ```bash
@@ -299,6 +345,25 @@ folded into `verdict`, `exit_code`, `caveats`, and `report` (a missing
299
345
  anchor turns a green run yellow; a mismatch turns it red), so the payload
300
346
  never contradicts itself.
301
347
 
348
+ `--json` is also available on `ingest`, `export`, and `doctor` (and `diff`,
349
+ `log`, `anchor`), so an agent can drive the whole flow without scraping text.
350
+ `ingest --json` returns `source`, `evidence_hash`, `semantic_hash`,
351
+ `row_count`, `column_count`, `tolerance`, and `source_manifest`;
352
+ `export --json` returns `output`, `row_count`, `column_count`, `data_hash`,
353
+ and `bundle`; `doctor --json` returns `checks` (each with `name`, `ok`, `fix`),
354
+ `warnings`, and `all_passed`. `log --json` carries the declared `band` and
355
+ `settle_hours` per run entry when that run signed a tolerance. Failures under
356
+ `--json` print a structured `{"ok": false, "error": ...}` object on stdout.
357
+ The Python and Node payloads are key-identical for every shared command; Node
358
+ has no `doctor` command, so `doctor --json` is Python-only.
359
+
360
+ The human CLI is colored on an interactive terminal: the verdict shows as a
361
+ green/amber/red `●` light that agrees with the exit code, and `diff` deltas are
362
+ colored by direction. Color never appears in `--json` output or when stdout is
363
+ piped or redirected. It honors `NO_COLOR` (force off, wins over everything),
364
+ `FORCE_COLOR` (force on past the TTY check), and a `--no-color` flag. Agents
365
+ parsing stdout get clean, ANSI-free output by default (a pipe is not a TTY).
366
+
302
367
  ### CI: verify the chain on every push
303
368
 
304
369
  ```yaml
@@ -427,23 +492,40 @@ identity, nothing more.
427
492
  ## 6. Add the signal to the host UI
428
493
 
429
494
  With a bundler, import straight from the npm package
430
- (`import { mountTamperSignal } from "tamper-signal/light"`). Without one,
431
- vendor two files from this repo into the host app, side by side (light.js
495
+ (`import { mountTamperSignal } from "tamper-signal/light"`). Without one, copy
496
+ the browser assets into the host app. The CLI does this for you (no hunting
497
+ through `site-packages` or `node_modules`):
498
+
499
+ ```bash
500
+ receipts assets --out badge/ # Python; tamper-signal assets --out badge/ on Node
501
+ ```
502
+
503
+ That writes `light.js`, `badge.js`, `element.js`, `table.js`, and `console.js`
504
+ into `badge/`. For the inline signal you need two of them side by side (light.js
432
505
  imports `./badge.js` relatively):
433
506
 
434
507
  - `badge/badge.js` (verification core + the expandable badge)
435
508
  - `badge/light.js` (the signal: the inline status light)
436
509
 
437
510
  Serve the `receipts/` directory statically, then mount the signal in the host
438
- header:
511
+ header. Import the asset from wherever you served it; the snippets here assume
512
+ you vendored into `badge/` and serve it at `/badge/`:
439
513
 
440
514
  ```html
441
515
  <script type="module">
442
- import { mountTamperSignal } from "/static/light.js";
516
+ import { mountTamperSignal } from "/badge/light.js";
443
517
  mountTamperSignal(document.querySelector("header"), "/receipts/chain.json");
444
518
  </script>
445
519
  ```
446
520
 
521
+ **These surfaces verify over HTTP, not from `file://`.** The signal, badge, and
522
+ table all `fetch()` the chain (and table.json), which the browser blocks on a
523
+ `file://` page, so opening `index.html` directly leaves them silently
524
+ unverified. Serve the page over HTTP: any static server works, and
525
+ `receipts serve` is the one-liner for local dev. There is no `file://` mode; an
526
+ offline recipient verifies with the CLI on a bundle (`receipts export --bundle`,
527
+ step 8) instead.
528
+
447
529
  React hosts: `import { TamperSignal } from "tamper-signal/react"` (or vendor
448
530
  `badge/light-react.js`), then `<TamperSignal chain="/receipts/chain.json" />`.
449
531
 
@@ -551,27 +633,43 @@ verified table, not just charts. Two steps:
551
633
 
552
634
  ```bash
553
635
  # Python
554
- receipts export --chain receipts/chain.json --data path/to/dashboard_data.xlsx
636
+ receipts export receipts/chain.json --data path/to/dashboard_data.xlsx
555
637
  # JavaScript
556
638
  tamper-signal export receipts/chain.json --data path/to/dashboard_data.csv
557
639
  ```
558
640
 
641
+ The chain path is positional in both CLIs (the Python CLI also accepts
642
+ `--chain receipts/chain.json` for the same value).
643
+
559
644
  This writes `receipts/table.json` and refuses if the data does not match
560
645
  the final receipt (the Data tab only ever shows attested data). Re-run it
561
646
  whenever the pipeline runs, or the tab will honestly report a stale table.
562
647
  In a JS build you can write it programmatically instead with
563
648
  `canonicalDocument(finalRecords)` (see step 1b).
564
649
 
565
- 2. Mount the table (vendor `badge/table.js` beside badge.js, or import
566
- `tamper-signal/table`):
650
+ 2. Mount the table (vendor `badge/table.js` beside badge.js with
651
+ `receipts assets`, or import `tamper-signal/table`):
567
652
 
568
653
  ```html
569
654
  <script type="module">
570
- import { mountReceiptTable } from "/static/table.js";
655
+ import { mountReceiptTable } from "/badge/table.js";
571
656
  mountReceiptTable(document.querySelector("#data-tab"), "/receipts/chain.json");
572
657
  </script>
573
658
  ```
574
659
 
660
+ Or, in plain HTML or any framework, the web component — the parallel of
661
+ `<tamper-signal>` for the badge. Importing `tamper-signal/table` (or
662
+ `badge/table.js`) registers `<tamper-signal-table>`:
663
+
664
+ ```html
665
+ <script type="module" src="/badge/table.js"></script>
666
+ <tamper-signal-table chain="/receipts/chain.json"></tamper-signal-table>
667
+ ```
668
+
669
+ Attributes: `chain` (required), `table` (table.json URL; defaults to
670
+ table.json beside the chain), and `max-rows` (rows before the "show all"
671
+ footer, default 500).
672
+
575
673
  The component re-hashes the served document in the viewer's browser and
576
674
  compares it against the final receipt, so VERIFIED means the rows on screen
577
675
  are byte-for-byte the attested data. It renders its own states: green, yellow
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # The light is green, the data is clean.
4
4
 
5
- [![PyPI](https://img.shields.io/pypi/v/tamper-signal)](https://pypi.org/project/tamper-signal/) [![npm](https://img.shields.io/npm/v/tamper-signal)](https://www.npmjs.com/package/tamper-signal) [![Socket Badge (npm)](https://badge.socket.dev/npm/package/tamper-signal/1.6.0)](https://socket.dev/npm/package/tamper-signal/overview/1.6.0) [![Socket Badge (PyPI)](https://badge.socket.dev/pypi/package/tamper-signal/1.6.0)](https://socket.dev/pypi/package/tamper-signal/overview/1.6.0) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
5
+ [![PyPI](https://img.shields.io/pypi/v/tamper-signal)](https://pypi.org/project/tamper-signal/) [![npm](https://img.shields.io/npm/v/tamper-signal)](https://www.npmjs.com/package/tamper-signal) [![Socket Badge (npm)](https://badge.socket.dev/npm/package/tamper-signal/1.7.2)](https://socket.dev/npm/package/tamper-signal/overview/1.7.1) [![Socket Badge (PyPI)](https://badge.socket.dev/pypi/package/tamper-signal/1.7.2)](https://socket.dev/pypi/package/tamper-signal/overview/1.7.1) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
6
 
7
7
  Your social team exports a month of TikTok performance data. Someone vibe-codes a dashboard on top of it with an AI assistant in an afternoon. It looks great. Then a transform silently drops 22 rows, or the model hallucinates an aggregation, and the numbers in front of your boss are wrong. Nothing in that workflow catches it. This is the missing verification layer: every stage of the pipeline signs a receipt for what went in and what came out, and one command (or a badge on the dashboard itself) tells you whether the chain is intact, or exactly where it broke and by how much.
8
8
 
@@ -42,6 +42,8 @@ receipts demo
42
42
 
43
43
  `receipts demo` runs the whole story end to end: generates a deliberately messy sample export, ingests it, runs two AI-written-style transforms, verifies the chain (PASS), then tampers with one spend value and verifies again (FAIL, pinpointing the broken link and the totals delta). It finishes by serving the badge at `http://localhost:8000/badge/badge.html` so you can see green, yellow, and red side by side.
44
44
 
45
+ > **`receipts: command not found`?** pip installed the script into a bin directory that is not on PATH (common on the python.org framework Python, the default macOS download). Either run it through the same interpreter, `python3 -m tamper_signal verify ...` (works as a drop-in for every `receipts ...` command), or link it onto PATH once: `sudo ln -sf "$(python3 -c 'import sysconfig;print(sysconfig.get_path("scripts"))')/receipts" /usr/local/bin/receipts`.
46
+
45
47
  ## CLI
46
48
 
47
49
  ```bash
@@ -52,6 +54,7 @@ receipts diff # compare two runs: code-hash changes and totals d
52
54
  receipts log # archived run history as a per-metric trend across runs (read-only)
53
55
  receipts doctor # integration self-check with actionable fixes
54
56
  receipts serve # serve receipts/ on localhost with CORS (dev only)
57
+ receipts assets --out badge/ # vendor the browser surfaces (light/badge/element/table/console.js) into a project
55
58
  ```
56
59
 
57
60
  `--pub` repeats for key rotation (any trusted key verifies), and `TAMPER_SIGNAL_KEY` can carry the PEM private key in CI so no key file touches disk. `ingest` and `verify --data` accept .xlsx, .csv, .tsv, .json (array of objects), and .ndjson; the semantic hash is identical across formats, so an xlsx ingest verifies against a CSV copy of the same data. `verify` exits with the traffic light: 0 green, 1 red, 2 yellow (verifies, with caveats). Add `--warn-drift` to also flag any control-totals movement across links as a caveat; it is off by default because filters and aggregations legitimately move totals. `--json` emits a structured verdict (schema in `AGENTS.md`) for CI and coding agents.
package/badge/table.d.ts CHANGED
@@ -26,3 +26,27 @@ export function mountReceiptTable(
26
26
  tableUrl?: string | ReceiptTableOptions,
27
27
  opts?: ReceiptTableOptions,
28
28
  ): ReceiptTableHandle;
29
+
30
+ /**
31
+ * The verified Data tab as a custom element, the parallel of `<tamper-signal>`.
32
+ * Importing this module registers `<tamper-signal-table>` as a side effect.
33
+ *
34
+ * Attributes:
35
+ * - `chain` (required) — URL of chain.json
36
+ * - `table` — URL of table.json (defaults to table.json beside chain)
37
+ * - `max-rows` — rows rendered before the "show all" footer (default 500)
38
+ */
39
+ export class TamperSignalTableElement extends HTMLElement {
40
+ static get observedAttributes(): string[];
41
+ /** The underlying mount handle (refresh/destroy), or null. */
42
+ get table(): ReceiptTableHandle | null;
43
+ connectedCallback(): void;
44
+ disconnectedCallback(): void;
45
+ attributeChangedCallback(): void;
46
+ }
47
+
48
+ declare global {
49
+ interface HTMLElementTagNameMap {
50
+ "tamper-signal-table": TamperSignalTableElement;
51
+ }
52
+ }
package/badge/table.js CHANGED
@@ -519,3 +519,67 @@ export function mountReceiptTable(containerEl, chainUrl, tableUrl, opts) {
519
519
  },
520
520
  };
521
521
  }
522
+
523
+ // <tamper-signal-table>: the verified Data tab as a custom element, the parallel
524
+ // of <tamper-signal> for the badge. Importing this module (tamper-signal/table,
525
+ // or vendored badge/table.js) registers the element, so one tag works in plain
526
+ // HTML, Vue, Svelte, Angular, or anything else that renders DOM.
527
+ //
528
+ // <script type="module" src="/badge/table.js"></script>
529
+ // <tamper-signal-table chain="/receipts/chain.json"></tamper-signal-table>
530
+ //
531
+ // Attributes:
532
+ // chain URL of chain.json (required; nothing mounts without it)
533
+ // table URL of table.json (optional; defaults to table.json beside chain)
534
+ // max-rows rows rendered before the "show all" footer (default 500)
535
+ class TamperSignalTableElement extends HTMLElement {
536
+ static get observedAttributes() {
537
+ return ["chain", "table", "max-rows"];
538
+ }
539
+
540
+ constructor() {
541
+ super();
542
+ this._handle = null;
543
+ }
544
+
545
+ connectedCallback() {
546
+ this._mount();
547
+ }
548
+
549
+ disconnectedCallback() {
550
+ this._unmount();
551
+ }
552
+
553
+ attributeChangedCallback() {
554
+ if (this.isConnected) this._mount();
555
+ }
556
+
557
+ // The mount handle, for hosts that want refresh()/destroy().
558
+ get table() {
559
+ return this._handle;
560
+ }
561
+
562
+ _unmount() {
563
+ if (this._handle) {
564
+ this._handle.destroy();
565
+ this._handle = null;
566
+ }
567
+ }
568
+
569
+ _mount() {
570
+ this._unmount();
571
+ const chain = this.getAttribute("chain");
572
+ if (!chain) return; // nothing to verify yet; attribute may arrive later
573
+ const tableUrl = this.getAttribute("table") || undefined;
574
+ const maxRows = Number(this.getAttribute("max-rows"));
575
+ this._handle = mountReceiptTable(this, chain, tableUrl, {
576
+ maxRows: Number.isFinite(maxRows) && maxRows > 0 ? maxRows : undefined,
577
+ });
578
+ }
579
+ }
580
+
581
+ if (typeof customElements !== "undefined" && !customElements.get("tamper-signal-table")) {
582
+ customElements.define("tamper-signal-table", TamperSignalTableElement);
583
+ }
584
+
585
+ export { TamperSignalTableElement };
package/node/cli.js CHANGED
@@ -12,9 +12,11 @@
12
12
  import { createHash } from "node:crypto";
13
13
  import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
14
14
  import { basename, dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
15
16
  import { parseArgs } from "node:util";
16
17
  import process from "node:process";
17
18
 
19
+ import * as color from "./color.js";
18
20
  import { canonicalDocument, canonicalJsonBytes, semanticHash } from "./canonical.js";
19
21
  import {
20
22
  LOG_GRANULARITIES,
@@ -89,6 +91,9 @@ commands:
89
91
  final receipt); --bundle writes a
90
92
  verified zip (data + chain.json +
91
93
  receipts) for offline re-verification
94
+ assets [--out badge/] copy the bundled browser assets
95
+ (light.js, badge.js, element.js,
96
+ table.js, console.js) into a project
92
97
  `;
93
98
 
94
99
  // Shipped inside every verified bundle so a recipient can verify it without
@@ -181,6 +186,12 @@ function archivePriorChain(chainDir) {
181
186
  }
182
187
  }
183
188
 
189
+ // Emit a structured payload on stdout (the established --json convention,
190
+ // shared byte-for-byte with the Python CLI).
191
+ function printJson(payload) {
192
+ console.log(JSON.stringify(payload, null, 2));
193
+ }
194
+
184
195
  // `ingest --as period`: continue the chain's run history under a trusted signer.
185
196
  function cmdIngestPeriod(values, file) {
186
197
  // Like replace, compute the unsnapshotted-reset condition and preserve the
@@ -205,19 +216,36 @@ function cmdIngestPeriod(values, file) {
205
216
  });
206
217
  } catch (err) {
207
218
  if (err instanceof UntrustedSignerError) {
208
- console.error(`✗ Refusing to append a period: ${err.message}`);
219
+ if (values.json) printJson({ ok: false, error: `Refusing to append a period: ${err.message}` });
220
+ else console.error(`✗ Refusing to append a period: ${err.message}`);
209
221
  return 1;
210
222
  }
211
- console.error(err.message);
223
+ if (values.json) printJson({ ok: false, error: err.message });
224
+ else console.error(err.message);
212
225
  return 1;
213
226
  }
214
227
  if (unsnapshottedReset) {
215
228
  console.error("warning: previous run was never verified; its totals will not enter history");
216
229
  }
217
230
  const totals = result.manifest.control_totals;
231
+ if (values.json) {
232
+ const caveats = result.caveats ?? [];
233
+ printJson({
234
+ source: result.manifest.source.filename,
235
+ evidence_hash: result.manifest.source.evidence_hash,
236
+ semantic_hash: result.manifest.semantic_hash,
237
+ row_count: totals.row_count,
238
+ column_count: totals.column_count,
239
+ mode: "period",
240
+ verdict: caveats.length ? "yellow" : "green",
241
+ caveats,
242
+ compared: Boolean(result.compared),
243
+ });
244
+ return caveats.length ? 2 : 0;
245
+ }
218
246
  console.log(`Imported next period: ${result.manifest.source.filename}`);
219
- console.log(` evidence_hash ${result.manifest.source.evidence_hash}`);
220
- console.log(` semantic_hash ${result.manifest.semantic_hash}`);
247
+ console.log(` evidence_hash ${color.dim(result.manifest.source.evidence_hash)}`);
248
+ console.log(` semantic_hash ${color.dim(result.manifest.semantic_hash)}`);
221
249
  console.log(` rows ${totals.row_count}, columns ${totals.column_count}`);
222
250
  const grouped = groupedNumericColumns(result.records);
223
251
  if (grouped.length) {
@@ -255,6 +283,7 @@ function cmdIngest(args) {
255
283
  "bucket-column": { type: "string" },
256
284
  as: { type: "string", default: "replace" },
257
285
  pub: { type: "string", multiple: true, default: [] },
286
+ json: { type: "boolean", default: false },
258
287
  },
259
288
  });
260
289
  const file = positionals[0];
@@ -290,16 +319,29 @@ function cmdIngest(args) {
290
319
  bucketColumn: values["bucket-column"] ?? null,
291
320
  }));
292
321
  } catch (err) {
293
- console.error(err.message);
322
+ if (values.json) printJson({ ok: false, error: err.message });
323
+ else console.error(err.message);
294
324
  return 1;
295
325
  }
296
326
  if (unsnapshottedReset) {
297
327
  console.error("warning: previous run was never verified; its totals will not enter history");
298
328
  }
299
329
  const totals = manifest.control_totals;
330
+ if (values.json) {
331
+ printJson({
332
+ source: manifest.source.filename,
333
+ evidence_hash: manifest.source.evidence_hash,
334
+ semantic_hash: manifest.semantic_hash,
335
+ row_count: totals.row_count,
336
+ column_count: totals.column_count,
337
+ tolerance: manifest.tolerance ?? null,
338
+ source_manifest: `${values.out.replace(/\/?$/, "/")}${SOURCE_RECEIPT_NAME}`,
339
+ });
340
+ return 0;
341
+ }
300
342
  console.log(`Ingested ${basename(file)}`);
301
- console.log(` evidence_hash ${manifest.source.evidence_hash}`);
302
- console.log(` semantic_hash ${manifest.semantic_hash}`);
343
+ console.log(` evidence_hash ${color.dim(manifest.source.evidence_hash)}`);
344
+ console.log(` semantic_hash ${color.dim(manifest.semantic_hash)}`);
303
345
  console.log(` rows ${totals.row_count}, columns ${totals.column_count}`);
304
346
  if (manifest.tolerance) {
305
347
  const t = manifest.tolerance;
@@ -422,6 +464,9 @@ function cmdVerify(args) {
422
464
  };
423
465
  console.log(JSON.stringify(payload, null, 2));
424
466
  } else {
467
+ if (color.shouldColor()) {
468
+ console.log(`${color.light(result.verdict)} ${color.colorize(result.verdict.toUpperCase(), result.verdict)}`);
469
+ }
425
470
  for (const line of result.lines) console.log(line);
426
471
  }
427
472
  // Archive the run snapshot AFTER the final exit code settled: a red run
@@ -602,14 +647,14 @@ function renderStageDelta(delta) {
602
647
  for (const key of ["row_count", "column_count"]) {
603
648
  const entry = delta[key];
604
649
  if (entry) {
605
- const suffix = "delta" in entry ? ` (${signedInt(entry.delta)})` : "";
650
+ const suffix = "delta" in entry ? ` (${color.signed(signedInt(entry.delta))})` : "";
606
651
  lines.push(`${key} ${entry.before} -> ${entry.after}${suffix}`);
607
652
  }
608
653
  }
609
654
  for (const [column, entry] of Object.entries(delta.numeric_sums ?? {})) {
610
655
  const before = entry.before !== null ? entry.before : "(added)";
611
656
  const after = entry.after !== null ? entry.after : "(removed)";
612
- const suffix = "delta" in entry ? ` (${entry.delta})` : "";
657
+ const suffix = "delta" in entry ? ` (${color.signed(entry.delta)})` : "";
613
658
  lines.push(`${column} ${before} -> ${after}${suffix}`);
614
659
  }
615
660
  for (const [column, entry] of Object.entries(delta.null_counts ?? {})) {
@@ -1014,19 +1059,36 @@ function cmdLog(args) {
1014
1059
  const { rows, collapsed } = buildLogPeriods(items, granularity, metrics);
1015
1060
 
1016
1061
  if (values.json) {
1017
- const payload = {
1018
- granularity,
1019
- // Total runs collapsed away by the granularity. Ordered chronological
1020
- // (oldest first), matching the Python CLI byte-for-byte.
1021
- collapsed,
1022
- runs: rows.map((row, index) => ({
1062
+ // The signed tolerance declaration is per-snapshot, display-only; surface
1063
+ // it per run entry (omitted when that run declared none), matching Python.
1064
+ const tolByTail = new Map();
1065
+ for (const snapshot of snapshots) {
1066
+ if (snapshot.tolerance && typeof snapshot.tolerance === "object") {
1067
+ tolByTail.set(snapshot.chain_tail_hash, snapshot.tolerance);
1068
+ }
1069
+ }
1070
+ const runEntry = (row, index) => {
1071
+ const entry = {
1023
1072
  period: row.period,
1024
1073
  created_at: row.created_at,
1025
1074
  tail: row.tail ? row.tail.slice(0, 8) : null,
1026
1075
  unsigned: row.unsigned,
1027
1076
  metrics: logJsonMetrics(rows, index, metrics),
1028
1077
  breached: metrics.filter((m) => row.breached.has(m)).sort(),
1029
- })),
1078
+ };
1079
+ const tolerance = tolByTail.get(row.tail);
1080
+ if (tolerance && typeof tolerance === "object") {
1081
+ if (tolerance.band != null) entry.band = tolerance.band;
1082
+ if (tolerance.settle_hours != null) entry.settle_hours = tolerance.settle_hours;
1083
+ }
1084
+ return entry;
1085
+ };
1086
+ const payload = {
1087
+ granularity,
1088
+ // Total runs collapsed away by the granularity. Ordered chronological
1089
+ // (oldest first), matching the Python CLI byte-for-byte.
1090
+ collapsed,
1091
+ runs: rows.map(runEntry),
1030
1092
  };
1031
1093
  console.log(JSON.stringify(payload, null, 2));
1032
1094
  return 0;
@@ -1045,6 +1107,7 @@ function cmdExport(args) {
1045
1107
  data: { type: "string" },
1046
1108
  out: { type: "string" },
1047
1109
  bundle: { type: "boolean" },
1110
+ json: { type: "boolean", default: false },
1048
1111
  },
1049
1112
  });
1050
1113
  const chainPath = positionals[0];
@@ -1062,11 +1125,13 @@ function cmdExport(args) {
1062
1125
  try {
1063
1126
  receipts = (chain.receipts ?? []).map((name) => readReceipt(chainDir, name));
1064
1127
  } catch (err) {
1065
- console.error(`Cannot load chain: ${err.message}`);
1128
+ if (values.json) printJson({ ok: false, error: `Cannot load chain: ${err.message}` });
1129
+ else console.error(`Cannot load chain: ${err.message}`);
1066
1130
  return 1;
1067
1131
  }
1068
1132
  if (!receipts.length) {
1069
- console.error("Chain is empty; nothing to export against.");
1133
+ if (values.json) printJson({ ok: false, error: "Chain is empty; nothing to export against." });
1134
+ else console.error("Chain is empty; nothing to export against.");
1070
1135
  return 1;
1071
1136
  }
1072
1137
 
@@ -1080,10 +1145,19 @@ function cmdExport(args) {
1080
1145
  const dataHash = createHash("sha256").update(canonicalJsonBytes(document)).digest("hex");
1081
1146
  const expected = outputHashOf(receipts[receipts.length - 1]);
1082
1147
  if (dataHash !== expected) {
1083
- console.error("✗ Refusing to export: the data does not match the final receipt.");
1084
- console.error(` expected output hash ${expected}`);
1085
- console.error(` found data hash ${dataHash}`);
1086
- console.error(" The Data tab only shows attested data. Re-run the pipeline or fix --data.");
1148
+ if (values.json) {
1149
+ printJson({
1150
+ ok: false,
1151
+ error: "the data does not match the final receipt",
1152
+ expected_output_hash: expected,
1153
+ data_hash: dataHash,
1154
+ });
1155
+ } else {
1156
+ console.error("✗ Refusing to export: the data does not match the final receipt.");
1157
+ console.error(` expected output hash ${expected}`);
1158
+ console.error(` found data hash ${dataHash}`);
1159
+ console.error(" The Data tab only shows attested data. Re-run the pipeline or fix --data.");
1160
+ }
1087
1161
  return 1;
1088
1162
  }
1089
1163
 
@@ -1103,23 +1177,85 @@ function cmdExport(args) {
1103
1177
  const stem = dataName.replace(/\.[^.]+$/, "");
1104
1178
  const bundlePath = values.out || join(chainDir, `${stem}-verified.zip`);
1105
1179
  writeFileSync(bundlePath, makeStoredZip(entries));
1180
+ if (values.json) {
1181
+ printJson({
1182
+ output: bundlePath,
1183
+ data: dataName,
1184
+ receipts: receiptNames.length,
1185
+ data_hash: dataHash,
1186
+ bundle: true,
1187
+ });
1188
+ return 0;
1189
+ }
1106
1190
  console.log(`Exported verified bundle: ${bundlePath}`);
1107
1191
  console.log(` data ${dataName}, ${receiptNames.length} receipts + ${CHAIN_FILENAME}`);
1108
- console.log(` semantic_hash ${dataHash} (matches final receipt)`);
1192
+ console.log(` semantic_hash ${color.dim(dataHash)} (matches final receipt)`);
1109
1193
  console.log(` recipient: unzip, then \`tamper-signal verify ${CHAIN_FILENAME}\``);
1110
1194
  return 0;
1111
1195
  }
1112
1196
 
1113
1197
  const outPath = values.out || join(chainDir, "table.json");
1114
1198
  writeFileSync(outPath, JSON.stringify(document, null, 2) + "\n");
1199
+ if (values.json) {
1200
+ printJson({
1201
+ output: outPath,
1202
+ row_count: document.rows.length,
1203
+ column_count: document.headers.length,
1204
+ data_hash: dataHash,
1205
+ bundle: false,
1206
+ });
1207
+ return 0;
1208
+ }
1115
1209
  console.log(`Exported verified table: ${outPath}`);
1116
1210
  console.log(` rows ${document.rows.length}, columns ${document.headers.length}`);
1117
- console.log(` semantic_hash ${dataHash} (matches final receipt)`);
1211
+ console.log(` semantic_hash ${color.dim(dataHash)} (matches final receipt)`);
1212
+ return 0;
1213
+ }
1214
+
1215
+ // The browser assets ship in the package's badge/ directory (see package.json
1216
+ // "files"). Mirror the Python `receipts assets`: copy them into a project so an
1217
+ // integrator never has to dig them out of node_modules by hand.
1218
+ const ASSET_NAMES = ["light.js", "badge.js", "element.js", "table.js", "console.js"];
1219
+
1220
+ function cmdAssets(args) {
1221
+ const { values } = parseArgs({
1222
+ args,
1223
+ options: {
1224
+ out: { type: "string" },
1225
+ json: { type: "boolean", default: false },
1226
+ },
1227
+ });
1228
+ const srcDir = join(dirname(fileURLToPath(import.meta.url)), "..", "badge");
1229
+ const outDir = values.out || "badge/";
1230
+ const copied = [];
1231
+ mkdirSync(outDir, { recursive: true });
1232
+ for (const name of ASSET_NAMES) {
1233
+ const src = join(srcDir, name);
1234
+ if (!existsSync(src)) continue;
1235
+ copyFileSync(src, join(outDir, name));
1236
+ copied.push(name);
1237
+ }
1238
+ if (!copied.length) {
1239
+ if (values.json) printJson({ ok: false, error: "No bundled browser assets found in the package." });
1240
+ else console.error("No bundled browser assets found in the package.");
1241
+ return 1;
1242
+ }
1243
+ if (values.json) {
1244
+ printJson({ out: outDir, files: copied });
1245
+ return 0;
1246
+ }
1247
+ console.log(`Copied ${copied.length} browser assets into ${outDir}`);
1248
+ for (const name of copied) console.log(` ${name}`);
1249
+ console.log(`Import them at the path you serve them, e.g. "/${basename(outDir.replace(/\/+$/, ""))}/light.js".`);
1118
1250
  return 0;
1119
1251
  }
1120
1252
 
1121
- const [, , command, ...rest] = process.argv;
1122
- const commands = { keygen: cmdKeygen, ingest: cmdIngest, verify: cmdVerify, diff: cmdDiff, log: cmdLog, export: cmdExport };
1253
+ const [, , command, ...rawRest] = process.argv;
1254
+ // --no-color is global: honor it at any position and strip it so each command's
1255
+ // strict parser does not reject it. NO_COLOR / FORCE_COLOR env are honored too.
1256
+ if (rawRest.includes("--no-color")) color.setNoColor(true);
1257
+ const rest = rawRest.filter((arg) => arg !== "--no-color");
1258
+ const commands = { keygen: cmdKeygen, ingest: cmdIngest, verify: cmdVerify, diff: cmdDiff, log: cmdLog, export: cmdExport, assets: cmdAssets };
1123
1259
  if (!command || !(command in commands)) {
1124
1260
  console.error(USAGE);
1125
1261
  process.exit(command ? 1 : 0);
package/node/color.js ADDED
@@ -0,0 +1,78 @@
1
+ // Terminal color for the human-facing CLI: the Node port of tamper_signal/color.py.
2
+ // Presentation layer only. Color is emitted to stdout only when it is an
3
+ // interactive terminal and no override turns it off; every primitive returns
4
+ // plain text when color is off, so the word and glyph survive without ANSI.
5
+ //
6
+ // Gating precedence (highest first):
7
+ // 1. --no-color flag (setNoColor) -> off
8
+ // 2. NO_COLOR env present (any value) -> off
9
+ // 3. FORCE_COLOR env present -> on
10
+ // 4. otherwise -> stream.isTTY
11
+ // NO_COLOR wins over FORCE_COLOR. shouldColor is only evaluated against stdout;
12
+ // stderr (notices) and --json output are always plain.
13
+
14
+ // SGR codes. Kept identical to tamper_signal/color.py so the two CLIs match.
15
+ export const RESET = "\x1b[0m";
16
+ export const DIM = "\x1b[2m";
17
+ export const BOLD = "\x1b[1m";
18
+ export const GREEN = "\x1b[32m";
19
+ export const YELLOW = "\x1b[33m";
20
+ export const RED = "\x1b[31m";
21
+
22
+ // "amber" names the color; the verdict value stays "yellow".
23
+ const VERDICT_COLOR = { green: GREEN, yellow: YELLOW, red: RED };
24
+
25
+ // Set by the CLI when --no-color is passed. Always wins over the environment.
26
+ let noColor = false;
27
+
28
+ export function setNoColor(value) {
29
+ noColor = Boolean(value);
30
+ }
31
+
32
+ export function shouldColor(stream = process.stdout) {
33
+ if (noColor) return false;
34
+ if (process.env.NO_COLOR !== undefined) return false;
35
+ if (process.env.FORCE_COLOR !== undefined) return true;
36
+ return Boolean(stream && stream.isTTY);
37
+ }
38
+
39
+ function paint(text, code, stream) {
40
+ return shouldColor(stream) ? `${code}${text}${RESET}` : text;
41
+ }
42
+
43
+ // Dim secondary detail (hashes, counts) when color is on.
44
+ export function dim(text, stream = process.stdout) {
45
+ return paint(text, DIM, stream);
46
+ }
47
+
48
+ export function bold(text, stream = process.stdout) {
49
+ return paint(text, BOLD, stream);
50
+ }
51
+
52
+ // The colored traffic-light glyph for a verdict ("green"/"yellow"/"red").
53
+ // The caller prints the verdict word alongside it, so meaning survives color-off.
54
+ export function light(verdict, stream = process.stdout) {
55
+ return paint("●", VERDICT_COLOR[verdict] ?? "", stream);
56
+ }
57
+
58
+ // Paint text in a verdict's color ("green"/"yellow"/"red").
59
+ export function colorize(text, verdict, stream = process.stdout) {
60
+ return paint(text, VERDICT_COLOR[verdict] ?? "", stream);
61
+ }
62
+
63
+ // Color a pre-formatted signed token by its sign (+green, -red).
64
+ export function signed(token, stream = process.stdout) {
65
+ const text = String(token);
66
+ if (text.startsWith("+")) return paint(text, GREEN, stream);
67
+ if (text.startsWith("-")) return paint(text, RED, stream);
68
+ return text;
69
+ }
70
+
71
+ // A signed movement value, colored by direction (increase green, decrease red).
72
+ // The sign is always printed, so direction reads without color and for
73
+ // colorblind users. Direction is a neutral cue, not a verdict.
74
+ export function delta(value, stream = process.stdout) {
75
+ if (value > 0) return paint(`+${value}`, GREEN, stream);
76
+ if (value < 0) return paint(`${value}`, RED, stream);
77
+ return "0";
78
+ }
@@ -0,0 +1,87 @@
1
+ // Node CLI color application and gating (U6): the colored verdict headline and
2
+ // the NO_COLOR / FORCE_COLOR / --no-color gate, mirroring tests/test_cli_color.py.
3
+ // Color is gated to a TTY, so these force it on with FORCE_COLOR (the pipe from
4
+ // execFileSync is not a TTY) and assert plain output when it is off.
5
+
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdtempSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { test } from "node:test";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import { GREEN, RESET } from "../color.js";
15
+
16
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
17
+ const cli = join(repoRoot, "node", "cli.js");
18
+
19
+ function run(cwd, args, env = {}) {
20
+ // Start from a clean slate: NO_COLOR / FORCE_COLOR only present when a test
21
+ // sets them (a key set to undefined would stringify to "undefined").
22
+ const base = { ...process.env, TAMPER_SIGNAL_KEY: "" };
23
+ delete base.NO_COLOR;
24
+ delete base.FORCE_COLOR;
25
+ return execFileSync(process.execPath, [cli, ...args], { cwd, env: { ...base, ...env }, encoding: "utf-8" });
26
+ }
27
+
28
+ function seedChain() {
29
+ const dir = mkdtempSync(join(tmpdir(), "tamper-signal-color-"));
30
+ run(dir, ["keygen", "--out", "keys"]);
31
+ writeFileSync(join(dir, "d.csv"), "day,amount\n2026-05-01,10\n");
32
+ run(dir, ["ingest", "d.csv"]);
33
+ return dir;
34
+ }
35
+
36
+ test("verify headline is a colored light under FORCE_COLOR", () => {
37
+ const dir = seedChain();
38
+ const out = run(dir, ["verify", "receipts/chain.json"], { FORCE_COLOR: "1" });
39
+ assert.ok(out.includes(`${GREEN}●${RESET}`));
40
+ assert.ok(out.includes(`${GREEN}GREEN${RESET}`));
41
+ assert.ok(out.includes("CHAIN INTACT"));
42
+ });
43
+
44
+ test("verify piped is plain text with no ANSI", () => {
45
+ const dir = seedChain();
46
+ const out = run(dir, ["verify", "receipts/chain.json"]);
47
+ assert.ok(!out.includes("\x1b"));
48
+ assert.ok(out.includes("CHAIN INTACT"));
49
+ });
50
+
51
+ test("--no-color beats FORCE_COLOR", () => {
52
+ const dir = seedChain();
53
+ const out = run(dir, ["verify", "receipts/chain.json", "--no-color"], { FORCE_COLOR: "1" });
54
+ assert.ok(!out.includes("\x1b"));
55
+ });
56
+
57
+ test("NO_COLOR beats FORCE_COLOR", () => {
58
+ const dir = seedChain();
59
+ const out = run(dir, ["verify", "receipts/chain.json"], { FORCE_COLOR: "1", NO_COLOR: "1" });
60
+ assert.ok(!out.includes("\x1b"));
61
+ });
62
+
63
+ // The acceptance gate (R16): ANSI must never leak into --json, even with color
64
+ // forced on. Mirrors tests/test_cli_json_gate.py (node has no doctor command).
65
+ test("no ANSI leaks into --json even under FORCE_COLOR", () => {
66
+ const dir = mkdtempSync(join(tmpdir(), "tamper-signal-gate-"));
67
+ run(dir, ["keygen", "--out", "keys"]);
68
+ writeFileSync(join(dir, "d.csv"), "day,amount\n2026-05-01,10\n");
69
+ run(dir, ["ingest", "d.csv"]);
70
+ run(dir, ["verify", "receipts/chain.json"]);
71
+ writeFileSync(join(dir, "d.csv"), "day,amount\n2026-05-01,10\n2026-05-02,20\n");
72
+ run(dir, ["ingest", "d.csv"]);
73
+ run(dir, ["verify", "receipts/chain.json"]);
74
+
75
+ const commands = [
76
+ ["verify", "receipts/chain.json", "--json"],
77
+ ["log", "--chain", "receipts/", "--json"],
78
+ ["diff", "--json"],
79
+ ["export", "receipts/chain.json", "--data", "d.csv", "--json"],
80
+ ["ingest", "d.csv", "--json"],
81
+ ];
82
+ for (const args of commands) {
83
+ const out = run(dir, args, { FORCE_COLOR: "1" });
84
+ assert.ok(!out.includes("\x1b"), `ANSI leaked into --json of ${args[0]}`);
85
+ JSON.parse(out);
86
+ }
87
+ });
@@ -0,0 +1,90 @@
1
+ // Mirrors tests/test_color.py: the gating matrix plus verdict/delta conventions.
2
+ // Also asserts the SGR palette matches the Python helper byte for byte (pins R15).
3
+
4
+ import { test, beforeEach } from "node:test";
5
+ import assert from "node:assert/strict";
6
+
7
+ import { setNoColor, shouldColor, light, delta, dim, bold, GREEN, YELLOW, RED, DIM, BOLD, RESET } from "../color.js";
8
+
9
+ const TTY = { isTTY: true };
10
+ const PIPE = { isTTY: false };
11
+
12
+ beforeEach(() => {
13
+ delete process.env.NO_COLOR;
14
+ delete process.env.FORCE_COLOR;
15
+ setNoColor(false);
16
+ });
17
+
18
+ test("shouldColor: true on a TTY with no overrides", () => {
19
+ assert.equal(shouldColor(TTY), true);
20
+ });
21
+
22
+ test("shouldColor: false when not a TTY", () => {
23
+ assert.equal(shouldColor(PIPE), false);
24
+ });
25
+
26
+ test("shouldColor: NO_COLOR wins even on a TTY (any value)", () => {
27
+ process.env.NO_COLOR = "1";
28
+ assert.equal(shouldColor(TTY), false);
29
+ process.env.NO_COLOR = "";
30
+ assert.equal(shouldColor(TTY), false);
31
+ });
32
+
33
+ test("shouldColor: --no-color flag wins even on a TTY", () => {
34
+ setNoColor(true);
35
+ assert.equal(shouldColor(TTY), false);
36
+ });
37
+
38
+ test("shouldColor: FORCE_COLOR turns on even when piped", () => {
39
+ process.env.FORCE_COLOR = "1";
40
+ assert.equal(shouldColor(PIPE), true);
41
+ });
42
+
43
+ test("shouldColor: NO_COLOR beats FORCE_COLOR", () => {
44
+ process.env.NO_COLOR = "1";
45
+ process.env.FORCE_COLOR = "1";
46
+ assert.equal(shouldColor(TTY), false);
47
+ });
48
+
49
+ test("light: colors each verdict when on", () => {
50
+ process.env.FORCE_COLOR = "1";
51
+ assert.equal(light("green", PIPE), `${GREEN}●${RESET}`);
52
+ assert.equal(light("yellow", PIPE), `${YELLOW}●${RESET}`);
53
+ assert.equal(light("red", PIPE), `${RED}●${RESET}`);
54
+ });
55
+
56
+ test("light: plain glyph when off", () => {
57
+ assert.equal(light("green", PIPE), "●");
58
+ assert.ok(!light("red", PIPE).includes("\x1b"));
59
+ });
60
+
61
+ test("delta: signs and colors direction when on", () => {
62
+ process.env.FORCE_COLOR = "1";
63
+ assert.equal(delta(12, PIPE), `${GREEN}+12${RESET}`);
64
+ assert.equal(delta(-5, PIPE), `${RED}-5${RESET}`);
65
+ assert.equal(delta(0, PIPE), "0");
66
+ });
67
+
68
+ test("delta: keeps sign when color off", () => {
69
+ assert.equal(delta(12, PIPE), "+12");
70
+ assert.equal(delta(-5, PIPE), "-5");
71
+ assert.ok(!delta(12, PIPE).includes("\x1b"));
72
+ });
73
+
74
+ test("dim/bold wrap only when on", () => {
75
+ assert.equal(dim("abc", PIPE), "abc");
76
+ assert.equal(bold("abc", PIPE), "abc");
77
+ process.env.FORCE_COLOR = "1";
78
+ assert.equal(dim("abc", PIPE), `${DIM}abc${RESET}`);
79
+ assert.equal(bold("abc", PIPE), `${BOLD}abc${RESET}`);
80
+ });
81
+
82
+ // Cross-stack palette parity: these MUST equal tamper_signal/color.py's codes.
83
+ test("palette parity: SGR codes match the Python helper", () => {
84
+ assert.equal(GREEN, "\x1b[32m");
85
+ assert.equal(YELLOW, "\x1b[33m");
86
+ assert.equal(RED, "\x1b[31m");
87
+ assert.equal(DIM, "\x1b[2m");
88
+ assert.equal(BOLD, "\x1b[1m");
89
+ assert.equal(RESET, "\x1b[0m");
90
+ });
@@ -0,0 +1,87 @@
1
+ // `tamper-signal ingest --json` and `export --json` (U4): the machine surface
2
+ // added in 1.7.1. Mirrors the Python tests in tests/test_cli_agent_ergonomics.py;
3
+ // payload keys must match the Python CLI byte for byte (R5). Driven through the
4
+ // real CLI via execFileSync so the captured stdout is exactly what ships.
5
+
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdtempSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { test } from "node:test";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
15
+ const cli = join(repoRoot, "node", "cli.js");
16
+
17
+ function runCli(cwd, args, { expectFail = false } = {}) {
18
+ const env = { ...process.env, TAMPER_SIGNAL_KEY: "" };
19
+ try {
20
+ return { stdout: execFileSync(process.execPath, [cli, ...args], { cwd, env, encoding: "utf-8" }), stderr: "", status: 0 };
21
+ } catch (err) {
22
+ if (!expectFail) throw err;
23
+ return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", status: err.status ?? 1 };
24
+ }
25
+ }
26
+
27
+ function seedChain() {
28
+ const dir = mkdtempSync(join(tmpdir(), "tamper-signal-json-"));
29
+ runCli(dir, ["keygen", "--out", "keys"]);
30
+ writeFileSync(join(dir, "data.csv"), "day,amount\n2026-05-01,10\n2026-05-02,20\n");
31
+ return dir;
32
+ }
33
+
34
+ test("ingest --json emits a structured result", () => {
35
+ const dir = seedChain();
36
+ const payload = JSON.parse(runCli(dir, ["ingest", "data.csv", "--json"]).stdout);
37
+ assert.equal(payload.source, "data.csv");
38
+ assert.equal(payload.row_count, 2);
39
+ assert.equal(payload.column_count, 2);
40
+ assert.equal(payload.tolerance, null);
41
+ assert.ok(payload.semantic_hash);
42
+ assert.ok(payload.source_manifest.endsWith("000_source.json"));
43
+ // Pinned key set: must equal the Python ingest --json payload (R5).
44
+ assert.deepEqual(
45
+ Object.keys(payload).sort(),
46
+ ["column_count", "evidence_hash", "row_count", "semantic_hash", "source", "source_manifest", "tolerance"],
47
+ );
48
+ });
49
+
50
+ test("ingest --json includes a declared tolerance", () => {
51
+ const dir = seedChain();
52
+ const payload = JSON.parse(runCli(dir, ["ingest", "data.csv", "--band", "5%", "--settle", "72h", "--json"]).stdout);
53
+ assert.equal(payload.tolerance.band, "0.05");
54
+ assert.equal(payload.tolerance.settle_hours, 72);
55
+ });
56
+
57
+ test("export --json writes a table result", () => {
58
+ const dir = seedChain();
59
+ runCli(dir, ["ingest", "data.csv"]);
60
+ const payload = JSON.parse(runCli(dir, ["export", "receipts/chain.json", "--data", "data.csv", "--json"]).stdout);
61
+ assert.equal(payload.bundle, false);
62
+ assert.ok(payload.output.endsWith("table.json"));
63
+ assert.ok(payload.data_hash);
64
+ assert.equal(payload.row_count, 2);
65
+ // Pinned key set: must equal the Python export --json payload (R5).
66
+ assert.deepEqual(Object.keys(payload).sort(), ["bundle", "column_count", "data_hash", "output", "row_count"]);
67
+ });
68
+
69
+ test("export --bundle --json reports the bundle", () => {
70
+ const dir = seedChain();
71
+ runCli(dir, ["ingest", "data.csv"]);
72
+ const payload = JSON.parse(runCli(dir, ["export", "receipts/chain.json", "--data", "data.csv", "--bundle", "--json"]).stdout);
73
+ assert.equal(payload.bundle, true);
74
+ assert.ok(payload.receipts >= 1);
75
+ assert.ok(payload.output.endsWith("-verified.zip"));
76
+ });
77
+
78
+ test("export --json gives a structured error on mismatch (clean stdout)", () => {
79
+ const dir = seedChain();
80
+ runCli(dir, ["ingest", "data.csv"]);
81
+ writeFileSync(join(dir, "wrong.csv"), "x\n1\n");
82
+ const res = runCli(dir, ["export", "receipts/chain.json", "--data", "wrong.csv", "--json"], { expectFail: true });
83
+ assert.equal(res.status, 1);
84
+ const payload = JSON.parse(res.stdout);
85
+ assert.equal(payload.ok, false);
86
+ assert.match(payload.error, /match/);
87
+ });
@@ -233,3 +233,15 @@ test("log renders a real built history end to end", () => {
233
233
  assert.ok(payload.runs.length >= 1);
234
234
  assert.ok(payload.runs.every((r) => r.unsigned === false));
235
235
  });
236
+
237
+ test("log --json surfaces band/settle per run, only where declared", () => {
238
+ const s0 = snapshot({ createdAt: "2026-05-01T00:00:00Z", rowCount: 100, tail: "aa".repeat(32) });
239
+ const s1 = snapshot({ createdAt: "2026-05-02T00:00:00Z", rowCount: 110, tail: "bb".repeat(32) });
240
+ s1.tolerance = { band: "0.05", settle_hours: 72, bucket_column: "day" };
241
+ const receipts = writeHistory([s0, s1]);
242
+
243
+ const runs = JSON.parse(runLog(receipts, ["--json"]).stdout).runs; // oldest first
244
+ assert.ok(!("band" in runs[0]) && !("settle_hours" in runs[0]));
245
+ assert.equal(runs[1].band, "0.05");
246
+ assert.equal(runs[1].settle_hours, 72);
247
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamper-signal",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Signed receipts for vibe-coded data pipelines. Proves nobody changed your data, and shows the exact link if they did.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,7 +64,7 @@
64
64
  "AGENTS.md"
65
65
  ],
66
66
  "scripts": {
67
- "test": "node --test node/test/badge.test.js node/test/canonical.test.js node/test/chain.test.js node/test/interop.test.js node/test/express.test.js node/test/totals.test.js node/test/pipeline.test.js node/test/history.test.js node/test/diff.test.js node/test/log.test.js node/test/judgment.test.js node/test/theme.test.js node/test/zip.test.js node/test/append_period.test.js"
67
+ "test": "node --test node/test/badge.test.js node/test/canonical.test.js node/test/chain.test.js node/test/interop.test.js node/test/express.test.js node/test/totals.test.js node/test/pipeline.test.js node/test/history.test.js node/test/diff.test.js node/test/log.test.js node/test/judgment.test.js node/test/theme.test.js node/test/zip.test.js node/test/append_period.test.js node/test/color.test.js node/test/json_surface.test.js node/test/cli_color.test.js"
68
68
  },
69
69
  "engines": {
70
70
  "node": ">=18.17"