leftblock 0.3.2__py3-none-any.whl
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.
- leftblock/__init__.py +1 -0
- leftblock/cli.py +444 -0
- leftblock/core.py +162 -0
- leftblock/data/gitleaks.toml +245 -0
- leftblock/hooks.py +298 -0
- leftblock/merge.py +172 -0
- leftblock-0.3.2.dist-info/METADATA +390 -0
- leftblock-0.3.2.dist-info/RECORD +12 -0
- leftblock-0.3.2.dist-info/WHEEL +5 -0
- leftblock-0.3.2.dist-info/entry_points.txt +2 -0
- leftblock-0.3.2.dist-info/licenses/LICENSE +21 -0
- leftblock-0.3.2.dist-info/top_level.txt +1 -0
leftblock/merge.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Conservatively scope command checkers during an in-progress merge.
|
|
2
|
+
|
|
3
|
+
Only regular files whose complete blob AND mode already exist at the same path
|
|
4
|
+
in a merge parent are omitted from the staged diff. This avoids reporting an
|
|
5
|
+
unchanged imported file as newly introduced material. It does not establish that
|
|
6
|
+
the inherited content is safe.
|
|
7
|
+
|
|
8
|
+
Modified/resolved files, renamed paths absent from every parent, binary changes
|
|
9
|
+
and submodule pointers retain their normal staged representation. In particular,
|
|
10
|
+
we never remove individual known lines: their new context or combination can
|
|
11
|
+
create a sensitive value. Any scoping error falls back to the full staged scan.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
from leftblock.core import index_environment
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MergeScopeError(Exception):
|
|
23
|
+
"""A safe temporary index could not be constructed."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _git(repo_root, *args, env=None, input=None):
|
|
27
|
+
proc = subprocess.run(
|
|
28
|
+
["git", *args], cwd=repo_root, env=index_environment(env), input=input,
|
|
29
|
+
capture_output=True, check=False,
|
|
30
|
+
)
|
|
31
|
+
if proc.returncode:
|
|
32
|
+
# Do not include repository contents or raw Git diagnostics.
|
|
33
|
+
raise MergeScopeError("git " + args[0] + " failed")
|
|
34
|
+
return proc.stdout
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def merge_parents(repo_root):
|
|
38
|
+
"""Return MERGE_HEAD commit IDs, or [] outside an in-progress merge."""
|
|
39
|
+
path = _git(repo_root, "rev-parse", "--git-path", "MERGE_HEAD").decode().strip()
|
|
40
|
+
if not path:
|
|
41
|
+
raise MergeScopeError("Missing merge state path")
|
|
42
|
+
if not os.path.isabs(path):
|
|
43
|
+
path = os.path.join(repo_root, path)
|
|
44
|
+
try:
|
|
45
|
+
with open(path) as stream:
|
|
46
|
+
heads = [line.strip() for line in stream if line.strip()]
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
return []
|
|
49
|
+
if not heads or any(re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", head) is None for head in heads):
|
|
50
|
+
raise MergeScopeError("Invalid merge state")
|
|
51
|
+
return heads
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def merge_state(repo_root):
|
|
55
|
+
"""Pin the current parent commits; callers recheck after scoped inspection."""
|
|
56
|
+
parents = merge_parents(repo_root)
|
|
57
|
+
if not parents:
|
|
58
|
+
return None
|
|
59
|
+
head = _git(repo_root, "rev-parse", "--verify", "HEAD^{commit}").decode().strip()
|
|
60
|
+
if re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", head) is None:
|
|
61
|
+
raise MergeScopeError("Invalid HEAD")
|
|
62
|
+
for parent in parents:
|
|
63
|
+
resolved = _git(repo_root, "rev-parse", "--verify", parent + "^{commit}").decode().strip()
|
|
64
|
+
if resolved != parent:
|
|
65
|
+
raise MergeScopeError("Invalid merge parent")
|
|
66
|
+
return head, tuple(parents)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def checker_environment(scoped_index=None):
|
|
70
|
+
"""Expose rename content, optionally using the private merge index."""
|
|
71
|
+
env = dict(os.environ)
|
|
72
|
+
if scoped_index:
|
|
73
|
+
env["GIT_INDEX_FILE"] = scoped_index
|
|
74
|
+
env = index_environment(env)
|
|
75
|
+
count = int(env.get("GIT_CONFIG_COUNT", "0"))
|
|
76
|
+
if count < 0:
|
|
77
|
+
raise MergeScopeError("Invalid Git environment")
|
|
78
|
+
env["GIT_CONFIG_KEY_" + str(count)] = "diff.renames"
|
|
79
|
+
env["GIT_CONFIG_VALUE_" + str(count)] = "false"
|
|
80
|
+
env["GIT_CONFIG_COUNT"] = str(count + 1)
|
|
81
|
+
return env
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_ls(data, staged=False):
|
|
85
|
+
"""Parse NUL-separated Git index/tree records without interpreting paths."""
|
|
86
|
+
entries = {}
|
|
87
|
+
for record in data.split(b"\0"):
|
|
88
|
+
if not record:
|
|
89
|
+
continue
|
|
90
|
+
meta, separator, path = record.partition(b"\t")
|
|
91
|
+
fields = meta.split()
|
|
92
|
+
if not separator or not path or len(fields) != 3 or path in entries:
|
|
93
|
+
raise MergeScopeError("Invalid Git entry")
|
|
94
|
+
if staged:
|
|
95
|
+
mode, sha, stage = fields
|
|
96
|
+
if stage != b"0":
|
|
97
|
+
raise MergeScopeError("Unresolved index")
|
|
98
|
+
else:
|
|
99
|
+
mode, kind, sha = fields
|
|
100
|
+
if kind not in (b"blob", b"commit"):
|
|
101
|
+
raise MergeScopeError("Unsupported Git entry")
|
|
102
|
+
if re.fullmatch(rb"[0-7]{6}", mode) is None or re.fullmatch(rb"[0-9a-f]{40}|[0-9a-f]{64}", sha) is None:
|
|
103
|
+
raise MergeScopeError("Invalid Git entry")
|
|
104
|
+
entries[path] = (mode, sha)
|
|
105
|
+
return entries
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def index_entries(repo_root, scoped_index):
|
|
109
|
+
"""Canonical staged entries, also used to reject checker index mutations."""
|
|
110
|
+
return _parse_ls(
|
|
111
|
+
_git(repo_root, "ls-files", "-s", "-z", env=dict(os.environ, GIT_INDEX_FILE=scoped_index)),
|
|
112
|
+
staged=True,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_scoped_index(repo_root, parents, head="HEAD"):
|
|
117
|
+
"""Return an owned temporary index; the caller removes it after checking.
|
|
118
|
+
|
|
119
|
+
Read the staged snapshot through the copy, not the live index. No blobs,
|
|
120
|
+
trees, refs or original index bytes are written by this operation. A caller
|
|
121
|
+
must still compare the real staged tree before and after checking.
|
|
122
|
+
"""
|
|
123
|
+
if not parents or any(re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", head) is None for head in parents):
|
|
124
|
+
raise MergeScopeError("Invalid merge parents")
|
|
125
|
+
alternate_index = os.environ.get("GIT_INDEX_FILE")
|
|
126
|
+
if alternate_index:
|
|
127
|
+
real_index = os.path.abspath(alternate_index)
|
|
128
|
+
else:
|
|
129
|
+
real_index = _git(repo_root, "rev-parse", "--git-path", "index").decode().strip()
|
|
130
|
+
if not real_index:
|
|
131
|
+
raise MergeScopeError("Missing index path")
|
|
132
|
+
real_index = os.path.abspath(os.path.join(repo_root, real_index))
|
|
133
|
+
if not os.path.isfile(real_index):
|
|
134
|
+
raise MergeScopeError("No index")
|
|
135
|
+
|
|
136
|
+
fd, scoped = tempfile.mkstemp(prefix="leftblock-merge-index-")
|
|
137
|
+
os.close(fd)
|
|
138
|
+
try:
|
|
139
|
+
shutil.copyfile(real_index, scoped)
|
|
140
|
+
env = dict(os.environ, GIT_INDEX_FILE=scoped)
|
|
141
|
+
# Expand sparse-directory entries and resolve split-index references
|
|
142
|
+
# through Git before modifying the private copy.
|
|
143
|
+
staged = index_entries(repo_root, scoped)
|
|
144
|
+
trees = [
|
|
145
|
+
_parse_ls(_git(repo_root, "ls-tree", "-r", "-z", head))
|
|
146
|
+
for head in [head] + list(parents)
|
|
147
|
+
]
|
|
148
|
+
head_tree = trees[0]
|
|
149
|
+
updates = []
|
|
150
|
+
for path, (mode, sha) in staged.items():
|
|
151
|
+
if head_tree.get(path) == (mode, sha):
|
|
152
|
+
continue
|
|
153
|
+
if mode not in (b"100644", b"100755"):
|
|
154
|
+
continue
|
|
155
|
+
if not any(parent.get(path) == (mode, sha) for parent in trees[1:]):
|
|
156
|
+
continue
|
|
157
|
+
if path in head_tree:
|
|
158
|
+
old_mode, old_sha = head_tree[path]
|
|
159
|
+
updates.append(b"%s %s 0\t%s\0" % (old_mode, old_sha, path))
|
|
160
|
+
else:
|
|
161
|
+
updates.append(b"0 %s 0\t%s\0" % (b"0" * len(sha), path))
|
|
162
|
+
if updates:
|
|
163
|
+
_git(
|
|
164
|
+
repo_root, "-c", "core.splitIndex=false", "-c", "index.sparse=false",
|
|
165
|
+
"update-index", "--no-split-index", "-z", "--index-info",
|
|
166
|
+
env=env, input=b"".join(updates),
|
|
167
|
+
)
|
|
168
|
+
return scoped
|
|
169
|
+
except BaseException:
|
|
170
|
+
if os.path.exists(scoped):
|
|
171
|
+
os.remove(scoped)
|
|
172
|
+
raise
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: leftblock
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: LeftBlock: a pluggable inspection gate for Git commits
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Anthus AI Solutions
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: PyYAML
|
|
31
|
+
Requires-Dist: behave
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# LeftBlock
|
|
35
|
+
|
|
36
|
+
**LeftBlock** is a pluggable inspection gate for Git commits. It runs configured scanners, including Gitleaks, on staged changes. Local check-only mode blocks findings before a commit; optional receipt mode also records the inspection result in an HMAC receipt.
|
|
37
|
+
|
|
38
|
+
LeftBlock **shifts privacy checks left**: detected sensitive material can be blocked before it enters a commit and becomes part of local Git history. Pushing distributes that history to a remote; subsequent clones and pulls distribute it further. A later check can stop the next step, but cannot undo copies already made.
|
|
39
|
+
|
|
40
|
+
```mermaid
|
|
41
|
+
flowchart LR
|
|
42
|
+
Work["Working tree / staged changes"] --> Check{"Pre-commit check<br/>LeftBlock + Gitleaks"}
|
|
43
|
+
Check -->|Detected sensitive material| Block["Commit blocked<br/>Fix staged changes"]
|
|
44
|
+
Block --> Work
|
|
45
|
+
Check -->|Checks pass| History["Local Git history<br/>Commit and provenance"]
|
|
46
|
+
History -->|Push| Remote["Remote repository"]
|
|
47
|
+
Remote -->|Clone / pull| Copies["Downstream copies"]
|
|
48
|
+
Work -.->|Hook bypass| History
|
|
49
|
+
Check -.->|Receipt mode approved override| History
|
|
50
|
+
History -.-> Later["Later checks<br/>Pre-push / CI / receipt verification"]
|
|
51
|
+
Remote -.-> Later
|
|
52
|
+
Later --> Limit["May stop further distribution or deployment<br/>Material is already in history"]
|
|
53
|
+
|
|
54
|
+
style Block fill:#ffe4e4,stroke:#b42318
|
|
55
|
+
style Check fill:#e5f3ff,stroke:#175cd3
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Local check-only mode installs a Git **`pre-commit` hook**. Receipt mode installs a **`commit-msg` hook**, which runs later but still before Git creates the commit object. The local hook never offers an override or adds a receipt. The default Gitleaks command scans the staged diff, not every working-tree file or all existing history. Staging may already write blobs to the local object database, so blocking a commit does not erase every local copy.
|
|
59
|
+
|
|
60
|
+
This is a layered defense, not a guarantee that a repository contains no sensitive information. Scanners can miss formats or context, allowlists and configuration affect coverage, and hooks can be bypassed with `--no-verify` or disabled. In receipt mode, interactive approval can intentionally override findings. Missing scanners, invalid configuration, or other checker errors block the commit without an override. Use review, narrowly scoped exceptions, and independent checks before pushing or deploying as additional layers.
|
|
61
|
+
|
|
62
|
+
`leftblock verify` validates receipts; it does not rescan content, and a valid receipt can record an approved override. The HMAC binds the recorded result to a tree hash, but anyone who can read the shared secret can mint a receipt. LeftBlock does not itself isolate that secret from an agent running as the same user.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Project and release status
|
|
67
|
+
|
|
68
|
+
LeftBlock has been donated to **Data Parade (`DataParade-io`)**, alongside
|
|
69
|
+
BotSpy. Its repository is
|
|
70
|
+
[DataParade-io/LeftBlock](https://github.com/DataParade-io/LeftBlock).
|
|
71
|
+
|
|
72
|
+
**LeftBlock 0.3.2 is unreleased.** The current source uses `leftblock` as its
|
|
73
|
+
distribution, Python package, and CLI name. It includes no earlier package
|
|
74
|
+
aliases or command shims. The CLI is also available as
|
|
75
|
+
`python -m leftblock.cli`.
|
|
76
|
+
|
|
77
|
+
Current receipt-mode configuration uses `.leftblock.yml`, `.leftblock/`
|
|
78
|
+
rule paths, `LEFTBLOCK_SECRET_PATH`, and `leftblock-*` rule IDs. New receipts
|
|
79
|
+
use the `leftblock-v1` validator for configuration version 1. Earlier
|
|
80
|
+
configuration filenames, environment-variable names, and hook ownership
|
|
81
|
+
records are not migrated automatically.
|
|
82
|
+
|
|
83
|
+
### Migrating an earlier installation
|
|
84
|
+
|
|
85
|
+
This release does not remove or replace a separately installed earlier
|
|
86
|
+
distribution, and it does not rewrite published releases or Git history.
|
|
87
|
+
That does **not** make an existing installation automatically compatible:
|
|
88
|
+
an editable install or hook may still load code from the checkout being
|
|
89
|
+
updated. Before pulling these changes into such a checkout:
|
|
90
|
+
|
|
91
|
+
1. Inventory each protected repository's common Git directory, hook manager,
|
|
92
|
+
interpreter, installed package, checker policy, rules, and secret location.
|
|
93
|
+
Linked worktrees share their common Git metadata; coordinate them together.
|
|
94
|
+
2. Prepare and test the new runtime independently. Retain the working earlier
|
|
95
|
+
runtime and protection until the replacement has passed validation.
|
|
96
|
+
3. Review and migrate policy paths and rule IDs to the current names. Preserve
|
|
97
|
+
custom rules, narrow exceptions, owner edits, and existing hook-manager
|
|
98
|
+
behavior. Do not replace an edited or foreign hook to force installation.
|
|
99
|
+
4. For receipt mode, retain the **same secret bytes**. Securely migrate the
|
|
100
|
+
existing file to the new default location, or set `LEFTBLOCK_SECRET_PATH`
|
|
101
|
+
to its existing absolute path in the hook and verifier environments.
|
|
102
|
+
Do not print the secret, commit it, or run an installer that generates a
|
|
103
|
+
replacement secret as a shortcut. New receipt installation is not a
|
|
104
|
+
credential migration tool.
|
|
105
|
+
5. Coordinate the runtime, policy, hook command and ownership-state transition.
|
|
106
|
+
Keep a rollback path and existing protection until the new setup accepts
|
|
107
|
+
clean fixtures, blocks synthetic sensitive fixtures without advancing
|
|
108
|
+
`HEAD`, and verifies the required historical receipt range.
|
|
109
|
+
|
|
110
|
+
Historical receipts must remain byte-for-byte unchanged. Verification reads
|
|
111
|
+
and authenticates the validator string stored in each receipt, so a naming
|
|
112
|
+
change does not require resigning history when the original secret is
|
|
113
|
+
available. This is a migration checklist, not an automatic migration or a
|
|
114
|
+
claim that existing repositories have already been upgraded.
|
|
115
|
+
|
|
116
|
+
## Quick Start
|
|
117
|
+
|
|
118
|
+
LeftBlock is built in Python and requires `git`. Install Gitleaks separately
|
|
119
|
+
from a trusted source and confirm `gitleaks version` works. LeftBlock
|
|
120
|
+
configures the integration; it does not install scanner binaries.
|
|
121
|
+
|
|
122
|
+
**1. Install the CLI:**
|
|
123
|
+
|
|
124
|
+
While 0.3.2 is unreleased, install only a reviewed local checkout containing
|
|
125
|
+
these changes. From that checkout, in the intended Python environment:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
python -m pip install .
|
|
129
|
+
python -m leftblock.cli --help
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The installation is from local source, not a published release. Confirm the
|
|
133
|
+
checkout's commit and validation results before installing; do not assume
|
|
134
|
+
`main` or an earlier feature-branch commit contains this implementation.
|
|
135
|
+
Follow the migration checklist above before replacing an earlier setup.
|
|
136
|
+
|
|
137
|
+
After the release is published and its distribution identity is verified,
|
|
138
|
+
the versioned package installation will be:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
python -m pip install leftblock==0.3.2
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**2. Enable local staged protection:**
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
cd my-repo
|
|
148
|
+
leftblock install --local --check-only
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
This mode installs a managed `pre-commit` hook and keeps its policy in the
|
|
152
|
+
repository's common Git metadata directory. It creates no signing secret,
|
|
153
|
+
adds no commit receipts, and writes no tracked repository files. Linked
|
|
154
|
+
worktrees share that metadata and hook, so coordinate setup across them.
|
|
155
|
+
To select a specific existing Gitleaks executable, pass its absolute path:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
leftblock install --local --check-only --gitleaks "$(command -v gitleaks)"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The installer preserves existing hooks and refuses to overwrite a foreign
|
|
162
|
+
or edited hook. An unchanged hook managed by LeftBlock can be refreshed.
|
|
163
|
+
A configured `core.hooksPath` is left untouched; compose the check explicitly
|
|
164
|
+
with the existing hook manager instead of replacing it. An earlier hook
|
|
165
|
+
without the current ownership record requires a reviewed migration even
|
|
166
|
+
if it is still functional.
|
|
167
|
+
|
|
168
|
+
To run a noninteractive staged check with an explicit policy:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
leftblock check --config /absolute/path/to/policy.yml
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Use the actual policy path for the repository. The command returns a
|
|
175
|
+
nonzero status for findings, unavailable scanners, or an invalid setup;
|
|
176
|
+
it never offers an override. Before adopting a hook in another project,
|
|
177
|
+
verify that a synthetic sensitive fixture is blocked and a clean fixture
|
|
178
|
+
passes in a throwaway repository. Keep private policy files in local Git
|
|
179
|
+
metadata or another private location.
|
|
180
|
+
|
|
181
|
+
During an in-progress merge, command checkers skip only regular files whose
|
|
182
|
+
complete blob and mode already exist at the same path in a parent. Changed
|
|
183
|
+
resolutions and renamed destinations retain their full staged diff, so
|
|
184
|
+
inherited material in a modified file can still block. If merge scoping
|
|
185
|
+
fails, the full staged check runs. Tactus uses the original index.
|
|
186
|
+
This does not rescan parent history or eliminate staged-diff limitations,
|
|
187
|
+
including sensitive context assembled only by deleting lines.
|
|
188
|
+
|
|
189
|
+
**3. Optionally enable inspection receipts:**
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
leftblock install
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Receipt mode installs a `commit-msg` hook, creates a shared secret if one
|
|
196
|
+
is absent, and writes defaults when they do not already exist:
|
|
197
|
+
|
|
198
|
+
- `.leftblock.yml` — checker configuration
|
|
199
|
+
- `.leftblock/gitleaks.toml` — the shipped sensitive-data ruleset
|
|
200
|
+
|
|
201
|
+
Existing files at the current configuration and ruleset paths are not
|
|
202
|
+
overwritten. Other hooks are preserved. Earlier path names are not discovered
|
|
203
|
+
or migrated automatically. The default receipt-mode configuration uses
|
|
204
|
+
Gitleaks with redacted output:
|
|
205
|
+
|
|
206
|
+
```yaml
|
|
207
|
+
version: 1
|
|
208
|
+
checkers:
|
|
209
|
+
- name: gitleaks
|
|
210
|
+
type: command
|
|
211
|
+
command: gitleaks protect --staged --config .leftblock/gitleaks.toml --redact --no-banner --report-format json --report-path {report_file}
|
|
212
|
+
success_codes: [0]
|
|
213
|
+
finding_codes: [1]
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The installed hook pins its Python environment and preserves the installation
|
|
217
|
+
PATH so it can find configured scanners. Review the hook after moving its
|
|
218
|
+
Python environment or Gitleaks executable. For manual composition, run
|
|
219
|
+
`leftblock check --config PATH` in the existing `pre-commit` flow and
|
|
220
|
+
`leftblock hook "$1"` in the `commit-msg` flow when receipts are needed.
|
|
221
|
+
Preserve existing behavior and propagate any failure's exit status.
|
|
222
|
+
|
|
223
|
+
Receipts use HMAC trailers, not Git GPG/SSH signatures; they do not appear
|
|
224
|
+
in `git log --show-signature`. Every machine running `leftblock verify`
|
|
225
|
+
needs the same secret, stored by default at `~/.config/leftblock/secret`
|
|
226
|
+
or the path in `LEFTBLOCK_SECRET_PATH`. Preserve the existing secret during
|
|
227
|
+
migration so historical receipts remain verifiable. Local check-only mode
|
|
228
|
+
does not need this secret.
|
|
229
|
+
|
|
230
|
+
**4. Add independent checks before distribution or deployment:**
|
|
231
|
+
|
|
232
|
+
For projects using receipts, verify the appropriate incoming commit range:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
leftblock verify HEAD~5..HEAD
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Choose the range for the pipeline rather than assuming every push contains
|
|
239
|
+
five commits. Receipt verification needs the shared secret and neither
|
|
240
|
+
rescans content nor rejects valid overrides. Use an independent secret scan
|
|
241
|
+
as another layer. A project using only local check-only mode does not mint
|
|
242
|
+
the receipts required by this gate.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Out-of-the-Box Detection
|
|
247
|
+
|
|
248
|
+
LeftBlock ships a Gitleaks ruleset (copied to `.leftblock/gitleaks.toml` in receipt mode) that
|
|
249
|
+
extends gitleaks' default secret rules with personal-data and crypto-wallet
|
|
250
|
+
rules. These patterns provide a starting point for detection; they do not
|
|
251
|
+
cover every kind of sensitive information.
|
|
252
|
+
|
|
253
|
+
**Covered by gitleaks defaults:** AWS credential pairs, Google/Gemini API keys
|
|
254
|
+
(`AIza...`, default `gcp-api-key` rule), generic API keys in keyword context,
|
|
255
|
+
PEM private keys, and other standard secret formats.
|
|
256
|
+
|
|
257
|
+
**Added by the LeftBlock ruleset:**
|
|
258
|
+
|
|
259
|
+
| Rule | What it catches |
|
|
260
|
+
|---|---|
|
|
261
|
+
| `leftblock-personal-email` | Personal email addresses (infrastructure identities like `git@github.com`, `@users.noreply.github.com` and `@example.*` placeholders are allowlisted) |
|
|
262
|
+
| `leftblock-home-path-posix` | Home-folder paths `/Users/<name>`, `/home/<name>` |
|
|
263
|
+
| `leftblock-home-path-windows` | `C:\Users\<name>` and `\Users\<name>` |
|
|
264
|
+
| `leftblock-session-uuid` | Potential session UUIDs, with narrow exceptions for canonical Kanbus structural fields and example values |
|
|
265
|
+
| `leftblock-actor-id` | `actor_id` / `user_id` / `author_id` JSON values |
|
|
266
|
+
| `leftblock-openai-key` | `sk-`, `sk-proj-`, `sk-svcacct-` OpenAI keys |
|
|
267
|
+
| `leftblock-anthropic-key` | `sk-ant-` Anthropic keys |
|
|
268
|
+
| `leftblock-eth-private-key` | Ethereum-style private keys (`0x` + 64 hex) |
|
|
269
|
+
| `leftblock-wif-key` | Bitcoin WIF private keys (compressed `K/L/M…`, uncompressed `5…`) |
|
|
270
|
+
| `leftblock-bip39-mnemonic` | BIP-39 seed phrases (12–24 words) in seed/mnemonic/recovery context |
|
|
271
|
+
|
|
272
|
+
Kanbus exceptions apply to complete identifier property lines in
|
|
273
|
+
`project/issues/*.json` and `project/events/*.json`. Event actor attribution
|
|
274
|
+
must occupy a root property line in the canonical two-space format.
|
|
275
|
+
Coordination claim references must occupy the canonical payload depth.
|
|
276
|
+
The legacy `project/.cache/index.json` allows only canonical issue ID/parent
|
|
277
|
+
lines and issue-filename keys with numeric modification times; cached
|
|
278
|
+
descriptions and comments still receive the normal checks.
|
|
279
|
+
Provider session fields, nested actor values, prose and arbitrary arrays
|
|
280
|
+
remain scanned. A project prefix alone grants no exception, and a second
|
|
281
|
+
value on the same line cannot inherit one. These checks recognize syntax;
|
|
282
|
+
they do not validate a full JSON schema or prove an identifier is public.
|
|
283
|
+
Other secret rules still apply to values in the accepted fields.
|
|
284
|
+
|
|
285
|
+
### Crypto wallet material: what is and is not flagged
|
|
286
|
+
|
|
287
|
+
- **Flagged:** `0x`-prefixed 64-hex private keys, WIF-format Bitcoin keys,
|
|
288
|
+
and 12/15/18/21/24-word BIP-39 seed phrases when they appear in
|
|
289
|
+
seed/mnemonic context (`seed_phrase = "..."`, `recovery mnemonic: ...`).
|
|
290
|
+
- **Not flagged on their own:** public wallet addresses (Ethereum `0x` + 40
|
|
291
|
+
hex, Bitcoin base58 addresses) and hash-like hex — SHA-256 digests, git
|
|
292
|
+
SHAs, SHA-512. A bare 64-hex string with no `0x` prefix and no key-like
|
|
293
|
+
keyword is shape-identical to a SHA-256 digest and git SHA, so it is
|
|
294
|
+
deliberately only reported when keyword context (e.g. `private_key = …`)
|
|
295
|
+
indicates it is a key. This trades a small residual gap (a bare,
|
|
296
|
+
unlabeled 64-hex private key stored without any keyword) for not
|
|
297
|
+
drowning developers in digest false positives. BIP-39 checksum
|
|
298
|
+
validation is future validator work; phrase detection is shape- and
|
|
299
|
+
context-based, so unlabeled bare word sequences that read like prose
|
|
300
|
+
are not flagged.
|
|
301
|
+
|
|
302
|
+
### Why Gitleaks is the default checker
|
|
303
|
+
|
|
304
|
+
The earlier scanner evaluation used a synthetic fixture corpus, clean
|
|
305
|
+
content, and a ~2 MB repository tree. Gitleaks results below include the
|
|
306
|
+
LeftBlock ruleset; these measurements describe that corpus, not a guarantee
|
|
307
|
+
about other repositories or scanner versions:
|
|
308
|
+
|
|
309
|
+
| Scanner | Verdict |
|
|
310
|
+
|---|---|
|
|
311
|
+
| **Gitleaks 8.30.1** (default checker) | Catches API keys, crypto keys (with the LeftBlock ruleset), and personal data via custom rules; deterministic exit codes (0 clean / 1 findings); zero false positives on clean content and on the real tree with the shipped ruleset; fast (~35 ms on 2 MB). |
|
|
312
|
+
| detect-secrets | No OpenAI/AWS-type detector hits for these formats — findings appear only as generic "High Entropy String"/"Secret Keyword"; misses mnemonics and all personal data; exit code 0 even with findings, so not gate-suitable without a wrapper. |
|
|
313
|
+
| TruffleHog (offline) | Its AWS detector caught the credential pair, but nothing else offline (OpenAI/Anthropic/Gemini/crypto/personal data all missed); scans git objects including gitignored files, which produces a different, noisier finding set; no allowlist mechanism for custom detectors. |
|
|
314
|
+
| Presidio | Built for PII text analytics; requires a 500 MB model, flags benign URLs/IPs/timestamps while missing every target category here (including `.test`-TLD emails and keys); unsuitable for commit gating. |
|
|
315
|
+
|
|
316
|
+
False-positive measurements with the shipped ruleset: **0 findings** on a
|
|
317
|
+
clean corpus (benign source/config files, placeholder emails and paths that
|
|
318
|
+
must stay allowed) and **0 findings** on a ~2 MB real repository tree
|
|
319
|
+
(LEFTBLOCK_DOGFOOD_TREE dogfood test). The unit-test suite
|
|
320
|
+
(`tests/test_sensitive_rules.py`) regenerates all fixtures synthetically at
|
|
321
|
+
test time from a seeded RNG — no real credential ever appears in the repo.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Custom Agent-Based Scanning (Tactus)
|
|
326
|
+
|
|
327
|
+
While standard tools like Gitleaks are great for finding AWS IAM keys, they struggle with subtle, context-dependent leaks—like mentioning a confidential client's name or proprietary business logic.
|
|
328
|
+
|
|
329
|
+
LeftBlock supports **Tactus procedures** for agent-based code review.
|
|
330
|
+
|
|
331
|
+
### Example: Protecting Confidential Client Names
|
|
332
|
+
Imagine you have a list of highly confidential clients that should never be mentioned in your repository's source code.
|
|
333
|
+
|
|
334
|
+
1. Create a `.gitignored` file named `confidential_clients.txt`.
|
|
335
|
+
2. Write a Tactus procedure (e.g., `check_clients`) that reads `confidential_clients.txt` and scans the staged files to ensure none of those names appear.
|
|
336
|
+
3. Add the procedure to your `.leftblock.yml`:
|
|
337
|
+
|
|
338
|
+
```yaml
|
|
339
|
+
version: 1
|
|
340
|
+
checkers:
|
|
341
|
+
- name: tactus-confidentiality-scan
|
|
342
|
+
type: tactus
|
|
343
|
+
procedure: check_clients
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
In receipt mode, LeftBlock invokes a configured Tactus checker when a commit is made. If Tactus finds a leaked client name, it exits with an error, LeftBlock blocks the commit, and prompts a human for an override password. If the code is clean, the signature is minted and the commit proceeds.
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## Deep Dive: How it works
|
|
351
|
+
|
|
352
|
+
### 1. The Commit Hook
|
|
353
|
+
Receipt mode uses a shared HMAC secret.
|
|
354
|
+
|
|
355
|
+
In receipt mode, before Git creates the commit, LeftBlock:
|
|
356
|
+
|
|
357
|
+
1. Computes the hash of the staged Git tree.
|
|
358
|
+
2. Runs the configured scanners. The default Gitleaks command checks the staged diff; custom checkers define their own scope.
|
|
359
|
+
3. If checks pass, records the result and tree hash in an HMAC receipt and appends it to the commit message as Git trailers. An approved interactive override is recorded as an override, not as a clean result.
|
|
360
|
+
|
|
361
|
+
The tree hash identifies the staged snapshot. It does not imply that the
|
|
362
|
+
scanner examined every file in that snapshot or the repository's history.
|
|
363
|
+
|
|
364
|
+
```text
|
|
365
|
+
Inspected-by: leftblock-v1
|
|
366
|
+
Inspection-tree: f18588e0c5f5b0a5ba281b5a78242127919d2388
|
|
367
|
+
Inspection-result: clean
|
|
368
|
+
Inspection-at: 2026-08-31T17:01:34Z
|
|
369
|
+
Inspection-sig: hmac-sha256:8cce6e3714782d025eb4ec4aec755...
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### 2. Retroactive Approval (Signature Pooling)
|
|
373
|
+
Because Git commit hashes change if you modify a commit message, you cannot simply add signatures to old commits without rewriting history (e.g., `git rebase`).
|
|
374
|
+
|
|
375
|
+
To support later receipts, LeftBlock ties the signature to the **Tree Hash** (the codebase state) rather than the **Commit Hash**.
|
|
376
|
+
|
|
377
|
+
If an agent bypasses the hook with `--no-verify`, or if you are onboarding an older project, you can run `leftblock approve`. This creates an empty "paperwork" commit at the tip of your branch that holds the signatures for the older commits.
|
|
378
|
+
|
|
379
|
+
```mermaid
|
|
380
|
+
flowchart LR
|
|
381
|
+
C1["Commit 1<br/>Tree: A<br/>Signed: Yes"] --> C2["Commit 2<br/>Tree: B<br/>Signed: No"]
|
|
382
|
+
C2 --> C3["Commit 3<br/>Tree: C<br/>Signed: No"]
|
|
383
|
+
C3 --> AC["Approval Commit<br/>Signs Trees: B, C"]
|
|
384
|
+
|
|
385
|
+
AC --> Gate{"Deploy Gate<br/>leftblock verify"}
|
|
386
|
+
Gate -->|Pools valid signatures| Check["Are Trees A, B, and C in the pool?"]
|
|
387
|
+
Check -->|Yes| Deploy["Deploy Success"]
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Retroactive receipts preserve commit history, but do not remove sensitive material from existing commits or copies. They document a later inspection or approval; they cannot provide the same early interception as a check before the original commit.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
leftblock/__init__.py,sha256=6DEhEdiVz7Y1LjQ0aLNV-nwUpQlhD17FNQLbcXKb6rw,57
|
|
2
|
+
leftblock/cli.py,sha256=hz0Iq2V38IWYsJshU42ihvenqO0DV5vWtpgXJBUL1tA,17891
|
|
3
|
+
leftblock/core.py,sha256=KVPX2plp28reedzQ-ZvmFn3Bvz3t1AIqEXk9nNpDRLs,7294
|
|
4
|
+
leftblock/hooks.py,sha256=dpke9CXXBPxcDS0ZZRcjsowfKMVccsRdgikgFNTRJ20,12103
|
|
5
|
+
leftblock/merge.py,sha256=xWcBMnKxwbeGjtuZ-XWDE1XHqv6k3uEBf7Ngc9oMk6g,6875
|
|
6
|
+
leftblock/data/gitleaks.toml,sha256=cHyKT0Vj_aJYKZPovAJE3I2I4D5nXirFnid4SbYGEo0,9575
|
|
7
|
+
leftblock-0.3.2.dist-info/licenses/LICENSE,sha256=lwi5rYQU2i8v8ETJW7EUitz-SwIu2CbVNivaF1If6wo,1076
|
|
8
|
+
leftblock-0.3.2.dist-info/METADATA,sha256=8ZcDaXXnjr4zeb0PK1yK-M3TfW99S90cupSWVbbKA3o,20766
|
|
9
|
+
leftblock-0.3.2.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
|
|
10
|
+
leftblock-0.3.2.dist-info/entry_points.txt,sha256=byUhhrFrgQhK1WeQmCtnHirqj1NBdF8eTid848cjkKc,49
|
|
11
|
+
leftblock-0.3.2.dist-info/top_level.txt,sha256=P9qEcrxtCiboehx3HZhclVsToue4-2y9VPfwQP7s6S4,10
|
|
12
|
+
leftblock-0.3.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anthus AI Solutions
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
leftblock
|