Skip to content

Commit 1dad59f

Browse files
committed
Harden scope guard for worktrees and throttle Stop hook reminders
Scope guard: resolve CWD with realpath to prevent symlink mismatches, detect .claude/worktrees/ and expand scope to project root so sibling worktrees aren't blocked, and improve error messages with resolved paths. Stop hooks: add 5-minute per-session cooldown to commit-reminder and spec-reminder to prevent repeated firing in team/agent scenarios.
1 parent c0584b7 commit 1dad59f

File tree

6 files changed

+176
-23
lines changed

6 files changed

+176
-23
lines changed

.devcontainer/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# CodeForge Devcontainer Changelog
22

3+
## v2.0.2 — 2026-03-02
4+
5+
### Security
6+
7+
- Workspace scope guard now resolves CWD with `os.path.realpath()` for consistent comparison with target paths, preventing false positives from symlinks and bind mounts
8+
- 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
9+
- Improved scope guard error messages to include resolved paths and scope root for easier debugging of false positives
10+
- CWD context injector now references the project root when running inside a worktree
11+
12+
### Agent System
13+
14+
- 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
15+
316
## v2.0.0 — 2026-02-26
417

518
### .codeforge/ Configuration System

.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/commit-reminder.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,39 @@
88
files touched) get an advisory suggestion; small changes are silent.
99
1010
Output is a systemMessage wrapped in <system-reminder> tags — advisory only,
11-
never blocks. The stop_hook_active guard prevents loops.
11+
never blocks. The stop_hook_active guard prevents loops. A 5-minute cooldown
12+
prevents repeated firing in agent/team scenarios where Stop events are frequent.
1213
"""
1314

1415
import json
1516
import os
1617
import subprocess
1718
import sys
19+
import time
1820

1921
GIT_CMD_TIMEOUT = 5
22+
COOLDOWN_SECS = 300 # 5 minutes between reminders per session
2023

2124
# Extensions considered source code (not config/docs)
22-
SOURCE_EXTS = frozenset((
23-
".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs",
24-
".java", ".kt", ".rb", ".svelte", ".vue", ".c", ".cpp", ".h",
25-
))
25+
SOURCE_EXTS = frozenset(
26+
(
27+
".py",
28+
".ts",
29+
".tsx",
30+
".js",
31+
".jsx",
32+
".go",
33+
".rs",
34+
".java",
35+
".kt",
36+
".rb",
37+
".svelte",
38+
".vue",
39+
".c",
40+
".cpp",
41+
".h",
42+
)
43+
)
2644

2745
# Patterns that indicate test files
2846
TEST_PATTERNS = ("test_", "_test.", ".test.", ".spec.", "/tests/", "/test/")
@@ -75,6 +93,26 @@ def _is_test_file(path: str) -> bool:
7593
return any(pattern in lower for pattern in TEST_PATTERNS)
7694

7795

96+
def _is_on_cooldown(session_id: str) -> bool:
97+
"""Check if the reminder fired recently. Returns True to suppress."""
98+
cooldown_path = f"/tmp/claude-commit-reminder-cooldown-{session_id}"
99+
try:
100+
mtime = os.path.getmtime(cooldown_path)
101+
return (time.time() - mtime) < COOLDOWN_SECS
102+
except OSError:
103+
return False
104+
105+
106+
def _touch_cooldown(session_id: str) -> None:
107+
"""Mark the cooldown as active."""
108+
cooldown_path = f"/tmp/claude-commit-reminder-cooldown-{session_id}"
109+
try:
110+
with open(cooldown_path, "w") as f:
111+
f.write("")
112+
except OSError:
113+
pass
114+
115+
78116
def _is_meaningful(edited_files: list[str]) -> bool:
79117
"""Determine if the session's edits are meaningful enough to suggest committing.
80118
@@ -111,6 +149,10 @@ def main():
111149
if not session_id:
112150
sys.exit(0)
113151

152+
# Cooldown — suppress if fired within the last 5 minutes
153+
if _is_on_cooldown(session_id):
154+
sys.exit(0)
155+
114156
edited_files = _read_session_edits(session_id)
115157
if not edited_files:
116158
sys.exit(0)
@@ -162,6 +204,7 @@ def main():
162204
"</system-reminder>"
163205
)
164206

