claude-dev-env 1.40.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 (66) hide show
  1. package/CLAUDE.md +9 -1
  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 +173 -6
  6. package/_shared/pr-loop/scripts/post_audit_thread.py +2 -2
  7. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +135 -14
  8. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +33 -0
  10. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
  11. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +4 -2
  12. package/hooks/_gh_pr_author_swap_utils.py +1211 -0
  13. package/hooks/blocking/gh_body_arg_blocker.py +9 -6
  14. package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
  15. package/hooks/blocking/gh_pr_author_restore.py +100 -0
  16. package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
  17. package/hooks/blocking/pr_description_enforcer.py +1 -3
  18. package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
  19. package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
  20. package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
  21. package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
  22. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
  23. package/hooks/config/gh_pr_author_swap_constants.py +76 -0
  24. package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
  25. package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
  26. package/hooks/config/pr_description_enforcer_constants.py +5 -0
  27. package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
  28. package/hooks/hooks.json +40 -0
  29. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
  30. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
  31. package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
  32. package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
  33. package/hooks/test__gh_pr_author_swap_utils.py +333 -0
  34. package/package.json +1 -1
  35. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  36. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  37. package/skills/bugteam/reference/audit-contract.md +22 -0
  38. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  39. package/skills/bugteam/scripts/_claude_permissions_common.py +109 -0
  40. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
  41. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +51 -2
  42. package/skills/bugteam/scripts/grant_project_claude_permissions.py +115 -4
  43. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +69 -17
  44. package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
  45. package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
  46. package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
  47. package/skills/implement/SKILL.md +66 -0
  48. package/skills/implement/scripts/append_note.py +133 -0
  49. package/skills/implement/scripts/config/__init__.py +0 -0
  50. package/skills/implement/scripts/config/notes_constants.py +12 -0
  51. package/skills/implement/scripts/test_append_note.py +191 -0
  52. package/skills/pr-converge/SKILL.md +8 -2
  53. package/skills/pr-converge/config/constants.py +7 -1
  54. package/skills/pr-converge/reference/state-schema.md +36 -8
  55. package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
  56. package/skills/pr-converge/scripts/check_convergence.py +167 -28
  57. package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
  58. package/skills/pr-converge/scripts/conftest.py +60 -0
  59. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
  60. package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
  61. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
  62. package/skills/pr-converge/scripts/test_check_convergence.py +306 -0
  63. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +1 -1
  64. package/skills/refine/SKILL.md +257 -0
  65. package/skills/refine/templates/implementation-notes-template.html +56 -0
  66. package/skills/refine/templates/plan-template.md +60 -0
@@ -9,23 +9,42 @@ the changes applied. No-op when the entries already exist.
9
9
  import sys
10
10
  from pathlib import Path
11
11
 
12
- sys.modules.pop("config", None)
13
- if str(Path(__file__).resolve().parent) not in sys.path:
14
- sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+ parent_directory = str(Path(__file__).absolute().parent)
13
+ try:
14
+ sys.path.remove(parent_directory)
15
+ except ValueError:
16
+ pass
17
+ if parent_directory not in sys.path:
18
+ sys.path.insert(0, parent_directory)
19
+
20
+ for each_cached_module_name in [
21
+ each_module_key
22
+ for each_module_key in list(sys.modules)
23
+ if each_module_key == "config"
24
+ or each_module_key.startswith("config.")
25
+ or each_module_key == "_claude_permissions_common"
26
+ ]:
27
+ sys.modules.pop(each_cached_module_name, None)
15
28
 
16
29
  from _claude_permissions_common import ( # noqa: E402
17
30
  append_if_missing,
31
+ build_agent_config_deny_rules,
18
32
  build_permission_rules,
19
33
  ensure_dict_section,
20
34
  ensure_list_entry,
21
35
  exit_with_error,
22
36
  get_current_project_path,
37
+ is_trust_entry_for_project,
23
38
  is_valid_project_root,
24
39
  load_settings,
40
+ remove_matching_entries_from_list,
25
41
  save_settings,
26
42
  )
27
43
  from config.claude_permissions_constants import ( # noqa: E402
44
+ ALL_AGENT_CONFIG_DENY_TOOLS,
45
+ ALL_AGENT_CONFIG_PATH_PATTERNS,
28
46
  ALL_PERMISSION_ALLOW_TOOLS,
47
+ AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX,
29
48
  AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
30
49
  get_claude_user_settings_path,
31
50
  )
