Prevent secret commits with pre-commit and gitleaks

Prevent secret commits with pre-commit and gitleaks

AI agent-generated code can sometimes contain secrets mixed in. We tested a mechanism to detect and stop this just before committing using pre-commit and gitleaks.
2026.10.17

This page has been translated by machine translation. View original

I'm emi, a coffee lover.

When leaving development to AI agents like Claude Code, there's a possibility that API keys or passwords could get mixed into the generated code and committed as-is.

As a countermeasure, I tried pre-commit and gitleaks, a mechanism that detects secrets right before a commit and stops it.

Conclusion First

By configuring pre-commit to call gitleaks, the moment I tried to git commit a file containing a secret, it was detected and the commit was aborted.

gitleaks and pre-commit

Since I don't do much development work on a regular basis, I'll describe an overview of what these tools are, for my own reference as well.

gitleaks

gitleaks is a tool that detects secrets such as API keys and passwords from source code and commit history.

  • It scans code and commits, and reports any strings that look like secrets as findings
  • When it detects a secret, it returns exit code 1, which allows pre-commit to abort the commit

https://github.com/gitleaks/gitleaks

Git Hooks

Git has a feature called hooks. It's a mechanism that automatically runs specified scripts at timings such as commit or push.

https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks
https://git-scm.com/docs/githooks

There are the following types of hooks. ※ These are just a few; there are others as well.

  • pre-commit hook
    • A hook that runs right before a commit is finalized. If something here returns an error, the commit is aborted. For example, stopping a commit if secrets are mixed in
  • commit-msg hook
    • A hook that runs after the commit message is entered
  • pre-push hook
    • A hook that runs right before a push

Here is an image of where in the flow of work (working tree) → stage → commit → push each hook runs.

Uses of the commit-msg hook
  • pre-commit hook: Runs before the commit message is obtained, before the commit is created

  • commit-msg hook: Runs receiving as an argument the file that holds the proposed commit message

    • Order: pre-commit hook → message input → commit-msg hook
  • Uses of the commit-msg hook

    • Since the pre-commit hook doesn't receive the message, use this when you want to inspect the message itself
    • Normalize commit messages to a standard format
    • Inspect the message and reject the commit if there's a problem
      • If the exit code is non-zero, the commit is aborted
    • The default bundled with Git stops the commit when it detects duplicate Signed-off-by lines
    • Examples
      • Enforce Conventional Commits format (such as feat: or fix:), require ticket numbers to always be included

https://git-scm.com/docs/githooks

The actual body of these hooks is a shell script placed under .git/hooks/ in the repository. The timing of execution is determined by the filename, and they are placed like .git/hooks/pre-commit, .git/hooks/commit-msg, .git/hooks/pre-push. No file extension is needed.

repository/
└── .git/
    └── hooks/
        ├── pre-commit 
        ├── commit-msg 
        └── pre-push 
pre-commit sample

When you create a repository with git init, samples bundled with Git are placed in .git/hooks/ with .sample appended. The sample for the pre-commit hook is pre-commit.sample, and its contents are the following shell script (some comments omitted).

.git/hooks/pre-commit.sample
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments.  The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".

if git rev-parse --verify HEAD >/dev/null 2>&1
then
	against=HEAD
else
	# Initial commit: diff against an empty tree object
	against=$(git hash-object -t tree /dev/null)
fi

# (omitted) If a non-ASCII filename is about to be added, stop with exit 1

# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --

This sample exits with exit 1 (non-zero) and stops the commit if it finds non-ASCII filenames or trailing whitespace errors.
The comment says "exit with a non-zero status to stop the commit" and "rename the file to pre-commit to enable this hook."
It means please remove the .sample extension and rename it to pre-commit when you use it.

.git/ is the local working directory of the repository and is not included in git clone or git push targets. Therefore, scripts placed directly in this location cannot be shared with a team, and each person needs to prepare them manually.

pre-commit (the tool)

The name is a bit confusing, but separate from Git's pre-commit hook, there is a tool called pre-commit. It's a Python program installed via pip that makes it easier to manage the checks run by the pre-commit hook.

https://pre-commit.com/

When pre-commit (the tool) is called from the pre-commit hook, it executes the checks written in the configuration file (.pre-commit-config.yaml) — in this case, gitleaks.

Differences from a bare pre-commit hook

Let's compare the flow for achieving "detect secrets with gitleaks before committing."

With a bare pre-commit hook

With a bare pre-commit hook, you write the check contents yourself directly in .git/hooks/pre-commit.

