Skip to content
Merged
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
40 changes: 40 additions & 0 deletions .devcontainer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,46 @@

## [Unreleased]

### Security
- Removed environment variable injection vector in agent redirect log path (S2-01)
- Narrowed config deployment allowed destinations from `/usr/local` to `/usr/local/share` (S2-09)
- Protected files guard now fails closed on unexpected errors instead of failing open (S2-04)

### Testing
- **Plugin test suite** — 289 pytest tests covering 6 critical plugin scripts that previously had zero tests:
- `block-dangerous.py` (62 tests) — all 33 dangerous command patterns with positive/negative/edge cases
- `guard-workspace-scope.py` (40 tests) — blacklist, scope, allowlist, bash enforcement layers, primary command extraction
- `guard-protected.py` (56 tests) — all protected file patterns (secrets, locks, keys, credentials, auth dirs)
- `guard-protected-bash.py` (49 tests) — write target extraction, multi-target commands, and protected path integration
- `guard-readonly-bash.py` (69 tests) — general-readonly and git-readonly modes, bypass prevention, global flag handling
- `redirect-builtin-agents.py` (13 tests) — redirect mapping, passthrough, output structure
- Added `test:plugins` and `test:all` npm scripts for running plugin tests
- Python plugin tests (`pytest`) added to CI pipeline (Q3-08)

### Dangerous Command Blocker
- **Force push block now suggests `git merge` as workaround** — error message explains how to avoid diverged history instead of leaving the agent to improvise destructive workarounds
- **Block `--force-with-lease`** — was slipping through regex; all force push variants now blocked uniformly
- **Block remote branch deletion** — `git push origin --delete` and colon-refspec deletion (`git push origin :branch`) now blocked; deleting remote branches closes associated PRs
- **Fixed README** — error handling was documented as "fails open" but code actually fails closed; corrected to match behavior
- Dangerous command blocker handles prefix bypasses (`\rm`, `command rm`, `env rm`) and symbolic chmod (S2-03)

### Guards
- Fixed greedy alternation in write-target regex — `>>` now matched before `>` (Q3-01)
- Unified write-target extraction patterns across guards — protected-files bash guard expanded from 5 to 20 patterns (C1-02)
- Multi-target command support — `rm`, `touch`, `mkdir`, `chmod`, `chown` with multiple file operands now check all targets
- Bare `git stash` (equivalent to push) now blocked in read-only mode (Q3-04)
- Fixed git global flag handling — `git -C /path stash list` no longer misidentifies the stash subcommand
Comment on lines +22 to +33
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

Rewrite implementation-centric bullets from user-impact perspective.

Several bullets describe internal mechanics (e.g., “was slipping through regex”, “greedy alternation”, “misidentifies subcommand”). Prefer outcome language (“force-with-lease is now blocked”, “append redirects are correctly detected first”, etc.) so release notes stay user-focused.

As per coding guidelines "Write CHANGELOG entries from the user's perspective — what changed, not how it was implemented".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.devcontainer/CHANGELOG.md around lines 22 - 33, Rewrite the CHANGELOG
bullets to be user-facing outcomes rather than implementation details: replace
lines like "was slipping through regex" and "greedy alternation in write-target
regex" with outcome statements such as "force-with-lease is now blocked" and
"append redirects are detected before overwrite redirects"; convert
"misidentifies subcommand" to "git stash with global flags is correctly
recognized" and similar for other items (e.g., change "Fixed README — error
handling was documented as 'fails open' but code actually fails closed" to
"README updated to reflect that error handling fails closed"). Update the Guards
section entries (e.g., "Multi-target command support", "Bare `git stash`",
"Fixed git global flag handling") to describe the user-visible behavior changes
rather than the internal fixes.


### Documentation
- **DevContainer CLI guide** — dedicated Getting Started page for terminal-only workflows without VS Code
- **v2 Migration Guide** — path changes, automatic migration, manual steps, breaking changes, and troubleshooting
- Documented 4 previously undocumented agents in agents.md: implementer, investigator, tester, documenter
- Added missing git-workflow and prompt-snippets to configuration.md enabledPlugins example
- Added CONFIG_SOURCE_DIR deprecation note in environment variables reference
- Added cc-orc orchestrator command to first-session launch commands table
- Tabbed client-specific instructions on the installation page
- Dedicated port forwarding reference page covering VS Code auto-detect, devcontainer-bridge, and SSH tunneling

## v2.0.0 — 2026-02-26

### .codeforge/ Configuration System
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,8 +513,9 @@ def check_git_readonly(command: str) -> str | None:
# Resolve git global flags to find the real subcommand
# e.g. git -C /path --no-pager log -> subcommand is "log"
sub = None
sub_idx = 0
skip_next = False
for w in words[1:]:
for idx, w in enumerate(words[1:], start=1):
if skip_next:
skip_next = False
continue
Expand All @@ -524,6 +525,7 @@ def check_git_readonly(command: str) -> str | None:
if w.startswith("-"):
continue
sub = w
sub_idx = idx
break

