@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 +21 -0
- package/README.md +149 -0
- package/bin/gate-manifest.mjs +113 -0
- package/lib/attest.mjs +63 -0
- package/lib/record.mjs +119 -0
- package/lib/show.mjs +57 -0
- package/lib/sign.mjs +83 -0
- package/lib/verify.mjs +155 -0
- package/package.json +46 -0
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
|
+
}
|