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.
Files changed (33) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +231 -3
  3. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +56 -2
  4. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +2 -0
  5. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +121 -4
  6. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +73 -17
  7. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
  8. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +33 -0
  9. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +3 -1
  10. package/package.json +1 -1
  11. package/skills/bugteam/scripts/_claude_permissions_common.py +109 -0
  12. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +51 -2
  13. package/skills/bugteam/scripts/grant_project_claude_permissions.py +115 -4
  14. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +69 -17
  15. package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
  16. package/skills/implement/SKILL.md +66 -0
  17. package/skills/implement/scripts/append_note.py +133 -0
  18. package/skills/implement/scripts/config/__init__.py +0 -0
  19. package/skills/implement/scripts/config/notes_constants.py +12 -0
  20. package/skills/implement/scripts/test_append_note.py +191 -0
  21. package/skills/pr-converge/config/constants.py +5 -0
  22. package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
  23. package/skills/pr-converge/scripts/check_convergence.py +167 -28
  24. package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
  25. package/skills/pr-converge/scripts/conftest.py +60 -0
  26. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
  27. package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
  28. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
  29. package/skills/pr-converge/scripts/test_check_convergence.py +306 -0
  30. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +1 -1
  31. package/skills/refine/SKILL.md +257 -0
  32. package/skills/refine/templates/implementation-notes-template.html +56 -0
  33. 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/** is the user's "
9
- "project Claude Code config tree; edits inside are routine"
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
- rules_added_count = add_rules_to_allow_list(settings, permission_rules)
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
- rules_added_count + directories_added_count + environment_entries_added_count
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: {rules_added_count} of {len(permission_rules)}")
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
- AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
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 remove_auto_mode_environment_entry(
122
- all_settings: dict[str, object], entry_text: str
148
+ def remove_trust_entries_for_project(
149
+ all_settings: dict[str, object], project_path: str, prefix: str
123
150
  ) -> int:
124
- """Remove an auto-mode environment entry for the project.
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
- entry_text: The environment entry text to remove.
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
- 1 when the entry was removed, 0 when not found.
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 remove_values_from_list(existing_environment, {entry_text})
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 permission rules from config
163
- constants, removes them from ~/.claude/settings.json, and prunes any
164
- newly empty sections.
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
- environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
185
- project_path=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
- rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
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 = remove_auto_mode_environment_entry(
193
- settings, environment_entry
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
- rules_removed_count
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: {rules_removed_count} of {len(permission_rules)}")
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}"