@rulecatch/ai-pooler 0.4.0

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.
@@ -0,0 +1,549 @@
1
+ #!/bin/bash
2
+ # Rulecatch AI tracking hook - Auto-generated
3
+ # Writes events to buffer files for batch processing
4
+ # Supports: Zero-knowledge encryption, Regional routing, Adaptive throttling
5
+
6
+ # Debug logging - set RULECATCH_DEBUG=true for verbose output
7
+ DEBUG="${RULECATCH_DEBUG:-false}"
8
+ LOG_FILE="/tmp/rulecatch-hook.log"
9
+
10
+ log() {
11
+ echo "[$(date)] $1" >> "$LOG_FILE"
12
+ }
13
+
14
+ log_debug() {
15
+ if [ "$DEBUG" == "true" ]; then
16
+ echo "[$(date)] [DEBUG] $1" >> "$LOG_FILE"
17
+ fi
18
+ }
19
+
20
+ log "Hook called"
21
+
22
+ # ============================================================================
23
+ # CONFIGURATION
24
+ # ============================================================================
25
+
26
+ CONFIG_DIR="$HOME/.claude/rulecatch"
27
+ CONFIG_FILE="$CONFIG_DIR/config.json"
28
+ BUFFER_DIR="$CONFIG_DIR/buffer"
29
+ FLUSH_SCRIPT="$HOME/.claude/hooks/rulecatch-flush.js"
30
+
31
+ # Exit if not configured
32
+ [ ! -f "$CONFIG_FILE" ] && exit 0
33
+
34
+ # Exit if data collection is paused (subscription expired)
35
+ # User must run `npx @rulecatch/ai-pooler reactivate` to resume
36
+ PAUSED_FILE="$CONFIG_DIR/.paused"
37
+ [ -f "$PAUSED_FILE" ] && exit 0
38
+
39
+ # Read config
40
+ API_KEY=$(jq -r '.apiKey // empty' "$CONFIG_FILE")
41
+ [ -z "$API_KEY" ] && exit 0
42
+
43
+ REGION=$(jq -r '.region // "us"' "$CONFIG_FILE")
44
+ ENCRYPTION_KEY=$(jq -r '.encryptionKey // empty' "$CONFIG_FILE")
45
+ SALT=$(jq -r '.salt // "rulecatch"' "$CONFIG_FILE")
46
+
47
+ # Ensure buffer directory exists
48
+ mkdir -p "$BUFFER_DIR"
49
+
50
+ # Determine if privacy/encryption is enabled
51
+ PRIVACY_ENABLED="false"
52
+ if [ -n "$ENCRYPTION_KEY" ]; then
53
+ PRIVACY_ENABLED="true"
54
+ fi
55
+
56
+ # ============================================================================
57
+ # ENCRYPTION FUNCTIONS
58
+ # Uses AES-256-GCM with PBKDF2 key derivation
59
+ # Format: iv_base64:ciphertext_base64 (compatible with browser Web Crypto API)
60
+ # ============================================================================
61
+
62
+ # Encrypt a single value using AES-256-GCM
63
+ encrypt_pii() {
64
+ local plaintext="$1"
65
+
66
+ if [ -z "$plaintext" ] || [ "$PRIVACY_ENABLED" != "true" ]; then
67
+ echo "$plaintext"
68
+ return
69
+ fi
70
+
71
+ # Use Python for reliable PBKDF2 + AES-GCM (matches browser Web Crypto)
72
+ python3 - "$plaintext" "$ENCRYPTION_KEY" <<'PYEOF' 2>/dev/null
73
+ import sys
74
+ import os
75
+ import base64
76
+ import hashlib
77
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
78
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
79
+ from cryptography.hazmat.primitives import hashes
80
+
81
+ plaintext = sys.argv[1]
82
+ password = sys.argv[2]
83
+
84
+ # Derive key using PBKDF2 (must match browser: salt="rulecatch", iterations=100000)
85
+ kdf = PBKDF2HMAC(
86
+ algorithm=hashes.SHA256(),
87
+ length=32,
88
+ salt=b'rulecatch',
89
+ iterations=100000,
90
+ )
91
+ key = kdf.derive(password.encode())
92
+
93
+ # Generate random IV (12 bytes for GCM)
94
+ iv = os.urandom(12)
95
+
96
+ # Encrypt with AES-256-GCM
97
+ aesgcm = AESGCM(key)
98
+ ciphertext = aesgcm.encrypt(iv, plaintext.encode(), None)
99
+
100
+ # Output format: iv:ciphertext (both base64)
101
+ print(base64.b64encode(iv).decode() + ':' + base64.b64encode(ciphertext).decode())
102
+ PYEOF
103
+
104
+ # If Python encryption failed, return original value
105
+ if [ $? -ne 0 ]; then
106
+ echo "$plaintext"
107
+ fi
108
+ }
109
+
110
+ # Hash a value for searchable indexing (one-way, for grouping/filtering)
111
+ hash_for_index() {
112
+ local plaintext="$1"
113
+ if [ -z "$plaintext" ]; then
114
+ echo ""
115
+ return
116
+ fi
117
+ # Truncated SHA256 hash with salt
118
+ echo -n "${plaintext}${SALT}" | sha256sum | cut -c1-16
119
+ }
120
+
121
+ # ============================================================================
122
+ # MAIN LOGIC
123
+ # ============================================================================
124
+
125
+ # Read JSON from stdin
126
+ INPUT=$(cat)
127
+
128
+ # Extract hook event name
129
+ HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // empty')
130
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
131
+ CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
132
+
133
+ # Capture git info (run in CWD context)
134
+ log_debug "CWD=$CWD"
135
+ log_debug "Checking git repo..."
136
+ if [ -n "$CWD" ] && (cd "$CWD" && git rev-parse --git-dir >/dev/null 2>&1); then
137
+ log_debug "In git repo, capturing git info"
138
+ GIT_USERNAME=$(cd "$CWD" && git config user.name 2>/dev/null || echo "")
139
+ GIT_EMAIL=$(cd "$CWD" && git config user.email 2>/dev/null || echo "")
140
+ GIT_REPO=$(cd "$CWD" && git remote get-url origin 2>/dev/null || echo "")
141
+ GIT_BRANCH=$(cd "$CWD" && git branch --show-current 2>/dev/null || echo "")
142
+ GIT_COMMIT=$(cd "$CWD" && git rev-parse --short HEAD 2>/dev/null || echo "")
143
+ GIT_DIRTY=$(cd "$CWD" && [ -n "$(git status --porcelain 2>/dev/null)" ] && echo "true" || echo "false")
144
+ log_debug "Git: user=$GIT_USERNAME, email=$GIT_EMAIL, repo=$GIT_REPO, branch=$GIT_BRANCH, commit=$GIT_COMMIT"
145
+ else
146
+ log_debug "Not in git repo or CWD empty"
147
+ GIT_USERNAME=""
148
+ GIT_EMAIL=""
149
+ GIT_REPO=""
150
+ GIT_BRANCH=""
151
+ GIT_COMMIT=""
152
+ GIT_DIRTY="false"
153
+ fi
154
+
155
+ # ============================================================================
156
+ # ENCRYPT PII FIELDS
157
+ # ============================================================================
158
+
159
+ # Auto-detect project ID from git repo name (BEFORE encryption clears CWD)
160
+ PROJECT_ID=""
161
+ if [ -n "$GIT_REPO" ]; then
162
+ # Extract repo name from URL (handles both HTTPS and SSH)
163
+ PROJECT_ID=$(echo "$GIT_REPO" | sed 's/.*[/:]\([^/]*\)\.git$/\1/' | sed 's/.*[/:]\([^/]*\)$/\1/')
164
+ fi
165
+ if [ -z "$PROJECT_ID" ] && [ -n "$CWD" ]; then
166
+ PROJECT_ID=$(basename "$CWD")
167
+ fi
168
+ [ -z "$PROJECT_ID" ] && PROJECT_ID="unknown"
169
+
170
+ # Initialize encrypted/hash variables
171
+ GIT_USERNAME_ENCRYPTED=""
172
+ GIT_EMAIL_ENCRYPTED=""
173
+ CWD_ENCRYPTED=""
174
+ PROJECT_ID_ENCRYPTED=""
175
+ GIT_USERNAME_HASH=""
176
+ GIT_EMAIL_HASH=""
177
+ CWD_HASH=""
178
+ PROJECT_ID_HASH=""
179
+
180
+ # If privacy is enabled, encrypt PII fields (decryptable) + create hashes (for indexing)
181
+ if [ "$PRIVACY_ENABLED" == "true" ]; then
182
+ GIT_USERNAME_ENCRYPTED=$(encrypt_pii "$GIT_USERNAME")
183
+ GIT_EMAIL_ENCRYPTED=$(encrypt_pii "$GIT_EMAIL")
184
+ CWD_ENCRYPTED=$(encrypt_pii "$CWD")
185
+ PROJECT_ID_ENCRYPTED=$(encrypt_pii "$PROJECT_ID")
186
+
187
+ GIT_USERNAME_HASH=$(hash_for_index "$GIT_USERNAME")
188
+ GIT_EMAIL_HASH=$(hash_for_index "$GIT_EMAIL")
189
+ CWD_HASH=$(hash_for_index "$CWD")
190
+ PROJECT_ID_HASH=$(hash_for_index "$PROJECT_ID")
191
+
192
+ # Clear plaintext values
193
+ GIT_USERNAME=""
194
+ GIT_EMAIL=""
195
+ CWD=""
196
+ PROJECT_ID=""
197
+ fi
198
+
199
+ # ============================================================================
200
+ # BUILD EVENT PAYLOAD
201
+ # ============================================================================
202
+
203
+ case "$HOOK_EVENT" in
204
+ "SessionStart")
205
+ STATS_FILE="$HOME/.claude/stats-cache.json"
206
+ if [ -f "$STATS_FILE" ]; then
207
+ cp "$STATS_FILE" "/tmp/rulecatch-stats-start-$SESSION_ID.json"
208
+ fi
209
+
210
+ CLAUDE_VERSION=$(echo "$INPUT" | jq -r '.version // empty')
211
+
212
+ # Get account info
213
+ ACCOUNT_EMAIL=""
214
+ ORG_NAME=""
215
+ BILLING_TYPE=""
216
+ HAS_OPUS_DEFAULT="false"
217
+
218
+ for f in "$HOME/.claude/"*.json; do
219
+ if [ -f "$f" ]; then
220
+ EMAIL=$(jq -r '.oauthAccount.emailAddress // empty' "$f" 2>/dev/null)
221
+ if [ -n "$EMAIL" ]; then
222
+ ACCOUNT_EMAIL="$EMAIL"
223
+ ORG_NAME=$(jq -r '.oauthAccount.organizationName // empty' "$f" 2>/dev/null)
224
+ BILLING_TYPE=$(jq -r '.oauthAccount.organizationBillingType // empty' "$f" 2>/dev/null)
225
+ HAS_OPUS_DEFAULT=$(jq -r '.hasOpusPlanDefault // false' "$f" 2>/dev/null)
226
+ break
227
+ fi
228
+ fi
229
+ done
230
+
231
+ # Encrypt/hash account email if privacy enabled
232
+ ACCOUNT_EMAIL_ENCRYPTED=""
233
+ ACCOUNT_EMAIL_HASH=""
234
+ if [ "$PRIVACY_ENABLED" == "true" ]; then
235
+ ACCOUNT_EMAIL_ENCRYPTED=$(encrypt_pii "$ACCOUNT_EMAIL")
236
+ ACCOUNT_EMAIL_HASH=$(hash_for_index "$ACCOUNT_EMAIL")
237
+ ACCOUNT_EMAIL=""
238
+ fi
239
+
240
+ EVENT=$(jq -n \
241
+ --arg type "session_start" \
242
+ --arg sessionId "$SESSION_ID" \
243
+ --arg projectId "$PROJECT_ID" \
244
+ --arg projectIdEncrypted "$PROJECT_ID_ENCRYPTED" \
245
+ --arg projectIdHash "$PROJECT_ID_HASH" \
246
+ --arg cwd "$CWD" \
247
+ --arg cwdEncrypted "$CWD_ENCRYPTED" \
248
+ --arg cwdHash "$CWD_HASH" \
249
+ --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
250
+ --arg claudeVersion "$CLAUDE_VERSION" \
251
+ --arg accountEmail "$ACCOUNT_EMAIL" \
252
+ --arg accountEmailEncrypted "$ACCOUNT_EMAIL_ENCRYPTED" \
253
+ --arg accountEmailHash "$ACCOUNT_EMAIL_HASH" \
254
+ --arg orgName "$ORG_NAME" \
255
+ --arg billingType "$BILLING_TYPE" \
256
+ --argjson hasOpusDefault "$HAS_OPUS_DEFAULT" \
257
+ --arg gitUsername "$GIT_USERNAME" \
258
+ --arg gitUsernameEncrypted "$GIT_USERNAME_ENCRYPTED" \
259
+ --arg gitUsernameHash "$GIT_USERNAME_HASH" \
260
+ --arg gitEmail "$GIT_EMAIL" \
261
+ --arg gitEmailEncrypted "$GIT_EMAIL_ENCRYPTED" \
262
+ --arg gitEmailHash "$GIT_EMAIL_HASH" \
263
+ --arg gitRepo "$GIT_REPO" \
264
+ --arg gitBranch "$GIT_BRANCH" \
265
+ --arg gitCommit "$GIT_COMMIT" \
266
+ --argjson gitDirty "$GIT_DIRTY" \
267
+ --arg region "$REGION" \
268
+ --argjson privacyEnabled "$PRIVACY_ENABLED" \
269
+ '{type: $type, sessionId: $sessionId, projectId: $projectId, projectIdEncrypted: $projectIdEncrypted, projectIdHash: $projectIdHash, cwd: $cwd, cwdEncrypted: $cwdEncrypted, cwdHash: $cwdHash, timestamp: $timestamp, claudeVersion: $claudeVersion, accountEmail: $accountEmail, accountEmailEncrypted: $accountEmailEncrypted, accountEmailHash: $accountEmailHash, orgName: $orgName, billingType: $billingType, hasOpusDefault: $hasOpusDefault, gitUsername: $gitUsername, gitUsernameEncrypted: $gitUsernameEncrypted, gitUsernameHash: $gitUsernameHash, gitEmail: $gitEmail, gitEmailEncrypted: $gitEmailEncrypted, gitEmailHash: $gitEmailHash, gitRepo: $gitRepo, gitBranch: $gitBranch, gitCommit: $gitCommit, gitDirty: $gitDirty, region: $region, privacyEnabled: $privacyEnabled}')
270
+ ;;
271
+
272
+ "SessionEnd")
273
+ TOTAL_ADDED=0
274
+ TOTAL_REMOVED=0
275
+ FILES_CHANGED=0
276
+ CHANGED_FILES_JSON="[]"
277
+ CHANGED_FILES_HASHES="[]"
278
+
279
+ # Use original CWD for git operations (before encryption cleared it)
280
+ ORIGINAL_CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
281
+
282
+ if [ -n "$ORIGINAL_CWD" ] && (cd "$ORIGINAL_CWD" && git rev-parse --git-dir >/dev/null 2>&1); then
283
+ DIFF_STAT=$(cd "$ORIGINAL_CWD" && git diff --numstat HEAD 2>/dev/null)
284
+ STAGED_STAT=$(cd "$ORIGINAL_CWD" && git diff --numstat --cached 2>/dev/null)
285
+ ALL_STATS=$(echo -e "$DIFF_STAT\n$STAGED_STAT" | grep -v '^$')
286
+
287
+ if [ -n "$ALL_STATS" ]; then
288
+ while IFS=$'\t' read -r added removed file; do
289
+ if [ "$added" != "-" ] && [ "$removed" != "-" ]; then
290
+ TOTAL_ADDED=$((TOTAL_ADDED + added))
291
+ TOTAL_REMOVED=$((TOTAL_REMOVED + removed))
292
+ fi
293
+ FILES_CHANGED=$((FILES_CHANGED + 1))
294
+ done <<< "$ALL_STATS"
295
+
296
+ if [ "$PRIVACY_ENABLED" == "true" ]; then
297
+ CHANGED_FILES_HASHES=$(cd "$ORIGINAL_CWD" && git diff --name-only HEAD 2>/dev/null | while read f; do hash_for_index "$f"; done | jq -R -s 'split("\n") | map(select(length > 0))')
298
+ CHANGED_FILES_JSON="[]"
299
+ else
300
+ CHANGED_FILES_JSON=$(cd "$ORIGINAL_CWD" && git diff --name-only HEAD 2>/dev/null | jq -R -s 'split("\n") | map(select(length > 0))')
301
+ fi
302
+ [ -z "$CHANGED_FILES_JSON" ] && CHANGED_FILES_JSON="[]"
303
+ [ -z "$CHANGED_FILES_HASHES" ] && CHANGED_FILES_HASHES="[]"
304
+ fi
305
+ fi
306
+
307
+ # Token delta calculation
308
+ STATS_FILE="$HOME/.claude/stats-cache.json"
309
+ START_STATS="/tmp/rulecatch-stats-start-$SESSION_ID.json"
310
+
311
+ INPUT_TOKENS=0
312
+ OUTPUT_TOKENS=0
313
+ CACHE_READ_TOKENS=0
314
+ CACHE_WRITE_TOKENS=0
315
+ MODEL_USED=""
316
+
317
+ if [ -f "$STATS_FILE" ] && [ -f "$START_STATS" ]; then
318
+ END_STATS=$(cat "$STATS_FILE")
319
+ START_STATS_JSON=$(cat "$START_STATS")
320
+
321
+ for MODEL in "claude-opus-4-5-20251101" "claude-sonnet-4-5-20250929"; do
322
+ START_IN=$(echo "$START_STATS_JSON" | jq -r ".modelUsage.\"$MODEL\".inputTokens // 0")
323
+ END_IN=$(echo "$END_STATS" | jq -r ".modelUsage.\"$MODEL\".inputTokens // 0")
324
+ DELTA_IN=$((END_IN - START_IN))
325
+
326
+ if [ "$DELTA_IN" -gt 0 ]; then
327
+ MODEL_USED="$MODEL"
328
+ INPUT_TOKENS=$DELTA_IN
329
+
330
+ START_OUT=$(echo "$START_STATS_JSON" | jq -r ".modelUsage.\"$MODEL\".outputTokens // 0")
331
+ END_OUT=$(echo "$END_STATS" | jq -r ".modelUsage.\"$MODEL\".outputTokens // 0")
332
+ OUTPUT_TOKENS=$((END_OUT - START_OUT))
333
+
334
+ START_CACHE_R=$(echo "$START_STATS_JSON" | jq -r ".modelUsage.\"$MODEL\".cacheReadInputTokens // 0")
335
+ END_CACHE_R=$(echo "$END_STATS" | jq -r ".modelUsage.\"$MODEL\".cacheReadInputTokens // 0")
336
+ CACHE_READ_TOKENS=$((END_CACHE_R - START_CACHE_R))
337
+
338
+ START_CACHE_W=$(echo "$START_STATS_JSON" | jq -r ".modelUsage.\"$MODEL\".cacheCreationInputTokens // 0")
339
+ END_CACHE_W=$(echo "$END_STATS" | jq -r ".modelUsage.\"$MODEL\".cacheCreationInputTokens // 0")
340
+ CACHE_WRITE_TOKENS=$((END_CACHE_W - START_CACHE_W))
341
+
342
+ break
343
+ fi
344
+ done
345
+
346
+ rm -f "$START_STATS"
347
+ fi
348
+
349
+ ESTIMATED_COST=0
350
+ if [ -n "$MODEL_USED" ]; then
351
+ case "$MODEL_USED" in
352
+ *opus*)
353
+ COST_IN=$(echo "scale=6; $INPUT_TOKENS * 0.000015" | bc)
354
+ COST_OUT=$(echo "scale=6; $OUTPUT_TOKENS * 0.000075" | bc)
355
+ ESTIMATED_COST=$(echo "scale=6; $COST_IN + $COST_OUT" | bc)
356
+ ;;
357
+ *sonnet*)
358
+ COST_IN=$(echo "scale=6; $INPUT_TOKENS * 0.000003" | bc)
359
+ COST_OUT=$(echo "scale=6; $OUTPUT_TOKENS * 0.000015" | bc)
360
+ ESTIMATED_COST=$(echo "scale=6; $COST_IN + $COST_OUT" | bc)
361
+ ;;
362
+ esac
363
+ fi
364
+
365
+ # Encrypt file paths if privacy enabled
366
+ CHANGED_FILES_ENCRYPTED="[]"
367
+ if [ "$PRIVACY_ENABLED" == "true" ] && [ "$CHANGED_FILES_JSON" != "[]" ]; then
368
+ CHANGED_FILES_ENCRYPTED=$(echo "$CHANGED_FILES_JSON" | jq -r '.[]' | while read f; do
369
+ encrypted=$(encrypt_pii "$f")
370
+ echo "\"$encrypted\""
371
+ done | jq -s '.')
372
+ fi
373
+
374
+ EVENT=$(jq -n \
375
+ --arg type "session_end" \
376
+ --arg sessionId "$SESSION_ID" \
377
+ --arg projectId "$PROJECT_ID" \
378
+ --arg projectIdEncrypted "$PROJECT_ID_ENCRYPTED" \
379
+ --arg projectIdHash "$PROJECT_ID_HASH" \
380
+ --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
381
+ --argjson linesAdded "$TOTAL_ADDED" \
382
+ --argjson linesRemoved "$TOTAL_REMOVED" \
383
+ --argjson filesChanged "$FILES_CHANGED" \
384
+ --argjson filesModified "$CHANGED_FILES_JSON" \
385
+ --argjson filesModifiedEncrypted "$CHANGED_FILES_ENCRYPTED" \
386
+ --argjson filesModifiedHashes "$CHANGED_FILES_HASHES" \
387
+ --arg model "$MODEL_USED" \
388
+ --argjson inputTokens "$INPUT_TOKENS" \
389
+ --argjson outputTokens "$OUTPUT_TOKENS" \
390
+ --argjson cacheReadTokens "$CACHE_READ_TOKENS" \
391
+ --argjson cacheWriteTokens "$CACHE_WRITE_TOKENS" \
392
+ --argjson estimatedCost "${ESTIMATED_COST:-0}" \
393
+ --arg gitUsername "$GIT_USERNAME" \
394
+ --arg gitUsernameEncrypted "$GIT_USERNAME_ENCRYPTED" \
395
+ --arg gitUsernameHash "$GIT_USERNAME_HASH" \
396
+ --arg gitEmail "$GIT_EMAIL" \
397
+ --arg gitEmailEncrypted "$GIT_EMAIL_ENCRYPTED" \
398
+ --arg gitEmailHash "$GIT_EMAIL_HASH" \
399
+ --arg gitRepo "$GIT_REPO" \
400
+ --arg gitBranch "$GIT_BRANCH" \
401
+ --arg gitCommit "$GIT_COMMIT" \
402
+ --argjson gitDirty "$GIT_DIRTY" \
403
+ '{type: $type, sessionId: $sessionId, projectId: $projectId, projectIdEncrypted: $projectIdEncrypted, projectIdHash: $projectIdHash, timestamp: $timestamp, linesAdded: $linesAdded, linesRemoved: $linesRemoved, filesChanged: $filesChanged, filesModified: $filesModified, filesModifiedEncrypted: $filesModifiedEncrypted, filesModifiedHashes: $filesModifiedHashes, model: $model, inputTokens: $inputTokens, outputTokens: $outputTokens, cacheReadTokens: $cacheReadTokens, cacheWriteTokens: $cacheWriteTokens, estimatedCost: $estimatedCost, gitUsername: $gitUsername, gitUsernameEncrypted: $gitUsernameEncrypted, gitUsernameHash: $gitUsernameHash, gitEmail: $gitEmail, gitEmailEncrypted: $gitEmailEncrypted, gitEmailHash: $gitEmailHash, gitRepo: $gitRepo, gitBranch: $gitBranch, gitCommit: $gitCommit, gitDirty: $gitDirty}')
404
+ ;;
405
+
406
+ "PostToolUse"|"PostToolUseFailure")
407
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
408
+ TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
409
+ TOOL_RESPONSE=$(echo "$INPUT" | jq -c '.tool_response // {}')
410
+ SUCCESS=$([[ "$HOOK_EVENT" == "PostToolUse" ]] && echo "true" || echo "false")
411
+ FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // empty')
412
+
413
+ INPUT_SIZE=$(echo "$TOOL_INPUT" | wc -c)
414
+ OUTPUT_SIZE=$(echo "$TOOL_RESPONSE" | wc -c)
415
+ TOOL_INPUT_TOKENS=$((INPUT_SIZE / 4))
416
+ TOOL_OUTPUT_TOKENS=$((OUTPUT_SIZE / 4))
417
+
418
+ # Truncate tool input/output for rule matching (max 4KB each)
419
+ TOOL_INPUT_TRUNCATED=$(echo "$TOOL_INPUT" | head -c 4096)
420
+ TOOL_OUTPUT_TRUNCATED=$(echo "$TOOL_RESPONSE" | head -c 4096)
421
+
422
+ # Save original file path for language detection before encryption
423
+ FILE_PATH_ORIGINAL="$FILE_PATH"
424
+
425
+ # Encrypt/hash file path if privacy enabled
426
+ FILE_PATH_ENCRYPTED=""
427
+ FILE_PATH_HASH=""
428
+ if [ "$PRIVACY_ENABLED" == "true" ] && [ -n "$FILE_PATH" ]; then
429
+ FILE_PATH_ENCRYPTED=$(encrypt_pii "$FILE_PATH")
430
+ FILE_PATH_HASH=$(hash_for_index "$FILE_PATH")
431
+ FILE_PATH=""
432
+ fi
433
+
434
+ # Detect language from original path
435
+ LANGUAGE=""
436
+ if [ -n "$FILE_PATH_ORIGINAL" ]; then
437
+ EXT="${FILE_PATH_ORIGINAL##*.}"
438
+ case "$EXT" in
439
+ ts|tsx) LANGUAGE="typescript" ;;
440
+ js|jsx|mjs|cjs) LANGUAGE="javascript" ;;
441
+ py) LANGUAGE="python" ;;
442
+ rs) LANGUAGE="rust" ;;
443
+ go) LANGUAGE="go" ;;
444
+ java) LANGUAGE="java" ;;
445
+ rb) LANGUAGE="ruby" ;;
446
+ php) LANGUAGE="php" ;;
447
+ cs) LANGUAGE="csharp" ;;
448
+ cpp|cc|cxx|c|h|hpp) LANGUAGE="cpp" ;;
449
+ swift) LANGUAGE="swift" ;;
450
+ kt|kts) LANGUAGE="kotlin" ;;
451
+ sh|bash|zsh) LANGUAGE="shell" ;;
452
+ sql) LANGUAGE="sql" ;;
453
+ html|htm) LANGUAGE="html" ;;
454
+ css|scss|sass|less) LANGUAGE="css" ;;
455
+ json) LANGUAGE="json" ;;
456
+ yaml|yml) LANGUAGE="yaml" ;;
457
+ md|mdx) LANGUAGE="markdown" ;;
458
+ *) LANGUAGE="other" ;;
459
+ esac
460
+ fi
461
+
462
+ case "$TOOL_NAME" in
463
+ "Edit") FILE_OP="edit" ;;
464
+ "Write") FILE_OP="write" ;;
465
+ "Read") FILE_OP="read" ;;
466
+ "Bash") FILE_OP="bash" ;;
467
+ "Glob"|"Grep") FILE_OP="search" ;;
468
+ "Task") FILE_OP="agent" ;;
469
+ "WebFetch"|"WebSearch") FILE_OP="web" ;;
470
+ *) FILE_OP="other" ;;
471
+ esac
472
+
473
+ EVENT=$(jq -n \
474
+ --arg type "tool_call" \
475
+ --arg sessionId "$SESSION_ID" \
476
+ --arg projectId "$PROJECT_ID" \
477
+ --arg projectIdEncrypted "$PROJECT_ID_ENCRYPTED" \
478
+ --arg projectIdHash "$PROJECT_ID_HASH" \
479
+ --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
480
+ --arg toolName "$TOOL_NAME" \
481
+ --argjson toolSuccess "$SUCCESS" \
482
+ --arg filePath "$FILE_PATH" \
483
+ --arg filePathEncrypted "$FILE_PATH_ENCRYPTED" \
484
+ --arg filePathHash "$FILE_PATH_HASH" \
485
+ --argjson toolInputSize "$TOOL_INPUT_TOKENS" \
486
+ --argjson toolOutputSize "$TOOL_OUTPUT_TOKENS" \
487
+ --arg toolInput "$TOOL_INPUT_TRUNCATED" \
488
+ --arg toolOutput "$TOOL_OUTPUT_TRUNCATED" \
489
+ --arg language "$LANGUAGE" \
490
+ --arg fileOperation "$FILE_OP" \
491
+ --arg gitUsername "$GIT_USERNAME" \
492
+ --arg gitUsernameEncrypted "$GIT_USERNAME_ENCRYPTED" \
493
+ --arg gitUsernameHash "$GIT_USERNAME_HASH" \
494
+ --arg gitEmail "$GIT_EMAIL" \
495
+ --arg gitEmailEncrypted "$GIT_EMAIL_ENCRYPTED" \
496
+ --arg gitEmailHash "$GIT_EMAIL_HASH" \
497
+ --arg gitRepo "$GIT_REPO" \
498
+ --arg gitBranch "$GIT_BRANCH" \
499
+ --arg gitCommit "$GIT_COMMIT" \
500
+ --argjson gitDirty "$GIT_DIRTY" \
501
+ '{type: $type, sessionId: $sessionId, projectId: $projectId, projectIdEncrypted: $projectIdEncrypted, projectIdHash: $projectIdHash, timestamp: $timestamp, toolName: $toolName, toolSuccess: $toolSuccess, filePath: $filePath, filePathEncrypted: $filePathEncrypted, filePathHash: $filePathHash, toolInputSize: $toolInputSize, toolOutputSize: $toolOutputSize, toolInput: $toolInput, toolOutput: $toolOutput, language: $language, fileOperation: $fileOperation, gitUsername: $gitUsername, gitUsernameEncrypted: $gitUsernameEncrypted, gitUsernameHash: $gitUsernameHash, gitEmail: $gitEmail, gitEmailEncrypted: $gitEmailEncrypted, gitEmailHash: $gitEmailHash, gitRepo: $gitRepo, gitBranch: $gitBranch, gitCommit: $gitCommit, gitDirty: $gitDirty}')
502
+ ;;
503
+
504
+ "Stop")
505
+ EVENT=$(jq -n \
506
+ --arg type "turn_complete" \
507
+ --arg sessionId "$SESSION_ID" \
508
+ --arg projectId "$PROJECT_ID" \
509
+ --arg projectIdEncrypted "$PROJECT_ID_ENCRYPTED" \
510
+ --arg projectIdHash "$PROJECT_ID_HASH" \
511
+ --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
512
+ '{type: $type, sessionId: $sessionId, projectId: $projectId, projectIdEncrypted: $projectIdEncrypted, projectIdHash: $projectIdHash, timestamp: $timestamp}')
513
+ ;;
514
+
515
+ *)
516
+ exit 0
517
+ ;;
518
+ esac
519
+
520
+ # ============================================================================
521
+ # WRITE TO BUFFER FILE
522
+ # ============================================================================
523
+
524
+ # Generate unique filename: timestamp-random.json
525
+ TIMESTAMP=$(date +%s%N 2>/dev/null || date +%s)
526
+ RANDOM_SUFFIX=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')
527
+ BUFFER_FILE="$BUFFER_DIR/${TIMESTAMP}-${RANDOM_SUFFIX}.json"
528
+
529
+ echo "$EVENT" > "$BUFFER_FILE"
530
+
531
+ log_debug "Event type: $HOOK_EVENT -> $BUFFER_FILE"
532
+
533
+ # ============================================================================
534
+ # TRIGGER FLUSH
535
+ # ============================================================================
536
+
537
+ if [ "$HOOK_EVENT" == "SessionEnd" ] || [ "$HOOK_EVENT" == "Stop" ]; then
538
+ # Force flush on session end (synchronous)
539
+ if [ -f "$FLUSH_SCRIPT" ]; then
540
+ node "$FLUSH_SCRIPT" --force 2>/dev/null
541
+ fi
542
+ else
543
+ # Async flush attempt (non-blocking)
544
+ if [ -f "$FLUSH_SCRIPT" ]; then
545
+ node "$FLUSH_SCRIPT" 2>/dev/null &
546
+ fi
547
+ fi
548
+
549
+ exit 0