Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
Expand Down
37 changes: 0 additions & 37 deletions scripts/prompt-guard-wrapper.sh

This file was deleted.

190 changes: 125 additions & 65 deletions scripts/prompt-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading