diff --git a/.devcontainer/CHANGELOG.md b/.devcontainer/CHANGELOG.md index fba2dec..9151927 100644 --- a/.devcontainer/CHANGELOG.md +++ b/.devcontainer/CHANGELOG.md @@ -1,5 +1,18 @@ # CodeForge Devcontainer Changelog +## v2.0.2 — 2026-03-02 + +### Security + +- Workspace scope guard now resolves CWD with `os.path.realpath()` for consistent comparison with target paths, preventing false positives from symlinks and bind mounts +- Scope guard detects `.claude/worktrees/` in CWD and expands scope to project root, allowing sibling worktrees and the main project directory to remain in-scope +- Improved scope guard error messages to include resolved paths and scope root for easier debugging of false positives +- CWD context injector now references the project root when running inside a worktree + +### Agent System + +- Commit reminder and spec reminder now have a 5-minute per-session cooldown, preventing repeated firing in team/agent scenarios where Stop events are frequent + ## v2.0.0 — 2026-02-26 ### .codeforge/ Configuration System diff --git a/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/commit-reminder.py b/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/commit-reminder.py index 37d6154..441ea40 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/commit-reminder.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/commit-reminder.py @@ -8,21 +8,39 @@ files touched) get an advisory suggestion; small changes are silent. Output is a systemMessage wrapped in tags — advisory only, -never blocks. The stop_hook_active guard prevents loops. +never blocks. The stop_hook_active guard prevents loops. A 5-minute cooldown +prevents repeated firing in agent/team scenarios where Stop events are frequent. """ import json import os import subprocess import sys +import time GIT_CMD_TIMEOUT = 5 +COOLDOWN_SECS = 300 # 5 minutes between reminders per session # Extensions considered source code (not config/docs) -SOURCE_EXTS = frozenset(( - ".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", - ".java", ".kt", ".rb", ".svelte", ".vue", ".c", ".cpp", ".h", -)) +SOURCE_EXTS = frozenset( + ( + ".py", + ".ts", + ".tsx", + ".js", + ".jsx", + ".go", + ".rs", + ".java", + ".kt", + ".rb", + ".svelte", + ".vue", + ".c", + ".cpp", + ".h", + ) +) # Patterns that indicate test files TEST_PATTERNS = ("test_", "_test.", ".test.", ".spec.", "/tests/", "/test/") @@ -75,6 +93,26 @@ def _is_test_file(path: str) -> bool: return any(pattern in lower for pattern in TEST_PATTERNS) +def _is_on_cooldown(session_id: str) -> bool: + """Check if the reminder fired recently. Returns True to suppress.""" + cooldown_path = f"/tmp/claude-commit-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-commit-reminder-cooldown-{session_id}" + try: + with open(cooldown_path, "w") as f: + f.write("") + except OSError: + pass + + def _is_meaningful(edited_files: list[str]) -> bool: """Determine if the session's edits are meaningful enough to suggest committing. @@ -111,6 +149,10 @@ def main(): if not session_id: sys.exit(0) + # Cooldown — suppress if fired within the last 5 minutes + if _is_on_cooldown(session_id): + sys.exit(0) + edited_files = _read_session_edits(session_id) if not edited_files: sys.exit(0) @@ -162,6 +204,7 @@ def main(): "" ) + _touch_cooldown(session_id) json.dump({"systemMessage": message}, sys.stdout) sys.exit(0) diff --git a/.devcontainer/plugins/devs-marketplace/plugins/spec-workflow/scripts/spec-reminder.py b/.devcontainer/plugins/devs-marketplace/plugins/spec-workflow/scripts/spec-reminder.py index e1cd210..e1f0b0d 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/spec-workflow/scripts/spec-reminder.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/spec-workflow/scripts/spec-reminder.py @@ -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 + + 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) diff --git a/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/README.md b/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/README.md index 26bf983..8cd9e62 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/README.md +++ b/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/README.md @@ -30,6 +30,21 @@ These paths are always permitted regardless of working directory: | `~/.claude/` | Claude config, plans, rules | | `/tmp/` | System temp directory | +### Worktree Support + +When CWD is inside a `.claude/worktrees/` directory (e.g., when an agent runs in a git worktree), the guard automatically expands scope to the **project root** — the parent of `.claude/worktrees/`. + +This means: +- Sibling worktrees under the same project are **in-scope** +- The main project directory is **in-scope** +- Other projects remain **out-of-scope** + +Example: if CWD is `/workspaces/projects/MyApp/.claude/worktrees/agent-abc123`, the scope root becomes `/workspaces/projects/MyApp/`. All paths under that root are permitted. + +### Path Resolution + +Both CWD and target paths are resolved via `os.path.realpath()` before comparison. This prevents false positives when paths involve symlinks or bind mounts. + ### CWD Context Injection The plugin injects working directory awareness on four hook events: @@ -41,6 +56,8 @@ The plugin injects working directory awareness on four hook events: | PreToolUse | Context alongside scope enforcement | | SubagentStart | Ensure subagents know their scope | +When in a worktree, the injected context references the project root as the scope boundary. + ## How It Works ### Hook Lifecycle (File Tools) @@ -52,13 +69,15 @@ Claude calls Read, Write, Edit, NotebookEdit, Glob, or Grep │ └─→ guard-workspace-scope.py │ + ├─→ Resolve CWD via os.path.realpath() + ├─→ Resolve scope root (worktree → project root) ├─→ Extract target path from tool input - ├─→ Resolve via os.path.realpath() (handles symlinks) + ├─→ Resolve target via os.path.realpath() (handles symlinks) ├─→ BLACKLIST check (first!) → exit 2 if blacklisted - ├─→ cwd is /workspaces? → allow (bypass, blacklist already checked) - ├─→ Path within cwd? → allow + ├─→ scope root is /workspaces? → allow (bypass, blacklist already checked) + ├─→ Path within scope root? → allow ├─→ Path on allowlist? → allow - └─→ Out of scope → exit 2 (block) + └─→ Out of scope → exit 2 (block with resolved path details) ``` ### Hook Lifecycle (Bash) @@ -70,10 +89,11 @@ Claude calls Bash │ └─→ guard-workspace-scope.py │ + ├─→ Resolve CWD + scope root (worktree-aware) ├─→ Extract write targets (Layer 1) + workspace paths (Layer 2) ├─→ BLACKLIST check on ALL extracted paths → exit 2 if any blacklisted - ├─→ cwd is /workspaces? → allow (bypass, blacklist already checked) - ├─→ Layer 1: Check write targets against scope + ├─→ scope root is /workspaces? → allow (bypass, blacklist already checked) + ├─→ Layer 1: Check write targets against scope root │ ├─→ System command exemption (only if ALL targets are system paths) │ └─→ exit 2 if any write target out of scope └─→ Layer 2: Scan ALL /workspaces/ paths in command (ALWAYS runs) diff --git a/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/guard-workspace-scope.py b/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/guard-workspace-scope.py index 8aae4a9..5678a77 100755 --- a/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/guard-workspace-scope.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/guard-workspace-scope.py @@ -5,6 +5,7 @@ Blocks ALL operations (read, write, bash) outside the current working directory. Permanently blacklists /workspaces/.devcontainer/ — no exceptions, no bypass. Bash enforcement via two-layer detection: write target extraction + workspace path scan. +Worktree-aware: detects .claude/worktrees/ in CWD and expands scope to project root. Fails closed on any error. Exit code 2 blocks the operation with an error message. @@ -148,6 +149,25 @@ def is_allowlisted(resolved_path: str) -> bool: return any(resolved_path.startswith(prefix) for prefix in ALLOWED_PREFIXES) +# Worktree path segment used to detect worktree CWDs +_WORKTREE_SEGMENT = "/.claude/worktrees/" + + +def resolve_scope_root(cwd: str) -> str: + """Resolve CWD to the effective scope root. + + When CWD is inside a .claude/worktrees/ directory, the scope root + is the project root (the parent of .claude/worktrees/). This allows + sibling worktrees and the main project directory to remain in-scope. + + Returns cwd unchanged when not in a worktree. + """ + idx = cwd.find(_WORKTREE_SEGMENT) + if idx != -1: + return cwd[:idx] + return cwd + + def get_target_path(tool_name: str, tool_input: dict) -> str | None: """Extract the target path from tool input. @@ -286,8 +306,9 @@ def check_bash_scope(command: str, cwd: str) -> None: if not skip_layer1: for target, resolved in resolved_targets: if not is_in_scope(resolved, cwd) and not is_allowlisted(resolved): + detail = f" (resolved: {resolved})" if resolved != target else "" print( - f"Blocked: Bash command writes to '{target}' which is " + f"Blocked: Bash command writes to '{target}'{detail} which is " f"outside the working directory ({cwd}).", file=sys.stderr, ) @@ -297,8 +318,9 @@ def check_bash_scope(command: str, cwd: str) -> None: for path_str in workspace_paths: resolved = os.path.realpath(path_str) if not is_in_scope(resolved, cwd) and not is_allowlisted(resolved): + detail = f" (resolved: {resolved})" if resolved != path_str else "" print( - f"Blocked: Bash command references '{path_str}' which is " + f"Blocked: Bash command references '{path_str}'{detail} which is " f"outside the working directory ({cwd}).", file=sys.stderr, ) @@ -316,11 +338,14 @@ def main(): tool_name = input_data.get("tool_name", "") tool_input = input_data.get("tool_input", {}) - cwd = os.getcwd() + # Resolve CWD with realpath for consistent comparison with resolved targets + cwd = os.path.realpath(os.getcwd()) + # Expand scope to project root when running inside a worktree + scope_root = resolve_scope_root(cwd) # --- Bash tool: separate code path --- if tool_name == "Bash": - check_bash_scope(tool_input.get("command", ""), cwd) + check_bash_scope(tool_input.get("command", ""), scope_root) sys.exit(0) # --- File tools --- @@ -350,11 +375,11 @@ def main(): sys.exit(2) # cwd=/workspaces bypass (blacklist already checked) - if cwd == "/workspaces": + if scope_root == "/workspaces": sys.exit(0) # In-scope check - if is_in_scope(resolved, cwd): + if is_in_scope(resolved, scope_root): sys.exit(0) # Allowlist check @@ -362,9 +387,15 @@ def main(): sys.exit(0) # Out of scope — BLOCK for ALL tools + detail = f" (resolved: {resolved})" if resolved != target_path else "" + scope_info = ( + f"scope root ({scope_root})" + if scope_root != cwd + else f"working directory ({scope_root})" + ) print( - f"Blocked: {tool_name} targets '{target_path}' which is outside " - f"the working directory ({cwd}). Move to that project's directory " + f"Blocked: {tool_name} targets '{target_path}'{detail} which is outside " + f"the {scope_info}. Move to that project's directory " f"first or work from /workspaces.", file=sys.stderr, ) diff --git a/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/inject-workspace-cwd.py b/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/inject-workspace-cwd.py index 64a3efb..8ee682c 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/inject-workspace-cwd.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/inject-workspace-cwd.py @@ -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." )