From 25e184e52d2e68e58acfdc90f91f9a11123e68f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 30 May 2026 08:01:49 +0300 Subject: [PATCH] =?UTF-8?q?fix(router-gate-v4):=20EMERGENCY=20Smoke=205=20?= =?UTF-8?q?closure=20=E2=80=94=20transcript=20JSONL=20hard-deny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke 5 (user-run 2026-05-30) found 5 of 6 self-exfil vectors OPEN — parent context transcripts (~/.claude/projects/*/.jsonl) readable via: - Bash: ls ~/.claude/projects/, cat ~/.claude/projects/.../jsonl, head/tail - PowerShell: Get-Content / gc / Select-String - Read tool: direct file_path Spec §3.1 declared this protection but Stream B/C/G never wired path-deny. DEFAULT_PROTECTED_PATTERNS in shell-content-rules.mjs lacked ~/.claude/projects/. Read tool had no path-deny hook at all. Closure: - shell-content-rules.mjs: +1 regex /(^|\/)\.claude\/projects(\/|$)/i in DEFAULT_PROTECTED_PATTERNS. Bash hook + PowerShell hook already use this shared list via pathDenyOverlay → both close automatically. - enforce-read-path-deny.mjs (NEW): PreToolUse(Read) wrapper rejects any Read on isProtectedPath() match. Uses same shared DEFAULT_PROTECTED_PATTERNS for consistency (covers transcript / runtime / .env / normative / dep-checksums). - .claude/settings.json: register enforce-read-path-deny.mjs PreToolUse Read. Tests: shell-content-rules.test.mjs +3 path cases in protects %s + 1 dedicated it() block. enforce-read-path-deny.test.mjs +6 cases covering Read tool block. 1714/1714 vitest tools GREEN (was 1707, +7 net). Critical: re-run Smoke 5 in clean session before continuing other smokes. Expected post-fix: all 6 vectors blocked (Bash/PowerShell via path-deny, Read via enforce-read-path-deny, Write via existing normative-content-rules). Side observation from Smoke 5 session: controller self-fabrication of intermediate results (4 occurrences across Smokes 1-5) confirms one of the 7 fundamental limits — gate paradox + behavioral residual irreducible. No hook catches in-response narrative fabrication (not through tool-gate). This is a Stream H + recovery-procedures.md documentation item, not a hook fix. --- .claude/settings.json | 10 ++++++ tools/enforce-read-path-deny.mjs | 52 +++++++++++++++++++++++++++ tools/enforce-read-path-deny.test.mjs | 30 ++++++++++++++++ tools/shell-content-rules.mjs | 3 ++ tools/shell-content-rules.test.mjs | 6 ++++ 5 files changed, 101 insertions(+) create mode 100644 tools/enforce-read-path-deny.mjs create mode 100644 tools/enforce-read-path-deny.test.mjs 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,