claude-dev-env 1.41.0 → 1.42.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 +8 -0
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +231 -3
- package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +56 -2
- package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +2 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +121 -4
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +73 -17
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +33 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +3 -1
- package/package.json +1 -1
- package/skills/bugteam/scripts/_claude_permissions_common.py +109 -0
- package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +51 -2
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +115 -4
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +69 -17
- package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
- package/skills/implement/SKILL.md +66 -0
- package/skills/implement/scripts/append_note.py +133 -0
- package/skills/implement/scripts/config/__init__.py +0 -0
- package/skills/implement/scripts/config/notes_constants.py +12 -0
- package/skills/implement/scripts/test_append_note.py +191 -0
- package/skills/pr-converge/config/constants.py +5 -0
- package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
- package/skills/pr-converge/scripts/check_convergence.py +167 -28
- package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
- package/skills/pr-converge/scripts/conftest.py +60 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
- package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
- package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
- package/skills/pr-converge/scripts/test_check_convergence.py +306 -0
- package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +1 -1
- package/skills/refine/SKILL.md +257 -0
- package/skills/refine/templates/implementation-notes-template.html +56 -0
- package/skills/refine/templates/plan-template.md +60 -0
|
@@ -12,6 +12,7 @@ import json
|
|
|
12
12
|
import os
|
|
13
13
|
import stat
|
|
14
14
|
import sys
|
|
15
|
+
from collections.abc import Callable
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from typing import NoReturn
|
|
17
18
|
|
|
@@ -26,6 +27,7 @@ for each_cached_module_name in [
|
|
|
26
27
|
)
|
|
27
28
|
|
|
28
29
|
from config.claude_permissions_common_constants import (
|
|
30
|
+
ALL_TRUST_ENTRY_PROJECT_PATH_BOUNDARY_QUOTE_CHARACTERS,
|
|
29
31
|
ATOMIC_WRITE_TEMPORARY_SUFFIX,
|
|
30
32
|
DEFAULT_SETTINGS_FILE_MODE,
|
|
31
33
|
TEXT_FILE_ENCODING,
|
|
@@ -105,6 +107,113 @@ def build_permission_rules(
|
|
|
105
107
|
]
|
|
106
108
|
|
|
107
109
|
|
|
110
|
+
def build_agent_config_deny_rule(
|
|
111
|
+
tool_name: str, project_path: str, agent_config_path_pattern: str
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Construct a deny rule for a single agent-config path pattern.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
tool_name: The permission tool name (e.g., "Edit", "Write", "Read").
|
|
117
|
+
project_path: The POSIX-style project root path.
|
|
118
|
+
agent_config_path_pattern: The agent-config path pattern under .claude/.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The deny rule string Claude Code matches against tool invocations.
|
|
122
|
+
"""
|
|
123
|
+
return f"{tool_name}({project_path}/.claude/{agent_config_path_pattern})"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def build_agent_config_deny_rules(
|
|
127
|
+
project_path: str,
|
|
128
|
+
all_permission_allow_tools: tuple[str, ...],
|
|
129
|
+
all_agent_config_path_patterns: tuple[str, ...],
|
|
130
|
+
) -> list[str]:
|
|
131
|
+
"""Construct deny rules covering every tool and pattern pair.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
project_path: The POSIX-style project root path.
|
|
135
|
+
all_permission_allow_tools: Tool names to build deny rules for.
|
|
136
|
+
all_agent_config_path_patterns: Agent-config path patterns to deny under .claude/.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
List of deny rule strings, one per tool/pattern combination.
|
|
140
|
+
"""
|
|
141
|
+
return [
|
|
142
|
+
build_agent_config_deny_rule(each_tool, project_path, each_pattern)
|
|
143
|
+
for each_tool in all_permission_allow_tools
|
|
144
|
+
for each_pattern in all_agent_config_path_patterns
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_project_path_token_at_word_boundary(
|
|
149
|
+
body_after_prefix: str, token_position: int
|
|
150
|
+
) -> bool:
|
|
151
|
+
if token_position == 0:
|
|
152
|
+
return True
|
|
153
|
+
preceding_character = body_after_prefix[token_position - 1]
|
|
154
|
+
if preceding_character.isspace():
|
|
155
|
+
return True
|
|
156
|
+
return preceding_character in ALL_TRUST_ENTRY_PROJECT_PATH_BOUNDARY_QUOTE_CHARACTERS
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def is_trust_entry_for_project(
|
|
160
|
+
candidate_entry: object, project_path: str, prefix: str
|
|
161
|
+
) -> bool:
|
|
162
|
+
"""Detect whether an autoMode.environment entry is a trust entry for the project.
|
|
163
|
+
|
|
164
|
+
The predicate matches any string entry whose prefix matches the trust-entry
|
|
165
|
+
marker and that contains the project's .claude/** path token anchored on a
|
|
166
|
+
non-path boundary (the start of the body after the prefix, a whitespace
|
|
167
|
+
character, or a quote character). The boundary anchor prevents
|
|
168
|
+
cross-project false positives where the current project's path is a path
|
|
169
|
+
suffix of an unrelated entry's path. The exact wording after the prefix is
|
|
170
|
+
allowed to vary between template revisions.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
candidate_entry: The autoMode.environment list value to inspect.
|
|
174
|
+
project_path: The POSIX-style project root path.
|
|
175
|
+
prefix: The literal prefix that marks a trust entry.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
True when the entry is a prior trust entry for this project.
|
|
179
|
+
"""
|
|
180
|
+
if not isinstance(candidate_entry, str):
|
|
181
|
+
return False
|
|
182
|
+
if not candidate_entry.startswith(prefix):
|
|
183
|
+
return False
|
|
184
|
+
project_path_token = f"{project_path}/.claude/**"
|
|
185
|
+
body_after_prefix = candidate_entry[len(prefix):]
|
|
186
|
+
token_position = body_after_prefix.find(project_path_token)
|
|
187
|
+
while token_position != -1:
|
|
188
|
+
if _is_project_path_token_at_word_boundary(body_after_prefix, token_position):
|
|
189
|
+
return True
|
|
190
|
+
next_search_start = token_position + 1
|
|
191
|
+
token_position = body_after_prefix.find(project_path_token, next_search_start)
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def remove_matching_entries_from_list(
|
|
196
|
+
all_target_list: list[object],
|
|
197
|
+
match_predicate: Callable[[object], bool],
|
|
198
|
+
) -> int:
|
|
199
|
+
"""Remove every entry from a list that satisfies the predicate.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
all_target_list: The list to filter in place.
|
|
203
|
+
match_predicate: Function returning True for entries to remove.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Number of entries removed.
|
|
207
|
+
"""
|
|
208
|
+
original_length = len(all_target_list)
|
|
209
|
+
all_target_list[:] = [
|
|
210
|
+
each_value
|
|
211
|
+
for each_value in all_target_list
|
|
212
|
+
if not match_predicate(each_value)
|
|
213
|
+
]
|
|
214
|
+
return original_length - len(all_target_list)
|
|
215
|
+
|
|
216
|
+
|
|
108
217
|
def load_settings(settings_path: Path) -> dict[str, object]:
|
|
109
218
|
"""Read and parse a JSON settings file from disk.
|
|
110
219
|
|
|
@@ -4,10 +4,58 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
TEXT_FILE_ENCODING: str = "utf-8"
|
|
6
6
|
ALL_PERMISSION_ALLOW_TOOLS: tuple[str, ...] = ("Edit", "Write", "Read")
|
|
7
|
+
ALL_AGENT_CONFIG_DENY_TOOLS: tuple[str, ...] = ("Edit", "Write", "Read", "Glob")
|
|
8
|
+
ALL_AGENT_CONFIG_PATH_PATTERNS: tuple[str, ...] = (
|
|
9
|
+
"settings*.json",
|
|
10
|
+
"hooks/**",
|
|
11
|
+
"commands/**",
|
|
12
|
+
"agents/**",
|
|
13
|
+
"skills/**",
|
|
14
|
+
"mcp.json",
|
|
15
|
+
"CLAUDE.md",
|
|
16
|
+
)
|
|
17
|
+
AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX: str = "Trusted local workspace:"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _describe_agent_config_pattern_for_humans(agent_config_path_pattern: str) -> str:
|
|
21
|
+
glob_suffix_under_directory = "/**"
|
|
22
|
+
file_name_for_special_phrasing = "mcp.json"
|
|
23
|
+
if agent_config_path_pattern.endswith(glob_suffix_under_directory):
|
|
24
|
+
directory_name = agent_config_path_pattern[
|
|
25
|
+
: -len(glob_suffix_under_directory)
|
|
26
|
+
]
|
|
27
|
+
return f"anything under {directory_name}/"
|
|
28
|
+
if agent_config_path_pattern == file_name_for_special_phrasing:
|
|
29
|
+
return f"the {file_name_for_special_phrasing} file"
|
|
30
|
+
return agent_config_path_pattern
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_agent_config_pattern_phrase(
|
|
34
|
+
all_agent_config_path_patterns: tuple[str, ...],
|
|
35
|
+
) -> str:
|
|
36
|
+
all_described_patterns: list[str] = [
|
|
37
|
+
_describe_agent_config_pattern_for_humans(each_pattern)
|
|
38
|
+
for each_pattern in all_agent_config_path_patterns
|
|
39
|
+
]
|
|
40
|
+
if len(all_described_patterns) <= 1:
|
|
41
|
+
return ", ".join(all_described_patterns)
|
|
42
|
+
leading_phrase_parts = ", ".join(all_described_patterns[:-1])
|
|
43
|
+
final_phrase_part = all_described_patterns[-1]
|
|
44
|
+
return f"{leading_phrase_parts}, and {final_phrase_part}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_AGENT_CONFIG_PATTERN_PHRASE: str = _build_agent_config_pattern_phrase(
|
|
48
|
+
ALL_AGENT_CONFIG_PATH_PATTERNS
|
|
49
|
+
)
|
|
50
|
+
|
|
7
51
|
AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE: str = (
|
|
8
|
-
"Trusted local workspace: {project_path}/.claude/**
|
|
9
|
-
"
|
|
52
|
+
f"Trusted local workspace: Files under {{project_path}}/.claude/** inherit "
|
|
53
|
+
f"the workspace's trust for Edit, Write, Read, and Glob operations EXCEPT "
|
|
54
|
+
f"for agent-config files: {_AGENT_CONFIG_PATTERN_PHRASE}. Edits to those "
|
|
55
|
+
f"agent-config files always require explicit per-edit user approval."
|
|
10
56
|
)
|
|
57
|
+
|
|
58
|
+
ALL_TRUST_ENTRY_PROJECT_PATH_BOUNDARY_QUOTE_CHARACTERS: tuple[str, ...] = ('"', "'")
|
|
11
59
|
ATOMIC_WRITE_TEMPORARY_SUFFIX: str = ".tmp"
|
|
12
60
|
GIT_DIRECTORY_MARKER: str = ".git"
|
|
13
61
|
CLAUDE_DIRECTORY_MARKER: str = ".claude"
|
|
@@ -15,6 +63,7 @@ CLAUDE_USER_SETTINGS_FILENAME: str = "settings.json"
|
|
|
15
63
|
DEFAULT_SETTINGS_FILE_MODE: int = 0o600
|
|
16
64
|
SETTINGS_PERMISSIONS_KEY: str = "permissions"
|
|
17
65
|
SETTINGS_ALLOW_KEY: str = "allow"
|
|
66
|
+
SETTINGS_DENY_KEY: str = "deny"
|
|
18
67
|
SETTINGS_ADDITIONAL_DIRECTORIES_KEY: str = "additionalDirectories"
|
|
19
68
|
SETTINGS_AUTO_MODE_KEY: str = "autoMode"
|
|
20
69
|
SETTINGS_ENVIRONMENT_KEY: str = "environment"
|
|
@@ -21,16 +21,22 @@ if parent_directory not in sys.path:
|
|
|
21
21
|
|
|
22
22
|
from _claude_permissions_common import ( # noqa: E402
|
|
23
23
|
append_if_missing,
|
|
24
|
+
build_agent_config_deny_rules,
|
|
24
25
|
build_permission_rules,
|
|
25
26
|
ensure_dict_section,
|
|
26
27
|
ensure_list_entry,
|
|
27
28
|
exit_with_error,
|
|
28
29
|
get_current_project_path,
|
|
30
|
+
is_trust_entry_for_project,
|
|
29
31
|
load_settings,
|
|
32
|
+
remove_matching_entries_from_list,
|
|
30
33
|
save_settings,
|
|
31
34
|
)
|
|
32
35
|
from config.claude_permissions_common_constants import ( # noqa: E402
|
|
36
|
+
ALL_AGENT_CONFIG_DENY_TOOLS,
|
|
37
|
+
ALL_AGENT_CONFIG_PATH_PATTERNS,
|
|
33
38
|
ALL_PERMISSION_ALLOW_TOOLS,
|
|
39
|
+
AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX,
|
|
34
40
|
AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
|
|
35
41
|
CLAUDE_DIRECTORY_MARKER,
|
|
36
42
|
CLAUDE_USER_SETTINGS_FILENAME,
|
|
@@ -38,6 +44,7 @@ from config.claude_permissions_common_constants import ( # noqa: E402
|
|
|
38
44
|
SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
|
|
39
45
|
SETTINGS_ALLOW_KEY,
|
|
40
46
|
SETTINGS_AUTO_MODE_KEY,
|
|
47
|
+
SETTINGS_DENY_KEY,
|
|
41
48
|
SETTINGS_ENVIRONMENT_KEY,
|
|
42
49
|
SETTINGS_PERMISSIONS_KEY,
|
|
43
50
|
)
|
|
@@ -77,6 +84,31 @@ def add_rules_to_allow_list(all_settings: dict[str, object], all_rules_to_add: l
|
|
|
77
84
|
)
|
|
78
85
|
|
|
79
86
|
|
|
87
|
+
def add_rules_to_deny_list(
|
|
88
|
+
all_settings: dict[str, object], all_rules_to_add: list[str]
|
|
89
|
+
) -> int:
|
|
90
|
+
"""Add permission rules to the settings deny list.
|
|
91
|
+
|
|
92
|
+
Deny rules take precedence over allow rules in Claude Code's permission
|
|
93
|
+
matching, so writing agent-config paths into the deny list forces a
|
|
94
|
+
per-edit user approval even when a broader allow rule would cover them.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
all_settings: The parsed settings dictionary.
|
|
98
|
+
all_rules_to_add: Permission rule strings to append.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Number of rules actually added (new entries).
|
|
102
|
+
"""
|
|
103
|
+
permissions_section = ensure_dict_section(all_settings, SETTINGS_PERMISSIONS_KEY)
|
|
104
|
+
existing_deny_list = ensure_list_entry(permissions_section, SETTINGS_DENY_KEY)
|
|
105
|
+
return sum(
|
|
106
|
+
1
|
|
107
|
+
for each_rule in all_rules_to_add
|
|
108
|
+
if append_if_missing(existing_deny_list, each_rule)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
80
112
|
def add_directory_to_additional_directories(
|
|
81
113
|
all_settings: dict[str, object], directory_path: str
|
|
82
114
|
) -> int:
|
|
@@ -117,11 +149,63 @@ def add_auto_mode_environment_entry(
|
|
|
117
149
|
return 0
|
|
118
150
|
|
|
119
151
|
|
|
152
|
+
def purge_stale_trust_entries(
|
|
153
|
+
all_settings: dict[str, object],
|
|
154
|
+
project_path: str,
|
|
155
|
+
prefix: str,
|
|
156
|
+
protected_entry: str | None = None,
|
|
157
|
+
) -> int:
|
|
158
|
+
"""Remove every prior trust entry for the project from autoMode.environment.
|
|
159
|
+
|
|
160
|
+
A trust entry is any string in autoMode.environment whose prefix matches
|
|
161
|
+
the trust-entry marker and that contains the project's .claude/** path.
|
|
162
|
+
Purging stale entries before adding the current template prevents
|
|
163
|
+
accumulation across template revisions. The optional protected_entry
|
|
164
|
+
survives the purge so an entry byte-identical to the one about to be
|
|
165
|
+
re-added is not removed and re-added on every invocation, preserving the
|
|
166
|
+
idempotency contract documented on grant_permissions_for_current_directory.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
all_settings: The parsed settings dictionary.
|
|
170
|
+
project_path: The POSIX-style project root path.
|
|
171
|
+
prefix: The literal prefix that marks a trust entry.
|
|
172
|
+
protected_entry: Optional entry text that, when byte-equal to a
|
|
173
|
+
candidate, prevents removal. Pass the freshly-formatted current
|
|
174
|
+
template entry from grant to preserve idempotency. Revoke passes
|
|
175
|
+
None so every matching entry is removed.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Number of stale entries removed.
|
|
179
|
+
"""
|
|
180
|
+
auto_mode_section = all_settings.get(SETTINGS_AUTO_MODE_KEY)
|
|
181
|
+
if not isinstance(auto_mode_section, dict):
|
|
182
|
+
return 0
|
|
183
|
+
existing_environment = auto_mode_section.get(SETTINGS_ENVIRONMENT_KEY)
|
|
184
|
+
if not isinstance(existing_environment, list):
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
def _should_purge_candidate(candidate_entry: object) -> bool:
|
|
188
|
+
if not is_trust_entry_for_project(candidate_entry, project_path, prefix):
|
|
189
|
+
return False
|
|
190
|
+
if protected_entry is not None and candidate_entry == protected_entry:
|
|
191
|
+
return False
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
return remove_matching_entries_from_list(
|
|
195
|
+
existing_environment,
|
|
196
|
+
_should_purge_candidate,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
120
200
|
def grant_permissions_for_current_directory() -> None:
|
|
121
201
|
"""Grant Edit/Write/Read permissions for the current project directory.
|
|
122
202
|
|
|
123
203
|
Reads the current project path, constructs permission rules from config
|
|
124
|
-
constants, and writes them to ~/.claude/settings.json atomically.
|
|
204
|
+
constants, and writes them to ~/.claude/settings.json atomically. Adds
|
|
205
|
+
deny rules for agent-config paths so edits to settings, hooks, commands,
|
|
206
|
+
agents, skills, mcp.json, and CLAUDE.md still require per-edit user
|
|
207
|
+
approval. Purges any prior trust entries for this project before writing
|
|
208
|
+
the current template to prevent accumulation across template revisions.
|
|
125
209
|
|
|
126
210
|
Raises:
|
|
127
211
|
SystemExit(1): When the current directory is not a valid project root.
|
|
@@ -141,19 +225,37 @@ def grant_permissions_for_current_directory() -> None:
|
|
|
141
225
|
raise SystemExit(1)
|
|
142
226
|
project_path = get_current_project_path()
|
|
143
227
|
permission_rules = build_permission_rules(project_path, ALL_PERMISSION_ALLOW_TOOLS)
|
|
228
|
+
all_agent_config_deny_rules = build_agent_config_deny_rules(
|
|
229
|
+
project_path,
|
|
230
|
+
ALL_AGENT_CONFIG_DENY_TOOLS,
|
|
231
|
+
ALL_AGENT_CONFIG_PATH_PATTERNS,
|
|
232
|
+
)
|
|
144
233
|
environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
|
|
145
234
|
project_path=project_path
|
|
146
235
|
)
|
|
147
236
|
settings = load_settings(claude_user_settings_path)
|
|
148
|
-
|
|
237
|
+
allow_rules_added_count = add_rules_to_allow_list(settings, permission_rules)
|
|
238
|
+
deny_rules_added_count = add_rules_to_deny_list(
|
|
239
|
+
settings, all_agent_config_deny_rules
|
|
240
|
+
)
|
|
149
241
|
directories_added_count = add_directory_to_additional_directories(
|
|
150
242
|
settings, project_path
|
|
151
243
|
)
|
|
244
|
+
stale_trust_entries_purged_count = purge_stale_trust_entries(
|
|
245
|
+
settings,
|
|
246
|
+
project_path,
|
|
247
|
+
AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX,
|
|
248
|
+
protected_entry=environment_entry,
|
|
249
|
+
)
|
|
152
250
|
environment_entries_added_count = add_auto_mode_environment_entry(
|
|
153
251
|
settings, environment_entry
|
|
154
252
|
)
|
|
155
253
|
total_changes_count = (
|
|
156
|
-
|
|
254
|
+
allow_rules_added_count
|
|
255
|
+
+ deny_rules_added_count
|
|
256
|
+
+ directories_added_count
|
|
257
|
+
+ stale_trust_entries_purged_count
|
|
258
|
+
+ environment_entries_added_count
|
|
157
259
|
)
|
|
158
260
|
if total_changes_count == 0:
|
|
159
261
|
print(f"Project path: {project_path}")
|
|
@@ -163,8 +265,17 @@ def grant_permissions_for_current_directory() -> None:
|
|
|
163
265
|
save_settings(claude_user_settings_path, settings)
|
|
164
266
|
print(f"Project path: {project_path}")
|
|
165
267
|
print(f"Settings file: {claude_user_settings_path}")
|
|
166
|
-
print(f"Allow rules added: {
|
|
268
|
+
print(f"Allow rules added: {allow_rules_added_count} of {len(permission_rules)}")
|
|
269
|
+
print(
|
|
270
|
+
f"Deny rules added: {deny_rules_added_count} of "
|
|
271
|
+
f"{len(all_agent_config_deny_rules)}"
|
|
272
|
+
)
|
|
167
273
|
print(f"Additional directories added: {directories_added_count}")
|
|
274
|
+
if stale_trust_entries_purged_count > 0:
|
|
275
|
+
print(
|
|
276
|
+
f"Stale auto-mode environment entries purged: "
|
|
277
|
+
f"{stale_trust_entries_purged_count}"
|
|
278
|
+
)
|
|
168
279
|
print(f"Auto-mode environment entries added: {environment_entries_added_count}")
|
|
169
280
|
|
|
170
281
|
|
|
@@ -21,22 +21,28 @@ if parent_directory not in sys.path:
|
|
|
21
21
|
sys.path.insert(0, parent_directory)
|
|
22
22
|
|
|
23
23
|
from _claude_permissions_common import ( # noqa: E402
|
|
24
|
+
build_agent_config_deny_rules,
|
|
24
25
|
build_permission_rules,
|
|
25
26
|
exit_with_error,
|
|
26
27
|
get_current_project_path,
|
|
28
|
+
is_trust_entry_for_project,
|
|
27
29
|
load_settings,
|
|
28
30
|
prune_empty_list_then_empty_section,
|
|
31
|
+
remove_matching_entries_from_list,
|
|
29
32
|
save_settings,
|
|
30
33
|
)
|
|
31
34
|
from config.claude_permissions_common_constants import ( # noqa: E402
|
|
35
|
+
ALL_AGENT_CONFIG_DENY_TOOLS,
|
|
36
|
+
ALL_AGENT_CONFIG_PATH_PATTERNS,
|
|
32
37
|
ALL_PERMISSION_ALLOW_TOOLS,
|
|
33
|
-
|
|
38
|
+
AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX,
|
|
34
39
|
CLAUDE_DIRECTORY_MARKER,
|
|
35
40
|
CLAUDE_USER_SETTINGS_FILENAME,
|
|
36
41
|
GIT_DIRECTORY_MARKER,
|
|
37
42
|
SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
|
|
38
43
|
SETTINGS_ALLOW_KEY,
|
|
39
44
|
SETTINGS_AUTO_MODE_KEY,
|
|
45
|
+
SETTINGS_DENY_KEY,
|
|
40
46
|
SETTINGS_ENVIRONMENT_KEY,
|
|
41
47
|
SETTINGS_PERMISSIONS_KEY,
|
|
42
48
|
)
|
|
@@ -97,6 +103,27 @@ def remove_rules_from_allow_list(
|
|
|
97
103
|
return remove_values_from_list(existing_allow_list, set(all_rules_to_remove))
|
|
98
104
|
|
|
99
105
|
|
|
106
|
+
def remove_rules_from_deny_list(
|
|
107
|
+
all_settings: dict[str, object], all_rules_to_remove: list[str]
|
|
108
|
+
) -> int:
|
|
109
|
+
"""Remove matching permission rules from the settings deny list.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
all_settings: The parsed settings dictionary.
|
|
113
|
+
all_rules_to_remove: Permission rule strings to remove.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Number of rules removed.
|
|
117
|
+
"""
|
|
118
|
+
permissions_section = all_settings.get(SETTINGS_PERMISSIONS_KEY)
|
|
119
|
+
if not isinstance(permissions_section, dict):
|
|
120
|
+
return 0
|
|
121
|
+
existing_deny_list = permissions_section.get(SETTINGS_DENY_KEY)
|
|
122
|
+
if not isinstance(existing_deny_list, list):
|
|
123
|
+
return 0
|
|
124
|
+
return remove_values_from_list(existing_deny_list, set(all_rules_to_remove))
|
|
125
|
+
|
|
126
|
+
|
|
100
127
|
def remove_directory_from_additional_directories(
|
|
101
128
|
all_settings: dict[str, object], directory_path: str
|
|
102
129
|
) -> int:
|
|
@@ -118,17 +145,23 @@ def remove_directory_from_additional_directories(
|
|
|
118
145
|
return remove_values_from_list(existing_directories, {directory_path})
|
|
119
146
|
|
|
120
147
|
|
|
121
|
-
def
|
|
122
|
-
all_settings: dict[str, object],
|
|
148
|
+
def remove_trust_entries_for_project(
|
|
149
|
+
all_settings: dict[str, object], project_path: str, prefix: str
|
|
123
150
|
) -> int:
|
|
124
|
-
"""Remove
|
|
151
|
+
"""Remove every trust entry for the project from autoMode.environment.
|
|
152
|
+
|
|
153
|
+
Matches any string in autoMode.environment whose prefix matches the
|
|
154
|
+
trust-entry marker and that contains the project's .claude/** path.
|
|
155
|
+
The match is wording-agnostic so prior template revisions are removed
|
|
156
|
+
cleanly even when the current template differs.
|
|
125
157
|
|
|
126
158
|
Args:
|
|
127
159
|
all_settings: The parsed settings dictionary.
|
|
128
|
-
|
|
160
|
+
project_path: The POSIX-style project root path.
|
|
161
|
+
prefix: The literal prefix that marks a trust entry.
|
|
129
162
|
|
|
130
163
|
Returns:
|
|
131
|
-
|
|
164
|
+
Number of entries removed.
|
|
132
165
|
"""
|
|
133
166
|
auto_mode_section = all_settings.get(SETTINGS_AUTO_MODE_KEY)
|
|
134
167
|
if not isinstance(auto_mode_section, dict):
|
|
@@ -136,7 +169,12 @@ def remove_auto_mode_environment_entry(
|
|
|
136
169
|
existing_environment = auto_mode_section.get(SETTINGS_ENVIRONMENT_KEY)
|
|
137
170
|
if not isinstance(existing_environment, list):
|
|
138
171
|
return 0
|
|
139
|
-
return
|
|
172
|
+
return remove_matching_entries_from_list(
|
|
173
|
+
existing_environment,
|
|
174
|
+
lambda candidate_entry: is_trust_entry_for_project(
|
|
175
|
+
candidate_entry, project_path, prefix
|
|
176
|
+
),
|
|
177
|
+
)
|
|
140
178
|
|
|
141
179
|
|
|
142
180
|
def prune_settings_after_revoke(all_settings: dict[str, object]) -> None:
|
|
@@ -148,6 +186,9 @@ def prune_settings_after_revoke(all_settings: dict[str, object]) -> None:
|
|
|
148
186
|
prune_empty_list_then_empty_section(
|
|
149
187
|
all_settings, SETTINGS_PERMISSIONS_KEY, SETTINGS_ALLOW_KEY
|
|
150
188
|
)
|
|
189
|
+
prune_empty_list_then_empty_section(
|
|
190
|
+
all_settings, SETTINGS_PERMISSIONS_KEY, SETTINGS_DENY_KEY
|
|
191
|
+
)
|
|
151
192
|
prune_empty_list_then_empty_section(
|
|
152
193
|
all_settings, SETTINGS_PERMISSIONS_KEY, SETTINGS_ADDITIONAL_DIRECTORIES_KEY
|
|
153
194
|
)
|
|
@@ -159,9 +200,10 @@ def prune_settings_after_revoke(all_settings: dict[str, object]) -> None:
|
|
|
159
200
|
def revoke_permissions_for_current_directory() -> None:
|
|
160
201
|
"""Revoke Edit/Write/Read permissions for the current project directory.
|
|
161
202
|
|
|
162
|
-
Reads the current project path, constructs
|
|
163
|
-
|
|
164
|
-
|
|
203
|
+
Reads the current project path, constructs the matching allow and deny
|
|
204
|
+
permission rules, removes them from ~/.claude/settings.json, removes
|
|
205
|
+
every trust entry for the project from autoMode.environment, and prunes
|
|
206
|
+
any newly empty sections.
|
|
165
207
|
|
|
166
208
|
Raises:
|
|
167
209
|
SystemExit(1): When the current directory is not a valid project root.
|
|
@@ -181,19 +223,25 @@ def revoke_permissions_for_current_directory() -> None:
|
|
|
181
223
|
raise SystemExit(1)
|
|
182
224
|
project_path = get_current_project_path()
|
|
183
225
|
permission_rules = build_permission_rules(project_path, ALL_PERMISSION_ALLOW_TOOLS)
|
|
184
|
-
|
|
185
|
-
project_path
|
|
226
|
+
all_agent_config_deny_rules = build_agent_config_deny_rules(
|
|
227
|
+
project_path,
|
|
228
|
+
ALL_AGENT_CONFIG_DENY_TOOLS,
|
|
229
|
+
ALL_AGENT_CONFIG_PATH_PATTERNS,
|
|
186
230
|
)
|
|
187
231
|
settings = load_settings(claude_user_settings_path)
|
|
188
|
-
|
|
232
|
+
allow_rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
|
|
233
|
+
deny_rules_removed_count = remove_rules_from_deny_list(
|
|
234
|
+
settings, all_agent_config_deny_rules
|
|
235
|
+
)
|
|
189
236
|
directories_removed_count = remove_directory_from_additional_directories(
|
|
190
237
|
settings, project_path
|
|
191
238
|
)
|
|
192
|
-
environment_entries_removed_count =
|
|
193
|
-
settings,
|
|
239
|
+
environment_entries_removed_count = remove_trust_entries_for_project(
|
|
240
|
+
settings, project_path, AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX
|
|
194
241
|
)
|
|
195
242
|
total_changes_count = (
|
|
196
|
-
|
|
243
|
+
allow_rules_removed_count
|
|
244
|
+
+ deny_rules_removed_count
|
|
197
245
|
+ directories_removed_count
|
|
198
246
|
+ environment_entries_removed_count
|
|
199
247
|
)
|
|
@@ -206,7 +254,11 @@ def revoke_permissions_for_current_directory() -> None:
|
|
|
206
254
|
save_settings(claude_user_settings_path, settings)
|
|
207
255
|
print(f"Project path: {project_path}")
|
|
208
256
|
print(f"Settings file: {claude_user_settings_path}")
|
|
209
|
-
print(f"Allow rules removed: {
|
|
257
|
+
print(f"Allow rules removed: {allow_rules_removed_count} of {len(permission_rules)}")
|
|
258
|
+
print(
|
|
259
|
+
f"Deny rules removed: {deny_rules_removed_count} of "
|
|
260
|
+
f"{len(all_agent_config_deny_rules)}"
|
|
261
|
+
)
|
|
210
262
|
print(f"Additional directories removed: {directories_removed_count}")
|
|
211
263
|
print(
|
|
212
264
|
f"Auto-mode environment entries removed: {environment_entries_removed_count}"
|