Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# Secrets - Layer 1 (design): structurally exclude credential locations
_credentials/
*.local.md
*.secret.md
credentials*.md
**/draft/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**/draft/ matches any draft/ directory at any depth, not just under credential paths. In a dotfiles tree that nests .worktrees/, dotagents/, and other repos, this can silently swallow legitimate draft/ content in unrelated projects.

The other patterns in this block (_credentials/, *.local.md, *.secret.md, credentials*.md) are intentionally narrow — consider anchoring this one too, e.g. /_credentials/draft/, or switching to a filename pattern like *.draft.md to match the existing style.


# AI
.aider.tags.cache.v4
.claude
Expand Down
5 changes: 5 additions & 0 deletions config/claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$HOME/dotfiles/config/shared/hooks/secret-guard.sh",
"timeout": 5
},
{
"type": "command",
"command": "git-ai checkpoint claude --hook-input stdin",
Expand Down
10 changes: 10 additions & 0 deletions config/codex/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@
}
],
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$HOME/dotfiles/config/shared/hooks/secret-guard.sh",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
Expand Down
1 change: 1 addition & 0 deletions config/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ in
./factory
./gemini
./git-ai
./gitleaks
./gomi
./ghostty
./hermes
Expand Down
37 changes: 37 additions & 0 deletions config/gitleaks/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
title = "dotfiles gitleaks config"

# Extend the default gitleaks rules
[extend]
useDefault = true

[[rules]]
id = "japanese-password-field"
description = "Detects Japanese password field patterns"
regex = '''(?i)(パスワード|password|pw|pass)\s*[::=]\s*["']?[A-Za-z0-9!@#$%^&*\-_=+]{6,}["']?'''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False-positive prone — no keywords, no word boundaries.

(?i)(パスワード|password|pw|pass)\s*[::=]\s*…{6,} matches pass: as a substring of bypass:, passphrase=, compass = …, etc. once the value has ≥6 chars from the allowed class. The API-key rule has the same shape.

Two small fixes that materially reduce noise without losing coverage:

[[rules]]
id = "japanese-password-field"
description = "Detects Japanese password field patterns"
keywords = ["password", "pw", "pass", "パスワード"]
regex = '''(?i)\b(パスワード|password|pw|pass)\b\s*[::=]\s*["']?[A-Za-z0-9!@#$%^&*\-_=+]{6,}["']?'''

keywords also lets gitleaks prefilter so the regex doesn't run against every line — useful for the lefthook pre-commit path on large diffs.


[[rules]]
id = "japanese-api-key-field"
description = "Detects Japanese API key field patterns"
regex = '''(?i)(APIキー|api[-_ ]?key|secret[-_ ]?key|access[-_ ]?token)\s*[::=]\s*["']?[A-Za-z0-9!@#$%^&*\-_=+]{16,}["']?'''

[allowlist]
description = "Global allowlist for templates, examples, and known-safe dummies"
paths = [
'''(.*?)\.template\.md$''',
'''(.*?)\.example$''',
'''\.env\.example$''',
'''docs/.*\.template\.md$''',
'''dotagents/.*''',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Avoid globally allowlisting the entire dotagents/ directory; it disables secret scanning for all files there.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At config/gitleaks/config.toml, line 24:

<comment>Avoid globally allowlisting the entire `dotagents/` directory; it disables secret scanning for all files there.</comment>

<file context>
@@ -0,0 +1,37 @@
+  '''(.*?)\.example$''',
+  '''\.env\.example$''',
+  '''docs/.*\.template\.md$''',
+  '''dotagents/.*''',
+  '''bun\.lock$''',
+  '''Cargo\.lock$''',
</file context>

'''bun\.lock$''',
'''Cargo\.lock$''',
'''flake\.lock$''',
'''node_modules/.*''',
'''target/.*''',
]
regexes = [
'''DUMMY_PASSWORD''',
'''<your-password-here>''',
'''<PLACEHOLDER>''',
'''xxxxxxxxxxxxxxxx''',
'''example\.com''',
]
8 changes: 8 additions & 0 deletions config/gitleaks/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{ config, ... }:
{
# Gitleaks reads $GITLEAKS_CONFIG when run outside a repo with a local .gitleaks.toml.
home.file.".config/gitleaks/config.toml".source = ./config.toml;
home.sessionVariables = {
GITLEAKS_CONFIG = "${config.home.homeDirectory}/.config/gitleaks/config.toml";
};
}
51 changes: 51 additions & 0 deletions config/shared/hooks/secret-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Shared PreToolUse hook for Claude Code and Codex Write/Edit operations.
# Blocks writes containing secrets detected by gitleaks.
# See: https://zenn.dev/takna/articles/secret-leak-prevention-4-layer
set -euo pipefail

