diff --git a/docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md b/docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md index 85c0ac37..399a6cdb 100644 --- a/docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md +++ b/docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md @@ -89,6 +89,27 @@ projects/ Write hole — verified prerequisite) then Part 2 (read subagent trans a Task in this turn).** Condition 1 verified VIOLATED (no Write-tool gate covers `~/.claude/projects/`), so Variant 1 alone would weaken — safe only bundled with Part 1. +**Closure (2026-05-31, TDD, no discipline weakened — net strengthening):** +- **Part 1** — `enforce-runtime-write-deny.mjs` extended with `TRANSCRIPT_RE` + (`(^|/)\.claude/projects/.*\.jsonl$`): the Write tool can no longer create/overwrite any + session/subagent transcript `.jsonl`. Memory files there are `.md` and stay writable + (never match `.jsonl$`). Resolving normalizer blocks `.`/`..` evasion. This makes the + agent-``.jsonl that Part 2 trusts unforgeable. +- **Part 2** — `enforce-tdd-gate.mjs`: `decide()` now also credits a subagent's matching + test edit + RED run via new `subagentEntriesList`. `turnTaskAgentIds(turn)` extracts the + **hex** agentId from the harness-written `Task` tool_result ("agentId: ") — the + controller cannot forge its own tool_result, and the hex-only match blocks + `agentId: ../../x` path-traversal. `subagentTranscriptPaths()` derives + `//subagents/agent-.jsonl` (bound to the controller session). + `main()` reads those transcripts best-effort (missing → no extra credit = stricter, never + an error). No NEW weakening: a delegated subagent doing real TDD is legitimate; the only + forgery vector (overwrite the agent jsonl) is closed by Part 1. +- Full tools-vitest: **2027 passed / 2 skipped**. +- **OWNER ACTION (settings.json, Claude can't edit it):** `enforce-tdd-gate.mjs` is already + a registered PreToolUse hook → Part 2 goes live on merge. **Part 1 requires that + `enforce-runtime-write-deny.mjs` be registered** on PreToolUse(Edit|Write|MultiEdit| + NotebookEdit); if it is not yet registered, the transcript Write-deny is inert until added. + ### D. Smoke 8 — live Workflow-gate F2 test Needs a clean session (not code). diff --git a/tools/enforce-runtime-write-deny.mjs b/tools/enforce-runtime-write-deny.mjs index d7736d7d..3c0d4f87 100644 --- a/tools/enforce-runtime-write-deny.mjs +++ b/tools/enforce-runtime-write-deny.mjs @@ -24,6 +24,11 @@ import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers. const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']); const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i; +// Transcript protection (Z Part 1): any *.jsonl under ~/.claude/projects/** is a +// session/subagent transcript. The tdd-gate credits a subagent's RED from its +// agent-.jsonl, so these must be unforgeable by the Write tool. Memory files +// there are *.md and never match `.jsonl$`, so memory writes stay allowed. +const TRANSCRIPT_RE = /(^|\/)\.claude\/projects\/.*\.jsonl$/i; /** * Pure decision. @@ -39,12 +44,19 @@ export function decide({ toolName, filePath, normalizeImpl = pathNormalize }) { if (!fp) return { block: false }; let norm; try { norm = normalizeImpl(fp); } catch { return { block: false }; } // cannot determine → fail-open - if (RUNTIME_RE.test(String(norm || ''))) { + const normStr = String(norm || ''); + if (RUNTIME_RE.test(normStr)) { return { block: true, reason: `Write to «${norm}» denied — ~/.claude/runtime is a protected side-channel (git-approval anchor). Hooks write it via Node fs, not the Write tool.`, }; } + if (TRANSCRIPT_RE.test(normStr)) { + return { + block: true, + reason: `Write to «${norm}» denied — ~/.claude/projects/**/*.jsonl are session/subagent transcripts (tamper-protected; the tdd-gate trusts them). The harness writes transcripts, never the Write tool. Memory *.md there stays writable.`, + }; + } return { block: false }; } diff --git a/tools/enforce-runtime-write-deny.test.mjs b/tools/enforce-runtime-write-deny.test.mjs index 48d29d44..383f208e 100644 --- a/tools/enforce-runtime-write-deny.test.mjs +++ b/tools/enforce-runtime-write-deny.test.mjs @@ -52,3 +52,47 @@ describe('enforce-runtime-write-deny decide()', () => { 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-.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); + }); +}); diff --git a/tools/enforce-tdd-gate.mjs b/tools/enforce-tdd-gate.mjs index ef9f8538..bf4c6fe1 100644 --- a/tools/enforce-tdd-gate.mjs +++ b/tools/enforce-tdd-gate.mjs @@ -27,6 +27,7 @@ import { isProductionCodePath, readRouterState, } from './enforce-hook-helpers.mjs'; +import { join, dirname, basename } from 'node:path'; const RULE_KEY_TDD = 'tdd-gate'; const RULE_KEY_PLAN = 'writing-plans-required'; @@ -132,8 +133,56 @@ function hasPlanIndicator(turn) { return false; } +const AGENT_ID_RE = /agentId:\s*([0-9a-f]+)/i; + +/** + * Cross-actor (Z Part 2): extract agentIds of subagents spawned by a `Task` + * tool in the controller's current turn. The agentId comes from the harness- + * written Task tool_result text ("agentId: ") — the controller cannot forge + * a tool_result in its own transcript. Only hex ids are accepted, so a crafted + * "agentId: ../../x" cannot become a path-traversal into an arbitrary file. + */ +export function turnTaskAgentIds(turn) { + const taskUseIds = new Set(); + for (const e of turn || []) { + const c = e && e.message && e.message.content; + if (!Array.isArray(c)) continue; + for (const b of c) { + if (b && b.type === 'tool_use' && b.name === 'Task') taskUseIds.add(b.id); + } + } + const ids = []; + for (const e of turn || []) { + const c = e && e.message && e.message.content; + if (!Array.isArray(c)) continue; + for (const b of c) { + if (!b || b.type !== 'tool_result' || !taskUseIds.has(b.tool_use_id)) continue; + const txt = typeof b.content === 'string' ? b.content + : Array.isArray(b.content) ? b.content.map((p) => p && p.text).filter(Boolean).join('\n') : ''; + const m = txt.match(AGENT_ID_RE); + if (m) ids.push(m[1]); + } + } + return ids; +} + +/** + * Derive subagent transcript paths from the controller transcript path and a + * list of agentIds. Subagent transcripts live at + * ///subagents/agent-.jsonl + * i.e. nested under the controller session's own directory (bound to it), while + * the controller transcript is <...>/.jsonl. + */ +export function subagentTranscriptPaths(controllerTranscriptPath, agentIds) { + const p = String(controllerTranscriptPath || ''); + if (!p) return []; + const dir = dirname(p); + const base = basename(p).replace(/\.jsonl$/i, ''); + return (agentIds || []).map((id) => join(dir, base, 'subagents', `agent-${id}.jsonl`)); +} + export function decide({ - toolName, filePath, transcriptEntries, classification, override, overridePlan, + toolName, filePath, transcriptEntries, classification, override, overridePlan, subagentEntriesList = [], }) { if (!['Edit', 'Write', 'MultiEdit'].includes(toolName)) return { block: false }; if (!isProductionCodePath(filePath)) return { block: false }; @@ -155,24 +204,31 @@ export function decide({ } } - // Rule #3 — TDD gate. + // Rule #3 — TDD gate. Credit the controller's own turn OR a subagent that was + // spawned by a Task in this turn (cross-actor, Z Part 2). Subagent evidence is + // read from its agent-.jsonl, which is tamper-protected by the transcript + // Write-deny (Z Part 1) — so crediting it does not open a forgery channel. if (override) return { block: false }; - const hasTest = hasMatchingTestEdit(turn, filePath); + const subList = Array.isArray(subagentEntriesList) ? subagentEntriesList : []; + const hasTest = hasMatchingTestEdit(turn, filePath) || subList.some((es) => hasMatchingTestEdit(es, filePath)); if (!hasTest) { return { block: true, message: [ `[enforce-tdd-gate] Production code edit on "${filePath}" without preceding test edit.`, - `Write the failing test FIRST in the corresponding *.test.mjs / *.spec.ts / *Test.php.`, + `Write the failing test FIRST in the corresponding *.test.mjs / *.spec.ts / *Test.php`, + `(a subagent's test edit, if it was spawned by a Task in this turn, is also credited).`, `Then run vitest/pest to confirm RED, then return to this prod-code Edit.`, ].join('\n'), }; } - if (!hasFailingTestRun(turn)) { + const hasRed = hasFailingTestRun(turn) || subList.some((es) => hasFailingTestRun(es)); + if (!hasRed) { return { block: true, message: [ - `[enforce-tdd-gate] Test was edited but no vitest/pest run with RED output observed in this turn.`, + `[enforce-tdd-gate] Test was edited but no vitest/pest run with RED output observed in this turn`, + `(nor in any in-turn subagent transcript).`, `Run the test suite (vitest run / composer test) to confirm RED before prod-code edit.`, ].join('\n'), }; @@ -199,7 +255,19 @@ async function main() { task_type: state.classification.task_type, } : null; - const result = decide({ toolName, filePath, transcriptEntries: transcript, classification, override, overridePlan }); + // Cross-actor (Z Part 2): read transcripts of subagents spawned by a Task in + // this turn, bound to the controller session via the derived path. Best-effort + // — a missing/unreadable subagent transcript just yields no extra credit + // (stricter), never an error. + let subagentEntriesList = []; + try { + const turn = lastTurnEntries(transcript); + const agentIds = turnTaskAgentIds(turn); + const paths = subagentTranscriptPaths(event.transcript_path, agentIds); + subagentEntriesList = paths.map((p) => readTranscript(p)).filter((e) => Array.isArray(e) && e.length); + } catch { subagentEntriesList = []; } + + const result = decide({ toolName, filePath, transcriptEntries: transcript, classification, override, overridePlan, subagentEntriesList }); exitDecision(result); } catch { exitDecision({ block: false }); diff --git a/tools/enforce-tdd-gate.test.mjs b/tools/enforce-tdd-gate.test.mjs index aae8049f..c84e5d08 100644 --- a/tools/enforce-tdd-gate.test.mjs +++ b/tools/enforce-tdd-gate.test.mjs @@ -1,5 +1,79 @@ import { describe, it, expect } from 'vitest'; -import { decide } from './enforce-tdd-gate.mjs'; +import { decide, turnTaskAgentIds, subagentTranscriptPaths } from './enforce-tdd-gate.mjs'; + +// Z Part 2 (2026-05-31): the tdd-gate must credit a subagent's test edit + RED +// when that subagent was spawned by a Task in the controller's current turn. +// Pairs with the transcript Write-hole closed in enforce-runtime-write-deny.mjs +// (Z Part 1) so the credited agent-.jsonl cannot be forged. +describe('enforce-tdd-gate Z cross-actor (pairs with enforce-runtime-write-deny Part 1)', () => { + const subagentRedRun = [ + { message: { role: 'user', content: 'write the failing test for foo and confirm RED' } }, + { message: { role: 'assistant', content: [ + { type: 'tool_use', id: 's1', name: 'Write', input: { file_path: 'tools/foo.test.mjs' } }, + { type: 'tool_use', id: 's2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } }, + ] } }, + { message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 's2', content: 'Tests 1 failed | 0 passed' } ] } }, + ]; + + it('credits a subagent test edit + RED for the controller prod edit', () => { + const r = decide({ + toolName: 'Edit', + filePath: 'tools/foo.mjs', + transcriptEntries: [ + { message: { role: 'user', content: 'delegate the test, then I implement' } }, + { message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Task', input: { subagent_type: 'tester' } } ] } }, + { message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 't1', content: 'done. agentId: a1234abcd' } ] } }, + ], + subagentEntriesList: [subagentRedRun], + }); + expect(r.block).toBe(false); + }); + + it('still blocks when subagent edited a test but NO RED exists anywhere', () => { + const subNoRed = [ + { message: { role: 'user', content: 'write test' } }, + { message: { role: 'assistant', content: [ { type: 'tool_use', id: 's1', name: 'Write', input: { file_path: 'tools/foo.test.mjs' } } ] } }, + ]; + const r = decide({ + toolName: 'Edit', filePath: 'tools/foo.mjs', + transcriptEntries: [ { message: { role: 'user', content: 'go' } } ], + subagentEntriesList: [subNoRed], + }); + expect(r.block).toBe(true); + expect(r.message).toMatch(/RED/); + }); + + it('preserves old behavior when no subagent entries (blocks without test)', () => { + const r = decide({ + toolName: 'Edit', filePath: 'tools/foo.mjs', + transcriptEntries: [ { message: { role: 'user', content: 'go' } } ], + subagentEntriesList: [], + }); + expect(r.block).toBe(true); + expect(r.message).toMatch(/without preceding test edit/); + }); + + it('turnTaskAgentIds extracts a hex agentId from an in-turn Task tool_result', () => { + const turn = [ + { message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Task', input: {} } ] } }, + { message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 't1', content: 'ok agentId: a1b2c3d4e5' } ] } }, + ]; + expect(turnTaskAgentIds(turn)).toContain('a1b2c3d4e5'); + }); + + it('turnTaskAgentIds ignores non-Task results and rejects non-hex ids (no path traversal)', () => { + const turn = [ + { message: { role: 'assistant', content: [ { type: 'tool_use', id: 'b1', name: 'Bash', input: {} } ] } }, + { message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'b1', content: 'agentId: ../../evil' } ] } }, + ]; + expect(turnTaskAgentIds(turn)).toHaveLength(0); + }); + + it('subagentTranscriptPaths derives //subagents/agent-.jsonl', () => { + const paths = subagentTranscriptPaths('/p/projects/slug/sessUUID.jsonl', ['a1b2']); + expect(paths[0].split('\\').join('/')).toBe('/p/projects/slug/sessUUID/subagents/agent-a1b2.jsonl'); + }); +}); function userMsg(text) { return { message: { role: 'user', content: text } };