cfgaudit

Rules / CFG041

CFG041 permissions.deny does not restrict access to .env files

Severity: error OWASP: LLM02:2026 – Sensitive Information Disclosure

What cfgaudit checks

.env files routinely hold database passwords, API keys, and other credentials. permissions.deny is Claude Code's documented, harness-enforced mechanism for blocking file access. cfgaudit flags a permissions.deny block that exists but contains no pattern covering .env files.

A pattern counts as sufficient coverage if it targets .env:

.env
.env.*
*.env
**/.env
**/.env.*

A completely absent permissions.deny block is reported by CFG006, not here.

A deny-all entry also counts as coverage and suppresses this finding: Read(*) / Read(**) / Read(**/*) (blocks every read, any version), or the bare "*" deny-all-tools glob — but "*" only on Claude Code ≥ 2.1.166, where that glob was introduced. On older releases "*" is an unknown tool name and a no-op, so the finding still fires (add an explicit .env pattern or upgrade). When the Claude Code version is unknown, "*" is treated as covering.

An exception rule — Read(!<glob>) or Read(./!<glob>) (also under Edit and, for cfgaudit, any tool) — denies nothing; it removes the paths it matches from the deny rules listed before it. cfgaudit therefore does not count an exception as coverage, and it un-covers the class when the exception re-exposes the secret: a deny-all with Read(!**/.env) after it leaves .env readable, so the finding fires. A narrower carve that names a safe sibling, such as Read(!**/.env.example) beside a Read(**/.env*) deny, does not un-cover .env and stays quiet.

Trigger example

{
  "permissions": {
    "deny": ["Bash(rm *)"]
  }
}

→ error (deny exists but no .env coverage).

Safe alternative

{
  "permissions": {
    "deny": ["Read(**/.env)", "Read(**/.env.*)"]
  }
}

References

Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation