@openthink/stamp 1.1.0 → 1.2.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/README.md CHANGED
@@ -13,7 +13,7 @@ PR dashboard, no human comment threads in core. Just a CLI + a git hook.
13
13
 
14
14
  Part of the [OpenThink](https://openthink.dev) suite.
15
15
 
16
- **Docs:** [server quickstart](./docs/quickstart-server.md) (from-zero project on a stamp server) · [DESIGN](./DESIGN.md) (spec) · [ROADMAP](./docs/ROADMAP.md) (what's shipped + what's next) · [personas](./docs/personas.md) (writing reviewer prompts) · [troubleshooting](./docs/troubleshooting.md) · [server](./server/README.md) (Railway deploy)
16
+ **Docs:** [server quickstart](./docs/quickstart-server.md) (from-zero project on a stamp server) · [DESIGN](./DESIGN.md) (spec) · [threat model](./docs/threat-model.md) (who attacks, how, what defends) · [ROADMAP](./docs/ROADMAP.md) (what's shipped + what's next) · [personas](./docs/personas.md) (writing reviewer prompts) · [troubleshooting](./docs/troubleshooting.md) · [server](./server/README.md) (Railway deploy)
17
17
 
18
18
  ## Install
19
19
 
@@ -164,7 +164,11 @@ stamp bootstrap # one-shot: replace placeholder examp
164
164
  stamp review --diff <revspec> # run all configured reviewers in parallel
165
165
  stamp review --diff <revspec> --only <name> # run a single reviewer
166
166
  stamp status --diff <revspec> # gate check; exit 0 if open, 1 if closed
167
- stamp merge <branch> --into <target> # run required_checks → sign merge commit
167
+ stamp merge <branch> --into <target> # operator confirmation → merge → required_checks → sign
168
+ # prompts y/N (with base/head SHAs) before any ref moves.
169
+ # bypass: --yes flag, STAMP_REQUIRE_HUMAN_MERGE=0,
170
+ # or branches.<name>.require_human_merge: false in config.
171
+ # audit H1.
168
172
  stamp push <target> # plain git push; hook stderr forwarded
169
173
  stamp verify <sha> # verify a merge commit's attestation locally
170
174
  ```
@@ -254,6 +258,40 @@ matches the committed config.
254
258
  Optional: `.stamp/mirror.yml` enables GitHub mirroring via the post-receive
255
259
  hook. See [`server/README.md`](./server/README.md).
256
260
 
261
+ ### Per-user reviewer-model selection
262
+
263
+ `~/.stamp/config.yml` lets each operator pick which Anthropic model each
264
+ reviewer runs on. Defaults are written by `stamp init` (and lazily on
265
+ first `stamp review` after upgrade) — Sonnet across the three starter
266
+ personas:
267
+
268
+ ```yaml
269
+ reviewers:
270
+ security: claude-sonnet-4-6
271
+ standards: claude-sonnet-4-6
272
+ product: claude-sonnet-4-6
273
+ ```
274
+
275
+ Tune with the CLI rather than hand-editing:
276
+
277
+ ```
278
+ stamp config reviewers show
279
+ stamp config reviewers set security claude-opus-4-7
280
+ stamp config reviewers clear security # remove one entry
281
+ stamp config reviewers clear --all # delete the whole file
282
+ ```
283
+
284
+ Reviewers without a pinned model fall back to the agent SDK's default. The
285
+ file is per-user (not committed) and intentionally NOT included in the
286
+ reviewer attestation hash chain — cost/speed is operator infrastructure,
287
+ not committed review policy. Different operators on the same repo can
288
+ pick different models without merge-conflicting over preference.
289
+
290
+ Note: when two operators run reviews on the same diff with different
291
+ models pinned, each operator records their own verdict in their own
292
+ state.db (same as today's reviewer-prompt model). Stamp does not assume
293
+ verdicts are model-portable.
294
+
257
295
  ## Deployment shapes
258
296
 
259
297
  Three ways to run stamp-cli in a real setting, trading setup cost for
@@ -300,13 +338,19 @@ practice:
300
338
  - **State is files.** `.stamp/config.yml`, `.git/stamp/state.db` (chmoded
301
339
  `0600`; parent `.git/stamp/` chmoded `0700`), git commit trailers. Easy
302
340
  to inspect, hard to lose. To bound retention on long-lived repos, run
303
- `stamp prune --older-than 30d` (use `--dry-run` first to preview).
341
+ `stamp prune --older-than 30d` one invocation cleans both DB rows
342
+ and any failed-parse spool files under `.git/stamp/failed-parses/`.
343
+ Use `--dry-run` first to preview.
304
344
  - **Operations are idempotent.** `stamp init` is safe to re-run. `stamp
305
345
  review` accumulates history; re-invoking doesn't corrupt anything.
306
346
 
307
347
  The canonical unattended loop:
308
348
 
309
349
  ```sh
350
+ # Unattended-loop intent: agent has no TTY, so confirm-on-merge would
351
+ # block forever. Declare the bypass once at shell scope.
352
+ export STAMP_REQUIRE_HUMAN_MERGE=0
353
+
310
354
  while :; do
311
355
  stamp review --diff main..$BRANCH
312
356
  if stamp status --diff main..$BRANCH; then
@@ -319,13 +363,28 @@ while :; do
319
363
  done
320
364
  ```
321
365
 
366
+ `stamp merge` defaults to interactive confirmation (audit H1: residual
367
+ risk of LLM-verdict-as-merge-authorization). Three opt-out paths:
368
+ `--yes` per-invocation, `STAMP_REQUIRE_HUMAN_MERGE=0` per-shell, or
369
+ `branches.<name>.require_human_merge: false` in `.stamp/config.yml`
370
+ (committed and reviewer-gated like any other config).
371
+
372
+ For trust-anchor changes specifically, set
373
+ `reviewers.<name>.enforce_reads_on_dotstamp: true` on the reviewer
374
+ that verifies them (typically `security`). When that reviewer
375
+ approves a diff that touches `.stamp/*`, every modified path must
376
+ appear in its `Read` trace; otherwise the verdict is overridden to
377
+ `changes_requested` with a diagnostic prose pointing at the missing
378
+ files. Defends against a prompt-injected reviewer waving through
379
+ its own trust anchors. Audit-H1 defense-in-depth.
380
+
322
381
  **Exit-code cheat sheet:**
323
382
 
324
383
  | Command | 0 | non-zero (check stderr to disambiguate) |
325
384
  |---|---|---|
326
385
  | `stamp review` | reviewers ran and recorded | invocation failed (reviewer crash, DB error) — verdict may or may not be approved; always follow with `stamp status` to check the gate |
327
386
  | `stamp status` | gate open (all required reviewers approved) | gate closed — at least one required reviewer missing or non-approved |
328
- | `stamp merge` | merge signed, on main | stderr says which case: `gate CLOSED:` (need reviews), `pre-merge checks failed:` (merge rolled back, need fix), or a git-merge conflict message (working tree needs resolution) |
387
+ | `stamp merge` | merge signed, on main | stderr says which case: `gate CLOSED:` (need reviews), `confirmation required:` (no TTY + no opt-out — set `STAMP_REQUIRE_HUMAN_MERGE=0` or pass `--yes`), `merge cancelled:` (operator answered 'n' at the prompt), `pre-merge checks failed:` (merge rolled back, need fix), or a git-merge conflict message (working tree needs resolution) |
329
388
  | `stamp push` | remote accepted | stderr has `remote: stamp-verify: rejecting ...` for hook rejections, or a standard git error for network/auth issues |
330
389
  | `stamp verify` | attestation valid | stderr names the specific verification step that failed (signature invalid, untrusted signer, SHA mismatch, missing check, etc.) |
331
390
 
@@ -350,7 +409,15 @@ with concrete fixes.
350
409
 
351
410
  stamp-cli runs reviewers by sending the diff to Anthropic. Operators
352
411
  working with sensitive content should know the data-flow contract before
353
- running their first `stamp review`.
412
+ running their first `stamp review`. To disable LLM-using stamp surfaces
413
+ entirely on a host (regulated environment, DPA-bound deployment, air-gap),
414
+ set `STAMP_NO_LLM=1` — `stamp review`, `stamp reviewers test`, and
415
+ `stamp bootstrap` will refuse to start with a clear error, and no diff
416
+ content will leave the host. The signing, verification, merge, and log
417
+ primitives (`stamp keys`, `stamp merge`, `stamp verify`, `stamp log`,
418
+ the pre-receive hook) all continue to work; an operator can capture
419
+ manual-review verdicts in `state.db` out-of-band before merge if
420
+ required.
354
421
 
355
422
  **What gets sent to Anthropic on every `stamp review`:**
356
423
 
@@ -366,11 +433,15 @@ running their first `stamp review`.
366
433
  `.git/stamp/state.db` (a sqlite file under the repo's git common
367
434
  dir; per-machine, not committed, not pushed). The DB is chmoded
368
435
  `0600` and its parent directory `0700` on every open so peer users
369
- on shared/dev machines can't read review prose. To bound retention
370
- long-lived repos accumulate every review's verbatim model output
371
- indefinitely use `stamp prune --older-than <duration>`
372
- (e.g. `stamp prune --older-than 30d`; `--dry-run` previews without
373
- deleting).
436
+ on shared/dev machines can't read review prose. Failed parses
437
+ additionally write the raw model output to a per-machine file under
438
+ `.git/stamp/failed-parses/<unix-ms>-<reviewer>.txt` (mode `0600`),
439
+ also never pushed. To bound retention long-lived repos accumulate
440
+ every review's verbatim model output indefinitely — use
441
+ `stamp prune --older-than <duration>` (e.g. `stamp prune
442
+ --older-than 30d`; one invocation cleans both DB rows and old spool
443
+ files under the same threshold; `--dry-run` previews both passes
444
+ without deleting).
374
445
  - Your Ed25519 signing key (`~/.stamp/keys/`) never leaves your machine.
375
446
 
376
447
  **What gets attached to the merge commit and mirrored to GitHub:**
@@ -156,6 +156,9 @@ function userKeysDir() {
156
156
  function userServerConfigPath() {
157
157
  return join(homedir(), ".stamp", "server.yml");
158
158
  }
159
+ function userConfigPath() {
160
+ return join(homedir(), ".stamp", "config.yml");
161
+ }
159
162
  function ensureDir(path, mode = 493) {
160
163
  if (!existsSync(path)) {
161
164
  mkdirSync(path, { recursive: true, mode });
@@ -473,8 +476,10 @@ export {
473
476
  stampConfigFile,
474
477
  stampStateDbPath,
475
478
  stampLlmNoticeMarkerPath,
479
+ gitCommonDir,
476
480
  userKeysDir,
477
481
  userServerConfigPath,
482
+ userConfigPath,
478
483
  ensureDir,
479
484
  openDb,
480
485
  recordReview,
@@ -499,4 +504,4 @@ export {
499
504
  signBytes,
500
505
  verifyBytes
501
506
  };
502
- //# sourceMappingURL=chunk-TTOMORIY.js.map
507
+ //# sourceMappingURL=chunk-UBRQLZON.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/git.ts","../src/lib/paths.ts","../src/lib/db.ts","../src/lib/keys.ts","../src/lib/attestation.ts","../src/lib/signing.ts"],"sourcesContent":["import { execFileSync, spawnSync } from \"node:child_process\";\n\nexport interface ResolvedDiff {\n /** Original revspec as passed by the user, e.g. \"main..HEAD\" */\n revspec: string;\n /** Commit SHA of the merge base (the \"base\" of the diff) */\n base_sha: string;\n /** Commit SHA of the head being reviewed */\n head_sha: string;\n /** Unified diff text covering the change from base to head */\n diff: string;\n}\n\nexport interface CommitSummary {\n sha: string;\n title: string;\n author: string;\n date: string;\n /** Full commit message body */\n body: string;\n /** Parent SHAs (typically 1 for normal commits, 2 for merges) */\n parents: string[];\n}\n\nexport function currentBranch(cwd: string): string {\n return git([\"rev-parse\", \"--abbrev-ref\", \"HEAD\"], cwd).trim();\n}\n\n/**\n * First-parent commit history on a branch — follows only the branch's linear\n * history, skipping commits that came in via merged feature branches. This\n * matches what the pre-receive hook verifies on push.\n */\nexport function firstParentCommits(\n branch: string,\n limit: number,\n cwd: string,\n): CommitSummary[] {\n const sep = \"----stamp-record-end----\";\n const fmt = `%H%n%P%n%an <%ae>%n%ai%n%s%n%n%b${sep}`;\n const out = git(\n [\"log\", \"--first-parent\", `-${limit}`, `--format=${fmt}`, branch],\n cwd,\n );\n const records = out.split(sep).map((r) => r.trim()).filter(Boolean);\n const commits: CommitSummary[] = [];\n for (const rec of records) {\n const lines = rec.split(\"\\n\");\n if (lines.length < 5) continue;\n const [sha, parents, author, date, title, ...rest] = lines as [\n string,\n string,\n string,\n string,\n string,\n ...string[],\n ];\n const body = rest.join(\"\\n\").replace(/^\\n+/, \"\").trimEnd();\n commits.push({\n sha,\n parents: parents.split(/\\s+/).filter(Boolean),\n author,\n date,\n title,\n body,\n });\n }\n return commits;\n}\n\nexport function commitMessage(sha: string, cwd: string): string {\n return git([\"show\", \"-s\", \"--format=%B\", sha], cwd);\n}\n\n/**\n * Read a file's contents from a specific git tree (commit / tag / branch /\n * tree-ish). Wraps `git show <ref>:<path>`. Throws via runGit's stderr-\n * capturing path if the file doesn't exist at that ref.\n *\n * Used by `stamp review` and `stamp merge` to source reviewer config +\n * prompts from the merge-base tree (rather than the working tree), which is\n * the security boundary that prevents a feature branch from reviewing\n * itself with a reviewer prompt it just modified.\n */\nexport function showAtRef(ref: string, path: string, cwd: string): string {\n return runGit([\"show\", `${ref}:${path}`], cwd);\n}\n\n/**\n * True when `<path>` exists in the tree at `<ref>`. Use this when \"missing\"\n * is a legitimate state to branch on rather than an error to recover from —\n * e.g. probing for an optional file like a reviewer lock that may or may\n * not be pinned.\n *\n * Branches on `git cat-file -e`'s exit code: 0 = present, 128 = absent\n * (git's catch-all for \"path or ref didn't resolve\"), anything else =\n * unexpected failure (rethrown). Matches the status-128 = \"legitimate\n * bootstrap state\" convention loadConfigAtSha already uses in verify.ts.\n *\n * Use this instead of try/catch around `git show` when absence is expected,\n * so genuine non-128 failures aren't silently swallowed as \"absent.\"\n */\nexport function pathExistsAtRef(\n ref: string,\n path: string,\n cwd: string,\n): boolean {\n const result = spawnSync(\"git\", [\"cat-file\", \"-e\", `${ref}:${path}`], {\n cwd,\n stdio: [\"ignore\", \"ignore\", \"pipe\"],\n });\n if (result.status === 0) return true;\n if (result.status === 128) return false;\n const stderr = result.stderr?.toString(\"utf8\").trim() ?? \"\";\n throw new Error(\n `git cat-file -e ${ref}:${path} failed (status ${result.status}): ${stderr || \"(no stderr)\"}`,\n );\n}\n\n/**\n * True when `<path>` is already tracked by git on the current branch. Used\n * by `stamp init` to detect \"is this the first time we're adding stamp\n * config\" — the bootstrap moment where the scaffolding files can be\n * committed directly to main.\n *\n * `--error-unmatch` exits non-zero if the path isn't tracked, so we just\n * branch on whether the call throws.\n */\nexport function isPathTracked(path: string, cwd: string): boolean {\n try {\n runGit([\"ls-files\", \"--error-unmatch\", path], cwd);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * True when the repository has at least one commit on HEAD. False on a\n * freshly `git init`'d repo where no commits have been made yet.\n */\nexport function repoHasAnyCommit(cwd: string): boolean {\n try {\n runGit([\"rev-parse\", \"--verify\", \"HEAD\"], cwd);\n return true;\n } catch {\n return false;\n }\n}\n\nexport function commitSummary(sha: string, cwd: string): CommitSummary {\n const commits = firstParentCommits(sha, 1, cwd);\n if (commits.length === 0) {\n throw new Error(`commit ${sha} not found`);\n }\n return commits[0]!;\n}\n\n/**\n * Parse and resolve a git revspec of the form \"<base>..<head>\".\n * - base_sha is merge-base(<base>, <head>), the point at which <head> diverged\n * - head_sha is the commit SHA that <head> currently points to\n * - diff is `git diff <base>...<head>` — changes introduced by <head>\n * relative to <base>, ignoring any changes that <base> has since made\n *\n * Throws on invalid revspecs or on git failures.\n */\nexport function resolveDiff(revspec: string, cwd: string): ResolvedDiff {\n const parts = revspec.split(\"..\");\n if (parts.length !== 2 || !parts[0] || !parts[1]) {\n throw new Error(\n `invalid revspec \"${revspec}\": expected form <base>..<head> (two dots)`,\n );\n }\n const [baseRef, headRef] = parts;\n\n const base_sha = git([\"merge-base\", baseRef, headRef], cwd).trim();\n const head_sha = git([\"rev-parse\", \"--verify\", `${headRef}^{commit}`], cwd).trim();\n const diff = git([\"diff\", `${baseRef}...${headRef}`], cwd);\n\n return { revspec, base_sha, head_sha, diff };\n}\n\n/**\n * Shared git-shell helper used by every command that needs to run git\n * subprocesses. Captures stderr (rather than inheriting it) so failures\n * surface via the thrown message — no raw `fatal: ...` lines bleed onto\n * the user's terminal alongside otherwise-successful output. Returns\n * stdout as utf-8.\n *\n * Use this in command modules (commands/*.ts) instead of a local copy.\n */\nexport function runGit(args: string[], cwd: string): string {\n try {\n return execFileSync(\"git\", args, {\n cwd,\n encoding: \"utf8\",\n maxBuffer: 64 * 1024 * 1024, // 64MB; big diffs happen\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n } catch (err) {\n const stderr = (err as { stderr?: Buffer | string } | null)?.stderr;\n const stderrText =\n typeof stderr === \"string\" ? stderr : stderr?.toString(\"utf8\") ?? \"\";\n const base = err instanceof Error ? err.message : String(err);\n throw new Error(\n `git ${args.join(\" \")} failed: ${stderrText.trim() || base}`,\n );\n }\n}\n\n// Local alias so existing callers in this file keep their short name.\nconst git = runGit;\n","import { existsSync, mkdirSync, readFileSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, isAbsolute, join, resolve } from \"node:path\";\n\nexport function findRepoRoot(startFrom: string = process.cwd()): string {\n let current = resolve(startFrom);\n while (true) {\n if (existsSync(join(current, \".git\"))) return current;\n const parent = dirname(current);\n if (parent === current) {\n throw new Error(\n `not inside a git repository (searched up from ${startFrom})`,\n );\n }\n current = parent;\n }\n}\n\nexport function stampConfigDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\");\n}\n\nexport function stampReviewersDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"reviewers\");\n}\n\nexport function stampTrustedKeysDir(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"trusted-keys\");\n}\n\nexport function stampConfigFile(repoRoot: string): string {\n return join(repoRoot, \".stamp\", \"config.yml\");\n}\n\nexport function stampStateDbPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"state.db\");\n}\n\n/**\n * Marker file that records \"we have shown the LLM data-flow notice in this\n * repo at least once.\" Lives next to state.db under the git common dir so\n * it's per-repo (not per-worktree, not committed).\n */\nexport function stampLlmNoticeMarkerPath(repoRoot: string): string {\n return join(gitCommonDir(repoRoot), \"stamp\", \"llm-notice-shown\");\n}\n\n/**\n * Resolve the git common directory for `repoRoot`. For a normal checkout this\n * is `<repoRoot>/.git`; for a worktree, `<repoRoot>/.git` is a *file* of the\n * form `gitdir: <path>` and the real common dir lives at `<gitdir>/commondir`\n * (a path relative to gitdir, typically `../..`). Mirrors `git rev-parse\n * --git-common-dir` without spawning git.\n *\n * State that should be shared across every worktree of one repository (review\n * verdicts, the per-machine sqlite db) lives under this common dir, so callers\n * resolve their paths through here rather than hard-coding `<repoRoot>/.git`.\n */\nexport function gitCommonDir(repoRoot: string): string {\n const dotGit = join(repoRoot, \".git\");\n const st = statSync(dotGit);\n if (st.isDirectory()) return dotGit;\n\n // Worktree (or submodule): `.git` is a file. Parse the `gitdir:` line, then\n // follow the `commondir` pointer from there. Submodules have no `commondir`,\n // so the gitdir itself is the writable common dir — fall through to that.\n const contents = readFileSync(dotGit, \"utf8\");\n const match = contents.match(/^gitdir:\\s*(.+)$/m);\n if (!match || !match[1]) {\n throw new Error(\n `expected '.git' at ${repoRoot} to be a directory or a 'gitdir:' pointer file, got: ${contents.slice(0, 120)}`,\n );\n }\n const gitdirRaw = match[1].trim();\n const gitdir = isAbsolute(gitdirRaw) ? gitdirRaw : resolve(repoRoot, gitdirRaw);\n\n const commondirPath = join(gitdir, \"commondir\");\n if (!existsSync(commondirPath)) return gitdir;\n const commondirRaw = readFileSync(commondirPath, \"utf8\").trim();\n return isAbsolute(commondirRaw) ? commondirRaw : resolve(gitdir, commondirRaw);\n}\n\nexport function userKeysDir(): string {\n return join(homedir(), \".stamp\", \"keys\");\n}\n\n/**\n * Per-user stamp-server config. Holds {host, port, user, repo_root_prefix}\n * so commands like `stamp provision` can reach the operator's stamp server\n * without making the agent guess at SSH endpoints.\n */\nexport function userServerConfigPath(): string {\n return join(homedir(), \".stamp\", \"server.yml\");\n}\n\n/**\n * Per-user stamp config. Today holds reviewer-model selections; structured\n * as a top-level object so future per-user knobs (telemetry sinks, default\n * timeouts, etc.) can land alongside without renaming the file. Lives\n * separately from per-repo `.stamp/config.yml` because cost/speed is\n * operator infrastructure rather than committed review policy — different\n * operators on the same repo are free to pick different models without\n * a merge-conflict over preference, and this file is intentionally\n * EXCLUDED from the v3 reviewer attestation hash chain.\n */\nexport function userConfigPath(): string {\n return join(homedir(), \".stamp\", \"config.yml\");\n}\n\nexport function ensureDir(path: string, mode = 0o755): void {\n if (!existsSync(path)) {\n mkdirSync(path, { recursive: true, mode });\n }\n}\n\nexport function isFile(path: string): boolean {\n try {\n return statSync(path).isFile();\n } catch {\n return false;\n }\n}\n","import { chmodSync, existsSync } from \"node:fs\";\nimport { DatabaseSync } from \"node:sqlite\";\nimport { dirname } from \"node:path\";\nimport { ensureDir } from \"./paths.js\";\n\nexport type Verdict = \"approved\" | \"changes_requested\" | \"denied\";\n\nexport interface ReviewRow {\n id: number;\n reviewer: string;\n base_sha: string;\n head_sha: string;\n verdict: Verdict;\n issues: string | null;\n /** JSON-encoded ToolCall[] (see lib/toolCalls.ts), or null for reviews\n * recorded before Step 4 shipped or where no tools were invoked. */\n tool_calls: string | null;\n created_at: string;\n}\n\nexport interface RecordReviewInput {\n reviewer: string;\n base_sha: string;\n head_sha: string;\n verdict: Verdict;\n issues?: string | null;\n /** JSON-encoded ToolCall[] or null. See lib/toolCalls.ts. */\n tool_calls?: string | null;\n}\n\nexport function openDb(path: string): DatabaseSync {\n // Tighten parent directory to 0700 so peer users on shared/dev machines\n // can't enter `.git/stamp/` to read state.db (or its WAL sidecars). Done\n // before opening the DB so a brand-new file inherits the locked-down\n // ancestor. Idempotent: chmodSync runs on every open even if ensureDir\n // no-oped, which tightens an already-existing 0755 dir from prior versions.\n const dir = dirname(path);\n ensureDir(dir, 0o700);\n chmodSync(dir, 0o700);\n\n const db = new DatabaseSync(path);\n db.exec(\"PRAGMA journal_mode = WAL\");\n db.exec(\"PRAGMA foreign_keys = ON\");\n initSchema(db);\n\n // Tighten state.db itself plus the WAL sidecars (if SQLite has created\n // them — `-wal` and `-shm` only exist while WAL writes are in flight or\n // recently flushed). chmodSync targets the inode, not any open fd, so\n // this is idempotent across opens; an in-flight write keeps its old fd\n // mode but the on-disk bits flip immediately.\n chmodSync(path, 0o600);\n for (const sidecar of [`${path}-wal`, `${path}-shm`]) {\n if (existsSync(sidecar)) chmodSync(sidecar, 0o600);\n }\n\n return db;\n}\n\nfunction initSchema(db: DatabaseSync): void {\n db.exec(`\n CREATE TABLE IF NOT EXISTS reviews (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n reviewer TEXT NOT NULL,\n base_sha TEXT NOT NULL,\n head_sha TEXT NOT NULL,\n verdict TEXT NOT NULL CHECK (verdict IN ('approved','changes_requested','denied')),\n issues TEXT,\n tool_calls TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\n );\n\n CREATE INDEX IF NOT EXISTS idx_reviews_shas\n ON reviews(base_sha, head_sha, reviewer);\n `);\n\n // Migration for DBs created before Step 4 shipped — tool_calls column\n // wasn't in the original schema. PRAGMA table_info lists columns; if\n // tool_calls is absent, add it. Idempotent: repeat opens no-op.\n const cols = db.prepare(\"PRAGMA table_info(reviews)\").all() as Array<{ name: string }>;\n if (!cols.some((c) => c.name === \"tool_calls\")) {\n db.exec(\"ALTER TABLE reviews ADD COLUMN tool_calls TEXT\");\n }\n}\n\nexport function recordReview(\n db: DatabaseSync,\n input: RecordReviewInput,\n): number {\n const stmt = db.prepare(\n `INSERT INTO reviews (reviewer, base_sha, head_sha, verdict, issues, tool_calls)\n VALUES (?, ?, ?, ?, ?, ?)`,\n );\n const result = stmt.run(\n input.reviewer,\n input.base_sha,\n input.head_sha,\n input.verdict,\n input.issues ?? null,\n input.tool_calls ?? null,\n );\n return Number(result.lastInsertRowid);\n}\n\nexport interface LatestVerdict {\n reviewer: string;\n verdict: Verdict;\n}\n\nexport interface LatestReview {\n id: number;\n reviewer: string;\n verdict: Verdict;\n issues: string | null;\n tool_calls: string | null;\n}\n\nconst LATEST_VERDICTS_SQL = `\n SELECT id, reviewer, verdict, issues, tool_calls\n FROM (\n SELECT\n id,\n reviewer,\n verdict,\n issues,\n tool_calls,\n ROW_NUMBER() OVER (\n PARTITION BY reviewer\n ORDER BY created_at DESC, id DESC\n ) AS rn\n FROM reviews\n WHERE base_sha = ? AND head_sha = ?\n )\n WHERE rn = 1\n`;\n\n/**\n * For a given (base_sha, head_sha), return the latest verdict per reviewer.\n * Uses ROW_NUMBER() window function with (created_at DESC, id DESC) ordering\n * so same-second inserts tiebreak on insertion order.\n */\nexport function latestVerdicts(\n db: DatabaseSync,\n base_sha: string,\n head_sha: string,\n): LatestVerdict[] {\n const stmt = db.prepare(LATEST_VERDICTS_SQL);\n return stmt.all(base_sha, head_sha) as unknown as LatestVerdict[];\n}\n\n/**\n * Same as latestVerdicts but also returns prose (for computing review_sha\n * during attestation, or for display).\n */\nexport function latestReviews(\n db: DatabaseSync,\n base_sha: string,\n head_sha: string,\n): LatestReview[] {\n const stmt = db.prepare(LATEST_VERDICTS_SQL);\n return stmt.all(base_sha, head_sha) as unknown as LatestReview[];\n}\n\nexport function reviewHistory(\n db: DatabaseSync,\n opts: { limit?: number } = {},\n): ReviewRow[] {\n const limit = opts.limit ?? 50;\n const stmt = db.prepare(`\n SELECT id, reviewer, base_sha, head_sha, verdict, issues, created_at\n FROM reviews\n ORDER BY created_at DESC, id DESC\n LIMIT ?\n `);\n return stmt.all(limit) as unknown as ReviewRow[];\n}\n\nexport interface ReviewerStats {\n reviewer: string;\n total: number;\n approved: number;\n changes_requested: number;\n denied: number;\n first_seen: string | null;\n last_seen: string | null;\n}\n\nexport function reviewerStats(\n db: DatabaseSync,\n reviewer: string,\n): ReviewerStats {\n const stmt = db.prepare(`\n SELECT\n COUNT(*) AS total,\n SUM(CASE WHEN verdict = 'approved' THEN 1 ELSE 0 END) AS approved,\n SUM(CASE WHEN verdict = 'changes_requested' THEN 1 ELSE 0 END) AS changes_requested,\n SUM(CASE WHEN verdict = 'denied' THEN 1 ELSE 0 END) AS denied,\n MIN(created_at) AS first_seen,\n MAX(created_at) AS last_seen\n FROM reviews\n WHERE reviewer = ?\n `);\n const row = stmt.get(reviewer) as {\n total: number;\n approved: number | null;\n changes_requested: number | null;\n denied: number | null;\n first_seen: string | null;\n last_seen: string | null;\n };\n return {\n reviewer,\n total: row.total ?? 0,\n approved: row.approved ?? 0,\n changes_requested: row.changes_requested ?? 0,\n denied: row.denied ?? 0,\n first_seen: row.first_seen,\n last_seen: row.last_seen,\n };\n}\n\nexport function recentReviewsByReviewer(\n db: DatabaseSync,\n reviewer: string,\n limit: number,\n): ReviewRow[] {\n const stmt = db.prepare(`\n SELECT id, reviewer, base_sha, head_sha, verdict, issues, created_at\n FROM reviews\n WHERE reviewer = ?\n ORDER BY created_at DESC, id DESC\n LIMIT ?\n `);\n return stmt.all(reviewer, limit) as unknown as ReviewRow[];\n}\n\nexport interface PrunePerReviewer {\n reviewer: string;\n count: number;\n}\n\nexport interface PrunePeekResult {\n total: number;\n perReviewer: PrunePerReviewer[];\n}\n\n/**\n * Count rows older than `now − sqliteModifier` per reviewer, without\n * deleting. Mirrors the row set that `pruneReviews` would delete given the\n * same modifier. Used by `--dry-run` and to compute the \"reviewers affected\"\n * count surfaced in non-dry-run output.\n *\n * `sqliteModifier` is a string suitable for SQLite's `datetime('now', ?)`\n * (e.g. `-30 days`, `-12 hours`); produced by parseRetentionDuration so the\n * cutoff is computed inside SQLite — avoids any wall-clock fencepost\n * between JS `Date.now()` and the `created_at` strings written via\n * `datetime('now')` at insert time.\n */\nexport function peekPrunable(\n db: DatabaseSync,\n sqliteModifier: string,\n): PrunePeekResult {\n const stmt = db.prepare(`\n SELECT reviewer, COUNT(*) AS count\n FROM reviews\n WHERE created_at < datetime('now', ?)\n GROUP BY reviewer\n ORDER BY reviewer\n `);\n const rows = stmt.all(sqliteModifier) as unknown as PrunePerReviewer[];\n const total = rows.reduce((sum, r) => sum + r.count, 0);\n return { total, perReviewer: rows };\n}\n\n/**\n * Delete rows older than `now − sqliteModifier`. Returns the same shape as\n * peekPrunable but with the actual deleted-row counts. The DELETE runs in\n * a single statement; callers must run VACUUM separately (and outside any\n * transaction) to actually shrink the file.\n */\nexport function pruneReviews(\n db: DatabaseSync,\n sqliteModifier: string,\n): PrunePeekResult {\n const peek = peekPrunable(db, sqliteModifier);\n if (peek.total === 0) return peek;\n const del = db.prepare(\n \"DELETE FROM reviews WHERE created_at < datetime('now', ?)\",\n );\n del.run(sqliteModifier);\n return peek;\n}\n","import {\n createHash,\n createPublicKey,\n generateKeyPairSync,\n KeyObject,\n} from \"node:crypto\";\nimport {\n chmodSync,\n readdirSync,\n readFileSync,\n writeFileSync,\n} from \"node:fs\";\nimport { join } from \"node:path\";\nimport {\n ensureDir,\n isFile,\n stampTrustedKeysDir,\n userKeysDir,\n} from \"./paths.js\";\n\nexport interface Keypair {\n privateKeyPem: string;\n publicKeyPem: string;\n fingerprint: string; // \"sha256:<hex>\"\n}\n\nconst PRIVATE_KEY_FILE = \"ed25519\";\nconst PUBLIC_KEY_FILE = \"ed25519.pub\";\n\nexport function generateKeypair(): Keypair {\n const { publicKey, privateKey } = generateKeyPairSync(\"ed25519\");\n const privateKeyPem = privateKey.export({\n type: \"pkcs8\",\n format: \"pem\",\n }) as string;\n const publicKeyPem = publicKey.export({\n type: \"spki\",\n format: \"pem\",\n }) as string;\n return {\n privateKeyPem,\n publicKeyPem,\n fingerprint: fingerprintFromPem(publicKeyPem),\n };\n}\n\nexport function fingerprintFromPem(publicKeyPem: string): string {\n const pub = createPublicKey(publicKeyPem);\n const raw = pub.export({ type: \"spki\", format: \"der\" }) as Buffer;\n const hash = createHash(\"sha256\").update(raw).digest(\"hex\");\n return `sha256:${hash}`;\n}\n\nexport function loadUserKeypair(): Keypair | null {\n const dir = userKeysDir();\n const privPath = join(dir, PRIVATE_KEY_FILE);\n const pubPath = join(dir, PUBLIC_KEY_FILE);\n if (!isFile(privPath) || !isFile(pubPath)) return null;\n const privateKeyPem = readFileSync(privPath, \"utf8\");\n const publicKeyPem = readFileSync(pubPath, \"utf8\");\n return {\n privateKeyPem,\n publicKeyPem,\n fingerprint: fingerprintFromPem(publicKeyPem),\n };\n}\n\nexport function saveUserKeypair(kp: Keypair): void {\n const dir = userKeysDir();\n ensureDir(dir, 0o700);\n chmodSync(dir, 0o700);\n const privPath = join(dir, PRIVATE_KEY_FILE);\n const pubPath = join(dir, PUBLIC_KEY_FILE);\n writeFileSync(privPath, kp.privateKeyPem, { mode: 0o600 });\n writeFileSync(pubPath, kp.publicKeyPem, { mode: 0o644 });\n}\n\nexport function ensureUserKeypair(): {\n keypair: Keypair;\n created: boolean;\n} {\n const existing = loadUserKeypair();\n if (existing) return { keypair: existing, created: false };\n const kp = generateKeypair();\n saveUserKeypair(kp);\n return { keypair: kp, created: true };\n}\n\nexport function publicKeyFingerprintFilename(fingerprint: string): string {\n // \"sha256:abc...\" -> \"sha256_abc....pub\" (colons are valid on unix but messy)\n return fingerprint.replace(\":\", \"_\") + \".pub\";\n}\n\nexport function publicKeyFromObject(obj: KeyObject): string {\n return obj.export({ type: \"spki\", format: \"pem\" }) as string;\n}\n\n/**\n * Look up a public key PEM in a repo's .stamp/trusted-keys/ directory by\n * fingerprint. Returns null if no file in the directory matches.\n */\nexport function findTrustedKey(\n repoRoot: string,\n fingerprint: string,\n): string | null {\n const dir = stampTrustedKeysDir(repoRoot);\n let files: string[];\n try {\n files = readdirSync(dir);\n } catch {\n return null;\n }\n for (const f of files) {\n if (!f.endsWith(\".pub\")) continue;\n let pem: string;\n try {\n pem = readFileSync(join(dir, f), \"utf8\");\n } catch {\n continue;\n }\n try {\n if (fingerprintFromPem(pem) === fingerprint) return pem;\n } catch {\n // skip malformed keys\n }\n }\n return null;\n}\n","import type { Verdict } from \"./db.js\";\nimport type { ToolCall } from \"./toolCalls.js\";\n\n/**\n * Current attestation payload schema version.\n *\n * v1 (absent field) — initial shape; no hash binding to reviewer config.\n * v2 — per-approval prompt/tools/mcp hashes, sourced from the merge\n * commit's own tree. SECURITY ISSUE: a feature branch could modify\n * a reviewer's prompt and the resulting attestation hash matched\n * the modified prompt, so the server hook accepted a self-reviewing\n * merge.\n * v3 — same hash fields, but sourced from the merge-base tree (the\n * common ancestor of the two merge parents). This is the version\n * of the reviewer that existed BEFORE the diff, so a feature\n * branch cannot self-review by modifying its own reviewer prompt.\n *\n * Verifiers reject v2 and below — they're known-broken under the self-\n * review attack. Only v3+ is accepted.\n */\nexport const CURRENT_PAYLOAD_VERSION = 3;\nexport const MIN_ACCEPTED_PAYLOAD_VERSION = 3;\n\nexport interface Approval {\n reviewer: string;\n verdict: Verdict;\n /** sha256 of the review's prose, hex — lets verifiers tie attestation to a specific DB row */\n review_sha: string;\n /** v2+: sha256 of the reviewer's prompt file at merge time */\n prompt_sha256?: string;\n /** v2+: sha256 of the canonical-form tool allowlist (sorted JSON array) */\n tools_sha256?: string;\n /** v2+: sha256 of the canonical-form mcp_servers config (sorted-key JSON) */\n mcp_sha256?: string;\n /** v2+: canonical source the reviewer was fetched from (if a lock file\n * existed at merge time). Enables downstream audit: \"was this reviewer\n * fetched from an approved manifest at an approved version?\" */\n reviewer_source?: {\n source: string;\n ref: string;\n };\n /** v2+: audit trace of tool calls the reviewer's agent made during review.\n * Each entry is `{ tool, input_sha256 }`. Not cryptographically verified —\n * the operator can forge the list — but catches lazy tampering and gives\n * auditors a concrete signal (\"did product call linear.get_issue at all?\").\n * Omitted or empty for reviewers that ran with no tools or where the SDK\n * version didn't surface tool-use blocks. */\n tool_calls?: ToolCall[];\n}\n\nexport interface CheckAttestation {\n name: string;\n command: string;\n exit_code: number;\n output_sha: string;\n}\n\nexport interface AttestationPayload {\n /** Schema version. Absent = v1 (pre-Step-2). Present = v2+. */\n schema_version?: number;\n base_sha: string;\n head_sha: string;\n target_branch: string;\n approvals: Approval[];\n /** Pre-merge checks that ran on the signer's machine and passed.\n * Empty array if the branch has no required_checks configured. */\n checks: CheckAttestation[];\n /** \"sha256:<hex>\" fingerprint of the signer's public key */\n signer_key_id: string;\n}\n\nexport const STAMP_PAYLOAD_TRAILER = \"Stamp-Payload\";\nexport const STAMP_VERIFIED_TRAILER = \"Stamp-Verified\";\n\n/**\n * Hard cap on the base64 trailer value AND its decoded bytes. parseCommit-\n * Attestation runs on every new commit in the pre-receive hook BEFORE the\n * Ed25519 signature is checked, so an attacker who can produce a commit\n * (any push attempt) could otherwise force JSON.parse on a multi-megabyte\n * payload before reaching the signature verification step that would\n * reject it. 64KB is generous for any sane attestation — the largest real\n * payloads are a few KB even with full tool-call traces.\n */\nexport const MAX_TRAILER_BYTES = 64 * 1024;\n\n/**\n * Serialize the payload to the exact bytes that will be signed. We do NOT\n * canonicalize JSON — the signer and verifier both operate on the base64\n * Stamp-Payload trailer value, so whatever bytes we produce here are the\n * same bytes the verifier base64-decodes. Deterministic serialization\n * isn't required for correctness.\n */\nexport function serializePayload(p: AttestationPayload): Buffer {\n return Buffer.from(JSON.stringify(p), \"utf8\");\n}\n\nexport function payloadToTrailerValue(p: AttestationPayload): string {\n return serializePayload(p).toString(\"base64\");\n}\n\nexport function trailerValueToPayload(b64: string): AttestationPayload {\n const json = Buffer.from(b64, \"base64\").toString(\"utf8\");\n return JSON.parse(json) as AttestationPayload;\n}\n\nexport function trailerValueToPayloadBytes(b64: string): Buffer {\n return Buffer.from(b64, \"base64\");\n}\n\nexport interface ParsedAttestation {\n payload: AttestationPayload;\n payloadBytes: Buffer;\n signatureBase64: string;\n}\n\n/**\n * Extract Stamp-Payload + Stamp-Verified trailers from a commit message.\n * Returns null if either is missing. Matches single-line trailer values.\n */\nexport function parseCommitAttestation(\n commitMessage: string,\n): ParsedAttestation | null {\n const payloadMatch = commitMessage.match(\n new RegExp(`^${STAMP_PAYLOAD_TRAILER}:\\\\s*(.+)$`, \"m\"),\n );\n const sigMatch = commitMessage.match(\n new RegExp(`^${STAMP_VERIFIED_TRAILER}:\\\\s*(.+)$`, \"m\"),\n );\n if (!payloadMatch || !sigMatch) return null;\n const b64Payload = payloadMatch[1]?.trim();\n const b64Sig = sigMatch[1]?.trim();\n if (!b64Payload || !b64Sig) return null;\n\n // Bail before allocating or parsing if the trailer is oversized — both as\n // a base64 string and as decoded bytes. See MAX_TRAILER_BYTES rationale.\n if (b64Payload.length > MAX_TRAILER_BYTES) return null;\n const payloadBytes = trailerValueToPayloadBytes(b64Payload);\n if (payloadBytes.length > MAX_TRAILER_BYTES) return null;\n const payload = JSON.parse(payloadBytes.toString(\"utf8\")) as AttestationPayload;\n return { payload, payloadBytes, signatureBase64: b64Sig };\n}\n\n/**\n * Format the two trailer lines. Suitable for appending to a commit message\n * body after a blank-line separator.\n */\nexport function formatTrailers(\n p: AttestationPayload,\n signatureBase64: string,\n): string {\n return (\n `${STAMP_PAYLOAD_TRAILER}: ${payloadToTrailerValue(p)}\\n` +\n `${STAMP_VERIFIED_TRAILER}: ${signatureBase64}`\n );\n}\n","import { createPrivateKey, createPublicKey, sign, verify } from \"node:crypto\";\n\n/**\n * Ed25519 signing. Per RFC 8032, Ed25519 signatures commit to the message\n * directly — no pre-hashing, no padding. Node's crypto.sign/verify accept\n * `null` as the algorithm to get this mode.\n */\n\nexport function signBytes(privateKeyPem: string, data: Buffer): string {\n const key = createPrivateKey(privateKeyPem);\n const sig = sign(null, data, key);\n return sig.toString(\"base64\");\n}\n\nexport function verifyBytes(\n publicKeyPem: string,\n data: Buffer,\n signatureBase64: string,\n): boolean {\n const key = createPublicKey(publicKeyPem);\n const sig = Buffer.from(signatureBase64, \"base64\");\n return verify(null, data, key, sig);\n}\n"],"mappings":"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","names":["existsSync","dirname","dirname","existsSync","chmodSync","readFileSync","join","join","readFileSync","chmodSync","readFileSync","join","commitMessage","createPublicKey"]}