Stop committing secrets with pre-commit and gitleaks

Stop committing secrets with pre-commit and gitleaks

AI agent-generated code can contain secrets. We tested a mechanism to detect and block them just before commits 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 generated code containing API keys or passwords may be committed as-is.

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

Conclusion First

When configured to call gitleaks via pre-commit, secrets contained in files are detected the moment you try to git commit them, and the commit is aborted.

gitleaks and pre-commit

Since I don't do much development work on a regular basis, I'll include 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.

  • Scans code and commits, and reports any strings that look like secrets as findings
  • When secrets are detected, 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 predefined 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 some examples; there are others as well.

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

The following image shows where in the flow of work (working tree) → stage → commit → push each hook runs.

Uses of commit-msg hook
  • pre-commit hook: runs before the commit message is retrieved, before the commit is created

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

    • The order is: pre-commit hook → message input → commit-msg hook
  • Uses of commit-msg hook

    • Since the pre-commit hook doesn't receive the message, this is used when you want to inspect the message itself
    • Normalizing commit messages to a standard format
    • Inspecting the message and rejecting 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 duplicate Signed-off-by lines are detected
    • Examples
      • Enforcing Conventional Commits format (like feat: or fix:), requiring a ticket number to always be included

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

The actual body of these hooks are shell scripts placed under the repository's .git/hooks/. The timing of execution is determined by the filename, and they are placed as .git/hooks/pre-commit, .git/hooks/commit-msg, .git/hooks/pre-push, and so on. 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, Git-bundled samples are placed in .git/hooks/ with a .sample extension. The pre-commit hook sample is pre-commit.sample, and its contents are a shell script like the following (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) stops with exit 1 if a non-ASCII filename is about to be added

# 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) when it finds non-ASCII filenames or trailing whitespace errors, stopping the commit.
The comment says "exit with non-zero status to stop the commit" and "rename the file to pre-commit to enable this hook."
In other words, remove the .sample extension and rename it to pre-commit when you want to 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 the team, and each person needs to set them up manually.

pre-commit (tool)

The naming is a bit confusing, but there is a tool called pre-commit that is separate from Git's pre-commit hook. 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/

The pre-commit tool, when called from the pre-commit hook, executes the checks written in the configuration file (.pre-commit-config.yaml) (in this case, gitleaks).

Differences from a plain pre-commit hook

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

With a plain pre-commit hook

With a plain pre-commit hook, you write the check content directly into .git/hooks/pre-commit yourself.

