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.
Files changed (34) hide show
  1. package/README.md +18 -8
  2. package/dist/application/use-cases/generate-narration.js +111 -0
  3. package/dist/application/use-cases/generate-thread-title.js +58 -4
  4. package/dist/application/use-cases/narrator-prompt-template.js +158 -0
  5. package/dist/index.js +75 -6
  6. package/dist/infrastructure/cost/budget-config.js +113 -0
  7. package/dist/infrastructure/cost/spend-aggregator.js +147 -0
  8. package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
  9. package/dist/infrastructure/hooks/hook-mode.js +71 -0
  10. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
  11. package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
  12. package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
  13. package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
  14. package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
  15. package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
  16. package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
  17. package/dist/infrastructure/orchestration/failover-policy.js +106 -0
  18. package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
  19. package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
  20. package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
  21. package/dist/infrastructure/orchestration/session-manager.js +117 -10
  22. package/dist/infrastructure/orchestration/session-persistence.js +28 -0
  23. package/dist/infrastructure/protocols/mcp-server-impl.js +564 -13
  24. package/dist/infrastructure/protocols/ui-page.js +2728 -245
  25. package/dist/infrastructure/security/permissions-config.js +234 -0
  26. package/dist/infrastructure/storage/project-thread-mirror.js +379 -24
  27. package/dist/infrastructure/storage/project-thread-reader.js +23 -1
  28. package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
  29. package/dist/presentation/cli/commands/budget-command.js +79 -0
  30. package/dist/presentation/cli/commands/narrator-command.js +101 -0
  31. package/dist/presentation/cli/commands/permission-command.js +116 -0
  32. package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
  33. package/dist/utils/typo-guard.js +77 -17
  34. 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(entry) + '\n', { mode: 0o600 });
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 findings = scanPayload(payload);
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