# Hook is supplementary, not primary defense. Skip silently if deps missing.
command -v jq >/dev/null 2>&1 || exit 0
command -v gitleaks >/dev/null 2>&1 || exit 0

PAYLOAD=$(cat)

# Extract content across Claude and Codex payload shapes (Write/Edit/MultiEdit).
CONTENT=$(printf '%s' "$PAYLOAD" | jq -r '
.tool_input.content //
.tool_input.new_string //
.tool.input.content //
.tool.input.new_string //
.toolInput.content //
.toolInput.new_string //
(.tool_input.edits // [] | map(.new_string) | join("\n")) //
(.tool.input.edits // [] | map(.new_string) | join("\n")) //

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex MultiEdit silently bypasses this hook.

jq's // only falls through on null/false. When .tool_input.edits is missing (the Codex case), .tool_input.edits // [] becomes [], and map(.new_string) | join("\n") turns that into "" — which is defined for //, so the chain short-circuits here and never evaluates .tool.input.edits.

Reproduced:

$ echo '{"tool":{"input":{"edits":[{"new_string":"AKIA1234567890ABCDEF"}]}}}' | jq -r '
    .tool_input.content // .tool_input.new_string //
    .tool.input.content // .tool.input.new_string //
    .toolInput.content // .toolInput.new_string //
    (.tool_input.edits // [] | map(.new_string) | join("\n")) //
    (.tool.input.edits  // [] | map(.new_string) | join("\n")) //
    empty'

(empty)

Downstream the script hits [[ -z "$CONTENT" ]] && exit 0 and silently allows the write. Claude Write/Edit/MultiEdit and Codex Write/Edit still work — only Codex MultiEdit regresses.

Fix by coalescing the array sources before the join, e.g.:

(((.tool_input.edits // .tool.input.edits // .toolInput.edits) // [])
   | map(.new_string) | join("\n")) //
empty

empty
' 2>/dev/null)
Comment on lines +11 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The jq filter has a logic bug where "" (produced by join("\n") on an empty or missing edits array) is considered truthy in jq. This causes the // chain to stop prematurely, potentially skipping subsequent checks (e.g., Codex fields might be skipped if Claude edits is missing). Additionally, reading the entire payload into a shell variable is inefficient for large tool inputs. Piping stdin directly to jq is more robust.

Suggested change
PAYLOAD=$(cat)
# Extract content across Claude and Codex payload shapes (Write/Edit/MultiEdit).
CONTENT=$(printf '%s' "$PAYLOAD" | jq -r '
.tool_input.content //
.tool_input.new_string //
.tool.input.content //
.tool.input.new_string //
.toolInput.content //
.toolInput.new_string //
(.tool_input.edits // [] | map(.new_string) | join("\n")) //
(.tool.input.edits // [] | map(.new_string) | join("\n")) //
empty
' 2>/dev/null)
# Extract content across Claude and Codex payload shapes (Write/Edit/MultiEdit).
CONTENT=$(jq -r '
[
.tool_input.content,
.tool_input.new_string,
.tool.input.content,
.tool.input.new_string,
.toolInput.content,
.toolInput.new_string,
(.tool_input.edits | select(.) | map(.new_string) | join("\n")),
(.tool.input.edits | select(.) | map(.new_string) | join("\n"))
] | map(select(. != null and . != "")) | .[0] // empty
' 2>/dev/null)


[[ -z $CONTENT || $CONTENT == "null" ]] && exit 0

TMP_DIR=$(mktemp -d -t secret-guard.XXXXXX)
trap 'rm -rf "$TMP_DIR"' EXIT
printf '%s' "$CONTENT" >"$TMP_DIR/payload.txt"

CONFIG_ARG=()
if [[ -f "$PWD/.gitleaks.toml" ]]; then
CONFIG_ARG=(--config "$PWD/.gitleaks.toml")
elif [[ -n ${GITLEAKS_CONFIG:-} && -f $GITLEAKS_CONFIG ]]; then
CONFIG_ARG=(--config "$GITLEAKS_CONFIG")
elif [[ -f "$HOME/.config/gitleaks/config.toml" ]]; then
CONFIG_ARG=(--config "$HOME/.config/gitleaks/config.toml")
fi

if gitleaks dir "$TMP_DIR" "${CONFIG_ARG[@]}" --no-banner --redact >/dev/null 2>&1; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitleaks errors are misreported as "secret found".

gitleaks returns 0 for clean, 1 for leaks, and other non-zero codes for errors (unknown subcommand on older builds, bad config, panic, etc.). if gitleaks … ; then exit 0; fi; exit 2 collapses all of those into the same generic "detected secrets" message, and 2>/dev/null hides the real reason — so a broken install or malformed GITLEAKS_CONFIG looks like every Write/Edit/MultiEdit suddenly contains a secret.

Consider capturing the exit code and falling open (or surfacing stderr) on anything that isn't 1, e.g.:

GITLEAKS_OUT=$(gitleaks dir "$TMP_DIR" "${CONFIG_ARG[@]}" --no-banner --redact 2>&1)
rc=$?
case "$rc" in
  0) exit 0 ;;
  1) ;; # fall through to block message
  *) printf 'secret-guard: gitleaks errored (rc=%s); skipping\n%s\n' "$rc" "$GITLEAKS_OUT" >&2; exit 0 ;;
esac

That keeps the hook's self-stated "supplementary, not primary defense" posture (deps missing → silent exit 0).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Gitleaks v8 (which is standard in Nixpkgs) has replaced the dir command with detect --source. Using the old dir syntax will cause the command to fail with an error. Since stderr is redirected to /dev/null and the script exits with a non-zero code on failure, this will result in blocking ALL AI writes regardless of whether they contain secrets.

Suggested change
if gitleaks dir "$TMP_DIR" "${CONFIG_ARG[@]}" --no-banner --redact >/dev/null 2>&1; then
if gitleaks detect --source "$TMP_DIR" "${CONFIG_ARG[@]}" --no-banner --redact >/dev/null 2>&1; then

exit 0
fi

cat >&2 <<'EOF'
secret-guard: Blocked Write/Edit due to detected secrets.
- Move credentials to .gitignore'd paths (_credentials/, *.secret.md, etc.)
- Use explicit placeholders like <PLACEHOLDER> or DUMMY_PASSWORD
- See: https://zenn.dev/takna/articles/secret-leak-prevention-4-layer
EOF
exit 2
2 changes: 2 additions & 0 deletions home-manager/packages/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ with pkgs;
fzf-make
gh
git
gitleaks
glance
glow
gnumake
Expand All @@ -66,6 +67,7 @@ with pkgs;
just
k6
lean4
lefthook
(if stdenv.isLinux && isDesktop then llama-cpp.override { vulkanSupport = true; } else llama-cpp)
llm
lsof
Expand Down
5 changes: 5 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pre-commit:
parallel: true
commands:
gitleaks:
run: gitleaks git --staged --redact --verbose

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Gitleaks v8 has replaced the git command with protect. The git --staged syntax is deprecated and may not work with the version installed via Nixpkgs.

      run: gitleaks protect --staged --redact --verbose

1 change: 1 addition & 0 deletions spec/coverage_spec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ config/copilot/hooks/rtk-rewrite.sh
config/copilot/hooks/security.sh
config/shared/hooks/block-gh-settings.sh
config/shared/hooks/block-git-push.sh
config/shared/hooks/secret-guard.sh
config/cursor/activate.sh
config/gemini/activate.sh
config/git-ai/activate.sh
Expand Down
Loading