tamper-signal 1.7.1 → 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
@@ -446,23 +492,40 @@ identity, nothing more.
446
492
  ## 6. Add the signal to the host UI
447
493
 
448
494
  With a bundler, import straight from the npm package
449
- (`import { mountTamperSignal } from "tamper-signal/light"`). Without one,
450
- 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
451
505
  imports `./badge.js` relatively):
452
506
 
453
507
  - `badge/badge.js` (verification core + the expandable badge)
454
508
  - `badge/light.js` (the signal: the inline status light)
455
509
 
456
510
  Serve the `receipts/` directory statically, then mount the signal in the host
457
- 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/`:
458
513
 
459
514
  ```html
460
515
  <script type="module">
461
- import { mountTamperSignal } from "/static/light.js";
516
+ import { mountTamperSignal } from "/badge/light.js";
462
517
  mountTamperSignal(document.querySelector("header"), "/receipts/chain.json");
463
518
  </script>
464
519
  ```
465
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
+
466
529
  React hosts: `import { TamperSignal } from "tamper-signal/react"` (or vendor
467
530
  `badge/light-react.js`), then `<TamperSignal chain="/receipts/chain.json" />`.
468
531
 
@@ -570,27 +633,43 @@ verified table, not just charts. Two steps:
570
633
 
571
634
  ```bash
572
635
  # Python
573
- receipts export --chain receipts/chain.json --data path/to/dashboard_data.xlsx
636
+ receipts export receipts/chain.json --data path/to/dashboard_data.xlsx
574
637
  # JavaScript
575
638
  tamper-signal export receipts/chain.json --data path/to/dashboard_data.csv
576
639
  ```
577
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
+
578
644
  This writes `receipts/table.json` and refuses if the data does not match
579
645
  the final receipt (the Data tab only ever shows attested data). Re-run it
580
646
  whenever the pipeline runs, or the tab will honestly report a stale table.
581
647
  In a JS build you can write it programmatically instead with
582
648
  `canonicalDocument(finalRecords)` (see step 1b).
583
649
 
584
- 2. Mount the table (vendor `badge/table.js` beside badge.js, or import
585
- `tamper-signal/table`):
650
+ 2. Mount the table (vendor `badge/table.js` beside badge.js with
651
+ `receipts assets`, or import `tamper-signal/table`):
586
652
 
587
653
  ```html
588
654
  <script type="module">
589
- import { mountReceiptTable } from "/static/table.js";
655
+ import { mountReceiptTable } from "/badge/table.js";
590
656
  mountReceiptTable(document.querySelector("#data-tab"), "/receipts/chain.json");
591
657
  </script>
592
658
  ```
593
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
+
594
673
  The component re-hashes the served document in the viewer's browser and
595
674
  compares it against the final receipt, so VERIFIED means the rows on screen
596
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,6 +12,7 @@
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
 
@@ -90,6 +91,9 @@ commands:
90
91
  final receipt); --bundle writes a
91
92
  verified zip (data + chain.json +
92
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
93
97
  `;
94
98
 
95
99
  // Shipped inside every verified bundle so a recipient can verify it without
@@ -1208,12 +1212,50 @@ function cmdExport(args) {
1208
1212
  return 0;
1209
1213
  }
1210
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".`);
1250
+ return 0;
1251
+ }
1252
+
1211
1253
  const [, , command, ...rawRest] = process.argv;
1212
1254
  // --no-color is global: honor it at any position and strip it so each command's
1213
1255
  // strict parser does not reject it. NO_COLOR / FORCE_COLOR env are honored too.
1214
1256
  if (rawRest.includes("--no-color")) color.setNoColor(true);
1215
1257
  const rest = rawRest.filter((arg) => arg !== "--no-color");
1216
- const commands = { keygen: cmdKeygen, ingest: cmdIngest, verify: cmdVerify, diff: cmdDiff, log: cmdLog, export: cmdExport };
1258
+ const commands = { keygen: cmdKeygen, ingest: cmdIngest, verify: cmdVerify, diff: cmdDiff, log: cmdLog, export: cmdExport, assets: cmdAssets };
1217
1259
  if (!command || !(command in commands)) {
1218
1260
  console.error(USAGE);
1219
1261
  process.exit(command ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamper-signal",
3
- "version": "1.7.1",
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",