@adlc/gate-manifest 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Williams
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # gate-manifest
2
+
3
+ **ADLC C11 — cross-cutting provenance.** A hash-chained evidence ledger that records what each ADLC gate verified, proving to auditors (and CI) that agentic code was checked before it shipped. Set `ADLC_MANIFEST_KEY` to add HMAC-SHA256 signatures so the chain attests *authorship*, not just internal consistency (see [Signing & provenance](#signing--provenance)).
4
+
5
+ ## ADLC phase
6
+
7
+ Serves **C11** (cross-cutting provenance / agentic SLSA). Consumed by **P6 human-gate** reviewers who need `attest` output as a PR comment, and by **CI** which runs `verify` as a blocking gate.
8
+
9
+ ## Usage
10
+
11
+ ```
12
+ gate-manifest record <gate-name> [--ticket id] [--data '{json}'] [--files a,b,c] [--dir path] [--json]
13
+ gate-manifest verify [--json] [--dir path]
14
+ gate-manifest show [--ticket id] [--json] [--dir path]
15
+ gate-manifest attest [--ticket id] [--dir path]
16
+ ```
17
+
18
+ ### record
19
+
20
+ Append one entry to `.adlc/manifest.jsonl`.
21
+
22
+ ```sh
23
+ gate-manifest record spec-lint --ticket T-42 --data '{"model":"haiku","pass":true}' --files src/foo.mjs,src/bar.mjs
24
+ ```
25
+
26
+ The entry stored:
27
+
28
+ ```json
29
+ {
30
+ "seq": 3,
31
+ "gate": "spec-lint",
32
+ "ticket": "T-42",
33
+ "ts": "2024-01-01T00:00:00.000Z",
34
+ "data": { "model": "haiku", "pass": true },
35
+ "files": { "src/foo.mjs": "<sha256>", "src/bar.mjs": "<sha256>" },
36
+ "prev": "<sha256 of the previous raw JSONL line, or null>",
37
+ "sig": "<HMAC-SHA256 over the canonical entry bytes — present only when ADLC_MANIFEST_KEY is set>"
38
+ }
39
+ ```
40
+
41
+ When `ADLC_MANIFEST_KEY` is set, `record` appends a `sig` (the human output prints `(signed)` / `(unsigned)`). See **Signing & provenance** below.
42
+
43
+ | Flag | Description |
44
+ |------|-------------|
45
+ | `--ticket id` | Associate this entry with a ticket id (optional) |
46
+ | `--data '{json}'` | Arbitrary JSON payload (must be valid JSON; malformed → exit 1) |
47
+ | `--files a,b,c` | Comma-separated paths; each is SHA-256 hashed (missing files hash to null) |
48
+ | `--dir path` | Override ledger directory (default `.adlc`) |
49
+ | `--json` | Print the recorded entry as JSON |
50
+
51
+ ### verify
52
+
53
+ Walk the raw ledger lines and validate the hash chain. Every entry's `prev` must equal `sha256` of the exact raw bytes of the previous line; sequence numbers must be strictly monotonically increasing.
54
+
55
+ ```sh
56
+ gate-manifest verify # human-readable
57
+ gate-manifest verify --json # machine-readable
58
+ ```
59
+
60
+ **Exit 0** when valid (or empty manifest). **Exit 2** when the chain is broken — reports the seq and line number of the first break.
61
+
62
+ When `ADLC_MANIFEST_KEY` is set, `verify` additionally checks every entry's HMAC signature. A missing sig (`unsigned entry`) or a wrong sig (`signature invalid`) breaks the chain. The JSON result includes `signed: true` only when a key was present and every entry verified cryptographically; otherwise `signed: false`.
63
+
64
+ | Flag | Description |
65
+ |------|-------------|
66
+ | `--json` | Emit `{ valid, message, count, signed, break }` |
67
+ | `--dir path` | Override ledger directory |
68
+
69
+ ### show
70
+
71
+ Print entries from the ledger, optionally filtered by ticket.
72
+
73
+ ```sh
74
+ gate-manifest show
75
+ gate-manifest show --ticket T-42
76
+ gate-manifest show --ticket T-42 --json
77
+ ```
78
+
79
+ | Flag | Description |
80
+ |------|-------------|
81
+ | `--ticket id` | Filter to entries with this ticket id |
82
+ | `--json` | Emit `{ entries, skipped }` |
83
+ | `--dir path` | Override ledger directory |
84
+
85
+ ### attest
86
+
87
+ Generate a Markdown summary suitable for a PR comment.
88
+
89
+ ```sh
90
+ gate-manifest attest --ticket T-42
91
+ ```
92
+
93
+ Output example:
94
+
95
+ ```markdown
96
+ ## Gate evidence for T-42
97
+
98
+ | seq | gate | ts | files | data |
99
+ |-----|------|-----|-------|------|
100
+ | 1 | spec-lint | 2024-01-01T… | 0 | — |
101
+ | 2 | hollow-test | 2024-01-01T… | 3 | model=haiku |
102
+
103
+ Chain status: **valid** (2 entries)
104
+ ```
105
+
106
+ | Flag | Description |
107
+ |------|-------------|
108
+ | `--ticket id` | Filter entries and use ticket id in heading |
109
+ | `--dir path` | Override ledger directory |
110
+
111
+ ## Exit codes
112
+
113
+ | Code | Meaning |
114
+ |------|---------|
115
+ | 0 | Gate passes (record, show, attest always; verify when chain is valid) |
116
+ | 1 | Operational error (bad input, unreadable file, malformed `--data` JSON) |
117
+ | 2 | Gate fails (verify detects chain break) |
118
+
119
+ ## Chain integrity
120
+
121
+ `record` reads the ledger file via `readFileSync` (raw bytes) — never via `readEntries` which re-serialises and would lose byte-exact fidelity. The `prev` field is `sha256(previous raw JSONL line)` (null for the first entry). Tampering any middle line breaks all subsequent `prev` hashes, detected by `verify`.
122
+
123
+ ## Signing & provenance
124
+
125
+ The hash chain alone proves **internal consistency**, not **authorship**. `sha256` is a public, keyless function: anyone who can write `manifest.jsonl` can recompute every `prev` and forge a clean chain from scratch. On its own the chain is therefore *not* cryptographic provenance — do not represent a hash-chain-only pass as in-toto/SLSA attestation.
126
+
127
+ To get real provenance, set a secret signing key:
128
+
129
+ ```sh
130
+ export ADLC_MANIFEST_KEY="$(openssl rand -hex 32)" # store in your CI secret manager, never in the repo
131
+ gate-manifest record spec-lint --ticket T-42
132
+ gate-manifest verify --json # → { ..., "signed": true }
133
+ ```
134
+
135
+ - **record** computes `sig = HMAC-SHA256(key, canonicalEntryBytes)`. The signed bytes are the deterministic JSON of `{ seq, gate, ts, ticket?, data?, files, prev }` in that fixed key order (optional `ticket`/`data` included only when present), **excluding** `sig` itself. `sig` is appended last.
136
+ - **verify** (run with the key) requires every entry to carry a valid sig — comparison is constant-time (`crypto.timingSafeEqual`). A missing sig → `unsigned entry`; a wrong sig → `signature invalid`. Either breaks the chain (exit 2). This defeats the forge-from-scratch attack: without the key, an attacker cannot produce valid signatures.
137
+ - **verify** without a key still checks the hash chain but reports `signed: false`, so callers cannot claim cryptographic provenance.
138
+
139
+ Zero-dependency: HMAC comes from Node's built-in `node:crypto`. Key management (rotation, distribution) is out of scope for this tool — supply the key via the environment.
140
+
141
+ ## Sibling tools
142
+
143
+ - `rails-guard` (C5) — appends its own proof here after verifying diff is rails-clean.
144
+ - `hollow-test` (C4) — appends coverage and mutation results.
145
+ - `review-calibration` (C8) — appends prosecution verdicts and calibration score.
146
+
147
+ ## Core gaps
148
+
149
+ None for ledger/CLI primitives — `sha256`, `hashFiles`, `appendEntry`, `readEntries`, `ledgerPath`, `ADLC_DIR`, `parseArgs`, `pass`, `gateFail`, `opError`, `printJson` from `@adlc/core` cover them. Core exposes `sha256` but no keyed-MAC primitive, so HMAC signing uses Node's built-in `node:crypto` (`createHmac`, `timingSafeEqual`) directly in `lib/sign.mjs` — still zero runtime dependencies.
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ // gate-manifest — ADLC C11 hash-chained agentic provenance ledger.
3
+ // Verbs: record | verify | show | attest
4
+
5
+ import { parseArgs, pass, gateFail, opError, printJson } from '@adlc/core';
6
+ import { record, parseData } from '../lib/record.mjs';
7
+ import { verify } from '../lib/verify.mjs';
8
+ import { loadFiltered, renderEntries } from '../lib/show.mjs';
9
+ import { buildAttest } from '../lib/attest.mjs';
10
+ import { ADLC_DIR } from '@adlc/core';
11
+
12
+ const { values: flags, positionals } = parseArgs({
13
+ options: {
14
+ ticket: { type: 'string' },
15
+ data: { type: 'string' },
16
+ files: { type: 'string' },
17
+ json: { type: 'boolean', default: false },
18
+ dir: { type: 'string', default: ADLC_DIR },
19
+ },
20
+ });
21
+
22
+ const verb = positionals[0];
23
+
24
+ if (!verb) {
25
+ opError(
26
+ 'usage: gate-manifest <verb> [options]\n' +
27
+ 'verbs: record <gate-name> [--ticket id] [--data \'{json}\'] [--files a,b,c]\n' +
28
+ ' verify [--json]\n' +
29
+ ' show [--ticket id] [--json]\n' +
30
+ ' attest [--ticket id]'
31
+ );
32
+ }
33
+
34
+ // ── record ──────────────────────────────────────────────────────────────────
35
+ if (verb === 'record') {
36
+ const gate = positionals[1];
37
+ if (!gate) {
38
+ opError('usage: gate-manifest record <gate-name> [--ticket id] [--data \'{json}\'] [--files a,b,c]');
39
+ }
40
+
41
+ // Validate --data early so we get opError (exit 1) on bad JSON
42
+ try {
43
+ parseData(flags.data);
44
+ } catch (err) {
45
+ opError(err.message);
46
+ }
47
+
48
+ let entry;
49
+ try {
50
+ entry = record({
51
+ gate,
52
+ ticket: flags.ticket,
53
+ rawData: flags.data,
54
+ rawFiles: flags.files,
55
+ dir: flags.dir,
56
+ });
57
+ } catch (err) {
58
+ opError(err.message);
59
+ }
60
+
61
+ if (flags.json) {
62
+ printJson(entry);
63
+ } else {
64
+ const signed = typeof entry.sig === 'string' ? ' (signed)' : ' (unsigned)';
65
+ console.log(`recorded: seq=${entry.seq} gate=${entry.gate} ts=${entry.ts}${signed}`);
66
+ }
67
+
68
+ pass();
69
+ }
70
+
71
+ // ── verify ───────────────────────────────────────────────────────────────────
72
+ if (verb === 'verify') {
73
+ const result = verify(flags.dir);
74
+
75
+ if (flags.json) {
76
+ printJson(result);
77
+ } else {
78
+ console.log(result.message);
79
+ }
80
+
81
+ if (result.valid) {
82
+ pass();
83
+ } else {
84
+ gateFail(`gate-manifest verify: ${result.message}`);
85
+ }
86
+ }
87
+
88
+ // ── show ─────────────────────────────────────────────────────────────────────
89
+ if (verb === 'show') {
90
+ const { entries, skipped } = loadFiltered({ ticket: flags.ticket, dir: flags.dir });
91
+
92
+ if (flags.json) {
93
+ printJson({ entries, skipped });
94
+ } else {
95
+ const lines = renderEntries(entries);
96
+ for (const l of lines) console.log(l);
97
+ if (skipped.length > 0) {
98
+ console.warn(`warning: ${skipped.length} malformed line(s) skipped`);
99
+ }
100
+ }
101
+
102
+ pass();
103
+ }
104
+
105
+ // ── attest ───────────────────────────────────────────────────────────────────
106
+ if (verb === 'attest') {
107
+ const md = buildAttest({ ticket: flags.ticket, dir: flags.dir });
108
+ console.log(md);
109
+ pass();
110
+ }
111
+
112
+ // Unknown verb
113
+ opError(`unknown verb: ${verb}. Expected: record | verify | show | attest`);
package/lib/attest.mjs ADDED
@@ -0,0 +1,63 @@
1
+ // attest.mjs — generate markdown gate evidence for a ticket (PR comment ready).
2
+
3
+ import { verify } from './verify.mjs';
4
+ import { loadFiltered } from './show.mjs';
5
+ import { ADLC_DIR } from '@adlc/core';
6
+
7
+ /**
8
+ * Summarise the `data` field of an entry for the attest table.
9
+ * Returns a short string.
10
+ */
11
+ function dataSummary(data) {
12
+ if (!data || typeof data !== 'object') return '—';
13
+ const keys = Object.keys(data);
14
+ if (keys.length === 0) return '—';
15
+ // Show first two key=value pairs, truncated
16
+ return keys
17
+ .slice(0, 2)
18
+ .map(k => {
19
+ const v = String(data[k]);
20
+ return `${k}=${v.length > 20 ? v.slice(0, 17) + '...' : v}`;
21
+ })
22
+ .join(', ');
23
+ }
24
+
25
+ /**
26
+ * Build the attest markdown for a (possibly filtered) set of entries.
27
+ *
28
+ * @param {object} opts
29
+ * @param {string|undefined} opts.ticket ticket id to filter on (also used in heading)
30
+ * @param {string} [opts.dir] ledger directory (default ADLC_DIR)
31
+ * @returns {string} markdown text
32
+ */
33
+ export function buildAttest({ ticket, dir = ADLC_DIR } = {}) {
34
+ const { entries } = loadFiltered({ ticket, dir });
35
+ const chainResult = verify(dir);
36
+
37
+ const heading = ticket
38
+ ? `## Gate evidence for ${ticket}`
39
+ : '## Gate evidence';
40
+
41
+ const lines = [heading, ''];
42
+
43
+ if (entries.length === 0) {
44
+ lines.push('_No entries found._', '');
45
+ } else {
46
+ lines.push('| seq | gate | ts | files | data |');
47
+ lines.push('|-----|------|-----|-------|------|');
48
+ for (const e of entries) {
49
+ const fileCount = e.files ? Object.keys(e.files).length : 0;
50
+ const ds = dataSummary(e.data);
51
+ lines.push(`| ${e.seq} | ${e.gate} | ${e.ts} | ${fileCount} | ${ds} |`);
52
+ }
53
+ lines.push('');
54
+ }
55
+
56
+ const chainStatus = chainResult.valid
57
+ ? `Chain status: **valid** (${chainResult.count} entries)`
58
+ : `Chain status: **BROKEN** — ${chainResult.message}`;
59
+
60
+ lines.push(chainStatus);
61
+
62
+ return lines.join('\n');
63
+ }
package/lib/record.mjs ADDED
@@ -0,0 +1,119 @@
1
+ // record.mjs — build and append a manifest entry.
2
+ // IMPORTANT: chain integrity depends on sha256(raw previous line bytes),
3
+ // so we read the ledger file directly via readFileSync — never via readEntries
4
+ // which parses and re-serialises, losing byte-exact fidelity.
5
+
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { sha256, hashFiles, appendEntry, ledgerPath, ADLC_DIR } from '@adlc/core';
8
+ import { getKey, signEntry } from './sign.mjs';
9
+
10
+ /**
11
+ * Parse JSON data from a --data flag string.
12
+ * Returns parsed object or throws with a clear message.
13
+ */
14
+ export function parseData(raw) {
15
+ if (!raw) return undefined;
16
+ try {
17
+ return JSON.parse(raw);
18
+ } catch (err) {
19
+ throw new Error(`--data is not valid JSON: ${err.message}`);
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Parse a comma-separated file list from --files flag string.
25
+ * Returns array of trimmed non-empty paths.
26
+ */
27
+ export function parseFileList(raw) {
28
+ if (!raw) return [];
29
+ return raw.split(',').map(s => s.trim()).filter(Boolean);
30
+ }
31
+
32
+ /**
33
+ * Read the last raw line (non-empty) from a ledger file.
34
+ * Returns null if the file does not exist or has no non-empty lines.
35
+ */
36
+ export function readLastRawLine(filePath) {
37
+ if (!existsSync(filePath)) return null;
38
+ const content = readFileSync(filePath, 'utf8');
39
+ // Split on newline but keep exact bytes by splitting the buffer
40
+ const lines = content.split('\n');
41
+ // Walk backwards to find the last non-empty line
42
+ for (let i = lines.length - 1; i >= 0; i--) {
43
+ if (lines[i].trim()) return lines[i];
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Build a new manifest entry object (pure, side-effect-free).
50
+ *
51
+ * @param {object} opts
52
+ * @param {string} opts.gate gate name
53
+ * @param {string|undefined} opts.ticket ticket id (optional)
54
+ * @param {object|undefined} opts.data parsed JSON data (optional)
55
+ * @param {string[]} opts.filePaths list of files to hash
56
+ * @param {string|null} opts.prevRawLine raw bytes of the previous JSONL line (or null)
57
+ * @param {number} opts.prevSeq sequence number of previous entry (0 if none)
58
+ * @param {string} opts.ts ISO timestamp
59
+ * @param {string|null} [opts.key] HMAC signing key; when present, entry gets a `sig`
60
+ * @returns manifest entry object
61
+ */
62
+ export function buildEntry({ gate, ticket, data, filePaths, prevRawLine, prevSeq, ts, key = null }) {
63
+ const entry = {
64
+ seq: prevSeq + 1,
65
+ gate,
66
+ ts,
67
+ };
68
+
69
+ if (ticket !== undefined) entry.ticket = ticket;
70
+ if (data !== undefined) entry.data = data;
71
+
72
+ entry.files = filePaths.length > 0 ? hashFiles(filePaths) : {};
73
+ entry.prev = prevRawLine !== null ? sha256(prevRawLine) : null;
74
+
75
+ // Sign last: `sig` is computed over the canonical bytes of all other fields
76
+ // (see sign.mjs) and appended as the final field so it is excluded from the
77
+ // signed payload. Without a key the entry is unsigned and verify will flag it.
78
+ if (key) entry.sig = signEntry(key, entry);
79
+
80
+ return entry;
81
+ }
82
+
83
+ /**
84
+ * Record a new entry in the manifest ledger.
85
+ *
86
+ * @param {object} opts
87
+ * @param {string} opts.gate
88
+ * @param {string|undefined} opts.ticket
89
+ * @param {string|undefined} opts.rawData raw --data string (parsed here)
90
+ * @param {string|undefined} opts.rawFiles raw --files string (parsed here)
91
+ * @param {string} [opts.dir] ledger directory (default ADLC_DIR)
92
+ * @returns the recorded entry object
93
+ * @throws Error for malformed --data JSON
94
+ */
95
+ export function record({ gate, ticket, rawData, rawFiles, dir = ADLC_DIR }) {
96
+ const data = parseData(rawData);
97
+ const filePaths = parseFileList(rawFiles);
98
+
99
+ const lp = ledgerPath('manifest', dir);
100
+ const prevRawLine = readLastRawLine(lp);
101
+
102
+ // Determine previous seq by parsing the last raw line if present
103
+ let prevSeq = 0;
104
+ if (prevRawLine !== null) {
105
+ try {
106
+ const parsed = JSON.parse(prevRawLine);
107
+ prevSeq = typeof parsed.seq === 'number' ? parsed.seq : 0;
108
+ } catch {
109
+ // Malformed last line; still record but seq continues from 0
110
+ }
111
+ }
112
+
113
+ const ts = new Date().toISOString();
114
+ const key = getKey();
115
+ const entry = buildEntry({ gate, ticket, data, filePaths, prevRawLine, prevSeq, ts, key });
116
+
117
+ appendEntry('manifest', entry, dir);
118
+ return entry;
119
+ }
package/lib/show.mjs ADDED
@@ -0,0 +1,57 @@
1
+ // show.mjs — render manifest entries (with optional ticket filter).
2
+
3
+ import { readEntries, ADLC_DIR } from '@adlc/core';
4
+
5
+ /**
6
+ * Load entries from the manifest ledger, optionally filtered by ticket id.
7
+ *
8
+ * @param {object} opts
9
+ * @param {string|undefined} opts.ticket filter to entries with this ticket id
10
+ * @param {string} [opts.dir] ledger directory (default ADLC_DIR)
11
+ * @returns {{ entries: object[], skipped: object[] }}
12
+ */
13
+ export function loadFiltered({ ticket, dir = ADLC_DIR } = {}) {
14
+ const { entries, skipped } = readEntries('manifest', dir);
15
+ const filtered = ticket
16
+ ? entries.filter(e => e.ticket === ticket)
17
+ : entries;
18
+ return { entries: filtered, skipped };
19
+ }
20
+
21
+ /**
22
+ * Render a single entry as a human-readable string array.
23
+ * @param {object} entry
24
+ * @returns {string[]}
25
+ */
26
+ export function renderEntry(entry) {
27
+ const lines = [];
28
+ lines.push(`seq=${entry.seq} gate=${entry.gate} ts=${entry.ts}`);
29
+ if (entry.ticket) lines.push(` ticket: ${entry.ticket}`);
30
+ if (entry.data && Object.keys(entry.data).length > 0) {
31
+ lines.push(` data: ${JSON.stringify(entry.data)}`);
32
+ }
33
+ const fileCount = entry.files ? Object.keys(entry.files).length : 0;
34
+ if (fileCount > 0) {
35
+ lines.push(` files (${fileCount}):`);
36
+ for (const [path, hash] of Object.entries(entry.files)) {
37
+ lines.push(` ${path}: ${hash ?? 'null'}`);
38
+ }
39
+ }
40
+ lines.push(` prev: ${entry.prev ?? 'null'}`);
41
+ return lines;
42
+ }
43
+
44
+ /**
45
+ * Render all entries as human-readable text lines.
46
+ * @param {object[]} entries
47
+ * @returns {string[]}
48
+ */
49
+ export function renderEntries(entries) {
50
+ if (entries.length === 0) return ['(no entries)'];
51
+ const out = [];
52
+ for (const e of entries) {
53
+ out.push(...renderEntry(e));
54
+ out.push('');
55
+ }
56
+ return out;
57
+ }
package/lib/sign.mjs ADDED
@@ -0,0 +1,83 @@
1
+ // sign.mjs — keyed signing for manifest entries (HMAC-SHA256, zero-dep).
2
+ //
3
+ // WHY: the hash chain (`prev` = sha256(previous raw line)) is keyless. Anyone
4
+ // who can write the ledger file can recompute every `prev` and forge a clean
5
+ // chain from scratch — sha256 is a public function with no secret. To make the
6
+ // chain a real *provenance* signal (in-toto/SLSA-style), each entry is signed
7
+ // with HMAC-SHA256 under a secret key (env ADLC_MANIFEST_KEY). An attacker
8
+ // without the key cannot produce a valid `sig`, so a forged chain fails verify.
9
+ //
10
+ // CANONICAL BYTES SIGNED — must be byte-identical on record and verify:
11
+ // We sign the deterministic JSON of an object containing ONLY the
12
+ // chain-relevant fields, in this fixed key order:
13
+ // { seq, gate, ts, ticket, data, files, prev }
14
+ // built via canonicalEntryBytes() below. Optional fields (ticket, data) are
15
+ // included only when present on the entry — matching how buildEntry omits
16
+ // them — so the signed bytes mirror the entry's own shape. The `sig` field
17
+ // itself is never part of the signed bytes.
18
+
19
+ import { createHmac, timingSafeEqual } from 'node:crypto';
20
+
21
+ /** Env var holding the secret signing key. */
22
+ export const KEY_ENV = 'ADLC_MANIFEST_KEY';
23
+
24
+ /**
25
+ * Read the signing key from the environment.
26
+ * Returns the key string, or null when unset/empty.
27
+ * @param {NodeJS.ProcessEnv} [env]
28
+ * @returns {string|null}
29
+ */
30
+ export function getKey(env = process.env) {
31
+ const k = env[KEY_ENV];
32
+ return typeof k === 'string' && k.length > 0 ? k : null;
33
+ }
34
+
35
+ /**
36
+ * Build the canonical byte string that gets signed for an entry.
37
+ *
38
+ * Deterministic: fixed key order, optional fields included only when the entry
39
+ * carries them. The `sig` field is always excluded.
40
+ *
41
+ * @param {object} entry a manifest entry (with or without `sig`)
42
+ * @returns {string} canonical JSON string
43
+ */
44
+ export function canonicalEntryBytes(entry) {
45
+ const canonical = {
46
+ seq: entry.seq,
47
+ gate: entry.gate,
48
+ ts: entry.ts,
49
+ };
50
+ if (entry.ticket !== undefined) canonical.ticket = entry.ticket;
51
+ if (entry.data !== undefined) canonical.data = entry.data;
52
+ canonical.files = entry.files;
53
+ canonical.prev = entry.prev;
54
+ return JSON.stringify(canonical);
55
+ }
56
+
57
+ /**
58
+ * Compute the HMAC-SHA256 signature (hex) of an entry under a key.
59
+ * @param {string} key
60
+ * @param {object} entry
61
+ * @returns {string} hex digest
62
+ */
63
+ export function signEntry(key, entry) {
64
+ return createHmac('sha256', key).update(canonicalEntryBytes(entry)).digest('hex');
65
+ }
66
+
67
+ /**
68
+ * Constant-time check that `entry.sig` is the correct HMAC for `key`.
69
+ * Returns false when sig is missing, malformed, or wrong.
70
+ * @param {string} key
71
+ * @param {object} entry
72
+ * @returns {boolean}
73
+ */
74
+ export function verifyEntrySig(key, entry) {
75
+ if (typeof entry.sig !== 'string' || entry.sig.length === 0) return false;
76
+ const expected = signEntry(key, entry);
77
+ // Both are hex strings of equal length (sha256 → 64 hex chars) when sig is
78
+ // well-formed; guard against length mismatch which timingSafeEqual rejects.
79
+ const a = Buffer.from(entry.sig, 'utf8');
80
+ const b = Buffer.from(expected, 'utf8');
81
+ if (a.length !== b.length) return false;
82
+ return timingSafeEqual(a, b);
83
+ }
package/lib/verify.mjs ADDED
@@ -0,0 +1,155 @@
1
+ // verify.mjs — walk raw lines and validate the hash chain.
2
+ // Each entry's `prev` must equal sha256(previous raw line).
3
+ // Sequence numbers must be strictly monotonically increasing.
4
+
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { sha256, ledgerPath, ADLC_DIR } from '@adlc/core';
7
+ import { getKey, verifyEntrySig } from './sign.mjs';
8
+
9
+ /**
10
+ * Result of a chain verification.
11
+ * @typedef {object} VerifyResult
12
+ * @property {boolean} valid
13
+ * @property {string} message human-readable summary
14
+ * @property {number} count number of entries checked
15
+ * @property {boolean} signed true only when a key was present AND every
16
+ * entry verified cryptographically. When no key
17
+ * is set this is false: callers MUST NOT claim
18
+ * cryptographic provenance from a hash-chain-only
19
+ * pass.
20
+ * @property {object|null} break null if valid; { seq, lineNo, reason } if broken
21
+ */
22
+
23
+ /**
24
+ * Verify the manifest ledger's hash chain — and, when ADLC_MANIFEST_KEY is
25
+ * set, the HMAC signature of every entry that carries one.
26
+ *
27
+ * Reads raw lines directly (never via readEntries) so that sha256 is computed
28
+ * over the exact bytes that were written.
29
+ *
30
+ * Security model:
31
+ * - No key in env → hash chain checked only; result.signed = false. The chain
32
+ * proves *internal consistency*, not authorship — a writer can forge it.
33
+ * - Key in env → every entry MUST carry a valid sig. A missing sig
34
+ * ('unsigned entry') or wrong sig ('signature invalid') breaks the chain.
35
+ * This defeats the forge-from-scratch attack: without the key an attacker
36
+ * cannot produce valid signatures, so verify (run WITH the key) returns
37
+ * valid:false.
38
+ *
39
+ * @param {string} [dir] ledger directory (default ADLC_DIR)
40
+ * @returns {VerifyResult}
41
+ */
42
+ export function verify(dir = ADLC_DIR) {
43
+ const lp = ledgerPath('manifest', dir);
44
+ const key = getKey();
45
+
46
+ if (!existsSync(lp)) {
47
+ return { valid: true, message: 'empty manifest', count: 0, signed: false, break: null };
48
+ }
49
+
50
+ const content = readFileSync(lp, 'utf8');
51
+ const rawLines = content.split('\n');
52
+
53
+ // Filter to non-empty lines, keeping original 1-based line numbers
54
+ const nonEmpty = rawLines
55
+ .map((line, i) => ({ line, lineNo: i + 1 }))
56
+ .filter(({ line }) => line.trim() !== '');
57
+
58
+ if (nonEmpty.length === 0) {
59
+ return { valid: true, message: 'empty manifest', count: 0, signed: false, break: null };
60
+ }
61
+
62
+ let prevRawLine = null;
63
+ let prevSeq = null;
64
+
65
+ for (const { line, lineNo } of nonEmpty) {
66
+ let entry;
67
+ try {
68
+ entry = JSON.parse(line);
69
+ } catch {
70
+ return {
71
+ valid: false,
72
+ message: `chain broken at line ${lineNo}: malformed JSON`,
73
+ count: lineNo - 1,
74
+ signed: false,
75
+ break: { seq: null, lineNo, reason: 'malformed JSON' },
76
+ };
77
+ }
78
+
79
+ // Check prev hash
80
+ if (prevRawLine === null) {
81
+ // First entry: prev must be null
82
+ if (entry.prev !== null) {
83
+ return {
84
+ valid: false,
85
+ message: `chain broken at seq ${entry.seq} (line ${lineNo}): first entry prev must be null`,
86
+ count: 0,
87
+ signed: false,
88
+ break: { seq: entry.seq, lineNo, reason: 'first entry prev must be null' },
89
+ };
90
+ }
91
+ } else {
92
+ const expected = sha256(prevRawLine);
93
+ if (entry.prev !== expected) {
94
+ return {
95
+ valid: false,
96
+ message: `chain broken at seq ${entry.seq} (line ${lineNo}): prev hash mismatch`,
97
+ count: lineNo - 1,
98
+ signed: false,
99
+ break: { seq: entry.seq, lineNo, reason: 'prev hash mismatch' },
100
+ };
101
+ }
102
+ }
103
+
104
+ // Check seq monotonicity
105
+ if (prevSeq !== null && entry.seq <= prevSeq) {
106
+ return {
107
+ valid: false,
108
+ message: `chain broken at seq ${entry.seq} (line ${lineNo}): seq must be strictly increasing`,
109
+ count: lineNo - 1,
110
+ signed: false,
111
+ break: { seq: entry.seq, lineNo, reason: 'seq not monotonically increasing' },
112
+ };
113
+ }
114
+
115
+ // Check HMAC signature when a key is configured. With a key, EVERY entry
116
+ // must carry a valid sig — otherwise the chain is not cryptographically
117
+ // attestable and a forged-from-scratch chain would slip through.
118
+ if (key !== null) {
119
+ if (entry.sig === undefined || entry.sig === null) {
120
+ return {
121
+ valid: false,
122
+ message: `chain broken at seq ${entry.seq} (line ${lineNo}): unsigned entry`,
123
+ count: lineNo - 1,
124
+ signed: false,
125
+ break: { seq: entry.seq, lineNo, reason: 'unsigned entry' },
126
+ };
127
+ }
128
+ if (!verifyEntrySig(key, entry)) {
129
+ return {
130
+ valid: false,
131
+ message: `chain broken at seq ${entry.seq} (line ${lineNo}): signature invalid`,
132
+ count: lineNo - 1,
133
+ signed: false,
134
+ break: { seq: entry.seq, lineNo, reason: 'signature invalid' },
135
+ };
136
+ }
137
+ }
138
+
139
+ prevRawLine = line;
140
+ prevSeq = entry.seq;
141
+ }
142
+
143
+ // signed:true only when a key verified every entry. With no key, the chain
144
+ // is internally consistent but NOT cryptographically attested.
145
+ const signed = key !== null;
146
+ return {
147
+ valid: true,
148
+ message: signed
149
+ ? `manifest ok, signed (${nonEmpty.length} entries)`
150
+ : `manifest ok (${nonEmpty.length} entries)`,
151
+ count: nonEmpty.length,
152
+ signed,
153
+ break: null,
154
+ };
155
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@adlc/gate-manifest",
3
+ "version": "1.0.0",
4
+ "description": "Hash-chained evidence ledger recording what each ADLC gate verified — agentic provenance with optional HMAC signing (C11).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Chris Williams (@voodootikigod)",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/voodootikigod/adlc.git",
11
+ "directory": "packages/gate-manifest"
12
+ },
13
+ "homepage": "https://github.com/voodootikigod/adlc/tree/main/packages/gate-manifest#readme",
14
+ "bugs": "https://github.com/voodootikigod/adlc/issues",
15
+ "keywords": [
16
+ "adlc",
17
+ "agentic",
18
+ "ai",
19
+ "llm",
20
+ "cli",
21
+ "gate-manifest",
22
+ "gate"
23
+ ],
24
+ "bin": {
25
+ "gate-manifest": "./bin/gate-manifest.mjs"
26
+ },
27
+ "files": [
28
+ "bin/",
29
+ "lib/",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "dependencies": {
34
+ "@adlc/core": "1.0.0"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test test/*.test.mjs"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ }
46
+ }