claude-dev-env 2.2.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -2
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
- package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +1 -0
- package/agents/code-verifier.md +4 -4
- package/agents/skill-writer-agent.md +84 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/docs/CLAUDE.md +1 -0
- package/docs/references/CLAUDE.md +1 -0
- package/docs/references/code-review-enforcement.md +97 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +8 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +194 -0
- package/hooks/blocking/code_review_push_gate.py +140 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
- package/hooks/blocking/config/verified_commit_constants.py +24 -9
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
- package/hooks/blocking/test_code_review_push_gate.py +189 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
- package/hooks/blocking/test_verification_verdict_store.py +920 -810
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verification_verdict_store.py +118 -5
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +103 -0
- package/hooks/hooks.json +16 -1
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +211 -23
- package/hooks/validators/run_all_validators.py +21 -14
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_ruff_integration.py +68 -1
- package/hooks/validators/test_run_all_validators.py +25 -0
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +20 -43
- package/rules/durable-post-artifacts.md +7 -0
- package/scripts/CLAUDE.md +2 -1
- package/scripts/claude-chain.example.json +15 -3
- package/scripts/claude_chain_runner.py +130 -27
- package/scripts/claude_chain_usage.py +346 -0
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +8 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
- package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/dev_env_scripts_constants/timing.py +1 -1
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +8 -1
- package/scripts/test_claude_chain_runner.py +412 -6
- package/scripts/test_claude_chain_usage.py +534 -0
- package/scripts/test_invoke_code_review.py +298 -4
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
- package/skills/autoconverge/workflow/converge.mjs +24 -8
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/fresh-branch/CLAUDE.md +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- package/skills/orchestrator/SKILL.md +1 -20
- package/skills/orchestrator-refresh/SKILL.md +1 -1
- package/skills/pr-converge/SKILL.md +3 -3
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +3 -3
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- package/skills/team-advisor/SKILL.md +5 -4
package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Constants and rule-format readers for stale_worktree_rule_sweep.
|
|
2
|
+
|
|
3
|
+
The worktrees root is derived from the same ~/.claude home the permission
|
|
4
|
+
grant script mints its rules under, so the location is named in exactly one
|
|
5
|
+
place. A worktree rule points at a directory one or more segments below that
|
|
6
|
+
root (flat or nested layouts)::
|
|
7
|
+
|
|
8
|
+
Edit(<claude home>/worktrees/<worktree name>/.claude/**)
|
|
9
|
+
^^^^^^^^^^^^^^^
|
|
10
|
+
segment 1 (flat)
|
|
11
|
+
|
|
12
|
+
Edit(<claude home>/worktrees/<repository>/<worktree name>/.claude/**)
|
|
13
|
+
^^^^^^^^^^^^ ^^^^^^^^^^^^^^^
|
|
14
|
+
nested layout
|
|
15
|
+
|
|
16
|
+
worktree_directory_for_rule(rule, root) -> project directory under root
|
|
17
|
+
|
|
18
|
+
A rule whose target path lies outside the worktrees root reads back as None,
|
|
19
|
+
so the sweep leaves it untouched.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from pr_loop_shared_constants.claude_permissions_constants import (
|
|
25
|
+
get_claude_user_settings_path,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
WORKTREES_SUBDIRECTORY_NAME: str = "worktrees"
|
|
29
|
+
|
|
30
|
+
MINIMUM_WORKTREE_PATH_SEGMENT_COUNT: int = 1
|
|
31
|
+
|
|
32
|
+
RULE_PATH_OPEN_DELIMITER: str = "("
|
|
33
|
+
|
|
34
|
+
RULE_PATH_CLOSE_DELIMITER: str = ")"
|
|
35
|
+
|
|
36
|
+
CLAUDE_HOME_PATH_MARKER: str = "/.claude"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_claude_worktrees_root() -> Path:
|
|
40
|
+
"""Return the ~/.claude/worktrees directory the grant rules point under.
|
|
41
|
+
|
|
42
|
+
Reuses get_claude_user_settings_path so the ~/.claude home is resolved
|
|
43
|
+
in one place: the settings file's parent directory is the home, and the
|
|
44
|
+
worktrees live directly beneath it.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The absolute path to the user's worktrees root directory.
|
|
48
|
+
"""
|
|
49
|
+
claude_home_directory = get_claude_user_settings_path().parent
|
|
50
|
+
return claude_home_directory / WORKTREES_SUBDIRECTORY_NAME
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def extract_rule_target_path(rule: str) -> str | None:
|
|
54
|
+
"""Return the path a permission rule targets, or None when unparsable.
|
|
55
|
+
|
|
56
|
+
A rule reads `Tool(<path>)`; the target is the text between the first
|
|
57
|
+
open delimiter and the last close delimiter::
|
|
58
|
+
|
|
59
|
+
Edit(/repo/wt/.claude/**) -> /repo/wt/.claude/**
|
|
60
|
+
not a rule -> None
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
rule: The permission rule string to read.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The target path string, or None when the delimiters are absent.
|
|
67
|
+
"""
|
|
68
|
+
open_index = rule.find(RULE_PATH_OPEN_DELIMITER)
|
|
69
|
+
close_index = rule.rfind(RULE_PATH_CLOSE_DELIMITER)
|
|
70
|
+
if open_index == -1 or close_index <= open_index:
|
|
71
|
+
return None
|
|
72
|
+
return rule[open_index + 1 : close_index]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def worktree_directory_for_rule(rule: str, worktrees_root: Path) -> Path | None:
|
|
76
|
+
"""Return the worktree directory a rule targets, or None when it is not one.
|
|
77
|
+
|
|
78
|
+
Keeps every path segment below the worktrees root so flat
|
|
79
|
+
(`worktrees/<name>`) and nested (`worktrees/<repo>/<name>`) layouts
|
|
80
|
+
both resolve to the on-disk project directory::
|
|
81
|
+
|
|
82
|
+
Edit(<root>/flat-wt/.claude/**) -> <root>/flat-wt
|
|
83
|
+
Edit(<root>/repo/wt/.claude/**) -> <root>/repo/wt
|
|
84
|
+
Edit(/elsewhere/project/.claude/**) -> None
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
rule: The permission rule string to read.
|
|
88
|
+
worktrees_root: The ~/.claude/worktrees directory rules live under.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The worktree directory, or None when the target lies outside the
|
|
92
|
+
worktrees root or has no path segments under it.
|
|
93
|
+
"""
|
|
94
|
+
target_path = extract_rule_target_path(rule)
|
|
95
|
+
if target_path is None:
|
|
96
|
+
return None
|
|
97
|
+
project_portion = target_path.rsplit(CLAUDE_HOME_PATH_MARKER, 1)[0]
|
|
98
|
+
if not project_portion:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
relative_path = Path(project_portion).relative_to(worktrees_root)
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
|
104
|
+
all_segments = relative_path.parts
|
|
105
|
+
if len(all_segments) < MINIMUM_WORKTREE_PATH_SEGMENT_COUNT:
|
|
106
|
+
return None
|
|
107
|
+
return worktrees_root.joinpath(*all_segments)
|
|
@@ -41,6 +41,9 @@ from pr_loop_shared_constants.claude_settings_keys_constants import (
|
|
|
41
41
|
CLAUDE_SETTINGS_ENVIRONMENT_KEY,
|
|
42
42
|
CLAUDE_SETTINGS_PERMISSIONS_KEY,
|
|
43
43
|
)
|
|
44
|
+
from stale_worktree_rule_sweep import ( # noqa: E402
|
|
45
|
+
sweep_stale_worktree_rules_from_settings,
|
|
46
|
+
)
|
|
44
47
|
|
|
45
48
|
|
|
46
49
|
def remove_values_from_list(
|
|
@@ -219,6 +222,7 @@ def revoke_permissions_for_current_directory() -> None:
|
|
|
219
222
|
ALL_AGENT_CONFIG_PATH_PATTERNS,
|
|
220
223
|
)
|
|
221
224
|
settings = load_settings(claude_user_settings_path)
|
|
225
|
+
worktree_sweep_removed_count = sweep_stale_worktree_rules_from_settings(settings)
|
|
222
226
|
allow_rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
|
|
223
227
|
deny_rules_removed_count = remove_rules_from_deny_list(
|
|
224
228
|
settings, all_agent_config_deny_rules
|
|
@@ -234,6 +238,7 @@ def revoke_permissions_for_current_directory() -> None:
|
|
|
234
238
|
+ deny_rules_removed_count
|
|
235
239
|
+ directories_removed_count
|
|
236
240
|
+ environment_entries_removed_count
|
|
241
|
+
+ worktree_sweep_removed_count
|
|
237
242
|
)
|
|
238
243
|
if total_changes_count == 0:
|
|
239
244
|
print(f"Project path: {project_path}")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Sweep permission rules that point at deleted worktrees, then deduplicate.
|
|
2
|
+
|
|
3
|
+
The grant flow mints per-worktree allow/deny rules into ~/.claude/settings.json
|
|
4
|
+
and the revoke flow removes them, but a revoke that never runs leaves rules
|
|
5
|
+
for deleted worktrees behind, and they pile up run after run. Before the grant
|
|
6
|
+
and revoke flows write, they call this sweep so the settings hold no rule for a
|
|
7
|
+
worktree directory that is gone::
|
|
8
|
+
|
|
9
|
+
allow: Edit(<root>/repo/live/.claude/**) live on disk -> kept
|
|
10
|
+
Edit(<root>/repo/gone/.claude/**) deleted -> dropped
|
|
11
|
+
Edit(<root>/repo/live/.claude/**) duplicate -> dropped
|
|
12
|
+
|
|
13
|
+
A rule whose target lies outside the worktrees root is left untouched.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from _claude_permissions_common import remove_matching_entries_from_list
|
|
19
|
+
from pr_loop_shared_constants.claude_settings_keys_constants import (
|
|
20
|
+
CLAUDE_SETTINGS_ALLOW_KEY,
|
|
21
|
+
CLAUDE_SETTINGS_DENY_KEY,
|
|
22
|
+
CLAUDE_SETTINGS_PERMISSIONS_KEY,
|
|
23
|
+
)
|
|
24
|
+
from pr_loop_shared_constants.stale_worktree_rule_sweep_constants import (
|
|
25
|
+
get_claude_worktrees_root,
|
|
26
|
+
worktree_directory_for_rule,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_stale_worktree_rule(candidate_rule: object, worktrees_root: Path) -> bool:
|
|
31
|
+
if not isinstance(candidate_rule, str):
|
|
32
|
+
return False
|
|
33
|
+
worktree_directory = worktree_directory_for_rule(candidate_rule, worktrees_root)
|
|
34
|
+
if worktree_directory is None:
|
|
35
|
+
return False
|
|
36
|
+
return not worktree_directory.exists()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _deduplicate_list_preserving_order(all_entries: list[object]) -> int:
|
|
40
|
+
all_unique_entries: list[object] = []
|
|
41
|
+
for each_entry in all_entries:
|
|
42
|
+
if each_entry not in all_unique_entries:
|
|
43
|
+
all_unique_entries.append(each_entry)
|
|
44
|
+
removed_count = len(all_entries) - len(all_unique_entries)
|
|
45
|
+
all_entries[:] = all_unique_entries
|
|
46
|
+
return removed_count
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sweep_and_deduplicate_rule_list(
|
|
50
|
+
all_rules: list[object], worktrees_root: Path
|
|
51
|
+
) -> int:
|
|
52
|
+
stale_removed_count = remove_matching_entries_from_list(
|
|
53
|
+
all_rules,
|
|
54
|
+
lambda candidate_rule: _is_stale_worktree_rule(candidate_rule, worktrees_root),
|
|
55
|
+
)
|
|
56
|
+
duplicate_removed_count = _deduplicate_list_preserving_order(all_rules)
|
|
57
|
+
return stale_removed_count + duplicate_removed_count
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _permission_rule_list(
|
|
61
|
+
all_settings: dict[str, object], list_key: str
|
|
62
|
+
) -> list[object] | None:
|
|
63
|
+
permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
|
|
64
|
+
if not isinstance(permissions_section, dict):
|
|
65
|
+
return None
|
|
66
|
+
rule_list = permissions_section.get(list_key)
|
|
67
|
+
if not isinstance(rule_list, list):
|
|
68
|
+
return None
|
|
69
|
+
return rule_list
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sweep_and_deduplicate_permission_lists(
|
|
73
|
+
all_settings: dict[str, object], worktrees_root: Path
|
|
74
|
+
) -> int:
|
|
75
|
+
"""Drop stale worktree rules and duplicates from the allow and deny lists.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
all_settings: The parsed ~/.claude/settings.json dictionary, mutated
|
|
79
|
+
in place.
|
|
80
|
+
worktrees_root: The ~/.claude/worktrees directory rules live under.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The total count of rules removed across both lists.
|
|
84
|
+
"""
|
|
85
|
+
total_removed_count = 0
|
|
86
|
+
for each_list_key in (CLAUDE_SETTINGS_ALLOW_KEY, CLAUDE_SETTINGS_DENY_KEY):
|
|
87
|
+
rule_list = _permission_rule_list(all_settings, each_list_key)
|
|
88
|
+
if rule_list is None:
|
|
89
|
+
continue
|
|
90
|
+
total_removed_count += _sweep_and_deduplicate_rule_list(
|
|
91
|
+
rule_list, worktrees_root
|
|
92
|
+
)
|
|
93
|
+
return total_removed_count
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def sweep_stale_worktree_rules_from_settings(all_settings: dict[str, object]) -> int:
|
|
97
|
+
"""Sweep the settings against the real ~/.claude/worktrees root on disk.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
all_settings: The parsed ~/.claude/settings.json dictionary, mutated
|
|
101
|
+
in place.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
The total count of rules removed across the allow and deny lists.
|
|
105
|
+
"""
|
|
106
|
+
worktrees_root = get_claude_worktrees_root()
|
|
107
|
+
return sweep_and_deduplicate_permission_lists(all_settings, worktrees_root)
|
|
@@ -27,6 +27,8 @@ pytest suite for the scripts and constants in `_shared/pr-loop/scripts/`. Each t
|
|
|
27
27
|
| `test_reviewer_availability.py` | `reviewer_availability.py` end-to-end: Copilot and Bugbot availability, opt-out via `CLAUDE_REVIEWS_DISABLED`, and every Copilot quota outcome |
|
|
28
28
|
| `test_reviewer_availability_constants.py` | `pr_loop_shared_constants/reviewer_availability_constants.py` |
|
|
29
29
|
| `test_revoke_project_claude_permissions.py` | `revoke_project_claude_permissions.py` end-to-end |
|
|
30
|
+
| `test_stale_worktree_rule_sweep.py` | `stale_worktree_rule_sweep.py` sweep and deduplication end-to-end |
|
|
31
|
+
| `test_stale_worktree_rule_sweep_constants.py` | `pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py` worktrees-root resolution and rule-format readers |
|
|
30
32
|
| `test_agent_config_carveout.py` | Agent-config deny-rule carve-out logic |
|
|
31
33
|
| `conftest.py` | Shared pytest fixtures |
|
|
32
34
|
|
|
@@ -15,7 +15,6 @@ import json
|
|
|
15
15
|
import sys
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from types import ModuleType
|
|
18
|
-
from typing import Any
|
|
19
18
|
|
|
20
19
|
import pytest
|
|
21
20
|
|
|
@@ -79,7 +78,7 @@ def _seed_grant_then_run(
|
|
|
79
78
|
fake_settings_path: Path,
|
|
80
79
|
fake_project_root: Path,
|
|
81
80
|
monkeypatch: pytest.MonkeyPatch,
|
|
82
|
-
pre_existing_settings: dict[str,
|
|
81
|
+
pre_existing_settings: dict[str, object],
|
|
83
82
|
) -> None:
|
|
84
83
|
fake_settings_path.write_text(json.dumps(pre_existing_settings), encoding="utf-8")
|
|
85
84
|
grant_module = _load_grant_module()
|
|
@@ -96,7 +95,7 @@ def _seed_revoke_then_run(
|
|
|
96
95
|
fake_settings_path: Path,
|
|
97
96
|
fake_project_root: Path,
|
|
98
97
|
monkeypatch: pytest.MonkeyPatch,
|
|
99
|
-
pre_existing_settings: dict[str,
|
|
98
|
+
pre_existing_settings: dict[str, object],
|
|
100
99
|
) -> None:
|
|
101
100
|
fake_settings_path.write_text(json.dumps(pre_existing_settings), encoding="utf-8")
|
|
102
101
|
revoke_module = _load_revoke_module()
|
|
@@ -119,6 +118,24 @@ def _project_path_as_posix(fake_project_root: Path) -> str:
|
|
|
119
118
|
return str(fake_project_root).replace("\\", "/")
|
|
120
119
|
|
|
121
120
|
|
|
121
|
+
def _two_stale_trust_entries(project_path_posix: str) -> tuple[str, str]:
|
|
122
|
+
base_entry = f"Trusted local workspace: {project_path_posix}/.claude/**"
|
|
123
|
+
return f"{base_entry} wording form one", f"{base_entry} wording form two"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _project_trust_entry_count(
|
|
127
|
+
environment_list: list[object], project_path_posix: str
|
|
128
|
+
) -> int:
|
|
129
|
+
project_marker = f"{project_path_posix}/.claude/**"
|
|
130
|
+
return sum(
|
|
131
|
+
1
|
|
132
|
+
for each_entry in environment_list
|
|
133
|
+
if isinstance(each_entry, str)
|
|
134
|
+
and each_entry.startswith("Trusted local workspace:")
|
|
135
|
+
and project_marker in each_entry
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
122
139
|
def test_grant_writes_deny_rules_for_every_tool_and_pattern(
|
|
123
140
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
124
141
|
) -> None:
|
|
@@ -140,14 +157,13 @@ def test_grant_writes_deny_rules_for_every_tool_and_pattern(
|
|
|
140
157
|
)
|
|
141
158
|
|
|
142
159
|
|
|
143
|
-
def
|
|
160
|
+
def test_grant_mints_only_edit_and_read_rules(
|
|
144
161
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
145
162
|
) -> None:
|
|
146
|
-
"""
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
clause is honored for the Glob tool.
|
|
163
|
+
"""Claude Code matches file operations against Edit(path) and Read(path)
|
|
164
|
+
rules only, so a grant run mints those two tools alone. A Write(path) or
|
|
165
|
+
Glob(path) rule is ignored by the permission checker and prints a startup
|
|
166
|
+
warning, so no minted rule may name either tool.
|
|
151
167
|
"""
|
|
152
168
|
fake_project_root = _make_fake_project(tmp_path)
|
|
153
169
|
fake_settings_path = tmp_path / "settings.json"
|
|
@@ -157,15 +173,15 @@ def test_grant_writes_glob_deny_rules_for_every_agent_config_pattern(
|
|
|
157
173
|
)
|
|
158
174
|
capsys.readouterr()
|
|
159
175
|
written_settings = json.loads(fake_settings_path.read_text(encoding="utf-8"))
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
assert "
|
|
163
|
-
assert "
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
176
|
+
permissions_section = written_settings["permissions"]
|
|
177
|
+
minted_rules = permissions_section["allow"] + permissions_section["deny"]
|
|
178
|
+
assert "Write" not in constants_module.ALL_PERMISSION_ALLOW_TOOLS
|
|
179
|
+
assert "Write" not in constants_module.ALL_AGENT_CONFIG_DENY_TOOLS
|
|
180
|
+
assert "Glob" not in constants_module.ALL_AGENT_CONFIG_DENY_TOOLS
|
|
181
|
+
assert minted_rules, "grant run wrote no permission rules"
|
|
182
|
+
for each_rule in minted_rules:
|
|
183
|
+
assert "Write(" not in each_rule, f"unmatched Write rule: {each_rule!r}"
|
|
184
|
+
assert "Glob(" not in each_rule, f"unmatched Glob rule: {each_rule!r}"
|
|
169
185
|
|
|
170
186
|
|
|
171
187
|
def test_grant_purges_stale_trust_entries_then_writes_current_template(
|
|
@@ -174,18 +190,10 @@ def test_grant_purges_stale_trust_entries_then_writes_current_template(
|
|
|
174
190
|
fake_project_root = _make_fake_project(tmp_path)
|
|
175
191
|
fake_settings_path = tmp_path / "settings.json"
|
|
176
192
|
project_path_posix = _project_path_as_posix(fake_project_root)
|
|
177
|
-
stale_entry_a = (
|
|
178
|
-
f"Trusted local workspace: {project_path_posix}/.claude/** old wording form A"
|
|
179
|
-
)
|
|
180
|
-
stale_entry_b = (
|
|
181
|
-
f"Trusted local workspace: {project_path_posix}/.claude/** "
|
|
182
|
-
f"different earlier wording"
|
|
183
|
-
)
|
|
193
|
+
stale_entry_a, stale_entry_b = _two_stale_trust_entries(project_path_posix)
|
|
184
194
|
unrelated_entry = "Some unrelated environment hint"
|
|
185
|
-
pre_existing_settings: dict[str,
|
|
186
|
-
"autoMode": {
|
|
187
|
-
"environment": [stale_entry_a, stale_entry_b, unrelated_entry],
|
|
188
|
-
},
|
|
195
|
+
pre_existing_settings: dict[str, object] = {
|
|
196
|
+
"autoMode": {"environment": [stale_entry_a, stale_entry_b, unrelated_entry]},
|
|
189
197
|
}
|
|
190
198
|
_seed_grant_then_run(
|
|
191
199
|
fake_settings_path,
|
|
@@ -199,14 +207,7 @@ def test_grant_purges_stale_trust_entries_then_writes_current_template(
|
|
|
199
207
|
assert stale_entry_a not in environment_list
|
|
200
208
|
assert stale_entry_b not in environment_list
|
|
201
209
|
assert unrelated_entry in environment_list
|
|
202
|
-
|
|
203
|
-
each_entry
|
|
204
|
-
for each_entry in environment_list
|
|
205
|
-
if isinstance(each_entry, str)
|
|
206
|
-
and each_entry.startswith("Trusted local workspace:")
|
|
207
|
-
and f"{project_path_posix}/.claude/**" in each_entry
|
|
208
|
-
]
|
|
209
|
-
assert len(matching_trust_entries) == 1
|
|
210
|
+
assert _project_trust_entry_count(environment_list, project_path_posix) == 1
|
|
210
211
|
assert "Stale auto-mode environment entries purged" in captured.out
|
|
211
212
|
|
|
212
213
|
|
|
@@ -223,10 +224,8 @@ def test_revoke_removes_deny_rules(
|
|
|
223
224
|
constants_module.ALL_AGENT_CONFIG_DENY_TOOLS,
|
|
224
225
|
constants_module.ALL_AGENT_CONFIG_PATH_PATTERNS,
|
|
225
226
|
)
|
|
226
|
-
pre_existing_settings: dict[str,
|
|
227
|
-
"permissions": {
|
|
228
|
-
"deny": list(all_deny_rules),
|
|
229
|
-
},
|
|
227
|
+
pre_existing_settings: dict[str, object] = {
|
|
228
|
+
"permissions": {"deny": list(all_deny_rules)},
|
|
230
229
|
}
|
|
231
230
|
_seed_revoke_then_run(
|
|
232
231
|
fake_settings_path,
|
|
@@ -248,16 +247,11 @@ def test_revoke_removes_every_legacy_trust_entry_for_project(
|
|
|
248
247
|
fake_project_root = _make_fake_project(tmp_path)
|
|
249
248
|
fake_settings_path = tmp_path / "settings.json"
|
|
250
249
|
project_path_posix = _project_path_as_posix(fake_project_root)
|
|
251
|
-
legacy_entry_a = (
|
|
252
|
-
f"Trusted local workspace: {project_path_posix}/.claude/** template revision A"
|
|
253
|
-
)
|
|
254
|
-
legacy_entry_b = (
|
|
255
|
-
f"Trusted local workspace: {project_path_posix}/.claude/** template revision B"
|
|
256
|
-
)
|
|
250
|
+
legacy_entry_a, legacy_entry_b = _two_stale_trust_entries(project_path_posix)
|
|
257
251
|
unrelated_other_project_entry = (
|
|
258
252
|
"Trusted local workspace: /some/other/project/.claude/** still valid"
|
|
259
253
|
)
|
|
260
|
-
pre_existing_settings: dict[str,
|
|
254
|
+
pre_existing_settings: dict[str, object] = {
|
|
261
255
|
"autoMode": {
|
|
262
256
|
"environment": [
|
|
263
257
|
legacy_entry_a,
|
|
@@ -293,43 +287,31 @@ def test_is_trust_entry_for_project_predicate_filters_by_prefix_and_project_path
|
|
|
293
287
|
common_module = _load_common_module()
|
|
294
288
|
project_path_posix = "/fake/proj"
|
|
295
289
|
trust_prefix = "Trusted local workspace:"
|
|
296
|
-
non_string_value: object = 42
|
|
297
|
-
assert (
|
|
298
|
-
common_module.is_trust_entry_for_project(
|
|
299
|
-
non_string_value, project_path_posix, trust_prefix
|
|
300
|
-
)
|
|
301
|
-
is False
|
|
302
|
-
)
|
|
303
290
|
wrong_prefix_entry = (
|
|
304
291
|
f"Something else: {project_path_posix}/.claude/** with marker token"
|
|
305
292
|
)
|
|
306
|
-
assert (
|
|
307
|
-
common_module.is_trust_entry_for_project(
|
|
308
|
-
wrong_prefix_entry, project_path_posix, trust_prefix
|
|
309
|
-
)
|
|
310
|
-
is False
|
|
311
|
-
)
|
|
312
293
|
different_project_entry = (
|
|
313
294
|
"Trusted local workspace: /other/project/.claude/** unrelated"
|
|
314
295
|
)
|
|
315
|
-
assert (
|
|
316
|
-
common_module.is_trust_entry_for_project(
|
|
317
|
-
different_project_entry, project_path_posix, trust_prefix
|
|
318
|
-
)
|
|
319
|
-
is False
|
|
320
|
-
)
|
|
321
296
|
matching_entry = (
|
|
322
297
|
f"Trusted local workspace: {project_path_posix}/.claude/** any wording form"
|
|
323
298
|
)
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
299
|
+
predicate_cases: list[tuple[object, bool]] = [
|
|
300
|
+
(42, False),
|
|
301
|
+
(wrong_prefix_entry, False),
|
|
302
|
+
(different_project_entry, False),
|
|
303
|
+
(matching_entry, True),
|
|
304
|
+
]
|
|
305
|
+
for candidate_entry, expected_result in predicate_cases:
|
|
306
|
+
assert (
|
|
307
|
+
common_module.is_trust_entry_for_project(
|
|
308
|
+
candidate_entry, project_path_posix, trust_prefix
|
|
309
|
+
)
|
|
310
|
+
is expected_result
|
|
327
311
|
)
|
|
328
|
-
is True
|
|
329
|
-
)
|
|
330
312
|
|
|
331
313
|
|
|
332
|
-
def
|
|
314
|
+
def test_is_trust_entry_for_project_rejects_cross_project_suffix_collision() -> None:
|
|
333
315
|
"""When the project_path is a path suffix of an unrelated entry's path,
|
|
334
316
|
the predicate must reject the unrelated entry (the boundary anchor case)."""
|
|
335
317
|
common_module = _load_common_module()
|
|
@@ -25,16 +25,14 @@ constants_module = _load_constants_module()
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def test_exposes_all_permission_allow_tools_tuple() -> None:
|
|
28
|
-
assert constants_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "
|
|
28
|
+
assert constants_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Read")
|
|
29
|
+
assert "Write" not in constants_module.ALL_PERMISSION_ALLOW_TOOLS
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
def
|
|
32
|
-
assert constants_module.ALL_AGENT_CONFIG_DENY_TOOLS == (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"Read",
|
|
36
|
-
"Glob",
|
|
37
|
-
)
|
|
32
|
+
def test_exposes_all_agent_config_deny_tools_tuple() -> None:
|
|
33
|
+
assert constants_module.ALL_AGENT_CONFIG_DENY_TOOLS == ("Edit", "Read")
|
|
34
|
+
assert "Write" not in constants_module.ALL_AGENT_CONFIG_DENY_TOOLS
|
|
35
|
+
assert "Glob" not in constants_module.ALL_AGENT_CONFIG_DENY_TOOLS
|
|
38
36
|
assert "Glob" not in constants_module.ALL_PERMISSION_ALLOW_TOOLS
|
|
39
37
|
|
|
40
38
|
|
|
@@ -56,13 +54,9 @@ def test_template_derives_human_readable_pattern_list_from_pattern_tuple() -> No
|
|
|
56
54
|
template_text: str = constants_module.AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE
|
|
57
55
|
assert "{project_path}" in template_text
|
|
58
56
|
for each_pattern in constants_module.ALL_AGENT_CONFIG_PATH_PATTERNS:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
elif each_pattern == "mcp.json":
|
|
63
|
-
expected_phrase = "the mcp.json file"
|
|
64
|
-
else:
|
|
65
|
-
expected_phrase = each_pattern
|
|
57
|
+
expected_phrase = constants_module._describe_agent_config_pattern_for_humans(
|
|
58
|
+
each_pattern
|
|
59
|
+
)
|
|
66
60
|
assert expected_phrase in template_text, (
|
|
67
61
|
f"template missing derived phrase for pattern {each_pattern!r}: "
|
|
68
62
|
f"expected {expected_phrase!r}"
|
|
@@ -29,9 +29,20 @@ def _load_grant_module() -> ModuleType:
|
|
|
29
29
|
return module
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _nested_list_at(settings: dict[str, object], section_key: str, list_key: str) -> (
|
|
33
|
+
list[object]
|
|
34
|
+
):
|
|
35
|
+
section = settings[section_key]
|
|
36
|
+
assert isinstance(section, dict)
|
|
37
|
+
nested_list = section[list_key]
|
|
38
|
+
assert isinstance(nested_list, list)
|
|
39
|
+
return nested_list
|
|
40
|
+
|
|
41
|
+
|
|
32
42
|
def test_module_imports_constants_from_config_modules() -> None:
|
|
33
43
|
grant_module = _load_grant_module()
|
|
34
|
-
assert grant_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "
|
|
44
|
+
assert grant_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Read")
|
|
45
|
+
assert grant_module.ALL_AGENT_CONFIG_DENY_TOOLS == ("Edit", "Read")
|
|
35
46
|
assert "{project_path}" in grant_module.AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE
|
|
36
47
|
assert grant_module.CLAUDE_SETTINGS_PERMISSIONS_KEY == "permissions"
|
|
37
48
|
|
|
@@ -47,3 +58,82 @@ def test_grant_module_guards_sys_path_insert_against_duplicates() -> None:
|
|
|
47
58
|
"grant_project_claude_permissions.py must guard sys.path.insert against "
|
|
48
59
|
"duplicate entries on reload (consistent with sibling modules)"
|
|
49
60
|
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_add_rules_to_allow_list_appends_new_rules_then_skips_duplicates() -> None:
|
|
64
|
+
grant_module = _load_grant_module()
|
|
65
|
+
settings: dict[str, object] = {}
|
|
66
|
+
new_rules = ["Edit(/proj/.claude/**)", "Read(/proj/.claude/**)"]
|
|
67
|
+
first_added_count = grant_module.add_rules_to_allow_list(settings, new_rules)
|
|
68
|
+
assert first_added_count == 2
|
|
69
|
+
allow_list = _nested_list_at(settings, "permissions", "allow")
|
|
70
|
+
assert new_rules[0] in allow_list
|
|
71
|
+
assert new_rules[1] in allow_list
|
|
72
|
+
second_added_count = grant_module.add_rules_to_allow_list(settings, new_rules)
|
|
73
|
+
assert second_added_count == 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_add_rules_to_deny_list_appends_new_rules_then_skips_duplicates() -> None:
|
|
77
|
+
grant_module = _load_grant_module()
|
|
78
|
+
settings: dict[str, object] = {}
|
|
79
|
+
new_rules = ["Edit(/proj/.claude/hooks/**)", "Read(/proj/.claude/hooks/**)"]
|
|
80
|
+
first_added_count = grant_module.add_rules_to_deny_list(settings, new_rules)
|
|
81
|
+
assert first_added_count == 2
|
|
82
|
+
deny_list = _nested_list_at(settings, "permissions", "deny")
|
|
83
|
+
assert new_rules[0] in deny_list
|
|
84
|
+
assert new_rules[1] in deny_list
|
|
85
|
+
second_added_count = grant_module.add_rules_to_deny_list(settings, new_rules)
|
|
86
|
+
assert second_added_count == 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_add_directory_to_additional_directories_is_idempotent() -> None:
|
|
90
|
+
grant_module = _load_grant_module()
|
|
91
|
+
settings: dict[str, object] = {}
|
|
92
|
+
project_directory = "/proj"
|
|
93
|
+
first_added_count = grant_module.add_directory_to_additional_directories(
|
|
94
|
+
settings, project_directory
|
|
95
|
+
)
|
|
96
|
+
assert first_added_count == 1
|
|
97
|
+
directories = _nested_list_at(settings, "permissions", "additionalDirectories")
|
|
98
|
+
assert project_directory in directories
|
|
99
|
+
second_added_count = grant_module.add_directory_to_additional_directories(
|
|
100
|
+
settings, project_directory
|
|
101
|
+
)
|
|
102
|
+
assert second_added_count == 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_add_auto_mode_environment_entry_is_idempotent() -> None:
|
|
106
|
+
grant_module = _load_grant_module()
|
|
107
|
+
settings: dict[str, object] = {}
|
|
108
|
+
entry_text = "Trusted local workspace: /proj/.claude/** current wording"
|
|
109
|
+
first_added_count = grant_module.add_auto_mode_environment_entry(
|
|
110
|
+
settings, entry_text
|
|
111
|
+
)
|
|
112
|
+
assert first_added_count == 1
|
|
113
|
+
environment = _nested_list_at(settings, "autoMode", "environment")
|
|
114
|
+
assert entry_text in environment
|
|
115
|
+
second_added_count = grant_module.add_auto_mode_environment_entry(
|
|
116
|
+
settings, entry_text
|
|
117
|
+
)
|
|
118
|
+
assert second_added_count == 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_purge_stale_trust_entries_removes_matches_and_keeps_protected() -> None:
|
|
122
|
+
grant_module = _load_grant_module()
|
|
123
|
+
trust_prefix = grant_module.AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX
|
|
124
|
+
stale_entry = "Trusted local workspace: /proj/.claude/** stale wording"
|
|
125
|
+
protected_entry = "Trusted local workspace: /proj/.claude/** current wording"
|
|
126
|
+
unrelated_entry = "Some unrelated environment hint"
|
|
127
|
+
settings: dict[str, object] = {
|
|
128
|
+
"autoMode": {
|
|
129
|
+
"environment": [stale_entry, protected_entry, unrelated_entry],
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
purged_count = grant_module.purge_stale_trust_entries(
|
|
133
|
+
settings, "/proj", trust_prefix, protected_entry=protected_entry
|
|
134
|
+
)
|
|
135
|
+
assert purged_count == 1
|
|
136
|
+
remaining_environment = _nested_list_at(settings, "autoMode", "environment")
|
|
137
|
+
assert stale_entry not in remaining_environment
|
|
138
|
+
assert protected_entry in remaining_environment
|
|
139
|
+
assert unrelated_entry in remaining_environment
|