claude-dev-env 8.65.1 → 8.67.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.
@@ -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));
@@ -17,6 +17,13 @@ It denies a colon that joins two clauses on one line, and an em dash::
17
17
  ok: Two checks failed at 9:47, then the list follows:
18
18
  (each item on its own line below the colon)
19
19
 
20
+ It denies a causal sentence ("so", "because", "caused", "due to",
21
+ "therefore", "means") that carries no inline code, URL, or markdown link
22
+ of its own. A command block in the same reply is no evidence::
23
+
24
+ flag: That window was Windows PowerShell without Administrator, so the write did not land.
25
+ ok: The write did not land, because the next read printed `Right after write: 1`.
26
+
20
27
  It denies a banned word or phrase, matched whole and case-insensitive::
21
28
 
22
29
  flag: The cause is likely the cache.
@@ -58,6 +65,7 @@ from hooks_constants.reply_length_gate_constants import (
58
65
  ALL_DECISION_CARD_PROSE_KEYS,
59
66
  ALL_DECISION_OPTION_PROSE_KEYS,
60
67
  ALL_DEFAULT_BANNED_WORDS,
68
+ ALL_EVIDENCE_PATTERNS,
61
69
  ALLOW_EXIT_CODE,
62
70
  BANNED_WORD_MESSAGE,
63
71
  BANNED_WORD_PART_SEPARATOR,
@@ -66,6 +74,7 @@ from hooks_constants.reply_length_gate_constants import (
66
74
  BANNED_WORDS_JSON_KEY,
67
75
  BANNED_WORDS_PATH_ENV_VAR,
68
76
  BLOCK_EXIT_CODE,
77
+ CAUSAL_CLAIM_PATTERN,
69
78
  CARD_TEXT_SEPARATOR,
70
79
  CLAUDE_HOME_DIRECTORY_NAME,
71
80
  CONFIG_FILE_ENCODING,
@@ -94,6 +103,7 @@ from hooks_constants.reply_length_gate_constants import (
94
103
  TOOL_NAME_KEY,
95
104
  UNLINKED_PULL_REQUEST_MESSAGE,
96
105
  UNLINKED_PULL_REQUEST_PATTERN,
106
+ UNSOURCED_CAUSE_MESSAGE,
97
107
  URL_PATTERN,
98
108
  WORD_PATTERN,
99
109
  WORD_SEPARATOR,
@@ -151,6 +161,21 @@ def em_dash_violation(reply_text: str) -> str | None:
151
161
  return EM_DASH_MESSAGE
152
162
 
153
163
 
164
+ def unsourced_cause_violation(reply_text: str) -> str | None:
165
+ """Return the deny reason for the first causal sentence that cites no evidence itself, or None."""
166
+ all_sentences = [
167
+ each_sentence
168
+ for each_line in LINE_BREAK_PATTERN.split(FENCED_BLOCK_PATTERN.sub(" ", reply_text))
169
+ for each_sentence in SENTENCE_END_PATTERN.split(each_line)
170
+ ]
171
+ for each_sentence in all_sentences:
172
+ is_causal = CAUSAL_CLAIM_PATTERN.search(INLINE_CODE_PATTERN.sub(" ", each_sentence))
173
+ has_evidence = any(each_pattern.search(each_sentence) for each_pattern in ALL_EVIDENCE_PATTERNS)
174
+ if is_causal and not has_evidence:
175
+ return UNSOURCED_CAUSE_MESSAGE.format(sentence=each_sentence.strip())
176
+ return None
177
+
178
+
154
179
  def unlinked_pull_request_violation(reply_text: str) -> str | None:
155
180
  """Return the deny reason for a pull request number outside a link, or None."""
156
181
  without_fences = FENCED_BLOCK_PATTERN.sub(" ", reply_text)
@@ -231,6 +256,7 @@ def tool_violation(tool_name: object, all_tool_input: dict[str, object]) -> tupl
231
256
  or mid_sentence_colon_violation(reply_text)
232
257
  or em_dash_violation(reply_text)
233
258
  or unlinked_pull_request_violation(reply_text)
259
+ or unsourced_cause_violation(reply_text)
234
260
  or banned_word_violation(reply_text, configured_banned_words())
235
261
  )
236
262
  return None if violation is None else (violation, reply_text)
@@ -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
@@ -487,3 +487,55 @@ def test_should_deny_an_em_dash_and_allow_one_in_code(
487
487
  )
488
488
  assert (denied_code, allowed_code) == (2, 0)
489
489
  assert "em dash" in denied_text
490
+
491
+
492
+ def test_should_deny_the_quoted_cause_with_no_source(
493
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
494
+ ) -> None:
495
+ exit_code, stderr_text = run_gate(
496
+ monkeypatch,
497
+ capsys,
498
+ POST_TOOL_NAME,
499
+ {"text": "That window was Windows PowerShell without Administrator, so the write did not land."},
500
+ )
501
+ assert exit_code == 2
502
+ assert "Cause with no source" in stderr_text
503
+
504
+
505
+ @pytest.mark.parametrize(
506
+ "reply_text",
507
+ [
508
+ "The write did not land, because the next read printed `Right after write: 1`.",
509
+ "The job failed because of [run 12](https://github.com/owner/repo/actions/runs/12).",
510
+ ],
511
+ )
512
+ def test_should_allow_a_cause_that_cites_evidence(
513
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
514
+ ) -> None:
515
+ exit_code, stderr_text = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text})
516
+ assert (exit_code, stderr_text) == (0, "")
517
+
518
+
519
+ @pytest.mark.parametrize(
520
+ "reply_text",
521
+ [
522
+ "That window lacked Administrator, so the write did not land.\n\n```powershell\nRestart-Service x\n```",
523
+ "The error shows the hook is the only line. So the filter wrote nothing.",
524
+ "One line survived because its start did not match my filter. Run this.",
525
+ ],
526
+ )
527
+ def test_should_deny_a_cause_whose_own_sentence_cites_nothing(
528
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
529
+ ) -> None:
530
+ exit_code, stderr_text = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text})
531
+ assert exit_code == 2
532
+ assert "Cause with no source" in stderr_text
533
+
534
+
535
+ def test_should_allow_a_reply_with_no_causal_claim(
536
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
537
+ ) -> None:
538
+ exit_code, _ = run_gate(
539
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The runner restarted. Also, so far so good."}
540
+ )
541
+ assert exit_code == 0
@@ -4,7 +4,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
4
4
 