.git/hooks/pre-commit (written yourself)
#!/bin/sh
# gitleaks needs to be installed separately by each person
gitleaks protect --staged || exit 1

This approach has drawbacks.

  • Each person needs to install gitleaks separately on their own
  • Since .git/hooks/ is not included in git clone or git push, this script cannot be shared with the team, and each member needs to place it manually
  • Versions and implementations tend to vary from person to person
Using pre-commit (the tool)

When using pre-commit (the tool), the only thing you need to prepare yourself is one configuration file called .pre-commit-config.yaml. In .pre-commit-config.yaml, you only write the check tool to use (gitleaks, etc.) and its version.

The contents to write are as follows.

.pre-commit-config.yaml (written yourself)
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks

Once you place .pre-commit-config.yaml, run pre-commit install just once. This only needs to be done once at the start, and running it causes pre-commit (the tool) to create a script called .git/hooks/pre-commit. The contents of the generated file are as follows.

.git/hooks/pre-commit (auto-generated by pre-commit install)
#!/usr/bin/env bash
# File generated by pre-commit: https://pre-commit.com

# start templated
INSTALL_PYTHON=/usr/bin/python3
ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit)
# end templated

HERE="$(cd "$(dirname "$0")" && pwd)"
ARGS+=(--hook-dir "$HERE" -- "$@")

if [ -x "$INSTALL_PYTHON" ]; then
    exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}"
elif command -v pre-commit > /dev/null; then
    exec pre-commit "${ARGS[@]}"
else
    echo '`pre-commit` not found.  Did you forget to activate your virtualenv?' 1>&2
    exit 1
fi

All this script does is call pre-commit (the tool). It passes the configuration file to read with --config=.pre-commit-config.yaml.

From then on, every time you git commit, the following flow runs automatically.

  1. git commit executes .git/hooks/pre-commit (the script)
  2. The script calls pre-commit (the tool)
  3. pre-commit (the tool) reads .pre-commit-config.yaml
  4. It executes gitleaks as written in the configuration file

With this approach, the drawbacks of the bare pre-commit hook are resolved.

  • Even without installing gitleaks yourself, pre-commit will fetch it from the repo in the configuration file
  • Since .pre-commit-config.yaml is placed at the repository root (same level as .git/), it can be committed along with the repository. Unlike placing it directly in .git/hooks/, the configuration can be shared with the team
  • The version can be pinned with rev, so the same check runs for all team members

It's a bit confusing. To summarize:

  • At pre-commit install time (the first time only)
    • pre-commit (the tool) creates .git/hooks/pre-commit (the script)
  • At git commit time
    • git commit calls .git/hooks/pre-commit (the script), and .git/hooks/pre-commit (the script) calls pre-commit (the tool)
    • pre-commit (the tool) reads .pre-commit-config.yaml and runs gitleaks

Folder Structure Used for Verification

This time I created a disposable verification repository lab/ and tried it out. The folder structure is as follows.

(working folder)/
└── lab/                          ← Repository initialized with git init for verification
    ├── .git/
    │   └── hooks/
    │       └── pre-commit        ← Auto-generated by pre-commit install
    ├── .pre-commit-config.yaml   ← Configuration file you write yourself (repository root)
    ├── config.py                 ← Verification file with secrets mixed in
    └── README.md

Operating Environment

  • Ubuntu on WSL
  • Git 2.34.1
  • pre-commit 4.6.2 (installed via pip)
  • gitleaks v8.30.1 (fetched via pre-commit)
  • Claude Code v2.1.248

I didn't install gitleaks separately myself; I left it to pre-commit to fetch. By writing the gitleaks repository in .pre-commit-config.yaml, it will be fetched when run.

Step 1: Check the Installation Status

First, I checked whether gitleaks and pre-commit were already installed in the current environment, as well as whether candidate installation methods (apt / snap / go / Homebrew / pip) were available.

echo "=== gitleaks ==="; which gitleaks; echo "=== pre-commit ==="; which pre-commit; echo "=== pipx ==="; which pipx; echo "=== pip3 ==="; which pip3; echo "=== go ==="; which go && go version; echo "=== snap ==="; which snap; echo "=== brew ==="; which brew; echo "=== apt-cache gitleaks ==="; apt-cache policy gitleaks 2>/dev/null | head -n 5

▼ Execution result

