leftblock 0.3.3__tar.gz → 0.3.4__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 (26) hide show
  1. {leftblock-0.3.3 → leftblock-0.3.4}/PKG-INFO +52 -4
  2. {leftblock-0.3.3 → leftblock-0.3.4}/README.md +51 -3
  3. leftblock-0.3.4/leftblock/allow.py +210 -0
  4. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock/cli.py +171 -6
  5. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock/core.py +9 -4
  6. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock.egg-info/PKG-INFO +52 -4
  7. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock.egg-info/SOURCES.txt +2 -0
  8. {leftblock-0.3.3 → leftblock-0.3.4}/pyproject.toml +1 -1
  9. {leftblock-0.3.3 → leftblock-0.3.4}/tests/test_branding.py +2 -2
  10. leftblock-0.3.4/tests/test_recorded_overrides.py +253 -0
  11. {leftblock-0.3.3 → leftblock-0.3.4}/LICENSE +0 -0
  12. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock/__init__.py +0 -0
  13. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock/data/gitleaks.toml +0 -0
  14. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock/hooks.py +0 -0
  15. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock/merge.py +0 -0
  16. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock.egg-info/dependency_links.txt +0 -0
  17. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock.egg-info/entry_points.txt +0 -0
  18. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock.egg-info/requires.txt +0 -0
  19. {leftblock-0.3.3 → leftblock-0.3.4}/leftblock.egg-info/top_level.txt +0 -0
  20. {leftblock-0.3.3 → leftblock-0.3.4}/setup.cfg +0 -0
  21. {leftblock-0.3.3 → leftblock-0.3.4}/tests/test_commit_gate.py +0 -0
  22. {leftblock-0.3.3 → leftblock-0.3.4}/tests/test_default_config.py +0 -0
  23. {leftblock-0.3.3 → leftblock-0.3.4}/tests/test_false_positives.py +0 -0
  24. {leftblock-0.3.3 → leftblock-0.3.4}/tests/test_install.py +0 -0
  25. {leftblock-0.3.3 → leftblock-0.3.4}/tests/test_local_install.py +0 -0
  26. {leftblock-0.3.3 → leftblock-0.3.4}/tests/test_sensitive_rules.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leftblock
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: LeftBlock: a pluggable inspection gate for Git commits
5
5
  License: MIT License
6
6
 
@@ -55,7 +55,7 @@ flowchart LR
55
55
  style Check fill:#e5f3ff,stroke:#175cd3
56
56
  ```
57
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.
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 offers no interactive override and adds no receipt; a false alarm can be handled only by a recorded, reasoned override that is committed and reviewable (see [Overriding a false alarm](#overriding-a-false-alarm)). 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
59
 
60
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
61
 
@@ -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.3
127
+ python -m pip install leftblock==0.3.4
128
128
  python -m leftblock.cli --help
129
129
  ```
130
130
 
@@ -162,11 +162,59 @@ leftblock check --config /absolute/path/to/policy.yml
162
162
 
163
163
  Use the actual policy path for the repository. The command returns a
164
164
  nonzero status for findings, unavailable scanners, or an invalid setup;
165
- it never offers an override. Before adopting a hook in another project,
165
+ it offers no interactive override, only the recorded override described
166
+ below. Before adopting a hook in another project,
166
167
  verify that a synthetic sensitive fixture is blocked and a clean fixture
167
168
  passes in a throwaway repository. Keep private policy files in local Git
168
169
  metadata or another private location.
169
170
 
171
+ ### Overriding a false alarm
172
+
173
+ A block stays the default. If a finding is a false alarm (for example a
174
+ 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:
177
+
178
+ ```bash
179
+ leftblock allow ID [ID ...] --reason "Why this is not sensitive"
180
+ ```
181
+
182
+ `allow` re-runs the checks on the staged changes, appends one entry per id
183
+ to `.leftblock-allow.yml` at the repository root, and stages that file. It
184
+ never commits. The reason is required and must be at least 10 characters.
185
+ An entry looks like:
186
+
187
+ ```yaml
188
+ version: 1
189
+ allow:
190
+ - id: "3f2a9c1e7b4d5a60"
191
+ rule: "leftblock-personal-email"
192
+ file: "apps/web/privacy/page.tsx"
193
+ reason: "Public role address required in the privacy notice."
194
+ added: 2026-10-20
195
+ ```
196
+
197
+ - **Scoped to line content.** The id hashes the rule, the file, and the exact
198
+ staged content of the flagged lines. Editing that line, or adding another
199
+ finding anywhere, blocks again.
200
+ - **Staged to count.** Only the version of `.leftblock-allow.yml` in the Git
201
+ index is read, so the override is always part of the commit and its review.
202
+ A file that is only in the working tree has no effect.
203
+ - **Visible every time.** Each run prints the file, rule, id, and reason of
204
+ every applied override.
205
+ - **Fail closed.** Invalid YAML, an unsupported version, an entry missing
206
+ `id`, `rule`, or `file`, or a reason under 10 characters blocks the commit.
207
+ Scanner errors are never overridden, and a finding with no id (its lines
208
+ could not be read) must be removed instead.
209
+ - **Configuration.** Without `--config`, `allow` uses the local install's
210
+ policy when present, otherwise `.leftblock.yml`; pass the same `--config`
211
+ you use for `check` if it differs.
212
+
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.
217
+
170
218
  During an in-progress merge, command checkers skip only regular files whose
171
219
  complete blob and mode already exist at the same path in a parent. Changed
172
220
  resolutions and renamed destinations retain their full staged diff, so
@@ -22,7 +22,7 @@ flowchart LR
22
22
  style Check fill:#e5f3ff,stroke:#175cd3
23
23
  ```
24
24
 
25
- 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.
25
+ 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 offers no interactive override and adds no receipt; a false alarm can be handled only by a recorded, reasoned override that is committed and reviewable (see [Overriding a false alarm](#overriding-a-false-alarm)). 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.
26
26
 
27
27
  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.
28
28
 
@@ -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.3
94
+ python -m pip install leftblock==0.3.4
95
95
  python -m leftblock.cli --help
96
96
  ```
97
97
 
@@ -129,11 +129,59 @@ leftblock check --config /absolute/path/to/policy.yml
129
129
 
130
130
  Use the actual policy path for the repository. The command returns a
131
131
  nonzero status for findings, unavailable scanners, or an invalid setup;
132
- it never offers an override. Before adopting a hook in another project,
132
+ it offers no interactive override, only the recorded override described
133
+ below. Before adopting a hook in another project,
133
134
  verify that a synthetic sensitive fixture is blocked and a clean fixture
134
135
  passes in a throwaway repository. Keep private policy files in local Git
135
136
  metadata or another private location.
136
137
 
138
+ ### Overriding a false alarm
139
+
140
+ A block stays the default. If a finding is a false alarm (for example a
141
+ 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:
144
+
145
+ ```bash
146
+ leftblock allow ID [ID ...] --reason "Why this is not sensitive"
147
+ ```
148
+
149
+ `allow` re-runs the checks on the staged changes, appends one entry per id
150
+ to `.leftblock-allow.yml` at the repository root, and stages that file. It
151
+ never commits. The reason is required and must be at least 10 characters.
152
+ An entry looks like:
153
+
154
+ ```yaml
155
+ version: 1
156
+ allow:
157
+ - id: "3f2a9c1e7b4d5a60"
158
+ rule: "leftblock-personal-email"
159
+ file: "apps/web/privacy/page.tsx"
160
+ reason: "Public role address required in the privacy notice."
161
+ added: 2026-10-20
162
+ ```
163
+
164
+ - **Scoped to line content.** The id hashes the rule, the file, and the exact
165
+ staged content of the flagged lines. Editing that line, or adding another
166
+ finding anywhere, blocks again.
167
+ - **Staged to count.** Only the version of `.leftblock-allow.yml` in the Git
168
+ index is read, so the override is always part of the commit and its review.
169
+ A file that is only in the working tree has no effect.
170
+ - **Visible every time.** Each run prints the file, rule, id, and reason of
171
+ every applied override.
172
+ - **Fail closed.** Invalid YAML, an unsupported version, an entry missing
173
+ `id`, `rule`, or `file`, or a reason under 10 characters blocks the commit.
174
+ Scanner errors are never overridden, and a finding with no id (its lines
175
+ could not be read) must be removed instead.
176
+ - **Configuration.** Without `--config`, `allow` uses the local install's
177
+ policy when present, otherwise `.leftblock.yml`; pass the same `--config`
178
+ you use for `check` if it differs.
179
+
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.
184
+
137
185
  During an in-progress merge, command checkers skip only regular files whose
138
186
  complete blob and mode already exist at the same path in a parent. Changed
139
187
  resolutions and renamed destinations retain their full staged diff, so
@@ -0,0 +1,210 @@
1
+ """Recorded, per-finding overrides kept in a committed .leftblock-allow.yml.
2
+
3
+ An override approves one finding: one rule on one exact line content in one
4
+ file. The id is a hash of that triple, so changing the line re-blocks it. The
5
+ allow file is read from the Git index, so only a staged override counts and it
6
+ always appears in the commit and its review.
7
+ """
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import subprocess
14
+ from datetime import datetime, timezone
15
+
16
+ import yaml
17
+
18
+ ALLOW_FILE = ".leftblock-allow.yml"
19
+ MIN_REASON = 10
20
+ ALLOW_HEADER = (
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"
23
+ )
24
+
25
+
26
+ class AllowError(Exception):
27
+ pass
28
+
29
+
30
+ def _git_bytes(repo_root, *args):
31
+ return subprocess.run(
32
+ ["git", *args], cwd=repo_root, capture_output=True, check=False,
33
+ stdin=subprocess.DEVNULL,
34
+ )
35
+
36
+
37
+ def finding_id(repo_root, rule, file, start, end):
38
+ """Hash of rule, file and the staged line content, or None if unreadable."""
39
+ if not isinstance(rule, str) or not isinstance(file, str):
40
+ return None
41
+ if type(start) is not int or type(end) is not int or start < 1 or end < start:
42
+ return None
43
+ proc = _git_bytes(repo_root, "show", ":" + file)
44
+ if proc.returncode:
45
+ return None
46
+ lines = proc.stdout.decode("utf-8", errors="replace").split("\n")
47
+ if end > len(lines):
48
+ return None
49
+ content = "\n".join(lines[start - 1:end])
50
+ digest = hashlib.sha256(f"{rule}\0{file}\0{content}".encode("utf-8"))
51
+ return digest.hexdigest()[:16]
52
+
53
+
54
+ def attach_ids(repo_root, results):
55
+ """Replace StartLine/EndLine on findings with an id (when computable)."""
56
+ for result in results:
57
+ if result.error or not isinstance(result.findings, list):
58
+ continue
59
+ for finding in result.findings:
60
+ start, end = finding.pop("StartLine", None), finding.pop("EndLine", None)
61
+ if finding.get("File") == ALLOW_FILE:
62
+ continue # the allow file can never approve itself
63
+ ident = finding_id(
64
+ repo_root, finding.get("RuleID"), finding.get("File"), start, end,
65
+ )
66
+ if ident:
67
+ finding["id"] = ident
68
+
69
+
70
+ def parse_allowlist(text):
71
+ """Return entries from allow-file text; raise AllowError if invalid."""
72
+ try:
73
+ data = yaml.safe_load(text)
74
+ except yaml.YAMLError:
75
+ raise AllowError("invalid YAML")
76
+ if not isinstance(data, dict) or data.get("version") != 1 or type(data.get("version")) is not int:
77
+ raise AllowError("unsupported version")
78
+ entries = data.get("allow")
79
+ if entries is None:
80
+ entries = []
81
+ if not isinstance(entries, list):
82
+ raise AllowError("allow must be a list")
83
+ for entry in entries:
84
+ if not isinstance(entry, dict):
85
+ raise AllowError("invalid entry")
86
+ for key in ("id", "rule", "file"):
87
+ if not isinstance(entry.get(key), str) or not entry[key].strip():
88
+ raise AllowError("entry missing " + key)
89
+ if not isinstance(entry.get("reason"), str) or len(entry["reason"].strip()) < MIN_REASON:
90
+ raise AllowError("entry reason too short")
91
+ return entries
92
+
93
+
94
+ def load_allowlist(repo_root):
95
+ """Entries from the staged allow file; [] when it is not in the index."""
96
+ staged = _git_bytes(repo_root, "ls-files", "--stage", "--", ALLOW_FILE)
97
+ if staged.returncode:
98
+ raise AllowError("cannot read index")
99
+ if not staged.stdout.strip():
100
+ return []
101
+ proc = _git_bytes(repo_root, "show", ":" + ALLOW_FILE)
102
+ if proc.returncode:
103
+ raise AllowError("cannot read staged file")
104
+ try:
105
+ return parse_allowlist(proc.stdout.decode("utf-8"))
106
+ except UnicodeDecodeError:
107
+ raise AllowError("not UTF-8")
108
+
109
+
110
+ def apply_allowlist(results, entries):
111
+ """Move allowed findings out of the results; return [(finding, reason)]."""
112
+ index = {
113
+ (e["id"], e["rule"], e["file"]): " ".join(e["reason"].split()) for e in entries
114
+ }
115
+ allowed = []
116
+ for result in results:
117
+ if result.error or result.clean or not isinstance(result.findings, list):
118
+ continue
119
+ remaining = []
120
+ for finding in result.findings:
121
+ key = (finding.get("id"), finding.get("RuleID"), finding.get("File"))
122
+ if key[0] and key in index:
123
+ allowed.append((finding, index[key]))
124
+ else:
125
+ remaining.append(finding)
126
+ if remaining:
127
+ result.findings = remaining
128
+ else:
129
+ result.clean, result.findings = True, None
130
+ return allowed
131
+
132
+
133
+ def _quote(value):
134
+ # JSON strings are valid YAML double-quoted scalars.
135
+ return json.dumps(value, ensure_ascii=False)
136
+
137
+
138
+ def _render(entries):
139
+ out = [ALLOW_HEADER + "version: 1\n"]
140
+ if not entries:
141
+ return out[0] + "allow: []\n"
142
+ out.append("allow:\n")
143
+ for e in entries:
144
+ added = str(e.get("added", ""))
145
+ out.append(f" - id: {_quote(e['id'])}\n")
146
+ out.append(f" rule: {_quote(e['rule'])}\n")
147
+ out.append(f" file: {_quote(e['file'])}\n")
148
+ out.append(f" reason: {_quote(' '.join(e['reason'].split()))}\n")
149
+ if added:
150
+ plain = re.fullmatch(r"\d{4}-\d{2}-\d{2}", added)
151
+ out.append(" added: " + (added if plain else _quote(added)) + "\n")
152
+ return "".join(out)
153
+
154
+
155
+ def record_overrides(repo_root, findings, reason):
156
+ """Append entries for findings (dicts with id/RuleID/File) to the allow file.
157
+
158
+ Returns the findings newly recorded. The working-tree file must be valid.
159
+ """
160
+ path = os.path.join(repo_root, ALLOW_FILE)
161
+ entries = []
162
+ if os.path.exists(path):
163
+ with open(path, "r", encoding="utf-8") as f:
164
+ entries = parse_allowlist(f.read())
165
+ known = {e["id"] for e in entries}
166
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
167
+ added = []
168
+ for finding in findings:
169
+ if finding["id"] in known:
170
+ continue
171
+ known.add(finding["id"])
172
+ entries.append({
173
+ "id": finding["id"], "rule": finding["RuleID"], "file": finding["File"],
174
+ "reason": reason, "added": today,
175
+ })
176
+ added.append(finding)
177
+ with open(path, "w", encoding="utf-8") as f:
178
+ f.write(_render(entries))
179
+ return added
180
+
181
+
182
+ def snapshot(repo_root):
183
+ """Working-tree bytes (or None) and the index entry (or None) of the file."""
184
+ path = os.path.join(repo_root, ALLOW_FILE)
185
+ content = None
186
+ if os.path.exists(path):
187
+ with open(path, "rb") as f:
188
+ content = f.read()
189
+ staged = _git_bytes(repo_root, "ls-files", "--stage", "--", ALLOW_FILE)
190
+ entry = None
191
+ if staged.returncode == 0 and staged.stdout.strip():
192
+ meta = staged.stdout.decode().split("\t")[0].split()
193
+ entry = (meta[0], meta[1])
194
+ return content, entry
195
+
196
+
197
+ def restore(repo_root, snap):
198
+ """Put the allow file and its index entry back exactly as snapshotted."""
199
+ content, entry = snap
200
+ path = os.path.join(repo_root, ALLOW_FILE)
201
+ if content is None:
202
+ if os.path.exists(path):
203
+ os.remove(path)
204
+ else:
205
+ with open(path, "wb") as f:
206
+ f.write(content)
207
+ if entry is None:
208
+ _git_bytes(repo_root, "rm", "--cached", "-q", "--ignore-unmatch", "--", ALLOW_FILE)
209
+ else:
210
+ _git_bytes(repo_root, "update-index", "--add", "--cacheinfo", f"{entry[0]},{entry[1]},{ALLOW_FILE}")
@@ -4,25 +4,33 @@ import os
4
4
  import json
5
5
  import subprocess
6
6
  import getpass
7
+ import shlex
7
8
  import yaml
8
9
  from datetime import datetime, timezone
9
10
  from leftblock.merge import (
10
11
  MergeScopeError, build_scoped_index, checker_environment, index_entries, merge_state,
11
12
  )
13
+ from leftblock import allow as allowlist
12
14
  from leftblock.core import (
13
15
  get_secret, compute_hmac, get_tree_hash, get_commit_tree_hash,
14
16
  load_config, validate_checkers, execute_checkers, get_trailer, run_cmd, SECRET_FILE_DEFAULT,
15
17
  CheckerResult,
16
18
  )
17
19
 
20
+ def _label(text, color):
21
+ # Plain labels when output is captured (git GUIs, wrappers, CI) or NO_COLOR is set.
22
+ if os.environ.get("NO_COLOR") or not sys.stderr.isatty():
23
+ return text
24
+ return f"\033[{color}m{text}\033[0m"
25
+
18
26
  def print_info(msg):
19
- print(f"\033[1;34m[leftblock]\033[0m {msg}", file=sys.stderr)
27
+ print(f"{_label('[leftblock]', '1;34')} {msg}", file=sys.stderr)
20
28
 
21
29
  def print_warn(msg):
22
- print(f"\033[1;33m[leftblock]\033[0m {msg}", file=sys.stderr)
30
+ print(f"{_label('[leftblock]', '1;33')} {msg}", file=sys.stderr)
23
31
 
24
32
  def print_err(msg):
25
- print(f"\033[1;31m[leftblock ERROR]\033[0m {msg}", file=sys.stderr)
33
+ print(f"{_label('[leftblock ERROR]', '1;31')} {msg}", file=sys.stderr)
26
34
 
27
35
  DEFAULT_LEFTBLOCK_YML = """\
28
36
  version: 1
@@ -110,7 +118,36 @@ def cmd_install(args):
110
118
  else:
111
119
  print_info(f"Shared secret already exists at {SECRET_FILE_DEFAULT}")
112
120
 
113
- def configured_checks(repo_root, config_path=None):
121
+ def configured_checks(repo_root, config_path=None, overrides=True):
122
+ """Run the checkers, attach override ids and apply staged overrides."""
123
+ config, results = _run_configured_checks(repo_root, config_path)
124
+ allowlist.attach_ids(repo_root, results)
125
+ if not overrides:
126
+ return config, results
127
+ try:
128
+ entries = allowlist.load_allowlist(repo_root)
129
+ except allowlist.AllowError:
130
+ results.append(CheckerResult(
131
+ "leftblock-allow", False,
132
+ "Invalid .leftblock-allow.yml; inspection blocked.", error=True,
133
+ ))
134
+ return config, results
135
+ shown = set()
136
+ for finding, reason in allowlist.apply_allowlist(results, entries):
137
+ # Several matches on one line share an id; report each override once.
138
+ if finding["id"] in shown:
139
+ continue
140
+ shown.add(finding["id"])
141
+ print_info(
142
+ "Override applied: file " + json.dumps(finding["File"], ensure_ascii=True)
143
+ + ", rule " + json.dumps(finding["RuleID"], ensure_ascii=True)
144
+ + ", id " + finding["id"]
145
+ + ", reason " + json.dumps(reason, ensure_ascii=True)
146
+ )
147
+ return config, results
148
+
149
+
150
+ def _run_configured_checks(repo_root, config_path=None):
114
151
  try:
115
152
  config = load_config(repo_root, config_path)
116
153
  checkers = validate_checkers(config)
@@ -166,6 +203,121 @@ def print_failures(failures):
166
203
  print_warn(str(result.findings))
167
204
 
168
205
 
206
+ def default_local_policy(repo_root):
207
+ """The local check-only policy file when one is installed, else None."""
208
+ proc = run_cmd(
209
+ ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
210
+ check=False, cwd=repo_root,
211
+ )
212
+ if proc.returncode or not proc.stdout.strip():
213
+ return None
214
+ path = os.path.join(proc.stdout.strip(), "leftblock", "policy.json")
215
+ return path if os.path.isfile(path) else None
216
+
217
+
218
+ def finding_summary(findings):
219
+ """One-line list of rule, file and id, for callers that show only the last line."""
220
+ parts = []
221
+ for f in findings:
222
+ part = f"{f.get('RuleID', 'finding')} in {f.get('File', 'unknown file')}"
223
+ if f.get("id"):
224
+ part += f" [id {f['id']}]"
225
+ if part not in parts:
226
+ parts.append(part)
227
+ return "; ".join(parts)
228
+
229
+
230
+ def print_override_instructions(failures, repo_root, config_path):
231
+ # Tools that drive `git commit` often surface only the final line, so the
232
+ # last ERROR line names every finding and the next step on its own.
233
+ findings = [
234
+ f for result in failures if isinstance(result.findings, list) for f in result.findings
235
+ ]
236
+ own = [f for f in findings if f.get("File") == allowlist.ALLOW_FILE]
237
+ findings = [f for f in findings if f.get("File") != allowlist.ALLOW_FILE]
238
+ ids = [f["id"] for f in findings if f.get("id")]
239
+ steps = []
240
+ if own:
241
+ print_warn(
242
+ f"A reason in {allowlist.ALLOW_FILE} contains a flagged value; "
243
+ "reword that reason (don't repeat the value)."
244
+ )
245
+ steps.append(f"reword the flagged reason in {allowlist.ALLOW_FILE}")
246
+ if len(ids) < len(findings):
247
+ print_warn("Findings without an id cannot be overridden and must be removed.")
248
+ if findings:
249
+ steps.append("remove the flagged content")
250
+ if ids:
251
+ command = f"{shlex.quote(sys.executable)} -I -B -m leftblock.cli allow {' '.join(dict.fromkeys(ids))}"
252
+ default = default_local_policy(repo_root) or os.path.join(repo_root, ".leftblock.yml")
253
+ if config_path and os.path.abspath(config_path) != os.path.abspath(default):
254
+ command += f" --config {shlex.quote(config_path)}"
255
+ command += ' --reason "<why this is not sensitive>"'
256
+ print_info(
257
+ "An override adds the finding to .leftblock-allow.yml and stages it, so it "
258
+ "appears in the commit and its review."
259
+ )
260
+ steps.append("if it is a false alarm, record a reviewed override: " + command)
261
+ advice = "; or ".join(steps)
262
+ print_err(f"Inspection blocked: {finding_summary(own + findings)}. {advice[:1].upper()}{advice[1:]}.")
263
+
264
+
265
+ def cmd_allow(args):
266
+ """Record reviewed overrides for current findings in .leftblock-allow.yml."""
267
+ reason = " ".join((args.reason or "").split())
268
+ if len(reason) < allowlist.MIN_REASON:
269
+ print_err(f"--reason must explain the override (at least {allowlist.MIN_REASON} characters).")
270
+ sys.exit(1)
271
+ repo_root = run_cmd(["git", "rev-parse", "--show-toplevel"]).stdout.strip()
272
+ config_path = args.config or default_local_policy(repo_root)
273
+ tree_hash = get_tree_hash()
274
+ _, results = configured_checks(repo_root, config_path, overrides=False)
275
+ if get_tree_hash() != tree_hash:
276
+ print_err("Staged tree changed during inspection. Run it again.")
277
+ sys.exit(1)
278
+ if any(result.error for result in results):
279
+ print_err("A checker could not complete. No override recorded.")
280
+ sys.exit(1)
281
+ current = {
282
+ f["id"]: f for result in results if isinstance(result.findings, list)
283
+ for f in result.findings if f.get("id")
284
+ }
285
+ unknown = [i for i in args.ids if i not in current]
286
+ if unknown:
287
+ print_err("Not a current finding id: " + ", ".join(json.dumps(i) for i in unknown))
288
+ sys.exit(1)
289
+ snapshot = allowlist.snapshot(repo_root)
290
+ try:
291
+ added = allowlist.record_overrides(
292
+ repo_root, [current[i] for i in dict.fromkeys(args.ids)], reason,
293
+ )
294
+ except (OSError, allowlist.AllowError):
295
+ print_err(f"Existing {allowlist.ALLOW_FILE} is unreadable or invalid; fix it first.")
296
+ sys.exit(1)
297
+ run_cmd(["git", "add", "--", allowlist.ALLOW_FILE], cwd=repo_root)
298
+ # A reason that repeats the flagged value would get the allow file flagged.
299
+ _, after = configured_checks(repo_root, config_path, overrides=False)
300
+ own = [
301
+ f for result in after if isinstance(result.findings, list)
302
+ for f in result.findings if f.get("File") == allowlist.ALLOW_FILE
303
+ ]
304
+ if own or any(result.error for result in after):
305
+ allowlist.restore(repo_root, snapshot)
306
+ if own:
307
+ print_err(
308
+ f"The reason would itself be flagged (rule {own[0].get('RuleID')}). "
309
+ "Don't repeat the flagged value in the reason; describe it instead."
310
+ )
311
+ else:
312
+ print_err("A checker could not complete. No override recorded.")
313
+ sys.exit(1)
314
+ for finding in added:
315
+ print_info(f"Recorded override {finding['id']} for {finding['RuleID']} in {finding['File']}.")
316
+ if not added:
317
+ print_info("Those ids were already recorded.")
318
+ print_info(f"{allowlist.ALLOW_FILE} is staged; the commit must include it.")
319
+
320
+
169
321
  def cmd_check(args):
170
322
  """Noninteractive staged inspection for existing pre-commit hook managers."""
171
323
  repo_root = run_cmd(["git", "rev-parse", "--show-toplevel"]).stdout.strip()
@@ -177,7 +329,13 @@ def cmd_check(args):
177
329
  failures = [result for result in results if not result.clean]
178
330
  if failures:
179
331
  print_failures(failures)
180
- print_err("Inspection blocked. No approval override is available for 'check'.")
332
+ if any(result.error for result in failures) and not any(
333
+ isinstance(result.findings, list) for result in failures
334
+ ):
335
+ problems = "; ".join(f"{result.name}: {result.findings}" for result in failures if result.error)
336
+ print_err(f"Inspection blocked: {problems} Fix this first; no override is available.")
337
+ else:
338
+ print_override_instructions(failures, repo_root, getattr(args, "config", None))
181
339
  sys.exit(1)
182
340
  print_info("All configured checks passed for staged changes.")
183
341
 
@@ -415,9 +573,14 @@ def main():
415
573
  install_p.add_argument("--check-only", action="store_true", help="Install a pre-commit check without signing (with --local)")
416
574
  install_p.add_argument("--gitleaks", help="Existing Gitleaks executable for a local check-only installation")
417
575
 
418
- check_p = subparsers.add_parser("check", help="Inspect staged changes without signing or allowing overrides")
576
+ check_p = subparsers.add_parser("check", help="Inspect staged changes without signing")
419
577
  check_p.add_argument("--config", help="Explicit local checker policy; defaults to .leftblock.yml")
420
578
 
579
+ allow_p = subparsers.add_parser("allow", help="Record a reviewed override for current findings")
580
+ allow_p.add_argument("ids", nargs="+", metavar="ID", help="Finding id(s) shown by the blocked check")
581
+ allow_p.add_argument("--reason", default=None, help="Why the finding is not sensitive")
582
+ allow_p.add_argument("--config", help="Checker policy; defaults to the local install policy, else .leftblock.yml")
583
+
421
584
  hook_p = subparsers.add_parser("hook", help="Run checkers (used internally by git hook)")
422
585
  hook_p.add_argument("commit_msg_file", help="Path to the commit message file")
423
586
 
@@ -433,6 +596,8 @@ def main():
433
596
  cmd_install(args)
434
597
  elif args.command == "check":
435
598
  cmd_check(args)
599
+ elif args.command == "allow":
600
+ cmd_allow(args)
436
601
  elif args.command == "hook":
437
602
  cmd_hook(args)
438
603
  elif args.command == "verify":
@@ -113,10 +113,15 @@ def run_command_checker(checker: Dict[str, Any], cwd: str = None, env: Dict[str,
113
113
  raise ValueError("Invalid report")
114
114
  # Never carry matched values, source lines, descriptions or raw logs
115
115
  # into the UI. Files/rule identifiers are the diagnostic boundary.
116
- findings = [
117
- {key: item[key] for key in ("File", "RuleID") if isinstance(item.get(key), str)}
118
- for item in report
119
- ]
116
+ # Line numbers are kept only to derive an override id and are
117
+ # dropped before display (see leftblock.allow.attach_ids).
118
+ findings = []
119
+ for item in report:
120
+ finding = {key: item[key] for key in ("File", "RuleID") if isinstance(item.get(key), str)}
121
+ for key in ("StartLine", "EndLine"):
122
+ if type(item.get(key)) is int:
123
+ finding[key] = item[key]
124
+ findings.append(finding)
120
125
  except (OSError, ValueError, TypeError):
121
126
  return CheckerResult(name, False, "Checker report unavailable or invalid; output suppressed.", error=True)
122
127
  return CheckerResult(name, False, findings)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leftblock
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: LeftBlock: a pluggable inspection gate for Git commits
5
5
  License: MIT License
6
6
 
@@ -55,7 +55,7 @@ flowchart LR
55
55
  style Check fill:#e5f3ff,stroke:#175cd3
56
56
  ```
57
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.
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 offers no interactive override and adds no receipt; a false alarm can be handled only by a recorded, reasoned override that is committed and reviewable (see [Overriding a false alarm](#overriding-a-false-alarm)). 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
59
 
60
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
61
 
@@ -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.3
127
+ python -m pip install leftblock==0.3.4
128
128
  python -m leftblock.cli --help
129
129
  ```
130
130
 
@@ -162,11 +162,59 @@ leftblock check --config /absolute/path/to/policy.yml
162
162
 
163
163
  Use the actual policy path for the repository. The command returns a
164
164
  nonzero status for findings, unavailable scanners, or an invalid setup;
165
- it never offers an override. Before adopting a hook in another project,
165
+ it offers no interactive override, only the recorded override described
166
+ below. Before adopting a hook in another project,
166
167
  verify that a synthetic sensitive fixture is blocked and a clean fixture
167
168
  passes in a throwaway repository. Keep private policy files in local Git
168
169
  metadata or another private location.
169
170
 
171
+ ### Overriding a false alarm
172
+
173
+ A block stays the default. If a finding is a false alarm (for example a
174
+ 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:
177
+
178
+ ```bash
179
+ leftblock allow ID [ID ...] --reason "Why this is not sensitive"
180
+ ```
181
+
182
+ `allow` re-runs the checks on the staged changes, appends one entry per id
183
+ to `.leftblock-allow.yml` at the repository root, and stages that file. It
184
+ never commits. The reason is required and must be at least 10 characters.
185
+ An entry looks like:
186
+
187
+ ```yaml
188
+ version: 1
189
+ allow:
190
+ - id: "3f2a9c1e7b4d5a60"
191
+ rule: "leftblock-personal-email"
192
+ file: "apps/web/privacy/page.tsx"
193
+ reason: "Public role address required in the privacy notice."
194
+ added: 2026-10-20
195
+ ```
196
+
197
+ - **Scoped to line content.** The id hashes the rule, the file, and the exact
198
+ staged content of the flagged lines. Editing that line, or adding another
199
+ finding anywhere, blocks again.
200
+ - **Staged to count.** Only the version of `.leftblock-allow.yml` in the Git
201
+ index is read, so the override is always part of the commit and its review.
202
+ A file that is only in the working tree has no effect.
203
+ - **Visible every time.** Each run prints the file, rule, id, and reason of
204
+ every applied override.
205
+ - **Fail closed.** Invalid YAML, an unsupported version, an entry missing
206
+ `id`, `rule`, or `file`, or a reason under 10 characters blocks the commit.
207
+ Scanner errors are never overridden, and a finding with no id (its lines
208
+ could not be read) must be removed instead.
209
+ - **Configuration.** Without `--config`, `allow` uses the local install's
210
+ policy when present, otherwise `.leftblock.yml`; pass the same `--config`
211
+ you use for `check` if it differs.
212
+
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.
217
+
170
218
  During an in-progress merge, command checkers skip only regular files whose
171
219
  complete blob and mode already exist at the same path in a parent. Changed
172
220
  resolutions and renamed destinations retain their full staged diff, so
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  leftblock/__init__.py
5
+ leftblock/allow.py
5
6
  leftblock/cli.py
6
7
  leftblock/core.py
7
8
  leftblock/hooks.py
@@ -19,4 +20,5 @@ tests/test_default_config.py
19
20
  tests/test_false_positives.py
20
21
  tests/test_install.py
21
22
  tests/test_local_install.py
23
+ tests/test_recorded_overrides.py
22
24
  tests/test_sensitive_rules.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "leftblock"
7
- version = "0.3.3"
7
+ version = "0.3.4"
8
8
  description = "LeftBlock: a pluggable inspection gate for Git commits"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -196,7 +196,7 @@ class DistributionOwnershipTests(unittest.TestCase):
196
196
 
197
197
  def test_distribution_owns_only_leftblock_command(self):
198
198
  self.assertIn("\nName: leftblock\n", "\n" + self.metadata)
199
- self.assertIn("\nVersion: 0.3.3\n", "\n" + self.metadata)
199
+ self.assertIn("\nVersion: 0.3.4\n", "\n" + self.metadata)
200
200
  self.assertIn("\nLicense-File: LICENSE\n", "\n" + self.metadata)
201
201
  self.assertEqual(dict(self.entry_points["console_scripts"]), {"leftblock": "leftblock.cli:main"})
202
202
 
@@ -208,7 +208,7 @@ class DistributionOwnershipTests(unittest.TestCase):
208
208
  self.assertIn(name, self.names)
209
209
  self.assertEqual(
210
210
  {name.split("/")[0] for name in self.names},
211
- {"leftblock", "leftblock-0.3.3.dist-info"},
211
+ {"leftblock", "leftblock-0.3.4.dist-info"},
212
212
  )
213
213
  self.assertEqual(self.rules, (PROJECT_ROOT / "leftblock/data/gitleaks.toml").read_bytes())
214
214
  self.assertEqual(self.license, (PROJECT_ROOT / "LICENSE").read_bytes())
@@ -0,0 +1,253 @@
1
+ """Recorded per-finding overrides, using synthetic data and disposable repos."""
2
+
3
+ import re
4
+ import unittest
5
+
6
+ import yaml
7
+
8
+ from test_commit_gate import GITLEAKS, RepositoryFixture
9
+
10
+ # Flagged by the shipped personal-email rule; the rule allowlists only
11
+ # example.* and GitHub identities.
12
+ EMAIL = "info" + "@" + "public-role.test"
13
+ OTHER_EMAIL = "help" + "@" + "public-role.test"
14
+ PAGE = "page.tsx"
15
+ REASON = "Public role address for the site"
16
+
17
+
18
+ @unittest.skipUnless(GITLEAKS, "Gitleaks must be installed for actual scanner tests")
19
+ class RecordedOverrideTests(RepositoryFixture):
20
+ def setUp(self):
21
+ super().setUp()
22
+ proc = self.leftblock("install", "--local", "--check-only", "--gitleaks", GITLEAKS)
23
+ self.assertEqual(proc.returncode, 0, "Local install failed")
24
+
25
+ def write_stage(self, text, name=PAGE):
26
+ (self.repo / name).write_text(text)
27
+ self.git("add", name, check=True)
28
+
29
+ def check(self):
30
+ policy = self.repo / ".git" / "leftblock" / "policy.json"
31
+ return self.leftblock("check", "--config", str(policy))
32
+
33
+ def ids(self, proc):
34
+ return re.findall(r'"id": "([0-9a-f]{16})"', proc.stderr)
35
+
36
+ def blocked_id(self, text=None):
37
+ self.write_stage(text or f"a\nContact: {EMAIL}\n")
38
+ proc = self.check()
39
+ self.assertNotEqual(proc.returncode, 0)
40
+ ids = self.ids(proc)
41
+ self.assertEqual(len(ids), 1)
42
+ return ids[0]
43
+
44
+ def allow(self, *ids, reason=REASON):
45
+ return self.leftblock("allow", *ids, "--reason", reason)
46
+
47
+ def test_block_output_has_id_and_instructions_but_no_value(self):
48
+ ident = self.blocked_id()
49
+ proc = self.check()
50
+ out = proc.stdout + proc.stderr
51
+ self.assertIn(f"allow {ident}", out)
52
+ self.assertIn("record a reviewed override", out)
53
+ self.assertIn("--reason", out)
54
+ self.assertNotIn(EMAIL, out)
55
+ self.assertNotIn("public-role", out)
56
+ self.assertNotIn("No approval override", out)
57
+
58
+ def test_last_line_alone_names_the_finding_and_next_step(self):
59
+ # Wrappers such as `kbs commit` often show only the final line.
60
+ ident = self.blocked_id()
61
+ last = self.check().stderr.strip().splitlines()[-1]
62
+ self.assertIn("Inspection blocked", last)
63
+ self.assertIn(f"leftblock-personal-email in {PAGE} [id {ident}]", last)
64
+ self.assertIn("Remove the flagged content", last)
65
+ self.assertIn(f"allow {ident}", last)
66
+ self.assertNotIn("\033[", last) # captured output carries no colour codes
67
+
68
+ def test_allow_stages_entry_then_check_passes_and_reports(self):
69
+ ident = self.blocked_id()
70
+ proc = self.allow(ident)
71
+ self.assertEqual(proc.returncode, 0, proc.stderr)
72
+ staged = self.git("show", ":.leftblock-allow.yml", check=True).stdout
73
+ data = yaml.safe_load(staged)
74
+ self.assertEqual(data["version"], 1)
75
+ entry = data["allow"][0]
76
+ self.assertEqual(
77
+ (entry["id"], entry["rule"], entry["file"], entry["reason"]),
78
+ (ident, "leftblock-personal-email", PAGE, REASON),
79
+ )
80
+ self.assertNotIn(EMAIL, staged)
81
+ check = self.check()
82
+ self.assertEqual(check.returncode, 0, check.stderr)
83
+ self.assertIn("Override applied", check.stderr)
84
+ self.assertIn(ident, check.stderr)
85
+ self.assertIn(REASON, check.stderr)
86
+ # Overrides stay visible on every run, and the commit goes through.
87
+ self.assertEqual(self.git("commit", "-m", "test: allowed").returncode, 0)
88
+
89
+ def test_allow_file_itself_is_not_flagged_and_dedupes(self):
90
+ ident = self.blocked_id()
91
+ self.assertEqual(self.allow(ident).returncode, 0)
92
+ self.assertEqual(self.allow(ident).returncode, 0)
93
+ data = yaml.safe_load((self.repo / ".leftblock-allow.yml").read_text())
94
+ self.assertEqual(len(data["allow"]), 1)
95
+ scan = self.check()
96
+ self.assertEqual(scan.returncode, 0, scan.stderr)
97
+ # Not the hex id, but the file as a whole: a numeric-looking id stays a string.
98
+ self.assertIsInstance(data["allow"][0]["id"], str)
99
+
100
+ def test_new_email_in_same_file_still_blocks(self):
101
+ ident = self.blocked_id()
102
+ self.assertEqual(self.allow(ident).returncode, 0)
103
+ self.write_stage(f"a\nContact: {EMAIL}\nAlso: {OTHER_EMAIL}\n")
104
+ proc = self.check()
105
+ self.assertNotEqual(proc.returncode, 0)
106
+ self.assertEqual(len(self.ids(proc)), 1)
107
+ self.assertNotIn(ident, self.ids(proc))
108
+
109
+ def test_changed_allowed_line_reblocks(self):
110
+ ident = self.blocked_id()
111
+ self.assertEqual(self.allow(ident).returncode, 0)
112
+ self.write_stage(f"a\nWrite to: {EMAIL}\n")
113
+ proc = self.check()
114
+ self.assertNotEqual(proc.returncode, 0)
115
+ self.assertNotIn(ident, self.ids(proc))
116
+
117
+ def test_line_numbers_inside_modified_committed_file(self):
118
+ self.write_stage("one\ntwo\nthree\n")
119
+ self.git("commit", "-m", "test: base", check=True)
120
+ ident = self.blocked_id(f"one\ntwo\nthree\nContact: {EMAIL}\n")
121
+ self.assertEqual(self.allow(ident).returncode, 0)
122
+ proc = self.check()
123
+ self.assertEqual(proc.returncode, 0, proc.stderr)
124
+
125
+ def test_reason_too_short_or_missing_is_refused(self):
126
+ ident = self.blocked_id()
127
+ for reason in ("", " short "):
128
+ with self.subTest(reason=reason):
129
+ proc = self.allow(ident, reason=reason)
130
+ self.assertEqual(proc.returncode, 1)
131
+ missing = self.leftblock("allow", ident)
132
+ self.assertEqual(missing.returncode, 1)
133
+ self.assertIn("at least 10 characters", missing.stderr)
134
+ self.assertNotIn("usage:", missing.stderr)
135
+ self.assertFalse((self.repo / ".leftblock-allow.yml").exists())
136
+
137
+ def index_blob(self):
138
+ return self.git("ls-files", "--stage", "--", ".leftblock-allow.yml").stdout
139
+
140
+ def test_reason_repeating_value_is_refused_and_restored_when_absent(self):
141
+ ident = self.blocked_id()
142
+ proc = self.allow(ident, reason=f"Public address {EMAIL} on the site")
143
+ self.assertEqual(proc.returncode, 1)
144
+ self.assertIn("The reason would itself be flagged (rule leftblock-personal-email)", proc.stderr)
145
+ self.assertIn("describe it instead", proc.stderr)
146
+ self.assertFalse((self.repo / ".leftblock-allow.yml").exists())
147
+ self.assertEqual(self.index_blob(), "")
148
+
149
+ def test_reason_repeating_value_restores_existing_entries(self):
150
+ first = self.blocked_id()
151
+ self.assertEqual(self.allow(first).returncode, 0)
152
+ path = self.repo / ".leftblock-allow.yml"
153
+ before, staged = path.read_bytes(), self.index_blob()
154
+ self.write_stage(f"x\nContact: {OTHER_EMAIL}\n", name="second.tsx")
155
+ proc = self.check()
156
+ second = [i for i in self.ids(proc) if i != first]
157
+ self.assertEqual(len(second), 1)
158
+ # Unstaged edit to the file must also survive a refused attempt.
159
+ path.write_bytes(before + b"# local note\n")
160
+ before = path.read_bytes()
161
+ refused = self.allow(second[0], reason=f"It is {OTHER_EMAIL} really")
162
+ self.assertEqual(refused.returncode, 1)
163
+ self.assertEqual(path.read_bytes(), before)
164
+ self.assertEqual(self.index_blob(), staged)
165
+
166
+ def test_finding_in_allow_file_has_no_id_and_friendly_message(self):
167
+ ident = self.blocked_id()
168
+ self.assertEqual(self.allow(ident).returncode, 0)
169
+ path = self.repo / ".leftblock-allow.yml"
170
+ path.write_text(path.read_text().replace(REASON, f"Address {EMAIL} is public"))
171
+ self.git("add", ".leftblock-allow.yml", check=True)
172
+ proc = self.check()
173
+ self.assertNotEqual(proc.returncode, 0)
174
+ self.assertEqual(self.ids(proc), [])
175
+ self.assertIn(
176
+ "A reason in .leftblock-allow.yml contains a flagged value; "
177
+ "reword that reason (don't repeat the value).", proc.stderr)
178
+ self.assertNotIn("allow ", proc.stderr.split("blocked")[-1])
179
+
180
+ def test_hand_edited_entry_with_empty_reason_blocks_as_invalid(self):
181
+ ident = self.blocked_id()
182
+ self.assertEqual(self.allow(ident).returncode, 0)
183
+ path = self.repo / ".leftblock-allow.yml"
184
+ data = yaml.safe_load(path.read_text())
185
+ data["allow"][0]["reason"] = ""
186
+ path.write_text(yaml.safe_dump(data))
187
+ self.git("add", ".leftblock-allow.yml", check=True)
188
+ proc = self.check()
189
+ self.assertNotEqual(proc.returncode, 0)
190
+ self.assertIn("Invalid .leftblock-allow.yml", proc.stderr)
191
+
192
+ def test_invalid_allow_files_block_even_on_clean_content(self):
193
+ self.write_stage("Harmless.\n")
194
+ path = self.repo / ".leftblock-allow.yml"
195
+ bad = (
196
+ "version: [",
197
+ "version: 2\nallow: []\n",
198
+ "version: 1\nallow:\n - rule: r\n file: f\n reason: long enough reason\n",
199
+ )
200
+ for text in bad:
201
+ with self.subTest(text=text):
202
+ path.write_text(text)
203
+ self.git("add", ".leftblock-allow.yml", check=True)
204
+ proc = self.check()
205
+ self.assertNotEqual(proc.returncode, 0)
206
+ self.assertIn("Invalid .leftblock-allow.yml", proc.stderr)
207
+
208
+ def test_unstaged_allow_file_has_no_effect(self):
209
+ ident = self.blocked_id()
210
+ self.assertEqual(self.allow(ident).returncode, 0)
211
+ self.git("reset", "-q", "--", ".leftblock-allow.yml", check=True)
212
+ self.assertTrue((self.repo / ".leftblock-allow.yml").exists())
213
+ proc = self.check()
214
+ self.assertNotEqual(proc.returncode, 0)
215
+
216
+ def test_unknown_id_is_refused(self):
217
+ self.blocked_id()
218
+ proc = self.allow("0123456789abcdef")
219
+ self.assertEqual(proc.returncode, 1)
220
+ self.assertFalse((self.repo / ".leftblock-allow.yml").exists())
221
+
222
+ def test_allow_does_not_commit(self):
223
+ ident = self.blocked_id()
224
+ self.assertEqual(self.allow(ident).returncode, 0)
225
+ self.assert_not_committed()
226
+
227
+
228
+ class OverrideNeverCoversErrorsTests(RepositoryFixture):
229
+ def test_missing_scanner_is_not_overridden(self):
230
+ self.stage(f"Contact: {EMAIL}\n")
231
+ self.policy("definitely-missing-scanner-binary --scan")
232
+ entry = {
233
+ "version": 1,
234
+ "allow": [{"id": "0123456789abcdef", "rule": "x", "file": "fixture.txt",
235
+ "reason": "A long enough reason."}],
236
+ }
237
+ (self.repo / ".leftblock-allow.yml").write_text(yaml.safe_dump(entry))
238
+ self.git("add", ".leftblock-allow.yml", check=True)
239
+ self.assertNotEqual(self.leftblock("check").returncode, 0)
240
+ proc = self.leftblock("allow", "0123456789abcdef", "--reason", REASON)
241
+ self.assertEqual(proc.returncode, 1)
242
+
243
+ def test_error_result_with_matching_entry_stays_blocked(self):
244
+ from leftblock.allow import apply_allowlist
245
+ from leftblock.core import CheckerResult
246
+ result = CheckerResult("s", False, [{"File": "f", "RuleID": "r", "id": "a" * 16}], error=True)
247
+ entries = [{"id": "a" * 16, "rule": "r", "file": "f", "reason": "A long enough reason."}]
248
+ self.assertEqual(apply_allowlist([result], entries), [])
249
+ self.assertFalse(result.clean)
250
+
251
+
252
+ if __name__ == "__main__":
253
+ unittest.main()
File without changes
File without changes
File without changes
File without changes