207+
_touch_cooldown(session_id)
165208
json.dump({"systemMessage": message}, sys.stdout)
166209
sys.exit(0)
167210

.devcontainer/plugins/devs-marketplace/plugins/spec-workflow/scripts/spec-reminder.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@
1111
Reads hook input from stdin (JSON). Returns JSON on stdout.
1212
Blocks with decision/reason so Claude addresses the spec gap
1313
before finishing. The stop_hook_active guard prevents infinite loops.
14+
A 5-minute cooldown prevents repeated firing in agent/team scenarios.
1415
"""
1516

1617
import json
1718
import os
1819
import subprocess
1920
import sys
21+
import time
2022

2123
GIT_CMD_TIMEOUT = 5
24+
COOLDOWN_SECS = 300 # 5 minutes between reminders per session
2225

2326
# Directories whose changes should trigger the spec reminder
2427
CODE_DIRS = (
@@ -40,6 +43,26 @@
4043
)
4144

4245

46+
def _is_on_cooldown(session_id: str) -> bool:
47+
"""Check if the reminder fired recently. Returns True to suppress."""
48+
cooldown_path = f"/tmp/claude-spec-reminder-cooldown-{session_id}"
49+
try:
50+
mtime = os.path.getmtime(cooldown_path)
51+
return (time.time() - mtime) < COOLDOWN_SECS
52+
except OSError:
53+
return False
54+
55+
56+
def _touch_cooldown(session_id: str) -> None:
57+
"""Mark the cooldown as active."""
58+
cooldown_path = f"/tmp/claude-spec-reminder-cooldown-{session_id}"
59+
try:
60+
with open(cooldown_path, "w") as f:
61+
f.write("")
62+
except OSError:
63+
pass
64+
65+
4366
def _run_git(args: list[str]) -> str | None:
4467
"""Run a git command and return stdout, or None on any failure."""
4568
try:
@@ -66,6 +89,11 @@ def main():
6689
if input_data.get("stop_hook_active"):
6790
sys.exit(0)
6891

92+
# Cooldown — suppress if fired within the last 5 minutes
93+
session_id = input_data.get("session_id", "")
94+
if session_id and _is_on_cooldown(session_id):
95+
sys.exit(0)
96+
6997
cwd = os.getcwd()
7098

7199
# Only fire if this project uses the spec system
@@ -116,6 +144,8 @@ def main():
116144
"or /spec-refine if the spec is still in draft status."
117145
)
118146

147+
if session_id:
148+
_touch_cooldown(session_id)
119149
json.dump({"decision": "block", "reason": message}, sys.stdout)
120150
sys.exit(0)
121151

.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/README.md

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ These paths are always permitted regardless of working directory:
3030
| `~/.claude/` | Claude config, plans, rules |
3131
| `/tmp/` | System temp directory |
3232

33+
### Worktree Support
34+
35+
When CWD is inside a `.claude/worktrees/<id>` 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/`.
36+
37+
This means:
38+
- Sibling worktrees under the same project are **in-scope**
39+
- The main project directory is **in-scope**
40+
- Other projects remain **out-of-scope**
41+
42+
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.
43+
44+
### Path Resolution
45+
46+
Both CWD and target paths are resolved via `os.path.realpath()` before comparison. This prevents false positives when paths involve symlinks or bind mounts.
47+
3348
### CWD Context Injection
3449

3550
The plugin injects working directory awareness on four hook events:
@@ -41,6 +56,8 @@ The plugin injects working directory awareness on four hook events:
4156
| PreToolUse | Context alongside scope enforcement |
4257
| SubagentStart | Ensure subagents know their scope |
4358

59+
When in a worktree, the injected context references the project root as the scope boundary.
60+
4461
## How It Works
4562

4663
### Hook Lifecycle (File Tools)
@@ -52,13 +69,15 @@ Claude calls Read, Write, Edit, NotebookEdit, Glob, or Grep
5269
5370
└─→ guard-workspace-scope.py
5471
72+
├─→ Resolve CWD via os.path.realpath()
73+
├─→ Resolve scope root (worktree → project root)
5574
├─→ Extract target path from tool input
56-
├─→ Resolve via os.path.realpath() (handles symlinks)
75+
├─→ Resolve target via os.path.realpath() (handles symlinks)
5776
├─→ BLACKLIST check (first!) → exit 2 if blacklisted
58-
├─→ cwd is /workspaces? → allow (bypass, blacklist already checked)
59-
├─→ Path within cwd? → allow
77+
├─→ scope root is /workspaces? → allow (bypass, blacklist already checked)
78+
├─→ Path within scope root? → allow
6079
├─→ Path on allowlist? → allow
61-
└─→ Out of scope → exit 2 (block)
80+
└─→ Out of scope → exit 2 (block with resolved path details)
6281
```
6382

6483
### Hook Lifecycle (Bash)
@@ -70,10 +89,11 @@ Claude calls Bash
7089
7190
└─→ guard-workspace-scope.py
7291
92+
├─→ Resolve CWD + scope root (worktree-aware)
7393
├─→ Extract write targets (Layer 1) + workspace paths (Layer 2)
7494
├─→ BLACKLIST check on ALL extracted paths → exit 2 if any blacklisted
75-
├─→ cwd is /workspaces? → allow (bypass, blacklist already checked)
76-
├─→ Layer 1: Check write targets against scope
95+
├─→ scope root is /workspaces? → allow (bypass, blacklist already checked)
96+
├─→ Layer 1: Check write targets against scope root
7797
│ ├─→ System command exemption (only if ALL targets are system paths)
7898
│ └─→ exit 2 if any write target out of scope
7999
└─→ Layer 2: Scan ALL /workspaces/ paths in command (ALWAYS runs)

.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/guard-workspace-scope.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Blocks ALL operations (read, write, bash) outside the current working directory.
66
Permanently blacklists /workspaces/.devcontainer/ — no exceptions, no bypass.
77
Bash enforcement via two-layer detection: write target extraction + workspace path scan.
8+
Worktree-aware: detects .claude/worktrees/ in CWD and expands scope to project root.
89
Fails closed on any error.
910
1011
Exit code 2 blocks the operation with an error message.
@@ -148,6 +149,25 @@ def is_allowlisted(resolved_path: str) -> bool:
148149
return any(resolved_path.startswith(prefix) for prefix in ALLOWED_PREFIXES)
149150

150151

152+
# Worktree path segment used to detect worktree CWDs
153+
_WORKTREE_SEGMENT = "/.claude/worktrees/"
154+
155+
156+
def resolve_scope_root(cwd: str) -> str:
157+
"""Resolve CWD to the effective scope root.
158+
159+
When CWD is inside a .claude/worktrees/<id> directory, the scope root
160+
is the project root (the parent of .claude/worktrees/). This allows
161+
sibling worktrees and the main project directory to remain in-scope.
162+
163+
Returns cwd unchanged when not in a worktree.
164+
"""
165+
idx = cwd.find(_WORKTREE_SEGMENT)
166+
if idx != -1:
167+
return cwd[:idx]
168+
return cwd
169+
170+
151171
def get_target_path(tool_name: str, tool_input: dict) -> str | None:
152172
"""Extract the target path from tool input.
153173
@@ -286,8 +306,9 @@ def check_bash_scope(command: str, cwd: str) -> None:
286306
if not skip_layer1:
287307
for target, resolved in resolved_targets:
288308
if not is_in_scope(resolved, cwd) and not is_allowlisted(resolved):
309+
detail = f" (resolved: {resolved})" if resolved != target else ""
289310
print(
290-
f"Blocked: Bash command writes to '{target}' which is "
311+
f"Blocked: Bash command writes to '{target}'{detail} which is "
291312
f"outside the working directory ({cwd}).",
292313
file=sys.stderr,
293314
)
@@ -297,8 +318,9 @@ def check_bash_scope(command: str, cwd: str) -> None:
297318
for path_str in workspace_paths:
298319
resolved = os.path.realpath(path_str)
299320
if not is_in_scope(resolved, cwd) and not is_allowlisted(resolved):
321+
detail = f" (resolved: {resolved})" if resolved != path_str else ""
300322
print(
301-
f"Blocked: Bash command references '{path_str}' which is "
323+
f"Blocked: Bash command references '{path_str}'{detail} which is "
302324
f"outside the working directory ({cwd}).",
303325
file=sys.stderr,
304326
)
@@ -316,11 +338,14 @@ def main():
316338
tool_name = input_data.get("tool_name", "")
317339
tool_input = input_data.get("tool_input", {})
318340

