codehere 0.8.0 → 1.0.1

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 (134) hide show
  1. package/README.md +22 -13
  2. package/dist/application/services/verdict-service.js +154 -0
  3. package/dist/application/use-cases/assess-cross-vendor.js +126 -0
  4. package/dist/application/use-cases/build-cross-vendor-verdict.js +118 -0
  5. package/dist/application/use-cases/export-aat.js +107 -0
  6. package/dist/application/use-cases/generate-acceptance-criteria.js +223 -0
  7. package/dist/application/use-cases/generate-final-report.js +140 -15
  8. package/dist/application/use-cases/generate-narration.js +5 -3
  9. package/dist/application/use-cases/generate-thread-title.js +5 -2
  10. package/dist/application/use-cases/plan-auto-review.js +57 -0
  11. package/dist/application/use-cases/reconcile-audit.js +100 -0
  12. package/dist/application/use-cases/rerun-tests.js +226 -0
  13. package/dist/application/use-cases/search-sessions.js +70 -0
  14. package/dist/application/use-cases/should-auto-review.js +71 -0
  15. package/dist/application/use-cases/thread-export.js +90 -0
  16. package/dist/application/use-cases/verify-claim.js +187 -5
  17. package/dist/domain/audit.js +11 -0
  18. package/dist/domain/services/codebase-detector.js +0 -6
  19. package/dist/domain/task.js +91 -0
  20. package/dist/error-handler.js +3 -115
  21. package/dist/index.js +269 -25
  22. package/dist/infrastructure/completion/bash-completion.js +7 -3
  23. package/dist/infrastructure/completion/fish-completion.js +7 -12
  24. package/dist/infrastructure/completion/zsh-completion.js +7 -12
  25. package/dist/infrastructure/config/config-manager.js +4 -3
  26. package/dist/infrastructure/config/product-context.js +1 -19
  27. package/dist/infrastructure/context/adapters/figma-source.js +37 -2
  28. package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
  29. package/dist/infrastructure/context/github-api.js +110 -0
  30. package/dist/infrastructure/context/github-device-flow.js +65 -0
  31. package/dist/infrastructure/context/oauth-store.js +5 -2
  32. package/dist/infrastructure/context/registry-bootstrap.js +0 -4
  33. package/dist/infrastructure/cost/budget-config.js +3 -2
  34. package/dist/infrastructure/cost/cost-router.js +16 -23
  35. package/dist/infrastructure/cost/spend-aggregator.js +90 -15
  36. package/dist/infrastructure/cost/team-ledger.js +355 -0
  37. package/dist/infrastructure/discovery/discovery-log.js +85 -0
  38. package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
  39. package/dist/infrastructure/hooks/claude-sdk-hook.js +133 -3
  40. package/dist/infrastructure/hooks/hook-mode.js +20 -4
  41. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
  42. package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
  43. package/dist/infrastructure/observability/execution-tracer.js +1 -1
  44. package/dist/infrastructure/orchestration/agents-config-seed.js +22 -10
  45. package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
  46. package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
  47. package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
  48. package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
  49. package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
  50. package/dist/infrastructure/orchestration/context-handoff.js +32 -14
  51. package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
  52. package/dist/infrastructure/orchestration/failover-config.js +0 -10
  53. package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
  54. package/dist/infrastructure/orchestration/generic-cli-adapter.js +132 -1
  55. package/dist/infrastructure/orchestration/graphify-context.js +14 -59
  56. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +164 -9
  57. package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
  58. package/dist/infrastructure/orchestration/progress-aggregator.js +238 -21
  59. package/dist/infrastructure/orchestration/session-manager.js +593 -94
  60. package/dist/infrastructure/orchestration/session-persistence.js +177 -41
  61. package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
  62. package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
  63. package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
  64. package/dist/infrastructure/protocols/mcp-server-impl.js +1462 -380
  65. package/dist/infrastructure/protocols/mcp-tools.js +401 -0
  66. package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
  67. package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
  68. package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
  69. package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
  70. package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
  71. package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
  72. package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
  73. package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
  74. package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
  75. package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
  76. package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
  77. package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
  78. package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
  79. package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
  80. package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
  81. package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
  82. package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
  83. package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
  84. package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
  85. package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
  86. package/dist/infrastructure/protocols/ui/styles.js +2147 -0
  87. package/dist/infrastructure/protocols/ui/tokens.js +96 -0
  88. package/dist/infrastructure/protocols/ui-page-v2.js +116 -4214
  89. package/dist/infrastructure/protocols/verify-page.js +849 -155
  90. package/dist/infrastructure/security/fix-code.js +144 -0
  91. package/dist/infrastructure/security/fix-secret.js +89 -0
  92. package/dist/infrastructure/security/permissions-config.js +3 -2
  93. package/dist/infrastructure/security/scan-types.js +10 -0
  94. package/dist/infrastructure/security/ship-scan.js +195 -0
  95. package/dist/infrastructure/security/trust-scorer.js +70 -3
  96. package/dist/infrastructure/security/vibe-checks.js +99 -0
  97. package/dist/infrastructure/storage/atomic-write.js +86 -0
  98. package/dist/infrastructure/storage/project-thread-mirror.js +150 -3
  99. package/dist/infrastructure/storage/project-thread-reader.js +261 -24
  100. package/dist/monitoring.js +2 -2
  101. package/dist/presentation/cli/commands/audit-command.js +79 -4
  102. package/dist/presentation/cli/commands/budget-command.js +2 -2
  103. package/dist/presentation/cli/commands/check-command.js +96 -0
  104. package/dist/presentation/cli/commands/context-source-command.js +20 -1
  105. package/dist/presentation/cli/commands/help-command.js +5 -5
  106. package/dist/presentation/cli/commands/hook-command.js +5 -5
  107. package/dist/presentation/cli/commands/init-command.js +26 -14
  108. package/dist/presentation/cli/commands/memory-command.js +28 -28
  109. package/dist/presentation/cli/commands/permission-command.js +1 -12
  110. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  111. package/dist/presentation/cli/commands/protocols-command.js +4 -4
  112. package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
  113. package/dist/presentation/cli/commands/report-command.js +48 -32
  114. package/dist/presentation/cli/commands/scan-command.js +52 -0
  115. package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
  116. package/dist/presentation/cli/commands/search-command.js +51 -0
  117. package/dist/presentation/cli/commands/setup-command.js +31 -8
  118. package/dist/presentation/cli/commands/status-command.js +39 -86
  119. package/dist/presentation/cli/commands/team-command.js +256 -0
  120. package/dist/presentation/cli/commands/thread-command.js +87 -5
  121. package/dist/presentation/cli/commands/trust-command.js +53 -31
  122. package/dist/presentation/cli/commands/verify-command.js +74 -9
  123. package/dist/presentation/cli/error-display.js +0 -8
  124. package/dist/presentation/cli/repl.js +15 -7
  125. package/dist/presentation/cli/task-deliverable.js +36 -10
  126. package/dist/testing/replay-adapter.js +0 -19
  127. package/dist/ui.js +51 -239
  128. package/dist/utils/version.js +0 -6
  129. package/package.json +30 -10
  130. package/dist/domain/entities/task.js +0 -61
  131. package/dist/infrastructure/protocols/ui-page.js +0 -2377
  132. package/dist/infrastructure/security/input-validator.js +0 -152
  133. package/dist/infrastructure/storage/embeddings-path.js +0 -37
  134. package/dist/infrastructure/storage/task-repository.js +0 -206