@@ -33,6 +52,7 @@ from config.claude_settings_keys_constants import ( # noqa: E402
33
52
  CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
34
53
  CLAUDE_SETTINGS_ALLOW_KEY,
35
54
  CLAUDE_SETTINGS_AUTO_MODE_KEY,
55
+ CLAUDE_SETTINGS_DENY_KEY,
36
56
  CLAUDE_SETTINGS_ENVIRONMENT_KEY,
37
57
  CLAUDE_SETTINGS_PERMISSIONS_KEY,
38
58
  )
@@ -41,6 +61,15 @@ from config.claude_settings_keys_constants import ( # noqa: E402
41
61
  def add_rules_to_allow_list(
42
62
  all_settings: dict[str, object], all_rules_to_add: list[str]
43
63
  ) -> int:
64
+ """Add permission rules to the settings allow list.
65
+
66
+ Args:
67
+ all_settings: The parsed settings dictionary.
68
+ all_rules_to_add: Permission rule strings to append.
69
+
70
+ Returns:
71
+ Number of rules actually added (new entries).
72
+ """
44
73
  permissions_section = ensure_dict_section(
45
74
  all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
46
75
  )
@@ -54,9 +83,47 @@ def add_rules_to_allow_list(
54
83
  )
55
84
 
56
85
 
86
+ def add_rules_to_deny_list(
87
+ all_settings: dict[str, object], all_rules_to_add: list[str]
88
+ ) -> int:
89
+ """Add permission rules to the settings deny list.
90
+
91
+ Deny rules take precedence over allow rules in Claude Code's permission
92
+ matching, so writing agent-config paths into the deny list forces a
93
+ per-edit user approval even when a broader allow rule would cover them.
94
+
95
+ Args:
96
+ all_settings: The parsed settings dictionary.
97
+ all_rules_to_add: Permission rule strings to append.
98
+
99
+ Returns:
100
+ Number of rules actually added (new entries).
101
+ """
102
+ permissions_section = ensure_dict_section(
103
+ all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
104
+ )
105
+ existing_deny_list = ensure_list_entry(
106
+ permissions_section, CLAUDE_SETTINGS_DENY_KEY
107
+ )
108
+ return sum(
109
+ 1
110
+ for each_rule in all_rules_to_add
111
+ if append_if_missing(existing_deny_list, each_rule)
112
+ )
113
+
114
+
57
115
  def add_directory_to_additional_directories(
58
116
  all_settings: dict[str, object], directory_path: str
59
117
  ) -> int:
118
+ """Add a project path to the additionalDirectories allow list.
119
+
120
+ Args:
121
+ all_settings: The parsed settings dictionary.
122
+ directory_path: The project directory path to add.
123
+
124
+ Returns:
125
+ 1 when the entry was added, 0 when it already existed.
126
+ """
60
127
  permissions_section = ensure_dict_section(
61
128
  all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
62
129
  )
@@ -71,6 +138,15 @@ def add_directory_to_additional_directories(
71
138
  def add_auto_mode_environment_entry(
72
139
  all_settings: dict[str, object], entry_text: str
73
140
  ) -> int:
141
+ """Add an auto-mode environment entry for the project.
142
+
143
+ Args:
144
+ all_settings: The parsed settings dictionary.
145
+ entry_text: The environment entry text to add.
146
+
147
+ Returns:
148
+ 1 when the entry was added, 0 when it already existed.
149
+ """
74
150
  auto_mode_section = ensure_dict_section(
75
151
  all_settings, CLAUDE_SETTINGS_AUTO_MODE_KEY
76
152
  )
@@ -82,7 +158,69 @@ def add_auto_mode_environment_entry(
82
158
  return 0
83
159
 
84
160
 
161
+ def purge_stale_trust_entries(
162
+ all_settings: dict[str, object],
163
+ project_path: str,
164
+ prefix: str,
165
+ protected_entry: str | None = None,
166
+ ) -> int:
167
+ """Remove every prior trust entry for the project from autoMode.environment.
168
+
169
+ A trust entry is any string in autoMode.environment whose prefix matches
170
+ the trust-entry marker and that contains the project's .claude/** path.
171
+ Purging stale entries before adding the current template prevents
172
+ accumulation across template revisions. The optional protected_entry
173
+ survives the purge so an entry byte-identical to the one about to be
174
+ re-added is not removed and re-added on every invocation, preserving the
175
+ idempotency contract documented on grant_permissions_for_current_directory.
176
+
177
+ Args:
178
+ all_settings: The parsed settings dictionary.
179
+ project_path: The POSIX-style project root path.
180
+ prefix: The literal prefix that marks a trust entry.
181
+ protected_entry: Optional entry text that, when byte-equal to a
182
+ candidate, prevents removal. Pass the freshly-formatted current
183
+ template entry from grant to preserve idempotency. Revoke passes
184
+ None so every matching entry is removed.
185
+
186
+ Returns:
187
+ Number of stale entries removed.
188
+ """
189
+ auto_mode_section = all_settings.get(CLAUDE_SETTINGS_AUTO_MODE_KEY)
190
+ if not isinstance(auto_mode_section, dict):
191
+ return 0
192
+ existing_environment = auto_mode_section.get(CLAUDE_SETTINGS_ENVIRONMENT_KEY)
193
+ if not isinstance(existing_environment, list):
194
+ return 0
195
+
196
+ def _should_purge_candidate(candidate_entry: object) -> bool:
197
+ if not is_trust_entry_for_project(candidate_entry, project_path, prefix):
198
+ return False
199
+ if protected_entry is not None and candidate_entry == protected_entry:
200
+ return False
201
+ return True
202
+
203
+ return remove_matching_entries_from_list(
204
+ existing_environment,
205
+ _should_purge_candidate,
206
+ )
207
+
208
+
85
209
  def grant_permissions_for_current_directory() -> None:
210
+ """Grant Edit/Write/Read permissions for the current project directory.
211
+
212
+ Reads the current project path, constructs permission rules from config
213
+ constants, and writes them to ~/.claude/settings.json atomically. Adds
214
+ deny rules for agent-config paths so edits to settings, hooks, commands,
215
+ agents, skills, mcp.json, and CLAUDE.md still require per-edit user
216
+ approval. Purges any prior trust entries for this project before writing
217
+ the current template to prevent accumulation across template revisions.
218
+
219
+ Raises:
220
+ SystemExit: When the current directory is not a valid project root.
221
+ ValueError: Propagated from get_current_project_path() when the path
222
+ contains glob metacharacters.
223
+ """
86
224
  claude_user_settings_path: Path = get_claude_user_settings_path()
87
225
  project_root_path = Path.cwd()
88
226
  if not is_valid_project_root(project_root_path):
@@ -96,19 +234,37 @@ def grant_permissions_for_current_directory() -> None:
96
234
  all_permission_rules = build_permission_rules(
97
235
  project_path, ALL_PERMISSION_ALLOW_TOOLS
98
236
  )
237
+ all_agent_config_deny_rules = build_agent_config_deny_rules(
238
+ project_path,
239
+ ALL_AGENT_CONFIG_DENY_TOOLS,
240
+ ALL_AGENT_CONFIG_PATH_PATTERNS,
241
+ )
99
242
  environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
100
243
  project_path=project_path
101
244
  )
102
245
  settings = load_settings(claude_user_settings_path)
103
- rules_added_count = add_rules_to_allow_list(settings, all_permission_rules)
246
+ allow_rules_added_count = add_rules_to_allow_list(settings, all_permission_rules)
247
+ deny_rules_added_count = add_rules_to_deny_list(
248
+ settings, all_agent_config_deny_rules
249
+ )
104
250
  directories_added_count = add_directory_to_additional_directories(
105
251
  settings, project_path
106
252
  )
253
+ stale_trust_entries_purged_count = purge_stale_trust_entries(
254
+ settings,
255
+ project_path,
256
+ AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX,
257
+ protected_entry=environment_entry,
258
+ )
107
259
  environment_entries_added_count = add_auto_mode_environment_entry(
108
260
  settings, environment_entry
109
261
  )
110
262
  total_changes_count = (
111
- rules_added_count + directories_added_count + environment_entries_added_count
263
+ allow_rules_added_count
264
+ + deny_rules_added_count
265
+ + directories_added_count
266
+ + stale_trust_entries_purged_count
267
+ + environment_entries_added_count
112
268
  )
113
269
  if total_changes_count == 0:
114
270
  print(f"Project path: {project_path}")
@@ -118,8 +274,19 @@ def grant_permissions_for_current_directory() -> None:
118
274
  save_settings(claude_user_settings_path, settings)
119
275
  print(f"Project path: {project_path}")
120
276
  print(f"Settings file: {claude_user_settings_path}")
121
- print(f"Allow rules added: {rules_added_count} of {len(all_permission_rules)}")
277
+ print(
278
+ f"Allow rules added: {allow_rules_added_count} of {len(all_permission_rules)}"
279
+ )
280
+ print(
281
+ f"Deny rules added: {deny_rules_added_count} of "
282
+ f"{len(all_agent_config_deny_rules)}"
283
+ )
122
284
  print(f"Additional directories added: {directories_added_count}")
285
+ if stale_trust_entries_purged_count > 0:
286
+ print(
287
+ f"Stale auto-mode environment entries purged: "
288
+ f"{stale_trust_entries_purged_count}"
289
+ )
123
290
  print(f"Auto-mode environment entries added: {environment_entries_added_count}")
124
291
 
125
292
 
@@ -33,8 +33,8 @@ from pathlib import Path
33
33
  from typing import NoReturn
34
34
 
35
35
  sys.modules.pop("config", None)
36
- if str(Path(__file__).resolve().parent) not in sys.path:
37
- sys.path.insert(0, str(Path(__file__).resolve().parent))
36
+ if str(Path(__file__).absolute().parent) not in sys.path:
37
+ sys.path.insert(0, str(Path(__file__).absolute().parent))
38
38
 
39
39
  from config.post_audit_thread_constants import (
40
40
  ALL_GH_API_COMMAND_PARTS,
@@ -10,28 +10,47 @@ autoMode sections so repeated grant/revoke cycles leave no dead structure.
10
10
  import sys
11
11
  from pathlib import Path
12
12
 
13
- sys.modules.pop("config", None)
14
- if str(Path(__file__).resolve().parent) not in sys.path:
15
- sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+ parent_directory = str(Path(__file__).absolute().parent)
14
+ try:
15
+ sys.path.remove(parent_directory)
16
+ except ValueError:
17
+ pass
18
+ if parent_directory not in sys.path:
19
+ sys.path.insert(0, parent_directory)
20
+
21
+ for each_cached_module_name in [
22
+ each_module_key
23
+ for each_module_key in list(sys.modules)
24
+ if each_module_key == "config"
25
+ or each_module_key.startswith("config.")
26
+ or each_module_key == "_claude_permissions_common"
27
+ ]:
28
+ sys.modules.pop(each_cached_module_name, None)
16
29
 
17
30
  from _claude_permissions_common import ( # noqa: E402
31
+ build_agent_config_deny_rules,
18
32
  build_permission_rules,
19
33
  exit_with_error,
20
34
  get_current_project_path,
35
+ is_trust_entry_for_project,
21
36
  is_valid_project_root,
22
37
  load_settings,
23
38
  prune_empty_list_then_empty_section,
39
+ remove_matching_entries_from_list,
24
40
  save_settings,
25
41
  )
26
42
  from config.claude_permissions_constants import ( # noqa: E402
43
+ ALL_AGENT_CONFIG_DENY_TOOLS,
44
+ ALL_AGENT_CONFIG_PATH_PATTERNS,
27
45
  ALL_PERMISSION_ALLOW_TOOLS,
28
- AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
46
+ AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX,
29
47
  get_claude_user_settings_path,
30
48
  )
31
49
  from config.claude_settings_keys_constants import ( # noqa: E402
32
50
  CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
33
51
  CLAUDE_SETTINGS_ALLOW_KEY,
34
52
  CLAUDE_SETTINGS_AUTO_MODE_KEY,
53
+ CLAUDE_SETTINGS_DENY_KEY,
35
54
  CLAUDE_SETTINGS_ENVIRONMENT_KEY,
36
55
  CLAUDE_SETTINGS_PERMISSIONS_KEY,
37
56
  )
@@ -40,6 +59,15 @@ from config.claude_settings_keys_constants import ( # noqa: E402
40
59
  def remove_values_from_list(
41
60
  all_target_list: list[object], all_values_to_remove: set[str]
42
61
  ) -> int:
62
+ """Remove matching values from a list in place.
63
+
64
+ Args:
65
+ all_target_list: The list to remove values from.
66
+ all_values_to_remove: Set of string values to remove.
67
+
68
+ Returns:
69
+ Number of values removed.
70
+ """
43
71
  original_length = len(all_target_list)
44
72
  all_target_list[:] = [
45
73
  each_value
@@ -52,6 +80,15 @@ def remove_values_from_list(
52
80
  def remove_rules_from_allow_list(
53
81
  all_settings: dict[str, object], all_rules_to_remove: list[str]
54
82
  ) -> int:
83
+ """Remove matching permission rules from the settings allow list.
84
+
85
+ Args:
86
+ all_settings: The parsed settings dictionary.
87
+ all_rules_to_remove: Permission rule strings to remove.
88
+
89
+ Returns:
90
+ Number of rules removed.
91
+ """
55
92
  permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
56
93
  if not isinstance(permissions_section, dict):
57
94
  return 0
@@ -61,9 +98,39 @@ def remove_rules_from_allow_list(
61
98
  return remove_values_from_list(existing_allow_list, set(all_rules_to_remove))
62
99
 
63
100
 
101
+ def remove_rules_from_deny_list(
102
+ all_settings: dict[str, object], all_rules_to_remove: list[str]
103
+ ) -> int:
104
+ """Remove matching permission rules from the settings deny list.
105
+
106
+ Args:
107
+ all_settings: The parsed settings dictionary.
108
+ all_rules_to_remove: Permission rule strings to remove.
109
+
110
+ Returns:
111
+ Number of rules removed.
112
+ """
113
+ permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
114
+ if not isinstance(permissions_section, dict):
115
+ return 0
116
+ existing_deny_list = permissions_section.get(CLAUDE_SETTINGS_DENY_KEY)
117
+ if not isinstance(existing_deny_list, list):
118
+ return 0
119
+ return remove_values_from_list(existing_deny_list, set(all_rules_to_remove))
120
+
121
+
64
122
  def remove_directory_from_additional_directories(
65
123
  all_settings: dict[str, object], directory_path: str
66
124
  ) -> int:
125
+ """Remove a project path from the additionalDirectories list.
126
+
127
+ Args:
128
+ all_settings: The parsed settings dictionary.
129
+ directory_path: The project directory path to remove.
130
+
131
+ Returns:
132
+ 1 when the entry was removed, 0 when not found.
133
+ """
67
134
  permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
68
135
  if not isinstance(permissions_section, dict):
69
136
  return 0
@@ -75,24 +142,54 @@ def remove_directory_from_additional_directories(
75
142
  return remove_values_from_list(existing_directories, {directory_path})
76
143
 
77
144
 
78
- def remove_auto_mode_environment_entry(
79
- all_settings: dict[str, object], entry_text: str
145
+ def remove_trust_entries_for_project(
146
+ all_settings: dict[str, object], project_path: str, prefix: str
80
147
  ) -> int:
148
+ """Remove every trust entry for the project from autoMode.environment.
149
+
150
+ Matches any string in autoMode.environment whose prefix matches the
151
+ trust-entry marker and that contains the project's .claude/** path.
152
+ The match is wording-agnostic so prior template revisions are removed
153
+ cleanly even when the current template differs.
154
+
155
+ Args:
156
+ all_settings: The parsed settings dictionary.
157
+ project_path: The POSIX-style project root path.
158
+ prefix: The literal prefix that marks a trust entry.
159
+
160
+ Returns:
161
+ Number of entries removed.
162
+ """
81
163
  auto_mode_section = all_settings.get(CLAUDE_SETTINGS_AUTO_MODE_KEY)
82
164
  if not isinstance(auto_mode_section, dict):
83
165
  return 0
84
166
  existing_environment = auto_mode_section.get(CLAUDE_SETTINGS_ENVIRONMENT_KEY)
85
167
  if not isinstance(existing_environment, list):
86
168
  return 0
87
- return remove_values_from_list(existing_environment, {entry_text})
169
+ return remove_matching_entries_from_list(
170
+ existing_environment,
171
+ lambda candidate_entry: is_trust_entry_for_project(
172
+ candidate_entry, project_path, prefix
173
+ ),
174
+ )
88
175
 
89
176
 
90
177
  def prune_settings_after_revoke(all_settings: dict[str, object]) -> None:
178
+ """Remove empty lists and their parent sections after revoking entries.
179
+
180
+ Args:
181
+ all_settings: The parsed settings dictionary to prune in place.
182
+ """
91
183
  prune_empty_list_then_empty_section(
92
184
  all_settings,
93
185
  CLAUDE_SETTINGS_PERMISSIONS_KEY,
94
186
  CLAUDE_SETTINGS_ALLOW_KEY,
95
187
  )
188
+ prune_empty_list_then_empty_section(
189
+ all_settings,
190
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
191
+ CLAUDE_SETTINGS_DENY_KEY,
192
+ )
96
193
  prune_empty_list_then_empty_section(
97
194
  all_settings,
98
195
  CLAUDE_SETTINGS_PERMISSIONS_KEY,
@@ -106,6 +203,18 @@ def prune_settings_after_revoke(all_settings: dict[str, object]) -> None:
106
203
 
107
204
 
108
205
  def revoke_permissions_for_current_directory() -> None:
206
+ """Revoke permissions previously granted for the current project directory.
207
+
208
+ Reads the current project path, constructs the matching allow and deny
209
+ permission rules, removes them from ~/.claude/settings.json, removes
210
+ every trust entry for the project from autoMode.environment, and prunes
211
+ any newly empty sections.
212
+
213
+ Raises:
214
+ SystemExit: When the current directory is not a valid project root.
215
+ ValueError: Propagated from get_current_project_path() when the path
216
+ contains glob metacharacters.
217
+ """
109
218
  claude_user_settings_path: Path = get_claude_user_settings_path()
110
219
  project_root_path = Path.cwd()
111
220
  if not is_valid_project_root(project_root_path):
@@ -117,19 +226,25 @@ def revoke_permissions_for_current_directory() -> None:
117
226
  raise SystemExit(1)
118
227
  project_path = get_current_project_path()
119
228
  permission_rules = build_permission_rules(project_path, ALL_PERMISSION_ALLOW_TOOLS)
120
- environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
121
- project_path=project_path
229
+ all_agent_config_deny_rules = build_agent_config_deny_rules(
230
+ project_path,
231
+ ALL_AGENT_CONFIG_DENY_TOOLS,
232
+ ALL_AGENT_CONFIG_PATH_PATTERNS,
122
233
  )
123
234
  settings = load_settings(claude_user_settings_path)
124
- rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
235
+ allow_rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
236
+ deny_rules_removed_count = remove_rules_from_deny_list(
237
+ settings, all_agent_config_deny_rules
238
+ )
125
239
  directories_removed_count = remove_directory_from_additional_directories(
126
240
  settings, project_path
127
241
  )
128
- environment_entries_removed_count = remove_auto_mode_environment_entry(
129
- settings, environment_entry
242
+ environment_entries_removed_count = remove_trust_entries_for_project(
243
+ settings, project_path, AUTO_MODE_ENVIRONMENT_ENTRY_PREFIX
130
244
  )
131
245
  total_changes_count = (
132
- rules_removed_count
246
+ allow_rules_removed_count
247
+ + deny_rules_removed_count
133
248
  + directories_removed_count
134
249
  + environment_entries_removed_count
135
250
  )
@@ -142,7 +257,13 @@ def revoke_permissions_for_current_directory() -> None:
142
257
  save_settings(claude_user_settings_path, settings)
143
258
  print(f"Project path: {project_path}")
144
259
  print(f"Settings file: {claude_user_settings_path}")
145
- print(f"Allow rules removed: {rules_removed_count} of {len(permission_rules)}")
260
+ print(
261
+ f"Allow rules removed: {allow_rules_removed_count} of {len(permission_rules)}"
262
+ )
263
+ print(
264
+ f"Deny rules removed: {deny_rules_removed_count} of "
265
+ f"{len(all_agent_config_deny_rules)}"
266
+ )
146
267
  print(f"Additional directories removed: {directories_removed_count}")
147
268
  print(
148
269
  f"Auto-mode environment entries removed: {environment_entries_removed_count}"