From 3fec93c2821ae68f130cba6b81bbce31ae799c85 Mon Sep 17 00:00:00 2001 From: GeneAI Date: Sat, 11 Jul 2026 10:56:52 -0400 Subject: [PATCH] chore(hooks): vendor _bootstrap.py + re-sync canonical hooks (attune-ai #1313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attune-ai PR #1313 (cross-platform hook layer) added _bootstrap.py to the canonical plugin/hooks/ — stdlib-only UTF-8 stdio hardening + repo-src sys.path bootstrap — and format_on_save.py now imports it (with a graceful ImportError fallback, so vendored copies without it silently skip the UTF-8 hardening). - Add _bootstrap.py to HOOK_FILES in the Makefile sync-hooks section - make sync-hooks ATTUNE_AI_ROOT=~/attune-ai against post-#1313 main (56f84f25e): vendors _bootstrap.py and re-syncs the full hook closure (_state, compact_warning, format_on_save, security_guard, spec_audit, spec_orient) + refreshed .canonical-sha256 Mirrors the spec-status-integrity task-7 sync PRs (rag #192, gui #86, help #23, author #97). Co-Authored-By: Claude Fable 5 --- .claude/hooks/.canonical-sha256 | 13 +- .claude/hooks/_bootstrap.py | 81 +++++++++++ .claude/hooks/_state.py | 2 + .claude/hooks/compact_warning.py | 2 +- .claude/hooks/format_on_save.py | 12 +- .claude/hooks/security_guard.py | 228 ++++++++++++++++++++++++++++--- .claude/hooks/spec_audit.py | 4 +- .claude/hooks/spec_orient.py | 2 +- Makefile | 2 +- 9 files changed, 316 insertions(+), 30 deletions(-) create mode 100644 .claude/hooks/_bootstrap.py diff --git a/.claude/hooks/.canonical-sha256 b/.claude/hooks/.canonical-sha256 index 5d2ad76..287e421 100644 --- a/.claude/hooks/.canonical-sha256 +++ b/.claude/hooks/.canonical-sha256 @@ -1,9 +1,10 @@ -cfd43f72b3f64bde6cb779703eb13ea6dd2c55ea5ae3dace654bfa95e17345c9 security_guard.py -37ee358245e8be80b00517c32d586449cb669d6d6e02526cc37c0e6728c452d5 format_on_save.py -f06a2180e64db35f96bdb896fbbfa9bf0ebc5090744817f5b87a7f0fbbb7ec61 compact_warning.py -c4437034774443b904e6c7cb529028ea521cf00e7a3f4859a2f964aadf08ffbc spec_orient.py -306d4d68e8e28d09f2ce102c8df6e234c2e4175b1b0c7ccf3dee6f26444584fe _state.py +b62c7991281cf1cc0c34c38f5338fa85eb9c08e7163859d7771e57d0e8e2359c security_guard.py +0983dd23febbae7d2b429d1aa7b21df387811427a664b044a3b19d8821c05dd8 format_on_save.py +0def63915a6bfb0b023a02e504281535ab19d7e2e1e0604901cdb68e345f3990 compact_warning.py +7f924f99fb331b9f30a77f253a69ee4820e2a4f4f257062d58b22530d56ba41a spec_orient.py +d52f97f551a5c8068df2c6afb6faa6b18be795189a9c604d99bd99b815f19315 _state.py 63293f305ff32aab46d1da8b9d28c71ce39b658d2a8572c64024614abdf7dffe _resume_prompt.py baa145fb6fac25ae7d03a5b655b04aba25bfb77793dcdcaf44acc151394f030b _transcript_size.py 48674de791f509c539417b29214d9c87a33b7934b985597af79711ddd90ea17a _sdk_gate.py -7145a707f6e14473f71e738e3c50df059bf1ad02b3b3b9fa13e5e8b4bc247e72 spec_audit.py +68743464283f0d19f7aca1a491cb122daed037462dd69d7bc3bb1dfe56ae84bb spec_audit.py +76bedca6b34a9126b4bd8d864afad52c6ec72229b26b51f00ff8b4ab673474eb _bootstrap.py diff --git a/.claude/hooks/_bootstrap.py b/.claude/hooks/_bootstrap.py new file mode 100644 index 0000000..5ed419a --- /dev/null +++ b/.claude/hooks/_bootstrap.py @@ -0,0 +1,81 @@ +"""Shared cross-platform bootstrap helpers for hook scripts. + +Stdlib-only. Hook scripts import this (same-directory import, like +``_sdk_gate``) before doing any I/O: + +- :func:`ensure_utf8_stdio` — force UTF-8 (``errors="replace"``) on + stdout/stderr. On Windows the console default is often cp1252, + which cannot encode em-dashes/emoji and would crash the hook. +- :func:`read_stdin_utf8` — read the hook payload as UTF-8 bytes + regardless of locale (``sys.stdin.buffer`` bypasses the + locale-encoded text wrapper, which is cp1252 on most Windows + machines). +- :func:`ensure_repo_src_on_path` — make the repo's ``src/`` + importable so hooks can lazily import ``attune.*`` without the + POSIX-only ``PYTHONPATH=src python …`` env-prefix syntax in the + hook registration. + +Copyright 2026 Smart-AI-Memory +Licensed under the Apache License, Version 2.0 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def ensure_utf8_stdio() -> None: + """Reconfigure stdout/stderr to UTF-8 with replacement. + + No-op when the streams are already UTF-8 (macOS/Linux default) + or do not support ``reconfigure`` (e.g. pytest capture objects). + """ + for stream in (sys.stdout, sys.stderr): + encoding = getattr(stream, "encoding", None) + if ( + encoding + and encoding.lower() not in ("utf-8", "utf8") + and hasattr(stream, "reconfigure") + ): + stream.reconfigure(encoding="utf-8", errors="replace") + + +def read_stdin_utf8(limit: int | None = None) -> str: + """Read stdin as UTF-8 text, independent of the locale encoding. + + Args: + limit: Optional byte cap (e.g. 10_000 to bound hook input). + + Returns: + Decoded payload; undecodable bytes become U+FFFD replacements + rather than raising, so a hook never crashes on odd input. + """ + buffer = getattr(sys.stdin, "buffer", None) + if buffer is None: # already detached/wrapped (tests) + return sys.stdin.read() if limit is None else sys.stdin.read(limit) + data = buffer.read() if limit is None else buffer.read(limit) + return data.decode("utf-8", errors="replace") + + +def ensure_repo_src_on_path() -> None: + """Insert the repo's ``src/`` directory at the front of ``sys.path``. + + Resolved relative to this file — ``parents[3]`` climbs + ``scripts/`` → ``hooks/`` → ``attune/`` → ``src/`` — so it works + from any cwd, any worktree, and any platform without env-prefix + syntax in the registration. + """ + try: + src = Path(__file__).resolve().parents[3] + except IndexError: + return + # Require the real package (__init__.py), not just a directory named + # "attune" — from the plugin copy, parents[3] lands OUTSIDE the repo, + # where an unrelated "attune" dir (e.g. a workspace umbrella checkout) + # would otherwise shadow the installed package as a namespace package. + if not (src / "attune" / "__init__.py").is_file(): # plugin copy / moved layout — no-op + return + src_str = str(src) + if src_str not in sys.path: + sys.path.insert(0, src_str) diff --git a/.claude/hooks/_state.py b/.claude/hooks/_state.py index 6e6840b..bfb48f8 100644 --- a/.claude/hooks/_state.py +++ b/.claude/hooks/_state.py @@ -919,6 +919,8 @@ def _run_git(cwd: Path, *args: str) -> str: cwd=str(cwd), capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=2.0, check=False, ) diff --git a/.claude/hooks/compact_warning.py b/.claude/hooks/compact_warning.py index a07a9da..bb8445a 100644 --- a/.claude/hooks/compact_warning.py +++ b/.claude/hooks/compact_warning.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Stop-hook compact-warning — fires once per session at threshold. Stop-hook payloads from Claude Code do NOT expose a context- diff --git a/.claude/hooks/format_on_save.py b/.claude/hooks/format_on_save.py index 53f8759..e60602c 100644 --- a/.claude/hooks/format_on_save.py +++ b/.claude/hooks/format_on_save.py @@ -71,7 +71,8 @@ def _run_formatter(cmd: list[str], path: str) -> None: def main() -> None: """Read tool result from stdin, format Python files.""" try: - raw = sys.stdin.read() + _buf = getattr(sys.stdin, "buffer", None) # None when tests patch stdin + raw = _buf.read().decode("utf-8", errors="replace") if _buf else sys.stdin.read() if not raw.strip(): return @@ -102,6 +103,15 @@ def main() -> None: if __name__ == "__main__": + try: + from _bootstrap import ensure_utf8_stdio + except ImportError: + # Vendored copies (sibling .claude/hooks/) may not ship + # _bootstrap.py — degrade to the pre-bootstrap behavior + # rather than crashing the hook. + pass + else: + ensure_utf8_stdio() from _sdk_gate import exit_if_sdk_subprocess exit_if_sdk_subprocess() diff --git a/.claude/hooks/security_guard.py b/.claude/hooks/security_guard.py index 9d1e045..16f1f6d 100644 --- a/.claude/hooks/security_guard.py +++ b/.claude/hooks/security_guard.py @@ -1,11 +1,20 @@ """PreToolUse Security Validation Hook. Intercepts tool calls to enforce coding standards at runtime: -1. Blocks eval()/exec() in Bash commands (CWE-95) +1. Blocks eval()/exec() in shell commands (CWE-95) 2. Validates file paths in Edit/Write operations (CWE-22) -3. Prevents writes to system directories +3. Prevents writes to system directories (POSIX and Windows) 4. Blocks null byte injection in paths +Cross-platform policy (see README "Platform support"): +- POSIX shells (macOS, Linux, WSL2, Git Bash): deny-pattern + validation via POSIX_DENY_PATTERNS. +- PowerShell (native Windows, opt-in via + CLAUDE_CODE_USE_POWERSHELL_TOOL=1): FAIL CLOSED — both deny sets + apply AND every command segment's first token must be on a strict + allowlist. Unknown commands are blocked, not silently passed. +- Unknown shell family: treated like PowerShell (strict mode). + Claude Code Protocol: stdin: JSON with tool_name and tool_input exit 0: allow tool call @@ -17,11 +26,14 @@ import json import logging +import os import re import sys -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import Any +logger = logging.getLogger(__name__) + # Force utf-8 on stdout and stderr. On Windows the default cp1252 # encoding can't emit emoji/em-dash and would crash this hook (caught # by the outer try/except → silent breakage). errors='replace' @@ -30,8 +42,6 @@ if _stream.encoding and _stream.encoding.lower() != "utf-8": _stream.reconfigure(encoding="utf-8", errors="replace") -logger = logging.getLogger(__name__) - # Directories that must never be written to (includes macOS /private/* symlinks) SYSTEM_DIRECTORIES = frozenset( { @@ -47,8 +57,17 @@ }, ) -# Dangerous patterns in Bash commands -DANGEROUS_BASH_PATTERNS: list[tuple[re.Pattern[str], str]] = [ +# Windows equivalents — compared case-insensitively with normalized +# separators, so C:/windows/system32 and c:\WINDOWS both match. +WINDOWS_SYSTEM_DIRECTORIES: tuple[str, ...] = ( + "c:\\windows", + "c:\\program files", + "c:\\program files (x86)", + "c:\\programdata", +) + +# Dangerous patterns in POSIX-family shell commands (bash, zsh, Git Bash) +POSIX_DENY_PATTERNS: list[tuple[re.Pattern[str], str]] = [ ( re.compile(r"\beval\s*\("), "Blocked: eval() is prohibited — use ast.literal_eval() instead (CWE-95)", @@ -75,6 +94,78 @@ ), ] +# Backwards-compatible alias (pre-cross-platform public name). +DANGEROUS_BASH_PATTERNS = POSIX_DENY_PATTERNS + +# Dangerous patterns in PowerShell commands. Applied IN ADDITION to +# the strict allowlist below — defense in depth, and the messages are +# more specific than the generic allowlist block. +POWERSHELL_DENY_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + ( + re.compile(r"\b(iex|Invoke-Expression)\b", re.IGNORECASE), + "Blocked: Invoke-Expression is prohibited — PowerShell eval (CWE-95)", + ), + ( + re.compile( + r"\b(iwr|curl|wget|Invoke-WebRequest|Invoke-RestMethod)\b[^|]*\|", + re.IGNORECASE, + ), + "Blocked: piping downloaded content into another command is prohibited", + ), + ( + re.compile(r"\bSet-ExecutionPolicy\b", re.IGNORECASE), + "Blocked: Set-ExecutionPolicy changes are not allowed from hooks", + ), + ( + re.compile(r"\bStart-Process\b.*-Verb\s+RunAs", re.IGNORECASE), + "Blocked: elevation via Start-Process -Verb RunAs is not allowed", + ), + ( + re.compile( + r"\b(Remove-Item|rm|del|rd)\b(?=.*-Recurse)(?=.*-Force)" + r".*(?:[A-Za-z]:[\\/]\s*$|\\\\)", + re.IGNORECASE, + ), + "Blocked: recursive forced delete of a drive root is not allowed", + ), + ( + re.compile(r"\b(eval|exec)\s*\("), + "Blocked: eval()/exec() is prohibited (CWE-95)", + ), +] + +# Strict allowlist for PowerShell / unknown shell families: the first +# token of every command segment must be one of these. FAIL CLOSED — +# anything not recognized is blocked with a clear message (platform +# policy: native Windows + PowerShell is "limited" support). +STRICT_ALLOWED_FIRST_TOKENS = frozenset( + { + "attune", + "black", + "cat", + "cd", + "dir", + "echo", + "gh", + "git", + "ls", + "mkdir", + "mypy", + "node", + "npm", + "npx", + "pip", + "pre-commit", + "py", + "pytest", + "python", + "ruff", + "type", + "uv", + "where", + }, +) + # Commands that search for dangerous patterns (not executing them) SEARCH_COMMAND_PREFIXES = frozenset( { @@ -89,11 +180,45 @@ ) +def detect_shell_family(context: dict[str, Any]) -> str: + """Classify the shell family the command will run under. + + Returns one of ``"posix"``, ``"powershell"``, ``"unknown"``. + + Detection order (most to least explicit): + 1. An explicit tool/shell name in the hook payload. NOTE: the + exact payload shape on native Windows is unverified — run + .github/workflows/windows-payload-capture.yml (from main) to + capture real payloads and tighten this; the assumptions are + documented in tests/unit/hooks/test_cross_platform.py. + 2. On Windows: CLAUDE_CODE_USE_POWERSHELL_TOOL=1 → powershell; + a configured Git Bash → posix; otherwise unknown. + 3. Non-Windows → posix. + + Unknown NEVER falls through to the permissive posix path — the + caller applies strict mode (fail closed). + """ + explicit = str( + context.get("tool_name", "") or context.get("shell", ""), + ).lower() + if "powershell" in explicit or explicit == "pwsh": + return "powershell" + + if os.name == "nt": + if os.environ.get("CLAUDE_CODE_USE_POWERSHELL_TOOL") == "1": + return "powershell" + if os.environ.get("CLAUDE_CODE_GIT_BASH_PATH"): + return "posix" + return "unknown" + + return "posix" + + def _is_search_command(command: str) -> bool: """Check if a command is searching FOR dangerous patterns, not executing them. Args: - command: The bash command string. + command: The shell command string. Returns: True if the command is a search/grep operation. @@ -108,11 +233,45 @@ def _is_search_command(command: str) -> bool: return False -def validate_bash_command(command: str) -> tuple[bool, str]: - """Validate a Bash command against security policies. +def _split_command_segments(command: str) -> list[str]: + """Split a compound command into segments for allowlist checking. + + Splits on newlines, ``;``, ``|``, and ``&`` so a disallowed + command can't hide behind an allowed prefix. + """ + segments = re.split(r"[\n;|&]+", command) + return [seg.strip() for seg in segments if seg.strip()] + + +def _strict_allowlist_check(command: str) -> tuple[bool, str]: + """FAIL-CLOSED validation for PowerShell / unknown shell families. + + Every segment's first token must be on + :data:`STRICT_ALLOWED_FIRST_TOKENS`. + """ + for segment in _split_command_segments(command): + tokens = segment.split() + first = tokens[0] if tokens else "" + # strip PowerShell call operator and leading path syntax: & "cmd", .\cmd + first = first.lstrip("&").strip("\"'").lstrip(".\\/").lower() + if first.endswith(".exe"): + first = first[:-4] + if first and first not in STRICT_ALLOWED_FIRST_TOKENS: + return False, ( + f"Blocked (strict mode): '{first}' is not on the PowerShell " + "allowlist. Native Windows + PowerShell has limited support — " + "security validation fails closed. Use Git Bash or WSL2 for " + "full support, or extend STRICT_ALLOWED_FIRST_TOKENS." + ) + return True, "" + + +def validate_shell_command(command: str, family: str = "posix") -> tuple[bool, str]: + """Validate a shell command against security policies. Args: command: The command string to validate. + family: Shell family from :func:`detect_shell_family`. Returns: (True, "") if safe, (False, reason) if blocked. @@ -121,15 +280,30 @@ def validate_bash_command(command: str) -> tuple[bool, str]: if not command: return True, "" - # Allow search commands that look for dangerous patterns - if _is_search_command(command): + if family == "posix": + # Allow search commands that look for dangerous patterns + if _is_search_command(command): + return True, "" + for pattern, message in POSIX_DENY_PATTERNS: + if pattern.search(command): + return False, message return True, "" - for pattern, message in DANGEROUS_BASH_PATTERNS: + # powershell + unknown: strict mode — both deny sets, then allowlist. + for pattern, message in POSIX_DENY_PATTERNS + POWERSHELL_DENY_PATTERNS: if pattern.search(command): return False, message + return _strict_allowlist_check(command) - return True, "" + +def validate_bash_command(command: str) -> tuple[bool, str]: + """Validate a POSIX shell command (legacy name; see validate_shell_command).""" + return validate_shell_command(command, family="posix") + + +def _looks_like_windows_path(file_path: str) -> bool: + """True for drive-letter or UNC paths regardless of host OS.""" + return bool(re.match(r"^[A-Za-z]:[\\/]", file_path)) or file_path.startswith("\\\\") def validate_file_path(file_path: str) -> tuple[bool, str]: @@ -163,6 +337,11 @@ def validate_file_path(file_path: str) -> tuple[bool, str]: for sys_dir in SYSTEM_DIRECTORIES: if check_path.startswith(sys_dir): return False, f"Blocked: cannot write to system directory {sys_dir} (CWE-22)" + if _looks_like_windows_path(check_path): + normalized = str(PureWindowsPath(check_path)).lower() + for win_dir in WINDOWS_SYSTEM_DIRECTORIES: + if normalized.startswith(win_dir): + return False, (f"Blocked: cannot write to system directory {win_dir} (CWE-22)") return True, "" @@ -184,9 +363,10 @@ def main(context: dict[str, Any]) -> dict[str, Any]: # No tool info — fail open to avoid blocking Claude Code return {"allowed": True} - if tool_name == "Bash": + if tool_name == "Bash" or "powershell" in tool_name.lower(): command = tool_input.get("command", "") - allowed, reason = validate_bash_command(command) + family = detect_shell_family(context) + allowed, reason = validate_shell_command(command, family) if not allowed: return {"allowed": False, "reason": reason} @@ -202,6 +382,9 @@ def main(context: dict[str, Any]) -> dict[str, Any]: def _read_stdin_context() -> dict[str, Any]: """Read hook context from stdin (Claude Code protocol). + Reads BYTES and decodes as UTF-8 explicitly — on Windows the + text-mode stdin decodes as cp1252 and mangles non-ASCII payloads. + Returns: Parsed context dict, or empty dict if stdin is empty/invalid. @@ -209,7 +392,12 @@ def _read_stdin_context() -> dict[str, Any]: if sys.stdin.isatty(): return {} try: - raw = sys.stdin.read().strip() + buffer = getattr(sys.stdin, "buffer", None) + raw = ( + buffer.read().decode("utf-8", errors="replace") + if buffer is not None + else sys.stdin.read() + ).strip() if raw: return json.loads(raw) except (json.JSONDecodeError, ValueError) as e: @@ -224,9 +412,11 @@ def _read_stdin_context() -> dict[str, Any]: logging.basicConfig(level=logging.WARNING, format="%(message)s") context = _read_stdin_context() - # Fail-open: if we couldn't parse stdin, allow the tool call + # Parse error: there is no command text to validate, so blocking + # would brick every tool call (observed pre-2026). Fail open here; + # the strict fail-closed path applies when a command IS present + # but the shell family is PowerShell/unknown. if context.get("_parse_error"): - # (fail-closed was blocking all tools in Claude Code) sys.exit(0) result = main(context) diff --git a/.claude/hooks/spec_audit.py b/.claude/hooks/spec_audit.py index d428ef7..e62f8d8 100644 --- a/.claude/hooks/spec_audit.py +++ b/.claude/hooks/spec_audit.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """Spec status audit — flag specs whose deliverables shipped but status didn't. On-demand / CI companion to the always-on ``spec_orient`` SessionStart @@ -102,6 +102,8 @@ def _run_gh(args: list[str], cwd: Path | None) -> subprocess.CompletedProcess[st ["gh", *args], capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=_GH_TIMEOUT_SECONDS, cwd=str(cwd) if cwd else None, ) diff --git a/.claude/hooks/spec_orient.py b/.claude/hooks/spec_orient.py index 1f8c84a..440e243 100644 --- a/.claude/hooks/spec_orient.py +++ b/.claude/hooks/spec_orient.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python """SessionStart spec-orientation hook. Fires on every SessionStart event. Branches on the ``source`` diff --git a/Makefile b/Makefile index 0436e25..a052495 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ EDITOR_OUTPUT := sidecar/attune_gui/static/editor # attune umbrella workspace). Byte-identical copies of attune-ai canonical; # the drift-guard test enforces it. Re-sync after an upstream change. ATTUNE_AI_ROOT ?= ../attune-ai -HOOK_FILES = security_guard.py format_on_save.py compact_warning.py spec_orient.py _state.py _resume_prompt.py _transcript_size.py _sdk_gate.py spec_audit.py +HOOK_FILES = security_guard.py format_on_save.py compact_warning.py spec_orient.py _state.py _resume_prompt.py _transcript_size.py _sdk_gate.py spec_audit.py _bootstrap.py install-editor: cd $(EDITOR_FRONTEND) && npm install