@codedology/code-forge 0.1.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.
Files changed (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The run record (plan §4.9, §4.8, C4): created by `run start`, pinned OUTSIDE the workspace at
3
+ * `~/.code-forge/runs/<run>.json` (authoritative) and mirrored to `<workspace>/.code-forge/runs/`
4
+ * without the `worker` pin (informational only — nothing reads the mirror back).
5
+ *
6
+ * The worker pin is `{pid, started_at}` where `started_at` is `ps -o lstart= -p <pid>` — the
7
+ * process START time, so a recycled pid or a coder-started replacement worker does not match.
8
+ * The worker itself is B11's: `startRun` takes the pid of a worker the orchestrator started (or
9
+ * none), and `reattachWorker` re-pins after a restart.
10
+ *
11
+ * Ledger rows are written through the `writeRow` parameter (C17 seam: the B6 writer arrives as a
12
+ * parameter; the CLI verb does the static import) and are SIGNED with the run key first.
13
+ */
14
+
15
+ import { mkdir, readFile, rename, rmdir, writeFile } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import { exec } from '../util/exec.mjs';
18
+ import { StateError, assertRunId, assertWorkspaceOutsideRuns, mirrorPath, newRunId, runRecordPath, runsDir } from './paths.mjs';
19
+ import { generateKey, loadKey, signRow } from './signer.mjs';
20
+
21
+ /** @typedef {(row: Record<string, any>) => Promise<unknown>} WriteRow */
22
+ /** @typedef {(pid: number) => Promise<string | null>} StartTimeProbe */
23
+
24
+ const LOCK_TIMEOUT_MS = 5000;
25
+ const LOCK_POLL_MS = 25;
26
+
27
+ /**
28
+ * The process start time of `pid` (`ps -o lstart=`), or null when no such process exists.
29
+ * @type {StartTimeProbe}
30
+ */
31
+ export async function processStartTime(pid) {
32
+ if (!Number.isInteger(pid) || pid <= 0) return null;
33
+ const res = await exec(['ps', '-o', 'lstart=', '-p', String(pid)], {
34
+ env: { ...process.env, LC_ALL: 'C' },
35
+ okExitCodes: [0, 1],
36
+ timeoutMs: 10000,
37
+ });
38
+ const text = res.stdout.trim();
39
+ return res.result === 'ok' && res.code === 0 && text.length > 0 ? text : null;
40
+ }
41
+
42
+ /** @param {unknown} value @returns {string} */
43
+ const serialize = (value) => `${JSON.stringify(value, null, 2)}\n`;
44
+
45
+ /**
46
+ * @param {string} runId
47
+ * @returns {Promise<Record<string, any>>}
48
+ */
49
+ export async function readRun(runId) {
50
+ let text;
51
+ try {
52
+ text = await readFile(runRecordPath(runId), 'utf8');
53
+ } catch (err) {
54
+ if (err.code === 'ENOENT') throw new StateError('no-run', `no run record for ${runId}`);
55
+ throw err;
56
+ }
57
+ return JSON.parse(text);
58
+ }
59
+
60
+ /**
61
+ * Atomically write the authoritative record (0600) and the workspace mirror (minus `worker`).
62
+ * @param {Record<string, any>} record
63
+ */
64
+ export async function saveRun(record) {
65
+ await mkdir(runsDir(), { recursive: true, mode: 0o700 });
66
+ const file = runRecordPath(record.run_id);
67
+ const tmp = `${file}.${process.pid}.tmp`;
68
+ await writeFile(tmp, serialize(record), { mode: 0o600 });
69
+ await rename(tmp, file);
70
+ const { worker, ...mirror } = record;
71
+ const mirrorFile = mirrorPath(record.workspace, record.run_id);
72
+ await mkdir(path.dirname(mirrorFile), { recursive: true });
73
+ await writeFile(`${mirrorFile}.tmp`, serialize(mirror));
74
+ await rename(`${mirrorFile}.tmp`, mirrorFile);
75
+ }
76
+
77
+ /**
78
+ * Serialize read-modify-write of one run record across processes (a `mkdir` lock next to it).
79
+ * @template T
80
+ * @param {string} runId @param {() => Promise<T>} fn
81
+ * @returns {Promise<T>}
82
+ */
83
+ export async function withRunLock(runId, fn) {
84
+ await mkdir(runsDir(), { recursive: true, mode: 0o700 });
85
+ const lock = path.join(runsDir(), `${assertRunId(runId)}.lock`);
86
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
87
+ for (;;) {
88
+ try {
89
+ await mkdir(lock);
90
+ break;
91
+ } catch (err) {
92
+ if (err.code !== 'EEXIST') throw err;
93
+ if (Date.now() > deadline) throw new StateError('locked', `run ${runId} is locked (${lock}); remove it if no forge verb is running`);
94
+ await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
95
+ }
96
+ }
97
+ try {
98
+ return await fn();
99
+ } finally {
100
+ await rmdir(lock);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Sign `row` with the run key and hand it to the ledger writer.
106
+ * @param {string} runId @param {WriteRow} writeRow @param {Record<string, any>} row
107
+ */
108
+ export async function writeSigned(runId, writeRow, row) {
109
+ const key = await loadKey(runId);
110
+ return writeRow(signRow({ run: runId, ...row }, key));
111
+ }
112
+
113
+
114
+ /**
115
+ * A pid this package may ever signal: an integer > 1. `kill(0)` signals the caller's whole
116
+ * process group, `kill(-1)` every process the user owns, pid 1 is init/launchd.
117
+ * @param {unknown} pid
118
+ * @returns {pid is number}
119
+ */
120
+ export const isSignalablePid = (pid) => Number.isInteger(pid) && /** @type {number} */ (pid) > 1;
121
+
122
+ /**
123
+ * @param {number | undefined} workerPid @param {StartTimeProbe} probe
124
+ * @returns {Promise<{pid: number, started_at: string} | null>}
125
+ */
126
+ async function pinWorker(workerPid, probe) {
127
+ if (workerPid === undefined) return null;
128
+ if (!isSignalablePid(workerPid)) throw new StateError('bad-worker-pid', 'worker pid must be an integer > 1');
129
+ const startedAt = await probe(workerPid);
130
+ if (startedAt === null) throw new StateError('worker-not-running', `worker pid ${workerPid} is not running`);
131
+ return { pid: workerPid, started_at: startedAt };
132
+ }
133
+
134
+ /**
135
+ * `run start`: record + key + worker pin + ledger `run.start`, all under the run lock. An
136
+ * existing run id is refused before anything is written (its key and record stay untouched).
137
+ * @param {{
138
+ * workspace: string, project: string, config?: Record<string, any>, engine?: string,
139
+ * runId?: string, workerPid?: number, orchestratorPid?: number, soloPid?: string | null,
140
+ * writeRow: WriteRow, probe?: StartTimeProbe, now?: Date,
141
+ * }} opts
142
+ * @returns {Promise<Record<string, any>>} the record
143
+ */
144
+ export async function startRun(opts) {
145
+ const { project, config = {}, writeRow, probe = processStartTime, now = new Date() } = opts;
146
+ const runId = assertRunId(opts.runId ?? newRunId(now));
147
+ if (typeof project !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(project)) {
148
+ throw new StateError('bad-project', 'project slug must match /^[a-z0-9][a-z0-9-]*$/ (config key project.slug)');
149
+ }
150
+ const workspace = assertWorkspaceOutsideRuns(opts.workspace);
151
+ const worker = await pinWorker(opts.workerPid, probe);
152
+ const record = {
153
+ version: 1,
154
+ run_id: runId,
155
+ project,
156
+ workspace,
157
+ engine: opts.engine ?? config.engine ?? 'auto',
158
+ provider: config.provider ?? null,
159
+ levels: config.levels ?? null,
160
+ fallback_mode: config.system1?.fallback ?? 'rules',
161
+ started_at: now.toISOString(),
162
+ orchestrator_pid: opts.orchestratorPid ?? process.pid,
163
+ solo_pid: opts.soloPid ?? null,
164
+ worker,
165
+ status: 'active',
166
+ blocks: {},
167
+ orphans: [],
168
+ };
169
+ return withRunLock(runId, async () => {
170
+ const exists = await readRun(runId).then(
171
+ () => true,
172
+ (err) => (err.code === 'no-run' ? false : Promise.reject(err)),
173
+ );
174
+ if (exists) throw new StateError('run-exists', `run ${runId} already exists`);
175
+ await generateKey(runId);
176
+ await saveRun(record);
177
+ await writeSigned(runId, writeRow, { event: 'run.start', engine: record.engine, worker_pinned: worker !== null });
178
+ return record;
179
+ });
180
+ }
181
+
182
+ /**
183
+ * `run start --reattach`: re-pin a restarted worker (the orchestrator only — §4.8).
184
+ * @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe}} opts
185
+ */
186
+ export async function reattachWorker({ runId, workerPid, writeRow, probe = processStartTime }) {
187
+ if (!isSignalablePid(workerPid)) throw new StateError('bad-worker-pid', 'worker pid must be an integer > 1');
188
+ return withRunLock(runId, async () => {
189
+ const current = await readRun(runId);
190
+ if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
191
+ current.worker = await pinWorker(workerPid, probe);
192
+ await writeSigned(runId, writeRow, { event: 'worker.reattach', pid: workerPid });
193
+ await saveRun(current);
194
+ return current;
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Does the live worker match the pin? `livePid` is the pid the caller sees serving the queue
200
+ * (B11's worker pid file); the pinned pid's start time is re-read in every case.
201
+ * @param {Record<string, any>} record
202
+ * @param {{livePid?: number, probe?: StartTimeProbe}} [opts]
203
+ * @returns {Promise<{ok: boolean, event?: 'worker.replaced' | 'worker.down', reason?: string}>}
204
+ */
205
+ export async function checkWorkerPin(record, { livePid, probe = processStartTime } = {}) {
206
+ const pin = record.worker;
207
+ if (!pin) return { ok: false, event: 'worker.down', reason: 'no worker is pinned for this run' };
208
+ if (!isSignalablePid(pin.pid)) return { ok: false, event: 'worker.replaced', reason: 'the pinned worker pid is not an integer > 1' };
209
+ if (livePid !== undefined && livePid !== pin.pid) {
210
+ return { ok: false, event: 'worker.replaced', reason: `live worker pid ${livePid} is not the pinned pid ${pin.pid}` };
211
+ }
212
+ const startedAt = await probe(pin.pid);
213
+ if (startedAt === null) return { ok: false, event: 'worker.down', reason: `pinned worker pid ${pin.pid} is not running` };
214
+ if (startedAt !== pin.started_at) {
215
+ return { ok: false, event: 'worker.replaced', reason: `pid ${pin.pid} was restarted (start time differs from the pin)` };
216
+ }
217
+ return { ok: true };
218
+ }
219
+
220
+ /**
221
+ * The default worker stopper: SIGTERM the pinned pid ONLY when it is an integer > 1 AND its start
222
+ * time, re-read right before the signal, still equals the pin (a recycled pid is never signalled).
223
+ * A worker that already exited (ESRCH) counts as not stopped, never as an error.
224
+ * @param {{pid: number, started_at: string} | null} pin
225
+ * @param {{probe?: StartTimeProbe, kill?: (pid: number, signal: NodeJS.Signals) => unknown}} [opts]
226
+ * @returns {Promise<{stopped: boolean, reason?: string}>}
227
+ */
228
+ export async function stopPinnedWorker(pin, { probe = processStartTime, kill = (pid, signal) => process.kill(pid, signal) } = {}) {
229
+ if (!pin || !isSignalablePid(pin.pid)) return { stopped: false, reason: 'invalid pid' };
230
+ const startedAt = await probe(pin.pid);
231
+ if (startedAt === null) return { stopped: false, reason: 'not running' };
232
+ if (startedAt !== pin.started_at) return { stopped: false, reason: 'pid reused' };
233
+ try {
234
+ kill(pin.pid, 'SIGTERM');
235
+ } catch (err) {
236
+ if (err.code === 'ESRCH') return { stopped: false, reason: 'already exited' };
237
+ throw err;
238
+ }
239
+ return { stopped: true };
240
+ }
241
+
242
+ /**
243
+ * `run end`, entirely under the run lock: refuse an ended run or open blocks; check the pin (a
244
+ * failed check is written as its own signed `worker.replaced`/`worker.down` row); stop the
245
+ * worker only when the pin verifies; mark ended and write `run.end`.
246
+ * @param {{
247
+ * runId: string, writeRow: WriteRow, probe?: StartTimeProbe,
248
+ * stopWorker?: (pin: {pid: number, started_at: string}, opts: {probe?: StartTimeProbe}) => Promise<{stopped: boolean}>,
249
+ * }} opts
250
+ */
251
+ export async function endRun({ runId, writeRow, stopWorker = stopPinnedWorker, probe = processStartTime }) {
252
+ return withRunLock(runId, async () => {
253
+ const current = await readRun(runId);
254
+ if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has already ended`);
255
+ const open = Object.keys(current.blocks).filter((id) => current.blocks[id].status === 'open');
256
+ if (open.length > 0) throw new StateError('open-blocks', `run ${runId} still has open blocks: ${open.join(', ')}`);
257
+ const pin = await checkWorkerPin(current, { probe });
258
+ if (!pin.ok) await writeSigned(runId, writeRow, { event: pin.event, reason: pin.reason });
259
+ const stop = pin.ok ? await stopWorker(current.worker, { probe }) : { stopped: false };
260
+ current.status = 'ended';
261
+ current.ended_at = new Date().toISOString();
262
+ await writeSigned(runId, writeRow, { event: 'run.end', worker_check: pin.ok ? 'ok' : pin.event, worker_stopped: stop.stopped });
263
+ await saveRun(current);
264
+ return current;
265
+ });
266
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Signed ledger rows (plan §4.8, §8.6, C4) — `mac = HMAC-SHA256(run key, canonical row)`.
3
+ *
4
+ * What this proves, stated as the plan states it: TAMPER EVIDENCE within a coder's ordinary tool
5
+ * use (T1 a row the worker never wrote, T2 a result edited after signing, T3 — with the row keyed
6
+ * by content hash upstream — a stale approval). It is NOT a same-user boundary: the key is a 0600
7
+ * file the same OS user can read, so a coder that deliberately `cat`s it can mint a valid MAC. The
8
+ * forbidden list (`code-forge-runs-access`) and the transcript grep make that a named rule-break;
9
+ * the real boundary (a second OS user or a container) is the consumer's choice.
10
+ *
11
+ * Canonical form: the row minus `UNSIGNED_FIELDS`, keys sorted recursively, `JSON.stringify`
12
+ * value encoding. `tokens_source`/`cost_source` are excluded because the ledger writer (B6
13
+ * `appendRow`) fills them in AFTER signing — they are labels, never gate inputs. `ts` IS signed,
14
+ * so `signRow` stamps it first (the writer keeps an existing `ts`).
15
+ */
16
+
17
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
18
+ import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
19
+ import { registerSecret } from '../util/redact.mjs';
20
+ import { StateError, runKeyPath, runsDir } from './paths.mjs';
21
+
22
+ export const KEY_BYTES = 32;
23
+
24
+ /** Fields the MAC does not cover (see the module doc). */
25
+ export const UNSIGNED_FIELDS = Object.freeze(['mac', 'tokens_source', 'cost_source']);
26
+
27
+ /**
28
+ * Create the run's key: 32 random bytes, mode 0600, in `~/.code-forge/runs/` (0700). Refuses to
29
+ * overwrite an existing key — key rotation is a new run.
30
+ * @param {string} runId
31
+ * @returns {Promise<Buffer>}
32
+ */
33
+ export async function generateKey(runId) {
34
+ await mkdir(runsDir(), { recursive: true, mode: 0o700 });
35
+ await chmod(runsDir(), 0o700); // `mode` only applies to a directory mkdir creates
36
+ const key = randomBytes(KEY_BYTES);
37
+ const file = runKeyPath(runId);
38
+ try {
39
+ await writeFile(file, key, { mode: 0o600, flag: 'wx' });
40
+ } catch (err) {
41
+ if (err.code === 'EEXIST') throw new StateError('key-exists', `run ${runId} already has a key`);
42
+ throw err;
43
+ }
44
+ await chmod(file, 0o600);
45
+ registerSecret(key.toString('hex'));
46
+ return key;
47
+ }
48
+
49
+ /**
50
+ * @param {string} runId
51
+ * @returns {Promise<Buffer>}
52
+ * @throws {StateError} `no-key` / `bad-key` / `bad-key-mode` (group- or world-accessible)
53
+ */
54
+ export async function loadKey(runId) {
55
+ let key;
56
+ try {
57
+ const { mode } = await stat(runKeyPath(runId));
58
+ if ((mode & 0o077) !== 0) throw new StateError('bad-key-mode', `run ${runId} signer key is accessible to other users (mode ${(mode & 0o777).toString(8)}); expected 600`);
59
+ key = await readFile(runKeyPath(runId));
60
+ } catch (err) {
61
+ if (err.code === 'ENOENT') throw new StateError('no-key', `run ${runId} has no signer key`);
62
+ throw err;
63
+ }
64
+ if (key.length !== KEY_BYTES) throw new StateError('bad-key', `run ${runId} signer key is not ${KEY_BYTES} bytes`);
65
+ registerSecret(key.toString('hex'));
66
+ return key;
67
+ }
68
+
69
+ /**
70
+ * @param {unknown} value
71
+ * @returns {string} JSON with object keys sorted at every depth.
72
+ */
73
+ export function canonicalJSON(value) {
74
+ if (Array.isArray(value)) return `[${value.map((v) => (v === undefined ? 'null' : canonicalJSON(v))).join(',')}]`;
75
+ if (value !== null && typeof value === 'object') {
76
+ const entries = Object.keys(value)
77
+ .sort()
78
+ .filter((k) => value[k] !== undefined)
79
+ .map((k) => `${JSON.stringify(k)}:${canonicalJSON(value[k])}`);
80
+ return `{${entries.join(',')}}`;
81
+ }
82
+ return JSON.stringify(value);
83
+ }
84
+
85
+ /**
86
+ * @param {Record<string, any>} row @param {Buffer} key
87
+ * @returns {string} hex MAC
88
+ */
89
+ export function macFor(row, key) {
90
+ // The JSON round trip is what the ledger stores: Date → ISO string, functions/undefined dropped;
91
+ // a BigInt throws (a row that cannot be written must not be signed).
92
+ const covered = JSON.parse(JSON.stringify(row));
93
+ for (const field of UNSIGNED_FIELDS) delete covered[field];
94
+ return createHmac('sha256', key).update(canonicalJSON(covered)).digest('hex');
95
+ }
96
+
97
+ /**
98
+ * @param {Record<string, any>} row @param {Buffer} key
99
+ * @returns {Record<string, any>} the row with `ts` stamped (when absent) and `mac` set.
100
+ */
101
+ export function signRow(row, key) {
102
+ /** @type {Record<string, any>} */
103
+ const stamped = { ...row, ts: row.ts ?? new Date().toISOString() };
104
+ delete stamped.mac;
105
+ return { ...stamped, mac: macFor(stamped, key) };
106
+ }
107
+
108
+ /**
109
+ * @param {Record<string, any>} row @param {Buffer} key
110
+ * @returns {{ok: boolean, reason?: 'unsigned' | 'mismatch'}}
111
+ */
112
+ export function verifyRow(row, key) {
113
+ if (typeof row?.mac !== 'string' || row.mac.length === 0) return { ok: false, reason: 'unsigned' };
114
+ if (!/^[0-9a-f]{64}$/.test(row.mac)) return { ok: false, reason: 'mismatch' };
115
+ const expected = Buffer.from(macFor(row, key), 'hex');
116
+ const given = Buffer.from(row.mac, 'hex');
117
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) return { ok: false, reason: 'mismatch' };
118
+ return { ok: true };
119
+ }
120
+
121
+ /**
122
+ * @param {Record<string, any>[]} rows @param {Buffer} key
123
+ * @returns {{ok: boolean, failures: {index: number, event: string, reason: string}[]}}
124
+ */
125
+ export function verifyRows(rows, key) {
126
+ const failures = [];
127
+ rows.forEach((row, index) => {
128
+ const result = verifyRow(row, key);
129
+ if (!result.ok) failures.push({ index, event: String(row?.event), reason: result.reason });
130
+ });
131
+ return { ok: failures.length === 0, failures };
132
+ }