Skip to content

Commit ac023ff

Browse files
committed
test: harden permission decision coverage
1 parent 1a0bbde commit ac023ff

2 files changed

Lines changed: 175 additions & 4 deletions

File tree

minicode/permissions.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,16 @@ def _is_within_directory(root: str, target: str) -> bool:
6666
if _is_win:
6767
# Windows: case-insensitive path comparison
6868
target_str = target.lower()
69-
root_str = root.lower()
70-
return target_str == root_str or target_str.startswith(root_str + os.sep)
69+
root_str = root.lower().rstrip("\\/")
70+
return (
71+
target_str == root_str
72+
or target_str.startswith(root_str + "\\")
73+
or target_str.startswith(root_str + "/")
74+
)
7175

7276
# Unix: direct string comparison (paths already normalized)
73-
return target == root or target.startswith(root + os.sep)
77+
root_str = root.rstrip(os.sep)
78+
return target == root_str or target.startswith(root_str + os.sep)
7479

7580

7681
def _matches_directory_prefix(target_path: str, directories: set[str]) -> bool:

tests/test_permissions.py

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22

33
import pytest
44

5-
from minicode.permissions import PermissionManager
5+
import minicode.permissions as permissions_module
6+
from minicode.permissions import PermissionManager, _classify_dangerous_command, _is_within_directory
7+
8+
9+
@pytest.fixture(autouse=True)
10+
def isolated_permission_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
11+
store_path = tmp_path / "home" / "permissions.json"
12+
monkeypatch.setattr(permissions_module, "MINI_CODE_PERMISSIONS_PATH", store_path)
13+
permissions_module._normalize_path_cached.cache_clear()
14+
yield store_path
15+
permissions_module._normalize_path_cached.cache_clear()
616

717

