config-audit/tests/commands/command-placeholder-shell-safety.test.mjs
Kjell Tore Guttormsen 0b763f25c1 fix(commands): router dogfood — five seam defects, plus the placeholder class
Dogfooding `/config-audit` (the router) against the repo, fasit written before
any run (docs/router-fasit.local.md, untouched). Every claim below is measured
behaviour, not a reading of the source.

1. Bare `<target-path>` inside the step-3 fence is a shell REDIRECTION, not an
   argument. Measured in zsh: both CLIs failed before starting, no output file
   was written, and the echoed status was 1 — inside the band the router's own
   gate calls "continue normally". Quoting makes an unsubstituted placeholder
   reach argv, so it fails in the CLI where the exit code means something.
   Swept the whole class: 30 sites across 12 further command files, since a
   defect in one file is a class until the opposite is measured. New guard:
   command-placeholder-shell-safety.test.mjs.

2. The orchestrator's exit code was discarded. Two commands on one line share a
   single trailing `echo $?`, which reports only the last: measured, an
   orchestrator exit 3 echoed as posture's 0, so the "3 -> stop" gate could
   never fire. Both statuses are now captured and echoed.

3. "Running 12 configuration scanners" — the orchestrator registers 16. The new
   test binds the narrated count to the registry so the next scanner added
   cannot re-stale it silently.

4. The Area Breakdown table hardcoded 7 rows; posture emits 9 quality areas.
   Token Efficiency (a B on this repo) and Plugin Hygiene never reached the
   user. Rows added, and the row set is now asserted against lib/scoring.mjs.
   Label aligned: "MCP Servers" -> "MCP", as posture emits it.

5. Step 6 rendered "the headline line from the humanized stderr scorecard" and
   forbade deriving a replacement — while step 3 sent posture's stderr to
   /dev/null, as UX rule 2 requires, and the prose is absent from the JSON
   payload (measured). The slot could only be improvised. posture's stderr now
   goes to a file in the session dir, as commands/posture.md already did; the
   user still never sees raw scanner output.

Also: `grep -q -- "--raw"` matched any argument CONTAINING --raw (measured on
`--rawdog` and on a path with --raw in it) — anchored to whole arguments.
SCOPE_FLAGS renamed SCOPE_FLAG, since zsh does not word-split and the plural
invited the M-BUG-45 shape.

command-shell-state-shape.test.mjs only recognised line-initial assignments, so
it reported the idiomatic `node …; STATUS=$?` capture as never assigned. Widened
to assignments after a separator; verified it still fails on a real cross-block
reference before trusting it.

Suite 1477 -> 1483, frozen v5.0.0 snapshots untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDAwy1ZXRpZxht1wyCeSbF
2026-08-09 21:18:05 +02:00

102 lines
4.4 KiB
JavaScript

/**
* Session #56 — placeholders inside runnable bash fences must not be shell-active.
*
* Dogfooding the ROUTER (`commands/config-audit.md`) surfaced a defect one layer
* below the CLIs: the fence on the router's step 3 carries the placeholder
* `<target-path>` **bare** — unquoted, preceded by a space. `<` and `>` are
* redirection operators. Measured under the shell the Bash tool actually runs
* (zsh), in a scratch directory:
*
* $ node …/scan-orchestrator.mjs <target-path> --output-file scan.json \
* >/dev/null 2>/dev/null; node …/posture.mjs <target-path> \
* --output-file posture.json 2>/dev/null; echo $?
* zsh:1: no such file or directory: target-path
* zsh:1: no such file or directory: target-path
* 1
* → neither file written, neither CLI ever started
*
* Three properties make this worse than a plain typo:
*
* 1. **The CLI never runs.** Redirection is resolved by the shell before the
* command is executed, so the CLI's own argument validation — the layer that
* `cli-unknown-flag-rejection.test.mjs` hardened — never sees it.
* 2. **The failure is quiet where it counts.** The echoed status is `1`, and
* `1` is inside the band the router's own step 3 classifies as
* "continue normally" (0/1/2 = PASS/WARNING/FAIL; only 3 is a real error).
* A total non-execution is indistinguishable from a healthy WARNING run.
* 3. **The file already knew about the neighbouring hazard.** Two lines above
* the offending call sits a comment warning that a *square-bracket*
* placeholder "does not start with a dash, so both CLIs' arg loops would
* take it as the TARGET PATH instead of a flag" — awareness of the
* placeholder class, while carrying a strictly worse member of it.
*
* The invariant is not "substitute your placeholders" (a template cannot enforce
* that). It is that an UNSUBSTITUTED placeholder must fail **loudly, in the
* CLI**, not silently in the shell. Quoting achieves exactly that: `"<path>"`
* reaches argv as a literal, the CLI reports an unreadable target, and the exit
* code means what the router thinks it means.
*
* Measured breadth at the time of writing: 13 of 21 command files, 33 sites.
*/
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');
async function commandFiles() {
const entries = await readdir(COMMANDS_DIR);
return entries.filter((e) => e.endsWith('.md')).sort();
}
/**
* Yield the lines that live inside ```bash fences, with their 1-based file line
* numbers. Only bash-tagged fences count: an untagged or json fence is prose.
*/
function bashFenceLines(content) {
const out = [];
let inFence = false;
content.split('\n').forEach((line, i) => {
if (/^\s*```/.test(line)) {
inFence = /^\s*```bash\s*$/.test(line);
return;
}
if (inFence) out.push({ line, n: i + 1 });
});
return out;
}
/**
* A placeholder that the shell would read as a redirection: `<` at the start of
* a word (start of line, or after whitespace / `;` / `|` / `&`), a lowercase
* placeholder name, then `>`. A quoted placeholder (`"<path>"`) is excluded by
* construction — the `<` is preceded by a quote, not a word boundary.
*/
const BARE_PLACEHOLDER = /(?:^|[\s;|&(])(<[a-z][a-z0-9._-]*>)/;
test('no runnable bash fence carries a bare (shell-active) angle-bracket placeholder', async () => {
const offenders = [];
for (const file of await commandFiles()) {
const content = await readFile(resolve(COMMANDS_DIR, file), 'utf-8');
for (const { line, n } of bashFenceLines(content)) {
const m = line.match(BARE_PLACEHOLDER);
if (m) offenders.push(`${file}:${n} ${m[1]} in: ${line.trim().slice(0, 90)}`);
}
}
assert.deepEqual(
offenders,
[],
'A bare `<name>` inside a bash fence is a REDIRECTION, not an argument. Left\n' +
'unsubstituted it fails in the shell before the CLI starts — no output file,\n' +
'no CLI diagnostics, and an exit code (1) that the router reads as a normal\n' +
'WARNING run. Quote the placeholder (`"<name>"`) so an unsubstituted template\n' +
'fails loudly in the CLI instead.\n' +
'Offending sites:\n ' + offenders.join('\n '),
);
});