Local secrets firewall for coding agents. Detects credentials in prompts, file reads and tool output before they are sent to the LLM API, redacts them to stable placeholders, and restores the real values locally when the agent writes them back. Works with Claude Code, OpenAI Codex CLI and OpenCode. 100% local — no proxy, no network calls, no keys.
you paste a token in a prompt -> BLOCKED, with a redacted copy to resend
the agent reads .env -> DENIED (the value never enters the model)
a bash command prints a credential -> the model sees SECRETGATE_a1b2c3d4e5f6
the agent writes that placeholder
into a file -> the REAL value lands on disk
secretgate ships as an agent skill — there is no npm package to publish or
trust. Install the skill, then run init once:
npx skills add maxgfr/secretgate -g # installs the skill globally
# then just tell your agent: "install secretgate"The skill activates on that ask and runs secretgate init for you — which
auto-detects Claude Code / Codex / OpenCode on this machine, wires each, and
then checks the installed adapters with synthetic secrets. These local checks
cover prompt masking/blocking, tool output and OpenCode MCP/patch handling; they
do not certify that an already-running agent has loaded the hooks. Restart the agent session afterwards so the hooks load.
Prefer to run it yourself? The bundle lands next to the installed SKILL.md
(e.g. ~/.claude/skills/secretgate/scripts/secretgate.mjs, or
./.claude/skills/secretgate/… for a project install):
node <skill-dir>/scripts/secretgate.mjs initOnce wired, protection is fully automatic and deterministic: the hooks — not the model — scan and redact on every prompt and tool call. You never invoke anything per-use.
- Detection engine: 221 rules converted from gitleaks'
default config (vendored, pinned,
pnpm run rules:syncto refresh) + per-rule entropy thresholds + upstream stopword allowlists + secretgate built-ins for Luhn/IIN-gated credit cards, URL-embedded credentials (postgres://user:pass@…, basic-auth URLs) and quoted passwords with punctuation that gitleaks' generic rule misses. Zero runtime dependencies, single bundled.mjs, ~50ms per scan. If the realgitleaksbinary is installed,secretgate scanruns it as a second engine (hybrid). - Redact-and-restore: each secret maps to a stable
SECRETGATE_<hmac>placeholder (per-install salt, vault at~/.secretgate/vault.json, 0600). The model only ever sees placeholders; consistent across sessions, restored on Write/Edit. Restoring inside Bash commands is off by default — a prompt-injectedcurl $PLACEHOLDERmust not exfiltrate the real value. - Sensitive-file deny: reads of
.env*,*.pem,*.key,id_rsa*,~/.aws/**,~/.ssh/**,~/.kube/config,.npmrc,.netrc… are refused outright (.env.example/.sample/.template/.diststay readable). - Standalone scanner:
secretgate scan <dir>(exit 1 on findings) doubles as a pre-commit hook;secretgate piperedacts any stream.
| Surface | Claude Code | Codex CLI | OpenCode |
|---|---|---|---|
| Secret pasted in a prompt | ✅ blocked + redacted copy | ✅ blocked + redacted copy | ✅ redacted in place |
| Agent reads a sensitive file | ✅ hook deny + permissions.deny |
✅ hook deny | ✅ hook deny (.env also denied by OpenCode itself) |
| Secret in tool/bash/MCP output | ✅ redacted — PostToolUse fires on every tool (*) |
✅ successful supported tools: raw result blocked, redacted result substituted | ✅ redacted (incl. grep/glob) |
| Placeholder written to a file | ✅ real value restored | ✅ real value restored | ✅ real value restored |
| Oversized / un-scannable output | ✅ withheld (fail-closed), never passed raw | ✅ withheld via block-and-replace | ✅ withheld on scan failure |
Scan failures are explicit. Supported output envelopes are replaced with a withholding notice. If Claude Code cannot accept a safe replacement, Secretgate stops the turn and blocks later prompts for that session ID; start a fresh session. Malformed events without a session ID cannot be marked for this resume protection. Host process kills/timeouts remain a fail-open limitation. A per-scan wall-clock budget stops a maliciously slow payload from stalling a hook past the agent's timeout (which would otherwise fail open).
Not covered — know your residual risk:
| Gap | Why | Mitigation |
|---|---|---|
Claude Code @file mentions |
inlined without firing tool hooks | permissions.deny rules (broadened to cover keys, .aws, .ssh, credentials.json, …) block the common sensitive files |
| Codex failed/unsupported tool output | PostToolUse only fires for successful supported tools; native output rewrite remains unsupported |
Bash/apply_patch and successful MCP/local-function results are block-and-replace protected; an MCP result marked as an error can still bypass the post hook |
| Codex local telemetry/logs | block-and-replace changes the model-visible result, not Codex's local logging copy | the raw result stays local; protect access to Codex logs and telemetry configuration |
Low-entropy secrets (password: hunter2) |
indistinguishable from prose without huge false positives | catches strong/quoted passwords; use a real password manager |
Passphrases with spaces (password = "correct horse battery") |
a spaced value after a password key is a UI label or a sentence far more often than a credential — 155 of 649 such matches on a 42k-file corpus were labels like "password": "Client Secret" |
gitleaks' own generic rule stops at [\w.=-] for the same reason; use a real password manager |
| Restore → off-machine exfil | a prompt-injected agent could write a placeholder to a file (restored to the real value) then git push / upload it |
Bash restore is off by default; the secret never reaches the model, only a file the agent already had write access to |
| Images / clipboard / screenshots | no hook surface | — |
| Secrets already in context before install | history is not rewritten | start a fresh session |
| A disabled run (see below) | you asked for it — nothing is scanned while it lasts | pauses expire on their own (60 min default), status leads with a banner, and no in-repo file can trigger one |
A blocked prompt is never sent to the LLM, but Claude Code still echoes your
Original prompt:back to your local terminal — that's your own input on your own screen, not exfiltration. The credential does not reach the API. The same echo lands in headless output (claude -p --output-format json,resultfield), so treat that output as sensitive before piping it to other systems.
Narrowest fix first:
- inline
# pragma: allowlist secret(or# gitleaks:allow) on the line secretgate allow <value>— stored as SHA-256, never in clearsecretgate allow --path 'tests/fixtures/**'secretgate allow --rule <rule-id>(last resort)- one-off prompt bypass: include
[allow-secret]in the prompt - whole firewall off for one run:
secretgate disable(see below)
Projects can commit shared entries in .secretgate.json:
{"allowlist": {"paths": ["testdata/**"]}}.
Sometimes you are working on the credentials — debugging an auth flow, an incident, a fixtures repo. Three scopes, narrowest first:
SECRETGATE_DISABLE=1 claude # one process. No state, dies with the shell.
secretgate disable # this agent run, 60 min (--minutes N | --forever)
secretgate disable --session # this agent run, for its whole lifetime (no timer)
secretgate disable --project # this directory tree, until `enable --project`
secretgate enable # back on (--all clears every pause)secretgate disable with no flag pauses the agent session running in this
directory — another session in the same repo stays protected. Run it from the
agent's own shell to pause exactly that run. A pause expires on its own, and
secretgate status leads with a loud banner while any of them is active.
secretgate disable --session pauses that same run for the session's
lifetime instead of a fixed 60 minutes: no clock to wait on, and it is
collected the moment the run ends (its id leaves the recent-session index), so a
new conversation is protected automatically and no stale pause is left
behind. secretgate enable --session turns it back on now.
Two things a disable never switches off: placeholder restore (otherwise the
agent would write dead SECRETGATE_… tokens into your files) and the standalone
scan / pipe commands (running one is the intent).
The off switch lives in
~/.secretgate/and only there. A.secretgate.jsonin a repository can widen the allowlist but cannot disable the firewall — otherwise any repo you cloned could ship its own kill switch.
secretgate init Install for the agents on this machine + verify the firewall fires
secretgate install --claude-code|--codex|--opencode|--all [--project]
secretgate uninstall (same flags — removes exactly what install added)
secretgate status doctor: wiring, engines, vault health, limitations
secretgate scan <file|dir|-> [--json] [--exclude <glob>] exit 1 on findings
secretgate pipe stdin -> stdout, secrets redacted
secretgate allow <value> | --rule <id> | --path <glob>
secretgate vault list | clear
secretgate disable [--minutes N | --forever | --session] [--project] [--session <id>]
secretgate enable [--project] [--session [id]] [--all]
secretgate hook <agent> <event> (internal hook entrypoint)
Environment: SECRETGATE_HOME (state dir, default ~/.secretgate),
SECRETGATE_DISABLE=1 (firewall off for this process).
- 300+ tests incl. per-event hook replays and a zero-budget false-positive corpus (lockfiles, minified JS, uuids, git logs, base64 blobs).
- A differential CI job runs the real gitleaks binary against the same payloads and requires the JS engine to find everything gitleaks finds (machine check on the Go→JS regex conversion).
- A
skills-installCI job:npx skills add→ run the bundle'sinstall→ adapters installed → a secret-bearing prompt is actually blocked (the whole no-npm distribution path). - A self-scan CI job: secretgate scans its own repo → 0 findings.
- Verified against real
claude -psessions by inspecting the session transcript: a secret in a tool result never appears in what reached the model (only the placeholder does), and restore-on-write puts the real value on disk. E2E script:tests/e2e/claude-code.sh.
pnpm run test:live launches the actual Claude Code, Codex and OpenCode binaries
against a loopback model endpoint. It inspects outgoing requests for synthetic
secrets and confirms restore-on-write on disk, including MCP results. No model
account is needed. node tests/e2e/live-agents.mjs codex selects one agent.
bash tests/e2e/claude-code.sh additionally uses an authenticated Claude account
and defaults to claude-fable-5-1 (SECRETGATE_TEST_MODEL overrides it). This
service test complements the deterministic request-capture tests.
Clean hooks are silent. Path exceptions lift Secretgate's read denial while keeping output redaction; the agent's own permissions can still deny access. OpenCode also scans tool history before model conversion, including restored write arguments and errors. Disabling protection intentionally skips this scan.
Install/uninstall preserve existing rules and foreign hooks. Claude ownership
is recorded beside settings; legacy rules without an ownership record are
retained on uninstall. status checks Codex trust definitions as well as wiring.
pnpm install
pnpm test # vitest
pnpm run rules:sync # refresh rules/gitleaks.toml from upstream + regenerate
pnpm run check:build # reproducible-bundle + fresh-rules gate
SECRETGATE_DIFFERENTIAL=1 pnpm exec vitest run tests/engine/differential.test.ts # needs gitleaksDistributed as a skill only — no npm package. Releases (GitHub, via
semantic-release) publish the install-free bundle as a release asset. The
OpenCode install writes a self-contained plugin file; there is no npm-pin mode.
MIT — rule definitions derived from gitleaks (MIT).
secretgate is automatic by default, and that is the point of it.
One secretgate init wires the hooks into Claude Code, Codex and
OpenCode, and from then on every prompt, file read and tool result is
scanned and redacted by the hooks — not by the model, and not on request.
Turning the firewall off is secretgate disable for a run, or removing the
hooks; nothing about it depends on a skill being invoked.
The shipped skill is also model-invocable, so the agent can reach
secretgate's own commands when a task calls for them. You keep both switches:
| Host | Shipped, automatic | Explicit-only |
|---|---|---|
| Claude Code | no disable-model-invocation in SKILL.md |
add disable-model-invocation: true |
| Codex | allow_implicit_invocation: true under policy: in agents/openai.yaml |
set it to false |
| OpenCode | metadata.opencode/autoinvoke: 'true' in SKILL.md |
set it to 'false' |
Claude Code can do it without touching the file:
"skillOverrides": { "secretgate": "user-invocable-only" } in settings.json
leaves /secretgate working while hiding the skill from the model. Plugin installs
ignore skillOverrides, so edit the frontmatter there. Updating or reinstalling
restores the shipped default, so reapply the change afterwards.
OpenCode V1 reads no autoinvoke metadata; permission.skill in
~/.config/opencode/opencode.json or the project configuration is how you force
explicit-only there. Retain unrelated permissions:
{
"permission": {
"skill": {
"secretgate": "deny"
}
}
}On OpenCode 1.18.30 that rule hides the skill from the agent and rejects
skill-tool loading, while the explicit /secretgate command still works.
Installation with skills add does not write this OpenCode V1 configuration.