Skip to main content

looselips-guard

PreToolUse hook Python 3.8+ No dependencies 221 secret rules Alpha

Loose lips sink shops.

You want to run agents unattended. Sure, you can sandbox them and run a local model — but to do anything useful you have to let them access the outside world: the internet, git, your email. And nothing there stops an agent firing your personal data when it shouldn't, because the model doesn't know any better.

Existing agent tools focus on stopping your secrets reaching the model. looselips-guard catches an agent accidentally putting your data where the world can see it. It blocks a command before it runs when the outbound payload contains, in plaintext, a banned string from your denylist, a credential, or a suspiciously big file that's probably a database dump. The credential check is gitleaks' 221 secret-detection rules ported to run in-process, not a separate scanner you install or invoke.

It is a plaintext denylist, not a containment boundary. It will not stop an agent that base64s the value first, splits it across two calls, or otherwise means to get around it — see What it does not stop. In the incident that motivated this, every leak was accidental and in the clear; a denylist covers that case well and nothing fancier was needed.

It watches gh, git, curl/wget, scp/rsync, nc and MCP tool calls (full list below) — but the two that nothing else covers are the reason it exists:

  • issue and PR bodies — they never become git objects, so a git-history scanner never sees them
  • git add -A quietly sweeping a live database onto a public branch

Getting started

1. Get looselips-guard on your machine. One of:

npm install -g looselips-guard      # puts `looselips-guard` on PATH; needs python3
git clone https://github.com/ed-is-ai/looselips-guard.git ~/looselips-guard
alias looselips-guard='python3 ~/looselips-guard/looselips_guard.py'   # so the commands below work as written

It's one dependency-free Python file either way. In Claude Code,

/plugin marketplace add ed-is-ai/looselips-guard
/plugin install looselips-guard@reinvently

also wires the Claude hook — but you still want one of the above for init / add / check.

2. Wire it and describe your data, from the project you want guarded:

looselips-guard init      # scaffold config, detect your agent, wire its hook
looselips-guard add ZQXF VNTR Acct-99001122
looselips-guard add "ZQXF,VNTR,ACME Corp,Acct-99001122"   # or one comma-separated list
looselips-guard add --like Acct-99001122                  # block the format: \bAcct-\d{8}\b
looselips-guard presets                                   # optional starter regex sets (internal, cloud, k8s)
looselips-guard routes                                    # list egress routes; `routes disable git-push` to stop checking one
looselips-guard check     # confirm it's guarding you

Every route is checked by default. looselips-guard routes disable nc curl turns individual ones off (persisted to .looselips-guard.json); routes enable turns them back on. Routes: gh, git-add, git-commit, git-push, curl, scp, nc, mcp.

init bakes the hook command in the form you invoked it — the looselips-guard bin if you npm-installed, the script's own path if you cloned — so it keeps working. It also writes .looselips-guard.json, detects your host from ~/.claude, ~/.codex, ~/.cursor, ~/.hermes or .github/, and merges the hook into that host's config file in place, leaving your other hooks alone.

Even before any add, the hook already blocks oversized files being staged and any credential the ported gitleaks rules recognise. add extends it with your own strings — tickers, account ids, balances — that generic PII rules can't spot. For data that changes often, point a source at it instead (see Config) and it is re-read on every scan. .looselips-guard.json, .looselips-guard.list and .looselips-guard.snooze are never committed; .looselips-blocklist-example.json is the template init copies from.

Per host

looselips-guard init <host> when detection misses; --global writes the home-directory config instead of the project one. What init does per host, and the one thing worth knowing:

host init wires Worth knowing
claude .claude/settings.json or /plugin install looselips-guard@reinvently in Claude Code
codex ~/.codex/hooks.json same event shape as Claude, exit 2 blocks
copilot .github/hooks/looselips-guard.json MCP needs mcp_servers set (below); known bugs, not ours — plugin hooks don't always fire (#2540), subagents ungated (#2392)
cursor ~/.cursor/hooks.json failClosed: true — blocks on a slow hook instead of failing open
hermes prints YAML for ~/.hermes/config.yaml shell tool is terminal; fail_closed: true, like Cursor

