-
Notifications
You must be signed in to change notification settings - Fork 2
Release v2.0.2: scope guard hardening + stop hook throttling #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,14 +11,17 @@ | |
| Reads hook input from stdin (JSON). Returns JSON on stdout. | ||
| Blocks with decision/reason so Claude addresses the spec gap | ||
| before finishing. The stop_hook_active guard prevents infinite loops. | ||
| A 5-minute cooldown prevents repeated firing in agent/team scenarios. | ||
| """ | ||
|
|
||
| import json | ||
| import os | ||
| import subprocess | ||
| import sys | ||
| import time | ||
|
|
||
| GIT_CMD_TIMEOUT = 5 | ||
| COOLDOWN_SECS = 300 # 5 minutes between reminders per session | ||
|
|
||
| # Directories whose changes should trigger the spec reminder | ||
| CODE_DIRS = ( | ||
|
|
@@ -40,6 +43,26 @@ | |
| ) | ||
|
|
||
|
|
||
| def _is_on_cooldown(session_id: str) -> bool: | ||
| """Check if the reminder fired recently. Returns True to suppress.""" | ||
| cooldown_path = f"/tmp/claude-spec-reminder-cooldown-{session_id}" | ||
| try: | ||
| mtime = os.path.getmtime(cooldown_path) | ||
| return (time.time() - mtime) < COOLDOWN_SECS | ||
| except OSError: | ||
| return False | ||
|
|
||
|
|
||
| def _touch_cooldown(session_id: str) -> None: | ||
| """Mark the cooldown as active.""" | ||
| cooldown_path = f"/tmp/claude-spec-reminder-cooldown-{session_id}" | ||
| try: | ||
| with open(cooldown_path, "w") as f: | ||
| f.write("") | ||
| except OSError: | ||
| pass | ||
|
Comment on lines
+46
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Harden cooldown file path construction against path injection.
Proposed fix import json
import os
+import re
import subprocess
import sys
import time
+import tempfile
GIT_CMD_TIMEOUT = 5
COOLDOWN_SECS = 300 # 5 minutes between reminders per session
+_SAFE_SESSION_ID_RE = re.compile(r"[^A-Za-z0-9_.-]")
+
+
+def _cooldown_path(session_id: str) -> str:
+ safe_session_id = _SAFE_SESSION_ID_RE.sub("_", session_id)
+ return os.path.join(
+ tempfile.gettempdir(),
+ f"claude-spec-reminder-cooldown-{safe_session_id}",
+ )
def _is_on_cooldown(session_id: str) -> bool:
"""Check if the reminder fired recently. Returns True to suppress."""
- cooldown_path = f"/tmp/claude-spec-reminder-cooldown-{session_id}"
+ cooldown_path = _cooldown_path(session_id)
try:
mtime = os.path.getmtime(cooldown_path)
return (time.time() - mtime) < COOLDOWN_SECS
@@
def _touch_cooldown(session_id: str) -> None:
"""Mark the cooldown as active."""
- cooldown_path = f"/tmp/claude-spec-reminder-cooldown-{session_id}"
+ cooldown_path = _cooldown_path(session_id)
try:
- with open(cooldown_path, "w") as f:
- f.write("")
+ fd = os.open(cooldown_path, os.O_WRONLY | os.O_CREAT, 0o600)
+ os.close(fd)
+ os.utime(cooldown_path, None)
except OSError:
pass🧰 Tools🪛 Ruff (0.15.2)[error] 48-48: Probable insecure usage of temporary file or directory: "/tmp/claude-spec-reminder-cooldown-" (S108) [error] 58-58: Probable insecure usage of temporary file or directory: "/tmp/claude-spec-reminder-cooldown-" (S108) 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| def _run_git(args: list[str]) -> str | None: | ||
| """Run a git command and return stdout, or None on any failure.""" | ||
| try: | ||
|
|
@@ -66,6 +89,11 @@ def main(): | |
| if input_data.get("stop_hook_active"): | ||
| sys.exit(0) | ||
|
|
||
| # Cooldown — suppress if fired within the last 5 minutes | ||
| session_id = input_data.get("session_id", "") | ||
| if session_id and _is_on_cooldown(session_id): | ||
| sys.exit(0) | ||
|
|
||
| cwd = os.getcwd() | ||
|
|
||
| # Only fire if this project uses the spec system | ||
|
|
@@ -116,6 +144,8 @@ def main(): | |
| "or /spec-refine if the spec is still in draft status." | ||
| ) | ||
|
|
||
| if session_id: | ||
| _touch_cooldown(session_id) | ||
| json.dump({"decision": "block", "reason": message}, sys.stdout) | ||
| sys.exit(0) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,9 @@ | |
| CWD context injector — injects working directory into Claude's context | ||
| on every session start, user prompt, tool call, and subagent spawn. | ||
|
|
||
| Worktree-aware: when CWD is inside .claude/worktrees/, injects the project | ||
| root as the scope boundary instead of the worktree-specific path. | ||
|
|
||
| Fires on: SessionStart, UserPromptSubmit, PreToolUse, SubagentStart | ||
| Always exits 0 (advisory, never blocking). | ||
| """ | ||
|
|
@@ -11,20 +14,33 @@ | |
| import os | ||
| import sys | ||
|
|
||
| # Must match the segment used in guard-workspace-scope.py | ||
| _WORKTREE_SEGMENT = "/.claude/worktrees/" | ||
|
|
||
|
|
||
| def resolve_scope_root(cwd: str) -> str: | ||
| """Resolve CWD to project root when inside a worktree.""" | ||
| idx = cwd.find(_WORKTREE_SEGMENT) | ||
| if idx != -1: | ||
| return cwd[:idx] | ||
| return cwd | ||
|
|
||
|
|
||
| def main(): | ||
| cwd = os.getcwd() | ||
| cwd = os.path.realpath(os.getcwd()) | ||
| try: | ||
| input_data = json.load(sys.stdin) | ||
| # Some hook events provide cwd override | ||
| cwd = input_data.get("cwd", cwd) | ||
| cwd = os.path.realpath(input_data.get("cwd", cwd)) | ||
| hook_event = input_data.get("hook_event_name", "PreToolUse") | ||
| except (json.JSONDecodeError, ValueError): | ||
| hook_event = "PreToolUse" | ||
|
|
||
| scope_root = resolve_scope_root(cwd) | ||
|
|
||
| context = ( | ||
| f"Working Directory: {cwd}\n" | ||
| f"All file operations and commands MUST target paths within {cwd}. " | ||
| f"Working Directory: {cwd} — restrict all file operations to this directory unless explicitly instructed otherwise.\n" | ||
| f"All file operations and commands MUST target paths within {scope_root}. " | ||
| f"Do not read, write, or execute commands against paths outside this directory." | ||
|
Comment on lines
+42
to
44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scope instruction text is inconsistent in worktree mode. Line 42 frames the boundary as Proposed fix- context = (
- f"Working Directory: {cwd} — restrict all file operations to this directory unless explicitly instructed otherwise.\n"
- f"All file operations and commands MUST target paths within {scope_root}. "
- f"Do not read, write, or execute commands against paths outside this directory."
- )
+ context = (
+ f"Working Directory: {cwd}\n"
+ f"Scope Root: {scope_root} — restrict all file operations and commands to this boundary.\n"
+ "Do not read, write, or execute commands against paths outside this scope root."
+ )🤖 Prompt for AI Agents |
||
| ) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apply the same cooldown-path hardening here.
The cooldown filename is built from unsanitized
session_idin/tmp, creating the same path-manipulation risk in this hook.Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 Ruff (0.15.2)
[error] 98-98: Probable insecure usage of temporary file or directory: "/tmp/claude-commit-reminder-cooldown-"
(S108)
[error] 108-108: Probable insecure usage of temporary file or directory: "/tmp/claude-commit-reminder-cooldown-"
(S108)
🤖 Prompt for AI Agents