818
def test_permission_manager_uses_prompt_for_external_path(tmp_path: Path) -> None:
@@ -16,3 +26,159 @@ def test_permission_manager_denies_external_path_without_prompt(tmp_path: Path)
1626
manager = PermissionManager(str(tmp_path))
1727
with pytest.raises(RuntimeError):
1828
manager.ensure_path_access(str(external), "read")
29+
30+
31+
def test_allow_always_persists_external_directory(tmp_path: Path) -> None:
32+
workspace = tmp_path / "workspace"
33+
external_dir = tmp_path / "shared"
34+
workspace.mkdir()
35+
external_dir.mkdir()
36+
first = external_dir / "first.txt"
37+
second = external_dir / "second.txt"
38+
39+
manager = PermissionManager(
40+
str(workspace),
41+
prompt=lambda request: {"decision": "allow_always"},
42+
)
43+
manager.ensure_path_access(str(first), "read")
44+
45+
reloaded = PermissionManager(str(workspace), prompt=None)
46+
reloaded.ensure_path_access(str(second), "read")
47+
48+
49+
def test_deny_always_persists_external_directory_and_wins(tmp_path: Path) -> None:
50+
workspace = tmp_path / "workspace"
51+
external_dir = tmp_path / "shared"
52+
workspace.mkdir()
53+
external_dir.mkdir()
54+
target = external_dir / "blocked.txt"
55+
56+
manager = PermissionManager(
57+
str(workspace),
58+
prompt=lambda request: {"decision": "deny_always"},
59+
)
60+
with pytest.raises(RuntimeError, match="Access denied"):
61+
manager.ensure_path_access(str(target), "read")
62+
63+
reloaded = PermissionManager(
64+
str(workspace),
65+
prompt=lambda request: pytest.fail("persisted deny should not reprompt"),
66+
)
67+
with pytest.raises(RuntimeError, match="Access denied"):
68+
reloaded.ensure_path_access(str(target), "read")
69+
70+
71+
def test_dangerous_command_allow_always_persists(tmp_path: Path) -> None:
72+
workspace = tmp_path / "workspace"
73+
workspace.mkdir()
74+
75+
manager = PermissionManager(
76+
str(workspace),
77+
prompt=lambda request: {"decision": "allow_always"},
78+
)
79+
manager.ensure_command("git", ["reset", "--hard"], str(workspace))
80+
81+
reloaded = PermissionManager(
82+
str(workspace),
83+
prompt=lambda request: pytest.fail("persisted command allow should not reprompt"),
84+
)
85+
reloaded.ensure_command("git", ["reset", "--hard"], str(workspace))
86+
87+
88+
def test_dangerous_command_deny_always_persists(tmp_path: Path) -> None:
89+
workspace = tmp_path / "workspace"
90+
workspace.mkdir()
91+
92+
manager = PermissionManager(
93+
str(workspace),
94+
prompt=lambda request: {"decision": "deny_always"},
95+
)
96+
with pytest.raises(RuntimeError, match="Command denied"):
97+
manager.ensure_command("git", ["clean", "-fd"], str(workspace))
98+
99+
reloaded = PermissionManager(
100+
str(workspace),
101+
prompt=lambda request: pytest.fail("persisted command deny should not reprompt"),
102+
)
103+
with pytest.raises(RuntimeError, match="Command denied"):
104+
reloaded.ensure_command("git", ["clean", "-fd"], str(workspace))
105+
106+
107+
def test_edit_deny_with_feedback_surfaces_guidance(tmp_path: Path) -> None:
108+
target = tmp_path / "demo.py"
109+
target.write_text("old\n", encoding="utf-8")
110+
manager = PermissionManager(
111+
str(tmp_path),
112+
prompt=lambda request: {
113+
"decision": "deny_with_feedback",
114+
"feedback": "Please keep this file stable.",
115+
},
116+
)
117+
118+
with pytest.raises(RuntimeError, match="Please keep this file stable"):
119+
manager.ensure_edit(str(target), "- old\n+ new\n")
120+
121+
122+
def test_turn_scoped_edit_permissions_reset_between_turns(tmp_path: Path) -> None:
123+
target = tmp_path / "demo.py"
124+
target.write_text("old\n", encoding="utf-8")
125+
prompts: list[dict] = []
126+
127+
def prompt(request: dict):
128+
prompts.append(request)
129+
return {"decision": "allow_turn"}
130+
131+
manager = PermissionManager(str(tmp_path), prompt=prompt)
132+
manager.begin_turn()
133+
manager.ensure_edit(str(target), "- old\n+ new\n")
134+
manager.ensure_edit(str(target), "- old\n+ newer\n")
135+
assert len(prompts) == 1
136+
137+
manager.end_turn()
138+
manager.ensure_edit(str(target), "- old\n+ newest\n")
139+
assert len(prompts) == 2
140+
141+
142+
def test_allow_all_turn_applies_to_multiple_files_for_one_turn(tmp_path: Path) -> None:
143+
first = tmp_path / "first.py"
144+
second = tmp_path / "second.py"
145+
first.write_text("old\n", encoding="utf-8")
146+
second.write_text("old\n", encoding="utf-8")
147+
prompts: list[dict] = []
148+
149+
def prompt(request: dict):
150+
prompts.append(request)
151+
return {"decision": "allow_all_turn"}
152+
153+
manager = PermissionManager(str(tmp_path), prompt=prompt)
154+
manager.begin_turn()
155+
manager.ensure_edit(str(first), "- old\n+ new\n")
156+
manager.ensure_edit(str(second), "- old\n+ new\n")
157+
assert len(prompts) == 1
158+
159+
manager.end_turn()
160+
manager.ensure_edit(str(first), "- old\n+ newest\n")
161+
assert len(prompts) == 2
162+
163+
164+
@pytest.mark.parametrize(
165+
("command", "args", "expected"),
166+
[
167+
("git", ["reset", "--hard"], "discard local changes"),
168+
("git", ["push", "--force"], "rewrites remote history"),
169+
("rm", ["-rf", "build"], "catastrophic data loss"),
170+
("python", ["script.py"], "arbitrary local code"),
171+
("chmod", ["777", "file"], "opens permissions"),
172+
],
173+
)
174+
def test_destructive_command_classification(command: str, args: list[str], expected: str) -> None:
175+
reason = _classify_dangerous_command(command, args)
176+
177+
assert reason is not None
178+
assert expected in reason
179+
180+
181+
def test_windows_style_directory_match_is_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None:
182+
monkeypatch.setattr(permissions_module, "_is_win", True)
183+
184+
assert _is_within_directory("/Users/Alice/Repo", "/users/alice/repo/src/main.py")

0 commit comments

Comments
 (0)