codehere 0.5.0-alpha.5 → 0.6.0-alpha.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 +18 -8
- package/dist/application/use-cases/generate-narration.js +111 -0
- package/dist/application/use-cases/generate-thread-title.js +58 -4
- package/dist/application/use-cases/narrator-prompt-template.js +158 -0
- package/dist/index.js +75 -6
- package/dist/infrastructure/cost/budget-config.js +113 -0
- package/dist/infrastructure/cost/spend-aggregator.js +147 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
- package/dist/infrastructure/hooks/hook-mode.js +71 -0
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
- package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
- package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
- package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
- package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
- package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
- package/dist/infrastructure/orchestration/failover-policy.js +106 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
- package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
- package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
- package/dist/infrastructure/orchestration/session-manager.js +117 -10
- package/dist/infrastructure/orchestration/session-persistence.js +28 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +564 -13
- package/dist/infrastructure/protocols/ui-page.js +2728 -245
- package/dist/infrastructure/security/permissions-config.js +234 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +379 -24
- package/dist/infrastructure/storage/project-thread-reader.js +23 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
- package/dist/presentation/cli/commands/budget-command.js +79 -0
- package/dist/presentation/cli/commands/narrator-command.js +101 -0
- package/dist/presentation/cli/commands/permission-command.js +116 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
- package/dist/utils/typo-guard.js +77 -17
- package/package.json +1 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.6 S3 slice 1 — spend-today aggregator.
|
|
3
|
+
*
|
|
4
|
+
* Reads ~/.codehere/sessions.jsonl (the same append-only log session-persistence
|
|
5
|
+
* writes), filters completed records to today (local timezone), and estimates
|
|
6
|
+
* dollar spend per agent based on token usage × per-tier output-rate ceilings.
|
|
7
|
+
*
|
|
8
|
+
* Why · claim_market_position v3 lists "cost-bounded execution" as one of the
|
|
9
|
+
* few capture-strategy edges still possibly open. Demonstrating cost awareness
|
|
10
|
+
* starts here: surface what the user has actually spent today across all spawned
|
|
11
|
+
* agents, then (slice 2) enforce a per-day ceiling.
|
|
12
|
+
*
|
|
13
|
+
* Why estimates not exact · codehere doesn't see provider invoices. Adapters
|
|
14
|
+
* report token counts via TaskArtifacts.usage; we multiply by published tier
|
|
15
|
+
* ceilings. Per feedback_overconfidence_guard.md, copy frames this as
|
|
16
|
+
* "estimate" and points to vendor dashboards as authoritative.
|
|
17
|
+
*
|
|
18
|
+
* Cost table · output-tier ceilings (input is rougher; we use a 5x ratio).
|
|
19
|
+
* All values 2026 published rates. When a model isn't surfaced in usage,
|
|
20
|
+
* default to the agent's typical tier.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readFileSync } from 'fs';
|
|
23
|
+
import { homedir } from 'os';
|
|
24
|
+
import { join } from 'path';
|
|
25
|
+
/** Resolved at call time, not module load, so test setups that swap $HOME
|
|
26
|
+
* after import still get a fresh path on each invocation. */
|
|
27
|
+
function sessionsFile() {
|
|
28
|
+
return join(homedir(), '.codehere', 'sessions.jsonl');
|
|
29
|
+
}
|
|
30
|
+
/** USD per million OUTPUT tokens at the agent's typical 2026 tier. Input is
|
|
31
|
+
* estimated at 1/5 of output rate (Anthropic + OpenAI both publish ~5x
|
|
32
|
+
* output:input ratios on their flagship coding tiers). Cache reads ~1/10
|
|
33
|
+
* of input. Free-tier agents → 0.
|
|
34
|
+
*
|
|
35
|
+
* Conservative-ceiling philosophy: better to over-estimate spend than
|
|
36
|
+
* under-promise and miss a real bill. */
|
|
37
|
+
const COST_PER_M_OUTPUT_TOKENS_USD = {
|
|
38
|
+
'claude-code': 15.0, // Sonnet 4.6 baseline
|
|
39
|
+
'codex': 10.0, // GPT-5 / codex baseline
|
|
40
|
+
'aider': 15.0, // typically Sonnet
|
|
41
|
+
'gemini': 0, // free tier (Google AI Studio)
|
|
42
|
+
'opencode': 0, // typically free local / NIM-routed
|
|
43
|
+
'conductor': 15.0, // routes to Claude Code by default
|
|
44
|
+
};
|
|
45
|
+
const INPUT_RATE_RATIO = 0.2; // input ≈ 1/5 of output
|
|
46
|
+
const CACHE_READ_RATIO = 0.1; // cache reads ≈ 1/10 of input (so 1/50 output)
|
|
47
|
+
/**
|
|
48
|
+
* Estimate dollar cost for one session's token usage at one agent's typical tier.
|
|
49
|
+
* Returns 0 for free-tier agents and unknown agents (no mapping → no estimate).
|
|
50
|
+
*/
|
|
51
|
+
export function estimateCostUsd(agent, usage) {
|
|
52
|
+
if (!usage)
|
|
53
|
+
return 0;
|
|
54
|
+
const outputRate = COST_PER_M_OUTPUT_TOKENS_USD[agent];
|
|
55
|
+
if (outputRate === undefined || outputRate === 0)
|
|
56
|
+
return 0;
|
|
57
|
+
const inputRate = outputRate * INPUT_RATE_RATIO;
|
|
58
|
+
const cacheReadRate = inputRate * CACHE_READ_RATIO;
|
|
59
|
+
// Cache CREATION is billed at input rate (Anthropic). Use same default.
|
|
60
|
+
const tokens = {
|
|
61
|
+
input: Math.max(0, usage.inputTokens || 0),
|
|
62
|
+
output: Math.max(0, usage.outputTokens || 0),
|
|
63
|
+
cacheRead: Math.max(0, usage.cacheReadTokens || 0),
|
|
64
|
+
cacheCreate: Math.max(0, usage.cacheCreationTokens || 0),
|
|
65
|
+
};
|
|
66
|
+
return ((tokens.input / 1_000_000) * inputRate +
|
|
67
|
+
(tokens.output / 1_000_000) * outputRate +
|
|
68
|
+
(tokens.cacheRead / 1_000_000) * cacheReadRate +
|
|
69
|
+
(tokens.cacheCreate / 1_000_000) * inputRate);
|
|
70
|
+
}
|
|
71
|
+
/** Return the millisecond timestamp for the start of "today" in local time. */
|
|
72
|
+
function startOfTodayLocalMs(now = new Date()) {
|
|
73
|
+
const d = new Date(now);
|
|
74
|
+
d.setHours(0, 0, 0, 0);
|
|
75
|
+
return d.getTime();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Walk sessions.jsonl, sum estimated USD spend for sessions whose
|
|
79
|
+
* `completedAt` falls within today (local). Best-effort: missing file =
|
|
80
|
+
* empty report; parse errors per-line ignored. Pairs spawned + completed
|
|
81
|
+
* by id so we know which agent spent what.
|
|
82
|
+
*/
|
|
83
|
+
export function computeSpendToday(now = new Date()) {
|
|
84
|
+
const cutoff = startOfTodayLocalMs(now);
|
|
85
|
+
const empty = {
|
|
86
|
+
totalUsd: 0,
|
|
87
|
+
tokens: { input: 0, output: 0, cache: 0 },
|
|
88
|
+
sessionCount: 0,
|
|
89
|
+
byAgent: {},
|
|
90
|
+
windowStartedAt: cutoff,
|
|
91
|
+
};
|
|
92
|
+
if (!existsSync(sessionsFile()))
|
|
93
|
+
return empty;
|
|
94
|
+
let content;
|
|
95
|
+
try {
|
|
96
|
+
content = readFileSync(sessionsFile(), 'utf-8');
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return empty;
|
|
100
|
+
}
|
|
101
|
+
// Pair spawned (carries agent) with completed (carries usage + completedAt).
|
|
102
|
+
const spawnedAgent = new Map();
|
|
103
|
+
const report = { ...empty, byAgent: {} };
|
|
104
|
+
for (const line of content.split('\n')) {
|
|
105
|
+
const trimmed = line.trim();
|
|
106
|
+
if (!trimmed)
|
|
107
|
+
continue;
|
|
108
|
+
let rec;
|
|
109
|
+
try {
|
|
110
|
+
rec = JSON.parse(trimmed);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (rec.t === 'spawned' && rec.id && rec.agent) {
|
|
116
|
+
spawnedAgent.set(rec.id, rec.agent);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (rec.t !== 'completed' || !rec.id)
|
|
120
|
+
continue;
|
|
121
|
+
if (typeof rec.completedAt !== 'number' || rec.completedAt < cutoff)
|
|
122
|
+
continue;
|
|
123
|
+
const agent = spawnedAgent.get(rec.id);
|
|
124
|
+
if (!agent)
|
|
125
|
+
continue;
|
|
126
|
+
const usage = rec.artifacts?.usage;
|
|
127
|
+
const usd = estimateCostUsd(agent, usage);
|
|
128
|
+
report.sessionCount += 1;
|
|
129
|
+
if (usage) {
|
|
130
|
+
report.tokens.input += usage.inputTokens || 0;
|
|
131
|
+
report.tokens.output += usage.outputTokens || 0;
|
|
132
|
+
report.tokens.cache += (usage.cacheReadTokens || 0) + (usage.cacheCreationTokens || 0);
|
|
133
|
+
}
|
|
134
|
+
report.totalUsd += usd;
|
|
135
|
+
const cur = report.byAgent[agent] || { usd: 0, sessions: 0 };
|
|
136
|
+
cur.usd += usd;
|
|
137
|
+
cur.sessions += 1;
|
|
138
|
+
report.byAgent[agent] = cur;
|
|
139
|
+
}
|
|
140
|
+
// Round totals to 4 decimals (1/100 of a cent) — final display rounds to 2.
|
|
141
|
+
report.totalUsd = Math.round(report.totalUsd * 10_000) / 10_000;
|
|
142
|
+
for (const k of Object.keys(report.byAgent)) {
|
|
143
|
+
report.byAgent[k].usd = Math.round(report.byAgent[k].usd * 10_000) / 10_000;
|
|
144
|
+
}
|
|
145
|
+
return report;
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=spend-aggregator.js.map
|
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Docs: https://docs.anthropic.com/en/docs/claude-code/hooks
|
|
15
15
|
*/
|
|
16
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync } from 'fs';
|
|
16
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, closeSync, readSync, statSync, readFileSync, } from 'fs';
|
|
17
17
|
import { join } from 'path';
|
|
18
18
|
import { homedir } from 'os';
|
|
19
|
+
import { createHash } from 'crypto';
|
|
20
|
+
// P18 slice 2 — Tier 1 hook honors per-cwd permissions.yaml.
|
|
21
|
+
import { filterAllowed } from '../security/permissions-config.js';
|
|
19
22
|
const PATTERNS = [
|
|
20
23
|
// Prompt Injection
|
|
21
24
|
// Allow chained modifiers: "ignore all previous instructions", "ignore all your prior rules", etc.
|
|
@@ -388,6 +391,108 @@ export function calculateRiskScore(findings) {
|
|
|
388
391
|
* Override at process start with CODEHERE_AUDIT_FILE env var (used by tests, optional for users).
|
|
389
392
|
*/
|
|
390
393
|
export const AUDIT_FILE = process.env.CODEHERE_AUDIT_FILE || join(homedir(), '.codehere', 'audit', 'hook-events.jsonl');
|
|
394
|
+
const GENESIS_PREV_HASH = '0'.repeat(64);
|
|
395
|
+
/**
|
|
396
|
+
* Canonicalize an audit entry for hashing. We sort keys alphabetically and
|
|
397
|
+
* stringify with no whitespace so the hash is reproducible across writers.
|
|
398
|
+
* The `entryHash` field is excluded from its own hash input (chicken-and-egg).
|
|
399
|
+
*/
|
|
400
|
+
function canonicalizeForHash(entry) {
|
|
401
|
+
const { entryHash: _entryHash, ...rest } = entry;
|
|
402
|
+
void _entryHash; // referenced to satisfy lint; the destructure is the point
|
|
403
|
+
const keys = Object.keys(rest).sort();
|
|
404
|
+
const ordered = {};
|
|
405
|
+
for (const k of keys) {
|
|
406
|
+
ordered[k] = rest[k];
|
|
407
|
+
}
|
|
408
|
+
return JSON.stringify(ordered);
|
|
409
|
+
}
|
|
410
|
+
/** SHA-256 hex of an entry's canonical form (entryHash excluded). */
|
|
411
|
+
export function computeEntryHash(entry) {
|
|
412
|
+
return createHash('sha256').update(canonicalizeForHash(entry)).digest('hex');
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Read the last non-empty line from a file without loading the whole file.
|
|
416
|
+
* Reads in 4 KB chunks from the end until a newline is found. Returns null
|
|
417
|
+
* if the file is empty or unreadable.
|
|
418
|
+
*
|
|
419
|
+
* For audit-log size <100 KB this is fast (single readSync). Even at 10 MB
|
|
420
|
+
* the worst case is ~3 reads to find a newline.
|
|
421
|
+
*/
|
|
422
|
+
function readLastLine(filepath) {
|
|
423
|
+
try {
|
|
424
|
+
const stats = statSync(filepath);
|
|
425
|
+
if (stats.size === 0)
|
|
426
|
+
return null;
|
|
427
|
+
const fd = openSync(filepath, 'r');
|
|
428
|
+
try {
|
|
429
|
+
const chunkSize = 4096;
|
|
430
|
+
let position = stats.size;
|
|
431
|
+
let buffer = Buffer.alloc(0);
|
|
432
|
+
while (position > 0) {
|
|
433
|
+
const readSize = Math.min(chunkSize, position);
|
|
434
|
+
position -= readSize;
|
|
435
|
+
const chunk = Buffer.alloc(readSize);
|
|
436
|
+
readSync(fd, chunk, 0, readSize, position);
|
|
437
|
+
buffer = Buffer.concat([chunk, buffer]);
|
|
438
|
+
// Strip trailing newlines so the last "line" is the actual content
|
|
439
|
+
const text = buffer.toString('utf-8').replace(/\n+$/, '');
|
|
440
|
+
const lastNewline = text.lastIndexOf('\n');
|
|
441
|
+
if (lastNewline >= 0) {
|
|
442
|
+
return text.slice(lastNewline + 1);
|
|
443
|
+
}
|
|
444
|
+
if (position === 0) {
|
|
445
|
+
// Whole file fits in our buffer
|
|
446
|
+
return text;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
closeSync(fd);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Read the chain tip (seq + entryHash of the last chained entry) from disk.
|
|
461
|
+
* Walks BACKWARD from EOF looking for the most recent line that carries
|
|
462
|
+
* chain fields. Pre-fix this only checked the last line — if that line was
|
|
463
|
+
* legacy (no chain fields), the chain restarted at seq=0 producing a fake
|
|
464
|
+
* hash_mismatch in verifyAuditChain. Walking backward keeps the chain
|
|
465
|
+
* continuous across legacy-entry interleavings (rare but possible when
|
|
466
|
+
* an out-of-band script appends raw JSON to the audit file).
|
|
467
|
+
*/
|
|
468
|
+
function readChainTip(filepath) {
|
|
469
|
+
if (!existsSync(filepath))
|
|
470
|
+
return null;
|
|
471
|
+
let content;
|
|
472
|
+
try {
|
|
473
|
+
content = readFileSync(filepath, 'utf-8');
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
const lines = content.split('\n');
|
|
479
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
480
|
+
const raw = lines[i];
|
|
481
|
+
if (!raw.trim())
|
|
482
|
+
continue;
|
|
483
|
+
try {
|
|
484
|
+
const entry = JSON.parse(raw);
|
|
485
|
+
if (typeof entry.seq === 'number' && typeof entry.entryHash === 'string') {
|
|
486
|
+
return { seq: entry.seq, hash: entry.entryHash };
|
|
487
|
+
}
|
|
488
|
+
// Legacy entry — keep walking backward
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// Parse failure — skip and continue
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
391
496
|
export function appendAuditLog(entry) {
|
|
392
497
|
try {
|
|
393
498
|
const dir = AUDIT_FILE.substring(0, AUDIT_FILE.lastIndexOf('/'));
|
|
@@ -398,10 +503,17 @@ export function appendAuditLog(entry) {
|
|
|
398
503
|
// the May 1 2026 /cso security audit (Finding #1, MEDIUM).
|
|
399
504
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
400
505
|
}
|
|
506
|
+
// Compute chain fields. Read tip from file (cheap tail read). If no chain
|
|
507
|
+
// exists (fresh file, or last line is pre-chain legacy), start at genesis.
|
|
508
|
+
const tip = readChainTip(AUDIT_FILE);
|
|
509
|
+
const seq = tip ? tip.seq + 1 : 0;
|
|
510
|
+
const prevHash = tip ? tip.hash : GENESIS_PREV_HASH;
|
|
511
|
+
const chainedEntry = { ...entry, seq, prevHash };
|
|
512
|
+
chainedEntry.entryHash = computeEntryHash(chainedEntry);
|
|
401
513
|
// mode 0o600 — owner-only file. Same reasoning as the dir above. Only takes
|
|
402
514
|
// effect on first write (file creation); existing files are chmod'd below
|
|
403
515
|
// for backwards compatibility with audit logs created before this fix.
|
|
404
|
-
appendFileSync(AUDIT_FILE, JSON.stringify(
|
|
516
|
+
appendFileSync(AUDIT_FILE, JSON.stringify(chainedEntry) + '\n', { mode: 0o600 });
|
|
405
517
|
// Idempotent: if the file existed pre-fix with 0o644, tighten it now. The
|
|
406
518
|
// chmod is cheap and silent on already-0o600 files.
|
|
407
519
|
try {
|
|
@@ -415,6 +527,146 @@ export function appendAuditLog(entry) {
|
|
|
415
527
|
// Audit log failure must not block tool execution
|
|
416
528
|
}
|
|
417
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* Walk the audit file and verify the hash chain. Returns:
|
|
532
|
+
* - valid: true iff every chained entry's prevHash matches predecessor's
|
|
533
|
+
* entryHash, and every entry's entryHash matches recomputed value
|
|
534
|
+
* - totalEntries / chainedEntries / legacyEntries: line counts
|
|
535
|
+
* - errors: every detected break (verifier doesn't stop at first failure;
|
|
536
|
+
* reporter shows ALL tampering points so a forensic reviewer can
|
|
537
|
+
* see the full picture)
|
|
538
|
+
*
|
|
539
|
+
* Legacy preamble: pre-2026-05-06 entries (no seq/prevHash/entryHash) are
|
|
540
|
+
* counted as legacyEntries and skipped. The chain starts at the FIRST entry
|
|
541
|
+
* with seq=0 — every subsequent chained entry must continue from there.
|
|
542
|
+
*/
|
|
543
|
+
export function verifyAuditChain(filepath = AUDIT_FILE) {
|
|
544
|
+
const result = {
|
|
545
|
+
valid: true,
|
|
546
|
+
totalEntries: 0,
|
|
547
|
+
chainedEntries: 0,
|
|
548
|
+
legacyEntries: 0,
|
|
549
|
+
errors: [],
|
|
550
|
+
};
|
|
551
|
+
if (!existsSync(filepath))
|
|
552
|
+
return result;
|
|
553
|
+
let content;
|
|
554
|
+
try {
|
|
555
|
+
content = readFileSync(filepath, 'utf-8');
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
return result;
|
|
559
|
+
}
|
|
560
|
+
const lines = content.split('\n');
|
|
561
|
+
let prevHash = null; // null until we hit the first chained entry
|
|
562
|
+
let prevSeq = null;
|
|
563
|
+
for (let i = 0; i < lines.length; i++) {
|
|
564
|
+
const raw = lines[i];
|
|
565
|
+
if (!raw.trim())
|
|
566
|
+
continue;
|
|
567
|
+
const lineNumber = i + 1;
|
|
568
|
+
result.totalEntries++;
|
|
569
|
+
let entry;
|
|
570
|
+
try {
|
|
571
|
+
entry = JSON.parse(raw);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
result.valid = false;
|
|
575
|
+
result.errors.push({
|
|
576
|
+
lineNumber,
|
|
577
|
+
reason: 'parse_failure',
|
|
578
|
+
detail: 'JSON parse failed',
|
|
579
|
+
});
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const hasChainFields = typeof entry.seq === 'number' && typeof entry.prevHash === 'string' && typeof entry.entryHash === 'string';
|
|
583
|
+
if (!hasChainFields) {
|
|
584
|
+
result.legacyEntries++;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
result.chainedEntries++;
|
|
588
|
+
// Recompute entryHash and check it matches what was stored
|
|
589
|
+
const recomputed = computeEntryHash(entry);
|
|
590
|
+
if (recomputed !== entry.entryHash) {
|
|
591
|
+
result.valid = false;
|
|
592
|
+
result.errors.push({
|
|
593
|
+
lineNumber,
|
|
594
|
+
seq: entry.seq,
|
|
595
|
+
reason: 'self_hash_mismatch',
|
|
596
|
+
detail: `entryHash field does not match recomputed hash; entry was modified after writing`,
|
|
597
|
+
});
|
|
598
|
+
// Continue checking — we still want to report the chain link below,
|
|
599
|
+
// but using the STORED hash so cascading errors are honest about the
|
|
600
|
+
// physical state of the file.
|
|
601
|
+
}
|
|
602
|
+
// Chain link check
|
|
603
|
+
if (prevHash === null) {
|
|
604
|
+
// First chained entry: must be genesis (seq=0, prevHash=64x0)
|
|
605
|
+
if (entry.seq !== 0 || entry.prevHash !== GENESIS_PREV_HASH) {
|
|
606
|
+
result.valid = false;
|
|
607
|
+
result.errors.push({
|
|
608
|
+
lineNumber,
|
|
609
|
+
seq: entry.seq,
|
|
610
|
+
reason: 'genesis_violation',
|
|
611
|
+
detail: `first chained entry must have seq=0 and prevHash=${GENESIS_PREV_HASH.slice(0, 8)}…; got seq=${entry.seq}, prevHash=${entry.prevHash?.slice(0, 8)}…`,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
if (entry.seq !== (prevSeq ?? -1) + 1) {
|
|
617
|
+
result.valid = false;
|
|
618
|
+
result.errors.push({
|
|
619
|
+
lineNumber,
|
|
620
|
+
seq: entry.seq,
|
|
621
|
+
reason: 'seq_gap',
|
|
622
|
+
detail: `expected seq=${(prevSeq ?? -1) + 1}, got seq=${entry.seq}`,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
if (entry.prevHash !== prevHash) {
|
|
626
|
+
result.valid = false;
|
|
627
|
+
result.errors.push({
|
|
628
|
+
lineNumber,
|
|
629
|
+
seq: entry.seq,
|
|
630
|
+
reason: 'hash_mismatch',
|
|
631
|
+
detail: `prevHash=${entry.prevHash?.slice(0, 12)}… does not match predecessor entryHash=${prevHash.slice(0, 12)}…`,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// Move forward using the STORED entryHash. Even if recomputed mismatched,
|
|
636
|
+
// the next entry's prevHash was written against the stored value, so we
|
|
637
|
+
// chain on stored. The self-hash error above already flagged the breach.
|
|
638
|
+
prevHash = entry.entryHash;
|
|
639
|
+
prevSeq = entry.seq;
|
|
640
|
+
}
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Record a trust-override event in the audit log. Used when the scanner is
|
|
645
|
+
* bypassed for any reason (stdin failure, parse failure, --log-only mode,
|
|
646
|
+
* scan exception). Per CC frontier Q1 (auditable permission trails) — the
|
|
647
|
+
* audit MUST capture bypasses, not just block/allow decisions, so a reviewer
|
|
648
|
+
* can see EVERY case where the safety policy didn't run normally.
|
|
649
|
+
*
|
|
650
|
+
* Best-effort: never throws. The bypass already happened by the time we get
|
|
651
|
+
* here; failure to write the audit just means the bypass is undocumented.
|
|
652
|
+
*
|
|
653
|
+
* Fields default to safe minimums: empty toolInput, zero risk, blocked=false
|
|
654
|
+
* (because the tool DID run). Caller can override what it knows.
|
|
655
|
+
*/
|
|
656
|
+
export function appendOverrideAudit(reason, partial = {}) {
|
|
657
|
+
const entry = {
|
|
658
|
+
timestamp: new Date().toISOString(),
|
|
659
|
+
sessionId: partial.sessionId ?? 'unknown',
|
|
660
|
+
toolName: partial.toolName ?? 'unknown',
|
|
661
|
+
toolInput: partial.toolInput ?? '',
|
|
662
|
+
riskScore: partial.riskScore ?? 0,
|
|
663
|
+
blocked: false, // override = scanner didn't block, regardless of what it would have decided
|
|
664
|
+
findings: partial.findings ?? 0,
|
|
665
|
+
criticalFindings: partial.criticalFindings ?? 0,
|
|
666
|
+
overrideReason: reason,
|
|
667
|
+
};
|
|
668
|
+
appendAuditLog(entry);
|
|
669
|
+
}
|
|
418
670
|
// ── Main hook handler ────────────────────────────────────────────────────────
|
|
419
671
|
/**
|
|
420
672
|
* Process a Claude Code hook event.
|
|
@@ -480,12 +732,20 @@ export function processHookEvent(event) {
|
|
|
480
732
|
// Build context-appropriate scan payload (Bash = full, Write/Edit = path only)
|
|
481
733
|
const payload = buildScanPayload(tool_name, tool_input);
|
|
482
734
|
// Layer 0 scan — local, no API, ~2ms
|
|
483
|
-
const
|
|
735
|
+
const rawFindings = scanPayload(payload);
|
|
736
|
+
// P18 slice 2 — Tier 1 hook now honors per-cwd permissions.yaml.
|
|
737
|
+
// The hook subprocess inherits Claude Code's cwd when spawned, so
|
|
738
|
+
// process.cwd() points at the project root where .codehere/
|
|
739
|
+
// permissions.yaml would live. Allow-listed findings are dropped
|
|
740
|
+
// from the BLOCK calculation but still recorded in the audit entry
|
|
741
|
+
// so a forensic reviewer sees what was suppressed and why.
|
|
742
|
+
const cwd = process.cwd();
|
|
743
|
+
const { surviving: findings, allowed } = filterAllowed(rawFindings, cwd);
|
|
484
744
|
const riskScore = calculateRiskScore(findings);
|
|
485
745
|
const criticalFindings = findings.filter((f) => f.severity === 'critical');
|
|
486
746
|
// Blocking threshold: critical finding OR risk score too high
|
|
487
747
|
const blocked = criticalFindings.length > 0 || riskScore >= 60;
|
|
488
|
-
// Build audit entry
|
|
748
|
+
// Build audit entry — record what got allow-listed for the trail
|
|
489
749
|
const auditEntry = {
|
|
490
750
|
timestamp: new Date().toISOString(),
|
|
491
751
|
sessionId: session_id,
|
|
@@ -496,6 +756,22 @@ export function processHookEvent(event) {
|
|
|
496
756
|
findings: findings.length,
|
|
497
757
|
criticalFindings: criticalFindings.length,
|
|
498
758
|
};
|
|
759
|
+
if (allowed.length > 0) {
|
|
760
|
+
// Add to the audit row's toolInput JSON so /history can render
|
|
761
|
+
// "X findings allow-listed by permissions.yaml" badges later.
|
|
762
|
+
try {
|
|
763
|
+
const parsed = JSON.parse(auditEntry.toolInput);
|
|
764
|
+
parsed.allowedByPermissions = allowed.map((a) => ({
|
|
765
|
+
category: a.finding.category,
|
|
766
|
+
matched: a.finding.matched,
|
|
767
|
+
reason: a.entry.reason ?? '(no reason given)',
|
|
768
|
+
}));
|
|
769
|
+
auditEntry.toolInput = JSON.stringify(parsed).substring(0, 800);
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
// toolInput wasn't JSON (rare path); skip enrichment
|
|
773
|
+
}
|
|
774
|
+
}
|
|
499
775
|
appendAuditLog(auditEntry);
|
|
500
776
|
if (!blocked) {
|
|
501
777
|
return null; // allow
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook mode detection — Pattern 20 backend.
|
|
3
|
+
*
|
|
4
|
+
* Reads ~/.claude/settings.json and reports which posture the safety
|
|
5
|
+
* hook is in. Mode is encoded as flags on the configured command:
|
|
6
|
+
*
|
|
7
|
+
* off — no codehere scan-tool entry in PreToolUse
|
|
8
|
+
* log-only — `codehere scan-tool --log-only`
|
|
9
|
+
* advisory — reserved for future; not currently shipped
|
|
10
|
+
* block — `codehere scan-tool` (default critical/high block)
|
|
11
|
+
* OR `codehere scan-tool --block-all` (all severity levels)
|
|
12
|
+
*
|
|
13
|
+
* Read-only by design — this module never WRITES the config. Mode
|
|
14
|
+
* switching belongs in `codehere hook install/uninstall` so users go
|
|
15
|
+
* through the same path that re-validates the hook surface.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { homedir } from 'os';
|
|
20
|
+
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
21
|
+
/**
|
|
22
|
+
* Detect the current safety-hook mode from ~/.claude/settings.json.
|
|
23
|
+
* Defensive against missing / malformed settings — anything we can't
|
|
24
|
+
* parse cleanly returns 'off' (the most-conservative read).
|
|
25
|
+
*/
|
|
26
|
+
export function getHookMode() {
|
|
27
|
+
if (!existsSync(SETTINGS_PATH))
|
|
28
|
+
return 'off';
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return 'off';
|
|
35
|
+
}
|
|
36
|
+
const hooks = (parsed.hooks ?? {});
|
|
37
|
+
const preToolUse = (hooks.PreToolUse ?? []);
|
|
38
|
+
if (!Array.isArray(preToolUse) || preToolUse.length === 0)
|
|
39
|
+
return 'off';
|
|
40
|
+
let bestMatch = 'off';
|
|
41
|
+
for (const entry of preToolUse) {
|
|
42
|
+
if (!entry?.hooks)
|
|
43
|
+
continue;
|
|
44
|
+
for (const hk of entry.hooks) {
|
|
45
|
+
const cmd = hk?.command;
|
|
46
|
+
if (typeof cmd !== 'string')
|
|
47
|
+
continue;
|
|
48
|
+
if (!cmd.includes('codehere scan-tool'))
|
|
49
|
+
continue;
|
|
50
|
+
// Earliest match wins block > advisory > log-only — once a stricter
|
|
51
|
+
// mode is found we keep it. (block is the operational default;
|
|
52
|
+
// log-only is opt-in.)
|
|
53
|
+
if (cmd.includes('--log-only')) {
|
|
54
|
+
if (bestMatch === 'off')
|
|
55
|
+
bestMatch = 'log-only';
|
|
56
|
+
}
|
|
57
|
+
else if (cmd.includes('--advisory')) {
|
|
58
|
+
if (bestMatch === 'off' || bestMatch === 'log-only')
|
|
59
|
+
bestMatch = 'advisory';
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// No flag OR --block-all: both render as 'block' in the UI.
|
|
63
|
+
// Block-all is stricter but the user-visible posture ("safety
|
|
64
|
+
// hook actively blocks risky calls") is the same.
|
|
65
|
+
return 'block';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return bestMatch;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=hook-mode.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-vendor tool-call audit mirror — #110 slice 2 (Codex first).
|
|
3
|
+
*
|
|
4
|
+
* When non-Claude adapters emit a tool-call event, we can't BLOCK
|
|
5
|
+
* (no synchronous pre-tool intercept yet) but we CAN mirror the
|
|
6
|
+
* call into the audit log post-hoc. Closes the audit-trail-parity
|
|
7
|
+
* gap: every adapter's tool calls land in the same hook-events.jsonl
|
|
8
|
+
* the per-tool Claude hook writes to.
|
|
9
|
+
*
|
|
10
|
+
* Future slices (per-adapter blocking) replace the post-hoc mirror
|
|
11
|
+
* with pre-call intercepts. For now: visibility today, blocking next.
|
|
12
|
+
*/
|
|
13
|
+
import { scanPayload, calculateRiskScore, appendAuditLog } from './claude-sdk-hook.js';
|
|
14
|
+
export function mirrorToolCallToAudit(rec) {
|
|
15
|
+
// 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
|
+
const findings = scanPayload(rec.toolInput);
|
|
18
|
+
const riskScore = calculateRiskScore(findings);
|
|
19
|
+
const truncated = rec.toolInput.length > 500 ? rec.toolInput.slice(0, 500) + '…' : rec.toolInput;
|
|
20
|
+
const entry = {
|
|
21
|
+
timestamp: new Date().toISOString(),
|
|
22
|
+
sessionId: rec.sessionId,
|
|
23
|
+
toolName: rec.toolName,
|
|
24
|
+
toolInput: JSON.stringify({
|
|
25
|
+
agent: rec.agent,
|
|
26
|
+
input: truncated,
|
|
27
|
+
cwd: rec.cwd ?? null,
|
|
28
|
+
inputLength: rec.toolInput.length,
|
|
29
|
+
mirrored: true, // marks post-hoc vs the synchronous Claude hook
|
|
30
|
+
}),
|
|
31
|
+
riskScore,
|
|
32
|
+
blocked: false, // post-hoc — can't block, only log
|
|
33
|
+
findings: findings.length,
|
|
34
|
+
criticalFindings: findings.filter((f) => f.severity === 'critical').length,
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
appendAuditLog(entry);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Audit-write failures don't propagate — the agent's already running.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=mirror-tool-call-to-audit.js.map
|