Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .devcontainer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,39 @@
files touched) get an advisory suggestion; small changes are silent.

Output is a systemMessage wrapped in <system-reminder> 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/")
Expand Down Expand Up @@ -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
Comment on lines +96 to +113
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply the same cooldown-path hardening here.

The cooldown filename is built from unsanitized session_id in /tmp, creating the same path-manipulation risk in this hook.

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-commit-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-commit-reminder-cooldown-{session_id}"
+    cooldown_path = _cooldown_path(session_id)
@@
 def _touch_cooldown(session_id: str) -> None:
     """Mark the cooldown as active."""
-    cooldown_path = f"/tmp/claude-commit-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
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
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-commit-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 = _cooldown_path(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 = _cooldown_path(session_id)
try:
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] 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
Verify each finding against the current code and only fix it if needed.

In
@.devcontainer/plugins/devs-marketplace/plugins/session-context/scripts/commit-reminder.py
around lines 96 - 113, The cooldown filename currently embeds the raw session_id
into cooldown_path in both _is_on_cooldown and _touch_cooldown, which is unsafe;
instead derive a safe filename (e.g., hash the session_id with hashlib.sha256
and use the hex digest) and join it with a safe temp dir (e.g.,
tempfile.gettempdir() or pathlib.Path) so no path separators or traversal are
possible; update both functions to compute the sanitized filename once (e.g.,
safe_name = sha256(session_id.encode()).hexdigest()) and use that to build
cooldown_path before calling os.path.getmtime or opening the file.



def _is_meaningful(edited_files: list[str]) -> bool:
"""Determine if the session's edits are meaningful enough to suggest committing.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -162,6 +204,7 @@ def main():
"</system-reminder>"
)

_touch_cooldown(session_id)
json.dump({"systemMessage": message}, sys.stdout)
sys.exit(0)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden cooldown file path construction against path injection.

session_id is interpolated directly into a /tmp/... path. A malformed session_id (path separators, traversal tokens) can redirect reads/writes outside the intended cooldown file location.

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
Verify each finding against the current code and only fix it if needed.

In
@.devcontainer/plugins/devs-marketplace/plugins/spec-workflow/scripts/spec-reminder.py
around lines 46 - 63, The cooldown file name currently interpolates session_id
directly in _is_on_cooldown and _touch_cooldown, allowing path-injection;
instead derive a safe filename (e.g. hash the session_id with hashlib.sha256 and
use the hex digest) or otherwise sanitize/escape it, then build the
cooldown_path from that safe token (e.g.
f"/tmp/claude-spec-reminder-cooldown-{safe_token}"); keep the same logic for
reading/writing but use the safe token in both _is_on_cooldown and
_touch_cooldown so reads and writes target only intended files.



def _run_git(args: list[str]) -> str | None:
"""Run a git command and return stdout, or None on any failure."""
try:
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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/`.

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:
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/<id> 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.

Expand Down Expand Up @@ -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,
)
Expand All @@ -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,
)
Expand All @@ -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 ---
Expand Down Expand Up @@ -350,21 +375,27 @@ 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
if is_allowlisted(resolved):
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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
"""
Expand All @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Scope instruction text is inconsistent in worktree mode.

Line 42 frames the boundary as cwd, while Line 43 enforces scope_root; when they differ, the instruction conflicts.

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
Verify each finding against the current code and only fix it if needed.

In
@.devcontainer/plugins/devs-marketplace/plugins/workspace-scope-guard/scripts/inject-workspace-cwd.py
around lines 42 - 44, The two adjacent f-strings use different variables (cwd vs
scope_root) producing conflicting scope text; update the first f-string to
consistently reference scope_root (or explicitly show both) so the message
aligns—e.g., replace "Working Directory: {cwd}" with "Working Directory:
{scope_root}" or "Working Directory: {scope_root} (cwd: {cwd})" so the
instruction consistently enforces the same boundary; change the string in the
code that builds the message where cwd and scope_root are used.

)

Expand Down