From 28e8ed10b60ce83fa0f0598592692f9f4e1e1efe Mon Sep 17 00:00:00 2001 From: prathikcannotcode Date: Tue, 10 Nov 2026 15:42:06 +0530 Subject: [PATCH] Close two write-guard bypasses - package.json and package-lock.json leave the layout. `npm test` is pre-approved, so an agent that could rewrite the script could run any command without a prompt. - Both hooks resolve the target as the disk spells it before matching a rule. On Windows and macOS, BREACH-LOG.jsonl now hits the append-only rule and Report.json keeps its schema check. Co-Authored-By: Claude Fable 5.2 --- .claude/schemas/layout.json | 2 -- .claude/scripts/lib.mjs | 11 +++++++++-- .claude/scripts/tests/guardrails.test.mjs | 13 ++++++++++++- .claude/scripts/validate-on-write.mjs | 6 +++--- .claude/scripts/write-guard.mjs | 6 +++--- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.claude/schemas/layout.json b/.claude/schemas/layout.json index d9f8637..29abe1c 100644 --- a/.claude/schemas/layout.json +++ b/.claude/schemas/layout.json @@ -29,8 +29,6 @@ { "pattern": "CHANGELOG.md" }, { "pattern": "CITATION.cff" }, { "pattern": "LICENSE" }, - { "pattern": "package.json" }, - { "pattern": "package-lock.json" }, { "pattern": ".gitignore" }, { "pattern": ".gitattributes" }, { "pattern": ".editorconfig" } diff --git a/.claude/scripts/lib.mjs b/.claude/scripts/lib.mjs index f482410..295c257 100644 --- a/.claude/scripts/lib.mjs +++ b/.claude/scripts/lib.mjs @@ -3,8 +3,8 @@ * validation and findings. Node built-ins only, so a fresh clone needs no install. */ -import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; -import { join, extname, relative, resolve, dirname } from 'node:path'; +import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs'; +import { join, extname, relative, resolve, dirname, basename } from 'node:path'; const SCHEMA_DIR = '.claude/schemas'; const SKIP_DIRS = new Set(['.git', 'node_modules']); @@ -17,6 +17,13 @@ export function workspaceRoot(start = process.cwd()) { } } +/** The path as the disk spells it, so another case or a symlink cannot dodge a layout rule. */ +export function realPath(path) { + if (existsSync(path)) return realpathSync.native(path); + const parent = dirname(path); + return parent === path ? path : join(realPath(parent), basename(path)); +} + /** Problems with a SKILL.md frontmatter. A bare `: ` or ` #` in a value is not valid YAML. */ export function frontmatterErrors(text, name) { const front = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''; diff --git a/.claude/scripts/tests/guardrails.test.mjs b/.claude/scripts/tests/guardrails.test.mjs index bc888ce..0f2699b 100644 --- a/.claude/scripts/tests/guardrails.test.mjs +++ b/.claude/scripts/tests/guardrails.test.mjs @@ -68,7 +68,7 @@ test('layout matches data paths and rejects strays', () => { test('agents cannot write the guards that bind them', () => { const layout = loadLayout(repo); - for (const p of ['.claude/scripts/write-guard.mjs', '.claude/schemas/layout.json', '.claude/schemas/finding.schema.json', '.claude/settings.json', '.claude/settings.local.json']) { + for (const p of ['.claude/scripts/write-guard.mjs', '.claude/schemas/layout.json', '.claude/schemas/finding.schema.json', '.claude/settings.json', '.claude/settings.local.json', 'package.json', 'package-lock.json']) { // package.json: `npm test` is pre-approved, so its scripts are a guard too assert.equal(matchRule(layout, p), undefined, p); } assert.ok(matchRule(layout, '.claude/skills/audit/SKILL.md')); @@ -102,6 +102,17 @@ test('report $ref validates nested findings', () => { rmSync(dir, { recursive: true }); }); +test('another spelling of a guarded path is still guarded', { skip: !existsSync(join(repo, 'PACKAGE.JSON')) && 'case-sensitive disk' }, () => { + const dir = workspace(); + const log = 'company-profile/acme/BREACH-LOG.jsonl'; + const r = run('write-guard.mjs', { cwd: dir, input: { tool_name: 'Write', tool_input: { file_path: log, content: '{"b":2}\n' } } }); + assert.equal(r.status, 2); + assert.match(r.stderr, /append-only/); + const post = run('validate-on-write.mjs', { cwd: dir, input: { tool_input: { file_path: log } } }); + assert.equal(post.status, 2, 'the schema still applies'); + rmSync(dir, { recursive: true }); +}); + test('write-guard enforces layout, append-only logs and secrets', () => { const dir = workspace(); const guard = input => run('write-guard.mjs', { cwd: dir, input }).status; diff --git a/.claude/scripts/validate-on-write.mjs b/.claude/scripts/validate-on-write.mjs index 7c9c3b0..ae65656 100644 --- a/.claude/scripts/validate-on-write.mjs +++ b/.claude/scripts/validate-on-write.mjs @@ -7,7 +7,7 @@ */ import { resolve, relative } from 'node:path'; -import { checkFile, workspaceRoot } from './lib.mjs'; +import { checkFile, workspaceRoot, realPath } from './lib.mjs'; let raw = ''; process.stdin.setEncoding('utf8'); @@ -21,9 +21,9 @@ process.stdin.on('end', () => { const start = [event.cwd, process.cwd(), process.env.CLAUDE_PROJECT_DIR].find(dir => dir && workspaceRoot(dir)); if (!start) process.exit(0); - const root = workspaceRoot(start); + const root = realPath(workspaceRoot(start)); - const rel = relative(root, resolve(start, filePath)).replace(/\\/g, '/'); + const rel = relative(root, realPath(resolve(start, filePath))).replace(/\\/g, '/'); const errors = checkFile(rel, root); if (errors.length) { console.error(`Validation failed for ${rel}:\n ${errors.join('\n ')}`); diff --git a/.claude/scripts/write-guard.mjs b/.claude/scripts/write-guard.mjs index 36edc99..6e0109b 100644 --- a/.claude/scripts/write-guard.mjs +++ b/.claude/scripts/write-guard.mjs @@ -13,7 +13,7 @@ import { readFileSync, existsSync } from 'node:fs'; import { resolve, relative, isAbsolute } from 'node:path'; -import { loadLayout, matchRule, workspaceRoot } from './lib.mjs'; +import { loadLayout, matchRule, workspaceRoot, realPath } from './lib.mjs'; const SECRET_PATTERNS = [ /(?:api[_-]?key|apikey)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-]{20,}/i, @@ -77,9 +77,9 @@ function guard(event) { // The event's cwd follows Claude; the others cover a cwd this platform can't read. const start = [event.cwd, process.cwd(), process.env.CLAUDE_PROJECT_DIR].find(dir => dir && workspaceRoot(dir)); if (!start) block('no workspace layout found at or above the working directory'); - const root = workspaceRoot(start); + const root = realPath(workspaceRoot(start)); - const abs = resolve(start, filePath); + const abs = realPath(resolve(start, filePath)); const rel = relative(root, abs).replace(/\\/g, '/'); if (rel === '..' || rel.startsWith('../') || isAbsolute(rel)) block(`path outside workspace: ${filePath}`);