Why IFS= read was blocked, and what changed

PR #3454 · closes #3453 · compound-command-validator

Try a command

Before (shipped in alpha.25)
.
After (this PR)
.

The bug in one line

The DENY branch matched any IFS= assignment, including the empty one. So the idiomatic safe read loop was hard-blocked.

The command was not the operator's. OrchestKit's own ork:dream skill ships that exact line at src/skills/dream/SKILL.md:437, so STEP 8 of the skill was un-runnable on every invocation. It was found by running the skill, not by reading it.

// before
if (/\$\{?IFS\}?/.test(unquoted) || /\bIFS=/.test(unquoted)) {
  findings.push('IFS manipulation detected');
}

Why the empty assignment is not manipulation

The DENY exists for a real reason: redefining IFS re-tokenizes a command and can hide it from the substring denylist. That reasoning holds for IFS=, and IFS=$'\n'. It does not hold for IFS=:

The trap the fix avoids

The obvious patch is "allow any command that contains the benign form". That is a bypass: one harmless occurrence would launder a real manipulation elsewhere in the same command.

// naive, and exploitable:
if (!/\bIFS=\s+read\b/.test(u) && /\bIFS=/.test(u)) deny();
//   IFS=, cmd && IFS= read   ->  ALLOWED. wrong.

// shipped: strip the benign form, judge the REMAINDER
const ifsProbe = unquoted.replace(/\bIFS=(?:''|"")?(?=\s+read\b)/g, '');
if (/\$\{?IFS\}?/.test(ifsProbe) || /\bIFS=/.test(ifsProbe)) deny();

That mixed case is covered by a regression test, not just by argument.

Test matrix

CommandBeforeAfter

Third instance of one class

This is not an isolated bug. Same shape, three times:

Every one matched a syntactic shape instead of the evasive capability that shape sometimes carries. Worth a sweep of the remaining DENY branches rather than a fourth one-off.