Skip to content

Semicolon right after /dev/null defeats the write-guard's device exemption (regression of #87) #107

Description

@ashalliants

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions