claude-dev-env 1.36.1 → 1.37.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/_shared/pr-loop/audit-contract.md +159 -0
- package/_shared/pr-loop/code-rules-gate.md +64 -0
- package/_shared/pr-loop/fix-protocol.md +37 -0
- package/_shared/pr-loop/gh-payloads.md +85 -0
- package/_shared/pr-loop/scripts/README.md +20 -0
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
- package/_shared/pr-loop/scripts/config/__init__.py +0 -0
- package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
- package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
- package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
- package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
- package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
- package/_shared/pr-loop/scripts/config/preflight_constants.py +68 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
- package/_shared/pr-loop/scripts/gh_util.py +193 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
- package/_shared/pr-loop/scripts/preflight.py +449 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
- package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
- package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
- package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/scripts/tests/test_preflight.py +670 -0
- package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +77 -0
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
- package/_shared/pr-loop/state-schema.md +81 -0
- package/hooks/blocking/code_rules_enforcer.py +269 -23
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +157 -1
- package/hooks/config/test_unused_module_import_constants.py +48 -0
- package/hooks/config/unused_module_import_constants.py +41 -0
- package/package.json +2 -1
- package/skills/bg-agent/SKILL.md +69 -0
- package/skills/bugteam/CONSTRAINTS.md +10 -19
- package/skills/bugteam/PROMPTS.md +3 -3
- package/skills/bugteam/SKILL.md +103 -202
- package/skills/bugteam/SKILL_EVALS.md +75 -114
- package/skills/bugteam/reference/README.md +2 -4
- package/skills/bugteam/reference/design-rationale.md +3 -8
- package/skills/bugteam/reference/team-setup.md +11 -19
- package/skills/bugteam/reference/teardown-publish-permissions.md +2 -14
- package/skills/bugteam/scripts/config/__init__.py +0 -0
- package/skills/bugteam/scripts/config/reflow_skill_md_constants.py +12 -0
- package/skills/bugteam/scripts/reflow_skill_md.py +51 -47
- package/skills/bugteam/sources.md +1 -25
- package/skills/bugteam/test_skill_additions.py +4 -13
- package/skills/fresh-branch/SKILL.md +71 -0
- package/skills/gotcha/SKILL.md +73 -0
- package/skills/monitor-open-prs/SKILL.md +4 -37
- package/skills/monitor-open-prs/test_skill_contract.py +0 -5
- package/skills/pr-converge/SKILL.md +60 -1298
- package/skills/pr-converge/reference/convergence-gates.md +118 -0
- package/skills/pr-converge/reference/examples.md +76 -0
- package/skills/pr-converge/reference/fix-protocol.md +54 -0
- package/skills/pr-converge/reference/ground-rules.md +13 -0
- package/skills/pr-converge/reference/multi-pr-orchestration.md +204 -0
- package/skills/pr-converge/reference/per-tick.md +201 -0
- package/skills/pr-converge/reference/state-schema.md +19 -0
- package/skills/pr-converge/reference/stop-conditions.md +26 -0
- package/skills/pr-converge/scripts/README.md +36 -9
- package/skills/pr-converge/scripts/check_pr_mergeability.py +1 -2
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +58 -5
- package/skills/pr-converge/scripts/config/reflow_skill_md_constants.py +13 -0
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -24
- package/skills/pr-converge/scripts/cursor-agents-continue.ahk +22 -2
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +19 -59
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +15 -61
- package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +70 -0
- package/skills/pr-converge/scripts/fetch_claude_reviews.py +61 -0
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +19 -61
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +14 -74
- package/skills/pr-converge/scripts/reflow_skill_md.py +71 -50
- package/skills/pr-converge/scripts/reviewer_fetch_core.py +153 -0
- package/skills/pr-converge/scripts/reviewer_specs.py +98 -0
- package/skills/pr-converge/scripts/test_cursor_agents_continue.py +65 -0
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +107 -6
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +85 -6
- package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +485 -0
- package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +368 -0
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +74 -6
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +94 -8
- package/skills/pr-converge/scripts/test_reflow_skill_md.py +162 -0
- package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +448 -0
- package/skills/pr-converge/scripts/test_reviewer_specs.py +107 -0
- package/skills/pr-converge/workflows/schedule-wakeup-loop.md +24 -22
- package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +0 -113
- package/skills/bugteam/reference/workflow-path-b-task-harness.md +0 -48
- package/skills/bugteam/test_team_lifecycle.py +0 -103
- package/skills/monitor-open-prs/test_team_lifecycle.py +0 -46
- package/skills/pr-converge/scripts/open_followup_copilot_pr.py +0 -136
- package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +0 -236
- package/skills/pr-converge/test_team_lifecycle.py +0 -56
- package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +0 -108
|
@@ -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
|
+
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Tests for _shared permission helpers extracted from skills/bugteam/scripts/."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_common_module() -> ModuleType:
|
|
14
|
+
module_path = Path(__file__).parent.parent / "_claude_permissions_common.py"
|
|
15
|
+
parent_directory = str(module_path.parent.resolve())
|
|
16
|
+
if parent_directory not in sys.path:
|
|
17
|
+
sys.path.insert(0, parent_directory)
|
|
18
|
+
spec = importlib.util.spec_from_file_location(
|
|
19
|
+
"_claude_permissions_common", module_path
|
|
20
|
+
)
|
|
21
|
+
assert spec is not None
|
|
22
|
+
assert spec.loader is not None
|
|
23
|
+
module = importlib.util.module_from_spec(spec)
|
|
24
|
+
spec.loader.exec_module(module)
|
|
25
|
+
return module
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
common = _load_common_module()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_return_normalized_path_when_cwd_contains_spaces(
|
|
32
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
33
|
+
) -> None:
|
|
34
|
+
directory_with_spaces = tmp_path / "dir with spaces"
|
|
35
|
+
directory_with_spaces.mkdir()
|
|
36
|
+
monkeypatch.chdir(directory_with_spaces)
|
|
37
|
+
returned_project_path = common.get_current_project_path()
|
|
38
|
+
expected_suffix = "/dir with spaces"
|
|
39
|
+
assert returned_project_path.endswith(expected_suffix)
|
|
40
|
+
assert "\\" not in returned_project_path
|
|
41
|
+
built_rule = common.build_permission_rule("Edit", returned_project_path)
|
|
42
|
+
assert built_rule.startswith("Edit(")
|
|
43
|
+
assert built_rule.endswith("/.claude/**)")
|
|
44
|
+
assert "dir with spaces" in built_rule
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_raise_when_cwd_contains_glob_metacharacters(
|
|
48
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
49
|
+
) -> None:
|
|
50
|
+
directory_with_star = tmp_path / "weird[dir]"
|
|
51
|
+
directory_with_star.mkdir()
|
|
52
|
+
monkeypatch.chdir(directory_with_star)
|
|
53
|
+
with pytest.raises(ValueError, match="glob metacharacters"):
|
|
54
|
+
common.get_current_project_path()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_flag_glob_metacharacters_in_any_position() -> None:
|
|
58
|
+
assert common.path_contains_glob_metacharacters("/home/user/[dir]/project")
|
|
59
|
+
assert common.path_contains_glob_metacharacters("/home/user/project*")
|
|
60
|
+
assert not common.path_contains_glob_metacharacters("/home/user/dir with spaces")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_text_file_encoding_remains_local_constant() -> None:
|
|
64
|
+
assert common.TEXT_FILE_ENCODING == "utf-8"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_module_no_longer_redeclares_migrated_constants() -> None:
|
|
68
|
+
assert not hasattr(common, "ALL_PERMISSION_ALLOW_TOOLS")
|
|
69
|
+
assert not hasattr(common, "AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_save_settings_uses_unique_per_call_temp_suffix(
|
|
73
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Regression for atomic-write race: each save_settings call must
|
|
76
|
+
derive its own unique temp path so concurrent writers do not collide
|
|
77
|
+
on the same `.tmp` filename.
|
|
78
|
+
"""
|
|
79
|
+
settings_path = tmp_path / "settings.json"
|
|
80
|
+
settings_path.write_text(json.dumps({}), encoding="utf-8")
|
|
81
|
+
|
|
82
|
+
captured_temp_paths: list[Path] = []
|
|
83
|
+
real_write_atomically_with_mode = common.write_atomically_with_mode
|
|
84
|
+
|
|
85
|
+
def capturing_write(
|
|
86
|
+
temporary_path: Path, serialized_content: str, file_mode: int
|
|
87
|
+
) -> None:
|
|
88
|
+
captured_temp_paths.append(Path(temporary_path))
|
|
89
|
+
real_write_atomically_with_mode(
|
|
90
|
+
temporary_path, serialized_content, file_mode
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
monkeypatch.setattr(common, "write_atomically_with_mode", capturing_write)
|
|
94
|
+
|
|
95
|
+
common.save_settings(settings_path, {"writer": "first"})
|
|
96
|
+
common.save_settings(settings_path, {"writer": "second"})
|
|
97
|
+
|
|
98
|
+
assert len(captured_temp_paths) == 2
|
|
99
|
+
assert captured_temp_paths[0] != captured_temp_paths[1]
|
|
100
|
+
for each_temp_path in captured_temp_paths:
|
|
101
|
+
assert ".tmp." in each_temp_path.name
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_save_settings_temp_suffix_includes_pid_and_random_token(
|
|
105
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
106
|
+
) -> None:
|
|
107
|
+
"""The unique temp suffix must include the process id and a random
|
|
108
|
+
hex token so two processes cannot collide on the same temp filename.
|
|
109
|
+
"""
|
|
110
|
+
settings_path = tmp_path / "settings.json"
|
|
111
|
+
settings_path.write_text(json.dumps({}), encoding="utf-8")
|
|
112
|
+
|
|
113
|
+
captured_temp_paths: list[Path] = []
|
|
114
|
+
real_write_atomically_with_mode = common.write_atomically_with_mode
|
|
115
|
+
|
|
116
|
+
def capturing_write(
|
|
117
|
+
temporary_path: Path, serialized_content: str, file_mode: int
|
|
118
|
+
) -> None:
|
|
119
|
+
captured_temp_paths.append(Path(temporary_path))
|
|
120
|
+
real_write_atomically_with_mode(
|
|
121
|
+
temporary_path, serialized_content, file_mode
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
monkeypatch.setattr(common, "write_atomically_with_mode", capturing_write)
|
|
125
|
+
|
|
126
|
+
common.save_settings(settings_path, {"writer": "only"})
|
|
127
|
+
|
|
128
|
+
assert len(captured_temp_paths) == 1
|
|
129
|
+
suffix_token = captured_temp_paths[0].name.split(".tmp.", maxsplit=1)[1]
|
|
130
|
+
pid_text, random_token = suffix_token.split(".", maxsplit=1)
|
|
131
|
+
assert pid_text.isdigit()
|
|
132
|
+
minimum_random_token_hex_chars = 4
|
|
133
|
+
assert len(random_token) >= minimum_random_token_hex_chars
|
|
134
|
+
assert all(
|
|
135
|
+
each_character in "0123456789abcdef" for each_character in random_token
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_text_file_encoding_sourced_from_config() -> None:
|
|
140
|
+
config_module_path = (
|
|
141
|
+
Path(__file__).parent.parent / "config" / "claude_permissions_constants.py"
|
|
142
|
+
)
|
|
143
|
+
specification = importlib.util.spec_from_file_location(
|
|
144
|
+
"config.claude_permissions_constants", config_module_path
|
|
145
|
+
)
|
|
146
|
+
assert specification is not None
|
|
147
|
+
assert specification.loader is not None
|
|
148
|
+
config_module = importlib.util.module_from_spec(specification)
|
|
149
|
+
specification.loader.exec_module(config_module)
|
|
150
|
+
assert common.TEXT_FILE_ENCODING == config_module.TEXT_FILE_ENCODING
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_path_contains_glob_metacharacters_local_tuple_uses_all_collection_prefix() -> None:
|
|
154
|
+
source_text = inspect.getsource(common.path_contains_glob_metacharacters)
|
|
155
|
+
assert "all_glob_metacharacters_in_path" in source_text
|
|
156
|
+
assert "glob_metacharacters_in_path:" not in source_text.replace(
|
|
157
|
+
"all_glob_metacharacters_in_path", ""
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_is_valid_project_root_uses_extracted_directory_marker_constants() -> None:
|
|
162
|
+
"""is_valid_project_root must reference extracted constants, not inline string literals."""
|
|
163
|
+
source_text = inspect.getsource(common.is_valid_project_root)
|
|
164
|
+
assert "GIT_DIRECTORY_NAME" in source_text
|
|
165
|
+
assert "CLAUDE_SETTINGS_DIRECTORY_NAME" in source_text
|
|
166
|
+
assert "'.git'" not in source_text
|
|
167
|
+
assert '".git"' not in source_text
|
|
168
|
+
assert "'.claude'" not in source_text
|
|
169
|
+
assert '".claude"' not in source_text
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Tests for shared constants powering grant/revoke claude permissions."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _load_constants_module() -> ModuleType:
|
|
9
|
+
module_path = (
|
|
10
|
+
Path(__file__).parent.parent / "config" / "claude_permissions_constants.py"
|
|
11
|
+
)
|
|
12
|
+
specification = importlib.util.spec_from_file_location(
|
|
13
|
+
"config.claude_permissions_constants", module_path
|
|
14
|
+
)
|
|
15
|
+
assert specification is not None
|
|
16
|
+
assert specification.loader is not None
|
|
17
|
+
module = importlib.util.module_from_spec(specification)
|
|
18
|
+
specification.loader.exec_module(module)
|
|
19
|
+
return module
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
constants_module = _load_constants_module()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_exposes_all_permission_allow_tools_tuple() -> None:
|
|
26
|
+
assert constants_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Write", "Read")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_auto_mode_environment_entry_template_is_format_string() -> None:
|
|
30
|
+
rendered_template_text = (
|
|
31
|
+
constants_module.AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
|
|
32
|
+
project_path="/tmp/x"
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
assert "/tmp/x" in rendered_template_text
|
|
36
|
+
assert ".claude/**" in rendered_template_text
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_get_claude_user_settings_path_ends_in_settings_json() -> None:
|
|
40
|
+
resolved_settings_path = constants_module.get_claude_user_settings_path()
|
|
41
|
+
assert resolved_settings_path.name == constants_module.CLAUDE_SETTINGS_FILENAME
|
|
42
|
+
assert (
|
|
43
|
+
resolved_settings_path.parent.name
|
|
44
|
+
== constants_module.CLAUDE_SETTINGS_DIRECTORY_NAME
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_text_file_encoding_lives_in_config() -> None:
|
|
49
|
+
assert constants_module.TEXT_FILE_ENCODING == "utf-8"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_unique_temporary_suffix_byte_length_is_positive_integer() -> None:
|
|
53
|
+
assert isinstance(constants_module.UNIQUE_TEMPORARY_SUFFIX_BYTE_LENGTH, int)
|
|
54
|
+
assert constants_module.UNIQUE_TEMPORARY_SUFFIX_BYTE_LENGTH > 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_git_directory_name_lives_in_config() -> None:
|
|
58
|
+
assert constants_module.GIT_DIRECTORY_NAME == ".git"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Tests for claude_settings_keys_constants.py extracted constant set."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _load_constants_module() -> ModuleType:
|
|
9
|
+
module_path = (
|
|
10
|
+
Path(__file__).parent.parent / "config" / "claude_settings_keys_constants.py"
|
|
11
|
+
)
|
|
12
|
+
specification = importlib.util.spec_from_file_location(
|
|
13
|
+
"config.claude_settings_keys_constants", module_path
|
|
14
|
+
)
|
|
15
|
+
assert specification is not None
|
|
16
|
+
assert specification.loader is not None
|
|
17
|
+
module = importlib.util.module_from_spec(specification)
|
|
18
|
+
specification.loader.exec_module(module)
|
|
19
|
+
return module
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
constants_module = _load_constants_module()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_permissions_key_is_typed_string() -> None:
|
|
26
|
+
assert isinstance(constants_module.CLAUDE_SETTINGS_PERMISSIONS_KEY, str)
|
|
27
|
+
assert constants_module.CLAUDE_SETTINGS_PERMISSIONS_KEY == "permissions"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_allow_key_is_typed_string() -> None:
|
|
31
|
+
assert isinstance(constants_module.CLAUDE_SETTINGS_ALLOW_KEY, str)
|
|
32
|
+
assert constants_module.CLAUDE_SETTINGS_ALLOW_KEY == "allow"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_additional_directories_key_is_typed_string() -> None:
|
|
36
|
+
assert isinstance(constants_module.CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY, str)
|
|
37
|
+
assert (
|
|
38
|
+
constants_module.CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY
|
|
39
|
+
== "additionalDirectories"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_auto_mode_key_is_typed_string() -> None:
|
|
44
|
+
assert isinstance(constants_module.CLAUDE_SETTINGS_AUTO_MODE_KEY, str)
|
|
45
|
+
assert constants_module.CLAUDE_SETTINGS_AUTO_MODE_KEY == "autoMode"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_environment_key_is_typed_string() -> None:
|
|
49
|
+
assert isinstance(constants_module.CLAUDE_SETTINGS_ENVIRONMENT_KEY, str)
|
|
50
|
+
assert constants_module.CLAUDE_SETTINGS_ENVIRONMENT_KEY == "environment"
|