looselips-guard 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- looselips_guard-0.2.0/NOTICE +28 -0
- looselips_guard-0.2.0/PKG-INFO +490 -0
- looselips_guard-0.2.0/README.md +475 -0
- looselips_guard-0.2.0/gitleaks_rules.py +1256 -0
- looselips_guard-0.2.0/looselips_guard.egg-info/PKG-INFO +490 -0
- looselips_guard-0.2.0/looselips_guard.egg-info/SOURCES.txt +15 -0
- looselips_guard-0.2.0/looselips_guard.egg-info/dependency_links.txt +1 -0
- looselips_guard-0.2.0/looselips_guard.egg-info/entry_points.txt +2 -0
- looselips_guard-0.2.0/looselips_guard.egg-info/top_level.txt +2 -0
- looselips_guard-0.2.0/looselips_guard.py +845 -0
- looselips_guard-0.2.0/pyproject.toml +28 -0
- looselips_guard-0.2.0/setup.cfg +4 -0
- looselips_guard-0.2.0/tests/test_cli.py +74 -0
- looselips_guard-0.2.0/tests/test_hosts.py +64 -0
- looselips_guard-0.2.0/tests/test_matchers.py +73 -0
- looselips_guard-0.2.0/tests/test_matchers_adversarial.py +35 -0
- looselips_guard-0.2.0/tests/test_scan.py +51 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
looselips-guard bundles secret-detection rules ported from gitleaks.
|
|
2
|
+
Source: https://github.com/gitleaks/gitleaks (config/gitleaks.toml)
|
|
3
|
+
The rules live in gitleaks_rules.py and are regenerated by
|
|
4
|
+
scripts/port_gitleaks_rules.py. gitleaks itself is not bundled.
|
|
5
|
+
|
|
6
|
+
gitleaks is distributed under the MIT Licence:
|
|
7
|
+
|
|
8
|
+
MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2019 Zachary Rice
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: looselips-guard
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Stops your coding agent publishing your data to the world
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/ed-is-ai/looselips-guard
|
|
7
|
+
Keywords: security,agent,hooks,egress,claude-code,codex,cursor
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Security
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: NOTICE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<picture>
|
|
18
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
|
|
19
|
+
<img src="assets/logo.svg" alt="looselips-guard" width="360">
|
|
20
|
+
</picture>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
<img src="https://img.shields.io/badge/hook-PreToolUse-cf222e" alt="PreToolUse hook">
|
|
25
|
+
<img src="https://img.shields.io/badge/python-3.8%2B-3776ab" alt="Python 3.8+">
|
|
26
|
+
<img src="https://img.shields.io/badge/dependencies-none-2da44e" alt="No dependencies">
|
|
27
|
+
<img src="https://img.shields.io/badge/secret%20rules-221-8250df" alt="221 secret rules">
|
|
28
|
+
<img src="https://img.shields.io/badge/status-alpha-d29922" alt="Alpha">
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
<p align="center"><em>Loose lips sink shops.</em></p>
|
|
32
|
+
|
|
33
|
+
You want to run agents unattended. Sure, you can sandbox them and run a local
|
|
34
|
+
model — but to do anything useful you have to let them access the outside world: the
|
|
35
|
+
internet, git, your email. And nothing there stops an agent firing your personal
|
|
36
|
+
data when it shouldn't, because the model doesn't know any
|
|
37
|
+
better.
|
|
38
|
+
|
|
39
|
+
Existing agent tools focus on stopping your secrets reaching the model.
|
|
40
|
+
**looselips-guard catches an agent *accidentally* putting your data where the
|
|
41
|
+
world can see it.** It blocks a command *before it runs* when the outbound
|
|
42
|
+
payload contains, in plaintext, a banned string from your denylist, a
|
|
43
|
+
credential, or a suspiciously big file that's probably a database dump. The
|
|
44
|
+
credential check is [gitleaks](https://github.com/gitleaks/gitleaks)' 221
|
|
45
|
+
secret-detection rules ported to run in-process, not a separate scanner you
|
|
46
|
+
install or invoke.
|
|
47
|
+
|
|
48
|
+
It is a **plaintext denylist**, not a containment boundary. It will not stop an
|
|
49
|
+
agent that base64s the value first, splits it across two calls, or otherwise
|
|
50
|
+
means to get around it — see [What it does not stop](#what-it-does-not-stop). In
|
|
51
|
+
the incident that motivated this, every leak was accidental and in the clear; a
|
|
52
|
+
denylist covers that case well and nothing fancier was needed.
|
|
53
|
+
|
|
54
|
+
It watches `gh`, `git`, `curl`/`wget`, `scp`/`rsync`, `nc` and MCP tool calls
|
|
55
|
+
(full list [below](#what-it-intercepts)) — but the two that nothing else covers
|
|
56
|
+
are the reason it exists:
|
|
57
|
+
|
|
58
|
+
- **issue and PR bodies** — they never become git objects, so a git-history
|
|
59
|
+
scanner never sees them
|
|
60
|
+
- **`git add -A`** quietly sweeping a live database onto a public branch
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Getting started
|
|
65
|
+
|
|
66
|
+
**1. Get `looselips-guard` on your machine.** One of:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install -g looselips-guard # puts `looselips-guard` on PATH; needs python3
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
git clone https://github.com/ed-is-ai/looselips-guard.git ~/looselips-guard
|
|
74
|
+
alias looselips-guard='python3 ~/looselips-guard/looselips_guard.py' # so the commands below work as written
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It's one dependency-free Python file either way. In Claude Code,
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
/plugin marketplace add ed-is-ai/looselips-guard
|
|
81
|
+
/plugin install looselips-guard@reinvently
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
also wires the Claude hook — but you still want one of the above for
|
|
85
|
+
`init` / `add` / `check`.
|
|
86
|
+
|
|
87
|
+
**2. Wire it and describe your data**, from the project you want guarded:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
looselips-guard init # scaffold config, detect your agent, wire its hook
|
|
91
|
+
looselips-guard add ZQXF VNTR Acct-99001122
|
|
92
|
+
looselips-guard add "ZQXF,VNTR,ACME Corp,Acct-99001122" # or one comma-separated list
|
|
93
|
+
looselips-guard add --like Acct-99001122 # block the format: \bAcct-\d{8}\b
|
|
94
|
+
looselips-guard presets # optional starter regex sets (internal, cloud, k8s)
|
|
95
|
+
looselips-guard routes # list egress routes; `routes disable git-push` to stop checking one
|
|
96
|
+
looselips-guard check # confirm it's guarding you
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Every route is checked by default. `looselips-guard routes disable nc curl`
|
|
100
|
+
turns individual ones off (persisted to `.looselips-guard.json`);
|
|
101
|
+
`routes enable` turns them back on. Routes: `gh`, `git-add`, `git-commit`,
|
|
102
|
+
`git-push`, `curl`, `scp`, `nc`, `mcp`.
|
|
103
|
+
|
|
104
|
+
`init` bakes the hook command in the form you invoked it — the `looselips-guard`
|
|
105
|
+
bin if you npm-installed, the script's own path if you cloned — so it keeps
|
|
106
|
+
working. It also writes `.looselips-guard.json`, detects your host from
|
|
107
|
+
`~/.claude`, `~/.codex`, `~/.cursor`, `~/.hermes` or `.github/`, and merges the
|
|
108
|
+
hook into that host's config file in place, leaving your other hooks alone.
|
|
109
|
+
|
|
110
|
+
Even before any `add`, the hook already blocks oversized files being staged and
|
|
111
|
+
any credential the ported [gitleaks](https://github.com/gitleaks/gitleaks) rules
|
|
112
|
+
recognise. `add` extends it with your own strings — tickers, account ids,
|
|
113
|
+
balances — that generic PII rules can't spot. For data that changes often, point
|
|
114
|
+
a `source` at it instead (see [Config](#config)) and it is re-read on every scan.
|
|
115
|
+
`.looselips-guard.json`, `.looselips-guard.list` and `.looselips-guard.snooze`
|
|
116
|
+
are never committed;
|
|
117
|
+
[`.looselips-blocklist-example.json`](.looselips-blocklist-example.json) is the
|
|
118
|
+
template `init` copies from.
|
|
119
|
+
|
|
120
|
+
### Per host
|
|
121
|
+
|
|
122
|
+
`looselips-guard init <host>` when detection misses; `--global` writes the
|
|
123
|
+
home-directory config instead of the project one. What `init` does per host, and
|
|
124
|
+
the one thing worth knowing:
|
|
125
|
+
|
|
126
|
+
| `host` | `init` wires | Worth knowing |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `claude` | `.claude/settings.json` | or `/plugin install looselips-guard@reinvently` in Claude Code |
|
|
129
|
+
| `codex` | `~/.codex/hooks.json` | same event shape as Claude, `exit 2` blocks |
|
|
130
|
+
| `copilot` | `.github/hooks/looselips-guard.json` | MCP needs `mcp_servers` set (below); known bugs, not ours — plugin hooks don't always fire ([#2540](https://github.com/github/copilot-cli/issues/2540)), subagents ungated ([#2392](https://github.com/github/copilot-cli/issues/2392)) |
|
|
131
|
+
| `cursor` | `~/.cursor/hooks.json` | `failClosed: true` — blocks on a slow hook instead of failing open |
|
|
132
|
+
| `hermes` | prints YAML for `~/.hermes/config.yaml` | shell tool is `terminal`; `fail_closed: true`, like Cursor |
|
|
133
|
+
|
|
134
|
+
**MCP coverage.** MCP tool names use the same `mcp__<server>__<tool>` convention
|
|
135
|
+
across Claude Code, Codex and Hermes, so `init` sets the matcher to
|
|
136
|
+
`Bash|mcp__.*` / `terminal|^mcp__` and the guard scans write-y MCP calls there
|
|
137
|
+
too. Cursor gets a separate `beforeMCPExecution` hook; the OpenClaw plugin
|
|
138
|
+
forwards MCP calls as well (ids are `<server>__<tool>`).
|
|
139
|
+
|
|
140
|
+
**Copilot** names MCP tools `<server>-<tool>` with no prefix — nothing to key on
|
|
141
|
+
by shape — so list the servers you want scanned in `mcp_servers` (see
|
|
142
|
+
[Config](#config)). `init` already sets a matcher that fires the hook on
|
|
143
|
+
write-verb tool names; `mcp_servers` is what decides whether the call is
|
|
144
|
+
actually scanned, so an unlisted server or a built-in `write_file` is left
|
|
145
|
+
alone. Its `preToolUse` is the current event (an older `toolCall` is gone) and
|
|
146
|
+
`exit 2` denies.
|
|
147
|
+
|
|
148
|
+
Two hosts need a hand because they have no shell-command hook:
|
|
149
|
+
|
|
150
|
+
- **OpenClaw / OpenClaw 2** — in-process TS plugin.
|
|
151
|
+
`cp integrations/openclaw/looselips-guard.plugin.ts ~/.openclaw/policies/`, then
|
|
152
|
+
add `"~/.openclaw/policies/looselips-guard.plugin.ts"` to `plugins.load.paths`
|
|
153
|
+
in `~/.openclaw/openclaw.json`. It forwards `exec` and MCP calls to
|
|
154
|
+
`looselips-guard` and returns `{ block }` on exit 2 or on a spawn error, so a
|
|
155
|
+
*crash* fails closed; OpenClaw's `before_tool_call` sets no default handler
|
|
156
|
+
timeout and its policy for a hung handler is undocumented, so a true *hang*
|
|
157
|
+
would stall the turn rather than fail either way.
|
|
158
|
+
- **DeepSeek Harness** — enable its Claude Code / Codex `hooks.json` bridge
|
|
159
|
+
([`dsh-hooks-claude-code`](https://github.com/deepseek-ai/deepseek-harness)) and
|
|
160
|
+
point it at [`hooks/codex-hooks.json`](hooks/codex-hooks.json). Its pre-execute
|
|
161
|
+
waterfall honours exit 2; any other failure is "logged as non-blocking, action
|
|
162
|
+
proceeds" — fail-open, with no documented timeout. Unverified end to end.
|
|
163
|
+
|
|
164
|
+
Every host is fail-open on a slow hook except Cursor and Hermes. Claude Code and
|
|
165
|
+
Codex give it a 600 s window (see [How it works](#how-it-works)); OpenClaw,
|
|
166
|
+
DeepSeek, Copilot and opencode don't publish theirs.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## How it works
|
|
171
|
+
|
|
172
|
+
Four steps in one pass, and the host-specific part is almost nothing.
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
agent runs a command
|
|
176
|
+
│
|
|
177
|
+
▼
|
|
178
|
+
┌───────────────────┐ the host pauses the tool call and pipes the
|
|
179
|
+
│ host pre-tool │ command to us as JSON on stdin
|
|
180
|
+
│ hook │
|
|
181
|
+
└─────────┬─────────┘
|
|
182
|
+
▼
|
|
183
|
+
┌───────────────────┐ read `command` and `cwd` from the event;
|
|
184
|
+
│ host adapter │ later, block with `exit 2` — that's the whole of it
|
|
185
|
+
└─────────┬─────────┘
|
|
186
|
+
▼
|
|
187
|
+
┌───────────────────┐ is this a write to the outside world? gh issue/pr,
|
|
188
|
+
│ matcher │ gh api, git add/commit/push, curl/wget, scp/rsync, nc, MCP
|
|
189
|
+
└─────────┬─────────┘
|
|
190
|
+
▼
|
|
191
|
+
┌───────────────────┐ --body-file, curl @file, scp/rsync sources read from
|
|
192
|
+
│ payload │ disk; git add via --dry-run, push via rev-list; MCP args walked
|
|
193
|
+
└─────────┬─────────┘
|
|
194
|
+
▼
|
|
195
|
+
┌───────────────────┐ denylist + your regex patterns · 221 secret
|
|
196
|
+
│ rules │ rules · staged/uploaded-file size limit
|
|
197
|
+
└─────────┬─────────┘
|
|
198
|
+
▼
|
|
199
|
+
allow or block, naming exactly what matched and where
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Why a pre-tool hook.** It is the only layer that sees both leak routes,
|
|
203
|
+
because it sits where the agent *acts* rather than at any one destination. A
|
|
204
|
+
scanner at the git boundary never runs on an issue body; a guard at the model
|
|
205
|
+
boundary sees the agent legitimately read your database and then says nothing
|
|
206
|
+
when it pastes the balance into a public issue.
|
|
207
|
+
|
|
208
|
+
**There is barely a host adapter.** Every host gives us the same thing — a
|
|
209
|
+
command, before it runs. Claude Code, Codex, Copilot, Hermes and the DeepSeek
|
|
210
|
+
bridge send `tool_input.command`; Cursor sends `command` top-level; all pass
|
|
211
|
+
`cwd` and all read `exit 2` as a block. So one script reads the command out of
|
|
212
|
+
whichever key holds it and covers every one of them with no `--host` flag. Only
|
|
213
|
+
OpenClaw is genuinely different — no shell-command hook at all — and gets a small
|
|
214
|
+
in-process plugin.
|
|
215
|
+
|
|
216
|
+
**Three independent rule sources**, because they fail in different directions:
|
|
217
|
+
|
|
218
|
+
| Source | Catches | Why this shape |
|
|
219
|
+
|---|---|---|
|
|
220
|
+
| 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
|
+
| 221 rules ported from gitleaks | API keys, tokens, private keys | Credentials *do* have recognisable shapes |
|
|
222
|
+
| A size limit on staged / uploaded files | The 1.1 MB SQLite backup | One rule closes the whole "ship the database" route |
|
|
223
|
+
|
|
224
|
+
**Speed is a correctness requirement, not a nicety.** Most hosts are fail-open on
|
|
225
|
+
timeout — Claude Code's own docs say not to count on a stalled hook as a gate
|
|
226
|
+
([hooks reference](https://code.claude.com/docs/en/hooks)) — and only Cursor and
|
|
227
|
+
Hermes can be told to fail closed. A slow hook doesn't annoy you, it silently
|
|
228
|
+
stops guarding. So: no dependencies, nothing imported that isn't needed (argparse
|
|
229
|
+
only when a subcommand is given, never on the hook path), and a keyword prefilter
|
|
230
|
+
in front of the secret rules.
|
|
231
|
+
|
|
232
|
+
| Operation | Measured |
|
|
233
|
+
|---|---|
|
|
234
|
+
| Whole hook invocation | 21 ms (15 ms of it is Python starting up) |
|
|
235
|
+
| Matching work itself | 6 ms |
|
|
236
|
+
| Secret prefilter, clean payload | 0.25 ms — no regex compiled at all |
|
|
237
|
+
| Secret rules when something matches | ~1 ms |
|
|
238
|
+
| Compiling all 221 rules, if we didn't prefilter | 19.8 ms |
|
|
239
|
+
|
|
240
|
+
**In practice the timeout race isn't close.** Claude Code's default `PreToolUse`
|
|
241
|
+
timeout is **600 seconds** ([hooks reference](https://code.claude.com/docs/en/hooks)),
|
|
242
|
+
and Codex's is the same ([Codex hooks](https://developers.openai.com/codex/hooks)).
|
|
243
|
+
A 21 ms hook against a ten-minute ceiling doesn't fail open by accident — it would
|
|
244
|
+
have to *hang*: block forever on unreadable input, or catch a pathological regex.
|
|
245
|
+
The design closes those off specifically — no dependencies to hang in, lazy
|
|
246
|
+
imports, `--body-file -` blocked rather than read, a bad `patterns` entry skipped
|
|
247
|
+
rather than run. So the residual fail-open risk isn't a slow hook; it's a
|
|
248
|
+
genuinely stuck process or an adversary who can deliberately stall it.
|
|
249
|
+
|
|
250
|
+
For that, a fail-closed host (Cursor, Hermes) is the real answer — it blocks on
|
|
251
|
+
timeout regardless. If you've already set that up: well done you. You are a hero ❤️
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Hosts
|
|
256
|
+
|
|
257
|
+
The scanning is identical everywhere. What differs is how each host hands us the
|
|
258
|
+
command and how we say no.
|
|
259
|
+
|
|
260
|
+
Wiring for each is in [Getting started › Per host](#per-host); `looselips-guard
|
|
261
|
+
init` does it for you.
|
|
262
|
+
|
|
263
|
+
| Host | Integration | Status |
|
|
264
|
+
|---|---|---|
|
|
265
|
+
| Claude Code | `PreToolUse`, exit 2 | **works today** |
|
|
266
|
+
| Codex | `PreToolUse`, exit 2 | **works today** |
|
|
267
|
+
| GitHub Copilot | `PreToolUse` (PascalCase), exit 2 | **works today** |
|
|
268
|
+
| Hermes Agent | `pre_tool_call` shell hook, exit 2 | **works today** |
|
|
269
|
+
| Cursor | `beforeShellExecution`, exit 2 | **works today** |
|
|
270
|
+
| OpenClaw / OpenClaw 2 | `before_tool_call` plugin, `{ block }` | **works today** — bundled plugin |
|
|
271
|
+
| DeepSeek Harness | Claude Code / Codex hook bridge | works via bridge — unverified |
|
|
272
|
+
| opencode | `tool.execute.before`, throw | designed |
|
|
273
|
+
|
|
274
|
+
**works today** means the hook fires and `exit 2` blocks on that host — the
|
|
275
|
+
integration is wired and tested. It is not a claim about detection strength; see
|
|
276
|
+
[What it does not stop](#what-it-does-not-stop).
|
|
277
|
+
|
|
278
|
+
Two things worth knowing before you rely on this:
|
|
279
|
+
|
|
280
|
+
- **Every command-hook host is fail-open on timeout** except Hermes and Cursor,
|
|
281
|
+
which honour `fail_closed` / `failClosed` on the pre-execution hook. The window
|
|
282
|
+
is wide though — Claude Code and Codex default to a 600 s hook timeout, so a
|
|
283
|
+
21 ms hook only fails open if it truly hangs (see
|
|
284
|
+
[How it works](#how-it-works), and the per-host notes above).
|
|
285
|
+
- **opencode does not intercept subagent tool calls**
|
|
286
|
+
([open issue](https://github.com/anomalyco/opencode/issues/5894)), so a
|
|
287
|
+
delegated `gh` call bypasses the guard there. Not ours to fix, but yours to
|
|
288
|
+
know.
|
|
289
|
+
|
|
290
|
+
Antigravity is deliberately unsupported: there are open reports its hooks never
|
|
291
|
+
fire, and a guard that might not run is worse than no guard.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Config
|
|
296
|
+
|
|
297
|
+
All of this lives in **`.looselips-guard.json`** at the root of the project you are
|
|
298
|
+
guarding — the working directory the command runs in. It is never committed.
|
|
299
|
+
`.looselips-blocklist-example.json` is only a template to copy from;
|
|
300
|
+
`.looselips-guard.list` (below) is one optional data source, not the config itself.
|
|
301
|
+
|
|
302
|
+
The denylist is **generated from your own data**, not from generic PII regexes.
|
|
303
|
+
Generic rules fail here, concretely: in the incident that motivated this tool
|
|
304
|
+
the owner's own email authored 295 of 445 commits, so a "block emails" rule
|
|
305
|
+
blocks every commit they make; the only emails in tracked files were
|
|
306
|
+
`user@example.com` placeholders; and in a finance repo a UK phone regex matches
|
|
307
|
+
SEDOLs, ISINs, order ids and timestamps.
|
|
308
|
+
|
|
309
|
+
What worked was deriving the list from the ledger itself, excluding the
|
|
310
|
+
English-word collisions, and matching the rest on word boundaries — 27 leaking
|
|
311
|
+
items found across 258 issues and 306 PRs, with zero false positives.
|
|
312
|
+
|
|
313
|
+
- `values` — literal entries, listed inline in the config.
|
|
314
|
+
- `sources` — pull entries from a file instead, re-read on every scan:
|
|
315
|
+
`txt` (path, one value per line, `#` comments), `csv` (path + column),
|
|
316
|
+
`sqlite` (path + query), `env` (path, the value side of each `KEY=value`).
|
|
317
|
+
- `patterns` — regexes, matched raw (you write your own anchors), for a *format*
|
|
318
|
+
rather than a list: `\bAcct-\d{8}\b`, an internal hostname suffix. This is the
|
|
319
|
+
one place generic-regex risk is yours to own — see the warning above.
|
|
320
|
+
`add --regex '<pattern>'` appends one; `add --like 'Acct-99001122'` derives
|
|
321
|
+
`\bAcct-\d{8}\b` from an example. A pattern that won't compile is warned about
|
|
322
|
+
on stderr and skipped, never fatal. Empty by default.
|
|
323
|
+
- `allow` — collision list, for tickers that are also words (`ALL`, `ON`, `CAT`).
|
|
324
|
+
Matching is case-sensitive with word boundaries, which removes most collisions
|
|
325
|
+
before this list is needed. `allow` does not apply to `patterns`.
|
|
326
|
+
- `max_added_file_bytes` — files larger than this are blocked from `git add` and
|
|
327
|
+
from `scp`/`rsync` uploads without being read (default 500 KB).
|
|
328
|
+
- `routes` — a `{name: bool}` map of which egress routes `check()` enforces.
|
|
329
|
+
Anything not listed is checked; set one `false` to skip it. Names: `gh`,
|
|
330
|
+
`git-add`, `git-commit`, `git-push`, `curl`, `scp`, `nc`, `mcp`. Manage with
|
|
331
|
+
`looselips-guard routes [enable|disable] <name>…` rather than by hand.
|
|
332
|
+
- `mcp_tools` — regex of MCP tool names whose arguments get scanned. Default is a
|
|
333
|
+
set of write verbs (`create|post|send|comment|publish|upload|write|update|…`)
|
|
334
|
+
so reading your own data through an MCP server doesn't trip the denylist. Set
|
|
335
|
+
`".*"` to scan every MCP call, `""` or `false` to scan none.
|
|
336
|
+
- `mcp_servers` — Copilot only. A list of MCP server names (`["github", "slack"]`).
|
|
337
|
+
Copilot's tool names are `<server>-<tool>` with no prefix, so the guard can't
|
|
338
|
+
tell an MCP call from a built-in without this. On hosts that use the
|
|
339
|
+
`mcp__<server>__<tool>` convention it's unnecessary.
|
|
340
|
+
|
|
341
|
+
**Presets.** There is no shipped generic-PII pack, on purpose (see the box above
|
|
342
|
+
for why they backfire). What there is: a few opt-in starter sets of format-based
|
|
343
|
+
patterns. `looselips-guard presets` prints them with sources; nothing is added
|
|
344
|
+
until you run `add --preset`, and `init` never adds them.
|
|
345
|
+
|
|
346
|
+
| preset | patterns | from |
|
|
347
|
+
|---|---|---|
|
|
348
|
+
| `internal` | RFC 1918 IPs (`10/8`, `172.16/12`, `192.168/16`); `*.internal`/`*.corp`/`*.intranet`/`*.lan` | [RFC 1918](https://datatracker.ietf.org/doc/html/rfc1918); `.internal` is [ICANN-reserved](https://www.icann.org/en/board-activities-and-meetings/materials/approved-resolutions-special-meeting-of-the-icann-board-24-07-2024-en#section2.a) for private use |
|
|
349
|
+
| `cloud` | `arn:aws:…:<acct>:…`; `s3://…` URIs | [AWS ARN format](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html) |
|
|
350
|
+
| `k8s` | `*.svc.cluster.local`, `*.pod.cluster.local` | [Kubernetes cluster DNS](https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/) |
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
looselips-guard add --preset internal # pull one in; run again for another
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
They're a starting point, not a policy — review and trim to your environment.
|
|
357
|
+
|
|
358
|
+
Secrets are handled separately, by 221 rules ported from gitleaks, so there is
|
|
359
|
+
**nothing to install**. Regenerate them with `scripts/port_gitleaks_rules.py`.
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## What it intercepts
|
|
364
|
+
|
|
365
|
+
- `gh issue|pr|release|gist create|edit|comment` — `--body`, `--title`,
|
|
366
|
+
`--body-file <path>` (resolved and read), `--body-file -` (unreadable,
|
|
367
|
+
therefore unscannable, therefore blocked)
|
|
368
|
+
- `gh api` with a mutation, `POST`, or any `-f`/`-F` field
|
|
369
|
+
- `git add` — every path it would actually stage, by size and by content
|
|
370
|
+
- `git commit -m`
|
|
371
|
+
- `git push` — the diff of commits not yet on any remote (capped at 2 MB)
|
|
372
|
+
- `curl` / `wget` — request body (`-d`/`--data*`/`-F`/`-T`/`--json`/`--post-data`,
|
|
373
|
+
inline or `@file`), and the URL itself
|
|
374
|
+
- `scp` / `rsync` — when the destination is remote, the contents of every local
|
|
375
|
+
source file (directories walked, same size cap as `git add`); a download is
|
|
376
|
+
left alone
|
|
377
|
+
- `nc` / `ncat` / `netcat` with a port — the command line itself and any file it
|
|
378
|
+
pipes in (`cat file | nc …`, `nc … < file`)
|
|
379
|
+
- **MCP tool calls** whose name matches a write verb (`create_issue`,
|
|
380
|
+
`post_message`, …) — every string in the arguments. Tune with `mcp_tools`
|
|
381
|
+
(see [Config](#config)); reads like `query` or `list_*` are skipped by default
|
|
382
|
+
|
|
383
|
+
Each of these is a *route* you can turn off with `looselips-guard routes disable
|
|
384
|
+
<name>` — see [Getting started](#getting-started).
|
|
385
|
+
|
|
386
|
+
## Override
|
|
387
|
+
|
|
388
|
+
Blocking hard gets a tool bypassed, and then it protects nothing. A real bug
|
|
389
|
+
report may need to name the ticker that exposed the bug. So the hook prints
|
|
390
|
+
exactly what matched and where, and there are two deliberate ways through:
|
|
391
|
+
|
|
392
|
+
```bash
|
|
393
|
+
LOOSELIPS_GUARD_OK=1 gh issue create --title "…" --body-file issue.md # this one call
|
|
394
|
+
looselips-guard snooze # let the agent through here for 5 min (snooze 15 for longer)
|
|
395
|
+
looselips-guard snooze --clear # …or end the window now
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
`snooze` writes `.looselips-guard.snooze` (an epoch expiry) in the directory and
|
|
399
|
+
the hook allows matches while it's live. It **fails shut**: a missing, expired or
|
|
400
|
+
unreadable file means blocked, so a snooze only ever loosens the guard for the
|
|
401
|
+
window you asked for.
|
|
402
|
+
|
|
403
|
+
For an MCP call there's no command to prefix — use `snooze`, export
|
|
404
|
+
`LOOSELIPS_GUARD_OK=1` for the session, or narrow `mcp_tools`.
|
|
405
|
+
|
|
406
|
+
Every leak in the motivating incident was accidental. Making the deliberate case
|
|
407
|
+
cheap and the accidental case impossible is the whole design goal.
|
|
408
|
+
|
|
409
|
+
## Inbound guard (planned, opt-in)
|
|
410
|
+
|
|
411
|
+
Outbound is the product. Inbound — stopping secrets reaching the model — is
|
|
412
|
+
separate and opt-in, because **the two directions need opposite rules**: in the
|
|
413
|
+
incident that motivated this, the model was *supposed* to see the portfolio.
|
|
414
|
+
Applying the outbound denylist inbound would block the agent from working at
|
|
415
|
+
all.
|
|
416
|
+
|
|
417
|
+
So inbound guards secrets only, and redacts rather than blocks:
|
|
418
|
+
|
|
419
|
+
```
|
|
420
|
+
cat .env → cat .env | looselips-guard redact
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
That works because a pre-tool hook can rewrite tool *input*. Its hard limit:
|
|
424
|
+
**nothing can rewrite tool output**, so a read through the host's native read
|
|
425
|
+
tool (`Read`, `view`) cannot be sanitised — it warns and allows. Inbound
|
|
426
|
+
protection is best-effort by construction, and says so.
|
|
427
|
+
|
|
428
|
+
## What it does not stop
|
|
429
|
+
|
|
430
|
+
**Any transformation of the value.** Detection is a substring / regex match on
|
|
431
|
+
the plaintext payload. base64, gzip, hex, `gpg`, `rot13`, or splitting
|
|
432
|
+
`Acct-99001122` across two tool calls — none of that matches `Acct-99001122`, and
|
|
433
|
+
the guard allows it. This is inherent to a denylist and is the honest ceiling of
|
|
434
|
+
the approach: it catches *accidental plaintext*, not a determined exfiltrator.
|
|
435
|
+
For that you need network egress control or a sandbox, not a pre-tool hook.
|
|
436
|
+
|
|
437
|
+
**Parsing divergence.** For every intercepted command the guard re-implements
|
|
438
|
+
enough argument parsing to find the payload — `--body-file` resolution, `curl`
|
|
439
|
+
`@file`, `git add --dry-run`, the unpushed-diff range, walking MCP args. Anywhere
|
|
440
|
+
its model of what-will-be-sent differs from what the tool actually sends is a
|
|
441
|
+
silent bypass: exotic quoting, an encoding it doesn't decode, a redirection or
|
|
442
|
+
heredoc, an argument order the parser didn't expect. `curl --data @-` and
|
|
443
|
+
`--body-file -` (reads from stdin, which the hook can't see) are blocked
|
|
444
|
+
outright for this reason; the rest is best-effort. The
|
|
445
|
+
[test suite](tests/) includes an adversarial group, but it is
|
|
446
|
+
not exhaustive.
|
|
447
|
+
|
|
448
|
+
**Out of scope by design:**
|
|
449
|
+
|
|
450
|
+
- Anything typed into github.com in a browser — a pre-tool hook only sees
|
|
451
|
+
agent-initiated calls.
|
|
452
|
+
- Rewriting outbound payloads. Silently altering an issue body the agent wrote
|
|
453
|
+
is worse than refusing it, so outbound blocks and never edits.
|
|
454
|
+
- A `git commit` message from `$EDITOR` (no `-m`/`-F`) — the content is already
|
|
455
|
+
guarded at `git add`, but the message text isn't seen.
|
|
456
|
+
- MCP calls on Copilot when `mcp_servers` isn't set — no tool-name prefix to key
|
|
457
|
+
on, so you name the servers.
|
|
458
|
+
- Whatever tool comes next. The matcher is a short list of `argv[0]` cases plus
|
|
459
|
+
`nc` anywhere in a pipeline — built to extend, not exhaustive.
|
|
460
|
+
|
|
461
|
+
## Development
|
|
462
|
+
|
|
463
|
+
```bash
|
|
464
|
+
python3 tests/run.py # whole suite; or run one file, e.g. tests/test_matchers.py
|
|
465
|
+
git config core.hooksPath .githooks # opt in: run tests before every push
|
|
466
|
+
python3 scripts/port_gitleaks_rules.py # refresh the secret rules from upstream
|
|
467
|
+
scripts/release.sh 0.2.0 # bump, tag, push; CI publishes
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
## Test approach
|
|
471
|
+
CI (`.github/workflows/test.yml`) runs the suite on every PR and push to
|
|
472
|
+
`master` on Python 3.8 and 3.12; make it a required check in branch protection to
|
|
473
|
+
block merges on failure.
|
|
474
|
+
|
|
475
|
+
`tests/` is framework-free — `assert`-based `run_tests()` per file, shared corpus
|
|
476
|
+
in `tests/fixtures.py`; [`tests/README.md`](tests/README.md) explains the layout
|
|
477
|
+
and the two adversarial groups. Fixtures are synthetic by policy: a ticker-like
|
|
478
|
+
token beside a currency amount, a balance line, a holdings table, an oversized
|
|
479
|
+
SQLite backup. The real incident data that shaped them is never published.
|
|
480
|
+
|
|
481
|
+
## Credits
|
|
482
|
+
|
|
483
|
+
Built by the team at [Reinvently](https://reinvently.co.uk/about/).
|
|
484
|
+
|
|
485
|
+
Secret detection rules are ported from **[gitleaks](https://github.com/gitleaks/gitleaks)**
|
|
486
|
+
by **Zachary Rice**, used under the MIT Licence — see [NOTICE](NOTICE). gitleaks
|
|
487
|
+
does the hard part: 221 maintained rules, keyword prefilters and entropy
|
|
488
|
+
thresholds, refined over years of real-world false positives. looselips-guard only
|
|
489
|
+
translates them so they run in-process with no binary to install. For scanning
|
|
490
|
+
git history, use gitleaks itself.
|