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
2 changes: 0 additions & 2 deletions .claude/schemas/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
11 changes: 9 additions & 2 deletions .claude/scripts/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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] ?? '';
Expand Down
13 changes: 12 additions & 1 deletion .claude/scripts/tests/guardrails.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions .claude/scripts/validate-on-write.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 ')}`);
Expand Down
6 changes: 3 additions & 3 deletions .claude/scripts/write-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`);

Expand Down
Loading