Summary
The /dev/null (and other device) exemption added in #87 only works when the redirect target is followed by whitespace or end-of-string. When a chained command follows immediately with no space (2>/dev/null; rather than 2>/dev/null ;), the write-guard's word-splitter includes the ; in the parsed path, the result (/dev/null;) no longer matches NON_DESTRUCTIVE_TARGETS, and the command gets blocked as an unsafe file write.
Repro
Using little-coder's own detection function directly (.pi/extensions/_shared/shell-write.ts):
import { detectWriteTargets } from './.pi/extensions/_shared/shell-write.ts';
detectWriteTargets('find /x -name "y" 2>/dev/null; find /z -name "w" 2>/dev/null');
// => [ { path: '/dev/null;', kind: 'redirect' } ] <-- incorrectly flagged as a write
detectWriteTargets('find /x -name "y" 2>/dev/null');
// => [] <-- correct when there's no immediately-following semicolon
In the actual TUI, a real-world command like this:
find /path/to/project -name "llm_service.py" 2>/dev/null; find /path/to/project -name "review_integrity.py" -path "*api*" 2>/dev/null; find /path/to/project -name "*.py" | grep -iE "judge|evaluat|agent" | grep -v __pycache__
gets rejected with:
shell whitelist: this command writes to "/dev/null;" via shell redirection. Use the Write tool for a new file, or Edit for an existing one — do not redirect into files.
The model then retries the same (or a similarly-shaped) command a few times and the turn eventually fails with Retry failed after 3 attempts: terminated, effectively getting the agent stuck on a completely read-only, benign command.
Root cause
In packages/coding-agent's bundled .pi/extensions/_shared/shell-write.ts, detectWriteTargets extracts the redirect target via firstWord(rest) → splitWords(rest). splitWords only treats whitespace, quotes, and >/< as word boundaries — it does not treat ;, &&, ||, or | as boundaries. So for a redirect immediately followed by a chain operator with no space (2>/dev/null;, 2>/dev/null&&true, etc.), the chain operator gets absorbed into the "path", and isNonDestructiveTarget() correctly fails to recognize /dev/null; as /dev/null.
This is basically the same class of issue #87 fixed, just for the "no space before the next command" case, which is an extremely common shell style (and one small/local models reliably produce, per the module's own doc comment about /dev/null being emitted "constantly").
Suggested fix
In firstWord/splitWords (or specifically at the call site in detectWriteTargets), also stop the target word at an unquoted chain operator (;, &&, ||, |, newline) — not just whitespace/quotes/</>. Since splitCommandChain already implements exactly this kind of chain-operator-aware splitting elsewhere in the same file, one option is to extract the redirect target from within already-chain-split segments (or reuse CHAIN_OPERATORS) rather than scanning the raw, unsplit command string for the target.
Environment
- little-coder v1.8.2 → confirmed same repro logic present on current
main (_shared/shell-write.ts) as of 2026-08-17
- Reported against a local vLLM-backed OpenAI-compatible model (
vllm custom provider), but the bug is purely in the string-parsing logic and provider-independent.
Summary
The
/dev/null(and other device) exemption added in #87 only works when the redirect target is followed by whitespace or end-of-string. When a chained command follows immediately with no space (2>/dev/null;rather than2>/dev/null ;), the write-guard's word-splitter includes the;in the parsed path, the result (/dev/null;) no longer matchesNON_DESTRUCTIVE_TARGETS, and the command gets blocked as an unsafe file write.Repro
Using little-coder's own detection function directly (
.pi/extensions/_shared/shell-write.ts):In the actual TUI, a real-world command like this:
gets rejected with:
The model then retries the same (or a similarly-shaped) command a few times and the turn eventually fails with
Retry failed after 3 attempts: terminated, effectively getting the agent stuck on a completely read-only, benign command.Root cause
In
packages/coding-agent'sbundled.pi/extensions/_shared/shell-write.ts,detectWriteTargetsextracts the redirect target viafirstWord(rest)→splitWords(rest).splitWordsonly treats whitespace, quotes, and>/<as word boundaries — it does not treat;,&&,||, or|as boundaries. So for a redirect immediately followed by a chain operator with no space (2>/dev/null;,2>/dev/null&&true, etc.), the chain operator gets absorbed into the "path", andisNonDestructiveTarget()correctly fails to recognize/dev/null;as/dev/null.This is basically the same class of issue #87 fixed, just for the "no space before the next command" case, which is an extremely common shell style (and one small/local models reliably produce, per the module's own doc comment about
/dev/nullbeing emitted "constantly").Suggested fix
In
firstWord/splitWords(or specifically at the call site indetectWriteTargets), also stop the target word at an unquoted chain operator (;,&&,||,|, newline) — not just whitespace/quotes/</>. SincesplitCommandChainalready implements exactly this kind of chain-operator-aware splitting elsewhere in the same file, one option is to extract the redirect target from within already-chain-split segments (or reuseCHAIN_OPERATORS) rather than scanning the raw, unsplit command string for the target.Environment
main(_shared/shell-write.ts) as of 2026-08-17vllmcustom provider), but the bug is purely in the string-parsing logic and provider-independent.