@@ -0,0 +1,336 @@
1
+ /**
2
+ * R2 · sign the live audit-chain TIP with the machine's persisted ed25519 key.
3
+ *
4
+ * The hash chain catches a NAIVE edit, but a determined attacker can edit a
5
+ * mid-chain entry and RE-WALK every later hash to produce a chain that verifies
6
+ * green (`computeEntryHash` is public). Signing the tip closes that for anyone
7
+ * who verifies with the public key: a re-walk changes the hash of the SIGNED
8
+ * entry, so the stored signature — made with a key the tamperer doesn't hold —
9
+ * no longer matches. Truncation below the signed point likewise fails.
10
+ *
11
+ * VERIFICATION IS AN ANCESTOR CHECK, not a tip-equality check (devil's-advocate
12
+ * audit 2026-06-09 caught the original tip-equality version false-alarming
13
+ * "tampered" on honest chains): the chain appends CONTINUOUSLY (every tool call,
14
+ * spawns, overrides) while signing happens at session completion — so the signed
15
+ * entry is usually an ANCESTOR of the live tip, and that is honest. Verification
16
+ * asserts the signed (seq, entryHash) still exists UNMODIFIED at that seq.
17
+ * Entries appended after the most recent signing are not yet covered — they're
18
+ * covered by the next completion's signature. The protected prefix only ever
19
+ * grows.
20
+ *
21
+ * `rm <audit>.tipsig` bypass (same audit): once a chain has EVER been signed, a
22
+ * missing signature file must BLOCK — otherwise the forger just deletes the
23
+ * sidecar. Sign-state lives in the keystore dir (`keys/audit-sign-state.json`),
24
+ * separate from the audit file, so an attacker who deletes the sidecar but not
25
+ * the keystore is caught. NOTE: sign-state is LOCAL-ONLY and path-keyed — it does
26
+ * NOT travel with an exported chain, so deleted-sidecar detection only works on
27
+ * the machine that signed; an exported/shared chain carries its `.tipsig` but a
28
+ * recipient can't tell it was ever signed if the sidecar is stripped. (An
29
+ * owner-level attacker who can also rewrite the keystore defeats this entirely —
30
+ * see HONEST SCOPE; the real fix is R1's daemon-held key + R3's external anchor.)
31
+ *
32
+ * R1 DOES NOT CLOSE THE OWNER GAP — evaluated 2026-08-08. A daemon-held key is
33
+ * still reachable by the owner, because the daemon runs as the owner. R1 raises
34
+ * the cost of the attack; it does not change who can defeat it. Only an EXTERNAL
35
+ * anchor does, and that collides head-on with local-first + zero telemetry: an
36
+ * anchor has to be outside the machine to be an anchor. Those two values are in
37
+ * direct tension and no amount of local cryptography resolves it.
38
+ *
39
+ * THE ANCHOR THAT FITS, and it needs no service of ours: the user's own git
40
+ * remote. `git notes --ref=codehere-audit` on HEAD carries the signed tip, and
41
+ * nothing leaves the machine until the USER pushes — which they were doing
42
+ * anyway. Proven end-to-end 2026-08-08 in a throwaway repo:
43
+ * · the commit SHA is unchanged and the working tree stays clean (non-invasive)
44
+ * · a history rewrite ORPHANS the note, so the rewrite is visible
45
+ * · after the owner deletes the note locally, the REMOTE still holds it and a
46
+ * reviewer can fetch it back and read the original tip hash
47
+ * · erasing the remote copy needs an explicit destructive push, which shows up
48
+ * in host audit logs / branch protection / anyone who already fetched
49
+ * So it converts "silently forgeable by the owner" into "forgeable only by a
50
+ * visible act on a third party the owner does not solely control" — the same
51
+ * standard a signed git commit gets, no more and no less. It is NOT
52
+ * cryptographic owner-proofing; nothing local-first can be. Known costs:
53
+ * refs/notes are not pushed by default (needs a refspec), and a user who never
54
+ * pushes has no witness. It also lands exactly on the PR surface.
55
+ * NOT BUILT — this is the evaluation, not the feature.
56
+ *
57
+ * HONEST SCOPE: this defends against a 3RD PARTY (or the owner sharing an export)
58
+ * editing the chain after it was signed. It does NOT defend against the agent or
59
+ * owner who CAN reach the signing key + keystore and re-sign / rewrite sign-state
60
+ * — that needs a daemon-held key the agent can't reach (R1) + an external anchor
61
+ * (R3). SIGNATURE-ORACLE limit (R1.3): a signature attests the daemon
62
+ * RECORDED the entry — never that the described tool call actually happened
63
+ * (any local process can POST an honest-shaped entry). Surface wording must
64
+ * stay "recorded-by-daemon"-shaped. The tip signature also TRANSITIVELY
65
+ * notarizes the whole prefix — including hook-direct entries and any
66
+ * well-chained same-uid direct append (even one carrying a forged
67
+ * writer:'daemon'; the endpoint's rejection binds only endpoint callers).
68
+ * Until R3 the honest words remain
69
+ * "integrity-checked" + "signed at
70
+ * write-time".
71
+ *
72
+ * Deliberately standalone (reads the chain from the file itself) so the audit
73
+ * hot-path in claude-sdk-hook.ts never has to import the signing stack — keeps
74
+ * the dependency graph acyclic.
75
+ */
76
+ import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync, openSync, fstatSync, readSync, closeSync, renameSync, } from 'fs';
77
+ import { join } from 'path';
78
+ import { sign as edSign, verify as edVerify } from 'crypto';
79
+ import { loadOrCreateKeypair, keysDir } from '../orchestration/thread-key-store.js';
80
+ import { exportPublicPem, importPublicPem, publicKeyToBase64, } from '../../application/use-cases/thread-export.js';
81
+ /** Read the audit chain's current tip (last real line's seq + entryHash).
82
+ * TAIL-READ (last 64KB), not a full-file read: sign-at-write (R1.3) calls
83
+ * this per daemon append on a file with no rotation — a full read is O(N)
84
+ * on the daemon event loop, measured 4-5ms on a 6.4MB chain vs 0.2ms for
85
+ * the tail (refuter B2). The tip is by definition in the tail. */
86
+ export function readAuditTip(auditFile) {
87
+ if (!existsSync(auditFile))
88
+ return null;
89
+ try {
90
+ const fd = openSync(auditFile, 'r');
91
+ let text;
92
+ try {
93
+ const size = fstatSync(fd).size;
94
+ const start = Math.max(0, size - 65536);
95
+ const buf = Buffer.alloc(size - start);
96
+ readSync(fd, buf, 0, buf.length, start);
97
+ text = buf.toString('utf-8');
98
+ if (start > 0)
99
+ text = text.slice(text.indexOf('\n') + 1); // drop partial first line
100
+ }
101
+ finally {
102
+ closeSync(fd);
103
+ }
104
+ const lines = text.trimEnd().split('\n');
105
+ for (let i = lines.length - 1; i >= 0; i--) {
106
+ const line = lines[i].trim();
107
+ if (!line)
108
+ continue;
109
+ const e = JSON.parse(line);
110
+ if (typeof e.seq === 'number' && typeof e.entryHash === 'string') {
111
+ return { seq: e.seq, entryHash: e.entryHash };
112
+ }
113
+ return null; // last real line is a pre-chain legacy entry → no tip to sign
114
+ }
115
+ }
116
+ catch {
117
+ /* unreadable / malformed → no tip */
118
+ }
119
+ return null;
120
+ }
121
+ /** Read the entry hash recorded at a given seq, or null if no such entry. */
122
+ function readEntryHashAtSeq(auditFile, seq) {
123
+ if (!existsSync(auditFile))
124
+ return null;
125
+ try {
126
+ for (const line of readFileSync(auditFile, 'utf-8').split('\n')) {
127
+ const t = line.trim();
128
+ if (!t)
129
+ continue;
130
+ const e = JSON.parse(t);
131
+ if (e.seq === seq && typeof e.entryHash === 'string')
132
+ return e.entryHash;
133
+ }
134
+ }
135
+ catch {
136
+ /* malformed line mid-file → treat as not found (the chain verifier reports it) */
137
+ }
138
+ return null;
139
+ }
140
+ function tipSigPath(auditFile) {
141
+ return auditFile + '.tipsig';
142
+ }
143
+ function tipMessage(tip) {
144
+ return Buffer.from(`${tip.seq}:${tip.entryHash}`, 'utf-8');
145
+ }
146
+ function signStatePath() {
147
+ return join(keysDir(), 'audit-sign-state.json');
148
+ }
149
+ function loadSignState() {
150
+ try {
151
+ const raw = JSON.parse(readFileSync(signStatePath(), 'utf-8'));
152
+ if (raw && raw.schema === 1 && typeof raw.files === 'object')
153
+ return raw;
154
+ }
155
+ catch {
156
+ /* missing/corrupt → empty */
157
+ }
158
+ return { schema: 1, files: {} };
159
+ }
160
+ function recordSignState(auditFile, seq, signedAt) {
161
+ try {
162
+ mkdirSync(keysDir(), { recursive: true });
163
+ const state = loadSignState();
164
+ state.files[auditFile] = { seq, signedAt };
165
+ {
166
+ const tmpState = signStatePath() + '.tmp';
167
+ writeFileSync(tmpState, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
168
+ renameSync(tmpState, signStatePath());
169
+ }
170
+ }
171
+ catch {
172
+ /* best-effort — sign-state is a detection aid, never blocks signing */
173
+ }
174
+ }
175
+ /** Was this audit file ever signed (per the keystore's sign-state)? */
176
+ export function chainWasSigned(auditFile) {
177
+ return !!loadSignState().files[auditFile];
178
+ }
179
+ /**
180
+ * S9 · forget a chain's sign-state entry. ONLY for the mock practice chain:
181
+ * the daemon wipes the mock chain at boot, and without pruning, the stale
182
+ * sign-state key would flag the fresh chain as an rm-bypass (a false tamper
183
+ * alarm in practice mode) while dead keys accumulate in the REAL keystore.
184
+ * Never call this for a real chain — losing sign-state is losing detection.
185
+ */
186
+ export function pruneSignState(auditFile) {
187
+ try {
188
+ const state = loadSignState();
189
+ if (!state.files[auditFile])
190
+ return;
191
+ delete state.files[auditFile];
192
+ const tmpState = signStatePath() + '.tmp';
193
+ writeFileSync(tmpState, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
194
+ renameSync(tmpState, signStatePath());
195
+ }
196
+ catch {
197
+ /* best-effort — same contract as recordSignState */
198
+ }
199
+ }
200
+ // Cache the keypair per-process — loadOrCreateKeypair does disk I/O each call,
201
+ // and signing happens repeatedly in a long-lived daemon.
202
+ let cachedKeys = null;
203
+ function keys() {
204
+ return (cachedKeys ??= loadOrCreateKeypair());
205
+ }
206
+ /**
207
+ * Sign the current chain tip, writing `<auditFile>.tipsig` + recording
208
+ * sign-state in the keystore. Best-effort: returns null (no-op) when there's
209
+ * no chain yet. Never throws.
210
+ */
211
+ export function signAuditTip(auditFile, now = Date.now()) {
212
+ const tip = readAuditTip(auditFile);
213
+ if (!tip)
214
+ return null;
215
+ try {
216
+ const k = keys();
217
+ const sig = edSign(null, tipMessage(tip), k.privateKey).toString('base64');
218
+ const rec = {
219
+ schema: 1,
220
+ alg: 'ed25519',
221
+ seq: tip.seq,
222
+ entryHash: tip.entryHash,
223
+ sig,
224
+ pubPem: exportPublicPem(k.publicKey),
225
+ signedAt: now,
226
+ };
227
+ // Atomic (tmp+rename): per-write signing multiplies rewrite frequency
228
+ // ~1000x vs per-session — a crash mid-write must not leave a corrupt
229
+ // .tipsig that fails honest verification (refuter N1).
230
+ const tmpSig = tipSigPath(auditFile) + '.tmp';
231
+ writeFileSync(tmpSig, JSON.stringify(rec) + '\n', { mode: 0o600 });
232
+ renameSync(tmpSig, tipSigPath(auditFile));
233
+ try {
234
+ chmodSync(tipSigPath(auditFile), 0o600);
235
+ }
236
+ catch {
237
+ /* perms best-effort */
238
+ }
239
+ recordSignState(auditFile, tip.seq, now);
240
+ return rec;
241
+ }
242
+ catch {
243
+ return null; // signing must never break the path that called it
244
+ }
245
+ }
246
+ /**
247
+ * Verify the tip signature against the chain. The signed (seq, entryHash) must
248
+ * still be an UNMODIFIED ancestor of the current chain — appends since signing
249
+ * are honest and expected (signing happens at session completion; the chain
250
+ * grows continuously between completions). Pass `trustedKeys` to also check the
251
+ * signer is known.
252
+ */
253
+ export function verifyAuditTip(auditFile, trustedKeys) {
254
+ const base = {
255
+ present: false,
256
+ expectedButMissing: false,
257
+ signatureValid: false,
258
+ ancestorIntact: false,
259
+ matchesTip: false,
260
+ trusted: false,
261
+ signerKeyB64: null,
262
+ reason: '',
263
+ };
264
+ if (!existsSync(tipSigPath(auditFile))) {
265
+ if (chainWasSigned(auditFile)) {
266
+ return {
267
+ ...base,
268
+ expectedButMissing: true,
269
+ reason: 'this chain was previously signed but the signature file is GONE — it may have been deleted to hide an edit',
270
+ };
271
+ }
272
+ return { ...base, reason: 'no tip signature on this chain' };
273
+ }
274
+ let rec;
275
+ try {
276
+ rec = JSON.parse(readFileSync(tipSigPath(auditFile), 'utf-8'));
277
+ }
278
+ catch {
279
+ return { ...base, present: true, reason: 'tip signature file is unreadable or corrupt' };
280
+ }
281
+ if (rec.alg !== 'ed25519' || typeof rec.entryHash !== 'string' || typeof rec.seq !== 'number') {
282
+ return { ...base, present: true, reason: 'tip signature is malformed' };
283
+ }
284
+ base.present = true;
285
+ let pub;
286
+ try {
287
+ pub = importPublicPem(rec.pubPem);
288
+ }
289
+ catch {
290
+ return { ...base, reason: 'tip signature carries an invalid public key' };
291
+ }
292
+ base.signerKeyB64 = publicKeyToBase64(pub);
293
+ base.trusted = !trustedKeys || trustedKeys.has(base.signerKeyB64);
294
+ try {
295
+ base.signatureValid = edVerify(null, tipMessage({ seq: rec.seq, entryHash: rec.entryHash }), pub, Buffer.from(rec.sig, 'base64'));
296
+ }
297
+ catch {
298
+ base.signatureValid = false;
299
+ }
300
+ const hashAtSeq = readEntryHashAtSeq(auditFile, rec.seq);
301
+ base.ancestorIntact = hashAtSeq !== null && hashAtSeq === rec.entryHash;
302
+ const tip = readAuditTip(auditFile);
303
+ base.matchesTip = !!tip && tip.seq === rec.seq && tip.entryHash === rec.entryHash;
304
+ if (!base.signatureValid)
305
+ base.reason = 'tip signature is invalid — forged or corrupt';
306
+ else if (!base.ancestorIntact)
307
+ base.reason =
308
+ 'the signed entry is no longer intact in the chain — it was edited (re-walked) or truncated after signing';
309
+ else if (!base.trusted)
310
+ base.reason = 'chain is signed and intact, but by an untrusted key';
311
+ else if (base.matchesTip)
312
+ base.reason = "chain tip is signed and matches — no edits by anyone without this machine's key";
313
+ else
314
+ base.reason = `signed at seq ${rec.seq} · the chain has grown since · the signed prefix is intact`;
315
+ return base;
316
+ }
317
+ /**
318
+ * Does the tip-signature state BLOCK verification? Blocks when:
319
+ * - sign-state says the chain was signed but the .tipsig is GONE (the
320
+ * delete-the-sidecar bypass), or
321
+ * - a signature is present but invalid, its signed entry is no longer an
322
+ * intact ancestor (edit/re-walk/truncation), or the signer is untrusted
323
+ * (an attacker re-signing a forgery with their OWN key would otherwise pass).
324
+ * A never-signed chain does not block (pre-R2 chains stay valid). Kept pure —
325
+ * this is the verdict the `audit verify` exit code depends on.
326
+ */
327
+ export function tipBlocksVerification(tip) {
328
+ if (tip.expectedButMissing)
329
+ return true;
330
+ return tip.present && (!tip.signatureValid || !tip.ancestorIntact || !tip.trusted);
331
+ }
332
+ /** Reset the per-process key cache (tests). */
333
+ export function __resetTipKeyCache() {
334
+ cachedKeys = null;
335
+ }
336
+ //# sourceMappingURL=audit-tip-signature.js.map
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, readSync, statSync, unlinkSync, } from 'fs';
17
17
  import { join } from 'path';
18
- import { homedir } from 'os';
18
+ import { homedir, tmpdir } from 'os';
19
19
  import { createHash } from 'crypto';
20
20
  // P18 slice 2 — Tier 1 hook honors per-cwd permissions.yaml.
21
21
  import { filterAllowed } from '../security/permissions-config.js';
@@ -27,6 +27,18 @@ import { filterAllowed } from '../security/permissions-config.js';
27
27
  * two banks are scanned in different passes and counted separately.
28
28
  */
29
29
  export const PATTERNS = [
30
+ // Sensitive-destination writes (MED #18, atomic audit) · the Write/Edit
31
+ // scan payload carries ONLY the destination path (content is data) — but
32
+ // no pattern matched a bare path, so a Write straight at ~/.ssh/id_rsa
33
+ // scanned clean. Match the canonical credential/key destinations in a
34
+ // file_path-shaped payload. Path-only by design: a Bash command touching
35
+ // these is caught by the command patterns below.
36
+ {
37
+ regex: /file_path[^}]{0,200}?(\.ssh\/(id_[a-z0-9]+|authorized_keys|known_hosts)|\.aws\/credentials|\.gnupg\/|\.netrc|\.npmrc|\.pypirc|\.docker\/config\.json|\.kube\/config|\.git-credentials)/i,
38
+ severity: 'critical',
39
+ category: 'Sensitive File Write',
40
+ description: 'Write/Edit aimed at a credential or key file (~/.ssh, ~/.aws/credentials, …)',
41
+ },
30
42
  // Prompt Injection
31
43
  // Allow chained modifiers: "ignore all previous instructions", "ignore all your prior rules", etc.
32
44
  {
@@ -402,8 +414,20 @@ export function calculateRiskScore(findings) {
402
414
  * printed "Real ~/.codehere untouched" (the footer showed 162 entries · 51
403
415
  * agents in a brand-new home — all of them pollution from past mock runs).
404
416
  */
417
+ /**
418
+ * S9 · practice mode leaves no marks: a CODEHERE_MOCK=1 process never writes
419
+ * the REAL audit chain. Mock isolated spend but not the chain — the atomic
420
+ * audit proved it by landing probe entries in ~/.codehere (seq ~11862). The
421
+ * mock chain lives at a STABLE tmp path (one sign-state key, not one per
422
+ * boot) and the daemon wipes it + prunes its sign-state key at boot, so
423
+ * every practice session starts clean and leaves zero residue. An explicit
424
+ * CODEHERE_AUDIT_FILE still wins (tests point it at their own scratch).
425
+ */
426
+ export const MOCK_AUDIT_FILE = join(tmpdir(), 'codehere-mock', 'audit', 'hook-events.jsonl');
405
427
  export const AUDIT_FILE = process.env.CODEHERE_AUDIT_FILE ||
406
- join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'audit', 'hook-events.jsonl');
428
+ (process.env.CODEHERE_MOCK === '1'
429
+ ? MOCK_AUDIT_FILE
430
+ : join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'audit', 'hook-events.jsonl'));
407
431
  const GENESIS_PREV_HASH = '0'.repeat(64);
408
432
  /**
409
433
  * Canonicalize an audit entry for hashing. We sort keys alphabetically and
@@ -520,7 +544,102 @@ export function readChainTip(filepath, tailBytes = 65536) {
520
544
  // Pathological: no chain entry in the tail window. Pay the full read.
521
545
  return readChainTipFull(filepath);
522
546
  }
547
+ /**
548
+ * R1.2 · hook-process audit queue. The PreToolUse hook runs as its own
549
+ * process (codehere scan-tool); its DECISION path is synchronous, so it can't
550
+ * await the daemon there. Instead: the scan-tool command enables queue mode
551
+ * at start, every append in the hook process queues instead of writing, and
552
+ * the command FLUSHES before exit — each entry is offered to the daemon
553
+ * (POST /api/audit/append, 250ms abort so a hung daemon can never stall the
554
+ * user's tool call; connection-refused costs ~25ms in a fresh hook process —
555
+ * undici's lazy init — and <2ms warm) and falls back to the direct append
556
+ * marked writer:'hook-direct' (visible provenance, never silent). The abort
557
+ * is CLIENT-side: the daemon may have written before a timeout fired, so the
558
+ * fallback DEDUPES against the audit tail on (timestamp, sessionId, toolName)
559
+ * — one tool call must never mint two audit rows (refuter-demonstrated).
560
+ * Fail-open end to end per core value #5. Known durability delta: entries
561
+ * sit in memory between append and flush; a kill mid-flush can lose one —
562
+ * the old write-at-append path had no such window (accepted, bounded ≤2
563
+ * entries). The daemon process never enables queue mode.
564
+ */
565
+ let hookQueueMode = false;
566
+ const hookQueue = [];
567
+ export function enableHookAuditQueue() {
568
+ hookQueueMode = true;
569
+ }
570
+ /** @internal test seam — inspect/clear the queue without flushing. */
571
+ export function __testOnlyHookQueue() {
572
+ return hookQueue;
573
+ }
574
+ export async function flushHookAuditQueue(daemonUrl = 'http://127.0.0.1:3773') {
575
+ while (hookQueue.length > 0) {
576
+ const entry = hookQueue.shift();
577
+ let viaDaemon;
578
+ try {
579
+ const res = await fetch(daemonUrl + '/api/audit/append', {
580
+ method: 'POST',
581
+ headers: { 'content-type': 'application/json' },
582
+ // Declare which audit file this hook process is recording into. A
583
+ // daemon writing a DIFFERENT file (an isolated test, a second home)
584
+ // is not the right recorder for this entry — it 409s and we fall back
585
+ // direct, so routing can never silently divert entries to the wrong
586
+ // chain (caught by hook-e2e while a real daemon was live).
587
+ body: JSON.stringify({ ...entry, auditFile: AUDIT_FILE }),
588
+ signal: AbortSignal.timeout(250),
589
+ });
590
+ viaDaemon = res.ok;
591
+ }
592
+ catch {
593
+ viaDaemon = false; // daemon down / slow / refused — fall back, never throw
594
+ }
595
+ if (!viaDaemon) {
596
+ try {
597
+ if (!auditTailHas(entry)) {
598
+ appendAuditLogDirect({ ...entry, writer: 'hook-direct' });
599
+ }
600
+ }
601
+ catch {
602
+ /* audit failure must never break the hook */
603
+ }
604
+ }
605
+ }
606
+ }
607
+ /** True if an entry with the same (timestamp, sessionId, toolName) is already
608
+ * in the audit tail — the daemon may have written it before a client-side
609
+ * abort/500. Scans the last 10 lines (same window as the daemon's confirm). */
610
+ function auditTailHas(entry) {
611
+ try {
612
+ if (!existsSync(AUDIT_FILE))
613
+ return false;
614
+ const lines = readFileSync(AUDIT_FILE, 'utf-8').trim().split('\n');
615
+ for (let i = lines.length - 1; i >= 0 && i >= lines.length - 10; i--) {
616
+ try {
617
+ const e = JSON.parse(lines[i]);
618
+ if (e.timestamp === entry.timestamp && e.sessionId === entry.sessionId && e.toolName === entry.toolName) {
619
+ return true;
620
+ }
621
+ }
622
+ catch {
623
+ /* skip malformed */
624
+ }
625
+ }
626
+ }
627
+ catch {
628
+ /* unreadable → treat as absent (the append itself is fail-open) */
629
+ }
630
+ return false;
631
+ }
523
632
  export function appendAuditLog(entry) {
633
+ if (hookQueueMode) {
634
+ hookQueue.push(entry);
635
+ return;
636
+ }
637
+ appendAuditLogDirect(entry);
638
+ }
639
+ /** The direct writer — the DAEMON path uses this explicitly (it IS the sole
640
+ * writer; queue mode must never be able to defer it, even if both layers
641
+ * ever share a process — the embedded-server tests do exactly that). */
642
+ export function appendAuditLogDirect(entry) {
524
643
  // Cross-process file locking. Multiple Claude Code sessions (e.g., two
525
644
  // terminals, or IDE + terminal) can trigger PreToolUse concurrently.
526
645
  // Without a lock, two hook processes read the same chain tip and both
@@ -612,6 +731,7 @@ export function verifyAuditChain(filepath = AUDIT_FILE) {
612
731
  totalEntries: 0,
613
732
  chainedEntries: 0,
614
733
  legacyEntries: 0,
734
+ writerMix: { daemon: 0, hookDirect: 0, unattributed: 0 },
615
735
  errors: [],
616
736
  };
617
737
  if (!existsSync(filepath))
@@ -621,6 +741,9 @@ export function verifyAuditChain(filepath = AUDIT_FILE) {
621
741
  content = readFileSync(filepath, 'utf-8');
622
742
  }
623
743
  catch {
744
+ // The log exists but can't be read — that is an unknown state, not "intact".
745
+ result.valid = false;
746
+ result.errors.push({ lineNumber: 0, reason: 'unreadable', detail: 'audit log exists but could not be read' });
624
747
  return result;
625
748
  }
626
749
  const lines = content.split('\n');
@@ -651,6 +774,13 @@ export function verifyAuditChain(filepath = AUDIT_FILE) {
651
774
  continue;
652
775
  }
653
776
  result.chainedEntries++;
777
+ const w = entry.writer;
778
+ if (w === 'daemon')
779
+ result.writerMix.daemon++;
780
+ else if (w === 'hook-direct')
781
+ result.writerMix.hookDirect++;
782
+ else
783
+ result.writerMix.unattributed++;
654
784
  // Recompute entryHash and check it matches what was stored
655
785
  const recomputed = computeEntryHash(entry);
656
786
  if (recomputed !== entry.entryHash) {
@@ -825,7 +955,7 @@ export function processHookEvent(event) {
825
955
  criticalFindings: criticalFindings.length,
826
956
  };
827
957
  if (allowed.length > 0) {
828
- // Add to the audit row's toolInput JSON so /history can render
958
+ // Add to the audit row's toolInput JSON so the audit feed can render
829
959
  // "X findings allow-listed by permissions.yaml" badges later.
830
960
  try {
831
961
  const parsed = JSON.parse(auditEntry.toolInput);
@@ -6,7 +6,11 @@
6
6
  *
7
7
  * off — no codehere scan-tool entry in PreToolUse
8
8
  * log-only — `codehere scan-tool --log-only`
9
- * advisory — reserved for future; not currently shipped
9
+ * advisory — DETECTION ONLY (LOW #20, atomic audit): getHookMode
10
+ * recognizes a `--advisory` flag for forward-compat, but
11
+ * scan-tool implements no advisory posture — a user writing
12
+ * --advisory gets the DEFAULT block behavior while the nav
13
+ * indicator would read 'advisory'. Don't advertise the flag.
10
14
  * block — `codehere scan-tool` (default critical/high block)
11
15
  * OR `codehere scan-tool --block-all` (all severity levels)
12
16
  *
@@ -17,18 +21,30 @@
17
21
  import { existsSync, readFileSync } from 'fs';
18
22
  import { join } from 'path';
19
23
  import { homedir } from 'os';
20
- const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
24
+ /**
25
+ * Resolve at call time, not at import.
26
+ *
27
+ * This was a module-level const, which froze the path to whatever HOME happened to be when the
28
+ * module first loaded. For a one-shot CLI that is harmless — HOME does not move mid-process —
29
+ * but it made the safety-mode read untestable against an isolated home, and the test that
30
+ * covered it compensated by writing to the developer's REAL ~/.claude/settings.json instead.
31
+ * A safety feature whose only test edits the user's live safety config is not covered, it is
32
+ * gambled with, and on CI (no ~/.claude at all) it failed outright.
33
+ *
34
+ * A function costs one stat call per invocation and makes the module honest under any HOME.
35
+ */
36
+ const settingsPath = () => join(homedir(), '.claude', 'settings.json');
21
37
  /**
22
38
  * Detect the current safety-hook mode from ~/.claude/settings.json.
23
39
  * Defensive against missing / malformed settings — anything we can't
24
40
  * parse cleanly returns 'off' (the most-conservative read).
25
41
  */
26
42
  export function getHookMode() {
27
- if (!existsSync(SETTINGS_PATH))
43
+ if (!existsSync(settingsPath()))
28
44
  return 'off';
29
45
  let parsed;
30
46
  try {
31
- parsed = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8'));
47
+ parsed = JSON.parse(readFileSync(settingsPath(), 'utf-8'));
32
48
  }
33
49
  catch {
34
50
  return 'off';
@@ -10,10 +10,11 @@
10
10
  * Future slices (per-adapter blocking) replace the post-hoc mirror
11
11
  * with pre-call intercepts. For now: visibility today, blocking next.
12
12
  */
13
- import { scanPayload, calculateRiskScore, appendAuditLog } from './claude-sdk-hook.js';
13
+ import { scanPayload, calculateRiskScore, appendAuditLog, AUDIT_FILE } from './claude-sdk-hook.js';
14
+ import { signAuditTip } from './audit-tip-signature.js';
14
15
  export function mirrorToolCallToAudit(rec) {
15
16
  // Run the same pattern bank the per-tool hook uses, so post-hoc
16
- // findings show up in /history with the same severity calibration.
17
+ // findings show up in the audit feed with the same severity calibration.
17
18
  const findings = scanPayload(rec.toolInput);
18
19
  const riskScore = calculateRiskScore(findings);
19
20
  const truncated = rec.toolInput.length > 500 ? rec.toolInput.slice(0, 500) + '…' : rec.toolInput;
@@ -32,9 +33,14 @@ export function mirrorToolCallToAudit(rec) {
32
33
  blocked: false, // post-hoc — can't block, only log
33
34
  findings: findings.length,
34
35
  criticalFindings: findings.filter((f) => f.severity === 'critical').length,
36
+ // R1.5 · the mirror runs IN the daemon — stamp the provenance it promises.
37
+ writer: 'daemon',
35
38
  };
36
39
  try {
37
40
  appendAuditLog(entry);
41
+ // R1.3 · sign-at-write — the daemon's mirror is a daemon append; advance
42
+ // the signed tip per write (sub-ms, fail-open).
43
+ signAuditTip(AUDIT_FILE);
38
44
  }
39
45
  catch {
40
46
  // Audit-write failures don't propagate — the agent's already running.
@@ -20,10 +20,10 @@
20
20
  * off → no scan, spawn proceeds
21
21
  *
22
22
  * Every scan writes one audit-log entry with synthetic toolName
23
- * 'pre_prompt' so the existing /history feed + hash chain pick it up
23
+ * 'pre_prompt' so the audit feed + hash chain pick it up
24
24
  * without surface changes.
25
25
  */
26
- import { scanPayload, calculateRiskScore, appendAuditLog, } from './claude-sdk-hook.js';
26
+ import { scanPayload, calculateRiskScore, appendAuditLog } from './claude-sdk-hook.js';
27
27
  import { getHookMode } from './hook-mode.js';
28
28
  import { filterAllowed } from '../security/permissions-config.js';
29
29
  /**
@@ -58,7 +58,7 @@ export function scanPromptBeforeSpawn(input) {
58
58
  const wouldBlock = hardFindings.length > 0;
59
59
  const blocked = mode === 'block' && wouldBlock;
60
60
  // Audit entry — written for every scan, regardless of mode. Synthetic
61
- // toolName 'pre_prompt' lets /history filter chips group these
61
+ // toolName 'pre_prompt' lets audit-feed filters group these
62
62
  // separately from per-tool entries (existing chip filters on tool).
63
63
  const entry = {
64
64
  timestamp: new Date().toISOString(),
@@ -133,7 +133,7 @@ export class ExecutionTracer {
133
133
  const trace = run.traces[index];
134
134
  console.log(`\n[${index + 1}/${run.traces.length}] ${trace.operationType}: ${trace.operationName}`);
135
135
  if (trace.error) {
136
- console.log(` ⚠️ Original error: ${trace.error.message}`);
136
+ console.log(` ! Original error: ${trace.error.message}`);
137
137
  // Replay error trace
138
138
  this.trace({
139
139
  operationType: trace.operationType,