diff --git a/README.md b/README.md index e36dbca..5165354 100644 --- a/README.md +++ b/README.md @@ -170,13 +170,21 @@ Claude Code's hook system doesn't support transforming prompts - only blocking o ## Debugging -Debug logging is off by default. To enable it, set `PRIVACY_GUARD_DEBUG=1` in your environment before starting Claude Code, then check the log: +Debug logging is off by default. To enable it, set `PRIVACY_GUARD_DEBUG=1` in your environment before starting Claude Code, then check the log. + +On macOS/Linux: ```bash cat "${XDG_CACHE_HOME:-$HOME/.cache}/claude-code-privacy-guard/debug.log" ``` -The log only contains execution metadata (working directory, Node version, exit codes) - it never contains matched secret or PII values. +On Windows the log is written under `%LOCALAPPDATA%`: + +```powershell +type "$env:LOCALAPPDATA\claude-code-privacy-guard\debug.log" +``` + +The log only contains execution metadata (timestamp, plugin root, working directory, Node version, exit path/code) - it never contains matched secret or PII values. ## Contributing diff --git a/hooks/hooks.json b/hooks/hooks.json index 81aeaaa..a3be4a5 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/scripts/prompt-guard-wrapper.sh", + "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/prompt-guard.js", "timeout": 10 } ] diff --git a/scripts/prompt-guard-wrapper.sh b/scripts/prompt-guard-wrapper.sh deleted file mode 100755 index d90d086..0000000 --- a/scripts/prompt-guard-wrapper.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -# Debug logging is opt-in: set PRIVACY_GUARD_DEBUG=1 to enable. -# Never write matched secret values here - only execution metadata. -if [ "$PRIVACY_GUARD_DEBUG" = "1" ]; then - CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/claude-code-privacy-guard" - mkdir -p "$CACHE_DIR" - LOG_FILE="$CACHE_DIR/debug.log" - - { - echo "=== Hook Execution $(date) ===" - echo "CLAUDE_PLUGIN_ROOT: ${CLAUDE_PLUGIN_ROOT}" - echo "CWD: $(pwd)" - echo "Node version: $(node --version)" - } >> "$LOG_FILE" 2>&1 -fi - -# Run the actual script - stdin flows through, stdout goes to stdout. -# stderr goes to the debug log when enabled, otherwise it's discarded -# (Claude Code renders the block reason from stdout JSON, not stderr). -if [ -n "$CLAUDE_PLUGIN_ROOT" ]; then - if [ "$PRIVACY_GUARD_DEBUG" = "1" ]; then - node "${CLAUDE_PLUGIN_ROOT}/scripts/prompt-guard.js" 2>> "$LOG_FILE" - EXIT_CODE=$? - echo "Exit code: $EXIT_CODE" >> "$LOG_FILE" - else - node "${CLAUDE_PLUGIN_ROOT}/scripts/prompt-guard.js" 2>/dev/null - EXIT_CODE=$? - fi - exit $EXIT_CODE -else - echo "ERROR: CLAUDE_PLUGIN_ROOT not set" >&2 - if [ "$PRIVACY_GUARD_DEBUG" = "1" ]; then - echo "ERROR: CLAUDE_PLUGIN_ROOT not set" >> "$LOG_FILE" - fi - exit 1 -fi diff --git a/scripts/prompt-guard.js b/scripts/prompt-guard.js index 74d40f1..d84c865 100755 --- a/scripts/prompt-guard.js +++ b/scripts/prompt-guard.js @@ -5,83 +5,143 @@ * * This hook intercepts user prompts before they're sent to Claude, * scans for sensitive data, and blocks prompts containing sensitive information. + * + * It is invoked directly by Claude Code as `node prompt-guard.js` (no shell + * wrapper), so it must run unchanged on Windows, macOS, and Linux. All + * platform-specific behavior (debug log location, path handling) lives here. */ import { PrivacyScanner } from '../dist/scanner/engine.js'; -import { readFileSync } from 'fs'; +import { readFileSync, mkdirSync, appendFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { BUILTIN_RULES, loadExternalRulesFromJson } from '../dist/scanner/detectors.js'; import { ConfigLoader } from '../dist/config/loader.js'; import * as path from 'path'; +import * as os from 'os'; -// Read the user's prompt from stdin -let promptText = ''; -try { - // The prompt is passed via stdin by Claude Code - promptText = readFileSync(0, 'utf-8'); -} catch (error) { - console.error('Error reading prompt:', error.message); - process.exit(1); -} - -// Load user configuration (.privacy-guard.json, searched upward from cwd) -const configPath = ConfigLoader.findConfig(); -const config = new ConfigLoader(configPath ?? undefined).getConfig(); +// Debug logging is opt-in: set PRIVACY_GUARD_DEBUG=1 to enable. The log only +// ever holds execution metadata - never matched secret or PII values. +const DEBUG = process.env.PRIVACY_GUARD_DEBUG === '1'; -if (config.enabled === false) { - process.exit(0); +// Resolve the per-user cache directory in a cross-platform way. On Windows we +// use %LOCALAPPDATA%; on POSIX we honor XDG_CACHE_HOME, falling back to +// ~/.cache (matching the path documented in the README). +function getCacheDir() { + if (os.platform() === 'win32') { + const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + return path.join(base, 'claude-code-privacy-guard'); + } + const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); + return path.join(base, 'claude-code-privacy-guard'); } -// Initialize scanner with built-in + external JSON regex rules -let externalRulesPath = fileURLToPath(new URL('../data/regex_list_1.json', import.meta.url)); -if (config.externalRulesJsonPath) { - const baseDir = configPath ? path.dirname(configPath) : process.cwd(); - externalRulesPath = path.resolve(baseDir, config.externalRulesJsonPath); -} -const codingOnly = (config.externalRulesMode ?? 'coding-only') === 'coding-only'; -const externalRules = loadExternalRulesFromJson(externalRulesPath, { codingOnly }); - -// Honor disabledRules from config for both built-in and external rules -const disabledRules = new Set(config.disabledRules); -const scanner = new PrivacyScanner( - [...BUILTIN_RULES, ...externalRules].filter((rule) => !disabledRules.has(rule.id)) -); - -// Scan the prompt -const result = scanner.scan(promptText); - -// Mask a matched secret/PII value down to a short, non-recoverable hint, -// e.g. "sk-proj-abc123xyz1234567890" -> "sk-p…7890" -function maskMatch(value) { - if (value.length <= 8) return '*'.repeat(value.length); - return `${value.slice(0, 4)}…${value.slice(-4)}`; +// Append metadata lines to the debug log. Best-effort: debug logging must never +// affect the prompt flow, so any failure here is swallowed. +function debugLog(lines) { + if (!DEBUG) return; + try { + const dir = getCacheDir(); + mkdirSync(dir, { recursive: true }); + appendFileSync(path.join(dir, 'debug.log'), lines.join('\n') + '\n'); + } catch { + // Intentionally ignored - never let logging break prompting. + } } -// If sensitive data found, block the prompt -if (result.findings.length > 0) { - // Build detailed findings list - const findingsList = result.findings.map(f => - ` - ${f.title} (${f.ruleId}): ${maskMatch(f.match)}` - ).join('\n'); - - // Return blocking decision as JSON - const response = { - decision: "block", - reason: `🛡️ Privacy Guard blocked this prompt\n\n` + - `Found ${result.findings.length} sensitive item(s):\n${findingsList}\n\n` + - `Risk Score: ${result.riskScore}/100\n` + - `Secrets: ${result.summary.secret || 0} | PII: ${result.summary.pii || 0}\n\n` + - `Please remove or anonymize sensitive data before proceeding.\n` + - `To disable a rule, add its ID to "disabledRules" in .privacy-guard.json.` - }; - - console.log(JSON.stringify(response, null, 2)); - - // Per the UserPromptSubmit hook protocol, a JSON "decision": "block" is - // only honored on exit 0. A non-zero exit here would be treated as a - // non-blocking error and the prompt would go through anyway. - process.exit(0); +// Record the exit path in the debug log, then exit. Centralizes the "exit path" +// metadata that the old shell wrapper used to log after running the script. +function finish(exitPath, code) { + debugLog([`Exit path: ${exitPath}`, `Exit code: ${code}`, '']); + process.exit(code); } -// No sensitive data, allow the prompt -process.exit(0); +debugLog([ + `=== Hook Execution ${new Date().toISOString()} ===`, + `CLAUDE_PLUGIN_ROOT: ${process.env.CLAUDE_PLUGIN_ROOT ?? ''}`, + `CWD: ${process.cwd()}`, + `Node version: ${process.version}`, +]); + +try { + // Read the user's prompt from stdin (passed by Claude Code). + let promptText = ''; + try { + promptText = readFileSync(0, 'utf-8'); + } catch (error) { + // The shell wrapper discarded stderr when debug was off; keep stderr quiet + // and route the error to the debug log instead. Exit non-zero as before - + // per the hook protocol this is a non-blocking error, matching prior + // behavior (the prompt is not silently altered by this failure). + debugLog([`ERROR reading prompt: ${error.message}`]); + finish('stdin-read-error', 1); + } + + // Load user configuration (.privacy-guard.json, searched upward from cwd) + const configPath = ConfigLoader.findConfig(); + const config = new ConfigLoader(configPath ?? undefined).getConfig(); + + if (config.enabled === false) { + finish('disabled', 0); + } + + // Initialize scanner with built-in + external JSON regex rules + let externalRulesPath = fileURLToPath(new URL('../data/regex_list_1.json', import.meta.url)); + if (config.externalRulesJsonPath) { + const baseDir = configPath ? path.dirname(configPath) : process.cwd(); + externalRulesPath = path.resolve(baseDir, config.externalRulesJsonPath); + } + const codingOnly = (config.externalRulesMode ?? 'coding-only') === 'coding-only'; + const externalRules = loadExternalRulesFromJson(externalRulesPath, { codingOnly }); + + // Honor disabledRules from config for both built-in and external rules + const disabledRules = new Set(config.disabledRules); + const scanner = new PrivacyScanner( + [...BUILTIN_RULES, ...externalRules].filter((rule) => !disabledRules.has(rule.id)) + ); + + // Scan the prompt + const result = scanner.scan(promptText); + + // Mask a matched secret/PII value down to a short, non-recoverable hint, + // e.g. "sk-proj-abc123xyz1234567890" -> "sk-p…7890" + function maskMatch(value) { + if (value.length <= 8) return '*'.repeat(value.length); + return `${value.slice(0, 4)}…${value.slice(-4)}`; + } + + // If sensitive data found, block the prompt + if (result.findings.length > 0) { + // Build detailed findings list + const findingsList = result.findings.map(f => + ` - ${f.title} (${f.ruleId}): ${maskMatch(f.match)}` + ).join('\n'); + + // Return blocking decision as JSON + const response = { + decision: "block", + reason: `🛡️ Privacy Guard blocked this prompt\n\n` + + `Found ${result.findings.length} sensitive item(s):\n${findingsList}\n\n` + + `Risk Score: ${result.riskScore}/100\n` + + `Secrets: ${result.summary.secret || 0} | PII: ${result.summary.pii || 0}\n\n` + + `Please remove or anonymize sensitive data before proceeding.\n` + + `To disable a rule, add its ID to "disabledRules" in .privacy-guard.json.` + }; + + console.log(JSON.stringify(response, null, 2)); + + // Per the UserPromptSubmit hook protocol, a JSON "decision": "block" is + // only honored on exit 0. A non-zero exit here would be treated as a + // non-blocking error and the prompt would go through anyway. + finish('block', 0); + } + + // No sensitive data, allow the prompt + finish('allow', 0); +} catch (error) { + // Any unexpected internal error: keep stderr quiet (the wrapper used to + // discard it) and log to the debug file when enabled. Exit non-zero, which + // the hook protocol treats as a non-blocking error - matching the wrapper's + // pass-through of a failing exit code. + debugLog([`ERROR: ${error && error.stack ? error.stack : String(error)}`]); + finish('internal-error', 1); +}