· 7 min read
The pattern below is the one that matters most in our prompt-injection guard. It
catches the single most common injection phrase — some variant of ignore all
previous instructions — and it is marked critical.
\b(ignore|disregard|forget)\s+(all|any|the|your|my|...)?\s*(prior|previous|...)?\s*(instructions?|rules?|...)\bFeed it "ignore", then eight thousand spaces, then "x", and it never finishes.
Why it stalls
Look at the shape rather than the words:
\s+ (group)? \s* (group)? \s*Three whitespace quantifiers, with optional groups between them. When both optional groups match empty — which they do for any input that is just whitespace after the verb — the three quantifiers sit directly adjacent.
A run of N spaces can now be split between them in O(N²) ways. \s+ takes 5,
\s* takes 0, the next takes 3; or 4, 1, 3; or 4, 0, 4. Every one of those is a
distinct state the engine can be in.
None of it matters while the match is succeeding. It matters enormously when the
match fails, because then the engine has to prove no partition works. It walks
every one of them. The x at the end is what makes it fail.
The measurements
Times for "ignore" + N spaces + "x", on a current laptop:
| N | time |
|---|---|
| 2,000 | 1.5s |
| 4,000 | 12.5s |
| 8,000 | never observed to finish |
| 19,000 | never observed to finish |
The guard caps its input at 20,000 characters. That cap is worth dwelling on, because it looks like it should help and does not: the blow-up starts around 2,000 and the cap is ten times higher. A limit above the point where your slowest path degrades is decoration.
It is worse than a hang
Two things make this more than a performance bug.
The hook runs on every tool call, and it scans tool_response — file
contents, fetched pages, command output. That is untrusted text by definition. An
attacker does not need to reach your terminal; they need a file you read to
contain the word ignore followed by a lot of whitespace.
And the hook is fail-open by design, which is the right default for most hooks: a crashing linter should not wedge a session. But fail-open has a sharp edge that is easy to miss.
If a hook is the thing enforcing a rule, every path where it fails to reach a verdict is a path where the rule is not enforced.
In the default warn mode, the session stalls until the harness kills the hook, and no warning is ever emitted. In blocking mode it is worse: a hook that never returns never exits 2, so the critical pattern it exists to block sails through. The denial of service is also a bypass of the control.
The fix
The problem is ambiguity, not the alternation. Bind each optional segment's whitespace inside its own group:
(?:\s+X)? instead of \s+(X)?\s*Now a run of whitespace has exactly one valid partition, and there is nothing to backtrack through. The whole pattern becomes:
\b(?:ignore|disregard|forget)(?:\s+(?:all|any|the|your|my))?(?:\s+(?:prior|previous|above|earlier|prev|preceding))?\s+(?:instructions?|rules?|system\s*prompt|prompt|directives?)\b4.8ms at the full 20,000-character cap, and linear beyond it — 3.9ms on a million spaces.
Proving detection did not change
A faster regex that stops matching is worse than a slow one. Asserting "behaviour is unchanged" is not enough when the thing being changed is a security control.
So we generated every phrase the original grammar describes — every verb, every optional qualifier, every noun, every combination — 1,140 strings, and ran both patterns over all of them. Plus 483 more for a second pattern with the same defect. All 1,623 agreed: nothing matched by one and not the other.
That is a cheap thing to do and it converts a claim into a fact.
The test has to run in a child process
This part surprised us.
A catastrophic regex is synchronous and CPU-bound. node:test's own timeout
cannot interrupt it, because the timeout needs the event loop and the regex never
yields it. The first version of the regression test did not fail against the old
pattern — it hung the entire suite. A red build with no explanation is barely
better than no test.
Running the probe in a child process with a hard timeout turns the same regression into a named failure:
try {
execFileSync(process.execPath, ['-e', probe], { timeout: 20000 });
} catch (err) {
assert.fail('a pattern did not finish within 20s — backtracking is back');
}Verified in both directions, which is the only way to know a test works: the old
regex is killed at 15s with ETIMEDOUT, the new one finishes in 59ms.
What to take from it
Three things generalise beyond this bug.
Regexes in security hooks are attack surface. They run against text an attacker can influence. Test them with hostile input, not realistic input.
Watch for adjacent quantifiers. \s+(x)?\s* is the smell. If two quantifiers
can match the same characters and what follows can fail, you have a bomb. Binding
the whitespace into the optional group costs nothing.
Decide fail-open deliberately. It is usually right. But write down what a timeout means for each hook, because for anything that blocks, "didn't finish" and "allowed" are the same outcome.
The fix shipped in ECC 2.24.4. The guard, the patterns, and the tests are all plain Node with no dependencies, in hooks/safety — worth reading before trusting anything that runs on every tool call.
npm i -g kodelyth-ecc && kodelyth-ecc