leftblock 0.3.5__tar.gz → 0.4.2__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.
Files changed (30) hide show
  1. {leftblock-0.3.5 → leftblock-0.4.2}/PKG-INFO +129 -23
  2. {leftblock-0.3.5 → leftblock-0.4.2}/README.md +128 -22
  3. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock/allow.py +13 -5
  4. leftblock-0.4.2/leftblock/bypass.py +119 -0
  5. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock/cli.py +264 -36
  6. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock/core.py +7 -4
  7. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock/data/gitleaks.toml +24 -67
  8. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock/hooks.py +102 -4
  9. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock.egg-info/PKG-INFO +129 -23
  10. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock.egg-info/SOURCES.txt +3 -0
  11. leftblock-0.4.2/pyproject.toml +47 -0
  12. {leftblock-0.3.5 → leftblock-0.4.2}/tests/test_branding.py +7 -2
  13. leftblock-0.4.2/tests/test_bypasses.py +183 -0
  14. {leftblock-0.3.5 → leftblock-0.4.2}/tests/test_commit_gate.py +20 -6
  15. {leftblock-0.3.5 → leftblock-0.4.2}/tests/test_false_positives.py +10 -7
  16. leftblock-0.4.2/tests/test_kanbus_advice.py +104 -0
  17. {leftblock-0.3.5 → leftblock-0.4.2}/tests/test_local_install.py +87 -1
  18. {leftblock-0.3.5 → leftblock-0.4.2}/tests/test_recorded_overrides.py +131 -2
  19. {leftblock-0.3.5 → leftblock-0.4.2}/tests/test_sensitive_rules.py +149 -162
  20. leftblock-0.3.5/pyproject.toml +0 -24
  21. {leftblock-0.3.5 → leftblock-0.4.2}/LICENSE +0 -0
  22. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock/__init__.py +0 -0
  23. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock/merge.py +0 -0
  24. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock.egg-info/dependency_links.txt +0 -0
  25. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock.egg-info/entry_points.txt +0 -0
  26. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock.egg-info/requires.txt +0 -0
  27. {leftblock-0.3.5 → leftblock-0.4.2}/leftblock.egg-info/top_level.txt +0 -0
  28. {leftblock-0.3.5 → leftblock-0.4.2}/setup.cfg +0 -0
  29. {leftblock-0.3.5 → leftblock-0.4.2}/tests/test_default_config.py +0 -0
  30. {leftblock-0.3.5 → leftblock-0.4.2}/tests/test_install.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leftblock
3
- Version: 0.3.5
3
+ Version: 0.4.2
4
4
  Summary: LeftBlock: a pluggable inspection gate for Git commits
5
5
  License: MIT License
6
6
 
@@ -124,7 +124,7 @@ configures the integration; it does not install scanner binaries.
124
124
  In the intended Python environment:
125
125
 
126
126
  ```bash
127
- python -m pip install leftblock==0.3.5
127
+ python -m pip install leftblock==0.4.2
128
128
  python -m leftblock.cli --help
129
129
  ```
130
130
 
@@ -154,6 +154,73 @@ with the existing hook manager instead of replacing it. An earlier hook
154
154
  without the current ownership record requires a reviewed migration even
155
155
  if it is still functional.
156
156
 
