codehere 1.0.1 → 1.0.3

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 (163) hide show
  1. package/README.md +35 -193
  2. package/dist/application/use-cases/check-claimed-tests.js +81 -0
  3. package/dist/application/use-cases/detect-test-weakening.js +237 -0
  4. package/dist/application/use-cases/rerun-tests.js +110 -7
  5. package/dist/index.js +38 -1212
  6. package/dist/infrastructure/orchestration/artifacts-extractor.js +2 -323
  7. package/dist/presentation/cli/commands/check-command.js +178 -23
  8. package/dist/presentation/cli/commands/help-command.js +21 -285
  9. package/dist/ui.js +5 -236
  10. package/dist/utils/version.js +0 -6
  11. package/package.json +9 -38
  12. package/dist/application/services/verdict-service.js +0 -154
  13. package/dist/application/use-cases/assess-cross-vendor.js +0 -126
  14. package/dist/application/use-cases/build-cross-vendor-verdict.js +0 -118
  15. package/dist/application/use-cases/domain-trust.js +0 -110
  16. package/dist/application/use-cases/export-aat.js +0 -107
  17. package/dist/application/use-cases/generate-acceptance-criteria.js +0 -223
  18. package/dist/application/use-cases/generate-final-report.js +0 -259
  19. package/dist/application/use-cases/generate-narration.js +0 -130
  20. package/dist/application/use-cases/generate-thread-title.js +0 -181
  21. package/dist/application/use-cases/narrator-prompt-template.js +0 -158
  22. package/dist/application/use-cases/plan-auto-review.js +0 -57
  23. package/dist/application/use-cases/reconcile-audit.js +0 -100
  24. package/dist/application/use-cases/search-sessions.js +0 -70
  25. package/dist/application/use-cases/should-auto-review.js +0 -71
  26. package/dist/application/use-cases/thread-export.js +0 -245
  27. package/dist/application/use-cases/verify-claim.js +0 -456
  28. package/dist/application/use-cases/write-pass.js +0 -277
  29. package/dist/domain/audit.js +0 -11
  30. package/dist/domain/services/codebase-detector.js +0 -85
  31. package/dist/domain/task.js +0 -91
  32. package/dist/error-handler.js +0 -139
  33. package/dist/infrastructure/completion/bash-completion.js +0 -29
  34. package/dist/infrastructure/completion/fish-completion.js +0 -17
  35. package/dist/infrastructure/completion/zsh-completion.js +0 -25
  36. package/dist/infrastructure/config/config-manager.js +0 -174
  37. package/dist/infrastructure/config/product-context.js +0 -49
  38. package/dist/infrastructure/context/adapters/drawio-source.js +0 -157
  39. package/dist/infrastructure/context/adapters/figma-source.js +0 -198
  40. package/dist/infrastructure/context/adapters/github-issue-source.js +0 -160
  41. package/dist/infrastructure/context/adapters/linear-source.js +0 -190
  42. package/dist/infrastructure/context/context-resolver.js +0 -144
  43. package/dist/infrastructure/context/context-source.js +0 -114
  44. package/dist/infrastructure/context/github-api.js +0 -110
  45. package/dist/infrastructure/context/github-device-flow.js +0 -65
  46. package/dist/infrastructure/context/oauth-flow.js +0 -207
  47. package/dist/infrastructure/context/oauth-store.js +0 -122
  48. package/dist/infrastructure/context/registry-bootstrap.js +0 -95
  49. package/dist/infrastructure/context/write-pass-resolver.js +0 -106
  50. package/dist/infrastructure/cost/budget-config.js +0 -114
  51. package/dist/infrastructure/cost/cost-router.js +0 -97
  52. package/dist/infrastructure/cost/spend-aggregator.js +0 -222
  53. package/dist/infrastructure/cost/team-ledger.js +0 -355
  54. package/dist/infrastructure/discovery/discovery-log.js +0 -85
  55. package/dist/infrastructure/hooks/audit-tip-signature.js +0 -336
  56. package/dist/infrastructure/hooks/claude-sdk-hook.js +0 -1018
  57. package/dist/infrastructure/hooks/hook-mode.js +0 -87
  58. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +0 -49
  59. package/dist/infrastructure/hooks/pre-prompt-scan.js +0 -122
  60. package/dist/infrastructure/hooks/session-analyzer.js +0 -177
  61. package/dist/infrastructure/observability/execution-tracer.js +0 -227
  62. package/dist/infrastructure/orchestration/agent-adapter.js +0 -13
  63. package/dist/infrastructure/orchestration/agent-installer.js +0 -101
  64. package/dist/infrastructure/orchestration/agents-config-seed.js +0 -68
  65. package/dist/infrastructure/orchestration/aider-adapter.js +0 -611
  66. package/dist/infrastructure/orchestration/cassette-recorder.js +0 -120
  67. package/dist/infrastructure/orchestration/claude-code-adapter.js +0 -592
  68. package/dist/infrastructure/orchestration/codex-adapter.js +0 -574
  69. package/dist/infrastructure/orchestration/conductor-adapter.js +0 -416
  70. package/dist/infrastructure/orchestration/context-handoff.js +0 -162
  71. package/dist/infrastructure/orchestration/control/codex-control-surface.js +0 -99
  72. package/dist/infrastructure/orchestration/control/control-surface.js +0 -15
  73. package/dist/infrastructure/orchestration/detect-ollama.js +0 -75
  74. package/dist/infrastructure/orchestration/failover-classifier.js +0 -151
  75. package/dist/infrastructure/orchestration/failover-config.js +0 -67
  76. package/dist/infrastructure/orchestration/failover-policy.js +0 -106
  77. package/dist/infrastructure/orchestration/gemini-adapter.js +0 -663
  78. package/dist/infrastructure/orchestration/generic-cli-adapter.js +0 -689
  79. package/dist/infrastructure/orchestration/graphify-context.js +0 -209
  80. package/dist/infrastructure/orchestration/memory-injector.js +0 -380
  81. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +0 -388
  82. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +0 -73
  83. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +0 -83
  84. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +0 -37
  85. package/dist/infrastructure/orchestration/opencode-adapter.js +0 -580
  86. package/dist/infrastructure/orchestration/progress-aggregator.js +0 -906
  87. package/dist/infrastructure/orchestration/readiness.js +0 -103
  88. package/dist/infrastructure/orchestration/session-manager.js +0 -1590
  89. package/dist/infrastructure/orchestration/session-persistence.js +0 -394
  90. package/dist/infrastructure/orchestration/thread-key-store.js +0 -127
  91. package/dist/infrastructure/orchestration/write-pass-store.js +0 -38
  92. package/dist/infrastructure/protocols/cloud-sync.js +0 -207
  93. package/dist/infrastructure/protocols/mcp-helpers.js +0 -25
  94. package/dist/infrastructure/protocols/mcp-server-impl.js +0 -3935
  95. package/dist/infrastructure/protocols/mcp-tools.js +0 -401
  96. package/dist/infrastructure/protocols/probe-port.js +0 -36
  97. package/dist/infrastructure/protocols/thread-phase.js +0 -105
  98. package/dist/infrastructure/protocols/ui/app-script-artifacts.js +0 -121
  99. package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +0 -44
  100. package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +0 -56
  101. package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +0 -588
  102. package/dist/infrastructure/protocols/ui/app-script-context-sources.js +0 -168
  103. package/dist/infrastructure/protocols/ui/app-script-conversation.js +0 -67
  104. package/dist/infrastructure/protocols/ui/app-script-drawer.js +0 -115
  105. package/dist/infrastructure/protocols/ui/app-script-failover.js +0 -133
  106. package/dist/infrastructure/protocols/ui/app-script-github.js +0 -113
  107. package/dist/infrastructure/protocols/ui/app-script-observer.js +0 -104
  108. package/dist/infrastructure/protocols/ui/app-script-onboarding.js +0 -80
  109. package/dist/infrastructure/protocols/ui/app-script-overlay.js +0 -203
  110. package/dist/infrastructure/protocols/ui/app-script-recovery.js +0 -136
  111. package/dist/infrastructure/protocols/ui/app-script-render.js +0 -166
  112. package/dist/infrastructure/protocols/ui/app-script-share.js +0 -78
  113. package/dist/infrastructure/protocols/ui/app-script-sidebar.js +0 -194
  114. package/dist/infrastructure/protocols/ui/app-script-tour.js +0 -78
  115. package/dist/infrastructure/protocols/ui/app-script-wpass.js +0 -312
  116. package/dist/infrastructure/protocols/ui/app-script.js +0 -2998
  117. package/dist/infrastructure/protocols/ui/font-faces.js +0 -7
  118. package/dist/infrastructure/protocols/ui/styles.js +0 -2147
  119. package/dist/infrastructure/protocols/ui/tokens.js +0 -96
  120. package/dist/infrastructure/protocols/ui-page-v2.js +0 -320
  121. package/dist/infrastructure/protocols/verify-page.js +0 -1677
  122. package/dist/infrastructure/security/file-collection.js +0 -118
  123. package/dist/infrastructure/security/fix-code.js +0 -144
  124. package/dist/infrastructure/security/fix-secret.js +0 -89
  125. package/dist/infrastructure/security/permissions-config.js +0 -235
  126. package/dist/infrastructure/security/scan-types.js +0 -10
  127. package/dist/infrastructure/security/ship-scan.js +0 -195
  128. package/dist/infrastructure/security/trust-scorer.js +0 -724
  129. package/dist/infrastructure/security/vibe-checks.js +0 -99
  130. package/dist/infrastructure/storage/atomic-write.js +0 -86
  131. package/dist/infrastructure/storage/cross-tool-agents.js +0 -268
  132. package/dist/infrastructure/storage/project-thread-migration.js +0 -237
  133. package/dist/infrastructure/storage/project-thread-mirror.js +0 -671
  134. package/dist/infrastructure/storage/project-thread-reader.js +0 -409
  135. package/dist/infrastructure/storage/project-thread-title-backfill.js +0 -214
  136. package/dist/monitoring.js +0 -212
  137. package/dist/presentation/cli/commands/audit-command.js +0 -201
  138. package/dist/presentation/cli/commands/budget-command.js +0 -79
  139. package/dist/presentation/cli/commands/config-command.js +0 -125
  140. package/dist/presentation/cli/commands/context-source-command.js +0 -217
  141. package/dist/presentation/cli/commands/hook-command.js +0 -265
  142. package/dist/presentation/cli/commands/init-command.js +0 -590
  143. package/dist/presentation/cli/commands/memory-command.js +0 -1031
  144. package/dist/presentation/cli/commands/narrator-command.js +0 -101
  145. package/dist/presentation/cli/commands/permission-command.js +0 -105
  146. package/dist/presentation/cli/commands/protocols-builder.js +0 -254
  147. package/dist/presentation/cli/commands/protocols-command.js +0 -84
  148. package/dist/presentation/cli/commands/repo-map-builder.js +0 -247
  149. package/dist/presentation/cli/commands/report-command.js +0 -403
  150. package/dist/presentation/cli/commands/scan-command.js +0 -52
  151. package/dist/presentation/cli/commands/scan-tool-command.js +0 -110
  152. package/dist/presentation/cli/commands/search-command.js +0 -51
  153. package/dist/presentation/cli/commands/setup-command.js +0 -330
  154. package/dist/presentation/cli/commands/status-command.js +0 -614
  155. package/dist/presentation/cli/commands/team-command.js +0 -256
  156. package/dist/presentation/cli/commands/thread-command.js +0 -370
  157. package/dist/presentation/cli/commands/trust-command.js +0 -328
  158. package/dist/presentation/cli/commands/verify-command.js +0 -298
  159. package/dist/presentation/cli/error-display.js +0 -328
  160. package/dist/presentation/cli/repl.js +0 -805
  161. package/dist/presentation/cli/task-deliverable.js +0 -153
  162. package/dist/testing/cassette.js +0 -125
  163. package/dist/testing/replay-adapter.js +0 -147
