Files
wehub-resource-sync c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Has been cancelled
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.11) (push) Has been cancelled
Web UI / check (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:27 +08:00

896 lines
33 KiB
Python

# -*- coding: utf-8 -*-
"""Per-mode test cases for ``PermissionEngine``.
Each :class:`PermissionMode` has its own test class so the policy of
that mode can be verified in isolation. Tests cover:
- the three rule layers (deny / ask / allow) for that mode
- the ``tool.check_permissions`` return paths (ALLOW / DENY / safety-ASK
/ PASSTHROUGH)
- mode-specific behavior (e.g. EXPLORE's read-only resolution,
ACCEPT_EDITS's working-directory auto-allow, BYPASS's safety-ASK
immunity, DONT_ASK's default DENY)
- Bash dynamic read-only / non-read-only / dangerous commands where
relevant
Specific safety-check triggers (injection, dangerous removal, sed
constraints, dangerous config paths) are tested separately in
``permission_engine_test.py::PermissionEngineSafetyCheckAllowRuleImmuneTest``.
"""
import os
import sys
import tempfile
import unittest
from unittest.async_case import IsolatedAsyncioTestCase
from agentscope.permission import (
PermissionEngine,
PermissionMode,
PermissionContext,
PermissionRule,
PermissionBehavior,
AdditionalWorkingDirectory,
)
from agentscope.tool import (
Bash,
Write,
Read,
Edit,
)
# ---------------------------------------------------------------------------
# DEFAULT mode
# ---------------------------------------------------------------------------
class PermissionEngineDefaultModeTest(IsolatedAsyncioTestCase):
"""Tests for :attr:`PermissionMode.DEFAULT`.
DEFAULT is the most restrictive non-DONT_ASK mode: every operation
requires explicit permission unless an allow rule matches or the
tool itself returns ALLOW (e.g. Bash auto-allows known read-only
commands).
"""
async def asyncSetUp(self) -> None:
self.context = PermissionContext(mode=PermissionMode.DEFAULT)
self.engine = PermissionEngine(self.context)
async def test_default_write_with_no_rules_returns_ask(self) -> None:
"""Write to a safe path with no matching rules falls to the
engine's default ASK."""
decision = await self.engine.check_permission(
Write(),
{"file_path": "/tmp/file.txt"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
async def test_default_deny_rule_returns_deny(self) -> None:
"""Deny rule has the highest priority."""
self.engine.add_rule(
PermissionRule(
tool_name="Write",
rule_content="*.env",
behavior=PermissionBehavior.DENY,
source="test",
),
)
decision = await self.engine.check_permission(
Write(),
{"file_path": "/tmp/secret.env"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
async def test_default_ask_rule_returns_ask_with_suggestions(
self,
) -> None:
"""Ask rule short-circuits before the tool's own check and
attaches ``suggested_rules``."""
self.engine.add_rule(
PermissionRule(
tool_name="Write",
rule_content="*.py",
behavior=PermissionBehavior.ASK,
source="test",
),
)
decision = await self.engine.check_permission(
Write(),
{"file_path": "/tmp/main.py"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
self.assertIsNotNone(decision.suggested_rules)
self.assertGreater(len(decision.suggested_rules), 0)
async def test_default_allow_rule_returns_allow(self) -> None:
"""Allow rule grants permission when no deny/ask rule and no
safety ASK applies."""
self.engine.add_rule(
PermissionRule(
tool_name="Write",
rule_content="/tmp/**",
behavior=PermissionBehavior.ALLOW,
source="test",
),
)
decision = await self.engine.check_permission(
Write(),
{"file_path": "/tmp/file.txt"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_default_bash_read_only_command_auto_allows(self) -> None:
"""Bash returns ALLOW for known read-only commands, which the
engine surfaces directly in DEFAULT mode."""
for command in ("ls -a", "pwd", "git status", "cat README.md"):
decision = await self.engine.check_permission(
Bash(),
{"command": command},
)
self.assertEqual(
decision.behavior,
PermissionBehavior.ALLOW,
f"Expected ALLOW for read-only bash command: {command}",
)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_default_bash_modification_command_returns_ask(
self,
) -> None:
"""Bash modification commands (no matching rule) fall through to
the engine's default ASK."""
decision = await self.engine.check_permission(
Bash(),
{"command": "npm install"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
async def test_default_dangerous_path_safety_ask(self) -> None:
"""Write to a dangerous path produces a safety ASK from the tool
itself (bypass-immune)."""
decision = await self.engine.check_permission(
Write(),
{"file_path": "/home/user/.bashrc"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
self.assertIn("safety", (decision.decision_reason or "").lower())
async def test_default_safety_ask_not_overridden_by_allow_rule(
self,
) -> None:
"""A user-configured allow rule must not override a tool's
safety ASK (bypass-immune)."""
self.engine.add_rule(
PermissionRule(
tool_name="Write",
rule_content="**",
behavior=PermissionBehavior.ALLOW,
source="test",
),
)
decision = await self.engine.check_permission(
Write(),
{"file_path": "/home/user/.bashrc"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
# ---------------------------------------------------------------------------
# EXPLORE mode
# ---------------------------------------------------------------------------
class PermissionEngineExploreModeTest(IsolatedAsyncioTestCase):
"""Tests for :attr:`PermissionMode.EXPLORE`.
EXPLORE is the read-only mode: an invocation is ALLOWed iff
:meth:`ToolBase.check_read_only` returns True; everything else is
DENIed outright. ``tool.check_permissions`` is intentionally not
consulted (the read-only verdict is final), and allow rules cannot
grant write access — EXPLORE's read-only invariant is non-negotiable.
"""
async def asyncSetUp(self) -> None:
self.context = PermissionContext(mode=PermissionMode.EXPLORE)
self.engine = PermissionEngine(self.context)
async def test_explore_read_tool_allows(self) -> None:
"""Statically read-only tools (Read/Glob/Grep) → ALLOW."""
decision = await self.engine.check_permission(
Read(),
{"file_path": "/tmp/file.txt"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
async def test_explore_write_tool_denies(self) -> None:
"""Statically non-read-only tools (Write/Edit) → DENY."""
decision = await self.engine.check_permission(
Write(),
{"file_path": "/tmp/file.txt"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
async def test_explore_deny_rule_returns_deny(self) -> None:
"""Deny rule still has top priority in EXPLORE."""
self.engine.add_rule(
PermissionRule(
tool_name="Read",
rule_content="/secret/**",
behavior=PermissionBehavior.DENY,
source="test",
),
)
decision = await self.engine.check_permission(
Read(),
{"file_path": "/secret/key.txt"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
async def test_explore_ask_rule_on_read_only_tool_returns_ask(
self,
) -> None:
"""Ask rules apply before the read-only fast-path, so a read-only
tool with a matching ask rule still surfaces as ASK.
Documents current behavior — this is the design point tracked
as issue #7.
"""
self.engine.add_rule(
PermissionRule(
tool_name="Read",
rule_content="**/*.env",
behavior=PermissionBehavior.ASK,
source="test",
),
)
decision = await self.engine.check_permission(
Read(),
{"file_path": "/tmp/secret.env"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
async def test_explore_allow_rule_does_not_override_deny(self) -> None:
"""EXPLORE's deny on non-read-only tools is the invariant; an
allow rule must not be able to grant write access."""
self.engine.add_rule(
PermissionRule(
tool_name="Write",
rule_content="**",
behavior=PermissionBehavior.ALLOW,
source="test",
),
)
decision = await self.engine.check_permission(
Write(),
{"file_path": "/tmp/file.txt"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
async def test_explore_dangerous_path_returns_deny_not_safety_ask(
self,
) -> None:
"""EXPLORE never invokes ``tool.check_permissions``, so a dangerous
path on a write tool surfaces as DENY (the read-only verdict)
rather than a safety ASK. DENY is strictly stronger than ASK, so
this is consistent with EXPLORE's "no writes" guarantee.
"""
decision = await self.engine.check_permission(
Write(),
{"file_path": "/home/user/.bashrc"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_explore_bash_read_only_command_allows(self) -> None:
"""EXPLORE allows read-only bash commands via
:meth:`Bash.check_read_only` (regression for issue #1)."""
for command in ("ls -a", "pwd", "git status", "cat README.md"):
decision = await self.engine.check_permission(
Bash(),
{"command": command},
)
self.assertEqual(
decision.behavior,
PermissionBehavior.ALLOW,
f"Expected ALLOW for read-only bash command: {command}",
)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_explore_bash_modification_command_denies(self) -> None:
"""EXPLORE denies bash commands that are not statically
recognized as read-only."""
for command in ("cp a b", "mv a b", "touch /tmp/x"):
decision = await self.engine.check_permission(
Bash(),
{"command": command},
)
self.assertEqual(
decision.behavior,
PermissionBehavior.DENY,
f"Expected DENY for non-read-only bash command: {command}",
)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_explore_bash_dangerous_command_denies(self) -> None:
"""EXPLORE denies dangerous commands directly — in DEFAULT mode
the same command would surface as a safety ASK, but EXPLORE is
stricter."""
decision = await self.engine.check_permission(
Bash(),
{"command": "rm -rf /"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
# ---------------------------------------------------------------------------
# ACCEPT_EDITS mode
# ---------------------------------------------------------------------------
class PermissionEngineAcceptEditsModeTest(IsolatedAsyncioTestCase):
"""Tests for :attr:`PermissionMode.ACCEPT_EDITS`.
ACCEPT_EDITS auto-allows file edits within configured working
directories; reads are auto-allowed unconditionally; other
operations follow the normal DEFAULT-like flow.
"""
async def asyncSetUp(self) -> None:
self.context = PermissionContext(
mode=PermissionMode.ACCEPT_EDITS,
working_directories={
"/tmp/project": AdditionalWorkingDirectory(
path="/tmp/project",
source="test",
),
},
)
self.engine = PermissionEngine(self.context)
async def test_accept_edits_within_working_directory(self) -> None:
"""Write / Read / Edit within a working directory → ALLOW."""
for tool in (Write(), Read(), Edit()):
decision = await self.engine.check_permission(
tool,
{"file_path": "/tmp/project/file.txt"},
)
self.assertEqual(
decision.behavior,
PermissionBehavior.ALLOW,
f"Expected ALLOW for {tool.name} in working directory",
)
@unittest.skipIf(
os.name == "nt",
"os.symlink typically requires admin privileges on Windows",
)
async def test_accept_edits_resolves_symlinked_working_directory(
self,
) -> None:
"""Working directory comparison must use ``realpath`` so a path
reached through a symlink (e.g. macOS's ``/tmp`` ->
``/private/tmp``) is recognized. Regression test for the
``abspath`` → ``realpath`` fix in
:meth:`_path_in_allowed_working_path`.
"""
parent = tempfile.mkdtemp()
try:
real_dir = os.path.join(parent, "real")
os.makedirs(real_dir)
link_dir = os.path.join(parent, "link")
os.symlink(real_dir, link_dir)
# Case 1: working_dir given as real path, file via link
context = PermissionContext(
mode=PermissionMode.ACCEPT_EDITS,
working_directories={
real_dir: AdditionalWorkingDirectory(
path=real_dir,
source="test",
),
},
)
engine = PermissionEngine(context)
decision = await engine.check_permission(
Write(),
{"file_path": os.path.join(link_dir, "file.txt")},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
# Case 2: working_dir given as link, file via real path
context = PermissionContext(
mode=PermissionMode.ACCEPT_EDITS,
working_directories={
link_dir: AdditionalWorkingDirectory(
path=link_dir,
source="test",
),
},
)
engine = PermissionEngine(context)
decision = await engine.check_permission(
Edit(),
{"file_path": os.path.join(real_dir, "file.txt")},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
finally:
import shutil
shutil.rmtree(parent, ignore_errors=True)
async def test_accept_edits_outside_working_directory(self) -> None:
"""Edit outside a working directory falls to the default ASK."""
decision = await self.engine.check_permission(
Edit(),
{"file_path": "/home/user/file.txt"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
async def test_accept_edits_read_operation_auto_allowed(self) -> None:
"""Read tool is auto-allowed regardless of path (read-only fast
path)."""
decision = await self.engine.check_permission(
Read(),
{"file_path": "/anywhere/file.txt"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
async def test_accept_edits_deny_rule_returns_deny(self) -> None:
"""Deny rule overrides ACCEPT_EDITS's working-directory
auto-allow."""
self.engine.add_rule(
PermissionRule(
tool_name="Write",
rule_content="**/*.lock",
behavior=PermissionBehavior.DENY,
source="test",
),
)
decision = await self.engine.check_permission(
Write(),
{"file_path": "/tmp/project/poetry.lock"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
async def test_accept_edits_ask_rule_returns_ask(self) -> None:
"""Ask rule short-circuits before the working-directory check."""
self.engine.add_rule(
PermissionRule(
tool_name="Edit",
rule_content="**/*.py",
behavior=PermissionBehavior.ASK,
source="test",
),
)
decision = await self.engine.check_permission(
Edit(),
{"file_path": "/tmp/project/main.py"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
async def test_accept_edits_dangerous_path_safety_ask(self) -> None:
"""Safety ASK from a dangerous path is bypass-immune even when
the path is inside a working directory.
"""
decision = await self.engine.check_permission(
Write(),
{"file_path": "/home/user/.bashrc"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
self.assertIn("safety", (decision.decision_reason or "").lower())
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_accept_edits_bash_read_only_command_allows(self) -> None:
"""Read-only bash commands ALLOW via the read-only fast path."""
decision = await self.engine.check_permission(
Bash(),
{"command": "ls -a"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_accept_edits_bash_filesystem_command_inside_working_dir(
self,
) -> None:
"""ACCEPT_EDITS auto-allows recognized filesystem commands
(``mkdir``, ``touch``, ``rm``, ``cp``, ``mv``, ``sed``,
``rmdir``) when **all** target paths are inside the configured
working directories."""
cases = [
"touch /tmp/project/new.txt",
"mkdir /tmp/project/newdir",
"rm /tmp/project/old.txt",
"rmdir /tmp/project/olddir",
"cp /tmp/project/a /tmp/project/b",
"mv /tmp/project/a /tmp/project/b",
"sed -i 's/x/y/g' /tmp/project/foo.txt",
]
for command in cases:
decision = await self.engine.check_permission(
Bash(),
{"command": command},
)
self.assertEqual(
decision.behavior,
PermissionBehavior.ALLOW,
f"Expected ALLOW for in-working-dir command: {command}",
)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_accept_edits_bash_filesystem_command_outside_working_dir(
self,
) -> None:
"""Regression for issue #4: filesystem commands whose targets
escape the working directory must NOT be auto-allowed.
Without this guard ``cp /etc/hosts /Users/me/other-project/x``
would silently succeed in ACCEPT_EDITS even though the
equivalent ``Write`` call is correctly denied. The asserted
behavior is ASK (the PASSTHROUGH from Bash + the engine's
default ASK in ACCEPT_EDITS).
"""
cases = [
# Target entirely outside the working directory
"rm /Users/someone/other/foo",
"touch /Users/someone/other/foo",
"mkdir /Users/someone/other/newdir",
# cp / mv: at least one of (src, dst) outside
"cp /tmp/project/a /Users/someone/other/b",
"cp /Users/someone/other/a /tmp/project/b",
"mv /tmp/project/a /Users/someone/other/b",
# sed in-place modifying a file outside
"sed -i 's/x/y/g' /Users/someone/other/foo.txt",
]
for command in cases:
decision = await self.engine.check_permission(
Bash(),
{"command": command},
)
self.assertEqual(
decision.behavior,
PermissionBehavior.ASK,
f"Expected ASK for outside-working-dir command: {command}",
)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_accept_edits_bash_filesystem_command_no_args_not_allowed(
self,
) -> None:
"""Conservative behavior: if the parser extracts no target paths
(e.g. a bare ``mkdir`` with no arguments), we do not auto-allow.
The command itself will fail at execution, but it should not
silently pass the permission check."""
decision = await self.engine.check_permission(
Bash(),
{"command": "mkdir"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
# ---------------------------------------------------------------------------
# BYPASS mode
# ---------------------------------------------------------------------------
class PermissionEngineBypassModeTest(IsolatedAsyncioTestCase):
"""Tests for :attr:`PermissionMode.BYPASS`.
BYPASS is the "fully trusted" mode: the user has opted out of all
safety prompts. The only guardrails left are user-configured deny
/ ask rules and tool-emitted DENY. Every bypass-immune safety ASK
(dangerous removal, dangerous paths, sed in-place on sensitive
files, command injection, ...) is intentionally **skipped**.
Use BYPASS only in sandboxed environments or when you fully trust
the agent.
"""
async def asyncSetUp(self) -> None:
self.context = PermissionContext(mode=PermissionMode.BYPASS)
self.engine = PermissionEngine(self.context)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_bypass_no_rules_allows(self) -> None:
"""No rules → ALLOW (BYPASS's default fallback)."""
decision = await self.engine.check_permission(
Bash(),
{"command": "npm install"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_bypass_deny_rule_returns_deny(self) -> None:
"""Deny rules are bypass-immune."""
self.engine.add_rule(
PermissionRule(
tool_name="Bash",
rule_content="rm:*",
behavior=PermissionBehavior.DENY,
source="test",
),
)
decision = await self.engine.check_permission(
Bash(),
{"command": "rm -rf /tmp/foo"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_bypass_ask_rule_returns_ask(self) -> None:
"""A user-configured ask rule represents explicit intent to be
prompted; BYPASS must not override it."""
self.engine.add_rule(
PermissionRule(
tool_name="Bash",
rule_content="git push:*",
behavior=PermissionBehavior.ASK,
source="test",
),
)
decision = await self.engine.check_permission(
Bash(),
{"command": "git push origin main"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ASK)
async def test_bypass_skips_dangerous_path_safety(self) -> None:
"""BYPASS skips the Write tool's dangerous-path safety check —
writing to ``~/.bashrc`` is allowed through."""
decision = await self.engine.check_permission(
Write(),
{"file_path": "/home/user/.bashrc"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_bypass_skips_dangerous_removal(self) -> None:
"""BYPASS skips Bash's dangerous-removal safety check —
``rm -rf /`` is allowed through."""
decision = await self.engine.check_permission(
Bash(),
{"command": "rm -rf /"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_bypass_skips_command_injection(self) -> None:
"""BYPASS skips Bash's command-injection safety check —
dynamic expansion is allowed through."""
decision = await self.engine.check_permission(
Bash(),
{"command": "ls $(date +%s)"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_bypass_skips_sed_dangerous_file(self) -> None:
"""BYPASS skips Bash's sed-on-dangerous-file safety check."""
decision = await self.engine.check_permission(
Bash(),
{"command": "sed 's/old/new/e' file.txt"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_bypass_skips_dangerous_config_path_in_bash(self) -> None:
"""BYPASS skips Bash's dangerous-config-path safety check —
operating on ``~/.bashrc`` via bash is allowed through."""
decision = await self.engine.check_permission(
Bash(),
{"command": "rm ~/.bashrc"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_bypass_tool_allow_returns_allow(self) -> None:
"""Tool's own ALLOW (e.g. Bash read-only command) is returned
as-is in BYPASS as in any mode."""
decision = await self.engine.check_permission(
Bash(),
{"command": "ls -a"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
# ---------------------------------------------------------------------------
# DONT_ASK mode
# ---------------------------------------------------------------------------
class PermissionEngineDontAskModeTest(IsolatedAsyncioTestCase):
"""Tests for :attr:`PermissionMode.DONT_ASK`.
DONT_ASK is used when no user is available to answer prompts
(scheduled tasks, background runs). The invariant is "never return
ASK" — every ASK-producing code path (default, ASK rule, safety
ASK) is converted to DENY.
"""
async def asyncSetUp(self) -> None:
self.context = PermissionContext(mode=PermissionMode.DONT_ASK)
self.engine = PermissionEngine(self.context)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_dont_ask_no_rules_returns_deny(self) -> None:
"""No rules → DENY (DONT_ASK's default)."""
decision = await self.engine.check_permission(
Bash(),
{"command": "npm install"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_dont_ask_deny_rule_returns_deny(self) -> None:
"""Deny rules apply normally."""
self.engine.add_rule(
PermissionRule(
tool_name="Bash",
rule_content="rm:*",
behavior=PermissionBehavior.DENY,
source="test",
),
)
decision = await self.engine.check_permission(
Bash(),
{"command": "rm -rf /tmp/foo"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
async def test_dont_ask_allow_rule_returns_allow(self) -> None:
"""An explicit allow rule still grants permission in DONT_ASK."""
self.engine.add_rule(
PermissionRule(
tool_name="Write",
rule_content="/tmp/**",
behavior=PermissionBehavior.ALLOW,
source="test",
),
)
decision = await self.engine.check_permission(
Write(),
{"file_path": "/tmp/file.txt"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_dont_ask_bash_read_only_command_allows(self) -> None:
"""Tool's own ALLOW (Bash read-only command) is still ALLOW
under DONT_ASK — no user prompt is needed."""
decision = await self.engine.check_permission(
Bash(),
{"command": "ls -a"},
)
self.assertEqual(decision.behavior, PermissionBehavior.ALLOW)
async def test_dont_ask_ask_rule_returns_deny(self) -> None:
"""An ASK rule hit is converted to DENY (issue #3): the user is
not available to answer the prompt, so the operation cannot
proceed."""
self.engine.add_rule(
PermissionRule(
tool_name="Write",
rule_content="**/*.py",
behavior=PermissionBehavior.ASK,
source="test",
),
)
decision = await self.engine.check_permission(
Write(),
{"file_path": "/tmp/main.py"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
async def test_dont_ask_ask_rule_conversion_preserves_suggestions(
self,
) -> None:
"""Converted DENY decisions keep the original ASK's
``suggested_rules`` so callers can surface them to the user
out-of-band (e.g. in a scheduled-task failure report)."""
self.engine.add_rule(
PermissionRule(
tool_name="Write",
rule_content="**/*.py",
behavior=PermissionBehavior.ASK,
source="test",
),
)
decision = await self.engine.check_permission(
Write(),
{"file_path": "/tmp/main.py"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
self.assertIsNotNone(decision.suggested_rules)
self.assertGreater(len(decision.suggested_rules), 0)
async def test_dont_ask_safety_ask_returns_deny(self) -> None:
"""A safety ASK from ``tool.check_permissions`` (e.g. Write to a
dangerous path) is converted to DENY (issue #3) — DONT_ASK
respects the safety verdict but cannot ask the user, so the
only safe action is to refuse."""
decision = await self.engine.check_permission(
Write(),
{"file_path": "/home/user/.bashrc"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)
# Conversion records both the original safety reason and the
# DONT_ASK conversion in the decision_reason.
reason = (decision.decision_reason or "").lower()
self.assertIn("dont_ask", reason)
self.assertIn("safety", reason)
@unittest.skipIf(
sys.platform == "win32",
"Bash tool is not supported on Windows",
)
async def test_dont_ask_bash_dangerous_command_returns_deny(
self,
) -> None:
"""``rm -rf /`` triggers a safety ASK from Bash, which DONT_ASK
converts to DENY (issue #3)."""
decision = await self.engine.check_permission(
Bash(),
{"command": "rm -rf /"},
)
self.assertEqual(decision.behavior, PermissionBehavior.DENY)