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
1 change: 0 additions & 1 deletion .claude/schemas/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
{ "pattern": "CITATION.cff" },
{ "pattern": "SECURITY.md" },
{ "pattern": "LICENSE" },
{ "pattern": "package.json" },
{ "pattern": ".gitignore" },
{ "pattern": ".gitattributes" }
]
Expand Down
17 changes: 12 additions & 5 deletions .claude/scripts/hea-rea.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
*/

import { readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
import { dirname } from 'node:path';
import { dirname, resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadSchema, validate } from './lib.mjs';
import { loadSchema, validate, loadLayout, matchRule, realPath } from './lib.mjs';

const ROOT = fileURLToPath(new URL('../..', import.meta.url));
const DISCLAIMER =
Expand Down Expand Up @@ -165,9 +165,16 @@ if (isMain()) {
console.error(summarise(result));
const outIdx = process.argv.indexOf('--out');
if (outIdx !== -1 && process.argv[outIdx + 1]) {
mkdirSync(dirname(process.argv[outIdx + 1]), { recursive: true });
writeFileSync(process.argv[outIdx + 1], JSON.stringify(result, null, 2) + '\n');
console.log(`Wrote ${process.argv[outIdx + 1]}`);
const out = process.argv[outIdx + 1];
// --out runs outside the write-guard, so it takes only a new calc file in this workspace.
const rel = relative(realPath(ROOT), realPath(resolve(out))).replace(/\\/g, '/');
if (matchRule(loadLayout(ROOT), rel)?.schema !== 'calc-result.schema.json') {
throw new Error(`--out must be matters/<id>/calc/<run>.json in this workspace, got ${out}`);
}
mkdirSync(dirname(out), { recursive: true });
try { writeFileSync(out, JSON.stringify(result, null, 2) + '\n', { flag: 'wx' }); }
catch (e) { throw e.code === 'EEXIST' ? new Error(`${out} already exists. A run is a record: pick a new run name.`) : e; }
console.log(`Wrote ${out}`);
} else {
console.log(JSON.stringify(result, null, 2));
}
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 @@
* Schema validation. 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
19 changes: 17 additions & 2 deletions .claude/scripts/tests/guardrails.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -49,7 +49,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/calc-result.schema.json', '.claude/settings.json', '.claude/settings.local.json']) {
for (const p of ['.claude/scripts/write-guard.mjs', '.claude/schemas/layout.json', '.claude/schemas/calc-result.schema.json', '.claude/settings.json', '.claude/settings.local.json', 'package.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/env-catalog/SKILL.md'));
Expand Down Expand Up @@ -80,6 +80,21 @@ test('write-guard stops an agent recording expert validation', () => {
const r = run('write-guard.mjs', { cwd: dir, input: { tool_name: 'Write', tool_input: { file_path: 'matters/m1/calc/run-1.json', content: '{ "validation_status": "expert_validated" }' } } });
assert.equal(r.status, 2);
assert.match(r.stderr, /only a named human expert/);

const file = 'matters/m1/calc/run-1.json';
const guard = (tool_name, tool_input) => run('write-guard.mjs', { cwd: dir, input: { tool_name, tool_input: { file_path: file, ...tool_input } } }).status;
writeFileSync(join(dir, file), '{ "validation_status": "pending_expert_review", "label": "x" }');
assert.equal(guard('Edit', { old_string: 'pending_expert_review', new_string: 'expert_validated' }), 2, 'an Edit that swaps only the value');
assert.equal(guard('Write', { content: '{ "validation_status": "expert\\u005fvalidated" }' }), 2, 'a unicode escape');
assert.equal(guard('Edit', { old_string: '"x"', new_string: '"y"' }), 0, 'an ordinary edit still passes');
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 guard = (file_path, content) => run('write-guard.mjs', { cwd: dir, input: { tool_name: 'Write', tool_input: { file_path, content } } }).status;
assert.equal(guard('catalog/Provisions.json', '{}'), 2);
assert.equal(guard('matters/m1/FLAGS.jsonl', ''), 2);
rmSync(dir, { recursive: true });
});

Expand Down
26 changes: 21 additions & 5 deletions .claude/scripts/tests/hea-rea.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,27 @@ test('CLI reports bad input plainly and exits 1', () => {
}
});

test('CLI creates the output folder', () => {
const r = cli(worked, '--out', '<dir>/matters/new-matter/calc/run-1.json');
assert.equal(r.status, 0, r.stderr);
assert.ok(existsSync(join(r.dir, 'matters/new-matter/calc/run-1.json')));
rmSync(r.dir, { recursive: true });
test('CLI writes only a new calc file inside the workspace', () => {
const matter = join(repo, `matters/zz-test-${process.pid}`); // gitignored
const out = join(matter, 'calc/run-1.json');
try {
const first = cli(worked, '--out', out);
assert.equal(first.status, 0, first.stderr);
assert.ok(existsSync(out), 'creates the output folder');
const again = cli(worked, '--out', out);
assert.equal(again.status, 1);
assert.match(again.stderr, /already exists/);
for (const dir of [first.dir, again.dir]) rmSync(dir, { recursive: true });

for (const bad of ['<dir>/matters/m/calc/run-1.json', join(repo, 'catalog/provisions.json'), join(matter, 'flags.jsonl'), join(repo, '.claude/settings.json')]) {
const r = cli(worked, '--out', bad);
assert.equal(r.status, 1, bad);
assert.match(r.stderr, /--out must be matters/);
rmSync(r.dir, { recursive: true });
}
} finally {
rmSync(matter, { recursive: true, force: true });
}
});

test('CLI still runs when reached through a symlink', { skip: process.platform === 'win32' }, () => {
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
12 changes: 8 additions & 4 deletions .claude/scripts/write-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,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 @@ -63,9 +63,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 All @@ -81,7 +81,11 @@ function guard(event) {
}

const content = [input.content, ...editsOf(input).map(e => e.new_string)].filter(Boolean).join('\n');
if (/"validation_status"\s*:\s*"expert_validated"/.test(content)) {
// The file as it would be saved, parsed, so an Edit that swaps only the value or a \u escape is still caught.
let status;
try { status = JSON.parse(contentAfter(existsSync(abs) ? readFileSync(abs, 'utf8') : '', event.tool_name, input)).validation_status; }
catch { /* not a JSON file */ }
if (status === 'expert_validated' || /"validation_status"\s*:\s*"expert_validated"/.test(content)) {
block('only a named human expert records expert_validated, by hand, with validated_by and validated_at.');
}
if (SECRET_PATTERNS.some(p => p.test(content))) {
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Include what you found, how to reproduce it, and the impact.

## Known limits

- Hooks guard Claude Code's Write, Edit and MultiEdit tools. A shell command run through the Bash tool is not intercepted, and that includes the calculator's `--out`; `npm run validate` still checks what it wrote. Keep Bash permissions narrow (`.claude/settings.json` allows only the validate, test and calculator commands by default).
- Hooks guard Claude Code's Write, Edit and MultiEdit tools. A shell command run through the Bash tool is not intercepted. The calculator's `--out` checks itself: it writes only a new `matters/<id>/calc/<run>.json` and never overwrites one. Keep Bash permissions narrow (`.claude/settings.json` allows only the validate, test and calculator commands by default).
- `onFailure: block` needs Claude Code 2.1.295 or newer. On older versions, a hook that cannot start at all does not block the write.
- The hook command looks for the guard under `${CLAUDE_PROJECT_DIR}`, the folder a session started in, then relative to the working directory. A session that moved here from elsewhere and then changed into a subdirectory can't find it, so writes are blocked until it returns to the repo root.
- On Windows without Git Bash, Claude Code runs hooks in PowerShell, which can't parse this command. The write-guard then blocks every write, and post-write validation does not run. Install Git for Windows.
Expand Down
Loading