MCP coverage. MCP tool names use the same mcp__<server>__<tool> convention across Claude Code, Codex and Hermes, so init sets the matcher to Bash|mcp__.* / terminal|^mcp__ and the guard scans write-y MCP calls there too. Cursor gets a separate beforeMCPExecution hook; the OpenClaw plugin forwards MCP calls as well (ids are <server>__<tool>).

Copilot names MCP tools <server>-<tool> with no prefix — nothing to key on by shape — so list the servers you want scanned in mcp_servers (see Config). init already sets a matcher that fires the hook on write-verb tool names; mcp_servers is what decides whether the call is actually scanned, so an unlisted server or a built-in write_file is left alone. Its preToolUse is the current event (an older toolCall is gone) and exit 2 denies.

Two hosts need a hand because they have no shell-command hook:

  • OpenClaw / OpenClaw 2 — in-process TS plugin. cp integrations/openclaw/looselips-guard.plugin.ts ~/.openclaw/policies/, then add "~/.openclaw/policies/looselips-guard.plugin.ts" to plugins.load.paths in ~/.openclaw/openclaw.json. It forwards exec and MCP calls to looselips-guard and returns { block } on exit 2 or on a spawn error, so a crash fails closed; OpenClaw's before_tool_call sets no default handler timeout and its policy for a hung handler is undocumented, so a true hang would stall the turn rather than fail either way.
  • DeepSeek Harness — enable its Claude Code / Codex hooks.json bridge (dsh-hooks-claude-code) and point it at hooks/codex-hooks.json. Its pre-execute waterfall honours exit 2; any other failure is "logged as non-blocking, action proceeds" — fail-open, with no documented timeout. Unverified end to end.

Every host is fail-open on a slow hook except Cursor and Hermes. Claude Code and Codex give it a 600 s window (see How it works); OpenClaw, DeepSeek, Copilot and opencode don't publish theirs.


How it works

Four steps in one pass, and the host-specific part is almost nothing.

  agent runs a command
          │
          ▼
  ┌───────────────────┐   the host pauses the tool call and pipes the
  │  host pre-tool    │   command to us as JSON on stdin
  │  hook             │
  └─────────┬─────────┘
            ▼
  ┌───────────────────┐   read `command` and `cwd` from the event;
  │  host adapter     │   later, block with `exit 2` — that's the whole of it
  └─────────┬─────────┘
            ▼
  ┌───────────────────┐   is this a write to the outside world?  gh issue/pr,
  │  matcher          │   gh api, git add/commit/push, curl/wget, scp/rsync, nc, MCP
  └─────────┬─────────┘
            ▼
  ┌───────────────────┐   --body-file, curl @file, scp/rsync sources read from
  │  payload          │   disk; git add via --dry-run, push via rev-list; MCP args walked
  └─────────┬─────────┘
            ▼
  ┌───────────────────┐   denylist + your regex patterns · 221 secret
  │  rules            │   rules · staged/uploaded-file size limit
  └─────────┬─────────┘
            ▼
     allow   or   block, naming exactly what matched and where

Why a pre-tool hook. It is the only layer that sees both leak routes, because it sits where the agent acts rather than at any one destination. A scanner at the git boundary never runs on an issue body; a guard at the model boundary sees the agent legitimately read your database and then says nothing when it pastes the balance into a public issue.

There is barely a host adapter. Every host gives us the same thing — a command, before it runs. Claude Code, Codex, Copilot, Hermes and the DeepSeek bridge send tool_input.command; Cursor sends command top-level; all pass cwd and all read exit 2 as a block. So one script reads the command out of whichever key holds it and covers every one of them with no --host flag. Only OpenClaw is genuinely different — no shell-command hook at all — and gets a small in-process plugin.

Three independent rule sources, because they fail in different directions:

Source Catches Why this shape
Your denylist, derived from your own data (plus opt-in regex patterns) Tickers, balances, account ids and their formats Generic PII regexes are useless here — see below
221 rules ported from gitleaks API keys, tokens, private keys Credentials do have recognisable shapes
A size limit on staged / uploaded files The 1.1 MB SQLite backup One rule closes the whole "ship the database" route

Speed is a correctness requirement, not a nicety. Most hosts are fail-open on timeout — Claude Code's own docs say not to count on a stalled hook as a gate (hooks reference) — and only Cursor and Hermes can be told to fail closed. A slow hook doesn't annoy you, it silently stops guarding. So: no dependencies, nothing imported that isn't needed (argparse only when a subcommand is given, never on the hook path), and a keyword prefilter in front of the secret rules.

