claude-dev-env 1.21.2 → 1.22.1
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/package.json +1 -1
- package/skills/bugteam/SKILL.md +530 -0
- package/skills/bugteam/_claude_permissions_common.py +224 -0
- package/skills/bugteam/grant_project_claude_permissions.py +110 -0
- package/skills/bugteam/revoke_project_claude_permissions.py +136 -0
- package/skills/findbugs/SKILL.md +203 -0
- package/skills/fixbugs/SKILL.md +143 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Shared helpers for grant_project_claude_permissions and revoke_project_claude_permissions.
|
|
2
|
+
|
|
3
|
+
Writes to ~/.claude/settings.json are atomic and permission-preserving: the
|
|
4
|
+
target file's existing POSIX mode is captured, a sibling temp file is
|
|
5
|
+
created via os.open with O_CREAT | O_EXCL and the preserved mode, content
|
|
6
|
+
is written, then os.replace swaps it into place. Output is serialized with
|
|
7
|
+
sort_keys=True for a stable on-disk layout; the first run on a hand-ordered
|
|
8
|
+
settings file produces a one-time re-sort diff, subsequent writes are stable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, NoReturn
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
TEXT_FILE_ENCODING: str = "utf-8"
|
|
20
|
+
GLOB_METACHARACTERS_IN_PATH: tuple[str, ...] = (
|
|
21
|
+
"*",
|
|
22
|
+
"?",
|
|
23
|
+
"[",
|
|
24
|
+
"]",
|
|
25
|
+
"(",
|
|
26
|
+
")",
|
|
27
|
+
"{",
|
|
28
|
+
"}",
|
|
29
|
+
",",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
JSON_INDENT_SPACES: int = 2
|
|
33
|
+
PERMISSION_ALLOW_TOOLS: tuple[str, ...] = ("Edit", "Write", "Read")
|
|
34
|
+
|
|
35
|
+
DEFAULT_SETTINGS_FILE_MODE: int = 0o600
|
|
36
|
+
ATOMIC_WRITE_TEMPORARY_SUFFIX: str = ".tmp"
|
|
37
|
+
|
|
38
|
+
AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE: str = (
|
|
39
|
+
"Trusted local workspace: {project_path}/.claude/** is the user's "
|
|
40
|
+
"project Claude Code config tree; edits inside are routine"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def exit_with_error(message: str) -> NoReturn:
|
|
45
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
46
|
+
raise SystemExit(1)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def path_contains_glob_metacharacters(candidate_path: str) -> bool:
|
|
50
|
+
return any(
|
|
51
|
+
each_character in candidate_path
|
|
52
|
+
for each_character in GLOB_METACHARACTERS_IN_PATH
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def path_contains_whitespace(candidate_path: str) -> bool:
|
|
57
|
+
return any(each_character.isspace() for each_character in candidate_path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_current_project_path() -> str:
|
|
61
|
+
normalized_project_path = str(Path.cwd()).replace("\\", "/")
|
|
62
|
+
if path_contains_glob_metacharacters(normalized_project_path):
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"Current directory path contains glob metacharacters and cannot "
|
|
65
|
+
f"be used to build permission rules safely: {normalized_project_path}"
|
|
66
|
+
)
|
|
67
|
+
if path_contains_whitespace(normalized_project_path):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Current directory path contains whitespace and cannot be used "
|
|
70
|
+
f"to build permission rules safely: {normalized_project_path}"
|
|
71
|
+
)
|
|
72
|
+
return normalized_project_path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_permission_rule(tool_name: str, project_path: str) -> str:
|
|
76
|
+
return f"{tool_name}({project_path}/.claude/**)"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_permission_rules(
|
|
80
|
+
project_path: str, permission_allow_tools: tuple[str, ...]
|
|
81
|
+
) -> list[str]:
|
|
82
|
+
return [
|
|
83
|
+
build_permission_rule(each_tool, project_path)
|
|
84
|
+
for each_tool in permission_allow_tools
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_settings(settings_path: Path) -> dict[str, Any]:
|
|
89
|
+
if not settings_path.exists():
|
|
90
|
+
return {}
|
|
91
|
+
parsed_settings: dict[str, Any] = {}
|
|
92
|
+
try:
|
|
93
|
+
raw_text = settings_path.read_text(encoding=TEXT_FILE_ENCODING)
|
|
94
|
+
except OSError as read_error:
|
|
95
|
+
exit_with_error(f"Failed to read {settings_path}: {read_error}")
|
|
96
|
+
try:
|
|
97
|
+
parsed_settings = json.loads(raw_text)
|
|
98
|
+
except json.JSONDecodeError as decode_error:
|
|
99
|
+
exit_with_error(
|
|
100
|
+
f"Refusing to modify {settings_path}: existing file is not valid JSON "
|
|
101
|
+
f"({decode_error}). Fix or back up the file manually, then re-run."
|
|
102
|
+
)
|
|
103
|
+
if not isinstance(parsed_settings, dict):
|
|
104
|
+
exit_with_error(
|
|
105
|
+
f"Refusing to modify {settings_path}: existing file's root is "
|
|
106
|
+
f"{type(parsed_settings).__name__}, not a JSON object. Fix or back up "
|
|
107
|
+
f"the file manually, then re-run."
|
|
108
|
+
)
|
|
109
|
+
return parsed_settings
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_mode_to_preserve(settings_path: Path) -> int:
|
|
113
|
+
try:
|
|
114
|
+
stat_result = os.stat(settings_path)
|
|
115
|
+
except FileNotFoundError:
|
|
116
|
+
return DEFAULT_SETTINGS_FILE_MODE
|
|
117
|
+
except OSError as stat_error:
|
|
118
|
+
exit_with_error(f"Failed to stat {settings_path}: {stat_error}")
|
|
119
|
+
return stat.S_IMODE(stat_result.st_mode)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_atomically_with_mode(
|
|
123
|
+
temporary_path: Path, serialized_content: str, file_mode: int
|
|
124
|
+
) -> None:
|
|
125
|
+
file_descriptor = os.open(
|
|
126
|
+
str(temporary_path),
|
|
127
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
128
|
+
file_mode,
|
|
129
|
+
)
|
|
130
|
+
with os.fdopen(file_descriptor, "w", encoding=TEXT_FILE_ENCODING) as writer:
|
|
131
|
+
writer.write(serialized_content)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def save_settings(settings_path: Path, settings: dict[str, Any]) -> None:
|
|
135
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
serialized_settings = json.dumps(
|
|
137
|
+
settings, indent=JSON_INDENT_SPACES, sort_keys=True
|
|
138
|
+
)
|
|
139
|
+
temporary_path = settings_path.with_suffix(
|
|
140
|
+
settings_path.suffix + ATOMIC_WRITE_TEMPORARY_SUFFIX
|
|
141
|
+
)
|
|
142
|
+
mode_to_preserve = get_mode_to_preserve(settings_path)
|
|
143
|
+
try:
|
|
144
|
+
try:
|
|
145
|
+
write_atomically_with_mode(
|
|
146
|
+
temporary_path, serialized_settings, mode_to_preserve
|
|
147
|
+
)
|
|
148
|
+
os.replace(str(temporary_path), str(settings_path))
|
|
149
|
+
except OSError as os_error:
|
|
150
|
+
exit_with_error(
|
|
151
|
+
f"Failed to write settings atomically to {settings_path}: {os_error}"
|
|
152
|
+
)
|
|
153
|
+
finally:
|
|
154
|
+
if temporary_path.exists():
|
|
155
|
+
try:
|
|
156
|
+
temporary_path.unlink()
|
|
157
|
+
except OSError:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def append_if_missing(target_list: list[str], new_value: str) -> bool:
|
|
162
|
+
if new_value in target_list:
|
|
163
|
+
return False
|
|
164
|
+
target_list.append(new_value)
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def ensure_dict_section(
|
|
169
|
+
settings: dict[str, Any], section_name: str
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
"""Return an existing dict section or create an empty one if absent.
|
|
172
|
+
|
|
173
|
+
A missing key and an explicit JSON null are treated identically: both
|
|
174
|
+
produce a fresh empty dict stored back into settings. Any other non-dict
|
|
175
|
+
value (string, list, number, bool) calls exit_with_error to avoid
|
|
176
|
+
overwriting user data.
|
|
177
|
+
"""
|
|
178
|
+
existing_section = settings.get(section_name)
|
|
179
|
+
if existing_section is None:
|
|
180
|
+
replacement_section: dict[str, Any] = {}
|
|
181
|
+
settings[section_name] = replacement_section
|
|
182
|
+
return replacement_section
|
|
183
|
+
if not isinstance(existing_section, dict):
|
|
184
|
+
exit_with_error(
|
|
185
|
+
f"Refusing to modify settings key {section_name!r}: existing value "
|
|
186
|
+
f"is {type(existing_section).__name__}, not a JSON object. Fix or "
|
|
187
|
+
f"remove the key manually, then re-run."
|
|
188
|
+
)
|
|
189
|
+
return existing_section
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def ensure_list_entry(section: dict[str, Any], entry_name: str) -> list[Any]:
|
|
193
|
+
"""Return an existing list entry or create an empty one if absent.
|
|
194
|
+
|
|
195
|
+
A missing key and an explicit JSON null are treated identically: both
|
|
196
|
+
produce a fresh empty list stored back into the section. Any other
|
|
197
|
+
non-list value (string, dict, number, bool) calls exit_with_error to
|
|
198
|
+
avoid overwriting user data.
|
|
199
|
+
"""
|
|
200
|
+
existing_entry = section.get(entry_name)
|
|
201
|
+
if existing_entry is None:
|
|
202
|
+
replacement_entry: list[Any] = []
|
|
203
|
+
section[entry_name] = replacement_entry
|
|
204
|
+
return replacement_entry
|
|
205
|
+
if not isinstance(existing_entry, list):
|
|
206
|
+
exit_with_error(
|
|
207
|
+
f"Refusing to modify settings entry {entry_name!r}: existing value "
|
|
208
|
+
f"is {type(existing_entry).__name__}, not a JSON array. Fix or "
|
|
209
|
+
f"remove the entry manually, then re-run."
|
|
210
|
+
)
|
|
211
|
+
return existing_entry
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def prune_empty_list_then_empty_section(
|
|
215
|
+
settings: dict[str, Any], section_key: str, list_key: str
|
|
216
|
+
) -> None:
|
|
217
|
+
section = settings.get(section_key)
|
|
218
|
+
if not isinstance(section, dict):
|
|
219
|
+
return
|
|
220
|
+
list_entry = section.get(list_key)
|
|
221
|
+
if isinstance(list_entry, list) and len(list_entry) == 0:
|
|
222
|
+
del section[list_key]
|
|
223
|
+
if len(section) == 0:
|
|
224
|
+
del settings[section_key]
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
14
|
+
|
|
15
|
+
from _claude_permissions_common import ( # noqa: E402
|
|
16
|
+
append_if_missing,
|
|
17
|
+
build_permission_rules,
|
|
18
|
+
ensure_dict_section,
|
|
19
|
+
ensure_list_entry,
|
|
20
|
+
exit_with_error,
|
|
21
|
+
get_current_project_path,
|
|
22
|
+
load_settings,
|
|
23
|
+
save_settings,
|
|
24
|
+
AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
|
|
25
|
+
PERMISSION_ALLOW_TOOLS,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
CLAUDE_USER_SETTINGS_PATH: Path = Path.home() / ".claude" / "settings.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_valid_project_root(candidate_path: Path) -> bool:
|
|
33
|
+
git_marker_path = candidate_path / ".git"
|
|
34
|
+
claude_marker_path = candidate_path / ".claude"
|
|
35
|
+
return git_marker_path.exists() or claude_marker_path.exists()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def add_rules_to_allow_list(settings: dict[str, Any], rules_to_add: list[str]) -> int:
|
|
39
|
+
permissions_section = ensure_dict_section(settings, "permissions")
|
|
40
|
+
existing_allow_list = ensure_list_entry(permissions_section, "allow")
|
|
41
|
+
return sum(
|
|
42
|
+
1
|
|
43
|
+
for each_rule in rules_to_add
|
|
44
|
+
if append_if_missing(existing_allow_list, each_rule)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def add_directory_to_additional_directories(
|
|
49
|
+
settings: dict[str, Any], directory_path: str
|
|
50
|
+
) -> int:
|
|
51
|
+
permissions_section = ensure_dict_section(settings, "permissions")
|
|
52
|
+
existing_directories = ensure_list_entry(
|
|
53
|
+
permissions_section, "additionalDirectories"
|
|
54
|
+
)
|
|
55
|
+
if append_if_missing(existing_directories, directory_path):
|
|
56
|
+
return 1
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def add_auto_mode_environment_entry(settings: dict[str, Any], entry_text: str) -> int:
|
|
61
|
+
auto_mode_section = ensure_dict_section(settings, "autoMode")
|
|
62
|
+
existing_environment = ensure_list_entry(auto_mode_section, "environment")
|
|
63
|
+
if append_if_missing(existing_environment, entry_text):
|
|
64
|
+
return 1
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def grant_permissions_for_current_directory() -> None:
|
|
69
|
+
project_root_path = Path.cwd()
|
|
70
|
+
if not is_valid_project_root(project_root_path):
|
|
71
|
+
print(
|
|
72
|
+
f"ERROR: cwd {project_root_path} is not a project root "
|
|
73
|
+
f"(no .git or .claude). Run from a project root.",
|
|
74
|
+
file=sys.stderr,
|
|
75
|
+
)
|
|
76
|
+
raise SystemExit(1)
|
|
77
|
+
project_path = get_current_project_path()
|
|
78
|
+
permission_rules = build_permission_rules(project_path, PERMISSION_ALLOW_TOOLS)
|
|
79
|
+
environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
|
|
80
|
+
project_path=project_path
|
|
81
|
+
)
|
|
82
|
+
settings = load_settings(CLAUDE_USER_SETTINGS_PATH)
|
|
83
|
+
rules_added_count = add_rules_to_allow_list(settings, permission_rules)
|
|
84
|
+
directories_added_count = add_directory_to_additional_directories(
|
|
85
|
+
settings, project_path
|
|
86
|
+
)
|
|
87
|
+
environment_entries_added_count = add_auto_mode_environment_entry(
|
|
88
|
+
settings, environment_entry
|
|
89
|
+
)
|
|
90
|
+
total_changes_count = (
|
|
91
|
+
rules_added_count + directories_added_count + environment_entries_added_count
|
|
92
|
+
)
|
|
93
|
+
if total_changes_count == 0:
|
|
94
|
+
print(f"Project path: {project_path}")
|
|
95
|
+
print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
|
|
96
|
+
print("No changes needed; settings file left untouched.")
|
|
97
|
+
return
|
|
98
|
+
save_settings(CLAUDE_USER_SETTINGS_PATH, settings)
|
|
99
|
+
print(f"Project path: {project_path}")
|
|
100
|
+
print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
|
|
101
|
+
print(f"Allow rules added: {rules_added_count} of {len(permission_rules)}")
|
|
102
|
+
print(f"Additional directories added: {directories_added_count}")
|
|
103
|
+
print(f"Auto-mode environment entries added: {environment_entries_added_count}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
try:
|
|
108
|
+
grant_permissions_for_current_directory()
|
|
109
|
+
except ValueError as path_error:
|
|
110
|
+
exit_with_error(str(path_error))
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
15
|
+
|
|
16
|
+
from _claude_permissions_common import ( # noqa: E402
|
|
17
|
+
build_permission_rules,
|
|
18
|
+
exit_with_error,
|
|
19
|
+
get_current_project_path,
|
|
20
|
+
load_settings,
|
|
21
|
+
prune_empty_list_then_empty_section,
|
|
22
|
+
save_settings,
|
|
23
|
+
AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
|
|
24
|
+
PERMISSION_ALLOW_TOOLS,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
CLAUDE_USER_SETTINGS_PATH: Path = Path.home() / ".claude" / "settings.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_valid_project_root(candidate_path: Path) -> bool:
|
|
32
|
+
git_marker_path = candidate_path / ".git"
|
|
33
|
+
claude_marker_path = candidate_path / ".claude"
|
|
34
|
+
return git_marker_path.exists() or claude_marker_path.exists()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def remove_values_from_list(target_list: list[str], values_to_remove: set[str]) -> int:
|
|
38
|
+
original_length = len(target_list)
|
|
39
|
+
target_list[:] = [
|
|
40
|
+
each_value for each_value in target_list if each_value not in values_to_remove
|
|
41
|
+
]
|
|
42
|
+
return original_length - len(target_list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def remove_rules_from_allow_list(
|
|
46
|
+
settings: dict[str, Any], rules_to_remove: list[str]
|
|
47
|
+
) -> int:
|
|
48
|
+
permissions_section = settings.get("permissions")
|
|
49
|
+
if not isinstance(permissions_section, dict):
|
|
50
|
+
return 0
|
|
51
|
+
existing_allow_list = permissions_section.get("allow")
|
|
52
|
+
if not isinstance(existing_allow_list, list):
|
|
53
|
+
return 0
|
|
54
|
+
return remove_values_from_list(existing_allow_list, set(rules_to_remove))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def remove_directory_from_additional_directories(
|
|
58
|
+
settings: dict[str, Any], directory_path: str
|
|
59
|
+
) -> int:
|
|
60
|
+
permissions_section = settings.get("permissions")
|
|
61
|
+
if not isinstance(permissions_section, dict):
|
|
62
|
+
return 0
|
|
63
|
+
existing_directories = permissions_section.get("additionalDirectories")
|
|
64
|
+
if not isinstance(existing_directories, list):
|
|
65
|
+
return 0
|
|
66
|
+
return remove_values_from_list(existing_directories, {directory_path})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def remove_auto_mode_environment_entry(
|
|
70
|
+
settings: dict[str, Any], entry_text: str
|
|
71
|
+
) -> int:
|
|
72
|
+
auto_mode_section = settings.get("autoMode")
|
|
73
|
+
if not isinstance(auto_mode_section, dict):
|
|
74
|
+
return 0
|
|
75
|
+
existing_environment = auto_mode_section.get("environment")
|
|
76
|
+
if not isinstance(existing_environment, list):
|
|
77
|
+
return 0
|
|
78
|
+
return remove_values_from_list(existing_environment, {entry_text})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def prune_settings_after_revoke(settings: dict[str, Any]) -> None:
|
|
82
|
+
prune_empty_list_then_empty_section(settings, "permissions", "allow")
|
|
83
|
+
prune_empty_list_then_empty_section(
|
|
84
|
+
settings, "permissions", "additionalDirectories"
|
|
85
|
+
)
|
|
86
|
+
prune_empty_list_then_empty_section(settings, "autoMode", "environment")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def revoke_permissions_for_current_directory() -> None:
|
|
90
|
+
project_root_path = Path.cwd()
|
|
91
|
+
if not is_valid_project_root(project_root_path):
|
|
92
|
+
print(
|
|
93
|
+
f"ERROR: cwd {project_root_path} is not a project root "
|
|
94
|
+
f"(no .git or .claude). Run from a project root.",
|
|
95
|
+
file=sys.stderr,
|
|
96
|
+
)
|
|
97
|
+
raise SystemExit(1)
|
|
98
|
+
project_path = get_current_project_path()
|
|
99
|
+
permission_rules = build_permission_rules(project_path, PERMISSION_ALLOW_TOOLS)
|
|
100
|
+
environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
|
|
101
|
+
project_path=project_path
|
|
102
|
+
)
|
|
103
|
+
settings = load_settings(CLAUDE_USER_SETTINGS_PATH)
|
|
104
|
+
rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
|
|
105
|
+
directories_removed_count = remove_directory_from_additional_directories(
|
|
106
|
+
settings, project_path
|
|
107
|
+
)
|
|
108
|
+
environment_entries_removed_count = remove_auto_mode_environment_entry(
|
|
109
|
+
settings, environment_entry
|
|
110
|
+
)
|
|
111
|
+
total_changes_count = (
|
|
112
|
+
rules_removed_count
|
|
113
|
+
+ directories_removed_count
|
|
114
|
+
+ environment_entries_removed_count
|
|
115
|
+
)
|
|
116
|
+
if total_changes_count == 0:
|
|
117
|
+
print(f"Project path: {project_path}")
|
|
118
|
+
print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
|
|
119
|
+
print("No changes to revoke; settings file left untouched.")
|
|
120
|
+
return
|
|
121
|
+
prune_settings_after_revoke(settings)
|
|
122
|
+
save_settings(CLAUDE_USER_SETTINGS_PATH, settings)
|
|
123
|
+
print(f"Project path: {project_path}")
|
|
124
|
+
print(f"Settings file: {CLAUDE_USER_SETTINGS_PATH}")
|
|
125
|
+
print(f"Allow rules removed: {rules_removed_count} of {len(permission_rules)}")
|
|
126
|
+
print(f"Additional directories removed: {directories_removed_count}")
|
|
127
|
+
print(
|
|
128
|
+
f"Auto-mode environment entries removed: {environment_entries_removed_count}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
try:
|
|
134
|
+
revoke_permissions_for_current_directory()
|
|
135
|
+
except ValueError as path_error:
|
|
136
|
+
exit_with_error(str(path_error))
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: findbugs
|
|
3
|
+
description: >-
|
|
4
|
+
Audits the current branch's pull request as a whole for bugs by spawning the
|
|
5
|
+
code-quality-agent against the full PR diff with zero conversation context.
|
|
6
|
+
Returns P0/P1/P2 findings with file:line evidence and a verified-clean
|
|
7
|
+
coverage list. Read-only — never modifies code. Triggers: '/findbugs',
|
|
8
|
+
'find bugs in this PR', 'audit the PR', 'bug audit on the branch'.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Findbugs
|
|
12
|
+
|
|
13
|
+
**Core principle:** A clean-room bug audit on the entire pull request. The audit agent receives the PR diff and nothing else — no chat history, no prior framing, no implicit "we already looked at this." Independence is the point.
|
|
14
|
+
|
|
15
|
+
## When this skill applies
|
|
16
|
+
|
|
17
|
+
User types `/findbugs` or asks for a bug audit on the current branch's PR. Typical moment: PR is up (draft or ready), and the user wants an independent second pair of eyes before merge or before requesting human review.
|
|
18
|
+
|
|
19
|
+
If the current branch has no associated PR and no diff against the default branch, say so and stop. Do not invent scope.
|
|
20
|
+
|
|
21
|
+
## The Process
|
|
22
|
+
|
|
23
|
+
### Step 1: Resolve PR scope
|
|
24
|
+
|
|
25
|
+
Determine the audit target in this order:
|
|
26
|
+
|
|
27
|
+
1. **Open PR for current branch.** Run `gh pr view --json number,baseRefName,headRefName,url` from the working directory. If a PR exists, capture its number, base ref, head ref, and URL.
|
|
28
|
+
2. **No PR but a remote default branch exists.** Diff against the default branch's merge-base: `git merge-base HEAD origin/<default>` then `git diff <merge-base>...HEAD`. Treat this as the audit scope.
|
|
29
|
+
3. **Neither.** Respond exactly: `No PR or upstream diff found. Push the branch or open a PR first.` and stop.
|
|
30
|
+
|
|
31
|
+
### Step 2: Capture the full PR diff
|
|
32
|
+
|
|
33
|
+
Resolve the temp diff path **once, Claude-side**, before invoking any shell command. Use Python's `tempfile.gettempdir()` which honors `TMPDIR`, `TEMP`, and `TMP` in the platform-correct order and falls back to `C:\Users\<user>\AppData\Local\Temp` on Windows or `/tmp` on Unix. Avoid hand-rolled env var chains. The lookup works on macOS, Linux, Windows cmd.exe, and PowerShell:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
import tempfile
|
|
37
|
+
diff_temp_path = Path(tempfile.gettempdir()) / f"findbugs-pr-{os.getpid()}.patch"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`os.getpid()` supplies the per-invocation suffix that prevents collisions with parallel `/findbugs` runs (a UUID or timestamp is equally acceptable). Capture the resolved absolute path as `<diff_temp_path>` and pass that **literal** path to every shell command that follows. Shell-side parameter expansion (`${TMPDIR:-/tmp}`, `$$`, `%TEMP%`) is forbidden because cmd.exe and PowerShell do not honor it.
|
|
41
|
+
|
|
42
|
+
When a PR exists: `gh pr diff <number> -R <owner>/<repo> > "<diff_temp_path>"`.
|
|
43
|
+
|
|
44
|
+
When falling back to merge-base diff: `git diff <merge-base>...HEAD > "<diff_temp_path>"`.
|
|
45
|
+
|
|
46
|
+
The audit's authoritative scope is this single diff file. Do not inject extra files, related history, or "files Claude edited this session" — those introduce anchoring bias.
|
|
47
|
+
|
|
48
|
+
### Step 3: Spawn the code-quality-agent — clean room
|
|
49
|
+
|
|
50
|
+
Call the Agent tool with:
|
|
51
|
+
|
|
52
|
+
- `subagent_type: code-quality-agent`
|
|
53
|
+
- `model: sonnet`
|
|
54
|
+
- `description: "PR bug audit"`
|
|
55
|
+
- `run_in_background: false` — the user invoked `/findbugs` to get a result on this turn
|
|
56
|
+
|
|
57
|
+
**The agent prompt must be self-contained and context-free.** Specifically:
|
|
58
|
+
|
|
59
|
+
- **No references to the orchestrator's conversation.** Forbidden phrases: "as we discussed," "the earlier issue," "given our prior work," "the bug from last turn," "you previously identified."
|
|
60
|
+
- **No hints about expected outcomes.** Do not pre-stage findings, do not suggest where bugs probably are, do not name files as "the suspicious one." The agent forms its own hypotheses.
|
|
61
|
+
- **No instructions to favor or skip particular categories** beyond the standard category list. No "skip the typing stuff" or "focus on the clipboard logic" — those bias the audit.
|
|
62
|
+
- **Minimal background.** Identify the repo, branch, base branch, and PR URL only. Do not summarize what the PR does.
|
|
63
|
+
|
|
64
|
+
The XML prompt skeleton:
|
|
65
|
+
|
|
66
|
+
```xml
|
|
67
|
+
<context>
|
|
68
|
+
<repo>owner/repo</repo>
|
|
69
|
+
<branch>head ref</branch>
|
|
70
|
+
<base_branch>base ref</base_branch>
|
|
71
|
+
<pr_url>url or "none"</pr_url>
|
|
72
|
+
</context>
|
|
73
|
+
|
|
74
|
+
<scope>
|
|
75
|
+
<diff_path><diff_temp_path> (absolute scoped temp path from Step 2)</diff_path>
|
|
76
|
+
<scope_rule>Audit only lines added or modified in the diff. Pre-existing code on untouched lines is out of scope.</scope_rule>
|
|
77
|
+
</scope>
|
|
78
|
+
|
|
79
|
+
<bug_categories>
|
|
80
|
+
Investigate each explicitly:
|
|
81
|
+
A. API contract verification (signatures, return types, async/await correctness)
|
|
82
|
+
B. Selector / query / engine compatibility
|
|
83
|
+
C. Resource cleanup and lifecycle (file handles, connections, processes, locks)
|
|
84
|
+
D. Variable scoping, ordering, and unbound references
|
|
85
|
+
E. Dead code and unused imports
|
|
86
|
+
F. Silent failures (catch-all excepts, unconditional success returns, missing error propagation)
|
|
87
|
+
G. Off-by-one, bounds, and integer overflow
|
|
88
|
+
H. Security boundaries (injection, path traversal, auth bypass, secret leakage)
|
|
89
|
+
I. Concurrency hazards (race conditions, missing awaits, shared mutable state)
|
|
90
|
+
J. Magic values and configuration drift
|
|
91
|
+
</bug_categories>
|
|
92
|
+
|
|
93
|
+
<constraints>
|
|
94
|
+
Read-only. Report findings only. Do not modify code, do not propose
|
|
95
|
+
full diffs, do not commit, do not push. Cite file:line for every claim.
|
|
96
|
+
When the diff alone does not provide enough context to confirm or deny
|
|
97
|
+
a bug, list it under "Open questions" rather than asserting.
|
|
98
|
+
</constraints>
|
|
99
|
+
|
|
100
|
+
<output_format>
|
|
101
|
+
P0 = will not run / data corruption
|
|
102
|
+
P1 = regression or silent failure
|
|
103
|
+
P2 = dead code, minor smell
|
|
104
|
+
|
|
105
|
+
## Summary
|
|
106
|
+
Total: N (P0=N, P1=N, P2=N)
|
|
107
|
+
|
|
108
|
+
## Findings
|
|
109
|
+
### [P_] short title
|
|
110
|
+
File: file/path:line
|
|
111
|
+
Category: A-J
|
|
112
|
+
Issue: 2-3 sentence description with concrete trace
|
|
113
|
+
Evidence: code excerpt or grep result
|
|
114
|
+
|
|
115
|
+
## Verified clean
|
|
116
|
+
Per category investigated, name the evidence and the conclusion.
|
|
117
|
+
|
|
118
|
+
## Open questions
|
|
119
|
+
Anything ambiguous from the diff alone.
|
|
120
|
+
</output_format>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Step 4: Surface findings, then clean up
|
|
124
|
+
|
|
125
|
+
When the agent returns, report concisely:
|
|
126
|
+
|
|
127
|
+
- One-line totals: `N P0 / N P1 / N P2 — K categories cleared`
|
|
128
|
+
- Each finding's `file:line`, category, and one-sentence description
|
|
129
|
+
- The cleared categories so the user can see coverage breadth
|
|
130
|
+
- Any open questions the agent could not resolve from the diff alone
|
|
131
|
+
|
|
132
|
+
Offer the next step without auto-executing it: `Want me to spawn clean-coder to fix the P0/P1 findings?`
|
|
133
|
+
|
|
134
|
+
Delete the scoped temp diff at `<diff_temp_path>` after the audit completes (or moves to a fix flow). Temporary diff files do not belong in the working tree.
|
|
135
|
+
|
|
136
|
+
Do not paste the full agent transcript or the XML prompt unless the user asks.
|
|
137
|
+
|
|
138
|
+
## Output Format
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
N P0 / N P1 / N P2 — K categories cleared
|
|
142
|
+
|
|
143
|
+
P1 — short title
|
|
144
|
+
file/path.py:NN — one-sentence description (category: <name>)
|
|
145
|
+
|
|
146
|
+
P2 — short title
|
|
147
|
+
file/path.py:NN — one-sentence description (category: <name>)
|
|
148
|
+
|
|
149
|
+
Verified clean: <category>, <category>, <category>
|
|
150
|
+
|
|
151
|
+
Open questions:
|
|
152
|
+
<if any>
|
|
153
|
+
|
|
154
|
+
Want me to spawn clean-coder to fix the P0/P1 findings?
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Constraints
|
|
158
|
+
|
|
159
|
+
- **Read-only.** The skill never edits code, never pushes, never commits.
|
|
160
|
+
- **Foreground spawn.** The user is waiting for the result on this turn.
|
|
161
|
+
- **PR-scoped, not session-scoped.** The audit covers the entire PR diff regardless of which files were edited in this conversation.
|
|
162
|
+
- **Clean-room prompt.** The agent's prompt is self-contained — no references to chat history, no anchoring hints, no expected outcomes.
|
|
163
|
+
- **No clean-coder auto-spawn.** Always ask before fixing.
|
|
164
|
+
- **Trust the agent's verdict.** Pass through P0/P1/P2 categorizations as the agent assigned them; do not re-rank.
|
|
165
|
+
- **Temp file cleanup.** Delete the scoped `<diff_temp_path>` when the audit ends.
|
|
166
|
+
|
|
167
|
+
## Examples
|
|
168
|
+
|
|
169
|
+
<example>
|
|
170
|
+
User: `/findbugs`
|
|
171
|
+
Claude: [resolves PR #42 from current branch, fetches full diff, spawns code-quality-agent foreground with self-contained prompt, returns]
|
|
172
|
+
|
|
173
|
+
`1 P0 / 2 P1 / 0 P2 — 7 categories cleared`
|
|
174
|
+
|
|
175
|
+
`P0 — race condition on shared cache write`
|
|
176
|
+
` src/cache.py:88 — concurrent writers can both pass the existence check before either writes (category: concurrency)`
|
|
177
|
+
|
|
178
|
+
`P1 — silent paste failure`
|
|
179
|
+
` utils/clipboard.py:33 — validated_paste returns success without verifying the post-paste state (category: silent failure)`
|
|
180
|
+
|
|
181
|
+
`P1 — unbound variable on early-exception path`
|
|
182
|
+
` src/processor.py:283 — scheduling_log referenced after try/finally where it may be unbound (category: scoping)`
|
|
183
|
+
|
|
184
|
+
`Verified clean: API contract, selector compatibility, resource cleanup, dead code, off-by-one, security boundaries, magic values`
|
|
185
|
+
|
|
186
|
+
`Open questions: none`
|
|
187
|
+
|
|
188
|
+
`Want me to spawn clean-coder to fix the P0 + P1s?`
|
|
189
|
+
</example>
|
|
190
|
+
|
|
191
|
+
<example>
|
|
192
|
+
User: `/findbugs`
|
|
193
|
+
Claude: `No PR or upstream diff found. Push the branch or open a PR first.`
|
|
194
|
+
</example>
|
|
195
|
+
|
|
196
|
+
<example>
|
|
197
|
+
User: `/findbugs` (branch with no PR but commits ahead of main)
|
|
198
|
+
Claude: [falls back to `git diff origin/main...HEAD`, runs audit on that diff scope]
|
|
199
|
+
</example>
|
|
200
|
+
|
|
201
|
+
## Why this design
|
|
202
|
+
|
|
203
|
+
Anchoring bias is the failure mode of context-rich audits. An agent that inherits "we just fixed three bugs in clipboard_utils.py" subconsciously scopes its hunt around clipboard_utils.py and pattern-matches on the same bug shapes. A clean-room audit on the full PR diff treats every file equally, considers every category, and surfaces things the orchestrator session never looked at. The diff is the contract; everything else is noise.
|