claude-dev-env 8.54.9 → 8.55.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/bin/install.agents-home.test.mjs +2 -0
- package/bin/install.codex-rules.test.mjs +4 -0
- package/bin/install.cursor-rules.test.mjs +1 -0
- package/bin/install.mjs +19 -20
- package/bin/install.profile-settings.test.mjs +77 -0
- package/bin/install.profiles.test.mjs +2 -2
- package/bin/install.prune.test.mjs +2 -0
- package/bin/install.question-presentation.test.mjs +2 -0
- package/bin/install.settings-defaults.test.mjs +8 -3
- package/bin/install.shared-settings.test.mjs +1 -0
- package/bin/install.test.mjs +7 -3
- package/bin/install.transaction.test.mjs +14 -0
- package/bin/install.uninstall-transaction.test.mjs +4 -0
- package/bin/install.update-keeps-hooks.test.mjs +2 -0
- package/bin/merge_profile_settings.mjs +372 -0
- package/bin/merge_profile_settings.test.mjs +208 -0
- package/installable-surfaces.manifest.json +2 -1
- package/package.json +3 -2
- package/profile-settings/config/__init__.py +1 -0
- package/profile-settings/config/session_title_gate_constants.py +15 -0
- package/profile-settings/session_title_status_gate.py +77 -0
- package/profile-settings/test_session_title_status_gate.py +101 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +8 -4
- package/scripts/private_term_scan.py +15 -3
- package/scripts/tests/test_private_term_scan.py +81 -8
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Stop hook that blocks until the session title is set with a status emoji."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import TextIO
|
|
6
|
+
|
|
7
|
+
from config.session_title_gate_constants import ALL_TITLE_TOOL_NAMES, INSTRUCTION
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _content_blocks(entry: dict) -> list:
|
|
11
|
+
content = entry.get("message", {}).get("content")
|
|
12
|
+
if isinstance(content, str):
|
|
13
|
+
return [{"type": "text", "text": content}]
|
|
14
|
+
return content if isinstance(content, list) else []
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_user_text(entry: dict) -> bool:
|
|
18
|
+
if entry.get("type") != "user":
|
|
19
|
+
return False
|
|
20
|
+
return any(
|
|
21
|
+
block.get("type") == "text" and block.get("text", "").strip()
|
|
22
|
+
for block in _content_blocks(entry)
|
|
23
|
+
if isinstance(block, dict)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_title_call(entry: dict) -> bool:
|
|
28
|
+
if entry.get("type") != "assistant":
|
|
29
|
+
return False
|
|
30
|
+
return any(
|
|
31
|
+
block.get("type") == "tool_use" and block.get("name") in ALL_TITLE_TOOL_NAMES
|
|
32
|
+
for block in _content_blocks(entry)
|
|
33
|
+
if isinstance(block, dict)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def title_set_since_last_user_text(all_transcript_lines: list[str]) -> bool:
|
|
38
|
+
"""Report whether a title call follows the newest user text in the transcript.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
all_transcript_lines: The transcript's JSONL lines, oldest first.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
True when a set_session_title call comes after the last user text.
|
|
45
|
+
"""
|
|
46
|
+
for each_line in reversed(all_transcript_lines):
|
|
47
|
+
if not each_line.strip():
|
|
48
|
+
continue
|
|
49
|
+
entry = json.loads(each_line)
|
|
50
|
+
if _is_title_call(entry):
|
|
51
|
+
return True
|
|
52
|
+
if _is_user_text(entry):
|
|
53
|
+
return False
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(decision_stream: TextIO) -> None:
|
|
58
|
+
"""Block the stop with the title instruction when no title follows the last user text.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
decision_stream: The stream that receives the Stop hook decision.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
payload = json.load(sys.stdin)
|
|
65
|
+
if payload.get("stop_hook_active"):
|
|
66
|
+
return
|
|
67
|
+
with open(payload["transcript_path"], encoding="utf-8") as transcript_file:
|
|
68
|
+
all_transcript_lines = transcript_file.readlines()
|
|
69
|
+
if title_set_since_last_user_text(all_transcript_lines):
|
|
70
|
+
return
|
|
71
|
+
except (OSError, KeyError, ValueError):
|
|
72
|
+
return
|
|
73
|
+
decision_stream.write(json.dumps({"decision": "block", "reason": INSTRUCTION}))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main(sys.stdout)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Run the installed session-title gate as the Stop hook runs it."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
GATE_SOURCE_DIRECTORY = Path(__file__).resolve().parent
|
|
10
|
+
GATE_FILE_NAME = "session_title_status_gate.py"
|
|
11
|
+
ALL_GATE_RELATIVE_PATHS = (
|
|
12
|
+
Path(GATE_FILE_NAME),
|
|
13
|
+
Path("config") / "__init__.py",
|
|
14
|
+
Path("config") / "session_title_gate_constants.py",
|
|
15
|
+
)
|
|
16
|
+
TITLE_TOOL_NAME = "mcp__claude-code-remote__set_session_title"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _install_gate(claude_directory: Path) -> Path:
|
|
20
|
+
for each_relative_path in ALL_GATE_RELATIVE_PATHS:
|
|
21
|
+
target_path = claude_directory / each_relative_path
|
|
22
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
shutil.copyfile(GATE_SOURCE_DIRECTORY / each_relative_path, target_path)
|
|
24
|
+
return claude_directory / GATE_FILE_NAME
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _user_text_entry(text: str) -> dict:
|
|
28
|
+
return {"type": "user", "message": {"content": text}}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _title_call_entry() -> dict:
|
|
32
|
+
return {
|
|
33
|
+
"type": "assistant",
|
|
34
|
+
"message": {
|
|
35
|
+
"content": [{"type": "tool_use", "name": TITLE_TOOL_NAME, "input": {}}]
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_gate(
|
|
41
|
+
tmp_path: Path, all_entries: list[dict], is_stop_hook_active: bool = False
|
|
42
|
+
) -> str:
|
|
43
|
+
gate_path = _install_gate(tmp_path / ".claude")
|
|
44
|
+
transcript_path = tmp_path / "transcript.jsonl"
|
|
45
|
+
transcript_path.write_text(
|
|
46
|
+
"\n".join(json.dumps(each_entry) for each_entry in all_entries) + "\n\n",
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
)
|
|
49
|
+
payload = {
|
|
50
|
+
"transcript_path": str(transcript_path),
|
|
51
|
+
"stop_hook_active": is_stop_hook_active,
|
|
52
|
+
}
|
|
53
|
+
gate_run = subprocess.run(
|
|
54
|
+
[sys.executable, str(gate_path)],
|
|
55
|
+
input=json.dumps(payload),
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True,
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
check=True,
|
|
60
|
+
)
|
|
61
|
+
return gate_run.stdout
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def should_allow_stop_when_title_follows_last_user_text(tmp_path: Path) -> None:
|
|
65
|
+
assert (
|
|
66
|
+
_run_gate(tmp_path, [_user_text_entry("fix the bug"), _title_call_entry()])
|
|
67
|
+
== ""
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def should_block_stop_when_user_text_follows_last_title(tmp_path: Path) -> None:
|
|
72
|
+
gate_output = _run_gate(
|
|
73
|
+
tmp_path, [_title_call_entry(), _user_text_entry("one more thing")]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
decision = json.loads(gate_output)
|
|
77
|
+
assert decision["decision"] == "block"
|
|
78
|
+
assert "set_session_title" in decision["reason"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def should_allow_stop_when_stop_hook_is_already_active(tmp_path: Path) -> None:
|
|
82
|
+
assert (
|
|
83
|
+
_run_gate(tmp_path, [_user_text_entry("fix the bug")], is_stop_hook_active=True)
|
|
84
|
+
== ""
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def should_allow_stop_when_transcript_is_missing(tmp_path: Path) -> None:
|
|
89
|
+
gate_path = _install_gate(tmp_path / ".claude")
|
|
90
|
+
payload = {"transcript_path": str(tmp_path / "absent.jsonl")}
|
|
91
|
+
|
|
92
|
+
gate_run = subprocess.run(
|
|
93
|
+
[sys.executable, str(gate_path)],
|
|
94
|
+
input=json.dumps(payload),
|
|
95
|
+
capture_output=True,
|
|
96
|
+
text=True,
|
|
97
|
+
encoding="utf-8",
|
|
98
|
+
check=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
assert gate_run.stdout == ""
|
|
@@ -26,6 +26,10 @@ class PrivateTermDigest:
|
|
|
26
26
|
sha256: str
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
OWNER_HANDLE_DIGEST = PrivateTermDigest(
|
|
30
|
+
length=7,
|
|
31
|
+
sha256="d526db5593d5986500e4770cb6018f2d33c459ed93657487958a6086e52e6147",
|
|
32
|
+
)
|
|
29
33
|
ALL_PRIVATE_TERM_DIGESTS: frozenset[PrivateTermDigest] = frozenset(
|
|
30
34
|
{
|
|
31
35
|
PrivateTermDigest(
|
|
@@ -36,16 +40,16 @@ ALL_PRIVATE_TERM_DIGESTS: frozenset[PrivateTermDigest] = frozenset(
|
|
|
36
40
|
length=16,
|
|
37
41
|
sha256="182cb82e3e973d041b8527659064153f8c4af79503bd198508d3655d9a932fdf",
|
|
38
42
|
),
|
|
39
|
-
|
|
40
|
-
length=7,
|
|
41
|
-
sha256="d526db5593d5986500e4770cb6018f2d33c459ed93657487958a6086e52e6147",
|
|
42
|
-
),
|
|
43
|
+
OWNER_HANDLE_DIGEST,
|
|
43
44
|
PrivateTermDigest(
|
|
44
45
|
length=7,
|
|
45
46
|
sha256="968e2d5b08687bf42997461cbdef6c844eabbf04f440cee888c95b864c2a4bcc",
|
|
46
47
|
),
|
|
47
48
|
}
|
|
48
49
|
)
|
|
50
|
+
ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS: frozenset[PrivateTermDigest] = frozenset(
|
|
51
|
+
{OWNER_HANDLE_DIGEST}
|
|
52
|
+
)
|
|
49
53
|
PRIVATE_TERM_TEXT_ENCODING = "utf-8"
|
|
50
54
|
PRIVATE_TERM_FINDING_CODE = "private-term"
|
|
51
55
|
PRIVATE_TERM_MESSAGE_TEMPLATE = (
|
|
@@ -19,6 +19,7 @@ from collections.abc import Sequence
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
21
|
from dev_env_scripts_constants.private_term_constants import (
|
|
22
|
+
ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS,
|
|
22
23
|
ALL_EVENT_TEXT_FIELDS,
|
|
23
24
|
ALL_PRIVATE_TERM_DIGESTS,
|
|
24
25
|
COMMIT_FIELD_SEPARATOR,
|
|
@@ -34,18 +35,23 @@ from dev_env_scripts_constants.private_term_constants import (
|
|
|
34
35
|
PRIVATE_TERM_MESSAGE_TEMPLATE,
|
|
35
36
|
PRIVATE_TERM_TEXT_ENCODING,
|
|
36
37
|
SHORT_SHA_LENGTH,
|
|
38
|
+
PrivateTermDigest,
|
|
37
39
|
)
|
|
38
40
|
from private_terms import private_term_line_numbers
|
|
39
41
|
|
|
40
42
|
|
|
41
|
-
def _findings_for(
|
|
43
|
+
def _findings_for(
|
|
44
|
+
label: str,
|
|
45
|
+
text: str,
|
|
46
|
+
all_term_digests: frozenset[PrivateTermDigest],
|
|
47
|
+
) -> list[str]:
|
|
42
48
|
return [
|
|
43
49
|
FINDING_LINE_TEMPLATE.format(
|
|
44
50
|
label=label,
|
|
45
51
|
message=PRIVATE_TERM_MESSAGE_TEMPLATE.format(line_number=each_line_number),
|
|
46
52
|
)
|
|
47
53
|
for each_line_number in private_term_line_numbers(
|
|
48
|
-
text,
|
|
54
|
+
text, all_term_digests
|
|
49
55
|
)
|
|
50
56
|
]
|
|
51
57
|
|
|
@@ -67,7 +73,9 @@ def scan_event(event_path: Path) -> list[str]:
|
|
|
67
73
|
continue
|
|
68
74
|
text = event_object.get(each_field)
|
|
69
75
|
if isinstance(text, str):
|
|
70
|
-
all_findings.extend(
|
|
76
|
+
all_findings.extend(
|
|
77
|
+
_findings_for(each_label, text, ALL_PRIVATE_TERM_DIGESTS)
|
|
78
|
+
)
|
|
71
79
|
return all_findings
|
|
72
80
|
|
|
73
81
|
|
|
@@ -80,10 +88,12 @@ def _commit_findings(record: str) -> list[str]:
|
|
|
80
88
|
*_findings_for(
|
|
81
89
|
COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_IDENTITY_PART),
|
|
82
90
|
re.sub(GITHUB_NOREPLY_ADDRESS_PATTERN, MASKED_ADDRESS, identity, flags=re.IGNORECASE),
|
|
91
|
+
ALL_PRIVATE_TERM_DIGESTS - ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS,
|
|
83
92
|
),
|
|
84
93
|
*_findings_for(
|
|
85
94
|
COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_MESSAGE_PART),
|
|
86
95
|
message,
|
|
96
|
+
ALL_PRIVATE_TERM_DIGESTS,
|
|
87
97
|
),
|
|
88
98
|
]
|
|
89
99
|
|
|
@@ -99,6 +109,8 @@ def scan_commits(repository_root: Path, commit_range: str) -> list[str]:
|
|
|
99
109
|
One finding per commit identity or message line that names a private
|
|
100
110
|
organization. An identity address ending in users.noreply.github.com
|
|
101
111
|
is left out of the scan, since GitHub builds it from the account handle.
|
|
112
|
+
The owner's own handle passes in an identity and still fails in a
|
|
113
|
+
message.
|
|
102
114
|
"""
|
|
103
115
|
log_text = subprocess.run(
|
|
104
116
|
["git", "-C", str(repository_root), "log", "--reverse", COMMIT_LOG_FORMAT, commit_range],
|
|
@@ -9,6 +9,7 @@ import sys
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
11
|
import pytest
|
|
12
|
+
from dev_env_scripts_constants import private_term_constants
|
|
12
13
|
from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
|
|
13
14
|
|
|
14
15
|
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
|
|
@@ -18,7 +19,16 @@ if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
|
18
19
|
import private_term_scan
|
|
19
20
|
|
|
20
21
|
_FIXTURE_TERM = "acmewidget"
|
|
22
|
+
_FIXTURE_OWNER_HANDLE = "patowner"
|
|
21
23
|
_CLEAN_IDENTITY = ("Claude", "noreply@example.com")
|
|
24
|
+
_OWNER_HANDLE = "ohcEnoJ"[::-1]
|
|
25
|
+
_OWNER_IDENTITY = (_OWNER_HANDLE, f"24366590+{_OWNER_HANDLE}@users.noreply.github.com")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fixture_digest(term: str) -> PrivateTermDigest:
|
|
29
|
+
return PrivateTermDigest(
|
|
30
|
+
length=len(term), sha256=hashlib.sha256(term.encode("utf-8")).hexdigest()
|
|
31
|
+
)
|
|
22
32
|
|
|
23
33
|
|
|
24
34
|
@pytest.fixture(autouse=True)
|
|
@@ -26,14 +36,26 @@ def private_fixture_terms(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
26
36
|
monkeypatch.setattr(
|
|
27
37
|
private_term_scan,
|
|
28
38
|
"ALL_PRIVATE_TERM_DIGESTS",
|
|
29
|
-
frozenset(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
frozenset({_fixture_digest(_FIXTURE_TERM), _fixture_digest(_FIXTURE_OWNER_HANDLE)}),
|
|
40
|
+
)
|
|
41
|
+
monkeypatch.setattr(
|
|
42
|
+
private_term_scan,
|
|
43
|
+
"ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS",
|
|
44
|
+
frozenset({_fixture_digest(_FIXTURE_OWNER_HANDLE)}),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def shipped_private_terms(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
50
|
+
monkeypatch.setattr(
|
|
51
|
+
private_term_scan,
|
|
52
|
+
"ALL_PRIVATE_TERM_DIGESTS",
|
|
53
|
+
private_term_constants.ALL_PRIVATE_TERM_DIGESTS,
|
|
54
|
+
)
|
|
55
|
+
monkeypatch.setattr(
|
|
56
|
+
private_term_scan,
|
|
57
|
+
"ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS",
|
|
58
|
+
private_term_constants.ALL_COMMIT_IDENTITY_ACCEPTED_DIGESTS,
|
|
37
59
|
)
|
|
38
60
|
|
|
39
61
|
|
|
@@ -190,6 +212,57 @@ def test_should_flag_a_private_name_beside_a_github_noreply_address(
|
|
|
190
212
|
assert " identity:" in all_findings[0]
|
|
191
213
|
|
|
192
214
|
|
|
215
|
+
@pytest.mark.usefixtures("shipped_private_terms")
|
|
216
|
+
def test_should_accept_the_owner_handle_as_a_commit_identity(tmp_path: Path) -> None:
|
|
217
|
+
repository_root, base, head = _repository_with_commits(
|
|
218
|
+
tmp_path, [("feat: clean change", *_OWNER_IDENTITY)]
|
|
219
|
+
)
|
|
220
|
+
assert private_term_scan.scan_commits(repository_root, f"{base}..{head}") == []
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.usefixtures("shipped_private_terms")
|
|
224
|
+
def test_should_flag_the_owner_handle_in_a_commit_message(tmp_path: Path) -> None:
|
|
225
|
+
repository_root, base, head = _repository_with_commits(
|
|
226
|
+
tmp_path, [(f"docs: note\n\nWritten by {_OWNER_HANDLE}.", *_OWNER_IDENTITY)]
|
|
227
|
+
)
|
|
228
|
+
all_findings = private_term_scan.scan_commits(repository_root, f"{base}..{head}")
|
|
229
|
+
assert len(all_findings) == 1
|
|
230
|
+
assert " message: Line 3 " in all_findings[0]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_should_flag_a_private_name_in_the_message_of_an_owner_commit(
|
|
234
|
+
tmp_path: Path,
|
|
235
|
+
) -> None:
|
|
236
|
+
repository_root, base, head = _repository_with_commits(
|
|
237
|
+
tmp_path,
|
|
238
|
+
[
|
|
239
|
+
(
|
|
240
|
+
"docs: note\n\nWritten for Acme Widgets.",
|
|
241
|
+
"Pat Owner",
|
|
242
|
+
"81234567+patowner@users.noreply.github.com",
|
|
243
|
+
)
|
|
244
|
+
],
|
|
245
|
+
)
|
|
246
|
+
all_findings = private_term_scan.scan_commits(repository_root, f"{base}..{head}")
|
|
247
|
+
assert len(all_findings) == 1
|
|
248
|
+
assert " message: Line 3 " in all_findings[0]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_should_flag_a_private_name_in_a_pull_request_body_beside_an_accepted_handle(
|
|
252
|
+
tmp_path: Path,
|
|
253
|
+
) -> None:
|
|
254
|
+
event_path = _write_event(
|
|
255
|
+
tmp_path,
|
|
256
|
+
{"pull_request": {"title": "fix: one", "body": "By Pat Owner\nFor Acme Widgets\n"}},
|
|
257
|
+
)
|
|
258
|
+
assert private_term_scan.scan_event(event_path) == [
|
|
259
|
+
"pull request body: Line 1 names a private organization. "
|
|
260
|
+
"Describe it in general terms and drop any link to it.",
|
|
261
|
+
"pull request body: Line 2 names a private organization. "
|
|
262
|
+
"Describe it in general terms and drop any link to it.",
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
|
|
193
266
|
def test_should_print_findings_and_fail_from_the_command_line(
|
|
194
267
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
195
268
|
) -> None:
|