claude-dev-env 1.14.0 → 1.15.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/hooks/blocking/prompt-workflow-stop-guard.py +12 -1
- package/hooks/blocking/prompt_workflow_clipboard.py +63 -0
- package/hooks/blocking/prompt_workflow_gate_config.py +1 -5
- package/hooks/blocking/prompt_workflow_gate_core.py +54 -3
- package/hooks/blocking/test_prompt_workflow_clipboard.py +54 -0
- package/hooks/blocking/test_prompt_workflow_gate_core.py +25 -6
- package/hooks/blocking/test_prompt_workflow_stop_guard.py +11 -3
- package/package.json +1 -1
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Stop hook gate for prompt-workflow leakage and deterministic audit coverage.
|
|
2
|
+
"""Stop hook gate for prompt-workflow leakage and deterministic audit coverage.
|
|
3
|
+
|
|
4
|
+
When every workflow gate passes, the fenced ``xml`` artifact body is copied to the
|
|
5
|
+
system clipboard via :mod:`prompt_workflow_clipboard` (tkinter, then pyperclip).
|
|
6
|
+
Set ``PROMPT_WORKFLOW_SKIP_CLIPBOARD=1`` to disable (tests, CI, headless).
|
|
7
|
+
"""
|
|
3
8
|
|
|
4
9
|
from __future__ import annotations
|
|
5
10
|
|
|
@@ -9,7 +14,9 @@ import sys
|
|
|
9
14
|
from collections.abc import Callable
|
|
10
15
|
from pathlib import Path
|
|
11
16
|
|
|
17
|
+
from prompt_workflow_clipboard import copy_text_to_system_clipboard
|
|
12
18
|
from prompt_workflow_gate_core import (
|
|
19
|
+
extract_fenced_xml_content,
|
|
13
20
|
find_ambiguous_scope_terms,
|
|
14
21
|
find_negative_keywords_in_fenced_xml,
|
|
15
22
|
has_debug_intent,
|
|
@@ -199,6 +206,10 @@ def main() -> None:
|
|
|
199
206
|
|
|
200
207
|
if block is not None:
|
|
201
208
|
sys.stdout.write(json.dumps(block) + "\n")
|
|
209
|
+
elif is_prompt_workflow_response(assistant_message):
|
|
210
|
+
artifact_text = extract_fenced_xml_content(assistant_message).strip()
|
|
211
|
+
if artifact_text:
|
|
212
|
+
copy_text_to_system_clipboard(artifact_text)
|
|
202
213
|
|
|
203
214
|
sys.exit(0)
|
|
204
215
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cross-platform clipboard writes for prompt-workflow XML artifacts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _clipboard_disabled_by_env() -> bool:
|
|
10
|
+
flag = os.environ.get("PROMPT_WORKFLOW_SKIP_CLIPBOARD", "").strip().lower()
|
|
11
|
+
return flag in {"1", "true", "yes", "on"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def copy_text_to_system_clipboard(text: str) -> bool:
|
|
15
|
+
"""Write ``text`` to the OS clipboard using Python-only backends.
|
|
16
|
+
|
|
17
|
+
Tries :mod:`tkinter` (stdlib) first, then optional ``pyperclip`` if installed.
|
|
18
|
+
Returns ``True`` when a backend reports success, ``False`` otherwise
|
|
19
|
+
(missing dependency, headless display, empty payload, or env opt-out).
|
|
20
|
+
"""
|
|
21
|
+
if not text.strip():
|
|
22
|
+
return False
|
|
23
|
+
if _clipboard_disabled_by_env():
|
|
24
|
+
return False
|
|
25
|
+
if _copy_via_tkinter(text):
|
|
26
|
+
return True
|
|
27
|
+
return _copy_via_pyperclip(text)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _copy_via_tkinter(text: str) -> bool:
|
|
31
|
+
try:
|
|
32
|
+
import tkinter as tk
|
|
33
|
+
except ImportError:
|
|
34
|
+
return False
|
|
35
|
+
root: tk.Tk | None = None
|
|
36
|
+
try:
|
|
37
|
+
root = tk.Tk()
|
|
38
|
+
root.withdraw()
|
|
39
|
+
root.clipboard_clear()
|
|
40
|
+
root.clipboard_append(text)
|
|
41
|
+
root.update_idletasks()
|
|
42
|
+
root.update()
|
|
43
|
+
return True
|
|
44
|
+
except Exception:
|
|
45
|
+
return False
|
|
46
|
+
finally:
|
|
47
|
+
if root is not None:
|
|
48
|
+
try:
|
|
49
|
+
root.destroy()
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _copy_via_pyperclip(text: str) -> bool:
|
|
55
|
+
try:
|
|
56
|
+
import pyperclip
|
|
57
|
+
except ImportError:
|
|
58
|
+
return False
|
|
59
|
+
try:
|
|
60
|
+
pyperclip.copy(text)
|
|
61
|
+
return True
|
|
62
|
+
except Exception:
|
|
63
|
+
return False
|
|
@@ -96,16 +96,12 @@ NEGATIVE_INDIRECT_PATTERNS_IN_ARTIFACT: tuple[str, ...] = (
|
|
|
96
96
|
|
|
97
97
|
REQUIRED_XML_SECTIONS: tuple[str, ...] = (
|
|
98
98
|
"role",
|
|
99
|
-
"
|
|
99
|
+
"background",
|
|
100
100
|
"instructions",
|
|
101
101
|
"constraints",
|
|
102
102
|
"output_format",
|
|
103
103
|
)
|
|
104
104
|
|
|
105
|
-
FENCED_XML_BLOCK_PATTERN: re.Pattern[str] = re.compile(
|
|
106
|
-
r"```xml\s*\n(.*?)```", re.DOTALL
|
|
107
|
-
)
|
|
108
|
-
|
|
109
105
|
COMPILED_NEGATIVE_KEYWORD_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
|
|
110
106
|
re.compile(rf"\b{re.escape(keyword)}\b", re.IGNORECASE)
|
|
111
107
|
for keyword in NEGATIVE_KEYWORDS_IN_ARTIFACT
|
|
@@ -11,7 +11,6 @@ from prompt_workflow_gate_config import (
|
|
|
11
11
|
COMPILED_NEGATIVE_INDIRECT_PATTERNS,
|
|
12
12
|
COMPILED_NEGATIVE_KEYWORD_PATTERNS,
|
|
13
13
|
DEBUG_INTENT_MARKERS,
|
|
14
|
-
FENCED_XML_BLOCK_PATTERN,
|
|
15
14
|
INTERNAL_OBJECT_MARKERS,
|
|
16
15
|
PROMPT_WORKFLOW_RESPONSE_MARKERS,
|
|
17
16
|
REQUIRED_CHECKLIST_ROWS,
|
|
@@ -20,9 +19,61 @@ from prompt_workflow_gate_config import (
|
|
|
20
19
|
)
|
|
21
20
|
|
|
22
21
|
|
|
22
|
+
def _line_opens_xml_fence(line: str) -> bool:
|
|
23
|
+
stripped = line.strip()
|
|
24
|
+
if not stripped.startswith("```"):
|
|
25
|
+
return False
|
|
26
|
+
remainder = stripped[3:].strip()
|
|
27
|
+
return remainder == "xml" or remainder.startswith("xml ")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _line_is_bare_fence_close(line: str) -> bool:
|
|
31
|
+
return line.strip() == "```"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _line_opens_inner_markdown_fence(line: str) -> bool:
|
|
35
|
+
stripped = line.strip()
|
|
36
|
+
if not stripped.startswith("```"):
|
|
37
|
+
return False
|
|
38
|
+
return stripped != "```"
|
|
39
|
+
|
|
40
|
+
|
|
23
41
|
def extract_fenced_xml_content(text: str) -> str:
|
|
24
|
-
|
|
25
|
-
|
|
42
|
+
"""Extract bodies of ```xml fenced blocks.
|
|
43
|
+
|
|
44
|
+
The closing delimiter is a line whose stripped text is exactly three backticks.
|
|
45
|
+
Inner Markdown code fences (for example a line starting with three backticks
|
|
46
|
+
plus a language tag) are scanned until their own closing backtick line so the
|
|
47
|
+
outer ``xml`` fence does not end early.
|
|
48
|
+
"""
|
|
49
|
+
results: list[str] = []
|
|
50
|
+
lines = text.splitlines()
|
|
51
|
+
index = 0
|
|
52
|
+
while index < len(lines):
|
|
53
|
+
if _line_opens_xml_fence(lines[index]):
|
|
54
|
+
index += 1
|
|
55
|
+
body_lines: list[str] = []
|
|
56
|
+
while index < len(lines):
|
|
57
|
+
line = lines[index]
|
|
58
|
+
if _line_is_bare_fence_close(line):
|
|
59
|
+
index += 1
|
|
60
|
+
break
|
|
61
|
+
if _line_opens_inner_markdown_fence(line):
|
|
62
|
+
body_lines.append(line)
|
|
63
|
+
index += 1
|
|
64
|
+
while index < len(lines):
|
|
65
|
+
inner_line = lines[index]
|
|
66
|
+
body_lines.append(inner_line)
|
|
67
|
+
index += 1
|
|
68
|
+
if _line_is_bare_fence_close(inner_line):
|
|
69
|
+
break
|
|
70
|
+
continue
|
|
71
|
+
body_lines.append(line)
|
|
72
|
+
index += 1
|
|
73
|
+
results.append("\n".join(body_lines))
|
|
74
|
+
continue
|
|
75
|
+
index += 1
|
|
76
|
+
return "\n".join(results)
|
|
26
77
|
|
|
27
78
|
|
|
28
79
|
def missing_required_xml_sections(text: str) -> list[str]:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Tests for prompt_workflow_clipboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from prompt_workflow_clipboard import copy_text_to_system_clipboard
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_empty_text_returns_false() -> None:
|
|
11
|
+
assert copy_text_to_system_clipboard("") is False
|
|
12
|
+
assert copy_text_to_system_clipboard(" \n") is False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_respects_skip_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
16
|
+
monkeypatch.setenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", "1")
|
|
17
|
+
assert copy_text_to_system_clipboard("hello") is False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.parametrize(
|
|
21
|
+
"flag_value",
|
|
22
|
+
("1", "true", "YES", "on"),
|
|
23
|
+
)
|
|
24
|
+
def test_skip_env_variants(monkeypatch: pytest.MonkeyPatch, flag_value: str) -> None:
|
|
25
|
+
monkeypatch.setenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", flag_value)
|
|
26
|
+
assert copy_text_to_system_clipboard("payload") is False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_prefers_tkinter_when_it_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
30
|
+
monkeypatch.delenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", raising=False)
|
|
31
|
+
monkeypatch.setattr(
|
|
32
|
+
"prompt_workflow_clipboard._copy_via_tkinter",
|
|
33
|
+
lambda t: True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def _fail_pyperclip(_t: str) -> bool:
|
|
37
|
+
raise AssertionError("pyperclip must run only when tkinter fails")
|
|
38
|
+
|
|
39
|
+
monkeypatch.setattr("prompt_workflow_clipboard._copy_via_pyperclip", _fail_pyperclip)
|
|
40
|
+
assert copy_text_to_system_clipboard("hello") is True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_falls_back_to_pyperclip(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
44
|
+
monkeypatch.delenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", raising=False)
|
|
45
|
+
monkeypatch.setattr("prompt_workflow_clipboard._copy_via_tkinter", lambda t: False)
|
|
46
|
+
monkeypatch.setattr("prompt_workflow_clipboard._copy_via_pyperclip", lambda t: True)
|
|
47
|
+
assert copy_text_to_system_clipboard("hello") is True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_returns_false_when_both_backends_fail(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
51
|
+
monkeypatch.delenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", raising=False)
|
|
52
|
+
monkeypatch.setattr("prompt_workflow_clipboard._copy_via_tkinter", lambda t: False)
|
|
53
|
+
monkeypatch.setattr("prompt_workflow_clipboard._copy_via_pyperclip", lambda t: False)
|
|
54
|
+
assert copy_text_to_system_clipboard("hello") is False
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Unit tests for shared prompt workflow gate logic."""
|
|
2
2
|
|
|
3
3
|
from prompt_workflow_gate_core import (
|
|
4
|
+
extract_fenced_xml_content,
|
|
4
5
|
find_ambiguous_scope_terms,
|
|
5
6
|
has_checklist_container,
|
|
6
7
|
has_internal_object_leak,
|
|
@@ -62,7 +63,7 @@ def _fenced_xml(body: str) -> str:
|
|
|
62
63
|
def test_missing_required_xml_sections_all_present_returns_empty() -> None:
|
|
63
64
|
body = (
|
|
64
65
|
"<role>R.</role>\n"
|
|
65
|
-
"<
|
|
66
|
+
"<background>C.</background>\n"
|
|
66
67
|
"<instructions>I.</instructions>\n"
|
|
67
68
|
"<constraints>Co.</constraints>\n"
|
|
68
69
|
"<output_format>O.</output_format>\n"
|
|
@@ -70,19 +71,19 @@ def test_missing_required_xml_sections_all_present_returns_empty() -> None:
|
|
|
70
71
|
assert missing_required_xml_sections(_fenced_xml(body)) == []
|
|
71
72
|
|
|
72
73
|
|
|
73
|
-
def
|
|
74
|
+
def test_missing_required_xml_sections_missing_background() -> None:
|
|
74
75
|
body = (
|
|
75
76
|
"<role>R.</role>\n"
|
|
76
77
|
"<instructions>I.</instructions>\n"
|
|
77
78
|
"<constraints>Co.</constraints>\n"
|
|
78
79
|
"<output_format>O.</output_format>\n"
|
|
79
80
|
)
|
|
80
|
-
assert missing_required_xml_sections(_fenced_xml(body)) == ["
|
|
81
|
+
assert missing_required_xml_sections(_fenced_xml(body)) == ["background"]
|
|
81
82
|
|
|
82
83
|
|
|
83
84
|
def test_missing_required_xml_sections_missing_role_and_output_format() -> None:
|
|
84
85
|
body = (
|
|
85
|
-
"<
|
|
86
|
+
"<background>C.</background>\n"
|
|
86
87
|
"<instructions>I.</instructions>\n"
|
|
87
88
|
"<constraints>Co.</constraints>\n"
|
|
88
89
|
)
|
|
@@ -97,9 +98,27 @@ def test_missing_required_xml_sections_no_fence_returns_empty() -> None:
|
|
|
97
98
|
def test_missing_required_xml_sections_prose_without_tags_counts_as_missing() -> None:
|
|
98
99
|
body = (
|
|
99
100
|
"<role>R.</role>\n"
|
|
100
|
-
"
|
|
101
|
+
"background appears in prose but has no tags.\n"
|
|
101
102
|
"<instructions>I.</instructions>\n"
|
|
102
103
|
"<constraints>Co.</constraints>\n"
|
|
103
104
|
"<output_format>O.</output_format>\n"
|
|
104
105
|
)
|
|
105
|
-
assert missing_required_xml_sections(_fenced_xml(body)) == ["
|
|
106
|
+
assert missing_required_xml_sections(_fenced_xml(body)) == ["background"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_extract_fenced_xml_preserves_content_after_nested_inner_fence() -> None:
|
|
110
|
+
message = (
|
|
111
|
+
"```xml\n"
|
|
112
|
+
"<role>R</role>\n"
|
|
113
|
+
"<illustrations>\n"
|
|
114
|
+
"```bash\necho hi\n```\n"
|
|
115
|
+
"</illustrations>\n"
|
|
116
|
+
"<background>B</background>\n"
|
|
117
|
+
"<instructions>I</instructions>\n"
|
|
118
|
+
"<constraints>C</constraints>\n"
|
|
119
|
+
"<output_format>O</output_format>\n"
|
|
120
|
+
"```\n"
|
|
121
|
+
)
|
|
122
|
+
extracted = extract_fenced_xml_content(message)
|
|
123
|
+
assert "</illustrations>" in extracted
|
|
124
|
+
assert "<background>B</background>" in extracted
|
|
@@ -10,6 +10,14 @@ import pytest
|
|
|
10
10
|
|
|
11
11
|
SCRIPT_PATH = Path(__file__).parent / "prompt-workflow-stop-guard.py"
|
|
12
12
|
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(autouse=True)
|
|
15
|
+
def _disable_prompt_workflow_clipboard_in_subprocess(
|
|
16
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Subprocess hook inherits env; clipboard would be flaky in CI."""
|
|
19
|
+
monkeypatch.setenv("PROMPT_WORKFLOW_SKIP_CLIPBOARD", "1")
|
|
20
|
+
|
|
13
21
|
def _run_hook(payload: dict) -> subprocess.CompletedProcess[str]:
|
|
14
22
|
return subprocess.run(
|
|
15
23
|
[sys.executable, str(SCRIPT_PATH)],
|
|
@@ -121,7 +129,7 @@ def test_blocks_ambiguous_scope_phrasing() -> None:
|
|
|
121
129
|
def _wrap_five_section_scaffold(inner_body: str) -> str:
|
|
122
130
|
return (
|
|
123
131
|
"<role>Test role sentence one.</role>\n"
|
|
124
|
-
"<
|
|
132
|
+
"<background>Test background sentence one.</background>\n"
|
|
125
133
|
f"{inner_body}\n"
|
|
126
134
|
"<constraints>Test constraints sentence one.</constraints>\n"
|
|
127
135
|
"<output_format>Test output format sentence one.</output_format>\n"
|
|
@@ -218,7 +226,7 @@ def test_permits_negative_keywords_outside_fenced_xml() -> None:
|
|
|
218
226
|
assert result.stdout.strip() == ""
|
|
219
227
|
|
|
220
228
|
|
|
221
|
-
def
|
|
229
|
+
def test_blocks_when_fenced_xml_missing_background_section() -> None:
|
|
222
230
|
fenced_body = (
|
|
223
231
|
"<role>Test role sentence one.</role>\n"
|
|
224
232
|
"<instructions>Test instructions sentence one.</instructions>\n"
|
|
@@ -231,7 +239,7 @@ def test_blocks_when_fenced_xml_missing_context_section() -> None:
|
|
|
231
239
|
result = _run_hook(payload)
|
|
232
240
|
response = json.loads(result.stdout)
|
|
233
241
|
assert response["decision"] == "block"
|
|
234
|
-
assert "
|
|
242
|
+
assert "background" in response["reason"]
|
|
235
243
|
assert "include all required XML sections" in response["systemMessage"]
|
|
236
244
|
|
|
237
245
|
|