feat(router-gate): tdd-gate credits delegated (subagent) TDD + transcript write-deny

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>
This commit is contained in:
Дмитрий
2026-05-31 18:18:44 +03:00
parent 6577c04a1f
commit 53407a77cd
5 changed files with 228 additions and 9 deletions
@@ -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-`<id>`.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: <hex>") — the
controller cannot forge its own tool_result, and the hex-only match blocks
`agentId: ../../x` path-traversal. `subagentTranscriptPaths()` derives
`<dir>/<controller-session>/subagents/agent-<id>.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).
+13 -1
View File
@@ -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-<id>.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 };
}
+44
View File
@@ -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-<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);
});
});
+75 -7
View File
@@ -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: <hex>") 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
* <projects>/<slug>/<controller-session>/subagents/agent-<agentId>.jsonl
* i.e. nested under the controller session's own directory (bound to it), while
* the controller transcript is <...>/<controller-session>.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-<id>.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 <test-file> / 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 });
+75 -1
View File
@@ -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-<id>.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 <dir>/<sessbase>/subagents/agent-<id>.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 } };