if sub is None:
Expand All @@ -545,16 +547,18 @@ def check_git_readonly(command: str) -> str | None:
"-l",
"--get-regexp",
}
if not (set(words[2:]) & safe_flags):
if not (set(words[sub_idx + 1 :]) & safe_flags):
return "Blocked: 'git config' is only allowed with --get or --list"

elif sub == "stash":
# Only allow "stash list" and "stash show"
if len(words) > 2 and words[2] not in ("list", "show"):
return f"Blocked: 'git stash {words[2]}' is not allowed in read-only mode"
if len(words) <= sub_idx + 1:
return "Blocked: bare 'git stash' (equivalent to push) is not allowed in read-only mode"
if words[sub_idx + 1] not in ("list", "show"):
return f"Blocked: 'git stash {words[sub_idx + 1]}' is not allowed in read-only mode"

else:
for w in words[2:]:
for w in words[sub_idx + 1 :]:
if w in restricted:
return f"Blocked: 'git {sub} {w}' is not allowed in read-only mode"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
"""

import json
import os
import sys
from datetime import datetime, timezone

Expand All @@ -39,13 +38,11 @@
# Handles cases where the model uses the short name directly
UNQUALIFIED_MAP = {v: f"{PLUGIN_PREFIX}:{v}" for v in REDIRECT_MAP.values()}

LOG_FILE = os.environ.get("AGENT_REDIRECT_LOG", "/tmp/agent-redirect.log")
LOG_FILE = "/tmp/agent-redirect.log"


def log(message: str) -> None:
"""Append a timestamped log entry if logging is enabled."""
if not LOG_FILE:
return
"""Append a timestamped log entry."""
try:
with open(LOG_FILE, "a") as f:
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,71 @@
"Blocked: push with colon-refspec deletes remote branches and closes "
"associated pull requests. Do not use as a workaround for force push blocks.",
),
# Symbolic chmod equivalents of 777
(
r"\bchmod\s+a=rwx\b",
"Blocked: chmod a=rwx is equivalent to 777 — security vulnerability",
),
(
r"\bchmod\s+0777\b",
"Blocked: chmod 0777 creates security vulnerability",
),
# SetUID/SetGID bits
(
r"\bchmod\s+u\+s\b",
"Blocked: chmod u+s sets SetUID bit — privilege escalation risk",
),
(
r"\bchmod\s+g\+s\b",
"Blocked: chmod g+s sets SetGID bit — privilege escalation risk",
),
# Destructive Docker operations (additional)
(
r"\bdocker\s+system\s+prune\b",
"Blocked: docker system prune removes all unused data",
),
(
r"\bdocker\s+volume\s+rm\b",
"Blocked: docker volume rm destroys persistent data",
),
# Git history rewriting
(
r"\bgit\s+filter-branch\b",
"Blocked: git filter-branch rewrites repository history",
),
# Plus-refspec force push (git push origin +main)
(
r"\bgit\s+push\s+\S+\s+\+\S",
"Blocked: plus-refspec push (+ref) is a force push that destroys history",
),
# Force push variant: --force-if-includes
(r"\bgit\s+push\s+.*--force-if-includes\b", FORCE_PUSH_SUGGESTION),
]


def check_command(command: str) -> tuple[bool, str]:
"""Check if command matches any dangerous pattern.
def strip_command_prefixes(command: str) -> str:
"""Strip common command prefixes that bypass word-boundary matching.

Returns:
(is_dangerous, message)
Handles: backslash prefix (\\rm), command prefix, env prefix.
"""
for pattern, message in DANGEROUS_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
return True, message
stripped = command
# Strip leading backslash from commands (e.g. \rm -> rm)
stripped = re.sub(r"(?:^|(?<=\s))\\(?=\w)", "", stripped)
# Strip 'command' prefix (e.g. 'command rm' -> 'rm')
stripped = re.sub(r"\bcommand\s+", "", stripped)
# Strip 'env' prefix with optional VAR=val args (e.g. 'env VAR=x rm' -> 'rm')
stripped = re.sub(r"\benv\s+(?:\w+=\S+\s+)*", "", stripped)
return stripped


def check_command(command: str) -> tuple[bool, str]:
"""Check if command matches any dangerous pattern."""
stripped = strip_command_prefixes(command)
# Check both original and stripped versions
for cmd in (command, stripped):
for pattern, message in DANGEROUS_PATTERNS:
if re.search(pattern, cmd, re.IGNORECASE):
return True, message
return False, ""


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import json
import re
import shlex
import sys