157
+ #### Upgrades and the stable runtime link
158
+
159
+ When `install --local --check-only` runs from a virtualenv, it also keeps two
160
+ symlinks pointing at that environment:
161
+
162
+ - `$LEFTBLOCK_HOME/current` (default `~/.local/share/leftblock/current`) points
163
+ at the virtualenv, and every managed hook runs `current/bin/python` instead
164
+ of a versioned interpreter path.
165
+ - `~/.local/bin/leftblock` points at `current/bin/leftblock`. It is replaced
166
+ only if it is absent or already a symlink; a regular file is left alone.
167
+
168
+ To upgrade, install the new version into a new virtualenv and run
169
+ `leftblock install --local --check-only` once, in any repository. That
170
+ repoints `current`, so every repository's hook uses the new runtime. The
171
+ per-repository rules copy (in `.git/leftblock/`) is refreshed only when
172
+ you re-run the install in that repository.
173
+
174
+ If the runtime link is broken, the hook fails closed: it prints
175
+ `LeftBlock is not installed at <path>` with the reinstall steps and exits 1.
176
+ Outside a virtualenv, the hook keeps using the installing interpreter and no
177
+ links are made.
178
+
179
+ `leftblock --version` prints the installed version. A blocked check starts
180
+ with `leftblock <version>, ruleset <id>`, where the ruleset id is the first 12
181
+ hex characters of the SHA-256 of the gitleaks config file the checker names
182
+ with `--config` (`unknown` when there is none), so a report can say exactly
183
+ which rules ran.
184
+
185
+ #### Dry run
186
+
187
+ `leftblock check` is a dry run of the next commit: it inspects the staged
188
+ changes with the same policy the hook uses and changes nothing. Without
189
+ `--config` it uses the local install policy when one exists, otherwise
190
+ `.leftblock.yml`. A blocked run ends by printing `Re-run this check:
191
+ leftblock check` and the final summary line.
192
+
193
+ #### Exit codes and JSON output
194
+
195
+ `leftblock check` exits with:
196
+
197
+ | Code | Meaning |
198
+ |---|---|
199
+ | 0 | Passed (overrides may have been applied) |
200
+ | 1 | Blocked by findings |
201
+ | 2 | A checker or the configuration could not complete, including an invalid `.leftblock-allow.yml`; this wins over 1 when both occur |
202
+
203
+ `leftblock check --format json` also prints one JSON object on stdout and
204
+ leaves the human-readable stderr output unchanged:
205
+
206
+ ```json
207
+ {
208
+ "version": "0.3.5",
209
+ "result": "blocked",
210
+ "findings": [{"file": "page.tsx", "line": 2, "rule": "leftblock-personal-email",
211
+ "id": "3f2a9c1e7b4d5a60", "description": "Personal email address"}],
212
+ "overrides_applied": [{"file": "a.md", "rule": "leftblock-home-path-posix",
213
+ "id": "0123456789abcdef", "reason": "Documented example path"}],
214
+ "errors": [],
215
+ "override_command": "leftblock allow 3f2a9c1e7b4d5a60 --reason \"<why this is not sensitive>\""
216
+ }
217
+ ```
218
+
219
+ `result` is `passed`, `blocked` or `error`; `errors` is `[{checker, message}]`;
220
+ `override_command` is `null` when nothing can be overridden. Like the text
221
+ output, it never contains matched values or source lines. The git hooks only
222
+ need a nonzero status, so they are unaffected.
223
+
157
224
  To run a noninteractive staged check with an explicit policy:
158
225
 
159
226
  ```bash
@@ -172,8 +239,9 @@ metadata or another private location.
172
239
 
173
240
  A block stays the default. If a finding is a false alarm (for example a
174
241
  public role address in a privacy notice), record a reviewed override. The
175
- blocked check prints an `id` for each finding, never the matched value, and
176
- the exact command to run:
242
+ blocked check prints each finding as `file:LINE: rule [id …] (description)`,
243
+ where the description is the rule's own text from the ruleset, never the
244
+ matched value, source line or secret, and then the exact command to run:
177
245
 
178
246
  ```bash
179
247
  leftblock allow ID [ID ...] --reason "Why this is not sensitive"
@@ -190,10 +258,14 @@ allow:
190
258
  - id: "3f2a9c1e7b4d5a60"
191
259
  rule: "leftblock-personal-email"
192
260
  file: "apps/web/privacy/page.tsx"
261
+ kind: "Personal email address"
193
262
  reason: "Public role address required in the privacy notice."
194
263
  added: 2026-10-20
