codehere 0.7.1 → 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.
- package/README.md +22 -13
- package/dist/application/services/verdict-service.js +154 -0
- package/dist/application/use-cases/assess-cross-vendor.js +126 -0
- package/dist/application/use-cases/build-cross-vendor-verdict.js +118 -0
- package/dist/application/use-cases/export-aat.js +107 -0
- package/dist/application/use-cases/generate-acceptance-criteria.js +223 -0
- package/dist/application/use-cases/generate-final-report.js +140 -15
- package/dist/application/use-cases/generate-narration.js +5 -3
- package/dist/application/use-cases/generate-thread-title.js +5 -2
- package/dist/application/use-cases/narrator-prompt-template.js +1 -1
- package/dist/application/use-cases/plan-auto-review.js +57 -0
- package/dist/application/use-cases/reconcile-audit.js +100 -0
- package/dist/application/use-cases/rerun-tests.js +226 -0
- package/dist/application/use-cases/search-sessions.js +70 -0
- package/dist/application/use-cases/should-auto-review.js +71 -0
- package/dist/application/use-cases/thread-export.js +90 -0
- package/dist/application/use-cases/verify-claim.js +187 -5
- package/dist/domain/audit.js +11 -0
- package/dist/domain/services/codebase-detector.js +0 -6
- package/dist/domain/task.js +91 -0
- package/dist/error-handler.js +3 -115
- package/dist/index.js +279 -31
- package/dist/infrastructure/completion/bash-completion.js +7 -3
- package/dist/infrastructure/completion/fish-completion.js +7 -12
- package/dist/infrastructure/completion/zsh-completion.js +7 -12
- package/dist/infrastructure/config/config-manager.js +4 -3
- package/dist/infrastructure/config/product-context.js +1 -19
- package/dist/infrastructure/context/adapters/figma-source.js +37 -2
- package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
- package/dist/infrastructure/context/github-api.js +110 -0
- package/dist/infrastructure/context/github-device-flow.js +65 -0
- package/dist/infrastructure/context/oauth-store.js +5 -2
- package/dist/infrastructure/context/registry-bootstrap.js +0 -4
- package/dist/infrastructure/cost/budget-config.js +4 -3
- package/dist/infrastructure/cost/cost-router.js +16 -23
- package/dist/infrastructure/cost/spend-aggregator.js +91 -16
- package/dist/infrastructure/cost/team-ledger.js +355 -0
- package/dist/infrastructure/discovery/discovery-log.js +85 -0
- package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +139 -3
- package/dist/infrastructure/hooks/hook-mode.js +20 -4
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
- package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
- package/dist/infrastructure/observability/execution-tracer.js +1 -1
- package/dist/infrastructure/orchestration/agents-config-seed.js +23 -11
- package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
- package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
- package/dist/infrastructure/orchestration/cassette-recorder.js +1 -1
- package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
- package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
- package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
- package/dist/infrastructure/orchestration/context-handoff.js +32 -14
- package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
- package/dist/infrastructure/orchestration/failover-config.js +1 -11
- package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +134 -3
- package/dist/infrastructure/orchestration/graphify-context.js +14 -59
- package/dist/infrastructure/orchestration/memory-injector.js +4 -1
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +167 -9
- package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
- package/dist/infrastructure/orchestration/progress-aggregator.js +275 -23
- package/dist/infrastructure/orchestration/session-manager.js +593 -94
- package/dist/infrastructure/orchestration/session-persistence.js +177 -41
- package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
- package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
- package/dist/infrastructure/protocols/cloud-sync.js +2 -2
- package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
- package/dist/infrastructure/protocols/mcp-server-impl.js +1498 -354
- package/dist/infrastructure/protocols/mcp-tools.js +401 -0
- package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
- package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
- package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
- package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
- package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
- package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
- package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
- package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
- package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
- package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
- package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
- package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
- package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
- package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
- package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
- package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
- package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
- package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
- package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
- package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
- package/dist/infrastructure/protocols/ui/styles.js +2147 -0
- package/dist/infrastructure/protocols/ui/tokens.js +96 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +116 -4126
- package/dist/infrastructure/protocols/verify-page.js +884 -148
- package/dist/infrastructure/security/fix-code.js +144 -0
- package/dist/infrastructure/security/fix-secret.js +89 -0
- package/dist/infrastructure/security/permissions-config.js +3 -2
- package/dist/infrastructure/security/scan-types.js +10 -0
- package/dist/infrastructure/security/ship-scan.js +195 -0
- package/dist/infrastructure/security/trust-scorer.js +70 -3
- package/dist/infrastructure/security/vibe-checks.js +99 -0
- package/dist/infrastructure/storage/atomic-write.js +86 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +151 -4
- package/dist/infrastructure/storage/project-thread-reader.js +262 -25
- package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
- package/dist/monitoring.js +2 -2
- package/dist/presentation/cli/commands/audit-command.js +79 -4
- package/dist/presentation/cli/commands/budget-command.js +2 -2
- package/dist/presentation/cli/commands/check-command.js +96 -0
- package/dist/presentation/cli/commands/context-source-command.js +20 -1
- package/dist/presentation/cli/commands/help-command.js +5 -5
- package/dist/presentation/cli/commands/hook-command.js +5 -5
- package/dist/presentation/cli/commands/init-command.js +89 -66
- package/dist/presentation/cli/commands/memory-command.js +28 -28
- package/dist/presentation/cli/commands/permission-command.js +1 -12
- package/dist/presentation/cli/commands/protocols-builder.js +2 -2
- package/dist/presentation/cli/commands/protocols-command.js +4 -4
- package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
- package/dist/presentation/cli/commands/report-command.js +48 -32
- package/dist/presentation/cli/commands/scan-command.js +52 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
- package/dist/presentation/cli/commands/search-command.js +51 -0
- package/dist/presentation/cli/commands/setup-command.js +31 -8
- package/dist/presentation/cli/commands/status-command.js +39 -86
- package/dist/presentation/cli/commands/team-command.js +256 -0
- package/dist/presentation/cli/commands/thread-command.js +87 -5
- package/dist/presentation/cli/commands/trust-command.js +53 -31
- package/dist/presentation/cli/commands/verify-command.js +74 -9
- package/dist/presentation/cli/error-display.js +0 -8
- package/dist/presentation/cli/repl.js +15 -7
- package/dist/presentation/cli/task-deliverable.js +36 -10
- package/dist/testing/replay-adapter.js +0 -19
- package/dist/ui.js +51 -239
- package/dist/utils/version.js +0 -6
- package/package.json +30 -10
- package/dist/domain/entities/task.js +0 -61
- package/dist/infrastructure/protocols/ui-page.js +0 -2377
- package/dist/infrastructure/security/input-validator.js +0 -152
- package/dist/infrastructure/storage/embeddings-path.js +0 -37
- 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
|
{
|
|
@@ -396,8 +408,26 @@ export function calculateRiskScore(findings) {
|
|
|
396
408
|
/**
|
|
397
409
|
* Audit log path — defaults to ~/.codehere/audit/hook-events.jsonl.
|
|
398
410
|
* Override at process start with CODEHERE_AUDIT_FILE env var (used by tests, optional for users).
|
|
411
|
+
* Honors CODEHERE_HOME like sessions/config/threads — found via the v1 stranger
|
|
412
|
+
* walk: an "isolated" daemon (verify-all's :3779, the walk's scratch home) was
|
|
413
|
+
* writing mock audit entries into the REAL ~/.codehere/audit while verify-all
|
|
414
|
+
* printed "Real ~/.codehere untouched" (the footer showed 162 entries · 51
|
|
415
|
+
* agents in a brand-new home — all of them pollution from past mock runs).
|
|
399
416
|
*/
|
|
400
|
-
|
|
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');
|
|
427
|
+
export const AUDIT_FILE = process.env.CODEHERE_AUDIT_FILE ||
|
|
428
|
+
(process.env.CODEHERE_MOCK === '1'
|
|
429
|
+
? MOCK_AUDIT_FILE
|
|
430
|
+
: join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'audit', 'hook-events.jsonl'));
|
|
401
431
|
const GENESIS_PREV_HASH = '0'.repeat(64);
|
|
402
432
|
/**
|
|
403
433
|
* Canonicalize an audit entry for hashing. We sort keys alphabetically and
|
|
@@ -514,7 +544,102 @@ export function readChainTip(filepath, tailBytes = 65536) {
|
|
|
514
544
|
// Pathological: no chain entry in the tail window. Pay the full read.
|
|
515
545
|
return readChainTipFull(filepath);
|
|
516
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
|
+
}
|
|
517
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) {
|
|
518
643
|
// Cross-process file locking. Multiple Claude Code sessions (e.g., two
|
|
519
644
|
// terminals, or IDE + terminal) can trigger PreToolUse concurrently.
|
|
520
645
|
// Without a lock, two hook processes read the same chain tip and both
|
|
@@ -606,6 +731,7 @@ export function verifyAuditChain(filepath = AUDIT_FILE) {
|
|
|
606
731
|
totalEntries: 0,
|
|
607
732
|
chainedEntries: 0,
|
|
608
733
|
legacyEntries: 0,
|
|
734
|
+
writerMix: { daemon: 0, hookDirect: 0, unattributed: 0 },
|
|
609
735
|
errors: [],
|
|
610
736
|
};
|
|
611
737
|
if (!existsSync(filepath))
|
|
@@ -615,6 +741,9 @@ export function verifyAuditChain(filepath = AUDIT_FILE) {
|
|
|
615
741
|
content = readFileSync(filepath, 'utf-8');
|
|
616
742
|
}
|
|
617
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' });
|
|
618
747
|
return result;
|
|
619
748
|
}
|
|
620
749
|
const lines = content.split('\n');
|
|
@@ -645,6 +774,13 @@ export function verifyAuditChain(filepath = AUDIT_FILE) {
|
|
|
645
774
|
continue;
|
|
646
775
|
}
|
|
647
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++;
|
|
648
784
|
// Recompute entryHash and check it matches what was stored
|
|
649
785
|
const recomputed = computeEntryHash(entry);
|
|
650
786
|
if (recomputed !== entry.entryHash) {
|
|
@@ -819,7 +955,7 @@ export function processHookEvent(event) {
|
|
|
819
955
|
criticalFindings: criticalFindings.length,
|
|
820
956
|
};
|
|
821
957
|
if (allowed.length > 0) {
|
|
822
|
-
// Add to the audit row's toolInput JSON so
|
|
958
|
+
// Add to the audit row's toolInput JSON so the audit feed can render
|
|
823
959
|
// "X findings allow-listed by permissions.yaml" badges later.
|
|
824
960
|
try {
|
|
825
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 —
|
|
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
|
-
|
|
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(
|
|
43
|
+
if (!existsSync(settingsPath()))
|
|
28
44
|
return 'off';
|
|
29
45
|
let parsed;
|
|
30
46
|
try {
|
|
31
|
-
parsed = JSON.parse(readFileSync(
|
|
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
|
|
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
|
|
23
|
+
* 'pre_prompt' so the audit feed + hash chain pick it up
|
|
24
24
|
* without surface changes.
|
|
25
25
|
*/
|
|
26
|
-
import { scanPayload, calculateRiskScore, appendAuditLog
|
|
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
|
|
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(`
|
|
136
|
+
console.log(` ! Original error: ${trace.error.message}`);
|
|
137
137
|
// Replay error trace
|
|
138
138
|
this.trace({
|
|
139
139
|
operationType: trace.operationType,
|