From e0b0975b943a9bd8ad7a45f2c6a4fe49a905834a Mon Sep 17 00:00:00 2001 From: Jack Chan Date: Thu, 2 Jul 2026 06:53:17 +0000 Subject: [PATCH] harden secret-file denies: cover sed/awk/grep and other read vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude Code permission denylist only enumerated cat/head/tail/base64 for secret files (.env, values*, *secret*, keys, ssh/aws/kube/gcloud, ...), so content readers like `sed`, `awk`, `grep`, pagers, editors, and copy commands bypassed it entirely — e.g. `sed -E '...' .env` read a secret straight through the sandbox. Generate the deny matrix (exfil command x secret glob) from two lists so coverage stays exhaustive and self-maintaining instead of hand-enumerating each command per file variant. Deny beats allow, so these override the blanket Bash(cat *)/Bash(grep *) allows. Concrete .env variants only (never a blanket .env.*), so .env.example/.sample/.template stay readable. Co-Authored-By: Claude Opus 4.8 --- modules/common-options.nix | 65 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/modules/common-options.nix b/modules/common-options.nix index f932e65..628f1a4 100644 --- a/modules/common-options.nix +++ b/modules/common-options.nix @@ -301,7 +301,67 @@ in permissions = lib.mkOption { type = lib.types.attrsOf lib.types.anything; - default = { + default = + let + # Files whose contents are secrets (or secret-bearing). Enumerated + # once so the read-vector deny matrix below cannot drift out of sync + # with the hand-written cat/head/tail/base64 entries. NB: uses + # concrete variants (never a blanket .env.*) so templates like + # .env.example stay readable. + secretFileGlobs = [ + "**/.env" + "**/.env.local" + "**/.env**local" + "**/.env**dev**" + "**/.env**prod**" + "**/.env**stag**" + "**/.env**test**" + "**/values**dev**" + "**/values**prod**" + "**/values**stag**" + "**/values**test**" + "**/*secret*.yaml" + "**/*secret*.yml" + "**/*.pem" + "**/*.key" + "**/id_rsa*" + "**/id_ed25519*" + "**/.ssh/**" + "**/.aws/**" + "**/.config/gcloud/**" + "**/.kube/**" + "**/.netrc" + "**/.npmrc" + "**/.git-credentials" + "**/credentials.json" + "**/service-account*.json" + "**/secrets/**" + ]; + # Commands that reveal or relocate a file's contents. The old deny + # list only enumerated cat/head/tail/base64, so sed/awk/grep (and + # editors/pagers/copy) bypassed it — e.g. `sed -E '...' .env` read a + # secret straight through. Generating cmd x glob keeps it exhaustive + # and self-maintaining. Deny beats allow, so these are authoritative + # even against the blanket Bash(cat *)/Bash(grep *) allows. + secretExfilCmds = [ + # content readers / filters + "sed" "awk" "grep" "egrep" "fgrep" "rg" "ag" + "nl" "tac" "rev" "cut" "tr" "fold" "expand" "paste" "column" "col" + # pagers + "less" "more" "most" "pg" + # binary / encoded dumps + "xxd" "od" "hexdump" "strings" "base32" "uuencode" + # editors (open == read) + "vi" "vim" "nvim" "nano" "ex" "view" "emacs" + # raw copy / relocate vectors (exfil by making a readable copy) + "dd" "cp" "mv" "install" "rsync" "ln" + ]; + secretExfilDeny = + lib.concatMap + (cmd: map (glob: "Bash(${cmd} ${glob})") secretFileGlobs) + secretExfilCmds; + in + { defaultMode = "auto"; allow = [ "Bash(npm *)" @@ -563,7 +623,8 @@ in "Bash(cp **/.kube/**)" "Bash(cat **/.git-credentials)" "Read(**/.git-credentials)" - ]; + ] + ++ secretExfilDeny; }; description = "Permission configuration for Claude Code."; # NB: deny extras above are unconditional (applied on both platforms).