# Same patterns as guard-protected.py
Expand Down Expand Up @@ -57,8 +58,8 @@
# Patterns that indicate a bash command is writing to a file
# Each captures the target file path for checking against PROTECTED_PATTERNS
WRITE_PATTERNS = [
# Redirect: > file, >> file
r"(?:>|>>)\s*([^\s;&|]+)",
# Redirect: >> file, > file (>> before > to avoid greedy match)
r"(?:>>|>)\s*([^\s;&|]+)",
# tee: tee file, tee -a file
r"\btee\s+(?:-a\s+)?([^\s;&|]+)",
# cp/mv: cp src dest, mv src dest
Expand All @@ -67,9 +68,78 @@
r'\bsed\s+-i[^\s]*\s+(?:\'[^\']*\'\s+|"[^"]*"\s+|[^\s]+\s+)*([^\s;&|]+)',
# cat > file (heredoc style)
r"\bcat\s+(?:<<[^\s]*\s+)?>\s*([^\s;&|]+)",
# --- Extended patterns (unified with guard-workspace-scope.py) ---
r"\btouch\s+(?:-[^\s]+\s+)*([^\s;&|]+)", # touch file
r"\bmkdir\s+(?:-[^\s]+\s+)*([^\s;&|]+)", # mkdir [-p] dir
r"\brm\s+(?:-[^\s]+\s+)*([^\s;&|]+)", # rm [-rf] path
r"\bln\s+(?:-[^\s]+\s+)*[^\s]+\s+([^\s;&|]+)", # ln [-s] src dest
r"\binstall\s+(?:-[^\s]+\s+)*[^\s]+\s+([^\s;&|]+)", # install src dest
r"\brsync\s+(?:-[^\s]+\s+)*[^\s]+\s+([^\s;&|]+)", # rsync src dest
r"\bchmod\s+(?:-[^\s]+\s+)*[^\s]+\s+([^\s;&|]+)", # chmod mode path
r"\bchown\s+(?:-[^\s]+\s+)*[^\s:]+(?::[^\s]+)?\s+([^\s;&|]+)", # chown owner[:group] path
r"\bdd\b[^;|&]*\bof=([^\s;&|]+)", # dd of=path
r"\bwget\s+(?:-[^\s]+\s+)*-O\s+([^\s;&|]+)", # wget -O path
r"\bcurl\s+(?:-[^\s]+\s+)*-o\s+([^\s;&|]+)", # curl -o path
r"\btar\s+(?:-[^\s]+\s+)*-C\s+([^\s;&|]+)", # tar -C dir
r"\bunzip\s+(?:-[^\s]+\s+)*-d\s+([^\s;&|]+)", # unzip -d dir
r"\b(?:gcc|g\+\+|cc|c\+\+|clang)\s+(?:-[^\s]+\s+)*-o\s+([^\s;&|]+)", # gcc -o out
r"\bsqlite3\s+([^\s;&|]+)", # sqlite3 dbpath
]


# Commands where all trailing non-flag arguments are file targets
_MULTI_TARGET_CMDS = frozenset({"rm", "touch", "mkdir"})
# Commands where the first non-flag arg is NOT a file (mode/owner), rest are
_SKIP_FIRST_ARG_CMDS = frozenset({"chmod", "chown"})


def _extract_multi_targets(command: str) -> list[str]:
"""Extract all file targets from commands that accept multiple operands."""
try:
tokens = shlex.split(command)
except ValueError:
return []
if not tokens:
return []

# Handle prefixes like sudo, env, etc.
prefixes = {"sudo", "env", "nohup", "nice", "command"}
i = 0
while i < len(tokens) and tokens[i] in prefixes:
i += 1
# Skip sudo flags like -u root
if i > 0 and tokens[i - 1] == "sudo":
while i < len(tokens) and tokens[i].startswith("-"):
i += 1
if i < len(tokens) and not tokens[i].startswith("-"):
i += 1 # skip flag argument
# Skip env VAR=val
if i > 0 and tokens[i - 1] == "env":
while i < len(tokens) and "=" in tokens[i]:
i += 1
if i >= len(tokens):
return []
cmd = tokens[i]

if cmd not in _MULTI_TARGET_CMDS and cmd not in _SKIP_FIRST_ARG_CMDS:
return []

# Collect non-flag arguments
args = []
j = i + 1
while j < len(tokens):
if tokens[j].startswith("-"):
j += 1
continue
args.append(tokens[j])
j += 1

if cmd in _SKIP_FIRST_ARG_CMDS and args:
args = args[1:] # First arg is mode/owner, not a file

return args


def extract_write_targets(command: str) -> list[str]:
"""Extract file paths that the command writes to."""
targets = []
Expand All @@ -78,6 +148,10 @@ def extract_write_targets(command: str) -> list[str]:
target = match.group(1).strip("'\"")
if target:
targets.append(target)
# Supplement with multi-target extraction for commands like rm, touch, chmod
for target in _extract_multi_targets(command):
if target not in targets:
targets.append(target)
return targets


Expand Down Expand Up @@ -113,9 +187,9 @@ def main():
# Fail closed: can't parse means can't verify safety
sys.exit(2)
except Exception as e:
# Log error but don't block on hook failure
# Fail closed: unexpected errors should block, not allow
print(f"Hook error: {e}", file=sys.stderr)
sys.exit(0)
sys.exit(2)


if __name__ == "__main__":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ def main():
# Fail closed: can't parse means can't verify safety
sys.exit(2)
except Exception as e:
# Log error but don't block on hook failure
# Fail closed: unexpected errors should block, not allow
print(f"Hook error: {e}", file=sys.stderr)
sys.exit(0)
sys.exit(2)


if __name__ == "__main__":
Expand Down
Loading