fix(commands): stop assuming shell state survives between blocks
Dogfooding `plan` + `implement` against a throwaway config surfaced one root
defect with many arms: the command templates treat consecutive fenced blocks as
one shell. They are not. Every ```bash fence runs as its own Bash call in its own
process, so a variable set in one block is empty in the next, and `$$` is a
different PID (measured: 21710 vs 22109).
The planner agent confirmed the sharpest arm at runtime, reporting that
`Mode: $RAW_FLAG` "arrived literally unsubstituted" — `--raw` was documented in
three command files while being functionally dead. A machine sweep found the same
root in 20 places across 9 files, well past the two the written fasit predicted:
- `$RAW_FLAG` read from non-shell agent prompts (analyze, plan, implement)
- `$TMPFILE` read across blocks (tokens, manifest, whats-active,
plugin-health) — each command could not read the file it had just written
- `$GLOBAL_FLAG` across blocks (fix)
- `$TODAY` never assigned in any block (campaign), passing
`--reference-date ""` to a write CLI in six places
- three `$$` temp paths handed to the Read tool (fix), which expands neither
All now follow the hardened drift.md pattern: a fixed literal path, or a
re-derivation inside each block that needs it.
Also fixed, all confirmed against ground truth rather than inferred:
- `implement` printed a rollback ID it never captured (the timestamp lived only
inside a command substitution) — the one message a user reads after a bad run
- `plan` reported "No analysis results found" for valid sessions, because Read
was pointed at a glob it cannot expand; now uses Glob and verifies the
analysis report exists before spawning the agent
- five phase commands wrote state.yaml with two of four required fields; since
the agent writes all four, a follow-up write silently deleted the rest
- `implement` promised rollback deletes created files; rollback deliberately
leaves them (M-BUG-26 still open) — the doc, not the engine, was wrong
- `implement` claimed a score delta with no pre-change measurement
- `verifier-agent` was told to write a report it has no tool to write
- dead `Task` tool name in always-loaded rule context; planner-agent template
demonstrated the inline file content its own line 110 forbids
The sweeps land as tests/commands/command-shell-state-shape.test.mjs, verified
red before the fix and proven able to fail by reintroducing the defect. Two
existing tests asserted the old bash-block mechanism rather than the intent and
were updated. Suite 1449/0; frozen v5.0.0 snapshots and all scanner code
untouched.
Not fixed, deliberately: neither command scope-gates its actions to the audit
target. The generated plan included an edit to a real file under ~/.claude,
outside the throwaway target, because the skill/agent scanners are machine-wide.
That is a design change, not a side fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195udHgCcFegzm7ecKku2Yc
This commit is contained in:
parent
de8a7b5d51
commit
09f817977c
17 changed files with 413 additions and 71 deletions
|
|
@ -41,13 +41,33 @@ async function readCommand(name) {
|
|||
return await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
test('Action: every file contains a Bash invocation block', async () => {
|
||||
// plan.md invokes no scanner — it spawns the planner agent. Its only bash
|
||||
// block used to be a `RAW_FLAG=` assignment referenced from the agent prompt
|
||||
// below it; a prompt is not a shell, so the agent received the literal string
|
||||
// `$RAW_FLAG` (confirmed at runtime by the planner agent, session #49).
|
||||
// Removing that block is the fix, so this assertion skips it.
|
||||
const AGENT_DRIVEN = new Set(['plan.md']);
|
||||
|
||||
test('Action: every scanner-invoking file contains a Bash invocation block', async () => {
|
||||
for (const name of ACTION_FILES) {
|
||||
if (AGENT_DRIVEN.has(name)) continue;
|
||||
const content = await readCommand(name);
|
||||
assert.match(content, BASH_BLOCK_REGEX, `${name} missing bash block`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Action: agent-driven files spawn an Agent instead of a scanner', async () => {
|
||||
for (const name of AGENT_DRIVEN) {
|
||||
const content = await readCommand(name);
|
||||
assert.match(content, /Agent\(subagent_type:/, `${name} should spawn an Agent`);
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/RAW_FLAG=/,
|
||||
`${name} must not assign a shell variable it then references from an agent prompt`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Action: every file references the Read tool', async () => {
|
||||
for (const name of ACTION_FILES) {
|
||||
const content = await readCommand(name);
|
||||
|
|
|
|||
188
tests/commands/command-shell-state-shape.test.mjs
Normal file
188
tests/commands/command-shell-state-shape.test.mjs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* Session #49 — command-template shell-state shape tests.
|
||||
*
|
||||
* Dogfooding the `plan` + `implement` chunk surfaced one root defect with
|
||||
* several arms: **command templates assume shell state survives between
|
||||
* fenced blocks.** It does not. Every ```bash fence is executed as its own
|
||||
* Bash tool call, in its own process:
|
||||
*
|
||||
* - A variable assigned in block N is empty in block N+1.
|
||||
* - `$$` (the PID) differs between calls, so a `/tmp/foo-$$.json` path
|
||||
* created in one block can never be reconstructed in a later one.
|
||||
* - The Read tool expands neither shell variables nor `$$` nor globs; it
|
||||
* takes one literal path.
|
||||
*
|
||||
* Measured arms at the time of writing (all fixed by the accompanying commit):
|
||||
* - `$RAW_FLAG` referenced inside a (non-bash) agent-prompt fence in
|
||||
* analyze.md, plan.md, implement.md — the agent received the literal
|
||||
* string `$RAW_FLAG`, confirmed at runtime by the planner-agent itself.
|
||||
* - `$TMPFILE` referenced across blocks in manifest.md, tokens.md,
|
||||
* whats-active.md, plugin-health.md.
|
||||
* - `$GLOBAL_FLAG` across blocks in fix.md.
|
||||
* - `$TODAY` across blocks in campaign.md (6 sites).
|
||||
* - `$$` temp paths referenced outside their creating fence in fix.md.
|
||||
* - plan.md asked the Read tool to expand
|
||||
* `~/.claude/config-audit/sessions/*_/state.yaml`.
|
||||
*
|
||||
* The hardened pattern already present in drift.md is the target shape: a
|
||||
* fixed literal temp path, repeated literally in every block that needs it.
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands');
|
||||
|
||||
/** Shell variables supplied by the environment, not by a prior block. */
|
||||
const AMBIENT = new Set([
|
||||
'CLAUDE_PLUGIN_ROOT', 'ARGUMENTS', 'HOME', 'PATH', 'PWD', 'USER', 'TMPDIR',
|
||||
]);
|
||||
|
||||
async function commandFiles() {
|
||||
const entries = await readdir(COMMANDS_DIR);
|
||||
return entries.filter((e) => e.endsWith('.md')).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse fenced blocks. Returns { lines, blockIndexOf(lineIdx) } where
|
||||
* blockIndexOf returns -1 for prose outside any fence.
|
||||
*/
|
||||
function parseFences(content) {
|
||||
const lines = content.split('\n');
|
||||
const blocks = [];
|
||||
let open = null;
|
||||
lines.forEach((line, i) => {
|
||||
const m = line.match(/^\s*```(\w*)/);
|
||||
if (!m) return;
|
||||
if (open === null) open = { lang: m[1], start: i };
|
||||
else {
|
||||
blocks.push({ lang: open.lang, start: open.start, end: i });
|
||||
open = null;
|
||||
}
|
||||
});
|
||||
const blockIndexOf = (i) => blocks.findIndex((b) => i > b.start && i < b.end);
|
||||
return { lines, blocks, blockIndexOf };
|
||||
}
|
||||
|
||||
/** Strip `#` comments from a bash line so the test never matches its own prose. */
|
||||
function stripComment(line) {
|
||||
const h = line.indexOf('#');
|
||||
return h === -1 ? line : line.slice(0, h);
|
||||
}
|
||||
|
||||
test('Shell state: no variable is referenced outside the block that assigned it', async () => {
|
||||
const violations = [];
|
||||
for (const name of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
const { lines, blocks, blockIndexOf } = parseFences(content);
|
||||
|
||||
const assignedIn = new Map();
|
||||
lines.forEach((raw, i) => {
|
||||
const m = stripComment(raw).match(/^\s*([A-Z_][A-Z0-9_]*)=/);
|
||||
if (!m) return;
|
||||
const bi = blockIndexOf(i);
|
||||
if (bi < 0) return;
|
||||
if (!assignedIn.has(m[1])) assignedIn.set(m[1], new Set());
|
||||
assignedIn.get(m[1]).add(bi);
|
||||
});
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const line = stripComment(raw);
|
||||
const bi = blockIndexOf(i);
|
||||
const re = /\$\{?([A-Z_][A-Z0-9_]*)\}?/g;
|
||||
let m;
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
const v = m[1];
|
||||
if (AMBIENT.has(v)) continue;
|
||||
// Skip the assignment site itself (`FOO=$FOO...` right-hand side is fine).
|
||||
const eq = line.indexOf('=');
|
||||
if (/^\s*[A-Z_][A-Z0-9_]*=/.test(line) && line.indexOf(m[0]) < eq) continue;
|
||||
const where = assignedIn.get(v);
|
||||
if (!where) {
|
||||
violations.push(`${name}:${i + 1} $${v} is never assigned in any block`);
|
||||
} else if (bi < 0) {
|
||||
violations.push(
|
||||
`${name}:${i + 1} $${v} referenced in prose/agent-prompt — no shell expands it there`,
|
||||
);
|
||||
} else if (!where.has(bi)) {
|
||||
const lang = blocks[bi].lang || 'none';
|
||||
violations.push(
|
||||
`${name}:${i + 1} $${v} referenced in block ${bi} (lang=${lang}) but assigned only in block(s) ${[...where].join(', ')} — separate Bash calls, separate processes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.deepEqual(violations, [], `Cross-block shell-variable references:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
test('Shell state: no $$ temp path is referenced outside the block that created it', async () => {
|
||||
const violations = [];
|
||||
for (const name of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
const { lines, blockIndexOf } = parseFences(content);
|
||||
|
||||
const firstSeen = new Map();
|
||||
const scan = (raw, i, cb) => {
|
||||
const re = /(\/tmp\/[A-Za-z0-9._-]*\$\$[A-Za-z0-9._-]*)/g;
|
||||
let m;
|
||||
while ((m = re.exec(stripComment(raw))) !== null) cb(m[1], i);
|
||||
};
|
||||
lines.forEach((raw, i) => scan(raw, i, (p) => {
|
||||
if (!firstSeen.has(p)) firstSeen.set(p, { blk: blockIndexOf(i), line: i + 1 });
|
||||
}));
|
||||
lines.forEach((raw, i) => scan(raw, i, (p) => {
|
||||
const origin = firstSeen.get(p);
|
||||
if (origin.line === i + 1) return;
|
||||
const bi = blockIndexOf(i);
|
||||
if (bi < 0) {
|
||||
violations.push(`${name}:${i + 1} ${p} referenced in prose — the Read tool cannot expand $$`);
|
||||
} else if (bi !== origin.blk) {
|
||||
violations.push(
|
||||
`${name}:${i + 1} ${p} referenced in block ${bi} but created in block ${origin.blk} — $$ is a different PID there`,
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
assert.deepEqual(violations, [], `Unresolvable $$ temp paths:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
test('Read tool: never asked to expand a glob', async () => {
|
||||
const violations = [];
|
||||
for (const name of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
content.split('\n').forEach((raw, i) => {
|
||||
// Only flag when the glob is the *object* of a Read instruction, i.e.
|
||||
// "Read tool on `<glob>`" / "Read `<glob>`". Prose that merely explains
|
||||
// that Read cannot expand a glob is the fix, not the defect — matching
|
||||
// any co-occurrence on the line would flag this repo's own warning text.
|
||||
const m = raw.match(/\bRead(?:\s+the)?(?:\s+tool)?\s+(?:tool\s+)?on\s+`([~/][^`]*)`|\bRead\s+`([~/][^`]*)`/);
|
||||
if (!m) return;
|
||||
const path = m[1] ?? m[2];
|
||||
if (!path.includes('*')) return;
|
||||
violations.push(`${name}:${i + 1} Read tool pointed at a glob \`${path}\` — use Glob`);
|
||||
});
|
||||
}
|
||||
assert.deepEqual(violations, [], `Read-tool glob misuse:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
||||
test('state.yaml: phase commands name all four fields the rule requires', async () => {
|
||||
// .claude/rules/state-management.md mandates current_phase, completed_phases,
|
||||
// next_phase, updated_at after EVERY phase. A command that writes the file
|
||||
// while naming only two fields silently drops the other two.
|
||||
const REQUIRED = ['current_phase', 'completed_phases', 'next_phase', 'updated_at'];
|
||||
const violations = [];
|
||||
for (const name of await commandFiles()) {
|
||||
const content = await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
if (!/Update\s+`?state\.yaml`?|Update state/i.test(content)) continue;
|
||||
const missing = REQUIRED.filter((f) => !content.includes(f));
|
||||
if (missing.length) {
|
||||
violations.push(`${name} updates state.yaml but never names: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(violations, [], `Incomplete state.yaml contracts:\n${violations.join('\n')}`);
|
||||
});
|
||||
|
|
@ -68,13 +68,33 @@ async function readCommand(name) {
|
|||
return await readFile(resolve(COMMANDS_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
test('Group B: every file contains a Bash invocation block', async () => {
|
||||
// Agent-driven commands invoke no scanner, so they have no bash block to
|
||||
// assert. analyze.md's only bash block used to be a `RAW_FLAG=` assignment
|
||||
// referenced from the agent prompt below it — a prompt is not a shell, so the
|
||||
// agent received the literal string `$RAW_FLAG` (session #49). Removing that
|
||||
// block is the fix; requiring one here would re-assert the defect.
|
||||
const AGENT_DRIVEN = new Set(['analyze.md']);
|
||||
|
||||
test('Group B: every scanner-invoking file contains a Bash invocation block', async () => {
|
||||
for (const name of GROUP_B_FILES) {
|
||||
if (AGENT_DRIVEN.has(name)) continue;
|
||||
const content = await readCommand(name);
|
||||
assert.match(content, BASH_BLOCK_REGEX, `${name} missing bash block`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Group B: agent-driven files spawn an Agent instead of a scanner', async () => {
|
||||
for (const name of AGENT_DRIVEN) {
|
||||
const content = await readCommand(name);
|
||||
assert.match(content, /Agent\(subagent_type:/, `${name} should spawn an Agent`);
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/RAW_FLAG=/,
|
||||
`${name} must not assign a shell variable it then references from an agent prompt`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Group B: every file references the Read tool', async () => {
|
||||
for (const name of GROUP_B_FILES) {
|
||||
const content = await readCommand(name);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue