claude-dev-env 1.36.1 → 1.36.2

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 (36) hide show
  1. package/_shared/pr-loop/audit-contract.md +159 -0
  2. package/_shared/pr-loop/code-rules-gate.md +64 -0
  3. package/_shared/pr-loop/fix-protocol.md +37 -0
  4. package/_shared/pr-loop/gh-payloads.md +85 -0
  5. package/_shared/pr-loop/scripts/README.md +20 -0
  6. package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
  8. package/_shared/pr-loop/scripts/config/__init__.py +0 -0
  9. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
  10. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
  11. package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
  12. package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
  13. package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
  14. package/_shared/pr-loop/scripts/config/preflight_constants.py +47 -0
  15. package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
  16. package/_shared/pr-loop/scripts/gh_util.py +193 -0
  17. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
  18. package/_shared/pr-loop/scripts/preflight.py +227 -0
  19. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
  20. package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
  21. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
  22. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
  23. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
  24. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
  25. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
  27. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
  28. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
  29. package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
  30. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
  31. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
  32. package/_shared/pr-loop/scripts/tests/test_preflight.py +333 -0
  33. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +82 -0
  34. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
  35. package/_shared/pr-loop/state-schema.md +81 -0
  36. package/package.json +2 -1
@@ -0,0 +1,130 @@
1
+ """Grant Edit/Write/Read permissions on the current directory's .claude tree.
2
+
3
+ Run from the project root whose .claude/** you want a Claude Code session
4
+ (including spawned subagents) to edit without prompting. Writes idempotent
5
+ entries into the user-scope settings at ~/.claude/settings.json and prints
6
+ the changes applied. No-op when the entries already exist.
7
+ """
8
+
9
+ import sys
10
+ from pathlib import Path
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))
15
+
16
+ from _claude_permissions_common import ( # noqa: E402
17
+ append_if_missing,
18
+ build_permission_rules,
19
+ ensure_dict_section,
20
+ ensure_list_entry,
21
+ exit_with_error,
22
+ get_current_project_path,
23
+ is_valid_project_root,
24
+ load_settings,
25
+ save_settings,
26
+ )
27
+ from config.claude_permissions_constants import ( # noqa: E402
28
+ ALL_PERMISSION_ALLOW_TOOLS,
29
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
30
+ get_claude_user_settings_path,
31
+ )
32
+ from config.claude_settings_keys_constants import ( # noqa: E402
33
+ CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
34
+ CLAUDE_SETTINGS_ALLOW_KEY,
35
+ CLAUDE_SETTINGS_AUTO_MODE_KEY,
36
+ CLAUDE_SETTINGS_ENVIRONMENT_KEY,
37
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
38
+ )
39
+
40
+
41
+ def add_rules_to_allow_list(
42
+ all_settings: dict[str, object], all_rules_to_add: list[str]
43
+ ) -> int:
44
+ permissions_section = ensure_dict_section(
45
+ all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
46
+ )
47
+ existing_allow_list = ensure_list_entry(
48
+ permissions_section, CLAUDE_SETTINGS_ALLOW_KEY
49
+ )
50
+ return sum(
51
+ 1
52
+ for each_rule in all_rules_to_add
53
+ if append_if_missing(existing_allow_list, each_rule)
54
+ )
55
+
56
+
57
+ def add_directory_to_additional_directories(
58
+ all_settings: dict[str, object], directory_path: str
59
+ ) -> int:
60
+ permissions_section = ensure_dict_section(
61
+ all_settings, CLAUDE_SETTINGS_PERMISSIONS_KEY
62
+ )
63
+ existing_directories = ensure_list_entry(
64
+ permissions_section, CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY
65
+ )
66
+ if append_if_missing(existing_directories, directory_path):
67
+ return 1
68
+ return 0
69
+
70
+
71
+ def add_auto_mode_environment_entry(
72
+ all_settings: dict[str, object], entry_text: str
73
+ ) -> int:
74
+ auto_mode_section = ensure_dict_section(
75
+ all_settings, CLAUDE_SETTINGS_AUTO_MODE_KEY
76
+ )
77
+ existing_environment = ensure_list_entry(
78
+ auto_mode_section, CLAUDE_SETTINGS_ENVIRONMENT_KEY
79
+ )
80
+ if append_if_missing(existing_environment, entry_text):
81
+ return 1
82
+ return 0
83
+
84
+
85
+ def grant_permissions_for_current_directory() -> None:
86
+ claude_user_settings_path: Path = get_claude_user_settings_path()
87
+ project_root_path = Path.cwd()
88
+ if not is_valid_project_root(project_root_path):
89
+ print(
90
+ f"ERROR: cwd {project_root_path} is not a project root "
91
+ f"(no .git or .claude). Run from a project root.",
92
+ file=sys.stderr,
93
+ )
94
+ raise SystemExit(1)
95
+ project_path = get_current_project_path()
96
+ all_permission_rules = build_permission_rules(
97
+ project_path, ALL_PERMISSION_ALLOW_TOOLS
98
+ )
99
+ environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
100
+ project_path=project_path
101
+ )
102
+ settings = load_settings(claude_user_settings_path)
103
+ rules_added_count = add_rules_to_allow_list(settings, all_permission_rules)
104
+ directories_added_count = add_directory_to_additional_directories(
105
+ settings, project_path
106
+ )
107
+ environment_entries_added_count = add_auto_mode_environment_entry(
108
+ settings, environment_entry
109
+ )
110
+ total_changes_count = (
111
+ rules_added_count + directories_added_count + environment_entries_added_count
112
+ )
113
+ if total_changes_count == 0:
114
+ print(f"Project path: {project_path}")
115
+ print(f"Settings file: {claude_user_settings_path}")
116
+ print("No changes needed; settings file left untouched.")
117
+ return
118
+ save_settings(claude_user_settings_path, settings)
119
+ print(f"Project path: {project_path}")
120
+ print(f"Settings file: {claude_user_settings_path}")
121
+ print(f"Allow rules added: {rules_added_count} of {len(all_permission_rules)}")
122
+ print(f"Additional directories added: {directories_added_count}")
123
+ print(f"Auto-mode environment entries added: {environment_entries_added_count}")
124
+
125
+
126
+ if __name__ == "__main__":
127
+ try:
128
+ grant_permissions_for_current_directory()
129
+ except ValueError as path_error:
130
+ exit_with_error(str(path_error))
@@ -0,0 +1,227 @@
1
+ import argparse
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ sys.modules.pop("config", None)
8
+ if str(Path(__file__).resolve().parent) not in sys.path:
9
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
10
+
11
+ from config.fix_hookspath_constants import HOOKS_PATH_VERIFICATION_SUFFIX
12
+ from config.preflight_constants import (
13
+ ALL_GIT_CONFIG_GET_CORE_HOOKS_PATH_SUBCOMMAND,
14
+ ALL_PRE_COMMIT_RUN_ALL_FILES_COMMAND,
15
+ ALL_TEST_FILE_PATTERNS_FOR_DISCOVERY,
16
+ ALL_TESTS_DIRECTORY_IGNORE_PARTS,
17
+ BUGTEAM_PREFLIGHT_SKIP_ENABLED_VALUE,
18
+ BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME,
19
+ GIT_DIRECTORY_NAME,
20
+ PRE_COMMIT_CONFIG_YAML_FILENAME,
21
+ PYPROJECT_TOML_FILENAME,
22
+ PYTEST_INI_FILENAME,
23
+ PYTEST_NO_TESTS_COLLECTED_EXIT_CODE,
24
+ PYTEST_TOML_TABLE_PREFIX,
25
+ )
26
+
27
+
28
+ def verify_git_hooks_path(repository_root: Path | None = None) -> int:
29
+ """Check that core.hooksPath resolves to the claude-dev-env git-hooks directory.
30
+
31
+ When *repository_root* is provided, queries the effective config for that
32
+ repository (``git -C <root> config --get``), which detects repo-level
33
+ overrides such as Husky or lefthook. Falls back to the current working
34
+ directory's effective config when *repository_root* is None.
35
+
36
+ Returns zero when the configured path ends with the expected hooks suffix.
37
+ Returns non-zero and prints a correction message when unset or pointing elsewhere.
38
+ """
39
+ expected_hooks_path_suffix = HOOKS_PATH_VERIFICATION_SUFFIX
40
+ enforcement_absent_message = (
41
+ "Git-side CODE_RULES enforcement is not active on this host.\n"
42
+ "Run: npx claude-dev-env .\n"
43
+ "Or set core.hooksPath at any scope, e.g.:\n"
44
+ " git config --global core.hooksPath ~/.claude/hooks/git-hooks"
45
+ )
46
+ git_command: list[str] = ["git"]
47
+ if repository_root is not None:
48
+ git_command.extend(["-C", str(repository_root)])
49
+ git_command.extend(list(ALL_GIT_CONFIG_GET_CORE_HOOKS_PATH_SUBCOMMAND))
50
+ try:
51
+ query_result = subprocess.run(
52
+ git_command,
53
+ capture_output=True,
54
+ text=True,
55
+ encoding="utf-8",
56
+ errors="replace",
57
+ check=False,
58
+ )
59
+ except FileNotFoundError:
60
+ print(
61
+ "bugteam_preflight: git is not installed or not available on PATH.\n"
62
+ f"{enforcement_absent_message}",
63
+ file=sys.stderr,
64
+ )
65
+ return 1
66
+ except OSError as os_error:
67
+ print(
68
+ f"bugteam_preflight: failed to run git: {os_error}\n"
69
+ f"{enforcement_absent_message}",
70
+ file=sys.stderr,
71
+ )
72
+ return 1
73
+ if query_result.returncode != 0:
74
+ print(
75
+ f"bugteam_preflight: {enforcement_absent_message}",
76
+ file=sys.stderr,
77
+ )
78
+ return 1
79
+ configured_path = query_result.stdout.strip().replace("\\", "/").rstrip("/")
80
+ if not configured_path.endswith(expected_hooks_path_suffix):
81
+ print(
82
+ f"bugteam_preflight: core.hooksPath is '{configured_path}' — "
83
+ f"expected path ending in '{expected_hooks_path_suffix}'.\n"
84
+ f"{enforcement_absent_message}",
85
+ file=sys.stderr,
86
+ )
87
+ return 1
88
+ return 0
89
+
90
+
91
+ def find_repository_root(start: Path) -> Path:
92
+ resolved = start.resolve()
93
+ all_candidates = [resolved, *resolved.parents]
94
+ for each_candidate in all_candidates:
95
+ git_marker = each_candidate / GIT_DIRECTORY_NAME
96
+ if git_marker.is_dir() or git_marker.is_file():
97
+ return each_candidate
98
+ for each_candidate in all_candidates:
99
+ if (each_candidate / PYTEST_INI_FILENAME).is_file():
100
+ return each_candidate
101
+ return resolved
102
+
103
+
104
+ def has_pytest_configuration(root: Path) -> bool:
105
+ if (root / PYTEST_INI_FILENAME).is_file():
106
+ return True
107
+ pyproject = root / PYPROJECT_TOML_FILENAME
108
+ if not pyproject.is_file():
109
+ return False
110
+ text = pyproject.read_text(encoding="utf-8", errors="replace")
111
+ return PYTEST_TOML_TABLE_PREFIX in text
112
+
113
+
114
+ def has_discoverable_tests(root: Path) -> bool:
115
+ all_ignored_parts = ALL_TESTS_DIRECTORY_IGNORE_PARTS
116
+ test_filename_glob, test_suffix_glob = ALL_TEST_FILE_PATTERNS_FOR_DISCOVERY
117
+ for each_path in root.rglob(test_filename_glob):
118
+ if any(each_part in all_ignored_parts for each_part in each_path.parts):
119
+ continue
120
+ return True
121
+ for each_path in root.rglob(test_suffix_glob):
122
+ if any(each_part in all_ignored_parts for each_part in each_path.parts):
123
+ continue
124
+ return True
125
+ return False
126
+
127
+
128
+ def _pytest_exit_code_no_tests_collected() -> int:
129
+ pytest_no_tests_collected_exit_code = PYTEST_NO_TESTS_COLLECTED_EXIT_CODE
130
+ return pytest_no_tests_collected_exit_code
131
+
132
+
133
+ def run_pytest(repository_root: Path, verbose: bool) -> int:
134
+ command = [sys.executable, "-m", "pytest"]
135
+ if not verbose:
136
+ command.append("-q")
137
+ completed = subprocess.run(
138
+ command,
139
+ cwd=str(repository_root),
140
+ check=False,
141
+ )
142
+ if completed.returncode == _pytest_exit_code_no_tests_collected():
143
+ return 0
144
+ return completed.returncode
145
+
146
+
147
+ def run_pre_commit(repository_root: Path) -> int:
148
+ completed = subprocess.run(
149
+ list(ALL_PRE_COMMIT_RUN_ALL_FILES_COMMAND),
150
+ cwd=str(repository_root),
151
+ check=False,
152
+ )
153
+ return completed.returncode
154
+
155
+
156
+ def parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
157
+ parser = argparse.ArgumentParser(
158
+ description="Run local checks before /bugteam (pytest, optional pre-commit).",
159
+ )
160
+ parser.add_argument(
161
+ "--repo-root",
162
+ type=Path,
163
+ default=None,
164
+ help="Repository root (default: discover from cwd).",
165
+ )
166
+ parser.add_argument(
167
+ "--no-pytest",
168
+ action="store_true",
169
+ help="Skip pytest.",
170
+ )
171
+ parser.add_argument(
172
+ "--pre-commit",
173
+ action="store_true",
174
+ help=f"Run pre-commit when {PRE_COMMIT_CONFIG_YAML_FILENAME} exists.",
175
+ )
176
+ parser.add_argument(
177
+ "-v",
178
+ "--verbose",
179
+ action="store_true",
180
+ help="Verbose pytest output.",
181
+ )
182
+ return parser.parse_args(all_arguments)
183
+
184
+
185
+ def main(all_arguments: list[str]) -> int:
186
+ arguments = parse_arguments(all_arguments)
187
+ skip_env_var_name = BUGTEAM_PREFLIGHT_SKIP_ENV_VAR_NAME
188
+ skip_enabled_value = BUGTEAM_PREFLIGHT_SKIP_ENABLED_VALUE
189
+ if os.environ.get(skip_env_var_name, "").strip() == skip_enabled_value:
190
+ print(
191
+ f"bugteam_preflight: skipped ({skip_env_var_name}={skip_enabled_value}).",
192
+ file=sys.stderr,
193
+ )
194
+ return 0
195
+ start = Path.cwd()
196
+ repository_root = (
197
+ arguments.repo_root.resolve()
198
+ if arguments.repo_root is not None
199
+ else find_repository_root(start)
200
+ )
201
+ hooks_path_exit_code = verify_git_hooks_path(repository_root)
202
+ if hooks_path_exit_code != 0:
203
+ return hooks_path_exit_code
204
+ if not arguments.no_pytest and has_pytest_configuration(repository_root):
205
+ if not has_discoverable_tests(repository_root):
206
+ print(
207
+ "bugteam_preflight: pytest configured but no tests found; skipping pytest.",
208
+ file=sys.stderr,
209
+ )
210
+ else:
211
+ exit_code = run_pytest(repository_root, arguments.verbose)
212
+ if exit_code != 0:
213
+ return exit_code
214
+ elif not arguments.no_pytest:
215
+ print(
216
+ "bugteam_preflight: no pytest configuration found; skipping pytest.",
217
+ file=sys.stderr,
218
+ )
219
+ if arguments.pre_commit and (repository_root / PRE_COMMIT_CONFIG_YAML_FILENAME).is_file():
220
+ exit_code = run_pre_commit(repository_root)
221
+ if exit_code != 0:
222
+ return exit_code
223
+ return 0
224
+
225
+
226
+ if __name__ == "__main__":
227
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,156 @@
1
+ """Revoke the permissions previously granted by grant_project_claude_permissions.
2
+
3
+ Run from the same project root you previously granted. Removes the matching
4
+ allow rules, the additionalDirectories entry, and the autoMode environment
5
+ entry from ~/.claude/settings.json. Safe to run when no prior grant exists.
6
+ After removals, prunes any newly empty lists and their parent permissions or
7
+ autoMode sections so repeated grant/revoke cycles leave no dead structure.
8
+ """
9
+
10
+ import sys
11
+ from pathlib import Path
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))
16
+
17
+ from _claude_permissions_common import ( # noqa: E402
18
+ build_permission_rules,
19
+ exit_with_error,
20
+ get_current_project_path,
21
+ is_valid_project_root,
22
+ load_settings,
23
+ prune_empty_list_then_empty_section,
24
+ save_settings,
25
+ )
26
+ from config.claude_permissions_constants import ( # noqa: E402
27
+ ALL_PERMISSION_ALLOW_TOOLS,
28
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
29
+ get_claude_user_settings_path,
30
+ )
31
+ from config.claude_settings_keys_constants import ( # noqa: E402
32
+ CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
33
+ CLAUDE_SETTINGS_ALLOW_KEY,
34
+ CLAUDE_SETTINGS_AUTO_MODE_KEY,
35
+ CLAUDE_SETTINGS_ENVIRONMENT_KEY,
36
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
37
+ )
38
+
39
+
40
+ def remove_values_from_list(
41
+ all_target_list: list[object], all_values_to_remove: set[str]
42
+ ) -> int:
43
+ original_length = len(all_target_list)
44
+ all_target_list[:] = [
45
+ each_value
46
+ for each_value in all_target_list
47
+ if not (isinstance(each_value, str) and each_value in all_values_to_remove)
48
+ ]
49
+ return original_length - len(all_target_list)
50
+
51
+
52
+ def remove_rules_from_allow_list(
53
+ all_settings: dict[str, object], all_rules_to_remove: list[str]
54
+ ) -> int:
55
+ permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
56
+ if not isinstance(permissions_section, dict):
57
+ return 0
58
+ existing_allow_list = permissions_section.get(CLAUDE_SETTINGS_ALLOW_KEY)
59
+ if not isinstance(existing_allow_list, list):
60
+ return 0
61
+ return remove_values_from_list(existing_allow_list, set(all_rules_to_remove))
62
+
63
+
64
+ def remove_directory_from_additional_directories(
65
+ all_settings: dict[str, object], directory_path: str
66
+ ) -> int:
67
+ permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
68
+ if not isinstance(permissions_section, dict):
69
+ return 0
70
+ existing_directories = permissions_section.get(
71
+ CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY
72
+ )
73
+ if not isinstance(existing_directories, list):
74
+ return 0
75
+ return remove_values_from_list(existing_directories, {directory_path})
76
+
77
+
78
+ def remove_auto_mode_environment_entry(
79
+ all_settings: dict[str, object], entry_text: str
80
+ ) -> int:
81
+ auto_mode_section = all_settings.get(CLAUDE_SETTINGS_AUTO_MODE_KEY)
82
+ if not isinstance(auto_mode_section, dict):
83
+ return 0
84
+ existing_environment = auto_mode_section.get(CLAUDE_SETTINGS_ENVIRONMENT_KEY)
85
+ if not isinstance(existing_environment, list):
86
+ return 0
87
+ return remove_values_from_list(existing_environment, {entry_text})
88
+
89
+
90
+ def prune_settings_after_revoke(all_settings: dict[str, object]) -> None:
91
+ prune_empty_list_then_empty_section(
92
+ all_settings,
93
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
94
+ CLAUDE_SETTINGS_ALLOW_KEY,
95
+ )
96
+ prune_empty_list_then_empty_section(
97
+ all_settings,
98
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
99
+ CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
100
+ )
101
+ prune_empty_list_then_empty_section(
102
+ all_settings,
103
+ CLAUDE_SETTINGS_AUTO_MODE_KEY,
104
+ CLAUDE_SETTINGS_ENVIRONMENT_KEY,
105
+ )
106
+
107
+
108
+ def revoke_permissions_for_current_directory() -> None:
109
+ claude_user_settings_path: Path = get_claude_user_settings_path()
110
+ project_root_path = Path.cwd()
111
+ if not is_valid_project_root(project_root_path):
112
+ print(
113
+ f"ERROR: cwd {project_root_path} is not a project root "
114
+ f"(no .git or .claude). Run from a project root.",
115
+ file=sys.stderr,
116
+ )
117
+ raise SystemExit(1)
118
+ project_path = get_current_project_path()
119
+ 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
122
+ )
123
+ settings = load_settings(claude_user_settings_path)
124
+ rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
125
+ directories_removed_count = remove_directory_from_additional_directories(
126
+ settings, project_path
127
+ )
128
+ environment_entries_removed_count = remove_auto_mode_environment_entry(
129
+ settings, environment_entry
130
+ )
131
+ total_changes_count = (
132
+ rules_removed_count
133
+ + directories_removed_count
134
+ + environment_entries_removed_count
135
+ )
136
+ if total_changes_count == 0:
137
+ print(f"Project path: {project_path}")
138
+ print(f"Settings file: {claude_user_settings_path}")
139
+ print("No changes to revoke; settings file left untouched.")
140
+ return
141
+ prune_settings_after_revoke(settings)
142
+ save_settings(claude_user_settings_path, settings)
143
+ print(f"Project path: {project_path}")
144
+ print(f"Settings file: {claude_user_settings_path}")
145
+ print(f"Allow rules removed: {rules_removed_count} of {len(permission_rules)}")
146
+ print(f"Additional directories removed: {directories_removed_count}")
147
+ print(
148
+ f"Auto-mode environment entries removed: {environment_entries_removed_count}"
149
+ )
150
+
151
+
152
+ if __name__ == "__main__":
153
+ try:
154
+ revoke_permissions_for_current_directory()
155
+ except ValueError as path_error:
156
+ exit_with_error(str(path_error))
@@ -0,0 +1,51 @@
1
+ """Test fixtures for _shared/pr-loop/scripts/.
2
+
3
+ Two unrelated Python packages live under the name ``config`` in this repo:
4
+ - ``_shared/pr-loop/scripts/config/`` (constants for grant/revoke/gate/preflight scripts)
5
+ - ``hooks/config/`` (constants for the code-rules enforcer and other hooks)
6
+
7
+ When tests under this directory exercise the gate (which loads
8
+ ``hooks/blocking/code_rules_enforcer.py``) and also load the grant/revoke
9
+ scripts in the same pytest process, ``sys.modules['config']`` and
10
+ ``sys.modules['config.<submodule>']`` cache entries from one package leak
11
+ into the other. The next ``from config.<submodule> import ...`` then fails
12
+ with ``ModuleNotFoundError`` because the cached parent package does not
13
+ expose that submodule.
14
+
15
+ Independently, several scripts in this folder do
16
+ ``Path(__file__).resolve()`` then prepend the resulting directory to
17
+ ``sys.path``. On Windows when the working tree lives under a mapped drive
18
+ backed by a UNC share (``Y:`` -> ``\\\\server\\share\\...``), ``.resolve()``
19
+ returns the UNC form, and Python's import machinery on this host cannot
20
+ locate ``config`` packages from a UNC ``sys.path`` entry. The Y:-form entry
21
+ gets pushed to a later index by subsequent inserts, making ``from
22
+ config.<submodule> import ...`` fail.
23
+
24
+ This autouse fixture restores both invariants before each test:
25
+ 1. evict every ``config`` and ``config.*`` entry from ``sys.modules``
26
+ 2. prepend the drive-letter (``.absolute()``) form of the scripts
27
+ directory to ``sys.path`` so package resolution always has a
28
+ non-UNC path to search first
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ import pytest
37
+
38
+ SCRIPTS_DIRECTORY_DRIVE_LETTER_FORM = str(Path(__file__).absolute().parent.parent)
39
+
40
+
41
+ @pytest.fixture(autouse=True)
42
+ def _evict_config_namespace_between_tests() -> None:
43
+ for each_module_name in [
44
+ each_key
45
+ for each_key in list(sys.modules)
46
+ if each_key == "config" or each_key.startswith("config.")
47
+ ]:
48
+ sys.modules.pop(each_module_name, None)
49
+ if SCRIPTS_DIRECTORY_DRIVE_LETTER_FORM in sys.path:
50
+ sys.path.remove(SCRIPTS_DIRECTORY_DRIVE_LETTER_FORM)
51
+ sys.path.insert(0, SCRIPTS_DIRECTORY_DRIVE_LETTER_FORM)
@@ -0,0 +1,135 @@
1
+ """Regression tests for the leading-underscore _claude_permissions_common module.
2
+
3
+ The companion test_claude_permissions_common.py covers the public-name
4
+ matched suite. This file holds tests the TDD enforcer pairs to the
5
+ leading-underscore production filename.
6
+ """
7
+
8
+ import importlib.util
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+ from types import ModuleType
13
+
14
+ import pytest
15
+
16
+
17
+ def _load_common_module() -> ModuleType:
18
+ module_path = Path(__file__).parent.parent / "_claude_permissions_common.py"
19
+ parent_directory = str(module_path.parent.resolve())
20
+ if parent_directory not in sys.path:
21
+ sys.path.insert(0, parent_directory)
22
+ spec = importlib.util.spec_from_file_location(
23
+ "_claude_permissions_common", module_path
24
+ )
25
+ assert spec is not None
26
+ assert spec.loader is not None
27
+ module = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(module)
29
+ return module
30
+
31
+
32
+ common = _load_common_module()
33
+
34
+
35
+ def test_save_settings_chmods_after_replace_to_defeat_umask(
36
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
37
+ ) -> None:
38
+ """Regression: POSIX umask masks the mode passed to os.open, so the
39
+ "preserve mode" intent is silently defeated unless save_settings
40
+ chmods the final file after os.replace.
41
+ """
42
+ settings_path = tmp_path / "settings.json"
43
+ settings_path.write_text(json.dumps({}), encoding="utf-8")
44
+ captured_mode_to_preserve = 0o600
45
+ monkeypatch.setattr(
46
+ common, "get_mode_to_preserve", lambda _path: captured_mode_to_preserve
47
+ )
48
+
49
+ ordered_calls: list[tuple[str, str, int | None]] = []
50
+ real_replace = common.os.replace
51
+ real_chmod = common.os.chmod
52
+
53
+ def recording_replace(source_path: str, destination_path: str) -> None:
54
+ ordered_calls.append(("replace", str(destination_path), None))
55
+ real_replace(source_path, destination_path)
56
+
57
+ def recording_chmod(target_path: str, file_mode: int) -> None:
58
+ ordered_calls.append(("chmod", str(target_path), file_mode))
59
+ real_chmod(target_path, file_mode)
60
+
61
+ monkeypatch.setattr(common.os, "replace", recording_replace)
62
+ monkeypatch.setattr(common.os, "chmod", recording_chmod)
63
+
64
+ common.save_settings(settings_path, {"writer": "only"})
65
+
66
+ final_replace_index = next(
67
+ each_index
68
+ for each_index, each_call in enumerate(ordered_calls)
69
+ if each_call[0] == "replace" and each_call[1] == str(settings_path)
70
+ )
71
+ final_chmod_index = next(
72
+ (
73
+ each_index
74
+ for each_index, each_call in enumerate(ordered_calls)
75
+ if each_call[0] == "chmod"
76
+ and each_call[1] == str(settings_path)
77
+ and each_call[2] == captured_mode_to_preserve
78
+ ),
79
+ None,
80
+ )
81
+ assert final_chmod_index is not None
82
+ assert final_replace_index < final_chmod_index
83
+
84
+
85
+ def test_path_contains_glob_metacharacters_rejects_true_metacharacters() -> None:
86
+ assert common.path_contains_glob_metacharacters("C:/some/path/*.py") is True
87
+ assert common.path_contains_glob_metacharacters("C:/some/path/[abc].py") is True
88
+ assert common.path_contains_glob_metacharacters("C:/some/{a,b}/file") is True
89
+
90
+
91
+
92
+
93
+ def test_write_atomically_with_mode_closes_descriptor_when_fdopen_raises(
94
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
95
+ ) -> None:
96
+ """Regression: if os.fdopen fails, the raw descriptor from os.open must close.
97
+
98
+ Cursor Bugbot: without a failure path, the descriptor
99
+ leaks when fdopen raises before the file object assumes ownership.
100
+ """
101
+ closed_descriptors: list[int] = []
102
+ sentinel_descriptor = 91
103
+
104
+ def fake_open(_path: str, _flags: int, _mode: int = 0o777) -> int:
105
+ return sentinel_descriptor
106
+
107
+ def fake_fdopen(_file_descriptor: int, *_args: object, **_kwargs: object) -> object:
108
+ raise OSError("simulated fdopen failure")
109
+
110
+ def recording_close(file_descriptor: int) -> None:
111
+ closed_descriptors.append(file_descriptor)
112
+
113
+ monkeypatch.setattr(common.os, "open", fake_open)
114
+ monkeypatch.setattr(common.os, "fdopen", fake_fdopen)
115
+ monkeypatch.setattr(common.os, "close", recording_close)
116
+
117
+ temporary_path = tmp_path / "tempfile.tmp"
118
+ with pytest.raises(OSError, match="simulated fdopen failure"):
119
+ common.write_atomically_with_mode(temporary_path, "{}", 0o600)
120
+
121
+ assert closed_descriptors == [sentinel_descriptor]
122
+
123
+
124
+ def test_path_contains_glob_metacharacters_accepts_windows_paths_with_parens() -> None:
125
+ """Regression: Windows paths like C:/Program Files (x86)/ must not raise ValueError.
126
+
127
+ `(`, `)`, and `,` are not glob metacharacters in Claude Code's permission
128
+ rule matching. Including them in the metacharacter set causes
129
+ get_current_project_path to raise ValueError for any user whose home
130
+ directory contains parentheses (e.g. `C:/Users/Jon (Admin)/...`).
131
+ """
132
+ assert common.path_contains_glob_metacharacters("C:/Program Files (x86)/app") is False
133
+ assert common.path_contains_glob_metacharacters("C:/Users/Jon (Admin)/project") is False
134
+ assert common.path_contains_glob_metacharacters("C:/Projects/a,b/file.py") is False
135
+