195
264
  ```
196
265
 
266
+ - **Category for reviewers.** `kind` is the rule's description, written so a
267
+ reviewer of the pull request sees what kind of finding was approved. It is
268
+ informational: it is not part of matching and may be omitted.
197
269
  - **Scoped to line content.** The id hashes the rule, the file, and the exact
198
270
  staged content of the flagged lines. Editing that line, or adding another
199
271
  finding anywhere, blocks again.
@@ -210,10 +282,34 @@ allow:
210
282
  policy when present, otherwise `.leftblock.yml`; pass the same `--config`
211
283
  you use for `check` if it differs.
212
284
 
213
- This mechanism is separate from Gitleaks' own suppressions: inline
214
- `gitleaks:allow` markers and a root `.gitleaksignore` are still honoured by
215
- Gitleaks itself, and they are outside this mechanism's reason, scope, and
216
- review trail.
285
+ #### Gitleaks' own suppressions
286
+
287
+ Gitleaks skips a line carrying its inline allow comment and any entry in a
288
+ `.gitleaksignore` file, with no recorded reason. LeftBlock closes the silent
289
+ paths it can see: it reports, as ordinary findings that carry an id and can be
290
+ overridden only through a recorded reason as above,
291
+
292
+ - `leftblock-gitleaks-allow-marker`: any staged added line containing the
293
+ Gitleaks allow comment (the word `gitleaks`, a colon, then `allow`); and
294
+ - `leftblock-gitleaksignore-change`: any staged addition or edit of a
295
+ `.gitleaksignore` file at any path (removing one is not a finding).
296
+
297
+ Both are read from the staged diff (the merge-scoped index during a merge).
298
+ Removing a suppression is the fix, not a finding.
299
+
300
+ **Residual risk.** Tested with Gitleaks 8.30: a `.gitleaksignore` in the
301
+ repository root that already exists, committed or only in the working tree, is
302
+ still honoured by `protect --staged`, and no option turns that off. Passing
303
+ `--gitleaks-ignore-path` with an empty file or directory adds to the sources it
304
+ reads; it does not replace the root file. Ignore files in subdirectories and
305
+ ones that exist only in the index are not read. LeftBlock therefore cannot
306
+ stop an ignore file that was already there before the check ran, but every
307
+ `leftblock check` warns when a root `.gitleaksignore` exists, so its skips are
308
+ never silent. Review it (`git ls-files '*.gitleaksignore'`), and treat a file
309
+ that is untracked or edited outside the index as outside this check. The
310
+ installed policy passes `--ignore-gitleaks-allow`, so Gitleaks still scans
311
+ lines that carry the inline comment: a real finding on such a line is reported
312
+ alongside the marker finding.
217
313
 
218
314
  During an in-progress merge, command checkers skip only regular files whose
219
315
  complete blob and mode already exist at the same path in a parent. Changed
@@ -257,6 +353,12 @@ Python environment or Gitleaks executable. For manual composition, run
257
353
  `leftblock hook "$1"` in the `commit-msg` flow when receipts are needed.
258
354
  Preserve existing behavior and propagate any failure's exit status.
259
355
 
356
+ A receipt records `Inspection-result: clean` when nothing was found, and
357
+ `Inspection-result: allowed:N` when every finding was covered by `N` staged,
358
+ reasoned overrides (see [Overriding a false alarm](#overriding-a-false-alarm)).
359
+ Neither needs a passphrase. An interactive passphrase override of unrecorded
360
+ findings is still recorded as `override:N-checkers`.
361
+
260
362
  Receipts use HMAC trailers, not Git GPG/SSH signatures; they do not appear
261
363
  in `git log --show-signature`. Every machine running `leftblock verify`
262
364
  needs the same secret, stored by default at `~/.config/leftblock/secret`
@@ -295,29 +397,33 @@ PEM private keys, and other standard secret formats.
295
397
 
296
398
  | Rule | What it catches |
297
399
  |---|---|
298
- | `leftblock-personal-email` | Personal email addresses (infrastructure identities like `git@github.com`, `@users.noreply.github.com` and `@example.*` placeholders are allowlisted) |
400
+ | `leftblock-personal-email` | Personal email addresses (infrastructure identities like `git@github.com`, `@users.noreply.github.com`, no-reply addresses and `@example.*` placeholders are allowlisted) |
299
401
  | `leftblock-home-path-posix` | Home-folder paths `/Users/<name>`, `/home/<name>` |
300
402
  | `leftblock-home-path-windows` | `C:\Users\<name>` and `\Users\<name>` |
301
- | `leftblock-session-uuid` | Potential session UUIDs, with narrow exceptions for canonical Kanbus structural fields and example values |
302
- | `leftblock-actor-id` | `actor_id` / `user_id` / `author_id` JSON values |
403
+ | `leftblock-session-uuid` | Potential session UUIDs (not flagged in Kanbus board data; example values and the `.kanbus.yml` `project_id` line are also allowed) |
404
+ | `leftblock-actor-id` | `actor_id` / `user_id` / `author_id` JSON values (not flagged in Kanbus board data) |
303
405
  | `leftblock-openai-key` | `sk-`, `sk-proj-`, `sk-svcacct-` OpenAI keys |
304
406
  | `leftblock-anthropic-key` | `sk-ant-` Anthropic keys |
305
407
  | `leftblock-eth-private-key` | Ethereum-style private keys (`0x` + 64 hex) |
306
408
  | `leftblock-wif-key` | Bitcoin WIF private keys (compressed `K/L/M…`, uncompressed `5…`) |
307
409
  | `leftblock-bip39-mnemonic` | BIP-39 seed phrases (12–24 words) in seed/mnemonic/recovery context |
308
410
 
309
- Kanbus exceptions apply to complete identifier property lines in
310
- `project/issues/*.json` and `project/events/*.json`. Event actor attribution
311
- must occupy a root property line in the canonical two-space format.
312
- Coordination claim references must occupy the canonical payload depth.
313
- The legacy `project/.cache/index.json` allows only canonical issue ID/parent
314
- lines and issue-filename keys with numeric modification times; cached
315
- descriptions and comments still receive the normal checks.
316
- Provider session fields, nested actor values, prose and arbitrary arrays
317
- remain scanned. A project prefix alone grants no exception, and a second
318
- value on the same line cannot inherit one. These checks recognize syntax;
319
- they do not validate a full JSON schema or prove an identifier is public.
320
- Other secret rules still apply to values in the accepted fields.
411
+ A blocked check prints a one-line hint after each rule's findings (once per
412
+ rule): use a role or placeholder address for personal emails; a relative path,
413
+ `$HOME` or `~` for home paths; drop or describe session and actor identifiers;
414
+ and for API, private and wallet keys, remove the value **and rotate the key**,
415
+ since it may already be exposed. Findings in Kanbus board data
416
+ (`project/issues/*.json`, `project/events/*.json`) also say that Kanbus writes
417
+ those files and that the content should be fixed through `kbs` (for example
418
+ `kbs update` or `kbs comment`) rather than by hand.
419
+
420
+ Kanbus is the mandated task tracker, so identifiers in its board data (actor
421
+ ids, session UUIDs, and UUIDs in any field or prose) are not sensitive and never
422
+ block a `kbs` write. Board data means `project/issues/*.json`,
423
+ `project/events/*.json` and `project/.cache/index.json`; the exception is
424
+ limited to the session-UUID and actor-id rules, so personal emails, home paths
425
+ and API or wallet keys in those files are still detected, and the same content
426
+ in any other path is still flagged.
321
427
 
322
428
  ### Crypto wallet material: what is and is not flagged
323
429
 
@@ -91,7 +91,7 @@ configures the integration; it does not install scanner binaries.
91
91
  In the intended Python environment:
92
92
 
93
93
  ```bash
94
- python -m pip install leftblock==0.3.5
94
+ python -m pip install leftblock==0.4.2
95
95
  python -m leftblock.cli --help
96
96
  ```
97
97
 
@@ -121,6 +121,73 @@ with the existing hook manager instead of replacing it. An earlier hook
121
121
  without the current ownership record requires a reviewed migration even
122
122
  if it is still functional.
123
123
 
124
+ #### Upgrades and the stable runtime link
125
+
126
+ When `install --local --check-only` runs from a virtualenv, it also keeps two
127
+ symlinks pointing at that environment:
128
+
129
+ - `$LEFTBLOCK_HOME/current` (default `~/.local/share/leftblock/current`) points
130
+ at the virtualenv, and every managed hook runs `current/bin/python` instead
131
+ of a versioned interpreter path.
132
+ - `~/.local/bin/leftblock` points at `current/bin/leftblock`. It is replaced
133
+ only if it is absent or already a symlink; a regular file is left alone.
134
+
135
+ To upgrade, install the new version into a new virtualenv and run
136
+ `leftblock install --local --check-only` once, in any repository. That
137
+ repoints `current`, so every repository's hook uses the new runtime. The
138
+ per-repository rules copy (in `.git/leftblock/`) is refreshed only when
139
+ you re-run the install in that repository.
140
+
141
+ If the runtime link is broken, the hook fails closed: it prints
142
+ `LeftBlock is not installed at <path>` with the reinstall steps and exits 1.
143
+ Outside a virtualenv, the hook keeps using the installing interpreter and no
144
+ links are made.
145
+
146
+ `leftblock --version` prints the installed version. A blocked check starts
147
+ with `leftblock <version>, ruleset <id>`, where the ruleset id is the first 12
148
+ hex characters of the SHA-256 of the gitleaks config file the checker names
149
+ with `--config` (`unknown` when there is none), so a report can say exactly
150
+ which rules ran.
151
+
152
+ #### Dry run
153
+
154
+ `leftblock check` is a dry run of the next commit: it inspects the staged
155
+ changes with the same policy the hook uses and changes nothing. Without
156
+ `--config` it uses the local install policy when one exists, otherwise
157
+ `.leftblock.yml`. A blocked run ends by printing `Re-run this check:
158
+ leftblock check` and the final summary line.
159
+
160
+ #### Exit codes and JSON output
161
+
162
+ `leftblock check` exits with:
163
+
164
+ | Code | Meaning |
165
+ |---|---|
166
+ | 0 | Passed (overrides may have been applied) |
167
+ | 1 | Blocked by findings |
168
+ | 2 | A checker or the configuration could not complete, including an invalid `.leftblock-allow.yml`; this wins over 1 when both occur |
169
+
170
+ `leftblock check --format json` also prints one JSON object on stdout and
171
+ leaves the human-readable stderr output unchanged:
172
+
173
+ ```json
174
+ {
175
+ "version": "0.3.5",
176
+ "result": "blocked",
177
+ "findings": [{"file": "page.tsx", "line": 2, "rule": "leftblock-personal-email",
178
+ "id": "3f2a9c1e7b4d5a60", "description": "Personal email address"}],
179
+ "overrides_applied": [{"file": "a.md", "rule": "leftblock-home-path-posix",
180
+ "id": "0123456789abcdef", "reason": "Documented example path"}],
181
+ "errors": [],
182
+ "override_command": "leftblock allow 3f2a9c1e7b4d5a60 --reason \"<why this is not sensitive>\""
183
+ }
184
+ ```
185
+
186
+ `result` is `passed`, `blocked` or `error`; `errors` is `[{checker, message}]`;
187
+ `override_command` is `null` when nothing can be overridden. Like the text
188
+ output, it never contains matched values or source lines. The git hooks only
189
+ need a nonzero status, so they are unaffected.
190
+
124
191
  To run a noninteractive staged check with an explicit policy:
125
192
 
126
193
  ```bash
@@ -139,8 +206,9 @@ metadata or another private location.
139
206
 
140
207
  A block stays the default. If a finding is a false alarm (for example a
141
208
  public role address in a privacy notice), record a reviewed override. The
142
- blocked check prints an `id` for each finding, never the matched value, and
143
- the exact command to run:
209
+ blocked check prints each finding as `file:LINE: rule [id …] (description)`,
210
+ where the description is the rule's own text from the ruleset, never the
211
+ matched value, source line or secret, and then the exact command to run:
144
212
 
145
213
  ```bash
146
214
  leftblock allow ID [ID ...] --reason "Why this is not sensitive"
@@ -157,10 +225,14 @@ allow:
157
225
  - id: "3f2a9c1e7b4d5a60"
158
226
  rule: "leftblock-personal-email"
159
227
  file: "apps/web/privacy/page.tsx"
228
+ kind: "Personal email address"
160
229
  reason: "Public role address required in the privacy notice."
161
230
  added: 2026-10-20
162
231
  ```
163
232
 
233
+ - **Category for reviewers.** `kind` is the rule's description, written so a
234
+ reviewer of the pull request sees what kind of finding was approved. It is
235
+ informational: it is not part of matching and may be omitted.
164
236
  - **Scoped to line content.** The id hashes the rule, the file, and the exact
165
237
  staged content of the flagged lines. Editing that line, or adding another
166
238
  finding anywhere, blocks again.
@@ -177,10 +249,34 @@ allow:
177
249
  policy when present, otherwise `.leftblock.yml`; pass the same `--config`
178
250
  you use for `check` if it differs.
179
251
 
180
- This mechanism is separate from Gitleaks' own suppressions: inline
181
- `gitleaks:allow` markers and a root `.gitleaksignore` are still honoured by
182
- Gitleaks itself, and they are outside this mechanism's reason, scope, and
183
- review trail.
252
+ #### Gitleaks' own suppressions
253
+
254
+ Gitleaks skips a line carrying its inline allow comment and any entry in a
255
+ `.gitleaksignore` file, with no recorded reason. LeftBlock closes the silent
256
+ paths it can see: it reports, as ordinary findings that carry an id and can be
257
+ overridden only through a recorded reason as above,
258
+
259
+ - `leftblock-gitleaks-allow-marker`: any staged added line containing the
260
+ Gitleaks allow comment (the word `gitleaks`, a colon, then `allow`); and
261
+ - `leftblock-gitleaksignore-change`: any staged addition or edit of a
262
+ `.gitleaksignore` file at any path (removing one is not a finding).
263
+
264
+ Both are read from the staged diff (the merge-scoped index during a merge).
265
+ Removing a suppression is the fix, not a finding.
266
+
267
+ **Residual risk.** Tested with Gitleaks 8.30: a `.gitleaksignore` in the
268
+ repository root that already exists, committed or only in the working tree, is
269
+ still honoured by `protect --staged`, and no option turns that off. Passing
270
+ `--gitleaks-ignore-path` with an empty file or directory adds to the sources it
271
+ reads; it does not replace the root file. Ignore files in subdirectories and
272
+ ones that exist only in the index are not read. LeftBlock therefore cannot
273
+ stop an ignore file that was already there before the check ran, but every
274
+ `leftblock check` warns when a root `.gitleaksignore` exists, so its skips are
275
+ never silent. Review it (`git ls-files '*.gitleaksignore'`), and treat a file
276
+ that is untracked or edited outside the index as outside this check. The
277
+ installed policy passes `--ignore-gitleaks-allow`, so Gitleaks still scans
278
+ lines that carry the inline comment: a real finding on such a line is reported
279
+ alongside the marker finding.
184
280
 
185
281
  During an in-progress merge, command checkers skip only regular files whose
186
282
  complete blob and mode already exist at the same path in a parent. Changed
@@ -224,6 +320,12 @@ Python environment or Gitleaks executable. For manual composition, run
224
320
  `leftblock hook "$1"` in the `commit-msg` flow when receipts are needed.
225
321
  Preserve existing behavior and propagate any failure's exit status.
226
322
 
323
+ A receipt records `Inspection-result: clean` when nothing was found, and
324
+ `Inspection-result: allowed:N` when every finding was covered by `N` staged,
325
+ reasoned overrides (see [Overriding a false alarm](#overriding-a-false-alarm)).
326
+ Neither needs a passphrase. An interactive passphrase override of unrecorded
327
+ findings is still recorded as `override:N-checkers`.
328
+
227
329
  Receipts use HMAC trailers, not Git GPG/SSH signatures; they do not appear
228
330
  in `git log --show-signature`. Every machine running `leftblock verify`
229
331
  needs the same secret, stored by default at `~/.config/leftblock/secret`
@@ -262,29 +364,33 @@ PEM private keys, and other standard secret formats.
262
364
 
263
365
  | Rule | What it catches |
264
366
  |---|---|
265
- | `leftblock-personal-email` | Personal email addresses (infrastructure identities like `git@github.com`, `@users.noreply.github.com` and `@example.*` placeholders are allowlisted) |
367
+ | `leftblock-personal-email` | Personal email addresses (infrastructure identities like `git@github.com`, `@users.noreply.github.com`, no-reply addresses and `@example.*` placeholders are allowlisted) |
266
368
  | `leftblock-home-path-posix` | Home-folder paths `/Users/<name>`, `/home/<name>` |
267
369
  | `leftblock-home-path-windows` | `C:\Users\<name>` and `\Users\<name>` |
268
- | `leftblock-session-uuid` | Potential session UUIDs, with narrow exceptions for canonical Kanbus structural fields and example values |
269
- | `leftblock-actor-id` | `actor_id` / `user_id` / `author_id` JSON values |
370
+ | `leftblock-session-uuid` | Potential session UUIDs (not flagged in Kanbus board data; example values and the `.kanbus.yml` `project_id` line are also allowed) |
371
+ | `leftblock-actor-id` | `actor_id` / `user_id` / `author_id` JSON values (not flagged in Kanbus board data) |
270
372
  | `leftblock-openai-key` | `sk-`, `sk-proj-`, `sk-svcacct-` OpenAI keys |
271
373
  | `leftblock-anthropic-key` | `sk-ant-` Anthropic keys |
272
374
  | `leftblock-eth-private-key` | Ethereum-style private keys (`0x` + 64 hex) |
273
375
  | `leftblock-wif-key` | Bitcoin WIF private keys (compressed `K/L/M…`, uncompressed `5…`) |
274
376
  | `leftblock-bip39-mnemonic` | BIP-39 seed phrases (12–24 words) in seed/mnemonic/recovery context |
275
377
 
276
- Kanbus exceptions apply to complete identifier property lines in
277
- `project/issues/*.json` and `project/events/*.json`. Event actor attribution
278
- must occupy a root property line in the canonical two-space format.
279
- Coordination claim references must occupy the canonical payload depth.
280
- The legacy `project/.cache/index.json` allows only canonical issue ID/parent
281
- lines and issue-filename keys with numeric modification times; cached
282
- descriptions and comments still receive the normal checks.
283
- Provider session fields, nested actor values, prose and arbitrary arrays
284
- remain scanned. A project prefix alone grants no exception, and a second
285
- value on the same line cannot inherit one. These checks recognize syntax;
286
- they do not validate a full JSON schema or prove an identifier is public.
287
- Other secret rules still apply to values in the accepted fields.
378
+ A blocked check prints a one-line hint after each rule's findings (once per
379
+ rule): use a role or placeholder address for personal emails; a relative path,
380
+ `$HOME` or `~` for home paths; drop or describe session and actor identifiers;
381
+ and for API, private and wallet keys, remove the value **and rotate the key**,
382
+ since it may already be exposed. Findings in Kanbus board data
383
+ (`project/issues/*.json`, `project/events/*.json`) also say that Kanbus writes
384
+ those files and that the content should be fixed through `kbs` (for example
385
+ `kbs update` or `kbs comment`) rather than by hand.
386
+
387
+ Kanbus is the mandated task tracker, so identifiers in its board data (actor
388
+ ids, session UUIDs, and UUIDs in any field or prose) are not sensitive and never
389
+ block a `kbs` write. Board data means `project/issues/*.json`,
390
+ `project/events/*.json` and `project/.cache/index.json`; the exception is
391
+ limited to the session-UUID and actor-id rules, so personal emails, home paths
392
+ and API or wallet keys in those files are still detected, and the same content
393
+ in any other path is still flagged.
288
394
 
289
395
  ### Crypto wallet material: what is and is not flagged
290
396
 
@@ -19,7 +19,8 @@ ALLOW_FILE = ".leftblock-allow.yml"
19
19
  MIN_REASON = 10
20
20
  ALLOW_HEADER = (
21
21
  "# LeftBlock overrides: each entry approves one finding (one rule on one exact\n"
22
- "# line content in one file). Changing that line re-blocks it.\n"
22
+ "# line content in one file). Changing that line re-blocks it. `kind` is the\n"
23
+ "# rule's category, shown for reviewers; it is not part of matching.\n"
23
24
  )
24
25
 
25
26
 
@@ -52,12 +53,14 @@ def finding_id(repo_root, rule, file, start, end):
52
53
 
53
54
 
54
55
  def attach_ids(repo_root, results):
55
- """Replace StartLine/EndLine on findings with an id (when computable)."""
56
+ """Replace StartLine/EndLine on findings with `line` and an id (when computable)."""
56
57
  for result in results:
57
58
  if result.error or not isinstance(result.findings, list):
58
59
  continue
59
60
  for finding in result.findings:
60
61
  start, end = finding.pop("StartLine", None), finding.pop("EndLine", None)
62
+ if type(start) is int and start >= 1:
63
+ finding["line"] = start
61
64
  if finding.get("File") == ALLOW_FILE:
62
65
  continue # the allow file can never approve itself
63
66
  ident = finding_id(
@@ -145,6 +148,8 @@ def _render(entries):
145
148
  out.append(f" - id: {_quote(e['id'])}\n")
146
149
  out.append(f" rule: {_quote(e['rule'])}\n")
147
150
  out.append(f" file: {_quote(e['file'])}\n")
151
+ if e.get("kind"):
152
+ out.append(f" kind: {_quote(e['kind'])}\n")
148
153
  out.append(f" reason: {_quote(' '.join(e['reason'].split()))}\n")
149
154
  if added:
150
155
  plain = re.fullmatch(r"\d{4}-\d{2}-\d{2}", added)
@@ -153,7 +158,7 @@ def _render(entries):
153
158
 
154
159
 
155
160
  def record_overrides(repo_root, findings, reason):
156
- """Append entries for findings (dicts with id/RuleID/File) to the allow file.
161
+ """Append entries for findings (dicts with id/RuleID/File, optional kind) to the allow file.
157
162
 
158
163
  Returns the findings newly recorded. The working-tree file must be valid.
159
164
  """
@@ -169,10 +174,13 @@ def record_overrides(repo_root, findings, reason):
169
174
  if finding["id"] in known:
170
175
  continue
171
176
  known.add(finding["id"])
172
- entries.append({
177
+ entry = {
173
178
  "id": finding["id"], "rule": finding["RuleID"], "file": finding["File"],
174
179
  "reason": reason, "added": today,
175
- })
180
+ }
181
+ if finding.get("kind"):
182
+ entry["kind"] = finding["kind"] # informational, for reviewers only
183
+ entries.append(entry)
176
184
  added.append(finding)
177
185
  with open(path, "w", encoding="utf-8") as f:
178
186
  f.write(_render(entries))
@@ -0,0 +1,119 @@
1
+ """Findings for the two ways Gitleaks can be told to skip content silently.
2
+
3
+ Gitleaks honours an inline allow comment and `.gitleaksignore`
4
+ files without any recorded reason. LeftBlock reports staged additions of
5
+ either as findings of its own, so they are blocked by default and can be
6
+ overridden only through a recorded, reviewed override like any other finding.
7
+ """
8
+
9
+ import re
10
+ import subprocess
11
+
12
+ # Split so this repository does not trip its own check.
13
+ MARKER = "gitleaks" + ":allow"
14
+ IGNORE_FILE = ".gitleaksignore"
15
+ MARKER_RULE = "leftblock-gitleaks-allow-marker"
16
+ IGNORE_RULE = "leftblock-gitleaksignore-change"
17
+ # Descriptions are written into the allow file as `kind`, so they must not
18
+ # contain the marker themselves.
19
+ RULE_DESCRIPTIONS = {
20
+ MARKER_RULE: "Inline Gitleaks allow comment (skips the scanner with no recorded reason)",
21
+ IGNORE_RULE: "Change to .gitleaksignore (skips the scanner with no recorded reason)",
22
+ }
23
+
24
+ _HUNK = re.compile(r"^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
25
+ _ESCAPES = {"a": 7, "b": 8, "f": 12, "n": 10, "r": 13, "t": 9, "v": 11, '"': 34, "\\": 92}
26
+
27
+
28
+ class BypassScanError(Exception):
29
+ pass
30
+
31
+
32
+ def _git(repo_root, env, *args):
33
+ proc = subprocess.run(
34
+ ["git", *args], cwd=repo_root, env=env, capture_output=True, check=False,
35
+ stdin=subprocess.DEVNULL,
36
+ )
37
+ if proc.returncode:
38
+ raise BypassScanError("git " + args[0] + " failed")
39
+ return proc.stdout
40
+
41
+
42
+ def _unquote(path):
43
+ """Decode a path as Git prints it (C-style quoting when it has odd bytes)."""
44
+ if not (path.startswith(b'"') and path.endswith(b'"') and len(path) >= 2):
45
+ return path
46
+ out, i, body = bytearray(), 0, path[1:-1]
47
+ while i < len(body):
48
+ c = body[i]
49
+ if c != 0x5C:
50
+ out.append(c)
51
+ i += 1
52
+ elif body[i + 1:i + 2].isdigit() and re.fullmatch(rb"[0-7]{3}", body[i + 1:i + 4]):
53
+ out.append(int(body[i + 1:i + 4], 8))
54
+ i += 4
55
+ else:
56
+ out.append(_ESCAPES.get(chr(body[i + 1]), body[i + 1]))
57
+ i += 2
58
+ return bytes(out)
59
+
60
+
61
+ def _added_lines(diff):
62
+ """Yield (path, line_number, text) for each added line in a -U0 patch.
63
+
64
+ Hunk line counts decide which lines are content, so an added line that
65
+ itself starts with "++" or "@@" cannot be mistaken for a header.
66
+ """
67
+ path, line, old_left, new_left = None, 0, 0, 0
68
+ for raw in diff.split(b"\n"):
69
+ if old_left or new_left:
70
+ if raw.startswith(b"-"):
71
+ old_left -= 1
72
+ elif raw.startswith(b"+"):
73
+ new_left -= 1
74
+ if path is not None:
75
+ yield path, line, raw[1:]
76
+ line += 1
77
+ continue
78
+ if raw.startswith(b"+++ "):
79
+ target = _unquote(raw[4:])
80
+ path = None if target == b"/dev/null" else target[2:].decode("utf-8", "replace")
81
+ elif raw.startswith(b"@@"):
82
+ hunk = _HUNK.match(raw.decode("utf-8", "replace"))
83
+ if hunk:
84
+ old_left = int(hunk.group(1) or 1)
85
+ line = int(hunk.group(2))
86
+ new_left = int(hunk.group(3) or 1)
87
+
88
+
89
+ def bypass_findings(repo_root, env=None):
90
+ """Findings (File, RuleID, StartLine, EndLine, description) for staged bypasses.
91
+
92
+ Reads the staged diff through `env`, which carries the merge-scoped index
93
+ when one is in use. Raises BypassScanError when the diff cannot be read.
94
+ """
95
+ diff = _git(
96
+ repo_root, env, "-c", "core.quotePath=false", "diff", "--cached", "-U0",
97
+ "--no-color", "--no-ext-diff", "--no-renames", "--text",
98
+ )
99
+ findings = []
100
+ for path, number, text in _added_lines(diff):
101
+ if MARKER.encode() in text:
102
+ findings.append({
103
+ "File": path, "RuleID": MARKER_RULE, "StartLine": number, "EndLine": number,
104
+ "description": RULE_DESCRIPTIONS[MARKER_RULE],
105
+ })
106
+ names = _git(
107
+ repo_root, env, "diff", "--cached", "--name-only", "-z", "--no-renames",
108
+ "--diff-filter=AMT",
109
+ )
110
+ for raw in names.split(b"\0"):
111
+ path = raw.decode("utf-8", "replace")
112
+ if path and (path == IGNORE_FILE or path.endswith("/" + IGNORE_FILE)):
113
+ blob = _git(repo_root, env, "show", ":" + path)
114
+ lines = len(blob.decode("utf-8", "replace").split("\n"))
115
+ findings.append({
116
+ "File": path, "RuleID": IGNORE_RULE, "StartLine": 1, "EndLine": lines,
117
+ "description": RULE_DESCRIPTIONS[IGNORE_RULE],
118
+ })
119
+ return findings