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."
)