diff --git a/scripts/prompt-guard.js b/scripts/prompt-guard.js index 1e71346..a91c369 100755 --- a/scripts/prompt-guard.js +++ b/scripts/prompt-guard.js @@ -56,6 +56,22 @@ function finish(exitPath, code) { process.exit(code); } +// Pull the user's prompt out of the UserPromptSubmit stdin payload. Falls back +// to the raw text when stdin isn't the expected JSON envelope (e.g. the script +// invoked manually with a piped prompt, or a future protocol change) so the +// guard still scans something rather than silently allowing everything. +function extractPrompt(raw) { + const trimmed = raw.trim(); + if (!trimmed.startsWith('{')) return raw; + try { + const payload = JSON.parse(trimmed); + if (payload && typeof payload.prompt === 'string') return payload.prompt; + } catch { + // Not JSON after all - fall through and scan the raw text. + } + return raw; +} + debugLog([ `=== Hook Execution ${new Date().toISOString()} ===`, `CLAUDE_PLUGIN_ROOT: ${process.env.CLAUDE_PLUGIN_ROOT ?? ''}`, @@ -64,10 +80,15 @@ debugLog([ ]); try { - // Read the user's prompt from stdin (passed by Claude Code). + // Read the hook payload from stdin (passed by Claude Code). Claude Code sends + // a JSON envelope - {"session_id":...,"prompt":"..."} - not the bare prompt, + // so only the "prompt" field may be scanned or echoed back. Scanning the raw + // envelope would both produce false positives on paths/ids and leak the JSON + // into redact mode's copy-pasteable output. let promptText = ''; try { - promptText = readFileSync(0, 'utf-8'); + const raw = readFileSync(0, 'utf-8'); + promptText = extractPrompt(raw); } 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 - diff --git a/tests/prompt-payload.test.ts b/tests/prompt-payload.test.ts new file mode 100644 index 0000000..3fceb8b --- /dev/null +++ b/tests/prompt-payload.test.ts @@ -0,0 +1,68 @@ +/** + * End-to-end tests for the UserPromptSubmit hook script's stdin handling. + * + * Claude Code pipes a JSON envelope (session_id, transcript_path, cwd, prompt, + * ...) into the hook - not the bare prompt. The hook must scan and echo back + * only the "prompt" field; scanning the envelope produced false positives on + * paths/ids and leaked the whole JSON into redact mode's cleaned copy. + */ + +import { describe, expect, test } from '@jest/globals'; +import { execFileSync } from 'child_process'; +import { mkdtempSync, writeFileSync } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const HOOK = path.resolve(__dirname, '../scripts/prompt-guard.js'); + +function runHook(stdin: string, config: Record): { decision?: string; reason?: string; systemMessage?: string } | null { + const dir = mkdtempSync(path.join(os.tmpdir(), 'privacy-guard-hook-')); + writeFileSync(path.join(dir, '.privacy-guard.json'), JSON.stringify(config)); + const stdout = execFileSync('node', [HOOK], { + input: stdin, + cwd: dir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + return stdout.trim() ? JSON.parse(stdout) : null; +} + +const ENVELOPE = { + session_id: 'a24ce33f-3780-491d-8d22-aa4d88f34bd1', + transcript_path: '/Users/someone/.claude/projects/-Users-someone-proj/a24ce33f.jsonl', + cwd: '/Users/someone/Projects/proj', + prompt_id: 'c8320832-4a29-41d5-aa58-359903658ce6', + permission_mode: 'default', + hook_event_name: 'UserPromptSubmit', + prompt: 'my email is john.doe@example.com', +}; + +describe('hook stdin payload handling', () => { + test('redact mode returns the cleaned prompt, not the JSON envelope', () => { + const response = runHook(JSON.stringify(ENVELOPE), { mode: 'redact' }); + + expect(response?.decision).toBe('block'); + expect(response?.reason).toContain('---\nmy email is \n---'); + expect(response?.reason).not.toContain('session_id'); + expect(response?.reason).not.toContain('hook_event_name'); + }); + + test('scans only the prompt field, not envelope metadata', () => { + const clean = { ...ENVELOPE, prompt: 'refactor the parser' }; + expect(runHook(JSON.stringify(clean), { mode: 'block' })).toBeNull(); + }); + + test('still blocks on sensitive data inside the prompt field', () => { + const response = runHook(JSON.stringify(ENVELOPE), { mode: 'block' }); + + expect(response?.decision).toBe('block'); + expect(response?.reason).toContain('Email Address'); + }); + + test('falls back to scanning raw stdin when it is not a JSON envelope', () => { + const response = runHook('my email is john.doe@example.com', { mode: 'block' }); + + expect(response?.decision).toBe('block'); + expect(response?.reason).toContain('Email Address'); + }); +});