Skip to content

fix(tool): keep an attached cp/mv target directory visible to Bash path checks - #3668

Open
konlue wants to merge 2 commits into
agentscope-ai:mainfrom
konlue:fix/bash-attached-target-directory
Open

konlue wants to merge 2 commits into
agentscope-ai:mainfrom
konlue:fix/bash-attached-target-directory

Conversation

@konlue

@konlue konlue commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Background

Bash.check_permissions decides whether a filesystem command may run unattended from the paths returned by BashCommandParser.extract_file_paths. Two safety checks consume that list: the bypass-immune dangerous-path check (_bash.py, step 4) and the ACCEPT_EDITS / DONT_ASK working-directory check (_bash.py, step 6).

extract_file_paths skips every argument that starts with a dash:

# src/agentscope/tool/_builtin/_bash_parser.py
for arg in args:
    if not arg.startswith("-"):
        paths.append((cmd_name, arg))

GNU cp, mv and ln accept the target directory as an option value, and the value may ride along with the option token: cp -t<dir> and cp --target-directory=<dir>. Such a token starts with a dash, so the directory it names is dropped together with the option and never reaches either check. The separated spelling (cp -t <dir>, cp --target-directory <dir>) works, because there the value is a bare word.

As a result, in ACCEPT_EDITS and DONT_ASK a single cp/mv may write into a directory outside the working directory, or into a dangerous directory such as ~/.ssh, without any confirmation.

This is not the compound-command shape fixed by #3514 / #3519 (merged 2026-11-02): that fix requires every subcommand of a compound command to be a filesystem command and touched only _bash.py, leaving extract_file_paths untouched. The commands below are single commands that pass that guard.

Root cause

extract_file_paths treats "starts with a dash" as "is a flag", but an option may carry its value inline. The parser already handles this shape for sed (check_sed_constraints splits --expression=<value> with partition("=")), but the generic path extraction did not.

Fix

_bash_parser.py now recognizes the target directory of cp/mv/ln in every spelling a shell accepts, and keeps it in the extracted paths:

  • Attached: cp -t<dir>, cp --target-directory=<dir>.
  • Separated: cp -t <dir>, cp --target-directory <dir> (the value is the next argument).
  • Quoted: cp -t'<dir>', cp --target-directory='<dir>', cp -t '<dir>' — in the tree-sitter grammar a quoted attached value is a concatenation node and a quoted separated value is a raw_string node, so neither is a plain word and both used to be skipped.
  • Abbreviated: cp --target-d=<dir> — GNU long options may be abbreviated as long as the abbreviation stays unambiguous, and --target-directory is the only t-initial long option of cp/mv/ln.
  • Short option cluster: cp -rt<dir> — the value of -t may follow other value-less short flags. An uppercase T is never treated as a value carrier (ln -T means --no-target-directory).

Only cp, mv and ln are considered, and only when the preceding characters of a short option cluster are known value-less flags. The rule is applied in both extraction paths: the tree-sitter walk (_extract_paths_from_node) and the token-based fallback (_extract_paths_fallback), so the directory stays visible even when the shell syntax cannot be parsed.

Because the extracted list is the single input of both checks, the directory now reaches the dangerous-path check (bypass-immune ASK) and the working-directory check (no auto-allow) at once. A target directory inside the working directory is still auto-allowed.

Before / After

All rows below were observed on main @ 9c006f3 with PermissionContext(mode=PermissionMode.ACCEPT_EDITS); DONT_ASK shares the same branch.

Command Before After
cp -t../outside ./note.txt allow passthrough
cp --target-directory=../outside ./note.txt allow passthrough
mv -t../outside ./note.txt allow passthrough
cp -t'<dir>' ./note.txt (quoted) allow passthrough
cp --target-directory='<dir>' ./note.txt (quoted) allow passthrough
cp -t '<dir>' ./note.txt (quoted, separated) allow passthrough
cp --target-d=../outside ./note.txt (abbreviated) allow passthrough
cp -t<home>/.ssh ./note.txt allow ask (bypass-immune)
cp -t ./sub ./note.txt (inside working dir) allow allow
cp -t ../outside ./note.txt (separated) passthrough passthrough

The allow reason was "all target paths are within a working directory" in every auto-allowed case above: the target directory in the option value never reached the check.

