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