53407a77cd
Closes the TDD-gate cross-actor gap: when a subagent (spawned by a Task in the controller's current turn) writes the failing test and confirms RED, the controller's subsequent production edit was falsely blocked because the gate only scanned the controller's own turn. Net strengthening, no discipline weakened. - Part 1 (enforce-runtime-write-deny): block the Write tool from any ~/.claude/projects/**/*.jsonl (session/subagent transcripts). Memory *.md there stays writable (never matches .jsonl$). Resolving normalizer defeats ./.. evasion. This makes the agent-<id>.jsonl that Part 2 trusts unforgeable (it was the last ungated write channel; Bash/PowerShell/Read gates already covered it). - Part 2 (enforce-tdd-gate): decide() also credits a subagent's matching test edit + RED via a new subagentEntriesList. turnTaskAgentIds() reads the hex agentId from the harness-written Task tool_result (the controller cannot forge its own tool_result; hex-only match blocks "agentId: ../../x" path traversal). subagentTranscriptPaths() derives <dir>/<controller-session>/subagents/agent-<id>.jsonl. main() reads them best-effort (missing/unreadable -> no extra credit = stricter). No new weakening: a delegated subagent doing real TDD is legitimate; the only forgery vector (overwriting the agent jsonl) is closed by Part 1. Existing controller-turn behaviour is preserved (empty subagent list == old logic). OWNER (settings.json, Claude can't edit it): enforce-tdd-gate is already a registered PreToolUse hook -> Part 2 goes live on merge. enforce-runtime-write-deny must be registered on PreToolUse(Edit|Write|MultiEdit|NotebookEdit) for Part 1 to be live. TDD: RED -> GREEN per behavior. tools-vitest 2027 passed / 2 skipped. Backlog item C (=Z); plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
4.8 KiB
JavaScript
99 lines
4.8 KiB
JavaScript
// tools/enforce-runtime-write-deny.test.mjs
|
|
// Standalone write-deny on ~/.claude/runtime (router-gate v4 §"Side-channel
|
|
// write-protection", item 1b / C3). Closes a pre-existing self-write hole on the
|
|
// git-approval anchor; uses the resolving pathNormalize so `.`/`..` segments
|
|
// cannot evade the match (V2-2).
|
|
import { describe, it, expect } from 'vitest';
|
|
import { decide } from './enforce-runtime-write-deny.mjs';
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
const HOME = homedir();
|
|
const HOME_FWD = HOME.replace(/\\/g, '/');
|
|
|
|
describe('enforce-runtime-write-deny decide()', () => {
|
|
it('blocks a Write into ~/.claude/runtime (git-approval anchor)', () => {
|
|
const r = decide({ toolName: 'Write', filePath: join(HOME, '.claude', 'runtime', 'askuser-decisions-S.jsonl') });
|
|
expect(r.block).toBe(true);
|
|
});
|
|
|
|
it('blocks the .-segment evasion (V2-2)', () => {
|
|
// Raw string with `/./` — path.join would pre-collapse it, so build it literally.
|
|
const evasion = `${HOME_FWD}/.claude/./runtime/x.jsonl`;
|
|
const r = decide({ toolName: 'Write', filePath: evasion });
|
|
expect(r.block).toBe(true);
|
|
});
|
|
|
|
it('blocks Edit/MultiEdit/NotebookEdit too', () => {
|
|
const p = join(HOME, '.claude', 'runtime', 'safe-baseline-ledger-S.json');
|
|
expect(decide({ toolName: 'Edit', filePath: p }).block).toBe(true);
|
|
expect(decide({ toolName: 'MultiEdit', filePath: p }).block).toBe(true);
|
|
expect(decide({ toolName: 'NotebookEdit', filePath: p }).block).toBe(true);
|
|
});
|
|
|
|
it('allows a Write to a normal project path', () => {
|
|
const r = decide({ toolName: 'Write', filePath: join(HOME, 'project', 'src', 'x.mjs') });
|
|
expect(r.block).toBe(false);
|
|
});
|
|
|
|
it('ignores non-write tools', () => {
|
|
expect(decide({ toolName: 'Read', filePath: join(HOME, '.claude', 'runtime', 'x') }).block).toBe(false);
|
|
expect(decide({ toolName: 'Bash', filePath: join(HOME, '.claude', 'runtime', 'x') }).block).toBe(false);
|
|
});
|
|
|
|
it('fail-open (no block) when the normalizer throws — never bricks the session', () => {
|
|
const throwing = () => { throw new Error('boom'); };
|
|
const r = decide({ toolName: 'Write', filePath: join(HOME, '.claude', 'runtime', 'x'), normalizeImpl: throwing });
|
|
expect(r.block).toBe(false);
|
|
});
|
|
|
|
it('blocks via injected normalizer that resolves into runtime', () => {
|
|
const r = decide({ toolName: 'Write', filePath: 'whatever', normalizeImpl: () => '/home/u/.claude/runtime/x.jsonl' });
|
|
expect(r.block).toBe(true);
|
|
});
|
|
});
|
|
|
|
// Part 1 of Z (2026-05-31): close the transcript Write hole. The tdd-gate will
|
|
// (Part 2) credit a subagent's RED from its agent-<id>.jsonl; that transcript
|
|
// must therefore be unforgeable. The Write tool was the last ungated channel
|
|
// into ~/.claude/projects/**/*.jsonl (Bash/PowerShell/Read gates already cover
|
|
// it). Memory files there are .md and stay writable (they never match .jsonl$).
|
|
describe('enforce-runtime-write-deny — transcript .jsonl protection (Z Part 1)', () => {
|
|
it('blocks a Write to a subagent transcript under ~/.claude/projects', () => {
|
|
const p = join(HOME, '.claude', 'projects', 'slug', 'sess-uuid', 'subagents', 'agent-abc.jsonl');
|
|
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(true);
|
|
});
|
|
|
|
it('blocks a Write to the controller session transcript itself', () => {
|
|
const p = join(HOME, '.claude', 'projects', 'slug', 'sess-uuid.jsonl');
|
|
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(true);
|
|
});
|
|
|
|
it('blocks Edit/MultiEdit/NotebookEdit on a transcript .jsonl too', () => {
|
|
const p = join(HOME, '.claude', 'projects', 'slug', 'sess', 'subagents', 'agent-x.jsonl');
|
|
expect(decide({ toolName: 'Edit', filePath: p }).block).toBe(true);
|
|
expect(decide({ toolName: 'MultiEdit', filePath: p }).block).toBe(true);
|
|
expect(decide({ toolName: 'NotebookEdit', filePath: p }).block).toBe(true);
|
|
});
|
|
|
|
it('blocks the .-segment evasion into projects transcripts', () => {
|
|
const evasion = `${HOME_FWD}/.claude/projects/slug/./sess/subagents/agent-x.jsonl`;
|
|
expect(decide({ toolName: 'Write', filePath: evasion }).block).toBe(true);
|
|
});
|
|
|
|
it('ALLOWS a memory .md under ~/.claude/projects (never a .jsonl)', () => {
|
|
const p = join(HOME, '.claude', 'projects', 'slug', 'memory', 'feedback_x.md');
|
|
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(false);
|
|
});
|
|
|
|
it('ALLOWS a .jsonl OUTSIDE ~/.claude/projects (e.g. repo observer episodes)', () => {
|
|
const p = join(HOME, 'repo', 'docs', 'observer', 'episodes-2026-05.jsonl');
|
|
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(false);
|
|
});
|
|
|
|
it('ignores non-write tools on a transcript path', () => {
|
|
const p = join(HOME, '.claude', 'projects', 'slug', 'sess', 'subagents', 'agent-x.jsonl');
|
|
expect(decide({ toolName: 'Read', filePath: p }).block).toBe(false);
|
|
});
|
|
});
|