emikihoge@<hostname>:~/work/xx$ echo "=== gitleaks ==="; which gitleaks; echo "=== pre-commit ==="; which pre-commit; echo "=== pipx ==="; which pipx; echo "=== pip3 ==="; which pip3; echo "=== go ==="; which go && go version; echo "=== snap ==="; which snap; echo "=== brew ==="; which brew; echo "=== apt-cache gitleaks ==="; apt-cache policy gitleaks 2>/dev/null | head -n 5
=== gitleaks ===
=== pre-commit ===
=== pipx ===
=== pip3 ===
/usr/bin/pip3
=== go ===
=== snap ===
/usr/bin/snap
=== brew ===
=== apt-cache gitleaks ===
emikihoge@<hostname>:~/work/xx$ 

Neither gitleaks nor pre-commit were installed, and pip3 was available. I decided to install pre-commit via pip this time.

Step 2: Install pre-commit

Install pre-commit via pip.

pip3 install --user pre-commit
Execution result
emikihoge@<hostname>:~/work/xx$ pip3 install --user pre-commit
Collecting pre-commit
  Downloading pre_commit-4.6.2-py2.py3-none-any.whl (226 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 226.2/226.2 KB 2.1 MB/s eta 0:00:00
Collecting identify>=1.0.0
  Downloading identify-2.6.19-py2.py3-none-any.whl (99 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 99.4/99.4 KB 7.8 MB/s eta 0:00:00
Collecting virtualenv>=20.10.0
  Downloading virtualenv-21.7.11-py3-none-any.whl (5.3 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.3/5.3 MB 24.9 MB/s eta 0:00:00
Requirement already satisfied: pyyaml>=5.1 in /usr/lib/python3/dist-packages (from pre-commit) (5.4.1)
Collecting nodeenv>=0.11.1
  Downloading nodeenv-1.10.0-py2.py3-none-any.whl (23 kB)
Collecting cfgv>=2.0.0
  Downloading cfgv-3.5.0-py2.py3-none-any.whl (7.4 kB)
Collecting distlib<1,>=0.3.7
  Downloading distlib-0.4.3-py2.py3-none-any.whl (470 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 470.6/470.6 KB 12.4 MB/s eta 0:00:00
Collecting python-discovery>=1.6
  Downloading python_discovery-1.6.0-py3-none-any.whl (38 kB)
Collecting typing-extensions>=4.13.2
  Downloading typing_extensions-4.16.0-py3-none-any.whl (45 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 45.6/45.6 KB 4.0 MB/s eta 0:00:00
Collecting filelock<4,>=3.24.2
  Downloading filelock-3.32.7-py3-none-any.whl (100 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100.2/100.2 KB 7.1 MB/s eta 0:00:00
Collecting platformdirs<5,>=3.9.1
  Downloading platformdirs-4.11.9-py3-none-any.whl (24 kB)
Installing collected packages: distlib, typing-extensions, platformdirs, nodeenv, identify, filelock, cfgv, python-discovery, virtualenv, pre-commit
Successfully installed cfgv-3.5.0 distlib-0.4.3 filelock-3.32.7 identify-2.6.19 nodeenv-1.10.0 platformdirs-4.11.9 pre-commit-4.6.2 python-discovery-1.6.0 typing-extensions-4.16.0 virtualenv-21.7.11
emikihoge@<hostname>:~/work/xx$ 

It was installed in ~/.local/bin, and the PATH was already configured.

which pre-commit; pre-commit --version

▼ Execution result

emikihoge@<hostname>:~/work/xx$ which pre-commit; pre-commit --version
/home/emikihoge/.local/bin/pre-commit
pre-commit 4.6.2
emikihoge@<hostname>:~/work/xx$ 

Step 3: Create a Verification Repository

Create a disposable verification repository. I stored the working directory path in a variable called BASE and reused it going forward (the path is masked as /path/to/project).

BASE="/path/to/project"; mkdir -p "$BASE/lab" && cd "$BASE/lab" && git init -q && git config user.email "test@example.com" && git config user.name "test" && echo "# gitleaks lab" > README.md && git add README.md && git commit -qm "init" && echo "OK: lab created" && pwd && git log --oneline

▼ Execution result

emikihoge@<hostname>:~/work/xx$ BASE="/path/to/project"; mkdir -p "$BASE/lab" && cd "$BASE/lab" && git init -q && git config user.email "test@example.com" && git config user.name "test" && echo "# gitleaks lab" > README.md && git add README.md && git commit -qm "init" && echo "OK: lab created" && pwd && git log --oneline
OK: lab created
/path/to/project/lab
a12414b (HEAD -> master) init
emikihoge@<hostname>:~/work/xx$ 

Step 4: Create .pre-commit-config.yaml

Create .pre-commit-config.yaml and write it to call gitleaks. The writing format is summarized in the official pre-commit documentation.

https://pre-commit.com/#pre-commit-configyaml---top-level

The content I wrote this time is as follows.

.pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks
  • repos
    • A list of check tools (repositories) to use. This time it's just gitleaks
  • repo
    • The repository URL from which the hook code will be git cloned. Specifies the gitleaks GitHub repository
    • Since pre-commit fetches gitleaks from here, you can use it without installing gitleaks separately
  • rev
    • Specifies which point in time to fetch the gitleaks code from GitHub. rev stands for revision
    • Ways to specify "a point in time in code" in Git include tag names (e.g., v8.30.1), commit SHAs (e.g., 84f01ac...), branch names, and HEAD. Of these, tags and SHAs don't change their contents, but branch names and HEAD change when new commits are added
    • Only tags or SHAs — things whose contents don't change — can be written in rev. Since pre-commit caches the fetched gitleaks and reuses it, branch names like main or HEAD (which change) cannot be used
    • This time, I specified the tag v8.30.1, the latest release at the time of writing this article. By pinning with a tag, the same v8.30.1 gitleaks runs whenever and by whoever it's executed
  • hooks
    • A list of which hooks to use among those provided by that repository
    • A single repository may provide multiple hooks, each with an id (name). repo determines "which repository to fetch from," and the id in hooks selects "which check to use among those"
    • id is not something you name yourself; it's determined by the providing repository. The hooks provided are defined with IDs in .pre-commit-hooks.yaml at the root of the providing repository, so users look there to find and specify the ID they want to use
    • id
      • The identifier (name) of the hook to use. The .pre-commit-hooks.yaml in the gitleaks repository defines gitleaks (standard version), gitleaks-docker, and gitleaks-system. This time I specified the standard version id: gitleaks

When you want to use multiple checks, list the ids under hooks. For example, to use two from the standard repository pre-commit/pre-commit-hooks, write it like this:

.pre-commit-config.yaml (example with multiple IDs listed)
.pre-commit-config.yaml (example with multiple IDs listed)
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace   # Check that removes trailing whitespace
      - id: end-of-file-fixer     # Check that fixes end of file

This time it's just gitleaks, so there's only one id.

https://pre-commit.com/#pre-commit-configyaml---repos

The .pre-commit-config.yaml I prepared this time means "Please fetch gitleaks v8.30.1 from GitHub right before committing and run it."

Step 5: Register with the Git Hook

Register with the Git hook.

Run pre-commit install. This registers the contents of .pre-commit-config.yaml with the Git pre-commit hook and creates .git/hooks/pre-commit.

I'm also running ls -la .git/hooks/pre-commit at the same time. This is to confirm that .git/hooks/pre-commit was actually created and that execute permissions (the leading -rwxr-xr-x) are set.

BASE="/path/to/project"; cd "$BASE/lab" && pre-commit install && ls -la .git/hooks/pre-commit

▼ Execution result

emikihoge@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && pre-commit install && ls -la .git/hooks/pre-commit
pre-commit installed at .git/hooks/pre-commit
-rwxr-xr-x 1 emikihoge emikihoge 598 Sep 18 09:42 .git/hooks/pre-commit
emikihoge@<hostname>:~/work/xx$ 

Step 6: Check the Contents of the Generated Hook

Verify that the generated .git/hooks/pre-commit is indeed "a script that just calls pre-commit (the tool)."

BASE="/path/to/project"; cd "$BASE/lab" && cat .git/hooks/pre-commit

▼ Execution result

emikihoge@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && cat .git/hooks/pre-commit
#!/usr/bin/env bash
# File generated by pre-commit: https://pre-commit.com
# ID: 138fd403232d2ddd5efb44317e38bf03

# start templated
INSTALL_PYTHON=/usr/bin/python3
ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit)
# end templated

HERE="$(cd "$(dirname "$0")" && pwd)"
ARGS+=(--hook-dir "$HERE" -- "$@")

if [ -x "$INSTALL_PYTHON" ]; then
    exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}"
elif command -v pre-commit > /dev/null; then
    exec pre-commit "${ARGS[@]}"
else
    echo '`pre-commit` not found.  Did you forget to activate your virtualenv?' 1>&2
    exit 1
fi
emikihoge@<hostname>:~/work/xx$ 

As seen at the beginning, the check itself is not written there; it only calls pre-commit (the tool).

Step 7: Prepare a File with Dummy Values That Will Be Detected

I prepared config.py under the assumption that Claude Code created a configuration file containing AWS credentials.

config.py is a file with secrets mixed in, placed at the root of the verification repository lab/.

Folder structure (reprinted)
(working folder)/
└── lab/                          ← Repository initialized with git init for verification
    ├── .git/
    │   └── hooks/
    │       └── pre-commit        ← Auto-generated by pre-commit install
    ├── .pre-commit-config.yaml   ← Configuration file you write yourself (repository root)
    ├── config.py                 ← Verification file with secrets mixed in
    └── README.md

As for the dummy values to put in here, I didn't want to create random ones myself in case they accidentally matched someone else's real secret somewhere, so I used sample values already published by gitleaks.

  • AKIAIMNOJVGFDXXXE4OA

An example of this value being detected as an AWS access key is also shown in the gitleaks documentation.

https://github.com/gitleaks/gitleaks

Note that AWS authentication uses a combination of Access Key ID and Secret Access Key. Since I used the known sample value for the Access Key ID, I created a randomly generated string myself for the Secret side that only resembles the format.

config.py
AWS_ACCESS_KEY_ID = "AKIAIMNOJVGFDXXXE4OA"
AWS_SECRET_ACCESS_KEY = "kQ9pxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Actually, at the beginning of the verification I had common sample values in there, and at that time the commit went through. I'll mention that later.

Step 8: Stage the Changed Values

Since I don't do much development on a regular basis, I'll organize "stage" and "commit" here for my own reference.

In Git, rather than immediately recording changed files in history, you proceed in two stages.

  • git add (stage)
    • The operation of selecting "include this file in the next record" and placing it in a waiting area called the staging area
    • At this point it's not yet recorded in history. This is the stage where you're selecting what to record beforehand
  • git commit (commit)
    • The operation of recording the files placed in the stage as a single set of changes in history
    • This is where it's first recorded in the repository's history

The pre-commit and gitleaks checks in this case run right before this git commit (commit finalization). So the flow is: first git add the file to the stage, then when you git commit, the checks run.

First, git add config.py and .pre-commit-config.yaml to place them in the stage.

BASE="/path/to/project"; cd "$BASE/lab" && git add config.py .pre-commit-config.yaml && echo "---- staged ----" && git status

▼ Execution result

emikihoge@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && git add config.py .pre-commit-config.yaml && echo "---- staged ----" && git status
---- staged ----
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   .pre-commit-config.yaml
        new file:   config.py

emikihoge@<hostname>:~/work/xx$ 

The reason I'm adding .pre-commit-config.yaml together with config.py here is due to some trial and error that I'll touch on later in an aside.
※ Naturally, .pre-commit-config.yaml would normally be committed first.

Step 9: The Commit Was Stopped

Commit with the staged state.

BASE="/path/to/project"; cd "$BASE/lab" && git commit -m "add config"; echo "---- exit code: $? ----"

▼ Execution result

emikihoge@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && git commit -m "add config"; echo "---- exit code: $? ----"
Detect hardcoded secrets.................................................Failed
- hook id: gitleaks
- exit code: 1

    ○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

    Finding:     AWS_SECRET_ACCESS_KEY = "REDACTED"
    Secret:      REDACTED
    RuleID:      generic-api-key
    Entropy:     5.131556
    File:        config.py
    Line:        2
    Fingerprint: config.py:generic-api-key:2

    Finding:     ...WS_ACCESS_KEY_ID = "REDACTED
    Secret:      REDACTED
    RuleID:      aws-access-token
    Entropy:     3.646439
    File:        config.py
    Line:        1
    Fingerprint: config.py:aws-access-token:1

    11:45AM INF 0 commits scanned.
    11:45AM INF scanned ~212 bytes (212 bytes) in 295ms
    11:45AM WRN leaks found: 2

---- exit code: 1 ----
emikihoge@<hostname>:~/work/xx$ 

It returned Failed and the commit was aborted. The flow was: gitleaks returned exit code 1, and git commit failed as a result.

There were 2 findings. Line 1 is the Access Key ID (aws-access-token), and line 2 is the Secret side string, which was picked up as a generic API key (generic-api-key) due to its high randomness. This was an unexpected bonus, but it became a real-world example of how string-like secrets are also detected.

Step 10: Confirm That It Is Really Stopped

Confirm that the commit is really stopped.

BASE="/path/to/project"; cd "$BASE/lab" && echo "=== log ===" && git log --oneline && echo "=== status ===" && git status

▼ Execution result

emikihoge@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && echo "=== log ===" && git log --oneline && echo "=== status ===" && git status
=== log ===
a12414b (HEAD -> master) init
=== status ===
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   .pre-commit-config.yaml
        new file:   config.py

emikihoge@<hostname>:~/work/xx$ 
  • Below === log === (output of git log --oneline)
    • A command that outputs commit history one line at a time
    • Only the initial init line is shown
    • If the commit in Step 9 had succeeded, there should be an add config line added, but since it's not there, we can tell the commit was not made
  • Below === status === under Changes to be committed: (output of git status)
    • A command that shows the current state
    • config.py and .pre-commit-config.yaml are shown as new file
    • The files are still staged from git add, not yet git committed (not recorded in history)

Only the initial init remains in the commit history, and add config is not there. The files remain locally, but are not recorded in history. We can see that they were stopped right before the commit.

Side note: Sample values allowed the commit to pass

During verification, I initially entered commonly used sample values. The config.py at that time was as follows.

config.py
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

AKIAIOSFODNN7EXAMPLE is a dummy sample value also used in official AWS documentation.

https://docs.aws.amazon.com/ja_jp/sdkref/latest/guide/feature-static-credentials.html

I created config.py with this value, ran git add, and tried to commit.

emikihoge@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && cat > config.py <<'EOF'
# Configuration file assumed to have been generated by Claude Code
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
EOF
git add config.py && echo "OK: config.py staged"
OK: config.py staged
emikihoge@<hostname>:~/work/xx$ 
emikihoge@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && git commit -m "add config"; echo "---- exit code: $? ----"
[INFO] Initializing environment for https://github.com/gitleaks/gitleaks.
[INFO] Installing environment for https://github.com/gitleaks/gitleaks.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
Detect hardcoded secrets.................................................Passed
[master b850bb8] add config
 1 file changed, 3 insertions(+)
 create mode 100644 config.py
---- exit code: 0 ----
emikihoge@<hostname>:~/work/xx$ 

It showed Passed and the commit went through. I wanted it to be stopped, but it wasn't.

I'll revert this commit for now.

emikihoge@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && git reset --soft HEAD~1 && echo "---- reset done ----" && git status
---- reset done ----
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   config.py

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        .pre-commit-config.yaml

emikihoge@<hostname>:~/work/xx$ 

Since git reset --soft HEAD~1 only reverted the commit, config.py, which had already been added, remained in the staging area, and .pre-commit-config.yaml remained uncommitted. The reason I added config.py and .pre-commit-config.yaml together with git add in step 8 was because of this background.

The cause was that this value was excluded from gitleaks detection. The AWS detection rules in gitleaks have an allowlist that excludes values matching .+EXAMPLE$ (values ending with EXAMPLE).

https://github.com/gitleaks/gitleaks/blob/master/cmd/generate/config/rules/aws.go

AKIAIOSFODNN7EXAMPLE is commonly shown in official AWS documentation as a placeholder example, like "enter your access key here."
Because this value is reused across documentation and sample code, it appears all over the place in code around the world.
If gitleaks were to detect this as a secret every time, it would generate false positives for values that aren't real credentials.
For this reason, such sample values are sometimes excluded from detection via an allowlist to avoid false positives. The same issue comes up not only with gitleaks but also with GitLab's Secret Detection, where an Issue has been raised to add AWS tokens ending with EXAMPLE to the allowlist to reduce false positives (the status of this as of the time of writing has not been confirmed).

https://gitlab.com/gitlab-org/gitlab/-/work_items/452105

After replacing the value with gitleaks's own public test value, I was able to confirm that the commit was blocked as expected.

Conclusion

Having pre-commit and gitleaks in place stopped commits containing secrets at the local level. Even if Claude Code writes code that accidentally includes a secret, you can catch it before it ends up in the history.

However, this mechanism only protects at the time of committing.

Dangerous operations other than commits—such as Claude Code deleting files or communicating externally—cannot be stopped by gitleaks, so PreToolUse hooks and sandboxes are candidates as means to stop such operations.

https://dev.classmethod.jp/articles/claude-code-sandbox-202604/

https://dev.classmethod.jp/articles/claude-code-hooks-aws-production-guard/

For questions or requests about this article, please contact us via "Feedback for DevelopersIO" at the bottom of the screen.

References

https://pre-commit.com/

https://github.com/gitleaks/gitleaks

https://dev.classmethod.jp/articles/claude-code-security-basics/

Share this article