Operation Measured
Whole hook invocation 21 ms (15 ms of it is Python starting up)
Matching work itself 6 ms
Secret prefilter, clean payload 0.25 ms — no regex compiled at all
Secret rules when something matches ~1 ms
Compiling all 221 rules, if we didn't prefilter 19.8 ms

In practice the timeout race isn't close. Claude Code's default PreToolUse timeout is 600 seconds (hooks reference), and Codex's is the same (Codex hooks). A 21 ms hook against a ten-minute ceiling doesn't fail open by accident — it would have to hang: block forever on unreadable input, or catch a pathological regex. The design closes those off specifically — no dependencies to hang in, lazy imports, --body-file - blocked rather than read, a bad patterns entry skipped rather than run. So the residual fail-open risk isn't a slow hook; it's a genuinely stuck process or an adversary who can deliberately stall it.

For that, a fail-closed host (Cursor, Hermes) is the real answer — it blocks on timeout regardless. If you've already set that up: well done you. You are a hero ❤️


Hosts

The scanning is identical everywhere. What differs is how each host hands us the command and how we say no.

Wiring for each is in Getting started › Per host; looselips-guard init does it for you.

Host Integration Status
Claude Code PreToolUse, exit 2 works today
Codex PreToolUse, exit 2 works today
GitHub Copilot PreToolUse (PascalCase), exit 2 works today
Hermes Agent pre_tool_call shell hook, exit 2 works today
Cursor beforeShellExecution, exit 2 works today
OpenClaw / OpenClaw 2 before_tool_call plugin, { block } works today — bundled plugin
DeepSeek Harness Claude Code / Codex hook bridge works via bridge — unverified
opencode tool.execute.before, throw designed

works today means the hook fires and exit 2 blocks on that host — the integration is wired and tested. It is not a claim about detection strength; see What it does not stop.

Two things worth knowing before you rely on this:

  • Every command-hook host is fail-open on timeout except Hermes and Cursor, which honour fail_closed / failClosed on the pre-execution hook. The window is wide though — Claude Code and Codex default to a 600 s hook timeout, so a 21 ms hook only fails open if it truly hangs (see How it works, and the per-host notes above).
  • opencode does not intercept subagent tool calls (open issue), so a delegated gh call bypasses the guard there. Not ours to fix, but yours to know.

Antigravity is deliberately unsupported: there are open reports its hooks never fire, and a guard that might not run is worse than no guard.


Config

All of this lives in .looselips-guard.json at the root of the project you are guarding — the working directory the command runs in. It is never committed. .looselips-blocklist-example.json is only a template to copy from; .looselips-guard.list (below) is one optional data source, not the config itself.

The denylist is generated from your own data, not from generic PII regexes. Generic rules fail here, concretely: in the incident that motivated this tool the owner's own email authored 295 of 445 commits, so a "block emails" rule blocks every commit they make; the only emails in tracked files were user@example.com placeholders; and in a finance repo a UK phone regex matches SEDOLs, ISINs, order ids and timestamps.

