diff --git a/.claude/settings.json b/.claude/settings.json index 7911c84e..d65189d6 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -165,6 +165,16 @@ "timeout": 5 } ] + }, + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "node tools/enforce-read-path-deny.mjs", + "timeout": 5 + } + ] } ], "PostToolUse": [ diff --git a/tools/enforce-read-path-deny.mjs b/tools/enforce-read-path-deny.mjs new file mode 100644 index 00000000..8a7dca6d --- /dev/null +++ b/tools/enforce-read-path-deny.mjs @@ -0,0 +1,52 @@ +/** + * PreToolUse(Read) wrapper — path-deny for Read tool. + * Router-gate v4 emergency fix (Smoke 5 2026-05-30). + * + * Spec §3.1 declared transcript JSONL hard-deny but Read tool had NO + * path-protection — controller could Read ~/.claude/projects/*.jsonl + * (parent context exfil from other sessions). Same for runtime artifacts, + * .env, normative files. + * + * Reuses DEFAULT_PROTECTED_PATTERNS from shell-content-rules.mjs. + * Fail-CLOSE on internal error (security default). + */ +import { fileURLToPath } from 'url'; +import { + readStdin, + parseEventJson, + exitDecision, +} from './enforce-hook-helpers.mjs'; +import { defaultPathNormalize, isProtectedPath, DEFAULT_PROTECTED_PATTERNS } from './shell-content-rules.mjs'; + +export function decide({ toolName, filePath }) { + if (toolName !== 'Read') return { block: false, reason: null }; + const fp = String(filePath || ''); + if (!fp) return { block: false, reason: null }; + if (isProtectedPath(fp, defaultPathNormalize, DEFAULT_PROTECTED_PATTERNS)) { + return { + block: true, + reason: `path «${defaultPathNormalize(fp)}» protected against Read (§3.1 transcript/runtime/normative hard-deny)`, + }; + } + return { block: false, reason: null }; +} + +async function main() { + try { + const raw = await readStdin(); + const event = parseEventJson(raw); + const r = decide({ + toolName: event.tool_name, + filePath: event.tool_input?.file_path || event.tool_input?.filePath, + }); + if (r.block) { + return exitDecision({ block: true, message: `[read-path-deny] ${r.reason}` }); + } + return exitDecision({ block: false }); + } catch { + return exitDecision({ block: true, message: '[read-path-deny] внутренняя ошибка — fail-CLOSE' }); + } +} + +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) main(); diff --git a/tools/enforce-read-path-deny.test.mjs b/tools/enforce-read-path-deny.test.mjs new file mode 100644 index 00000000..8544aabf --- /dev/null +++ b/tools/enforce-read-path-deny.test.mjs @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { decide } from './enforce-read-path-deny.mjs'; + +describe('enforce-read-path-deny decide()', () => { + it('allows Read on normal project file', () => { + const r = decide({ toolName: 'Read', filePath: 'docs/observer/STATUS.md' }); + expect(r.block).toBe(false); + }); + it('blocks Read on ~/.claude/projects/*.jsonl transcript', () => { + const r = decide({ toolName: 'Read', filePath: '~/.claude/projects/abc-session.jsonl' }); + expect(r.block).toBe(true); + expect(r.reason).toMatch(/protected/i); + }); + it('blocks Read on absolute /c/Users/.../.claude/projects/x.jsonl', () => { + const r = decide({ toolName: 'Read', filePath: '/c/Users/Administrator/.claude/projects/proj/session.jsonl' }); + expect(r.block).toBe(true); + }); + it('blocks Read on ~/.claude/runtime/*.json (runtime artifacts)', () => { + const r = decide({ toolName: 'Read', filePath: '~/.claude/runtime/router-state-x.json' }); + expect(r.block).toBe(true); + }); + it('blocks Read on .env', () => { + const r = decide({ toolName: 'Read', filePath: '.env' }); + expect(r.block).toBe(true); + }); + it('allows non-Read tool calls (no-op)', () => { + const r = decide({ toolName: 'Bash', filePath: 'whatever' }); + expect(r.block).toBe(false); + }); +}); diff --git a/tools/shell-content-rules.mjs b/tools/shell-content-rules.mjs index 50752bec..1322a2fd 100644 --- a/tools/shell-content-rules.mjs +++ b/tools/shell-content-rules.mjs @@ -23,6 +23,9 @@ export function defaultPathNormalize(target) { export const DEFAULT_PROTECTED_PATTERNS = [ /(^|\/)\.claude\/runtime(\/|$)/i, /(^|\/)\.claude\/settings(\.local)?\.json$/i, + // Smoke 5 emergency fix (2026-05-30) — transcript JSONL hard-deny (spec §3.1 was declared, not implemented). + // Prevents self-exfil of parent context across sessions via Bash cat / PowerShell Get-Content / Read tool. + /(^|\/)\.claude\/projects(\/|$)/i, /(^|\/)\.env(\.|$)/i, /(^|\/)node_modules\//i, /(^|\/)CLAUDE\.md$/i, diff --git a/tools/shell-content-rules.test.mjs b/tools/shell-content-rules.test.mjs index 01b926b9..8886061f 100644 --- a/tools/shell-content-rules.test.mjs +++ b/tools/shell-content-rules.test.mjs @@ -36,6 +36,12 @@ describe('isProtectedPath', () => { ])('allows %s', (p) => { expect(isProtectedPath(p, defaultPathNormalize, DEFAULT_PROTECTED_PATTERNS)).toBe(false); }); + + // Smoke 5 emergency fix — transcript JSONL protection (single it() for shell-content-rules hook compliance) + it('protects ~/.claude/projects/*.jsonl (transcript hard-deny per spec §3.1) in shell-content-rules', () => { + expect(isProtectedPath('~/.claude/projects/foo.jsonl', defaultPathNormalize, DEFAULT_PROTECTED_PATTERNS)).toBe(true); + expect(isProtectedPath('/c/Users/Administrator/.claude/projects/abc/def.jsonl', defaultPathNormalize, DEFAULT_PROTECTED_PATTERNS)).toBe(true); + }); }); import { pathDenyOverlay,