.git/hooks/pre-commit (written by 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 separately install gitleaks themselves
  • Since .git/hooks/ is not included in git clone or git push, this script cannot be shared with the team, and each member has to place it manually
  • Versions and implementations tend to vary from person to person
Using pre-commit (tool)

When using the pre-commit tool, the only thing you need to prepare yourself is a single 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 by 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 beginning, and running it causes the pre-commit tool to create a script at .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 the pre-commit tool. It passes the configuration file to read via --config=.pre-commit-config.yaml.

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

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

With this approach, the drawbacks of the plain 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 in the repository root (the same level as .git/), it can be committed along with the repository. Unlike the approach of placing it directly in .git/hooks/, the configuration can be shared with the team
  • By fixing the version with rev, the same check runs for all team members

It's a bit confusing. To summarize:

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

Folder Structure Used for Verification

This time I created a disposable verification repository lab/ to test with. 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 written by 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

gitleaks is not separately installed by myself; I leave it to pre-commit to fetch it. If you write the gitleaks repository in .pre-commit-config.yaml, it will be fetched at runtime.

Step 1: Check Installation Status

First, I checked whether gitleaks and pre-commit were already installed in the current environment, as well as whether the 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

emiki@<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 ===
emiki@<hostname>:~/work/xx$ 

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

Step 2: Install pre-commit

Install pre-commit with pip.

pip3 install --user pre-commit
Execution result
emiki@<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
emiki@<hostname>:~/work/xx$ 

It was installed in ~/.local/bin, and the PATH was also set correctly.

which pre-commit; pre-commit --version

▼ Execution result

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

Step 3: Create a Verification Repository

Create a disposable verification repository. The working directory path is stored in a variable called BASE, which will be reused from here on (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

emiki@<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
emiki@<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 contents I wrote this time are 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 source repository URL from which to git clone the hook code. Specifies the gitleaks GitHub repository
    • Since pre-commit fetches gitleaks from here, you can use it without separately installing gitleaks yourself
  • rev
    • Specifies which version of the gitleaks code to fetch from GitHub. rev stands for revision
    • Ways to refer to "a specific point in time of 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, but branch names and HEAD change when new commits are added
    • What can be written in rev is a tag or SHA whose content doesn't change. Since pre-commit caches the fetched gitleaks for reuse, branch names like main or HEAD (= things that change) cannot be used
    • This time I specified the tag v8.30.1 for the latest release at the time of writing this article. By fixing to a tag, the same v8.30.1 of gitleaks runs no matter who runs it or when
  • hooks
    • A list of which hooks provided by that repository to use
    • 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 among those to use"
    • id is not something you name yourself; it is determined by the providing repository. The hooks provided by the providing repository are defined with IDs in .pre-commit-hooks.yaml at the root of that repository, so users look there to find and specify the IDs they want to use
    • id
      • The identifier (name) of the hook to use. The gitleaks repository's .pre-commit-hooks.yaml 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 id entries under hooks. For example, to use two from the popular pre-commit/pre-commit-hooks repository, write it as follows.

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 prepared this time means "fetch gitleaks v8.30.1 from GitHub and run it just before each commit."

Step 5: Register with 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.

At the same time, I also run ls -la .git/hooks/pre-commit. This is to confirm whether .git/hooks/pre-commit was actually created and whether it has execute permissions (the leading -rwxr-xr-x).

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

▼ Execution result

emiki@<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 emiki emiki 598 Sep 18 09:42 .git/hooks/pre-commit
emiki@<hostname>:~/work/xx$ 

Step 6: Check the Contents of the Generated Hook

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

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

▼ Execution result

emiki@<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
emiki@<hostname>:~/work/xx$ 

As seen at the beginning, the check itself is not written here; it simply calls the pre-commit tool.

Step 7: Prepare a File with Detectable Dummy Values

I prepare 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 (revisited)
(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 written by yourself (repository root)
    ├── config.py                 ← verification file with secrets mixed in
    └── README.md

Regarding the dummy values to put here, since I don't want to risk coincidentally matching someone else's actual secrets if I create random values myself, I'll use sample values that have already been 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 an Access Key ID and a Secret Access Key. Since I used a known sample value for the Access Key ID, I created a randomly generated string on my own that just resembles the format for the Secret side.

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

Actually, at the start of the verification I had used a commonly seen sample value, and at that time the commit went through. I'll mention that later.

Step 8: Stage the Changed Values

Since I don't usually do much development work, I'll organize "stage" and "commit" here for my own benefit.

In Git, rather than immediately recording file changes to history, you proceed in two stages.

  • git add (stage)
    • The operation of selecting "include this file in the next record" and loading it into a waiting area called the staging area
    • At this point, nothing is recorded in the history yet. This is the stage of selecting what to record ahead of time
  • git commit (commit)
    • The operation of recording the files loaded onto the stage as a unified set of changes in the history
    • This is when it first gets recorded in the repository's history

The pre-commit and gitleaks checks this time run right before this git commit (commit finalization). So the flow is: first load the file onto the stage with git add, and then when you run git commit, the check runs.

First, load config.py and .pre-commit-config.yaml onto the stage with git add.

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

▼ Execution result

emiki@<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

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

The reason I'm adding .pre-commit-config.yaml together with config.py here is due to the trial-and-error process mentioned in the side note at the end.
※ Naturally, .pre-commit-config.yaml would normally already be committed beforehand.

Step 9: The Commit Was Stopped

Commit with the files staged.

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

▼ Execution result

emiki@<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 ----
emiki@<hostname>:~/work/xx$ 

It resulted in Failed and the commit was aborted. The flow was that gitleaks returned exit code 1, and git commit failed in response.

There were 2 detections. Line 1 is the Access Key ID (aws-access-token), and line 2 is the string on the Secret side, which was picked up as a generic API key (generic-api-key) because of its high randomness. This was an unexpected bonus, but it became a real-world example of detecting suspicious strings as well.

Step 10: Confirm the Commit Was Actually Stopped

Confirm that the commit was actually stopped.

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

▼ Execution result

emiki@<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

emiki@<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 absent, we can see that the commit was not made
  • Below === status === under Changes to be committed: (output of git status)
    • A command to view the current state
    • config.py and .pre-commit-config.yaml appear as new file
    • The files are still loaded onto the stage via git add, but have not yet been git committed (recorded in history)

Only the initial init remains in the command history, and add config is not there. The files remain locally but have not been recorded in the history. We can confirm that the process was 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 the official AWS documentation.

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

I created config.py with these values, ran git add, and tried to commit.

emiki@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && cat > config.py <<'EOF'
# Configuration file assumed to be 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
emiki@<hostname>:~/work/xx$ 
emiki@<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 ----
emiki@<hostname>:~/work/xx$ 

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

I'll revert this commit for now.

emiki@<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

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

Here, using git reset --soft HEAD~1 only reverted the commit, so 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 in gitleaks's exclusion list. The AWS detection rule in gitleaks has 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 the official AWS documentation as a placeholder example, as in "put your access key here."
Because this value is reused across documentation and sample code, it appears throughout code in the wild.
From gitleaks's perspective, detecting this as a secret every time would increase false positives — warnings for values that aren't real secrets.
For this reason, such sample values may be excluded from detection via the allowlist to avoid false positives. In a GitLab Issue as well, a proposal was made to exclude AWS sample keys from detection targets.

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 the expected behavior of the commit being blocked.

Conclusion

By setting up pre-commit and gitleaks, commits containing secrets are blocked locally. Even if secrets accidentally get mixed into code written by Claude Code, you can catch them before they end up in the history.

However, this mechanism only protects at the point 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 block 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 page.

References

https://pre-commit.com/

https://github.com/gitleaks/gitleaks

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

Share this article