"""coding-agent-security-prompt-injection-mcp-secrets.py — audit coding-agent tool configs
for the patterns that widen an agent's blast radius.
Reads any of: Claude Code `.mcp.json`, `.claude/settings.json`, `.claude/settings.local.json`,
`~/.claude.json`; Gemini CLI `settings.json` (its `mcpServers` block); Codex `config.toml`
(`approval_policy`, `sandbox_mode`, `web_search`, `[mcp_servers.*]`, `[features.network_proxy]`).
Flags (severity in brackets):
UNPINNED [high] stdio server fetched by npx/uvx/pipx without a version, or docker without a tag/digest
SHELL [medium] server launched through a shell or a curl|sh pipeline
SECRET [high] a literal secret in env/headers instead of a ${VAR} reference or bearer_token_env_var
PLAINTEXT [high] http:// endpoint that is not loopback
BYPASS [high] Gemini trust:true; Claude enableAllProjectMcpServers / bypassPermissions;
Codex approval_policy=never with danger-full-access; dangerously_* proxy flags
BROAD [medium] bare Bash, Bash(*), bare WebFetch, WebFetch(domain:*), curl/wget allow rules,
whole-server MCP allow rules
NETWORK [medium] Codex network_access=true; [low] web_search="live"
SANDBOX [low] Codex danger-full-access is [high]; Claude sandbox missing/off is [low]
ADVISORY [low] no deny rule for secret files; no includeTools allowlist; SSE transport; hooks in
a shared project file; a host secret forwarded to a server
Usage:
python coding-agent-security-prompt-injection-mcp-secrets.py --demo
python coding-agent-security-prompt-injection-mcp-secrets.py --write-demo
python coding-agent-security-prompt-injection-mcp-secrets.py [...]
Given a directory it looks for .mcp.json, .claude/settings.json, .claude/settings.local.json,
.gemini/settings.json and .codex/config.toml. Exit status: 2 if any high finding, 1 if any medium,
else 0. Standard library only; TOML needs Python 3.11+ (tomllib). It reads configuration and never
executes anything from it.
"""
from __future__ import annotations
import json
import os
import re
import sys
from urllib.parse import urlsplit
try:
import tomllib # Python 3.11+
except ImportError: # pragma: no cover
tomllib = None
SECRET_NAME = re.compile(r"TOKEN|SECRET|PASSW|KEY|AUTH|CREDENTIAL|PRIVATE", re.I)
SECRET_HEADER = re.compile(r"^(authorization|x-api-key|api-key|cookie|proxy-authorization)$", re.I)
REFERENCE = re.compile(r"^\s*(\$\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z_][A-Za-z0-9_]*%)\s*$")
LOOPBACK = {"localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"}
NPX_LIKE = {"npx", "bunx", "pnpx", "pnpm dlx", "yarn dlx"}
PY_RUNNERS = {"uvx", "pipx"}
SHELLS = {"sh", "bash", "zsh", "cmd", "cmd.exe", "powershell", "pwsh", "curl", "wget"}
LEVEL_ORDER = {"high": 0, "medium": 1, "low": 2}
class Audit:
def __init__(self):
self.findings: list[tuple[str, str, str, str]] = []
def add(self, level, code, where, msg):
self.findings.append((level, code, where, msg))
# ---------------------------------------------------------------- servers ---
def server(self, where, s: dict, flavour: str):
cmd = str(s.get("command", "") or "")
args = [str(a) for a in (s.get("args") or [])]
if cmd:
self.launch(where, cmd, args)
for field in ("env",):
for k, v in (s.get(field) or {}).items():
self.secret_value(where + f".env.{k}", k, v, name_based=True)
for field in ("headers", "http_headers"):
for k, v in (s.get(field) or {}).items():
self.secret_value(where + f".{field}.{k}", k, v, name_based=False)
for name in s.get("env_vars") or []: # Codex: host variables whitelisted through to the server
if SECRET_NAME.search(str(name)):
self.add("low", "ADVISORY", where, f"host variable {name} is forwarded to the server process")
for field in ("url", "httpUrl"):
u = s.get(field)
if u:
self.endpoint(where + f".{field}", str(u))
if str(s.get("type", "")).lower() == "sse" or ("url" in s and "httpUrl" not in s and flavour == "gemini"):
self.add("low", "ADVISORY", where, "SSE transport; the Claude Code docs call it deprecated, prefer streamable HTTP")
if s.get("trust") is True:
self.add("high", "BYPASS", where, "trust: true bypasses every tool-call confirmation for this server")
if flavour == "gemini" and not s.get("includeTools"):
self.add("low", "ADVISORY", where, "no includeTools allowlist; every tool the server exposes is available")
def launch(self, where, cmd, args):
base = os.path.basename(cmd).lower()
flags_done = [a for a in args if not a.startswith("-")]
if base in NPX_LIKE:
spec = flags_done[0] if flags_done else ""
if spec and not re.search(r"[^@]@[^/]+$", spec):
self.add("high", "UNPINNED", where,
f"{base} resolves \"{spec}\" to whatever version is newest at launch; pin it ({spec}@x.y.z)")
if "-y" in args or "--yes" in args:
self.add("low", "ADVISORY", where, f"{base} -y installs the package without a confirmation")
elif base in PY_RUNNERS:
spec = flags_done[0] if flags_done else ""
if "--from" in args:
i = args.index("--from")
spec = args[i + 1] if i + 1 < len(args) else spec
if spec and not re.search(r"(==|@[^/]+$)", spec):
self.add("high", "UNPINNED", where,
f"{base} resolves \"{spec}\" to the newest release at launch; pin it ({spec}==x.y.z)")
elif base == "docker" and "run" in args:
image = next((a for a in args[args.index("run") + 1:]
if not a.startswith("-") and "=" not in a and ("/" in a or ":" in a or "." in a)), "")
if image and "@sha256:" not in image and (":" not in image.rsplit("/", 1)[-1] or image.endswith(":latest")):
self.add("medium", "UNPINNED", where, f"docker image \"{image}\" has no tag or digest")
elif base in SHELLS:
self.add("medium", "SHELL", where, f"server is launched through {base}; the command line is arbitrary code")
joined = " ".join(args)
if re.search(r"(curl|wget)[^|]*\|\s*(sh|bash|zsh|pwsh|powershell)", joined):
self.add("high", "SHELL", where, "a download is piped straight into a shell at server start")
def secret_value(self, where, key, value, name_based):
v = str(value)
looks_secret = SECRET_NAME.search(key) if name_based else SECRET_HEADER.match(key)
if not looks_secret or not v.strip():
return
if REFERENCE.match(v) or REFERENCE.match(re.sub(r"^\s*bearer\s+", "", v, flags=re.I)):
self.add("low", "ADVISORY", where, f"{key} is passed through to the server by reference (fine if it needs it)")
else:
self.add("high", "SECRET", where,
f"{key} holds a literal value in the config file; use a ${{VAR}} reference or bearer_token_env_var")
def endpoint(self, where, url):
parts = urlsplit(url)
host = (parts.hostname or "").lower()
if parts.scheme == "http" and host not in LOOPBACK and not url.startswith("${"):
self.add("high", "PLAINTEXT", where, f"http:// endpoint {host or url} is not loopback; tokens and tool results cross the wire in clear")
# ------------------------------------------------------- Claude settings ---
def claude_settings(self, where, d: dict, shared_project: bool):
if d.get("enableAllProjectMcpServers") is True:
self.add("high", "BYPASS", where, "enableAllProjectMcpServers approves every server in .mcp.json without a prompt")
perms = d.get("permissions") or {}
mode = perms.get("defaultMode")
if mode == "bypassPermissions":
self.add("high", "BYPASS", where + ".permissions.defaultMode", "bypassPermissions: nothing asks; the docs reserve it for isolated containers and VMs")
for rule in perms.get("allow") or []:
r = str(rule).strip()
tool, _, spec = r.partition("(")
spec = spec.rstrip(")")
loc = where + f".permissions.allow[{r}]"
if tool == "Bash" and spec in ("", "*"):
self.add("medium", "BROAD", loc, "every shell command runs without a prompt")
elif tool == "Bash" and re.match(r"^(curl|wget|Invoke-WebRequest|iwr)\b", spec):
self.add("medium", "BROAD", loc, "network fetch commands are pre-approved; fetched text becomes tool output the model acts on")
elif tool == "WebFetch" and spec in ("", "domain:*"):
self.add("medium", "BROAD", loc, "any URL can be fetched without a prompt")
elif tool.startswith("mcp__") and (tool.count("__") == 1 or tool.endswith("__*")):
self.add("medium", "BROAD", loc, "approves every tool of that server, including tools it adds later")
deny = [str(x) for x in perms.get("deny") or []]
if not any(re.search(r"\.env|secret|credential|\.pem|id_rsa", x, re.I) for x in deny):
self.add("low", "ADVISORY", where + ".permissions.deny", "no deny rule for secret files (the docs' example is Read(./.env))")
sb = d.get("sandbox")
if sb is None:
self.add("low", "SANDBOX", where, "no sandbox block; the permission rules are the only boundary for Bash")
elif sb.get("enabled") is False:
self.add("low", "SANDBOX", where + ".sandbox.enabled", "sandbox explicitly off")
if shared_project and d.get("hooks"):
self.add("low", "ADVISORY", where + ".hooks", "hooks in a shared project file run on every clone, including -p sessions without --bare")
# ---------------------------------------------------------------- Codex ---
def codex(self, where, d: dict):
ap, sm = d.get("approval_policy"), d.get("sandbox_mode")
if sm == "danger-full-access":
lvl = "high"
self.add(lvl, "BYPASS" if ap == "never" else "SANDBOX", where,
"approval_policy=never with danger-full-access: no prompt and no sandbox" if ap == "never"
else "danger-full-access: full filesystem and network access during command execution")
elif ap == "never":
self.add("medium", "BYPASS", where + ".approval_policy", "never: nothing prompts; safe only inside the sandbox it runs in")
if (d.get("sandbox_workspace_write") or {}).get("network_access") is True:
self.add("medium", "NETWORK", where + ".sandbox_workspace_write.network_access", "network on for sandboxed commands; the docs keep it off by default")
if d.get("web_search") == "live":
self.add("low", "NETWORK", where + ".web_search", "live web results reach the model; the docs default to a cache and say to treat results as untrusted")
proxy = (d.get("features") or {}).get("network_proxy") or {}
for k in ("dangerously_allow_non_loopback_proxy", "dangerously_allow_all_unix_sockets"):
if proxy.get(k) is True:
self.add("high", "BYPASS", where + f".features.network_proxy.{k}", "the docs say to use this only in tightly controlled environments")
for name, s in (d.get("mcp_servers") or {}).items():
self.server(where + f".mcp_servers.{name}", s, "codex")
# ------------------------------------------------------------------ drivers ---
def audit_json(a: Audit, label: str, d: dict):
low = label.replace("\\", "/").lower()
flavour = "gemini" if "/.gemini/" in low or low.endswith("/.gemini/settings.json") else "claude"
for name, s in (d.get("mcpServers") or {}).items():
a.server(f"{label}:mcpServers.{name}", s, flavour)
for proj, pd in (d.get("projects") or {}).items(): # ~/.claude.json per-project servers
for name, s in (pd.get("mcpServers") or {}).items():
a.server(f"{label}:projects[{proj}].mcpServers.{name}", s, "claude")
if any(k in d for k in ("permissions", "sandbox", "enableAllProjectMcpServers", "hooks")):
a.claude_settings(label, d, shared_project=low.endswith("/.claude/settings.json") or low.endswith("settings.json") and "local" not in low)
def audit_path(a: Audit, path: str, label: str | None = None):
label = label or path
if path.endswith(".toml"):
if tomllib is None:
a.add("low", "ADVISORY", label, "TOML needs Python 3.11+; file skipped")
return
with open(path, "rb") as fh:
a.codex(label, tomllib.load(fh))
return
with open(path, encoding="utf-8") as fh:
audit_json(a, label, json.load(fh))
KNOWN = [".mcp.json", ".claude/settings.json", ".claude/settings.local.json", ".gemini/settings.json", ".codex/config.toml"]
DEMO = {
".mcp.json": json.dumps({"mcpServers": {
"docs": {"type": "stdio", "command": "uvx", "args": ["acme-docs-mcp==2.1.0"]},
"scraper": {"type": "stdio", "command": "npx", "args": ["-y", "acme-scraper-mcp"],
"env": {"ACME_API_KEY": "ak_live_9f3c2e7d1b4a"}},
"tracker": {"type": "http", "url": "http://tracker.corp.example:8080/mcp",
"headers": {"Authorization": "Bearer tr_8d1e5c0a"}},
"issues": {"type": "http", "url": "https://mcp.example.com/mcp",
"headers": {"Authorization": "Bearer ${ISSUES_TOKEN}"}}}}, indent=2),
".claude/settings.json": json.dumps({
"enableAllProjectMcpServers": True,
"permissions": {"defaultMode": "acceptEdits",
"allow": ["Bash(python -m pytest *)", "Bash(curl *)", "WebFetch", "mcp__scraper__*"],
"deny": []}}, indent=2),
".gemini/settings.json": json.dumps({"mcpServers": {
"browser": {"command": "npx", "args": ["-y", "acme-browser-mcp@0.9.3"], "trust": True,
"env": {"BROWSER_AUTH_TOKEN": "$BROWSER_AUTH_TOKEN"}}}}, indent=2),
".codex/config.toml": (
'approval_policy = "never"\n'
'sandbox_mode = "danger-full-access"\n'
'web_search = "live"\n\n'
'[mcp_servers.ci]\n'
'command = "npx"\n'
'args = ["-y", "acme-ci-mcp"]\n'
'env = { CI_TOKEN = "ci_4b7e2a9d" }\n\n'
'[mcp_servers.wiki]\n'
'url = "https://wiki.example.com/mcp"\n'
'bearer_token_env_var = "WIKI_TOKEN"\n'),
}
def run_demo(a: Audit):
for name, text in DEMO.items():
label = "demo/" + name
if name.endswith(".toml"):
a.codex(label, tomllib.loads(text))
else:
audit_json(a, label, json.loads(text))
def main(argv):
a = Audit()
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
if argv[0] == "--write-demo":
root = argv[1] if len(argv) > 1 else "agent-config-demo"
for name, text in DEMO.items():
p = os.path.join(root, *name.split("/"))
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as fh:
fh.write(text + "\n")
print("wrote", p)
return 0
if argv[0] == "--demo":
run_demo(a)
else:
for target in argv:
if os.path.isdir(target):
found = [os.path.join(target, *k.split("/")) for k in KNOWN if os.path.isfile(os.path.join(target, *k.split("/")))]
if not found:
print(f"{target}: none of {', '.join(KNOWN)} present")
for p in found:
audit_path(a, p)
else:
audit_path(a, target)
a.findings.sort(key=lambda f: (LEVEL_ORDER[f[0]], f[2]))
for level, code, where, msg in a.findings:
print(f"{level.upper():<7}{code:<10}{where}\n {msg}")
counts = {lvl: sum(1 for f in a.findings if f[0] == lvl) for lvl in LEVEL_ORDER}
print(f"\n{len(a.findings)} findings: {counts['high']} high, {counts['medium']} medium, {counts['low']} low")
return 2 if counts["high"] else 1 if counts["medium"] else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))