319-
cwd = os.getcwd()
341+
# Resolve CWD with realpath for consistent comparison with resolved targets
342+
cwd = os.path.realpath(os.getcwd())
343+
# Expand scope to project root when running inside a worktree
344+
scope_root = resolve_scope_root(cwd)
320345

321346
# --- Bash tool: separate code path ---
322347
if tool_name == "Bash":
323-
check_bash_scope(tool_input.get("command", ""), cwd)
348+
check_bash_scope(tool_input.get("command", ""), scope_root)
324349
sys.exit(0)
325350

326351
# --- File tools ---
@@ -350,21 +375,27 @@ def main():
350375
sys.exit(2)
351376

352377
# cwd=/workspaces bypass (blacklist already checked)
353-
if cwd == "/workspaces":
378+
if scope_root == "/workspaces":
354379
sys.exit(0)
355380

356381
# In-scope check
357-
if is_in_scope(resolved, cwd):
382+
if is_in_scope(resolved, scope_root):
358383
sys.exit(0)
359384

360385
# Allowlist check
361386
if is_allowlisted(resolved):
362387
sys.exit(0)
363388

364389
# Out of scope — BLOCK for ALL tools
390+
detail = f" (resolved: {resolved})" if resolved != target_path else ""
391+
scope_info = (
392+
f"scope root ({scope_root})"
393+
if scope_root != cwd
394+
else f"working directory ({scope_root})"
395+
)
365396
print(
366-
f"Blocked: {tool_name} targets '{target_path}' which is outside "
367-
f"the working directory ({cwd}). Move to that project's directory "
397+
f"Blocked: {tool_name} targets '{target_path}'{detail} which is outside "
398+
f"the {scope_info}. Move to that project's directory "
368399
f"first or work from /workspaces.",
369400
file=sys.stderr,
370401
)