What worked was deriving the list from the ledger itself, excluding the English-word collisions, and matching the rest on word boundaries — 27 leaking items found across 258 issues and 306 PRs, with zero false positives.

  • values — literal entries, listed inline in the config.
  • sources — pull entries from a file instead, re-read on every scan: txt (path, one value per line, # comments), csv (path + column), sqlite (path + query), env (path, the value side of each KEY=value).
  • patterns — regexes, matched raw (you write your own anchors), for a format rather than a list: \bAcct-\d{8}\b, an internal hostname suffix. This is the one place generic-regex risk is yours to own — see the warning above. add --regex '<pattern>' appends one; add --like 'Acct-99001122' derives \bAcct-\d{8}\b from an example. A pattern that won't compile is warned about on stderr and skipped, never fatal. Empty by default.
  • allow — collision list, for tickers that are also words (ALL, ON, CAT). Matching is case-sensitive with word boundaries, which removes most collisions before this list is needed. allow does not apply to patterns.
  • max_added_file_bytes — files larger than this are blocked from git add and from scp/rsync uploads without being read (default 500 KB).
  • routes — a {name: bool} map of which egress routes check() enforces. Anything not listed is checked; set one false to skip it. Names: gh, git-add, git-commit, git-push, curl, scp, nc, mcp. Manage with looselips-guard routes [enable|disable] <name>… rather than by hand.
  • mcp_tools — regex of MCP tool names whose arguments get scanned. Default is a set of write verbs (create|post|send|comment|publish|upload|write|update|…) so reading your own data through an MCP server doesn't trip the denylist. Set ".*" to scan every MCP call, "" or false to scan none.
  • mcp_servers — Copilot only. A list of MCP server names (["github", "slack"]). Copilot's tool names are <server>-<tool> with no prefix, so the guard can't tell an MCP call from a built-in without this. On hosts that use the mcp__<server>__<tool> convention it's unnecessary.

Presets. There is no shipped generic-PII pack, on purpose (see the box above for why they backfire). What there is: a few opt-in starter sets of format-based patterns. looselips-guard presets prints them with sources; nothing is added until you run add --preset, and init never adds them.

preset patterns from
internal RFC 1918 IPs (10/8, 172.16/12, 192.168/16); *.internal/*.corp/*.intranet/*.lan RFC 1918; .internal is ICANN-reserved for private use
cloud arn:aws:…:<acct>:…; s3://… URIs AWS ARN format
k8s *.svc.cluster.local, *.pod.cluster.local Kubernetes cluster DNS
looselips-guard add --preset internal      # pull one in; run again for another

They're a starting point, not a policy — review and trim to your environment.

Secrets are handled separately, by 221 rules ported from gitleaks, so there is nothing to install. Regenerate them with scripts/port_gitleaks_rules.py.


What it intercepts

  • gh issue|pr|release|gist create|edit|comment--body, --title, --body-file <path> (resolved and read), --body-file - (unreadable, therefore unscannable, therefore blocked)
  • gh api with a mutation, POST, or any -f/-F field
  • git add — every path it would actually stage, by size and by content
  • git commit -m
  • git push — the diff of commits not yet on any remote (capped at 2 MB)
  • curl / wget — request body (-d/--data*/-F/-T/--json/--post-data, inline or @file), and the URL itself
  • scp / rsync — when the destination is remote, the contents of every local source file (directories walked, same size cap as git add); a download is left alone
  • nc / ncat / netcat with a port — the command line itself and any file it pipes in (cat file | nc …, nc … < file)
  • MCP tool calls whose name matches a write verb (create_issue, post_message, …) — every string in the arguments. Tune with mcp_tools (see Config); reads like query or list_* are skipped by default

Each of these is a route you can turn off with looselips-guard routes disable <name> — see Getting started.

Override

Blocking hard gets a tool bypassed, and then it protects nothing. A real bug report may need to name the ticker that exposed the bug. So the hook prints exactly what matched and where, and there are two deliberate ways through:

LOOSELIPS_GUARD_OK=1 gh issue create --title "…" --body-file issue.md  # this one call
looselips-guard snooze          # let the agent through here for 5 min (snooze 15 for longer)
looselips-guard snooze --clear  # …or end the window now

snooze writes .looselips-guard.snooze (an epoch expiry) in the directory and the hook allows matches while it's live. It fails shut: a missing, expired or unreadable file means blocked, so a snooze only ever loosens the guard for the window you asked for.

For an MCP call there's no command to prefix — use snooze, export LOOSELIPS_GUARD_OK=1 for the session, or narrow mcp_tools.

Every leak in the motivating incident was accidental. Making the deliberate case cheap and the accidental case impossible is the whole design goal.

Inbound guard (planned, opt-in)

Outbound is the product. Inbound — stopping secrets reaching the model — is separate and opt-in, because the two directions need opposite rules: in the incident that motivated this, the model was supposed to see the portfolio. Applying the outbound denylist inbound would block the agent from working at all.

So inbound guards secrets only, and redacts rather than blocks:

cat .env      →     cat .env | looselips-guard redact

That works because a pre-tool hook can rewrite tool input. Its hard limit: nothing can rewrite tool output, so a read through the host's native read tool (Read, view) cannot be sanitised — it warns and allows. Inbound protection is best-effort by construction, and says so.

What it does not stop

Any transformation of the value. Detection is a substring / regex match on the plaintext payload. base64, gzip, hex, gpg, rot13, or splitting Acct-99001122 across two tool calls — none of that matches Acct-99001122, and the guard allows it. This is inherent to a denylist and is the honest ceiling of the approach: it catches accidental plaintext, not a determined exfiltrator. For that you need network egress control or a sandbox, not a pre-tool hook.

