claude-dev-env 8.65.1 → 8.66.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/artifact_dark_mode_gate.py +184 -0
- package/hooks/blocking/artifact_dark_mode_render.cjs +132 -0
- package/hooks/blocking/test_artifact_dark_mode_gate.py +114 -0
- package/hooks/features/conduct-gates.md +4 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/artifact_dark_mode_gate_constants.py +57 -0
- package/hooks/hooks_constants/test_artifact_dark_mode_gate_constants.py +17 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +4 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook that stops an artifact page from publishing when dark mode hides its text.
|
|
3
|
+
|
|
4
|
+
The artifact service wraps every published page in a skeleton whose style pins
|
|
5
|
+
``body`` to a light background and dark text. A page that flips its theme
|
|
6
|
+
tokens on ``html`` alone keeps that light body in dark mode, and its light
|
|
7
|
+
dark-mode text lands on it. A plain local render has no wrapper, so the page
|
|
8
|
+
looks fine there and fails once published.
|
|
9
|
+
|
|
10
|
+
This gate renders the page inside that same wrapper in headless Chromium,
|
|
11
|
+
once under ``prefers-color-scheme: dark`` and once with ``data-theme="dark"``
|
|
12
|
+
on the root element::
|
|
13
|
+
|
|
14
|
+
Artifact {"action": "publish", "file_path": "page.html"} -> rendered
|
|
15
|
+
body stays light, or visible text under 4.5:1 -> denied
|
|
16
|
+
every check holds -> allowed
|
|
17
|
+
Artifact {"action": "read", ...} -> allowed
|
|
18
|
+
Artifact {"asset": true, ...} or {"type_url": ...} -> allowed
|
|
19
|
+
a publish whose file_path is not .html -> allowed
|
|
20
|
+
|
|
21
|
+
A host without Playwright or Chromium is denied with the install command,
|
|
22
|
+
because an unrendered page is the case this gate exists to stop.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
34
|
+
if hooks_root_directory not in sys.path:
|
|
35
|
+
sys.path.insert(0, hooks_root_directory)
|
|
36
|
+
|
|
37
|
+
from hooks_constants.artifact_dark_mode_gate_constants import (
|
|
38
|
+
ALL_SKIPPED_PUBLISH_INPUT_KEYS,
|
|
39
|
+
ALLOW_EXIT_CODE,
|
|
40
|
+
ARTIFACT_ACTION_INPUT_KEY,
|
|
41
|
+
ARTIFACT_FILE_PATH_INPUT_KEY,
|
|
42
|
+
ARTIFACT_PUBLISH_ACTION,
|
|
43
|
+
ARTIFACT_TOOL_NAME,
|
|
44
|
+
BLOCK_EXIT_CODE,
|
|
45
|
+
DARK_MODE_FAILURE_HEADER,
|
|
46
|
+
GATE_EVENT_NAME,
|
|
47
|
+
HTML_FILE_SUFFIX,
|
|
48
|
+
PUBLISH_SKELETON_PREFIX,
|
|
49
|
+
RENDERER_MISSING_EXIT_CODE,
|
|
50
|
+
RENDERER_MISSING_HEADER,
|
|
51
|
+
RENDERER_SCRIPT_PATH,
|
|
52
|
+
RENDERER_TIMEOUT_SECONDS,
|
|
53
|
+
RETRY_INSTRUCTION,
|
|
54
|
+
)
|
|
55
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
56
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
57
|
+
from hooks_constants.spawn_readiness_hook_constants import TOOL_INPUT_KEY, TOOL_NAME_KEY
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def published_page_path(tool_name: object, tool_input: object) -> Path | None:
|
|
61
|
+
"""Return the local HTML page a call publishes as an artifact page, or None.
|
|
62
|
+
|
|
63
|
+
>>> published_page_path("Artifact", {"file_path": "/tmp/page.html"})
|
|
64
|
+
PosixPath('/tmp/page.html')
|
|
65
|
+
>>> published_page_path("Artifact", {"action": "read", "url": "x"}) is None
|
|
66
|
+
True
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
tool_name: The tool the session called.
|
|
70
|
+
tool_input: The tool input.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The page path for a page publish, or None for any other call.
|
|
74
|
+
"""
|
|
75
|
+
if tool_name != ARTIFACT_TOOL_NAME or not isinstance(tool_input, dict):
|
|
76
|
+
return None
|
|
77
|
+
if (
|
|
78
|
+
tool_input.get(ARTIFACT_ACTION_INPUT_KEY, ARTIFACT_PUBLISH_ACTION)
|
|
79
|
+
!= ARTIFACT_PUBLISH_ACTION
|
|
80
|
+
):
|
|
81
|
+
return None
|
|
82
|
+
if any(tool_input.get(each_key) for each_key in ALL_SKIPPED_PUBLISH_INPUT_KEYS):
|
|
83
|
+
return None
|
|
84
|
+
file_path = tool_input.get(ARTIFACT_FILE_PATH_INPUT_KEY)
|
|
85
|
+
if not isinstance(file_path, str) or not file_path.lower().endswith(HTML_FILE_SUFFIX):
|
|
86
|
+
return None
|
|
87
|
+
return Path(file_path)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def wrap_in_publish_skeleton(page_source: str) -> str:
|
|
91
|
+
"""Return the page as the artifact service serves it, inside its skeleton.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
page_source: The HTML the session is about to publish.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The skeleton head and body opening followed by the page source.
|
|
98
|
+
"""
|
|
99
|
+
return PUBLISH_SKELETON_PREFIX + page_source
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _run_renderer(wrapped_page_path: Path) -> subprocess.CompletedProcess[str] | str:
|
|
103
|
+
try:
|
|
104
|
+
return subprocess.run(
|
|
105
|
+
["node", str(RENDERER_SCRIPT_PATH), str(wrapped_page_path)],
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True,
|
|
108
|
+
timeout=RENDERER_TIMEOUT_SECONDS,
|
|
109
|
+
check=False,
|
|
110
|
+
)
|
|
111
|
+
except FileNotFoundError:
|
|
112
|
+
return "node is not installed; install Node.js, then run: npm install -g playwright && npx playwright install chromium"
|
|
113
|
+
except subprocess.TimeoutExpired:
|
|
114
|
+
return f"the render took longer than {RENDERER_TIMEOUT_SECONDS} seconds"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def render_findings(page_source: str) -> tuple[dict[str, list[str]] | None, str]:
|
|
118
|
+
"""Render the wrapped page in both dark signals and return what failed.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
page_source: The HTML the session is about to publish.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The findings by dark signal (empty when the page passes) and an empty
|
|
125
|
+
message, or None and the reason the renderer could not run.
|
|
126
|
+
"""
|
|
127
|
+
with tempfile.TemporaryDirectory() as scratch_directory:
|
|
128
|
+
wrapped_page_path = Path(scratch_directory) / "wrapped_page.html"
|
|
129
|
+
wrapped_page_path.write_text(wrap_in_publish_skeleton(page_source), encoding="utf-8")
|
|
130
|
+
completed_render = _run_renderer(wrapped_page_path)
|
|
131
|
+
if isinstance(completed_render, str):
|
|
132
|
+
return None, completed_render
|
|
133
|
+
if completed_render.returncode == RENDERER_MISSING_EXIT_CODE:
|
|
134
|
+
return None, completed_render.stderr.strip()
|
|
135
|
+
try:
|
|
136
|
+
findings_by_signal = json.loads(completed_render.stdout)
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
return None, completed_render.stderr.strip() or "the renderer printed no result"
|
|
139
|
+
return findings_by_signal, ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def format_block_reason(findings_by_signal: dict[str, list[str]]) -> str:
|
|
143
|
+
"""Return the deny message that lists each failed check under its dark signal.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
findings_by_signal: Findings keyed by the dark signal that produced them.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The message the session reads when the publish is denied.
|
|
150
|
+
"""
|
|
151
|
+
all_lines = [DARK_MODE_FAILURE_HEADER]
|
|
152
|
+
for each_signal, each_signal_findings in findings_by_signal.items():
|
|
153
|
+
all_lines.append(f"Under {each_signal}:\n")
|
|
154
|
+
all_lines.extend(f"- {each_finding}\n" for each_finding in each_signal_findings)
|
|
155
|
+
return "".join(all_lines) + RETRY_INSTRUCTION
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main() -> int:
|
|
159
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
160
|
+
if hook_input is None:
|
|
161
|
+
return ALLOW_EXIT_CODE
|
|
162
|
+
page_path = published_page_path(hook_input.get(TOOL_NAME_KEY), hook_input.get(TOOL_INPUT_KEY))
|
|
163
|
+
if page_path is None or not page_path.is_file():
|
|
164
|
+
return ALLOW_EXIT_CODE
|
|
165
|
+
findings_by_signal, renderer_problem = render_findings(page_path.read_text(encoding="utf-8"))
|
|
166
|
+
if findings_by_signal is None:
|
|
167
|
+
block_reason = RENDERER_MISSING_HEADER + renderer_problem
|
|
168
|
+
elif findings_by_signal:
|
|
169
|
+
block_reason = format_block_reason(findings_by_signal)
|
|
170
|
+
else:
|
|
171
|
+
return ALLOW_EXIT_CODE
|
|
172
|
+
log_hook_block(
|
|
173
|
+
Path(__file__).name,
|
|
174
|
+
GATE_EVENT_NAME,
|
|
175
|
+
block_reason,
|
|
176
|
+
tool_name=ARTIFACT_TOOL_NAME,
|
|
177
|
+
offending_input_preview=str(page_path),
|
|
178
|
+
)
|
|
179
|
+
sys.stderr.write(block_reason)
|
|
180
|
+
return BLOCK_EXIT_CODE
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
sys.exit(main())
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const childProcess = require("child_process");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const MINIMUM_CONTRAST_RATIO = 4.5;
|
|
9
|
+
const MAXIMUM_DARK_PAGE_LUMINANCE = 0.2;
|
|
10
|
+
const MAXIMUM_REPORTED_TEXT_FAILURES = 8;
|
|
11
|
+
const TEXT_SNIPPET_LENGTH = 60;
|
|
12
|
+
const PAGE_LOAD_TIMEOUT_MILLISECONDS = 15000;
|
|
13
|
+
const RENDERER_MISSING_EXIT_CODE = 3;
|
|
14
|
+
const ALL_DARK_SIGNALS = [
|
|
15
|
+
{ name: "prefers-color-scheme: dark", colorScheme: "dark", dataTheme: null },
|
|
16
|
+
{ name: 'data-theme="dark"', colorScheme: "light", dataTheme: "dark" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function loadPlaywright() {
|
|
20
|
+
try {
|
|
21
|
+
return require("playwright");
|
|
22
|
+
} catch (localError) {
|
|
23
|
+
const globalRoot = childProcess.execSync("npm root -g", { encoding: "utf8" }).trim();
|
|
24
|
+
return require(path.join(globalRoot, "playwright"));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function measurePage([maximumDarkPageLuminance, minimumContrastRatio, snippetLength]) {
|
|
29
|
+
const parseColor = (colorText) => {
|
|
30
|
+
const channels = (colorText.match(/[\d.]+/g) || []).map(Number);
|
|
31
|
+
return { red: channels[0] || 0, green: channels[1] || 0, blue: channels[2] || 0, alpha: channels.length > 3 ? channels[3] : 1 };
|
|
32
|
+
};
|
|
33
|
+
const channelLuminance = (channel) => {
|
|
34
|
+
const scaled = channel / 255;
|
|
35
|
+
return scaled <= 0.03928 ? scaled / 12.92 : Math.pow((scaled + 0.055) / 1.055, 2.4);
|
|
36
|
+
};
|
|
37
|
+
const luminance = (color) => 0.2126 * channelLuminance(color.red) + 0.7152 * channelLuminance(color.green) + 0.0722 * channelLuminance(color.blue);
|
|
38
|
+
const contrastRatio = (first, second) => {
|
|
39
|
+
const lighter = Math.max(luminance(first), luminance(second));
|
|
40
|
+
const darker = Math.min(luminance(first), luminance(second));
|
|
41
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
42
|
+
};
|
|
43
|
+
const canvasColor = { red: 255, green: 255, blue: 255, alpha: 1 };
|
|
44
|
+
const effectiveBackground = (element) => {
|
|
45
|
+
for (let current = element; current; current = current.parentElement) {
|
|
46
|
+
const background = parseColor(getComputedStyle(current).backgroundColor);
|
|
47
|
+
if (background.alpha > 0.5) {
|
|
48
|
+
return background;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return canvasColor;
|
|
52
|
+
};
|
|
53
|
+
const isVisible = (element) => {
|
|
54
|
+
const style = getComputedStyle(element);
|
|
55
|
+
const box = element.getBoundingClientRect();
|
|
56
|
+
return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity) > 0 && box.width > 0 && box.height > 0;
|
|
57
|
+
};
|
|
58
|
+
const allFindings = [];
|
|
59
|
+
const pageBackground = effectiveBackground(document.body);
|
|
60
|
+
if (luminance(pageBackground) > maximumDarkPageLuminance) {
|
|
61
|
+
allFindings.push(`the page background stays light (${getComputedStyle(document.body).backgroundColor} on body); give body an explicit background from the theme tokens`);
|
|
62
|
+
}
|
|
63
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
64
|
+
const seenElements = new Set();
|
|
65
|
+
for (let textNode = walker.nextNode(); textNode; textNode = walker.nextNode()) {
|
|
66
|
+
const element = textNode.parentElement;
|
|
67
|
+
const text = textNode.textContent.trim();
|
|
68
|
+
if (!text || !element || seenElements.has(element) || ["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT"].includes(element.tagName)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
seenElements.add(element);
|
|
72
|
+
if (!isVisible(element)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const foreground = parseColor(getComputedStyle(element).color);
|
|
76
|
+
const background = effectiveBackground(element);
|
|
77
|
+
const ratio = contrastRatio(foreground, background);
|
|
78
|
+
if (ratio < minimumContrastRatio) {
|
|
79
|
+
allFindings.push(`text "${text.slice(0, snippetLength)}" has contrast ${ratio.toFixed(2)}:1 (${getComputedStyle(element).color} on rgb(${background.red}, ${background.green}, ${background.blue}))`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return allFindings;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function checkSignal(browser, pageHtml, darkSignal) {
|
|
86
|
+
const page = await browser.newPage({ colorScheme: darkSignal.colorScheme });
|
|
87
|
+
page.setDefaultTimeout(PAGE_LOAD_TIMEOUT_MILLISECONDS);
|
|
88
|
+
try {
|
|
89
|
+
await page.setContent(pageHtml, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT_MILLISECONDS }).catch(() => undefined);
|
|
90
|
+
if (darkSignal.dataTheme) {
|
|
91
|
+
await page.evaluate((themeName) => document.documentElement.setAttribute("data-theme", themeName), darkSignal.dataTheme);
|
|
92
|
+
}
|
|
93
|
+
const allFindings = await page.evaluate(measurePage, [MAXIMUM_DARK_PAGE_LUMINANCE, MINIMUM_CONTRAST_RATIO, TEXT_SNIPPET_LENGTH]);
|
|
94
|
+
return allFindings;
|
|
95
|
+
} finally {
|
|
96
|
+
await page.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function main() {
|
|
101
|
+
const pagePath = process.argv[2];
|
|
102
|
+
const pageHtml = fs.readFileSync(pagePath, "utf8");
|
|
103
|
+
let playwright;
|
|
104
|
+
try {
|
|
105
|
+
playwright = loadPlaywright();
|
|
106
|
+
} catch (loadError) {
|
|
107
|
+
process.stderr.write("Playwright is not installed; run: npm install -g playwright && npx playwright install chromium\n");
|
|
108
|
+
return RENDERER_MISSING_EXIT_CODE;
|
|
109
|
+
}
|
|
110
|
+
let browser;
|
|
111
|
+
try {
|
|
112
|
+
browser = await playwright.chromium.launch();
|
|
113
|
+
} catch (launchError) {
|
|
114
|
+
process.stderr.write(`Chromium did not start for Playwright; run: npx playwright install chromium (${launchError.message.split("\n")[0]})\n`);
|
|
115
|
+
return RENDERER_MISSING_EXIT_CODE;
|
|
116
|
+
}
|
|
117
|
+
const report = {};
|
|
118
|
+
try {
|
|
119
|
+
for (const darkSignal of ALL_DARK_SIGNALS) {
|
|
120
|
+
const allFindings = await checkSignal(browser, pageHtml, darkSignal);
|
|
121
|
+
if (allFindings.length > 0) {
|
|
122
|
+
report[darkSignal.name] = allFindings.slice(0, MAXIMUM_REPORTED_TEXT_FAILURES + 1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
await browser.close();
|
|
127
|
+
}
|
|
128
|
+
process.stdout.write(JSON.stringify(report));
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
main().then((exitCode) => process.exit(exitCode));
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Behavior tests for the artifact dark mode publish gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
14
|
+
|
|
15
|
+
from blocking.artifact_dark_mode_gate import (
|
|
16
|
+
render_findings,
|
|
17
|
+
format_block_reason,
|
|
18
|
+
published_page_path,
|
|
19
|
+
wrap_in_publish_skeleton,
|
|
20
|
+
)
|
|
21
|
+
from hooks_constants.artifact_dark_mode_gate_constants import (
|
|
22
|
+
ALLOW_EXIT_CODE,
|
|
23
|
+
BLOCK_EXIT_CODE,
|
|
24
|
+
PUBLISH_SKELETON_PREFIX,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
GATE_SCRIPT_PATH = Path(__file__).resolve().parent / "artifact_dark_mode_gate.py"
|
|
28
|
+
THEME_COLOR_STYLES = (
|
|
29
|
+
"<style>:root{--paper:#faf9f5;--ink:#141413;color-scheme:light dark}"
|
|
30
|
+
"@media (prefers-color-scheme: dark){:root:not([data-theme=light]){--paper:#262624;--ink:#faf9f5}}"
|
|
31
|
+
":root[data-theme=dark]{--paper:#262624;--ink:#faf9f5}"
|
|
32
|
+
"html{background:var(--paper);color:var(--ink)}h1{color:var(--ink)}</style>"
|
|
33
|
+
)
|
|
34
|
+
PAGE_WITH_TOKENS_ON_HTML_ONLY = THEME_COLOR_STYLES + "<h1>Weekly totals</h1><p>Three runs today.</p>"
|
|
35
|
+
PAGE_WITH_THEMED_BODY = (
|
|
36
|
+
THEME_COLOR_STYLES + "<style>body{background:var(--paper);color:var(--ink)}</style>"
|
|
37
|
+
"<h1>Weekly totals</h1><p>Three runs today.</p>"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _renderer_available() -> bool:
|
|
42
|
+
if shutil.which("node") is None:
|
|
43
|
+
return False
|
|
44
|
+
findings_by_signal, _ = render_findings("<p>probe</p>")
|
|
45
|
+
return findings_by_signal is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
needs_renderer = pytest.mark.skipif(
|
|
49
|
+
not _renderer_available(), reason="node Playwright with Chromium is not installed"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_gate(tool_input: dict[str, object]) -> subprocess.CompletedProcess[str]:
|
|
54
|
+
return subprocess.run(
|
|
55
|
+
[sys.executable, str(GATE_SCRIPT_PATH)],
|
|
56
|
+
input=json.dumps({"tool_name": "Artifact", "tool_input": tool_input}),
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
check=False,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_should_return_the_page_path_for_an_html_publish() -> None:
|
|
64
|
+
assert published_page_path("Artifact", {"file_path": "/tmp/page.html"}) == Path(
|
|
65
|
+
"/tmp/page.html"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_should_skip_reads_assets_typed_creates_and_other_tools() -> None:
|
|
70
|
+
assert published_page_path("Artifact", {"action": "read", "url": "x"}) is None
|
|
71
|
+
assert published_page_path("Artifact", {"file_path": "/tmp/logo.html", "asset": True}) is None
|
|
72
|
+
assert published_page_path("Artifact", {"file_path": "/tmp/data.html", "type_url": "x"}) is None
|
|
73
|
+
assert published_page_path("Artifact", {"file_path": "/tmp/notes.md"}) is None
|
|
74
|
+
assert published_page_path("Write", {"file_path": "/tmp/page.html"}) is None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_should_put_the_page_after_the_skeleton_that_pins_body_light() -> None:
|
|
78
|
+
wrapped_page = wrap_in_publish_skeleton("<p>x</p>")
|
|
79
|
+
assert wrapped_page == PUBLISH_SKELETON_PREFIX + "<p>x</p>"
|
|
80
|
+
assert "background:#faf9f5" in wrapped_page
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_should_list_each_finding_under_its_dark_signal() -> None:
|
|
84
|
+
block_reason = format_block_reason(
|
|
85
|
+
{"prefers-color-scheme: dark": ['text "A" has contrast 1.00:1']}
|
|
86
|
+
)
|
|
87
|
+
assert "Under prefers-color-scheme: dark:" in block_reason
|
|
88
|
+
assert '- text "A" has contrast 1.00:1' in block_reason
|
|
89
|
+
assert "body{background:var(--paper);color:var(--ink)}" in block_reason
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@needs_renderer
|
|
93
|
+
def test_should_fail_a_page_that_themes_html_but_not_body() -> None:
|
|
94
|
+
findings_by_signal, _ = render_findings(PAGE_WITH_TOKENS_ON_HTML_ONLY)
|
|
95
|
+
assert set(findings_by_signal) == {"prefers-color-scheme: dark", 'data-theme="dark"'}
|
|
96
|
+
assert any(
|
|
97
|
+
"Weekly totals" in each_finding for each_finding in findings_by_signal['data-theme="dark"']
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@needs_renderer
|
|
102
|
+
def test_should_pass_a_page_that_themes_body() -> None:
|
|
103
|
+
assert render_findings(PAGE_WITH_THEMED_BODY) == ({}, "")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@needs_renderer
|
|
107
|
+
def test_should_deny_and_then_allow_the_publish_call(tmp_path: Path) -> None:
|
|
108
|
+
page_path = tmp_path / "report.html"
|
|
109
|
+
page_path.write_text(PAGE_WITH_TOKENS_ON_HTML_ONLY, encoding="utf-8")
|
|
110
|
+
denied_run = _run_gate({"action": "publish", "file_path": str(page_path)})
|
|
111
|
+
assert denied_run.returncode == BLOCK_EXIT_CODE
|
|
112
|
+
assert "has unreadable text" in denied_run.stderr
|
|
113
|
+
page_path.write_text(PAGE_WITH_THEMED_BODY, encoding="utf-8")
|
|
114
|
+
assert _run_gate({"file_path": str(page_path)}).returncode == ALLOW_EXIT_CODE
|
|
@@ -12,6 +12,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
12
12
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
13
13
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
14
14
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
15
|
+
- `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
|
|
15
16
|
|
|
16
17
|
## When it fires
|
|
17
18
|
|
|
@@ -23,6 +24,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
23
24
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
24
25
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
25
26
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)`, timeout `10` seconds in `hooks.json`.
|
|
27
|
+
- `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
|
|
26
28
|
|
|
27
29
|
## Proving it
|
|
28
30
|
|
|
@@ -38,6 +40,7 @@ Preconditions:
|
|
|
38
40
|
- **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
|
|
39
41
|
- **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
|
|
40
42
|
- **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
|
|
43
|
+
- **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
|
|
41
44
|
|
|
42
45
|
## Gotchas
|
|
43
46
|
|
|
@@ -47,3 +50,4 @@ Preconditions:
|
|
|
47
50
|
- The edit marker gate scans replacement cards as well as the message text.
|
|
48
51
|
- The handoff close gate reads the `body` and `comment` fields of issue tools only. A `gh issue close --comment` command in Bash and a pull request body that closes the issue with `Closes #N` do not reach it.
|
|
49
52
|
- The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
|
|
53
|
+
- The dark mode gate denies a page publish when Node, Playwright or Chromium is missing, and its reason names the install command. It checks both dark signals, `prefers-color-scheme: dark` and `data-theme="dark"` on the root element.
|
package/hooks/hooks.json
CHANGED
|
@@ -152,6 +152,16 @@
|
|
|
152
152
|
}
|
|
153
153
|
]
|
|
154
154
|
},
|
|
155
|
+
{
|
|
156
|
+
"matcher": "Artifact",
|
|
157
|
+
"hooks": [
|
|
158
|
+
{
|
|
159
|
+
"type": "command",
|
|
160
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/artifact_dark_mode_gate.py",
|
|
161
|
+
"timeout": 60
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
},
|
|
155
165
|
{
|
|
156
166
|
"matcher": "Artifact|Skill",
|
|
157
167
|
"hooks": [
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Names, limits and messages for the artifact_dark_mode_gate PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ALLOW_EXIT_CODE",
|
|
9
|
+
"ALL_SKIPPED_PUBLISH_INPUT_KEYS",
|
|
10
|
+
"ARTIFACT_ACTION_INPUT_KEY",
|
|
11
|
+
"ARTIFACT_FILE_PATH_INPUT_KEY",
|
|
12
|
+
"ARTIFACT_PUBLISH_ACTION",
|
|
13
|
+
"ARTIFACT_TOOL_NAME",
|
|
14
|
+
"BLOCK_EXIT_CODE",
|
|
15
|
+
"DARK_MODE_FAILURE_HEADER",
|
|
16
|
+
"GATE_EVENT_NAME",
|
|
17
|
+
"HTML_FILE_SUFFIX",
|
|
18
|
+
"PUBLISH_SKELETON_PREFIX",
|
|
19
|
+
"RENDERER_MISSING_EXIT_CODE",
|
|
20
|
+
"RENDERER_MISSING_HEADER",
|
|
21
|
+
"RENDERER_SCRIPT_PATH",
|
|
22
|
+
"RENDERER_TIMEOUT_SECONDS",
|
|
23
|
+
"RETRY_INSTRUCTION",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
ALLOW_EXIT_CODE = 0
|
|
27
|
+
BLOCK_EXIT_CODE = 2
|
|
28
|
+
RENDERER_MISSING_EXIT_CODE = 3
|
|
29
|
+
RENDERER_TIMEOUT_SECONDS = 50
|
|
30
|
+
ARTIFACT_TOOL_NAME = "Artifact"
|
|
31
|
+
ARTIFACT_ACTION_INPUT_KEY = "action"
|
|
32
|
+
ARTIFACT_PUBLISH_ACTION = "publish"
|
|
33
|
+
ARTIFACT_FILE_PATH_INPUT_KEY = "file_path"
|
|
34
|
+
ALL_SKIPPED_PUBLISH_INPUT_KEYS = ("asset", "type_url")
|
|
35
|
+
HTML_FILE_SUFFIX = ".html"
|
|
36
|
+
GATE_EVENT_NAME = "PreToolUse"
|
|
37
|
+
RENDERER_SCRIPT_PATH = (
|
|
38
|
+
Path(__file__).resolve().parent.parent / "blocking" / "artifact_dark_mode_render.cjs"
|
|
39
|
+
)
|
|
40
|
+
PUBLISH_SKELETON_PREFIX = (
|
|
41
|
+
"<!doctype html><html><head><meta charset=utf8>"
|
|
42
|
+
'<meta name=viewport content="width=device-width,initial-scale=1">'
|
|
43
|
+
"<style>:root{color-scheme:light}body{margin:0;padding:0;"
|
|
44
|
+
"font:14px -apple-system,BlinkMacSystemFont,sans-serif;"
|
|
45
|
+
"background:#faf9f5;color:#141413}img{max-width:100%}"
|
|
46
|
+
"[hidden]:not([hidden=until-found i]){display:none!important}</style>"
|
|
47
|
+
"</head><body>\n"
|
|
48
|
+
)
|
|
49
|
+
DARK_MODE_FAILURE_HEADER = (
|
|
50
|
+
"Inside the artifact publish wrapper, this page has unreadable text when the viewer uses dark mode.\n"
|
|
51
|
+
)
|
|
52
|
+
RENDERER_MISSING_HEADER = "The page did not render for its contrast check before publish: "
|
|
53
|
+
RETRY_INSTRUCTION = (
|
|
54
|
+
"\nThe publish wrapper pins body to a light background and dark text. Set body background and"
|
|
55
|
+
" color from the page's theme tokens, for example body{background:var(--paper);color:var(--ink)},"
|
|
56
|
+
' so both flip under prefers-color-scheme: dark and data-theme="dark". Then publish again.'
|
|
57
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Behavioral tests for the artifact page gate's paths and publish wrapper."""
|
|
2
|
+
|
|
3
|
+
from hooks_constants.artifact_dark_mode_gate_constants import (
|
|
4
|
+
PUBLISH_SKELETON_PREFIX,
|
|
5
|
+
RENDERER_SCRIPT_PATH,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_renderer_path_names_the_packaged_render_script() -> None:
|
|
10
|
+
assert RENDERER_SCRIPT_PATH.is_file()
|
|
11
|
+
assert RENDERER_SCRIPT_PATH.suffix == ".cjs"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_publish_wrapper_pins_body_light_and_opens_the_body() -> None:
|
|
15
|
+
assert "body{margin:0;padding:0;" in PUBLISH_SKELETON_PREFIX
|
|
16
|
+
assert "background:#faf9f5;color:#141413" in PUBLISH_SKELETON_PREFIX
|
|
17
|
+
assert PUBLISH_SKELETON_PREFIX.endswith("<body>\n")
|
package/package.json
CHANGED
|
@@ -135,6 +135,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
135
135
|
hooks/blocking/verify_before_acting.py -> exempt
|
|
136
136
|
hooks/blocking/session_title_format_gate.py -> exempt
|
|
137
137
|
hooks/blocking/session_title_stop_gate.py -> exempt
|
|
138
|
+
hooks/blocking/artifact_dark_mode_gate.py -> exempt
|
|
138
139
|
hooks/blocking/some_new_blocker.py -> flagged
|
|
139
140
|
|
|
140
141
|
The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
|
|
@@ -163,6 +164,9 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
163
164
|
denies a malformed title, and the other asks for a title before a turn
|
|
164
165
|
ends. They decide the shape of a title and no code or safety policy.
|
|
165
166
|
|
|
167
|
+
The artifact page gate denies a page publish whose text is unreadable in
|
|
168
|
+
dark mode. It decides how a page renders and no code or safety policy.
|
|
169
|
+
|
|
166
170
|
Args:
|
|
167
171
|
registered_string: One command, path, script, or entrypoint string.
|
|
168
172
|
"""
|
|
@@ -71,6 +71,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
71
71
|
"blocking/verify_before_acting.py",
|
|
72
72
|
"blocking/session_title_format_gate.py",
|
|
73
73
|
"blocking/session_title_stop_gate.py",
|
|
74
|
+
"blocking/artifact_dark_mode_gate.py",
|
|
74
75
|
)
|
|
75
76
|
ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
|
|
76
77
|
ALL_TEST_FILE_PREFIXES = ("test_",)
|
|
@@ -109,6 +109,14 @@ def test_new_session_title_gate_registrations_are_clean(tmp_path: Path) -> None:
|
|
|
109
109
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def test_new_artifact_page_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
113
|
+
current_document = _hook_document(
|
|
114
|
+
["hooks/blocking/artifact_dark_mode_gate.py"],
|
|
115
|
+
'{"hooks": {}}',
|
|
116
|
+
)
|
|
117
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
118
|
+
|
|
119
|
+
|
|
112
120
|
def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
|
|
113
121
|
current_document = _hook_document(
|
|
114
122
|
["hooks/blocking/new.py"],
|