5
5
  ## Checks
6
6
 
7
- - `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, and requires a readable link when the text names a pull request number.
7
+ - `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, and requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link.
8
8
  - `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
9
9
  - `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
10
10
  - `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
@@ -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
+ )
@@ -40,6 +40,15 @@ MID_SENTENCE_COLON_MESSAGE = (
40
40
  " and put the list on the lines below."
41
41
  )
42
42
  EM_DASH_MESSAGE = "An em dash is in the text. Use a period or a comma."
43
+ CAUSAL_CLAIM_PATTERN = re.compile(
44
+ r"(?:^|,)\s*so\b(?!\s+far\b)|\b(?:because|caused|causes|due to|therefore|which means|that means)\b",
45
+ re.IGNORECASE,
46
+ )
47
+ ALL_EVIDENCE_PATTERNS = (INLINE_CODE_PATTERN, URL_PATTERN, MARKDOWN_LINK_PATTERN)
48
+ UNSOURCED_CAUSE_MESSAGE = (
49
+ 'Cause with no source: "{sentence}". Quote the log line or output in backticks,'
50
+ " or link the run or file and line that shows it."
51
+ )
43
52
  ALL_DEFAULT_BANNED_WORDS = (
44
53
  "real",
45
54
  "really",
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.65.1",
3
+ "version": "8.67.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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"],