#!/usr/bin/env bash # PreToolUse hook (Bash matcher) — hard-blocks command patterns that reveal # secret values into the transcript, rather than relying on remembering an # existence-only check every time. # # Triggered by two real leaks in one session (2026-07-30): # 1. `op item get --format json` — JSON mode has NO masking, unlike # the human-readable table view (`op item get ` alone masks # concealed fields with bullets by default). Printed a live Gitea MCP # token into the transcript. # 2. `env | grep -i gitea` (infra#228, 2026-07-27) — same shape: an # inspection command that dumps values instead of checking existence. # # Policy: block ALL direct reads of secret material via these command # shapes, unconditionally, regardless of flags. Existence/rotation must go # through a wrapper script (e.g. infra's scripts/op-safe.sh, scripts/rotate- # *.sh) that strips values before anything reaches stdout — those scripts # call the same underlying commands internally, but as a subprocess of the # script, never as a literal Bash tool call the model composes and can typo. # # Reads PreToolUse hook JSON on stdin, decides allow/deny for the Bash tool. set -euo pipefail INPUT="$(cat)" TOOL_NAME="$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')" [ "$TOOL_NAME" = "Bash" ] || { printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'; exit 0; } CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')" deny() { local reason="$1" jq -n --arg reason "$reason" \ '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $reason}, systemMessage: $reason}' exit 0 } # 1Password CLI raw reads — block unconditionally, any flags. if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+item\s+get\b'; then deny "Blocked: 'op item get' prints secret values to stdout (JSON mode has no masking at all; even table mode can leak via --fields/--reveal). Use a wrapper that strips .fields[].value before output (e.g. scripts/op-safe.sh exists|meta), or op item edit to WRITE without ever printing the value." fi if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+item\s+list\b'; then deny "Blocked: 'op item list' can include concealed field previews. Use a wrapper that filters to titles only (e.g. scripts/op-safe.sh list)." fi if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+read\b'; then deny "Blocked: 'op read' prints the raw secret value directly. Consume it inside a subprocess only (op run -- , or a script that never echoes/captures it to stdout)." fi if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+document\s+get\b'; then deny "Blocked: 'op document get' prints document contents (may contain secrets) to stdout. Use a wrapper script instead." fi # Raw env/secret-file dumps (infra#228/#229 root cause). if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)env(\s|$).*\|.*grep'; then deny "Blocked: 'env | grep' prints matching values in full. Use an existence-only check: [ -n \"\$VAR\" ] && echo set" fi if printf '%s' "$CMD" | grep -Eq '(^|[;&|])[[:space:]]*env[[:space:]]*($|[;&|])'; then deny "Blocked: bare 'env' prints every variable's value, including secrets. Use an existence-only check: [ -n \"\$VAR\" ] && echo set" fi if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)printenv\b'; then deny "Blocked: printenv dumps all env values. Use: [ -n \"\$VAR\" ] && echo set" fi if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)cat\b[^;&|]*\.env'; then deny "Blocked: cat of a .env file prints secret values. Use: grep -c '=' file.env (count only) or [ -s file.env ] && echo present" fi if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)cat\b[^;&|]*(\.llmkeys|\.op-env|\.op-agent-write\.env)\b'; then deny "Blocked: cat of a known secrets file prints raw values. Use: grep -o '^[A-Z_]*=' file (key names only, never the value)." fi # kubectl secrets — -o yaml/json prints base64 (trivially reversible). if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)kubectl\b[^;&|]*\bget\s+secrets?\b[^;&|]*(-o|--output)[[:space:]=]*(yaml|json)'; then deny "Blocked: 'kubectl get secret -o yaml/json' prints base64-encoded values (trivially reversible, same as plaintext). Use 'kubectl describe secret ' (shows keys + byte lengths only, never values) for existence checks." fi # Gitea app.ini — mixes plain config ([queue]/[indexer]/[repository]) with # live secrets ([security].INTERNAL_TOKEN/SECRET_KEY, [oauth2].JWT_SECRET, # [server].LFS_JWT_SECRET) in the SAME file, auto-generated on install and # not git/SOPS-tracked. A context-range grep (-A/-B/-C) doesn't respect # section boundaries and silently pulls a secret line into stdout even when # targeting an unrelated section. Root cause of a real leak, infra sprint # 2026-08-04 (`kubectl exec ... cat app.ini | grep -A15 "^\[queue\]"` pulled # through into [oauth2].JWT_SECRET). # Tight on purpose: requires "cat" immediately followed by a bare path-shaped # token ending in app.ini (no intervening whitespace) so this only fires on # an actual invocation ('cat /data/gitea/conf/app.ini', 'cat app.ini') and # never on prose that merely mentions both words, e.g. a commit message # describing this exact guard (that false-positived once while writing the # fix — commands are matched as raw strings, heredoc bodies included). if printf '%s' "$CMD" | grep -Eq '\bcat[[:space:]]+[A-Za-z0-9_./-]*app\.ini\b'; then deny "Blocked: cat of Gitea's app.ini prints secrets mixed into the same file ([security]/[oauth2]/[server].LFS_JWT_SECRET). Use exact-key checks: grep -c '^KEY = ' app.ini (existence only) — never cat the whole file." fi if printf '%s' "$CMD" | grep -Eq '\bgrep\b[^;&|]*-[A-Za-z]*[ABC][A-Za-z]*[[:space:]]*[0-9][^;&|]*app\.ini\b'; then deny "Blocked: grep -A/-B/-C context flags against app.ini can bleed past section boundaries into [security]/[oauth2]/[server] secrets, even when targeting an unrelated section (this is exactly how the 2026-08-04 leak happened). Use an exact anchor instead: grep '^KEY = ' app.ini, or bound to one section with awk '/^\\[section\\]/,/^\\[/' and confirm the section has no secret keys first." fi # sops decrypt output inspected directly — infra sweep-sprint incident, # 2026-08-05: `sops -d ntfy-tokens.enc.yaml > /tmp/plain.yaml` (safe, # redirected to a file) immediately followed by `cat /tmp/plain.yaml | head` # in the SAME command block printed a live ntfy token into the transcript. # Re-encrypting a secret (e.g. switching a file to data-only encryption per # infra#177's pattern) requires decrypting it locally at some point — that # redirect-to-file step alone is fine, the leak is any print command touching # the plaintext afterward. Block the combination unconditionally rather than # trying to track which file a decrypt wrote to across separate tool calls. if printf '%s' "$CMD" | grep -Eq '\bsops\b[^;&|]*(-d\b|--decrypt\b|[[:space:]]decrypt\b)' \ && printf '%s' "$CMD" | grep -Eq '\b(cat|head|tail|less|more|bat|od|xxd)\b'; then deny "Blocked: this command decrypts a sops-encrypted file AND runs a print command (cat/head/tail/etc) in the same invocation — that's exactly the 2026-08-05 ntfy-token leak shape. Decrypt to a temp file (sops -d file > /tmp/x, fine on its own), do whatever transform is needed WITHOUT printing it (e.g. sops -e --encrypted-regex '^(data|stringData)\$' /tmp/x > newfile), then shred -u the temp file. Never cat/head/tail a sops-decrypted file, even to 'just check a few lines'." fi # base64 transform of a secret-shaped env var — the documented 2026-06-11 # 'defeats op run masking' bypass. if printf '%s' "$CMD" | grep -Eqi '\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|_KEY|DSN|CREDENTIAL|PAT)[A-Za-z0-9_]*\}?[^;&|]*\|[^;&|]*base64'; then deny "Blocked: piping a secret-shaped variable through base64 to inspect it. base64 is an encoding, not masking — op run's own masking only recognizes the raw value, so this is a known bypass (2026-06-11 incident). Never transform a secret to look at it." fi if printf '%s' "$CMD" | grep -Eqi 'base64[^;&|]*<<<\s*"?\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|_KEY|DSN|CREDENTIAL|PAT)'; then deny "Blocked: base64 <<< of a secret-shaped variable. Same bypass as above — never transform a secret to inspect it." fi jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "allow"}}'