Note that this deliberately tightens cp -t /tmp/out file and friends — a target directory outside the working directory is no longer auto-allowed. That is the same outcome the separated spelling already had, so the two spellings now behave identically.

Tests

The regression tests are added to the existing test files, not to new ones.

tests/permission_bash_parser_test.py (BashParserFilePathsTest):

  • test_attached_target_directory_option — cp -t../outside, cp --target-directory=../outside, mv -t../outside, ln -t../outside, cp -r -t../outside, the separated cp -t ../outside, the quoted cp -t'../outside', cp --target-directory='../outside', cp --target-directory '../outside' and the abbreviated cp --target-d=../outside all extract the same paths.
  • test_attached_target_directory_option_fallback — the token-based fallback keeps the attached target directory visible.

tests/builtin_bash_test.py (BashToolTest):

  • test_attached_target_directory_not_auto_allowed — the attached, quoted and abbreviated spellings are not auto-allowed in ACCEPT_EDITS and DONT_ASK.
  • test_attached_target_directory_inside_working_dir — a target directory inside the working directory stays auto-allowed.
  • test_attached_target_directory_dangerous_path_asks — cp -t<home>/.ssh triggers the bypass-immune dangerous-path ASK.

Red on the unpatched base (main @ 9c006f3), green with this change:

# before the fix
$ python -m pytest tests/permission_bash_parser_test.py tests/builtin_bash_test.py -q
...
FAILED (failures=25)

# after the fix
$ python -m pytest tests/permission_bash_parser_test.py tests/builtin_bash_test.py -q
...
OK

The failing assertions on the unpatched base were exactly the expected ones, e.g. AssertionError: <PermissionBehavior.ALLOW: 'allow'> != <PermissionBehavior.ALLOW: 'allow'> for cp -t../outside ./note.txt, and AssertionError: <PermissionBehavior.ALLOW: 'allow'> != <PermissionBehavior.ASK: 'ask'> for cp -t<home>/.ssh ./note.txt.

black --line-length=79, flake8 --extend-ignore=E203 and git diff --check are clean on the changed files.

Fixes #3663

…ecks

`extract_file_paths` skipped every argument that starts with a dash, so a target directory written in attached form (`cp -tDIR`, `cp --target-directory=DIR`) vanished from the extracted paths. The dangerous-path check and the ACCEPT_EDITS/DONT_ASK working-directory check both consume that list, so `cp -t../outside ./note.txt` and `cp -t~/.ssh ./note.txt` were auto-allowed.

Recognize the attached target directory for cp/mv/ln in both the tree-sitter path and the token-based fallback. The separated spelling (`cp -t DIR`) keeps working unchanged.

Fixes agentscope-ai#3663
…e too

The attached value was only recognised when it was unquoted and spelled out. A quoted attached value is a concatenation node in the tree-sitter grammar, and a quoted separated value is a raw_string node, so both were still skipped; an abbreviated long option such as --target-d=... was not recognised either. GNU long options may be abbreviated as long as the abbreviation stays unambiguous.

The target directory of cp/mv/ln is now visible in every spelling: attached or separated, quoted or unquoted, spelled out or abbreviated.

Fixes agentscope-ai#3663
@konlue

konlue commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Revised after a self-review of my own change: the first version of the fix only covered the unquoted, spelled-out attached value, and three further spellings were still slipping through.

  • cp -t'<dir>' and cp --target-directory='<dir>' — in the tree-sitter grammar a quoted attached value is a concatenation node, not a word, so it was skipped.
  • cp -t '<dir>' — a quoted separated value is a raw_string node, also skipped.
  • cp --target-d=<dir> — GNU long options may be abbreviated as long as the abbreviation stays unambiguous, and --target-directory is the only t-initial long option of cp/mv/ln.

The second commit (5be954a) makes the target directory visible in every spelling a shell accepts: attached or separated, quoted or unquoted, spelled out or abbreviated. The regression tests were extended accordingly — on the unpatched base (main @ 9c006f3) the new cases now fail as well (25 failures in total), and the whole suite passes with the fix.

No change to the scope of the fix: still only cp/mv/ln, still only the target-directory option, still no change to the permission decision logic in _bash.py.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr/awaiting-review Waiting for maintainers to review this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Bash path extraction ignores attached -t/--target-directory values, so cp/mv can write outside the working directory without confirmation

1 participant