claude-dev-env 8.71.0 → 8.72.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/.agents/skills/build-eval/SKILL.md +3 -0
- package/docs/rule-guides/question-presentation.md +11 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -3
- package/hooks/features/write-edit-blocking.md +3 -6
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -7
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +2 -23
- package/package.json +1 -1
- package/rules/question-presentation.md +4 -2
- package/hooks/advisory/migration_safety_advisor.py +0 -74
- package/hooks/advisory/test_migration_safety_advisor.py +0 -204
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +0 -7
|
@@ -57,6 +57,9 @@ the arm with the plugin and the arm without it.
|
|
|
57
57
|
such a feature with the `claude -p` recipe in `reference/graders-and-commands.md`.
|
|
58
58
|
- `--keep-temp` on Windows warns that it cannot seal the folder. Delete that folder when done with
|
|
59
59
|
PowerShell `Remove-Item -Recurse -Force -LiteralPath <path>`.
|
|
60
|
+
- A loop the user asks to run until clean runs with no round cap and no retry cap. Use only the
|
|
61
|
+
limits the user set. Name any other stop condition to the user before the first launch, and
|
|
62
|
+
report each run it ended as stopped by that condition.
|
|
60
63
|
|
|
61
64
|
## When this applies
|
|
62
65
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Present questions clearly
|
|
2
|
+
|
|
3
|
+
Full text behind [`rules/question-presentation.md`](../../rules/question-presentation.md).
|
|
4
|
+
|
|
5
|
+
## Codex visual comparisons
|
|
6
|
+
|
|
7
|
+
For Codex visual comparisons, find Page creation, visualization upload, and Page opening tools. Create the comparison, upload its visualization, and open its Page here before asking. If tools are missing, steps fail, or opening queues, compare in chat before asking. Put inline Visualize references only in final replies. An async acknowledgement or saved visual is context only; wait for and use the submitted answer before dependent work.
|
|
8
|
+
|
|
9
|
+
## Option cards
|
|
10
|
+
|
|
11
|
+
When the user asks for option cards, build an Artifact page with the `html-plan:html-plan` skill and follow `~/.claude/docs/templates/html-plan/README.md`. Load the `artifact-capabilities` skill and declare the capability that saves the answer where you can read it. A tap selects a card, a second tap clears it, a picked card shows "Change it" with a notes field, and only Send delivers. After publishing, read the saved answer back before dependent work. If no capability can return the answer, use the native question tool with the same labels.
|
|
@@ -804,9 +804,7 @@ def test_dispatcher_allows_clean_apply_patch_add(tmp_path: Path) -> None:
|
|
|
804
804
|
assert not is_deny, reason_text
|
|
805
805
|
|
|
806
806
|
|
|
807
|
-
ALL_RETAINED_PATHS =
|
|
808
|
-
"advisory/migration_safety_advisor.py",
|
|
809
|
-
}
|
|
807
|
+
ALL_RETAINED_PATHS: set[str] = set()
|
|
810
808
|
ALL_RETIRED_PATHS = {
|
|
811
809
|
"validation/hook_format_validator.py",
|
|
812
810
|
"blocking/code_rules_enforcer.py",
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
# Write and Edit blocking
|
|
2
2
|
|
|
3
|
-
This family decides whether an agent's file mutation can run.
|
|
3
|
+
This family decides whether an agent's file mutation can run.
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
7
|
-
- `blocking/pre_tool_use_dispatcher.py`
|
|
8
|
-
- `advisory/migration_safety_advisor.py` warns when an Edit or MultiEdit adds an unsafe Django migration operation.
|
|
7
|
+
- `blocking/pre_tool_use_dispatcher.py` runs its native description check and returns one permission decision.
|
|
9
8
|
- `blocking/state_description_blocker.py` rejects historical or comparative prose in supported comments, docstrings, and Markdown.
|
|
10
9
|
|
|
11
10
|
## When it fires
|
|
12
11
|
|
|
13
12
|
- `blocking/pre_tool_use_dispatcher.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds in `hooks.json`.
|
|
14
|
-
- `advisory/migration_safety_advisor.py` runs inside that dispatcher on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. `ALL_HOSTED_HOOK_ENTRIES` selects `Edit` and `MultiEdit` for it.
|
|
15
13
|
- `blocking/state_description_blocker.py` runs through the dispatcher's native evaluator on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. Its evaluator checks `Write`, `Edit`, and `MultiEdit` payloads and skips `apply_patch`.
|
|
16
14
|
|
|
17
15
|
## Proving it
|
|
@@ -21,12 +19,11 @@ Preconditions:
|
|
|
21
19
|
- Run each command from the repository root with Python and pytest available. The tests use disposable paths where they need files.
|
|
22
20
|
|
|
23
21
|
- **Dispatcher decision.** Input is a clean Write payload. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pre_tool_use_dispatcher.py -q`. The adjacent test checks that the dispatcher allows clean writes and carries denials from its checks.
|
|
24
|
-
- **Migration warning.** Input is an Edit containing `migrations.RemoveField`. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_migration_safety_advisor.py -q`. The adjacent test observes an allow decision with a migration warning.
|
|
25
22
|
- **Description gate.** Input is a Write containing historical prose in a Python comment. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_state_description_blocker.py -q`. The adjacent test observes a denial reason.
|
|
26
23
|
|
|
27
24
|
## Gotchas
|
|
28
25
|
|
|
29
|
-
- `ALL_HOSTED_HOOK_ENTRIES`
|
|
26
|
+
- `ALL_HOSTED_HOOK_ENTRIES` is empty. The dispatcher calls the description evaluator through its native hook table.
|
|
30
27
|
- The dispatcher runs hosted entries in roster order. A hosted advisory crash stays silent; a native blocking check can deny the write.
|
|
31
28
|
- `scripts/policy_lint/adapter_detectors.py` also loads `blocking/state_description_blocker.py`. Check that adapter when changing its evaluator.
|
|
32
29
|
- `bin/install.mjs` names folded and retired script paths so reinstall can remove stale registrations.
|
|
@@ -82,10 +82,4 @@ class HostedHookEntry:
|
|
|
82
82
|
native_module_name: str | None = field(default=None)
|
|
83
83
|
|
|
84
84
|
|
|
85
|
-
ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
86
|
-
HostedHookEntry(
|
|
87
|
-
script_relative_path="advisory/migration_safety_advisor.py",
|
|
88
|
-
applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
|
|
89
|
-
is_blocking=False,
|
|
90
|
-
),
|
|
91
|
-
)
|
|
85
|
+
ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = ()
|
|
@@ -18,33 +18,12 @@ from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
|
18
18
|
ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
19
19
|
APPLY_PATCH_TOOL_NAME,
|
|
20
20
|
BLOCKING_CRASH_DENY_REASON,
|
|
21
|
-
EDIT_TOOL_NAME,
|
|
22
|
-
MULTI_EDIT_TOOL_NAME,
|
|
23
21
|
)
|
|
24
22
|
from pre_tool_use_dispatcher import HostedHookResult, aggregate_hosted_hook_results
|
|
25
23
|
|
|
26
24
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
each_entry
|
|
30
|
-
for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
31
|
-
if each_entry.script_relative_path == script_relative_path
|
|
32
|
-
]
|
|
33
|
-
return matching_entries[0] if matching_entries else None
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_roster_keeps_only_nonblocking_edit_advisors() -> None:
|
|
37
|
-
all_script_paths = tuple(
|
|
38
|
-
each_entry.script_relative_path for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
39
|
-
)
|
|
40
|
-
assert all_script_paths == ("advisory/migration_safety_advisor.py",)
|
|
41
|
-
assert all(not each_entry.is_blocking for each_entry in ALL_HOSTED_HOOK_ENTRIES)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_advisors_apply_to_edit_and_multi_edit() -> None:
|
|
45
|
-
entry = _entry_for("advisory/migration_safety_advisor.py")
|
|
46
|
-
assert entry is not None
|
|
47
|
-
assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME})
|
|
25
|
+
def test_roster_hosts_no_script() -> None:
|
|
26
|
+
assert ALL_HOSTED_HOOK_ENTRIES == ()
|
|
48
27
|
|
|
49
28
|
|
|
50
29
|
def test_every_hosted_script_path_exists_under_the_hooks_root() -> None:
|
package/package.json
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Before asking, give a short formatted chat brief with context and each choice's effect. Add helpful visuals or artifacts.
|
|
4
4
|
|
|
5
|
-
For Codex visual comparisons, find Page creation, visualization upload, and Page opening tools. Create the comparison, upload its visualization, and open its Page here before asking. If tools are missing, steps fail, or opening queues, compare in chat before asking. Put inline Visualize references only in final replies. An async acknowledgement or saved visual is context only; wait for and use the submitted answer before dependent work.
|
|
6
|
-
|
|
7
5
|
Ask one short, self-contained question with two or three choices. Give each a short label and one short sentence. Reuse labels in the brief, comparison, and picker.
|
|
8
6
|
|
|
9
7
|
Find the native question tool and schema. Claude may use `AskUserQuestion`; Codex may use `request_user_input_async` or `request_user_input`. Honor mode rules. Put the question in its title or question field and choices in string or object fields. If unusable, ask in chat.
|
|
8
|
+
|
|
9
|
+
When the user asks for option cards, or for a Codex visual comparison, read the full text first.
|
|
10
|
+
|
|
11
|
+
**Full text:** [`~/.claude/docs/rule-guides/question-presentation.md`](../docs/rule-guides/question-presentation.md).
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Advisory hook: warn when Django migrations contain unsafe operations."""
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import re
|
|
6
|
-
import sys
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
try:
|
|
10
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
11
|
-
if _hooks_dir not in sys.path:
|
|
12
|
-
sys.path.insert(0, _hooks_dir)
|
|
13
|
-
|
|
14
|
-
from hooks_constants.migration_safety_advisor_constants import (
|
|
15
|
-
MULTI_EDIT_TOOL_NAME,
|
|
16
|
-
)
|
|
17
|
-
from hooks_constants.multi_edit_reconstruction import joined_new_strings
|
|
18
|
-
except ImportError as import_error:
|
|
19
|
-
raise ImportError(
|
|
20
|
-
"migration_safety_advisor: cannot import its sibling modules; "
|
|
21
|
-
"ensure the hooks directory is importable."
|
|
22
|
-
) from import_error
|
|
23
|
-
|
|
24
|
-
MIGRATION_PATH_PATTERN = re.compile(r"[/\\]migrations[/\\]\d{4}_\w+\.py$")
|
|
25
|
-
UNSAFE_OPERATIONS = ["RemoveField", "RenameField", "DeleteModel", "RenameModel"]
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _resolve_content(tool_name: str, all_tool_input: dict) -> str:
|
|
29
|
-
"""Return the text a Write, Edit, or MultiEdit payload introduces."""
|
|
30
|
-
if tool_name == MULTI_EDIT_TOOL_NAME:
|
|
31
|
-
return joined_new_strings(all_tool_input)
|
|
32
|
-
return all_tool_input.get("content", "") or all_tool_input.get("new_string", "")
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def main() -> None:
|
|
36
|
-
try:
|
|
37
|
-
hook_input = json.load(sys.stdin)
|
|
38
|
-
except json.JSONDecodeError:
|
|
39
|
-
sys.exit(0)
|
|
40
|
-
|
|
41
|
-
tool_name = hook_input.get("tool_name", "")
|
|
42
|
-
tool_input = hook_input.get("tool_input", {})
|
|
43
|
-
file_path = tool_input.get("file_path", "")
|
|
44
|
-
|
|
45
|
-
if not MIGRATION_PATH_PATTERN.search(file_path):
|
|
46
|
-
sys.exit(0)
|
|
47
|
-
|
|
48
|
-
content = _resolve_content(tool_name, tool_input)
|
|
49
|
-
found_unsafe = [op for op in UNSAFE_OPERATIONS if op in content]
|
|
50
|
-
|
|
51
|
-
if found_unsafe:
|
|
52
|
-
operations = ", ".join(found_unsafe)
|
|
53
|
-
advisory_message = (
|
|
54
|
-
f"MIGRATION SAFETY: Contains {operations}. "
|
|
55
|
-
"After launch, model changes stay backwards-compatible, because running "
|
|
56
|
-
"instances keep reading the old schema until the deployment finishes. "
|
|
57
|
-
"Check that this migration keeps those instances working."
|
|
58
|
-
)
|
|
59
|
-
advisory_payload = {
|
|
60
|
-
"systemMessage": advisory_message,
|
|
61
|
-
"hookSpecificOutput": {
|
|
62
|
-
"hookEventName": "PreToolUse",
|
|
63
|
-
"permissionDecision": "allow",
|
|
64
|
-
"permissionDecisionReason": advisory_message,
|
|
65
|
-
"additionalContext": advisory_message,
|
|
66
|
-
},
|
|
67
|
-
}
|
|
68
|
-
print(json.dumps(advisory_payload))
|
|
69
|
-
|
|
70
|
-
sys.exit(0)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if __name__ == "__main__":
|
|
74
|
-
main()
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
"""Behavior tests for migration_safety_advisor through its production entry paths."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import subprocess
|
|
7
|
-
import sys
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
_ADVISORY_DIRECTORY = Path(__file__).resolve().parent
|
|
12
|
-
_HOOKS_DIRECTORY = _ADVISORY_DIRECTORY.parent
|
|
13
|
-
_ADVISOR_SCRIPT = _ADVISORY_DIRECTORY / "migration_safety_advisor.py"
|
|
14
|
-
_DISPATCHER_SCRIPT = _HOOKS_DIRECTORY / "blocking" / "pre_tool_use_dispatcher.py"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _run_hook(script_path: Path, payload: dict[str, object]) -> dict[str, object] | None:
|
|
18
|
-
completed_process = subprocess.run(
|
|
19
|
-
[sys.executable, str(script_path)],
|
|
20
|
-
check=False,
|
|
21
|
-
input=json.dumps(payload),
|
|
22
|
-
capture_output=True,
|
|
23
|
-
text=True,
|
|
24
|
-
encoding="utf-8",
|
|
25
|
-
)
|
|
26
|
-
assert completed_process.returncode == 0
|
|
27
|
-
if not completed_process.stdout.strip():
|
|
28
|
-
return None
|
|
29
|
-
parsed_payload = json.loads(completed_process.stdout)
|
|
30
|
-
assert isinstance(parsed_payload, dict)
|
|
31
|
-
return parsed_payload
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _build_edit_payload(file_path: Path, new_string: str) -> dict[str, object]:
|
|
35
|
-
return {
|
|
36
|
-
"tool_name": "Edit",
|
|
37
|
-
"tool_input": {
|
|
38
|
-
"file_path": str(file_path),
|
|
39
|
-
"old_string": "operations = []",
|
|
40
|
-
"new_string": new_string,
|
|
41
|
-
},
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _build_control_payload(tool_name: str, file_path: Path, content: str) -> dict[str, object]:
|
|
46
|
-
if tool_name == "Write":
|
|
47
|
-
tool_input: dict[str, object] = {
|
|
48
|
-
"file_path": str(file_path),
|
|
49
|
-
"content": content,
|
|
50
|
-
}
|
|
51
|
-
else:
|
|
52
|
-
tool_input = {
|
|
53
|
-
"file_path": str(file_path),
|
|
54
|
-
"edits": [{"old_string": "operations = []", "new_string": content}],
|
|
55
|
-
}
|
|
56
|
-
return {"tool_name": tool_name, "tool_input": tool_input}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _prepare_fresh_test_pair(migration_path: Path) -> None:
|
|
60
|
-
"""Write a migration module and its paired test file."""
|
|
61
|
-
migration_path.parent.mkdir(parents=True)
|
|
62
|
-
migration_path.write_text("operations = []\n", encoding="utf-8")
|
|
63
|
-
test_path = migration_path.with_name(f"test_{migration_path.stem}.py")
|
|
64
|
-
test_path.write_text("def test_migration_contract():\n assert True\n", encoding="utf-8")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _permission_fields(parsed_payload: dict[str, object]) -> dict[str, object]:
|
|
68
|
-
hook_specific = parsed_payload.get("hookSpecificOutput", {})
|
|
69
|
-
assert isinstance(hook_specific, dict)
|
|
70
|
-
return hook_specific
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _text_field(field_by_key: dict[str, object], field_name: str) -> str:
|
|
74
|
-
field_value = field_by_key.get(field_name)
|
|
75
|
-
assert isinstance(field_value, str)
|
|
76
|
-
return field_value
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def test_standalone_advisor_warns_for_unsafe_migration_edit(tmp_path: Path) -> None:
|
|
80
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
81
|
-
payload = _build_edit_payload(migration_path, 'operations = [migrations.RemoveField("name")]')
|
|
82
|
-
|
|
83
|
-
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
84
|
-
assert advisory_payload is not None
|
|
85
|
-
permission_fields = _permission_fields(advisory_payload)
|
|
86
|
-
|
|
87
|
-
assert permission_fields["permissionDecision"] == "allow"
|
|
88
|
-
assert "RemoveField" in _text_field(permission_fields, "additionalContext")
|
|
89
|
-
assert "MIGRATION SAFETY" in _text_field(advisory_payload, "systemMessage")
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def test_dispatcher_surfaces_migration_warning_for_edit(tmp_path: Path) -> None:
|
|
93
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
94
|
-
_prepare_fresh_test_pair(migration_path)
|
|
95
|
-
payload = _build_edit_payload(migration_path, 'operations = [migrations.RemoveField("name")]')
|
|
96
|
-
|
|
97
|
-
dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
|
|
98
|
-
assert dispatched_payload is not None
|
|
99
|
-
permission_fields = _permission_fields(dispatched_payload)
|
|
100
|
-
|
|
101
|
-
assert permission_fields["permissionDecision"] == "allow"
|
|
102
|
-
assert "RemoveField" in _text_field(permission_fields, "additionalContext")
|
|
103
|
-
assert "MIGRATION SAFETY" in _text_field(dispatched_payload, "systemMessage")
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def test_dispatcher_keeps_migration_advisor_write_excluded(tmp_path: Path) -> None:
|
|
107
|
-
"""Write stays out of the migration advisor's roster; only Edit and MultiEdit reach it."""
|
|
108
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
109
|
-
_prepare_fresh_test_pair(migration_path)
|
|
110
|
-
payload = _build_control_payload(
|
|
111
|
-
"Write",
|
|
112
|
-
migration_path,
|
|
113
|
-
'operations = [migrations.RemoveField("name")]',
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
|
|
117
|
-
serialized_payload = json.dumps(dispatched_payload or {})
|
|
118
|
-
|
|
119
|
-
assert "MIGRATION SAFETY" not in serialized_payload
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def test_dispatcher_extends_migration_advisor_to_multi_edit(tmp_path: Path) -> None:
|
|
123
|
-
"""A MultiEdit reaches the migration advisor the same way an Edit does."""
|
|
124
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
125
|
-
_prepare_fresh_test_pair(migration_path)
|
|
126
|
-
payload = _build_control_payload(
|
|
127
|
-
"MultiEdit",
|
|
128
|
-
migration_path,
|
|
129
|
-
'operations = [migrations.RemoveField("name")]',
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
|
|
133
|
-
assert dispatched_payload is not None
|
|
134
|
-
serialized_payload = json.dumps(dispatched_payload)
|
|
135
|
-
|
|
136
|
-
assert "MIGRATION SAFETY" in serialized_payload
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _build_multi_edit_payload(
|
|
140
|
-
file_path: Path, all_edits: list[dict[str, str]]
|
|
141
|
-
) -> dict[str, object]:
|
|
142
|
-
return {
|
|
143
|
-
"tool_name": "MultiEdit",
|
|
144
|
-
"tool_input": {"file_path": str(file_path), "edits": all_edits},
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def test_standalone_advisor_warns_for_unsafe_operation_in_second_multi_edit(
|
|
149
|
-
tmp_path: Path,
|
|
150
|
-
) -> None:
|
|
151
|
-
"""An unsafe operation in the second edit of a MultiEdit is caught, not only the first.
|
|
152
|
-
|
|
153
|
-
migration_safety_advisor has no tool_name gate today: it reads
|
|
154
|
-
tool_input.get("new_string"), a key a MultiEdit payload never carries at
|
|
155
|
-
the top level. Widening its roster entry alone would silently do
|
|
156
|
-
nothing on a real MultiEdit call.
|
|
157
|
-
"""
|
|
158
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
159
|
-
payload = _build_multi_edit_payload(
|
|
160
|
-
migration_path,
|
|
161
|
-
[
|
|
162
|
-
{"old_string": "operations = []", "new_string": "operations = [migrations.AddField()]"},
|
|
163
|
-
{
|
|
164
|
-
"old_string": "migrations.AddField()",
|
|
165
|
-
"new_string": 'migrations.AddField(), migrations.RemoveField("name")',
|
|
166
|
-
},
|
|
167
|
-
],
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
171
|
-
assert advisory_payload is not None
|
|
172
|
-
permission_fields = _permission_fields(advisory_payload)
|
|
173
|
-
|
|
174
|
-
assert permission_fields["permissionDecision"] == "allow"
|
|
175
|
-
assert "RemoveField" in _text_field(permission_fields, "additionalContext")
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def test_standalone_advisor_reads_a_write_payloads_content_field(tmp_path: Path) -> None:
|
|
179
|
-
"""_resolve_content's non-MultiEdit branch reads a Write payload's "content" key."""
|
|
180
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
181
|
-
payload: dict[str, object] = {
|
|
182
|
-
"tool_name": "Write",
|
|
183
|
-
"tool_input": {
|
|
184
|
-
"file_path": str(migration_path),
|
|
185
|
-
"content": 'operations = [migrations.RemoveField("name")]',
|
|
186
|
-
},
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
190
|
-
assert advisory_payload is not None
|
|
191
|
-
assert "RemoveField" in _text_field(
|
|
192
|
-
_permission_fields(advisory_payload), "additionalContext"
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def test_standalone_advisor_is_silent_for_a_clean_multi_edit(tmp_path: Path) -> None:
|
|
197
|
-
migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
|
|
198
|
-
payload = _build_multi_edit_payload(
|
|
199
|
-
migration_path,
|
|
200
|
-
[{"old_string": "operations = []", "new_string": "operations = [migrations.AddField()]"}],
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
|
|
204
|
-
assert advisory_payload is None
|