Parsing divergence. For every intercepted command the guard re-implements enough argument parsing to find the payload — --body-file resolution, curl @file, git add --dry-run, the unpushed-diff range, walking MCP args. Anywhere its model of what-will-be-sent differs from what the tool actually sends is a silent bypass: exotic quoting, an encoding it doesn't decode, a redirection or heredoc, an argument order the parser didn't expect. curl --data @- and --body-file - (reads from stdin, which the hook can't see) are blocked outright for this reason; the rest is best-effort. The test suite includes an adversarial group, but it is not exhaustive.

Out of scope by design:

  • Anything typed into github.com in a browser — a pre-tool hook only sees agent-initiated calls.
  • Rewriting outbound payloads. Silently altering an issue body the agent wrote is worse than refusing it, so outbound blocks and never edits.
  • A git commit message from $EDITOR (no -m/-F) — the content is already guarded at git add, but the message text isn't seen.
  • MCP calls on Copilot when mcp_servers isn't set — no tool-name prefix to key on, so you name the servers.
  • Whatever tool comes next. The matcher is a short list of argv[0] cases plus nc anywhere in a pipeline — built to extend, not exhaustive.

Development

python3 tests/run.py                   # whole suite; or run one file, e.g. tests/test_matchers.py
git config core.hooksPath .githooks    # opt in: run tests before every push
python3 scripts/port_gitleaks_rules.py # refresh the secret rules from upstream
scripts/release.sh 0.2.0               # bump, tag, push; CI publishes

Test approach

CI (.github/workflows/test.yml) runs the suite on every PR and push to master on Python 3.8 and 3.12; make it a required check in branch protection to block merges on failure.

tests/ is framework-free — assert-based run_tests() per file, shared corpus in tests/fixtures.py; tests/README.md explains the layout and the two adversarial groups. Fixtures are synthetic by policy: a ticker-like token beside a currency amount, a balance line, a holdings table, an oversized SQLite backup. The real incident data that shaped them is never published.

Credits

Built by the team at Reinvently.

Secret detection rules are ported from gitleaks by Zachary Rice, used under the MIT Licence — see NOTICE. gitleaks does the hard part: 221 maintained rules, keyword prefilters and entropy thresholds, refined over years of real-world false positives. looselips-guard only translates them so they run in-process with no binary to install. For scanning git history, use gitleaks itself.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

looselips_guard-0.3.1.tar.gz (46.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

looselips_guard-0.3.1-py3-none-any.whl (32.0 kB view details)

Uploaded Python 3

File details

Details for the file looselips_guard-0.3.1.tar.gz.

File metadata

  • Download URL: looselips_guard-0.3.1.tar.gz
  • Upload date:
  • Size: 46.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for looselips_guard-0.3.1.tar.gz
Algorithm Hash digest
SHA256 aecb37e347327fb02d0a186b2f2779a7a5781e7cd080934d870cf8b6dfc8aa9f
MD5 2e59eb77077bac232f1c45886dfdf22d
BLAKE2b-256 196c1c660054a304a9f1fa279e378e5254f1867b15bee4f799956d4e7919310d

See more details on using hashes here.

Provenance

The following attestation bundles were made for looselips_guard-0.3.1.tar.gz:

Publisher: release.yml on ed-is-ai/looselips-guard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file looselips_guard-0.3.1-py3-none-any.whl.

File metadata

  • Download URL: looselips_guard-0.3.1-py3-none-any.whl
  • Upload date:
  • Size: 32.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for looselips_guard-0.3.1-py3-none-any.whl
Algorithm Hash digest
SHA256 d021ef3af0889bb4ca5a4dddd3e9f14c38873676f3fff7bc3afe2c6805d17af9
MD5 8473d7947a7af3459ba906c95e36458e
BLAKE2b-256 750e2b6e238cfefc18194c2d9fd32ac10bd955c01d42cd01032d83a4b1876676

See more details on using hashes here.

Provenance

The following attestation bundles were made for looselips_guard-0.3.1-py3-none-any.whl:

Publisher: release.yml on ed-is-ai/looselips-guard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.3.1 This release

2 files

0.3.0

2 files

0.2.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page