.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/inject-workspace-cwd.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
CWD context injector — injects working directory into Claude's context
44
on every session start, user prompt, tool call, and subagent spawn.
55
6+
Worktree-aware: when CWD is inside .claude/worktrees/, injects the project
7+
root as the scope boundary instead of the worktree-specific path.
8+
69
Fires on: SessionStart, UserPromptSubmit, PreToolUse, SubagentStart
710
Always exits 0 (advisory, never blocking).
811
"""
@@ -11,20 +14,33 @@
1114
import os
1215
import sys
1316

17+
# Must match the segment used in guard-workspace-scope.py
18+
_WORKTREE_SEGMENT = "/.claude/worktrees/"
19+
20+
21+
def resolve_scope_root(cwd: str) -> str:
22+
"""Resolve CWD to project root when inside a worktree."""
23+
idx = cwd.find(_WORKTREE_SEGMENT)
24+
if idx != -1:
25+
return cwd[:idx]
26+
return cwd
27+
1428

1529
def main():
16-
cwd = os.getcwd()
30+
cwd = os.path.realpath(os.getcwd())
1731
try:
1832
input_data = json.load(sys.stdin)
1933
# Some hook events provide cwd override
20-
cwd = input_data.get("cwd", cwd)
34+
cwd = os.path.realpath(input_data.get("cwd", cwd))
2135
hook_event = input_data.get("hook_event_name", "PreToolUse")
2236
except (json.JSONDecodeError, ValueError):
2337
hook_event = "PreToolUse"
2438

39+
scope_root = resolve_scope_root(cwd)
40+
2541
context = (
26-
f"Working Directory: {cwd}\n"
27-
f"All file operations and commands MUST target paths within {cwd}. "
42+
f"Working Directory: {cwd} — restrict all file operations to this directory unless explicitly instructed otherwise.\n"
43+
f"All file operations and commands MUST target paths within {scope_root}. "
2844
f"Do not read, write, or execute commands against paths outside this directory."
2945
)
3046

0 commit comments

Comments
 (0)