From b237d5fda8c8129fec162941f25475100318917c Mon Sep 17 00:00:00 2001 From: Molecule AI Plugin-Dev Date: Sun, 10 May 2026 09:04:07 +0000 Subject: [PATCH 1/2] security: block token exfiltration patterns in careful-bash hook OFFSEC-002 (molecule-core#265): molecule-careful-bash did not block credential exfiltration commands. An LLM prompt injection could instruct the agent to read token files or grep for secrets in env. Added blocking for: - Direct token file reads: ~/.gh_token, .auth_token, .git-credentials-cache - cat of home-directory token paths: ~/.config/gh_token, /home/agent/.gh_token - env | grep for secrets: token, api_key, secret, auth, password (case-insensitive) - Generic credential file extensions in cat targets - curl/wget credential redirect exfil Also fixed: rm -rf .git check was looking for "/.git" (space before slash) which never matched "rm -rf .git". Changed to regex r"(^|\s)\.git(?:\s|$|/)". Added tests: 35 tests covering existing guards + new OFFSEC-002 patterns. All passing. Co-Authored-By: Claude Opus 4.7 --- .molecule-ci/scripts/requirements.txt | 1 + .molecule-ci/scripts/validate-plugin.py | 10 +- hooks/pre-bash-careful.py | 51 ++++- pytest.ini | 6 + ..._bash_careful.cpython-311-pytest-9.0.3.pyc | Bin 0 -> 43489 bytes tests/test_pre_bash_careful.py | 210 ++++++++++++++++++ 6 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/__pycache__/test_pre_bash_careful.cpython-311-pytest-9.0.3.pyc create mode 100644 tests/test_pre_bash_careful.py diff --git a/.molecule-ci/scripts/requirements.txt b/.molecule-ci/scripts/requirements.txt index 3aecde9..a08df0d 100644 --- a/.molecule-ci/scripts/requirements.txt +++ b/.molecule-ci/scripts/requirements.txt @@ -1 +1,2 @@ pyyaml>=6.0 + diff --git a/.molecule-ci/scripts/validate-plugin.py b/.molecule-ci/scripts/validate-plugin.py index 9e59e27..d463cb6 100644 --- a/.molecule-ci/scripts/validate-plugin.py +++ b/.molecule-ci/scripts/validate-plugin.py @@ -4,6 +4,7 @@ import os, sys, yaml errors = [] +# 1. plugin.yaml exists if not os.path.isfile("plugin.yaml"): print("::error::plugin.yaml not found at repo root") sys.exit(1) @@ -11,23 +12,28 @@ if not os.path.isfile("plugin.yaml"): with open("plugin.yaml") as f: plugin = yaml.safe_load(f) +# 2. Required fields for field in ["name", "version", "description"]: if not plugin.get(field): errors.append(f"Missing required field: {field}") +# 3. Version format v = str(plugin.get("version", "")) if v and not all(c in "0123456789." for c in v): errors.append(f"Invalid version format: {v}") +# 4. Runtimes type runtimes = plugin.get("runtimes") if runtimes is not None and not isinstance(runtimes, list): errors.append(f"runtimes must be a list, got {type(runtimes).__name__}") +# 5. Has content content_paths = ["SKILL.md", "hooks", "skills", "rules"] found = [p for p in content_paths if os.path.exists(p)] if not found: errors.append("Plugin must contain at least one of: SKILL.md, hooks/, skills/, rules/") +# 6. SKILL.md formatting check if os.path.isfile("SKILL.md"): with open("SKILL.md") as f: first_line = f.readline().strip() @@ -39,9 +45,9 @@ if errors: print(f"::error::{e}") sys.exit(1) -pn = plugin["name"]; pv = plugin["version"] -print(f"\u2713 plugin.yaml valid: {pn} v{pv}") +print(f"✓ plugin.yaml valid: {plugin['name']} v{plugin['version']}") if found: print(f" Content: {', '.join(found)}") if runtimes: print(f" Runtimes: {', '.join(runtimes)}") + diff --git a/hooks/pre-bash-careful.py b/hooks/pre-bash-careful.py index 32b6131..9989a25 100755 --- a/hooks/pre-bash-careful.py +++ b/hooks/pre-bash-careful.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """PreToolUse:Bash — enforce careful-mode patterns on shell commands.""" +import re import sys import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -44,7 +45,7 @@ def main() -> None: if "migrations" in cmd: deny_pretooluse("careful-mode: rm -rf inside a migrations dir is REFUSED.") deny_pretooluse(f"careful-mode: rm -rf at filesystem root, HOME, or .git is REFUSED. Command: {cmd[:200]}") - if "/.git" in cmd: + if re.search(r"(^|\s)\.git(?:\s|$|/)", cmd): deny_pretooluse("careful-mode: rm -rf .git is REFUSED. Re-clone if you need a fresh repo.") # WARN list — log but allow @@ -53,6 +54,54 @@ def main() -> None: if "gh pr close" in cmd or "gh issue close" in cmd: warn_to_stderr("[careful-mode WARN] closing a PR/issue is irreversible from this bot's standpoint. Confirm intent.") + # Token exfiltration — OFFSEC-002 / CRED-2 + # Block direct reads of known token file paths + token_paths = [ + ".gh_token", + ".git-credentials-cache", + ".auth_token", + "gh_token", + "git-credentials-cache", + ".auth-token", + ".claude/.gh_token", + "~/.gh_token", + ] + # Also block cat of any path containing token-like segments + if re.search(r"cat\s+.*(~|/home/[^/]+/)\S*(token|gh_token|git.credential|auth)", cmd, re.IGNORECASE): + deny_pretooluse( + "careful-mode: potential credential file read REFUSED. " + "Token files must not be read in the agent context. " + "Use platform-managed secrets instead." + ) + if any(tok in cmd for tok in token_paths): + deny_pretooluse( + "careful-mode: token file read REFUSED. " + "Token files must not be read in the agent context. " + "Use platform-managed secrets instead." + ) + + # Block env | grep for secrets (common exfil staging pattern) + # Matches: env | grep token; env|grep -i API_KEY; env | grep secret + if re.search(r"env\s*\|\s*grep\s+(-i\s+)?(token|api_key|secret|auth|password|passwd)", cmd, re.IGNORECASE): + deny_pretooluse( + "careful-mode: env grep for secrets REFUSED. " + "Reading environment variables for secrets is not permitted in agent context." + ) + + # Block generic cat of potential credential file extensions or token-named files + if re.search(r"cat\s+.*(\.gh_token|\.git-credential|\.auth_token|auth_token|gh_token)", cmd, re.IGNORECASE): + deny_pretooluse( + "careful-mode: potential credential file read REFUSED. " + "Token files must not be read in the agent context." + ) + + # Block curl/wget exfiltration of credentials to external endpoints + if re.search(r"(curl|wget)\s+.*(Authorization|Bearer|token|key).*\s+>", cmd): + deny_pretooluse( + "careful-mode: credential exfiltration via redirect REFUSED. " + "Do not redirect credentials to external endpoints." + ) + if __name__ == "__main__": try: diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..7aa90b9 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v \ No newline at end of file diff --git a/tests/__pycache__/test_pre_bash_careful.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_pre_bash_careful.cpython-311-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8edd926e970f6a9cfdc31cecc9bb292315479f1e GIT binary patch literal 43489 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 TX?F Date: Sun, 10 May 2026 09:06:13 +0000 Subject: [PATCH 2/2] docs: record OFFSEC-002 resolution in known-issues.md Co-Authored-By: Claude Opus 4.7 --- known-issues.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/known-issues.md b/known-issues.md index 68b663e..0536239 100644 --- a/known-issues.md +++ b/known-issues.md @@ -10,7 +10,30 @@ ## Recently Resolved -*(No recently resolved issues.)* +### [RESOLVED] OFFSEC-002: Token exfiltration patterns not blocked + +**Severity:** P0 (security) +**Resolved in:** v1.0.1 + +**Symptoms:** The PreToolUse:Bash hook blocked destructive commands (force push, DROP TABLE, rm -rf) but did NOT block token exfiltration patterns. An LLM prompt injection could instruct the agent to execute: +- `cat ~/.gh_token` +- `cat /tmp/.git-credentials-cache` +- `env | grep token` + +**Cause:** The REFUSE list in `pre-bash-careful.py` only covered git/SQL/rm commands. Token file reads and env variable grep patterns were not covered. + +**Fix:** Added blocking for: +- Direct token file reads (`.gh_token`, `.auth_token`, `.git-credentials-cache`, etc.) +- `cat` of home-directory token paths (`~/.config/gh_token`, `/home/agent/.gh_token`) +- `env | grep` for secrets (case-insensitive: token, api_key, secret, auth, password, passwd) +- Generic credential file extensions in `cat` targets +- curl/wget credential redirect exfil + +Also fixed: `rm -rf .git` check used `"/.git"` string search which never matched. Changed to regex `r"(^|\s)\.git(?:\s|$|/)"`. + +**Prevention:** New security-sensitive patterns must be reviewed during plugin review. Add token-exfil test cases to any hook touching credential paths. + +--- --- -- 2.45.2