Skip to content

Commit ec73c17

Browse files
authored
Release v2.0.0: security hardening, test suite, CI pipeline (#43)
* Add branching strategy to CLAUDE.md Document the staging branch workflow: feature branches target staging for PRs, and staging is PRed to main for releases. * Fix critical security findings from codebase review - Remove env var injection in agent redirect log path (S2-01) - Protected files guards fail closed on unexpected errors (S2-04) - Harden dangerous command regexes: prefix stripping, symbolic chmod, setuid, docker system/volume, git filter-branch, plus-refspec (S2-03) - Unify write-target patterns across guards (5 → 18 patterns) (C1-02) - Fix greedy alternation in redirect regex (>>|> order) (Q3-01) - Block bare git stash in read-only mode (Q3-04) - Narrow configApply allowed destinations to /usr/local/share (S2-09) - Add pytest to CI pipeline (Q3-08) - Add 36 new test cases covering all fixes (241 → 277 tests) * Bump to v2.0.0 and run full test suite on prepublish - Version 1.14.2 → 2.0.0 - prepublishOnly now runs npm run test:all (Node + Python tests) - Sync README version to match package.json * Fix CI: use dynamic HOME in allowlist test The test hardcoded /home/vscode but CI runs as /home/runner. Use the module's _home variable to match the actual environment. * Fix CodeRabbit review issues and switch to domain changelog headings - Remove dead `if not LOG_FILE` guard in redirect-builtin-agents.py - Fix git global flag handling in stash/config index resolution - Add multi-target extraction for rm/touch/mkdir/chmod/chown commands - Switch CLAUDE.md changelog guidelines to domain headings - Restructure [Unreleased] changelog to domain headings, fix test counts - Add 12 new tests (289 total): global-flag stash tests, multi-target tests --------- Co-authored-by: AnExiledDev <AnExiledDev@users.noreply.github.com>
1 parent 6fe72f4 commit ec73c17

File tree

17 files changed

+617
-74
lines changed

17 files changed

+617
-74
lines changed

.devcontainer/CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,46 @@
22

33
## [Unreleased]
44

5+
### Security
6+
- Removed environment variable injection vector in agent redirect log path (S2-01)
7+
- Narrowed config deployment allowed destinations from `/usr/local` to `/usr/local/share` (S2-09)
8+
- Protected files guard now fails closed on unexpected errors instead of failing open (S2-04)
9+
10+
### Testing
11+
- **Plugin test suite** — 289 pytest tests covering 6 critical plugin scripts that previously had zero tests:
12+
- `block-dangerous.py` (62 tests) — all 33 dangerous command patterns with positive/negative/edge cases
13+
- `guard-workspace-scope.py` (40 tests) — blacklist, scope, allowlist, bash enforcement layers, primary command extraction
14+
- `guard-protected.py` (56 tests) — all protected file patterns (secrets, locks, keys, credentials, auth dirs)
15+
- `guard-protected-bash.py` (49 tests) — write target extraction, multi-target commands, and protected path integration
16+
- `guard-readonly-bash.py` (69 tests) — general-readonly and git-readonly modes, bypass prevention, global flag handling
17+
- `redirect-builtin-agents.py` (13 tests) — redirect mapping, passthrough, output structure
18+
- Added `test:plugins` and `test:all` npm scripts for running plugin tests
19+
- Python plugin tests (`pytest`) added to CI pipeline (Q3-08)
20+
21+
### Dangerous Command Blocker
22+
- **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
23+
- **Block `--force-with-lease`** — was slipping through regex; all force push variants now blocked uniformly
24+
- **Block remote branch deletion**`git push origin --delete` and colon-refspec deletion (`git push origin :branch`) now blocked; deleting remote branches closes associated PRs
25+
- **Fixed README** — error handling was documented as "fails open" but code actually fails closed; corrected to match behavior
26+
- Dangerous command blocker handles prefix bypasses (`\rm`, `command rm`, `env rm`) and symbolic chmod (S2-03)
27+
28+
### Guards
29+
- Fixed greedy alternation in write-target regex — `>>` now matched before `>` (Q3-01)
30+
- Unified write-target extraction patterns across guards — protected-files bash guard expanded from 5 to 20 patterns (C1-02)
31+
- Multi-target command support — `rm`, `touch`, `mkdir`, `chmod`, `chown` with multiple file operands now check all targets
32+
- Bare `git stash` (equivalent to push) now blocked in read-only mode (Q3-04)
33+
- Fixed git global flag handling — `git -C /path stash list` no longer misidentifies the stash subcommand
34+
35+
### Documentation
36+
- **DevContainer CLI guide** — dedicated Getting Started page for terminal-only workflows without VS Code
37+
- **v2 Migration Guide** — path changes, automatic migration, manual steps, breaking changes, and troubleshooting
38+
- Documented 4 previously undocumented agents in agents.md: implementer, investigator, tester, documenter
39+
- Added missing git-workflow and prompt-snippets to configuration.md enabledPlugins example
40+
- Added CONFIG_SOURCE_DIR deprecation note in environment variables reference
41+
- Added cc-orc orchestrator command to first-session launch commands table
42+
- Tabbed client-specific instructions on the installation page
43+
- Dedicated port forwarding reference page covering VS Code auto-detect, devcontainer-bridge, and SSH tunneling
44+
545
## v2.0.0 — 2026-02-26
646

747
### .codeforge/ Configuration System

.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/guard-readonly-bash.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -513,8 +513,9 @@ def check_git_readonly(command: str) -> str | None:
513513
# Resolve git global flags to find the real subcommand
514514
# e.g. git -C /path --no-pager log -> subcommand is "log"
515515
sub = None
516+
sub_idx = 0
516517
skip_next = False
517-
for w in words[1:]:
518+
for idx, w in enumerate(words[1:], start=1):
518519
if skip_next:
519520
skip_next = False
520521
continue
@@ -524,6 +525,7 @@ def check_git_readonly(command: str) -> str | None:
524525
if w.startswith("-"):
525526
continue
526527
sub = w
528+
sub_idx = idx
527529
break
528530

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

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

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

.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/redirect-builtin-agents.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
"""
1919

2020
import json
21-
import os
2221
import sys
2322
from datetime import datetime, timezone
2423

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

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

4443

4544
def log(message: str) -> None:
46-
"""Append a timestamped log entry if logging is enabled."""
47-
if not LOG_FILE:
48-
return
45+
"""Append a timestamped log entry."""
4946
try:
5047
with open(LOG_FILE, "a") as f:
5148
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")

.devcontainer/plugins/devs-marketplace/plugins/dangerous-command-blocker/scripts/block-dangerous.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,18 +110,71 @@
110110
"Blocked: push with colon-refspec deletes remote branches and closes "
111111
"associated pull requests. Do not use as a workaround for force push blocks.",
112112
),
113+
# Symbolic chmod equivalents of 777
114+
(
115+
r"\bchmod\s+a=rwx\b",
116+
"Blocked: chmod a=rwx is equivalent to 777 — security vulnerability",
117+
),
118+
(
119+
r"\bchmod\s+0777\b",
120+
"Blocked: chmod 0777 creates security vulnerability",
121+
),
122+
# SetUID/SetGID bits
123+
(
124+
r"\bchmod\s+u\+s\b",
125+
"Blocked: chmod u+s sets SetUID bit — privilege escalation risk",
126+
),
127+
(
128+
r"\bchmod\s+g\+s\b",
129+
"Blocked: chmod g+s sets SetGID bit — privilege escalation risk",
130+
),
131+
# Destructive Docker operations (additional)
132+
(
133+
r"\bdocker\s+system\s+prune\b",
134+
"Blocked: docker system prune removes all unused data",
135+
),
136+
(
137+
r"\bdocker\s+volume\s+rm\b",
138+
"Blocked: docker volume rm destroys persistent data",
139+
),
140+
# Git history rewriting
141+
(
142+
r"\bgit\s+filter-branch\b",
143+
"Blocked: git filter-branch rewrites repository history",
144+
),
145+
# Plus-refspec force push (git push origin +main)
146+
(
147+
r"\bgit\s+push\s+\S+\s+\+\S",
148+
"Blocked: plus-refspec push (+ref) is a force push that destroys history",
149+
),
150+
# Force push variant: --force-if-includes
151+
(r"\bgit\s+push\s+.*--force-if-includes\b", FORCE_PUSH_SUGGESTION),
113152
]
114153

115154

116-
def check_command(command: str) -> tuple[bool, str]:
117-
"""Check if command matches any dangerous pattern.
155+
def strip_command_prefixes(command: str) -> str:
156+
"""Strip common command prefixes that bypass word-boundary matching.
118157
119-
Returns:
120-
(is_dangerous, message)
158+
Handles: backslash prefix (\\rm), command prefix, env prefix.
121159
"""
122-
for pattern, message in DANGEROUS_PATTERNS:
123-
if re.search(pattern, command, re.IGNORECASE):
124-
return True, message
160+
stripped = command
161+
# Strip leading backslash from commands (e.g. \rm -> rm)
162+
stripped = re.sub(r"(?:^|(?<=\s))\\(?=\w)", "", stripped)
163+
# Strip 'command' prefix (e.g. 'command rm' -> 'rm')
164+
stripped = re.sub(r"\bcommand\s+", "", stripped)
165+
# Strip 'env' prefix with optional VAR=val args (e.g. 'env VAR=x rm' -> 'rm')
166+
stripped = re.sub(r"\benv\s+(?:\w+=\S+\s+)*", "", stripped)
167+
return stripped
168+
169+
170+
def check_command(command: str) -> tuple[bool, str]:
171+
"""Check if command matches any dangerous pattern."""
172+
stripped = strip_command_prefixes(command)
173+
# Check both original and stripped versions
174+
for cmd in (command, stripped):
175+
for pattern, message in DANGEROUS_PATTERNS:
176+
if re.search(pattern, cmd, re.IGNORECASE):
177+
return True, message
125178
return False, ""
126179

127180

.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected-bash.py

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import json
1212
import re
13+
import shlex
1314
import sys
1415

1516
# Same patterns as guard-protected.py
@@ -57,8 +58,8 @@
5758
# Patterns that indicate a bash command is writing to a file
5859
# Each captures the target file path for checking against PROTECTED_PATTERNS
5960
WRITE_PATTERNS = [
60-
# Redirect: > file, >> file
61-
r"(?:>|>>)\s*([^\s;&|]+)",
61+
# Redirect: >> file, > file (>> before > to avoid greedy match)
62+
r"(?:>>|>)\s*([^\s;&|]+)",
6263
# tee: tee file, tee -a file
6364
r"\btee\s+(?:-a\s+)?([^\s;&|]+)",
6465
# cp/mv: cp src dest, mv src dest
@@ -67,9 +68,78 @@
6768
r'\bsed\s+-i[^\s]*\s+(?:\'[^\']*\'\s+|"[^"]*"\s+|[^\s]+\s+)*([^\s;&|]+)',
6869
# cat > file (heredoc style)
6970
r"\bcat\s+(?:<<[^\s]*\s+)?>\s*([^\s;&|]+)",
71+
# --- Extended patterns (unified with guard-workspace-scope.py) ---
72+
r"\btouch\s+(?:-[^\s]+\s+)*([^\s;&|]+)", # touch file
73+
r"\bmkdir\s+(?:-[^\s]+\s+)*([^\s;&|]+)", # mkdir [-p] dir
74+
r"\brm\s+(?:-[^\s]+\s+)*([^\s;&|]+)", # rm [-rf] path
75+
r"\bln\s+(?:-[^\s]+\s+)*[^\s]+\s+([^\s;&|]+)", # ln [-s] src dest
76+
r"\binstall\s+(?:-[^\s]+\s+)*[^\s]+\s+([^\s;&|]+)", # install src dest
77+
r"\brsync\s+(?:-[^\s]+\s+)*[^\s]+\s+([^\s;&|]+)", # rsync src dest
78+
r"\bchmod\s+(?:-[^\s]+\s+)*[^\s]+\s+([^\s;&|]+)", # chmod mode path
79+
r"\bchown\s+(?:-[^\s]+\s+)*[^\s:]+(?::[^\s]+)?\s+([^\s;&|]+)", # chown owner[:group] path
80+
r"\bdd\b[^;|&]*\bof=([^\s;&|]+)", # dd of=path
81+
r"\bwget\s+(?:-[^\s]+\s+)*-O\s+([^\s;&|]+)", # wget -O path
82+
r"\bcurl\s+(?:-[^\s]+\s+)*-o\s+([^\s;&|]+)", # curl -o path
83+
r"\btar\s+(?:-[^\s]+\s+)*-C\s+([^\s;&|]+)", # tar -C dir
84+
r"\bunzip\s+(?:-[^\s]+\s+)*-d\s+([^\s;&|]+)", # unzip -d dir
85+
r"\b(?:gcc|g\+\+|cc|c\+\+|clang)\s+(?:-[^\s]+\s+)*-o\s+([^\s;&|]+)", # gcc -o out
86+
r"\bsqlite3\s+([^\s;&|]+)", # sqlite3 dbpath
7087
]
7188

7289

90+
# Commands where all trailing non-flag arguments are file targets
91+
_MULTI_TARGET_CMDS = frozenset({"rm", "touch", "mkdir"})
92+
# Commands where the first non-flag arg is NOT a file (mode/owner), rest are
93+
_SKIP_FIRST_ARG_CMDS = frozenset({"chmod", "chown"})
94+
95+
96+
def _extract_multi_targets(command: str) -> list[str]:
97+
"""Extract all file targets from commands that accept multiple operands."""
98+
try:
99+
tokens = shlex.split(command)
100+
except ValueError:
101+
return []
102+
if not tokens:
103+
return []
104+
105+
# Handle prefixes like sudo, env, etc.
106+
prefixes = {"sudo", "env", "nohup", "nice", "command"}
107+
i = 0
108+
while i < len(tokens) and tokens[i] in prefixes:
109+
i += 1
110+
# Skip sudo flags like -u root
111+
if i > 0 and tokens[i - 1] == "sudo":
112+
while i < len(tokens) and tokens[i].startswith("-"):
113+
i += 1
114+
if i < len(tokens) and not tokens[i].startswith("-"):
115+
i += 1 # skip flag argument
116+
# Skip env VAR=val
117+
if i > 0 and tokens[i - 1] == "env":
118+
while i < len(tokens) and "=" in tokens[i]:
119+
i += 1
120+
if i >= len(tokens):
121+
return []
122+
cmd = tokens[i]
123+
124+
if cmd not in _MULTI_TARGET_CMDS and cmd not in _SKIP_FIRST_ARG_CMDS:
125+
return []
126+
127+
# Collect non-flag arguments
128+
args = []
129+
j = i + 1
130+
while j < len(tokens):
131+
if tokens[j].startswith("-"):
132+
j += 1
133+
continue
134+
args.append(tokens[j])
135+
j += 1
136+
137+
if cmd in _SKIP_FIRST_ARG_CMDS and args:
138+
args = args[1:] # First arg is mode/owner, not a file
139+
140+
return args
141+
142+
73143
def extract_write_targets(command: str) -> list[str]:
74144
"""Extract file paths that the command writes to."""
75145
targets = []
@@ -78,6 +148,10 @@ def extract_write_targets(command: str) -> list[str]:
78148
target = match.group(1).strip("'\"")
79149
if target:
80150
targets.append(target)
151+
# Supplement with multi-target extraction for commands like rm, touch, chmod
152+
for target in _extract_multi_targets(command):
153+
if target not in targets:
154+
targets.append(target)
81155
return targets
82156

83157

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

120194

121195
if __name__ == "__main__":

.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ def main():
100100
# Fail closed: can't parse means can't verify safety
101101
sys.exit(2)
102102
except Exception as e:
103-
# Log error but don't block on hook failure
103+
# Fail closed: unexpected errors should block, not allow
104104
print(f"Hook error: {e}", file=sys.stderr)
105-
sys.exit(0)
105+
sys.exit(2)
106106

107107

108108
if __name__ == "__main__":

0 commit comments

Comments
 (0)