@@ -1,1018 +0,0 @@
1
- /**
2
- * Claude Agent SDK Hook Handler
3
- *
4
- * Intercepts every Claude Code tool call via PreToolUse/PostToolUse hooks.
5
- * Runs Layer 0 security scan (local, no API, <50ms) before any tool executes.
6
- *
7
- * Claude Code hook contract:
8
- * - stdin: JSON event { session_id, hook_event_name, tool_name, tool_input }
9
- * - stdout: optional JSON { decision: 'block'|'approve', reason?: string, suppressOutput?: boolean }
10
- * - stderr: human-readable messages shown to user
11
- * - exit 0: allow (default)
12
- * - exit non-0: block with stderr shown to user
13
- *
14
- * Docs: https://docs.anthropic.com/en/docs/claude-code/hooks
15
- */
16
- import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, readSync, statSync, unlinkSync, } from 'fs';
17
- import { join } from 'path';
18
- import { homedir, tmpdir } 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';
22
- /**
23
- * Instruction-level scan pattern bank used by `scanPayload()`. Exported so
24
- * docs and the landing page can quote `PATTERNS.length` instead of hardcoding
25
- * a number that silently drifts (May 2026 audit found 50 / 47 / 60+ all in
26
- * the same PRD). Pair this with `CODE_PATTERNS` from `trust-scorer.ts` — the
27
- * two banks are scanned in different passes and counted separately.
28
- */
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
- },
42
- // Prompt Injection
43
- // Allow chained modifiers: "ignore all previous instructions", "ignore all your prior rules", etc.
44
- {
45
- regex: /ignore\s+(?:(?:previous|prior|above|all|your|the)\s+){1,4}(?:instructions?|rules?|prompts?|guidelines?)/i,
46
- severity: 'critical',
47
- category: 'Prompt Injection',
48
- description: 'Attempts to override system instructions',
49
- },
50
- {
51
- regex: /disregard\s+(previous|prior|all|your)\s+(instructions?|rules?|prompts?)/i,
52
- severity: 'critical',
53
- category: 'Prompt Injection',
54
- description: 'Disregard instruction override',
55
- },
56
- {
57
- regex: /forget\s+(everything|all|previous|your)\s*(instructions?|rules?|context)?/i,
58
- severity: 'critical',
59
- category: 'Prompt Injection',
60
- description: 'Context clearing attempt',
61
- },
62
- {
63
- regex: /you\s+are\s+now\s+(a|an|my)|new\s+role\s*:|act\s+as\s+(a|an|if)/i,
64
- severity: 'critical',
65
- category: 'Prompt Injection',
66
- description: 'Role hijacking attempt',
67
- },
68
- {
69
- regex: /\bsystem\s+prompt\b|<\/?system>|(?<!['"\\])<\/?instruction>/i,
70
- severity: 'critical',
71
- category: 'Prompt Injection',
72
- description: 'System prompt injection',
73
- },
74
- {
75
- regex: /\bdo\s+not\s+(follow|obey|listen|comply)\b/i,
76
- severity: 'critical',
77
- category: 'Prompt Injection',
78
- description: 'Instruction override attempt',
79
- },
80
- {
81
- regex: /\bjailbreak\b|\bDAN\s+mode\b|\bdeveloper\s+mode\s+enabled\b/i,
82
- severity: 'critical',
83
- category: 'Prompt Injection',
84
- description: 'Jailbreak attempt',
85
- },
86
- {
87
- regex: /bypass\s+(safety|restrictions?|filters?|guidelines?)/i,
88
- severity: 'critical',
89
- category: 'Prompt Injection',
90
- description: 'Safety bypass attempt',
91
- },
92
- {
93
- regex: /\[INST\]|\[\/INST\]|###\s*human:|###\s*assistant:/i,
94
- severity: 'high',
95
- category: 'Prompt Injection',
96
- description: 'Prompt template injection',
97
- },
98
- {
99
- regex: /override\s+(safety|security|policy|restrictions?)/i,
100
- severity: 'critical',
101
- category: 'Prompt Injection',
102
- description: 'Policy override attempt',
103
- },
104
- // Credential Theft
105
- {
106
- regex: /cat\s+~?\/?(\.env|\.secrets?|credentials?|\.aws\/credentials)/i,
107
- severity: 'critical',
108
- category: 'Credential Theft',
109
- description: 'Reads credential files',
110
- },
111
- {
112
- regex: /echo\s+\$\s*(API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY)/i,
113
- severity: 'critical',
114
- category: 'Credential Theft',
115
- description: 'Exfiltrates env secrets',
116
- },
117
- {
118
- regex: /\bprintenv\b|\benv\s*\|\s*(grep|curl|wget|nc)\b/i,
119
- severity: 'high',
120
- category: 'Credential Theft',
121
- description: 'Environment variable exfiltration',
122
- },
123
- {
124
- regex: /curl.*\$(API_KEY|TOKEN|SECRET|PASSWORD)/i,
125
- severity: 'critical',
126
- category: 'Credential Theft',
127
- description: 'API key exfiltration via curl',
128
- },
129
- // Reading SSH private keys is never benign in dev workflow — promote to critical.
130
- // ssh-keygen alone (key generation) stays 'high' since it's also a legitimate setup operation.
131
- {
132
- regex: /cat\s+~?\/?\.ssh\/(id_rsa|id_ed25519|id_ecdsa|id_dsa)\b/i,
133
- severity: 'critical',
134
- category: 'Credential Theft',
135
- description: 'Reads SSH private key',
136
- },
137
- {
138
- regex: /ssh-keygen|cat\s+~?\/?\.ssh\/authorized_keys/i,
139
- severity: 'high',
140
- category: 'Credential Theft',
141
- description: 'SSH key tooling access',
142
- },
143
- {
144
- regex: /\bkeychain\b|\bsecurity\s+find-generic-password\b/i,
145
- severity: 'high',
146
- category: 'Credential Theft',
147
- description: 'macOS keychain access attempt',
148
- },
149
- {
150
- regex: /OPENAI_API_KEY|ANTHROPIC_API_KEY|GITHUB_TOKEN/i,
151
- severity: 'high',
152
- category: 'Credential Theft',
153
- description: 'Known API key reference',
154
- },
155
- {
156
- regex: /\bsecret_key\b|\bapi_secret\b|\bauth_token\b/i,
157
- severity: 'medium',
158
- category: 'Credential Theft',
159
- description: 'Secret key reference',
160
- },
161
- {
162
- regex: /curl.*webhook.*\$\w+|wget.*\$\w+.*credentials/i,
163
- severity: 'critical',
164
- category: 'Credential Theft',
165
- description: 'Credential webhook exfiltration',
166
- },
167
- {
168
- regex: /\/proc\/\d+\/environ|\/etc\/shadow/i,
169
- severity: 'critical',
170
- category: 'Credential Theft',
171
- description: 'System credential file access',
172
- },
173
- // Destructive Operations
174
- // Block rm -rf against system roots or a user's whole home — but allow
175
- // routine deletion under /tmp, ~/Library/Caches, node_modules, etc.
176
- // Previous pattern included bare `\/` which matched any path starting
177
- // with `/` (so `rm -rf /tmp/foo` was blocked). The alternatives below
178
- // require an explicit system target or a bare home reference.
179
- {
180
- regex: /rm\s+-rf?\s+(?:\/(?:["'\s]|$|\*)|\/(?:home|etc|var|usr|bin|sbin|System|Library|opt|root|boot|dev)\b|~(?:["'\s]|$|\/(?:["'\s]|$|\*))|\$HOME(?:["'\s]|$|\/(?:["'\s]|$|\*))|\.\.(?:["'\s]|$))/im,
181
- severity: 'critical',
182
- category: 'Destructive',
183
- description: 'Recursive deletion of system/home directories',
184
- },
185
- {
186
- regex: /delete\s+all\s+files|wipe\s+(disk|drive|everything)|format\s+[a-z]:/i,
187
- severity: 'critical',
188
- category: 'Destructive',
189
- description: 'Mass file deletion',
190
- },
191
- {
192
- regex: />\s*\/dev\/(sda|hda|vda|nvme)|dd\s+if=.*of=\/dev/i,
193
- severity: 'critical',
194
- category: 'Destructive',
195
- description: 'Raw disk write attempt',
196
- },
197
- {
198
- regex: /DROP\s+TABLE|DROP\s+DATABASE|TRUNCATE\s+TABLE/i,
199
- severity: 'critical',
200
- category: 'Destructive',
201
- description: 'Database destruction',
202
- },
203
- // Force-push or hard reset against main/master/production is irreversible loss of shared history — critical.
204
- // Force-push to other branches stays 'high' since rebase-and-force-push to feature branches is routine.
205
- {
206
- regex: /git\s+(?:push\s+(?:--force|-f)|reset\s+--hard)\s+\S*origin\s+(main|master|production|release|prod)\b/i,
207
- severity: 'critical',
208
- category: 'Destructive',
209
- description: 'Force push or hard reset to a protected branch',
210
- },
211
- {
212
- regex: /git\s+(push\s+(?:--force|-f)|reset\s+--hard)\s+origin/i,
213
- severity: 'high',
214
- category: 'Destructive',
215
- description: 'Force push or hard reset to remote',
216
- },
217
- {
218
- regex: /shutdown\s+-[hr]|reboot\s+--force|init\s+0/i,
219
- severity: 'high',
220
- category: 'Destructive',
221
- description: 'System shutdown/reboot',
222
- },
223
- {
224
- regex: /pkill\s+-9\s+(node|python|ruby|java)\b/i,
225
- severity: 'medium',
226
- category: 'Destructive',
227
- description: 'Process mass termination',
228
- },
229
- {
230
- regex: /crontab\s+-r|at\s+now.*rm|systemctl\s+disable\s+--now/i,
231
- severity: 'high',
232
- category: 'Destructive',
233
- description: 'Scheduled destruction',
234
- },
235
- // Code/Command Injection
236
- // NOTE: patterns match execution intent, not grep/search of these strings.
237
- // A command starting with "grep", "rg", "ag", or "find" is a search, not execution —
238
- // those are pre-filtered in buildScanPayload before patterns run.
239
- {
240
- regex: /curl\s+\S+\s*\|\s*(bash|sh|python|ruby|perl|node)\b/i,
241
- severity: 'critical',
242
- category: 'Code Injection',
243
- description: 'Remote script piped directly to shell',
244
- },
245
- {
246
- regex: /wget\s+(-qO-\s+)?https?:\/\/\S+\s*\|\s*(bash|sh)/i,
247
- severity: 'critical',
248
- category: 'Code Injection',
249
- description: 'Remote code execution via wget',
250
- },
251
- {
252
- regex: /node\s+-e\s+["'].*eval\s*\(|node\s+-e\s+["'].*require\s*\(/i,
253
- severity: 'critical',
254
- category: 'Code Injection',
255
- description: 'Inline node eval execution',
256
- },
257
- {
258
- regex: /eval\s*\(.*process\.env\.|eval\s*\(.*\$_(GET|POST|REQUEST)/i,
259
- severity: 'critical',
260
- category: 'Code Injection',
261
- description: 'eval() with external or env input',
262
- },
263
- {
264
- regex: /exec\s*\(\s*['"]\s*system/i,
265
- severity: 'critical',
266
- category: 'Code Injection',
267
- description: 'System shell from code',
268
- },
269
- {
270
- regex: /python\s+-c\s+["']import\s+os;os\.system/i,
271
- severity: 'high',
272
- category: 'Code Injection',
273
- description: 'Python shell injection',
274
- },
275
- // Reverse shell forms:
276
- // nc -e /bin/bash 1.2.3.4 4444 (binary between -e and IP)
277
- // nc -e 1.2.3.4 4444 (IP directly after -e)
278
- // bash -i >& /dev/tcp/host/port (bash builtin)
279
- {
280
- regex: /nc\s+-[eln]+(?:\s+\S+)?\s+\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|bash\s+-i\s+>&\s*\/dev\/tcp/i,
281
- severity: 'critical',
282
- category: 'Code Injection',
283
- description: 'Reverse shell attempt',
284
- },
285
- {
286
- regex: /\bbase64\b.*\|.*(bash|sh)\b|\bbase64\b.*exec/i,
287
- severity: 'critical',
288
- category: 'Code Injection',
289
- description: 'Base64-encoded code execution',
290
- },
291
- {
292
- regex: /\$\(curl|subprocess\.run\(['"]curl/i,
293
- severity: 'high',
294
- category: 'Code Injection',
295
- description: 'Command substitution with curl',
296
- },
297
- // Supply Chain
298
- {
299
- regex: /npm\s+install\s+[a-z0-9-]+[0-9]{4,}|pip\s+install\s+\w+\d{6,}/i,
300
- severity: 'high',
301
- category: 'Supply Chain',
302
- description: 'Suspicious package version pattern',
303
- },
304
- {
305
- regex: /npm\s+install\s+--save\s+[a-z]{2}\.[a-z]{2}\.[a-z]{2}/i,
306
- severity: 'high',
307
- category: 'Supply Chain',
308
- description: 'Typosquatting package pattern',
309
- },
310
- {
311
- regex: /preinstall|postinstall|prepare/i,
312
- severity: 'medium',
313
- category: 'Supply Chain',
314
- description: 'Lifecycle script reference',
315
- },
316
- {
317
- regex: /registry\.npmjs\.org\/[^/]+\/-\/[^/]+\.tgz.*;\s*bash/i,
318
- severity: 'critical',
319
- category: 'Supply Chain',
320
- description: 'Direct npm tarball execution',
321
- },
322
- // OWASP / Security Misconfig
323
- {
324
- regex: /Access-Control-Allow-Origin:\s*\*/i,
325
- severity: 'medium',
326
- category: 'OWASP',
327
- description: 'Wildcard CORS header',
328
- },
329
- {
330
- regex: /verify\s*=\s*False|ssl_verify\s*=\s*False|checkCertificate\s*=\s*false/i,
331
- severity: 'high',
332
- category: 'OWASP',
333
- description: 'SSL certificate verification disabled',
334
- },
335
- {
336
- regex: /password\s*=\s*["'][^"']{0,16}["']|secret\s*=\s*["'][^"']{0,16}["']/i,
337
- severity: 'medium',
338
- category: 'OWASP',
339
- description: 'Hardcoded credential',
340
- },
341
- {
342
- regex: /eval\s*\([^)]*user|innerHTML\s*=\s*[^;]*user|document\.write\s*\([^)]*user/i,
343
- severity: 'high',
344
- category: 'OWASP',
345
- description: 'Potential XSS via user input',
346
- },
347
- {
348
- regex: /SELECT\s+\*?\s+FROM\s+\w+\s+WHERE.*['"]\s*\+|query\s*\+\s*user/i,
349
- severity: 'critical',
350
- category: 'OWASP',
351
- description: 'SQL injection pattern',
352
- },
353
- // Data Exfiltration
354
- {
355
- regex: /curl\s+.*-d\s+@\/|curl\s+.*--data-binary\s+@\//i,
356
- severity: 'high',
357
- category: 'Data Exfiltration',
358
- description: 'File upload via curl',
359
- },
360
- {
361
- regex: /scp\s+.*@\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/i,
362
- severity: 'high',
363
- category: 'Data Exfiltration',
364
- description: 'SCP to external IP',
365
- },
366
- {
367
- regex: /rclone\s+copy|rsync\s+-av?[rpz]+\s+\/\w+.*@/i,
368
- severity: 'medium',
369
- category: 'Data Exfiltration',
370
- description: 'Mass file sync to remote',
371
- },
372
- ];
373
- // ── Risk scoring ────────────────────────────────────────────────────────────
374
- const SEVERITY_SCORES = {
375
- critical: 40,
376
- high: 20,
377
- medium: 10,
378
- low: 5,
379
- };
380
- /**
381
- * Scans a text payload against all Layer 0 patterns.
382
- * Returns findings and a 0-100 risk score.
383
- * Average run time: < 5ms on a modern CPU.
384
- */
385
- export function scanPayload(text) {
386
- const findings = [];
387
- for (const pattern of PATTERNS) {
388
- const match = text.match(pattern.regex);
389
- if (match) {
390
- findings.push({
391
- severity: pattern.severity,
392
- category: pattern.category,
393
- description: pattern.description,
394
- matched: match[0].substring(0, 120), // truncate for audit log
395
- });
396
- }
397
- }
398
- return findings;
399
- }
400
- export function calculateRiskScore(findings) {
401
- let score = 0;
402
- for (const f of findings) {
403
- score += SEVERITY_SCORES[f.severity] ?? 5;
404
- }
405
- return Math.min(100, score);
406
- }
407
- // ── Audit log ───────────────────────────────────────────────────────────────
408
- /**
409
- * Audit log path — defaults to ~/.codehere/audit/hook-events.jsonl.
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).
416
- */
417
- /**
418
- * S9 · practice mode leaves no marks: a CODEHERE_MOCK=1 process never writes
419
- * the REAL audit chain. Mock isolated spend but not the chain — the atomic
420
- * audit proved it by landing probe entries in ~/.codehere (seq ~11862). The
421
- * mock chain lives at a STABLE tmp path (one sign-state key, not one per
422
- * boot) and the daemon wipes it + prunes its sign-state key at boot, so
423
- * every practice session starts clean and leaves zero residue. An explicit
424
- * CODEHERE_AUDIT_FILE still wins (tests point it at their own scratch).
425
- */
426
- export const MOCK_AUDIT_FILE = join(tmpdir(), 'codehere-mock', 'audit', 'hook-events.jsonl');
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'));
431
- const GENESIS_PREV_HASH = '0'.repeat(64);
432
- /**
433
- * Canonicalize an audit entry for hashing. We sort keys alphabetically and
434
- * stringify with no whitespace so the hash is reproducible across writers.
435
- * The `entryHash` field is excluded from its own hash input (chicken-and-egg).
436
- */
437
- function canonicalizeForHash(entry) {
438
- const { entryHash: _entryHash, ...rest } = entry;
439
- void _entryHash; // referenced to satisfy lint; the destructure is the point
440
- const keys = Object.keys(rest).sort();
441
- const ordered = {};
442
- for (const k of keys) {
443
- ordered[k] = rest[k];
444
- }
445
- return JSON.stringify(ordered);
446
- }
447
- /** SHA-256 hex of an entry's canonical form (entryHash excluded). */
448
- export function computeEntryHash(entry) {
449
- return createHash('sha256').update(canonicalizeForHash(entry)).digest('hex');
450
- }
451
- /**
452
- * Read the chain tip (seq + entryHash of the last chained entry) from disk.
453
- * Walks BACKWARD from EOF looking for the most recent line that carries
454
- * chain fields. Pre-fix this only checked the last line — if that line was
455
- * legacy (no chain fields), the chain restarted at seq=0 producing a fake
456
- * hash_mismatch in verifyAuditChain. Walking backward keeps the chain
457
- * continuous across legacy-entry interleavings (rare but possible when
458
- * an out-of-band script appends raw JSON to the audit file).
459
- */
460
- /**
461
- * The actual backward-walk: most recent line carrying chain fields wins.
462
- * Pure over a set of lines so both the bounded and full readers share it.
463
- */
464
- export function tipFromLines(lines) {
465
- for (let i = lines.length - 1; i >= 0; i--) {
466
- const raw = lines[i];
467
- if (!raw.trim())
468
- continue;
469
- try {
470
- const entry = JSON.parse(raw);
471
- if (typeof entry.seq === 'number' && typeof entry.entryHash === 'string') {
472
- return { seq: entry.seq, hash: entry.entryHash };
473
- }
474
- // Legacy entry — keep walking backward
475
- }
476
- catch {
477
- // Parse failure — skip and continue
478
- }
479
- }
480
- return null;
481
- }
482
- function readChainTipFull(filepath) {
483
- let content;
484
- try {
485
- content = readFileSync(filepath, 'utf-8');
486
- }
487
- catch {
488
- return null;
489
- }
490
- return tipFromLines(content.split('\n'));
491
- }
492
- /**
493
- * Read the chain tip (seq + entryHash of the last chained entry) from disk.
494
- *
495
- * Hot path: appendAuditLog calls this on every tool call to compute the next
496
- * seq/prevHash. Reading the whole file each time is O(filesize), and the audit
497
- * log grows unbounded (one line per tool call), so a heavy user eventually
498
- * pays a multi-MB read on every call — enough to blow the <50ms hook budget.
499
- *
500
- * So we read only the last `tailBytes` and run the SAME backward-walk on that
501
- * window. The window starts mid-file, so its first line is probably truncated
502
- * — we drop it (dropping the OLDEST line in the window never changes the most
503
- * recent chain entry, which is what we return). If the window holds no chain
504
- * entry (e.g. a long run of legacy entries, or the only entry was the dropped
505
- * partial line), we fall back to a full read. The bounded read is therefore a
506
- * pure optimization: it can never return a different tip than the full read,
507
- * only reach it faster. Proven by the property test in
508
- * test/unit/chain-tip-bounded.test.ts.
509
- */
510
- export function readChainTip(filepath, tailBytes = 65536) {
511
- if (!existsSync(filepath))
512
- return null;
513
- let size;
514
- try {
515
- size = statSync(filepath).size;
516
- }
517
- catch {
518
- return null;
519
- }
520
- if (size === 0)
521
- return null;
522
- if (size <= tailBytes)
523
- return readChainTipFull(filepath);
524
- // Bounded tail read: last `tailBytes` ending at EOF (so the final line is
525
- // never truncated; only the first line in the window may be).
526
- let fd;
527
- try {
528
- fd = openSync(filepath, 'r');
529
- const buf = Buffer.alloc(tailBytes);
530
- const bytesRead = readSync(fd, buf, 0, tailBytes, size - tailBytes);
531
- const lines = buf.toString('utf-8', 0, bytesRead).split('\n');
532
- lines.shift(); // drop the possibly-truncated first line
533
- const tip = tipFromLines(lines);
534
- if (tip)
535
- return tip;
536
- }
537
- catch {
538
- // fall through to the full read
539
- }
540
- finally {
541
- if (fd !== undefined)
542
- closeSync(fd);
543
- }
544
- // Pathological: no chain entry in the tail window. Pay the full read.
545
- return readChainTipFull(filepath);
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
- }
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) {
643
- // Cross-process file locking. Multiple Claude Code sessions (e.g., two
644
- // terminals, or IDE + terminal) can trigger PreToolUse concurrently.
645
- // Without a lock, two hook processes read the same chain tip and both
646
- // write seq 43 — corrupting the hash chain. We use an exclusive-create
647
- // lock file (+O_EXCL) which is atomic on all POSIX systems.
648
- const LOCK_FILE = AUDIT_FILE + '.lock';
649
- const lockAcquired = () => {
650
- for (let attempt = 0; attempt < 20; attempt++) {
651
- try {
652
- const fd = openSync(LOCK_FILE, 'wx');
653
- closeSync(fd);
654
- return true;
655
- }
656
- catch {
657
- // Lock held by another process — spin-wait ~10ms
658
- const deadline = Date.now() + 10;
659
- while (Date.now() < deadline) {
660
- /* busy-wait — sync context */
661
- }
662
- }
663
- }
664
- return false;
665
- };
666
- const lockReleased = () => {
667
- try {
668
- unlinkSync(LOCK_FILE);
669
- }
670
- catch {
671
- /* best effort */
672
- }
673
- };
674
- if (!lockAcquired()) {
675
- // Fall through unlocked rather than drop the entry. The entry still
676
- // appends; verification may flag a chain break, but the audit data
677
- // is preserved.
678
- }
679
- try {
680
- const dir = AUDIT_FILE.substring(0, AUDIT_FILE.lastIndexOf('/'));
681
- if (dir && !existsSync(dir)) {
682
- // mode 0o700 — owner-only directory. The audit log records every tool call
683
- // including bash commands and file paths; on multi-user systems a default
684
- // 0o755 dir would let other users walk in and read the file. Surfaced by
685
- // the May 1 2026 /cso security audit (Finding #1, MEDIUM).
686
- mkdirSync(dir, { recursive: true, mode: 0o700 });
687
- }
688
- // Compute chain fields. Read tip from file (cheap tail read). If no chain
689
- // exists (fresh file, or last line is pre-chain legacy), start at genesis.
690
- const tip = readChainTip(AUDIT_FILE);
691
- const seq = tip ? tip.seq + 1 : 0;
692
- const prevHash = tip ? tip.hash : GENESIS_PREV_HASH;
693
- const chainedEntry = { ...entry, seq, prevHash };
694
- chainedEntry.entryHash = computeEntryHash(chainedEntry);
695
- // mode 0o600 — owner-only file. Same reasoning as the dir above. Only takes
696
- // effect on first write (file creation); existing files are chmod'd below
697
- // for backwards compatibility with audit logs created before this fix.
698
- appendFileSync(AUDIT_FILE, JSON.stringify(chainedEntry) + '\n', { mode: 0o600 });
699
- // Idempotent: if the file existed pre-fix with 0o644, tighten it now. The
700
- // chmod is cheap and silent on already-0o600 files.
701
- try {
702
- chmodSync(AUDIT_FILE, 0o600);
703
- }
704
- catch {
705
- /* fall through — chmod failure shouldn't block the audit append */
706
- }
707
- }
708
- catch {
709
- // Audit log failure must not block tool execution
710
- }
711
- finally {
712
- lockReleased();
713
- }
714
- }
715
- /**
716
- * Walk the audit file and verify the hash chain. Returns:
717
- * - valid: true iff every chained entry's prevHash matches predecessor's
718
- * entryHash, and every entry's entryHash matches recomputed value
719
- * - totalEntries / chainedEntries / legacyEntries: line counts
720
- * - errors: every detected break (verifier doesn't stop at first failure;
721
- * reporter shows ALL tampering points so a forensic reviewer can
722
- * see the full picture)
723
- *
724
- * Legacy preamble: pre-2026-05-06 entries (no seq/prevHash/entryHash) are
725
- * counted as legacyEntries and skipped. The chain starts at the FIRST entry
726
- * with seq=0 — every subsequent chained entry must continue from there.
727
- */
728
- export function verifyAuditChain(filepath = AUDIT_FILE) {
729
- const result = {
730
- valid: true,
731
- totalEntries: 0,
732
- chainedEntries: 0,
733
- legacyEntries: 0,
734
- writerMix: { daemon: 0, hookDirect: 0, unattributed: 0 },
735
- errors: [],
736
- };
737
- if (!existsSync(filepath))
738
- return result;
739
- let content;
740
- try {
741
- content = readFileSync(filepath, 'utf-8');
742
- }
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' });
747
- return result;
748
- }
749
- const lines = content.split('\n');
750
- let prevHash = null; // null until we hit the first chained entry
751
- let prevSeq = null;
752
- for (let i = 0; i < lines.length; i++) {
753
- const raw = lines[i];
754
- if (!raw.trim())
755
- continue;
756
- const lineNumber = i + 1;
757
- result.totalEntries++;
758
- let entry;
759
- try {
760
- entry = JSON.parse(raw);
761
- }
762
- catch {
763
- result.valid = false;
764
- result.errors.push({
765
- lineNumber,
766
- reason: 'parse_failure',
767
- detail: 'JSON parse failed',
768
- });
769
- continue;
770
- }
771
- const hasChainFields = typeof entry.seq === 'number' && typeof entry.prevHash === 'string' && typeof entry.entryHash === 'string';
772
- if (!hasChainFields) {
773
- result.legacyEntries++;
774
- continue;
775
- }
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++;
784
- // Recompute entryHash and check it matches what was stored
785
- const recomputed = computeEntryHash(entry);
786
- if (recomputed !== entry.entryHash) {
787
- result.valid = false;
788
- result.errors.push({
789
- lineNumber,
790
- seq: entry.seq,
791
- reason: 'self_hash_mismatch',
792
- detail: `entryHash field does not match recomputed hash; entry was modified after writing`,
793
- });
794
- // Continue checking — we still want to report the chain link below,
795
- // but using the STORED hash so cascading errors are honest about the
796
- // physical state of the file.
797
- }
798
- // Chain link check
799
- if (prevHash === null) {
800
- // First chained entry: must be genesis (seq=0, prevHash=64x0)
801
- if (entry.seq !== 0 || entry.prevHash !== GENESIS_PREV_HASH) {
802
- result.valid = false;
803
- result.errors.push({
804
- lineNumber,
805
- seq: entry.seq,
806
- reason: 'genesis_violation',
807
- 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)}…`,
808
- });
809
- }
810
- }
811
- else {
812
- if (entry.seq !== (prevSeq ?? -1) + 1) {
813
- result.valid = false;
814
- result.errors.push({
815
- lineNumber,
816
- seq: entry.seq,
817
- reason: 'seq_gap',
818
- detail: `expected seq=${(prevSeq ?? -1) + 1}, got seq=${entry.seq}`,
819
- });
820
- }
821
- if (entry.prevHash !== prevHash) {
822
- result.valid = false;
823
- result.errors.push({
824
- lineNumber,
825
- seq: entry.seq,
826
- reason: 'hash_mismatch',
827
- detail: `prevHash=${entry.prevHash?.slice(0, 12)}… does not match predecessor entryHash=${prevHash.slice(0, 12)}…`,
828
- });
829
- }
830
- }
831
- // Move forward using the STORED entryHash. Even if recomputed mismatched,
832
- // the next entry's prevHash was written against the stored value, so we
833
- // chain on stored. The self-hash error above already flagged the breach.
834
- prevHash = entry.entryHash;
835
- prevSeq = entry.seq;
836
- }
837
- return result;
838
- }
839
- /**
840
- * Record a trust-override event in the audit log. Used when the scanner is
841
- * bypassed for any reason (stdin failure, parse failure, --log-only mode,
842
- * scan exception). Per CC frontier Q1 (auditable permission trails) — the
843
- * audit MUST capture bypasses, not just block/allow decisions, so a reviewer
844
- * can see EVERY case where the safety policy didn't run normally.
845
- *
846
- * Best-effort: never throws. The bypass already happened by the time we get
847
- * here; failure to write the audit just means the bypass is undocumented.
848
- *
849
- * Fields default to safe minimums: empty toolInput, zero risk, blocked=false
850
- * (because the tool DID run). Caller can override what it knows.
851
- */
852
- export function appendOverrideAudit(reason, partial = {}) {
853
- const entry = {
854
- timestamp: new Date().toISOString(),
855
- sessionId: partial.sessionId ?? 'unknown',
856
- toolName: partial.toolName ?? 'unknown',
857
- toolInput: partial.toolInput ?? '',
858
- riskScore: partial.riskScore ?? 0,
859
- blocked: false, // override = scanner didn't block, regardless of what it would have decided
860
- findings: partial.findings ?? 0,
861
- criticalFindings: partial.criticalFindings ?? 0,
862
- overrideReason: reason,
863
- };
864
- if (partial.note && partial.note.trim())
865
- entry.overrideNote = partial.note.trim().slice(0, 500);
866
- appendAuditLog(entry);
867
- }
868
- // ── Main hook handler ────────────────────────────────────────────────────────
869
- /**
870
- * Process a Claude Code hook event.
871
- * Called with the parsed stdin JSON from Claude Code's hook system.
872
- *
873
- * Returns:
874
- * - null: allow the tool call, print nothing
875
- * - HookDecision with decision:'block': reject the tool call
876
- */
877
- /**
878
- * Build a scan payload that is appropriate for the tool type.
879
- *
880
- * Bash / NotebookEdit: the command IS the executable — scan everything.
881
- * Exception: commands that start with grep/rg/find/ag are search operations,
882
- * not execution — strip the search term so pattern strings in args don't
883
- * trigger the scanner.
884
- *
885
- * Write / Edit / MultiEdit: the content is DATA being written to disk, not
886
- * a command being executed. Only scan the file path (destination) — that's
887
- * enough to catch writes to sensitive files (~/.ssh/id_rsa, ~/.aws/credentials).
888
- * Do NOT scan content: it produces false positives on legitimate source code
889
- * that mentions attack-shaped strings (i18n with "system prompt", README docs
890
- * describing what we block, LLM-prompt-engineering code, security tests).
891
- * The Bash surface is the right place to catch prompt-injection / code-injection
892
- * patterns; Edit-time content is data, not execution intent.
893
- */
894
- function buildScanPayload(toolName, toolInput) {
895
- if (toolName === 'Bash') {
896
- const cmd = String(toolInput.command ?? '');
897
- // Search commands — only scan the binary name and flags, not the pattern argument.
898
- // grep/rg/ag/find searching FOR dangerous strings is not dangerous.
899
- if (/^\s*(grep|rg|ag|find|awk|sed)\s/.test(cmd)) {
900
- const binary = cmd.match(/^\s*(\S+)/)?.[1] ?? cmd;
901
- return JSON.stringify({ command: binary });
902
- }
903
- return JSON.stringify({ command: cmd });
904
- }
905
- if (toolName === 'Write') {
906
- // Only scan the destination path — content is data, not a command
907
- return JSON.stringify({ file_path: toolInput.file_path ?? '' });
908
- }
909
- if (toolName === 'Edit') {
910
- // Same model as Write: scan only the destination. The old_string is
911
- // EXISTING file content (data), not execution intent — scanning it
912
- // produced false positives on legitimate edits to files containing
913
- // attack-shaped strings (e.g. i18n.js with a "system prompt" key).
914
- return JSON.stringify({ file_path: toolInput.file_path ?? '' });
915
- }
916
- if (toolName === 'MultiEdit') {
917
- // Scan file paths only
918
- const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
919
- return JSON.stringify({ file_paths: edits.map((e) => e.file_path ?? '') });
920
- }
921
- // Unknown tool — scan everything
922
- return JSON.stringify(toolInput);
923
- }
924
- export function processHookEvent(event) {
925
- const { hook_event_name, tool_name = 'unknown', tool_input = {}, session_id = 'unknown' } = event;
926
- // Only intercept PreToolUse events
927
- if (hook_event_name !== 'PreToolUse') {
928
- return null;
929
- }
930
- // Build context-appropriate scan payload (Bash = full, Write/Edit = path only)
931
- const payload = buildScanPayload(tool_name, tool_input);
932
- // Layer 0 scan — local, no API, ~2ms
933
- const rawFindings = scanPayload(payload);
934
- // P18 slice 2 — Tier 1 hook now honors per-cwd permissions.yaml.
935
- // The hook subprocess inherits Claude Code's cwd when spawned, so
936
- // process.cwd() points at the project root where .codehere/
937
- // permissions.yaml would live. Allow-listed findings are dropped
938
- // from the BLOCK calculation but still recorded in the audit entry
939
- // so a forensic reviewer sees what was suppressed and why.
940
- const cwd = process.cwd();
941
- const { surviving: findings, allowed } = filterAllowed(rawFindings, cwd);
942
- const riskScore = calculateRiskScore(findings);
943
- const criticalFindings = findings.filter((f) => f.severity === 'critical');
944
- // Blocking threshold: critical finding OR risk score too high
945
- const blocked = criticalFindings.length > 0 || riskScore >= 60;
946
- // Build audit entry — record what got allow-listed for the trail
947
- const auditEntry = {
948
- timestamp: new Date().toISOString(),
949
- sessionId: session_id,
950
- toolName: tool_name,
951
- toolInput: payload.substring(0, 500), // truncate long inputs
952
- riskScore,
953
- blocked,
954
- findings: findings.length,
955
- criticalFindings: criticalFindings.length,
956
- };
957
- if (allowed.length > 0) {
958
- // Add to the audit row's toolInput JSON so the audit feed can render
959
- // "X findings allow-listed by permissions.yaml" badges later.
960
- try {
961
- const parsed = JSON.parse(auditEntry.toolInput);
962
- parsed.allowedByPermissions = allowed.map((a) => ({
963
- category: a.finding.category,
964
- matched: a.finding.matched,
965
- reason: a.entry.reason ?? '(no reason given)',
966
- }));
967
- auditEntry.toolInput = JSON.stringify(parsed).substring(0, 800);
968
- }
969
- catch {
970
- // toolInput wasn't JSON (rare path); skip enrichment
971
- }
972
- }
973
- appendAuditLog(auditEntry);
974
- if (!blocked) {
975
- return null; // allow
976
- }
977
- // Build human-readable block reason
978
- const topFinding = criticalFindings[0] ?? findings[0];
979
- const categoryVoice = {
980
- 'Prompt Injection': 'Caught a prompt injection attempt',
981
- 'Credential Theft': "Stopped — this looks like it's trying to access credentials",
982
- Destructive: 'Stopped — this could delete or destroy data',
983
- 'Code Injection': 'Blocked — this pattern looks like code injection',
984
- 'Supply Chain': 'Blocked — suspicious package or dependency pattern',
985
- OWASP: 'Blocked — security misconfiguration pattern',
986
- 'Data Exfiltration': "Blocked — this looks like it's trying to send data out",
987
- };
988
- const reason = topFinding
989
- ? `[codehere] ${categoryVoice[topFinding.category] ?? 'Blocked'} — ${topFinding.description}. (matched: "${topFinding.matched}")`
990
- : `[codehere] Blocked — risk score ${riskScore}/100 is too high to allow this through`;
991
- return {
992
- decision: 'block',
993
- reason,
994
- };
995
- }
996
- // ── Hook configuration generator ────────────────────────────────────────────
997
- /**
998
- * Returns the JSON config block to add to ~/.claude/settings.json
999
- * under the "hooks" key.
1000
- */
1001
- export function generateHookConfig(options = {}) {
1002
- const command = options.logOnly
1003
- ? 'codehere scan-tool --log-only'
1004
- : `codehere scan-tool${options.blockMode === 'all' ? ' --block-all' : ''}`;
1005
- // Cover every tool that can mutate the filesystem or run code.
1006
- // NotebookEdit added to catch Jupyter notebook writes.
1007
- const hook = { type: 'command', command };
1008
- return {
1009
- PreToolUse: [
1010
- { matcher: 'Bash', hooks: [hook] },
1011
- { matcher: 'Edit', hooks: [hook] },
1012
- { matcher: 'Write', hooks: [hook] },
1013
- { matcher: 'MultiEdit', hooks: [hook] },
1014
- { matcher: 'NotebookEdit', hooks: [hook] },
1015
- ],
1016
- };
1017
- }
1018
- //# sourceMappingURL=claude-sdk-hook.js.map