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 ADDED
@@ -0,0 +1 @@
1
+ """LeftBlock: local inspection gates for Git commits."""
leftblock/cli.py ADDED
@@ -0,0 +1,444 @@
1
+ import argparse
2
+ import sys
3
+ import os
4
+ import json
5
+ import subprocess
6
+ import getpass
7
+ import yaml
8
+ from datetime import datetime, timezone
9
+ from leftblock.merge import (
10
+ MergeScopeError, build_scoped_index, checker_environment, index_entries, merge_state,
11
+ )
12
+ from leftblock.core import (
13
+ get_secret, compute_hmac, get_tree_hash, get_commit_tree_hash,
14
+ load_config, validate_checkers, execute_checkers, get_trailer, run_cmd, SECRET_FILE_DEFAULT,
15
+ CheckerResult,
16
+ )
17
+
18
+ def print_info(msg):
19
+ print(f"\033[1;34m[leftblock]\033[0m {msg}", file=sys.stderr)
20
+
21
+ def print_warn(msg):
22
+ print(f"\033[1;33m[leftblock]\033[0m {msg}", file=sys.stderr)
23
+
24
+ def print_err(msg):
25
+ print(f"\033[1;31m[leftblock ERROR]\033[0m {msg}", file=sys.stderr)
26
+
27
+ DEFAULT_LEFTBLOCK_YML = """\
28
+ version: 1
29
+ checkers:
30
+ - name: gitleaks
31
+ type: command
32
+ command: gitleaks protect --staged --config .leftblock/gitleaks.toml --redact --no-banner --report-format json --report-path {report_file}
33
+ success_codes: [0]
34
+ finding_codes: [1]
35
+ """
36
+
37
+ def write_default_configs(repo_root):
38
+ """Install the zero-config defaults into a repo.
39
+
40
+ Writes a default .leftblock.yml (pointing at the shipped gitleaks
41
+ ruleset) and copies the packaged gitleaks.toml into
42
+ <repo>/.leftblock/. Existing files are never overwritten.
43
+ """
44
+ config_dir = os.path.join(repo_root, ".leftblock")
45
+ leftblock_yml = os.path.join(repo_root, ".leftblock.yml")
46
+ ruleset_target = os.path.join(config_dir, "gitleaks.toml")
47
+
48
+ if not os.path.exists(leftblock_yml):
49
+ with open(leftblock_yml, "w") as f:
50
+ f.write(DEFAULT_LEFTBLOCK_YML)
51
+ print_info(f"Wrote default checker config at {leftblock_yml}")
52
+ else:
53
+ print_info(f"Existing {leftblock_yml} left untouched.")
54
+
55
+ packaged_ruleset = os.path.join(
56
+ os.path.dirname(os.path.abspath(__file__)), "data", "gitleaks.toml"
57
+ )
58
+ if os.path.exists(packaged_ruleset):
59
+ os.makedirs(config_dir, exist_ok=True)
60
+ if not os.path.exists(ruleset_target):
61
+ import shutil
62
+ shutil.copyfile(packaged_ruleset, ruleset_target)
63
+ print_info(f"Installed shipped gitleaks ruleset at {ruleset_target}")
64
+ else:
65
+ print_info(f"Existing {ruleset_target} left untouched.")
66
+ else:
67
+ print_warn(f"Packaged ruleset missing at {packaged_ruleset}; default gitleaks rules only.")
68
+
69
+ def cmd_install(args):
70
+ """Setup the git hook and secret."""
71
+ repo_root = run_cmd(
72
+ ["git", "rev-parse", "--show-toplevel"], check=False
73
+ ).stdout.strip()
74
+ if not repo_root:
75
+ print_err("Run installation inside a Git repository.")
76
+ sys.exit(1)
77
+ if getattr(args, "local", False) or getattr(args, "check_only", False):
78
+ if not (args.local and args.check_only):
79
+ print_err("Use --local and --check-only together for a local pre-commit gate.")
80
+ sys.exit(1)
81
+ from leftblock.hooks import install_local_check_hook
82
+ try:
83
+ install_local_check_hook(repo_root, getattr(args, "gitleaks", None))
84
+ except (ValueError, OSError) as exc:
85
+ print_err(str(exc))
86
+ sys.exit(1)
87
+ print_info("Installed local LeftBlock pre-commit gate; no signing secret is needed.")
88
+ return
89
+ if getattr(args, "gitleaks", None):
90
+ print_err("--gitleaks is supported with --local --check-only.")
91
+ sys.exit(1)
92
+ from leftblock.hooks import install_hook
93
+ try:
94
+ install_hook(repo_root)
95
+ except (ValueError, OSError) as exc:
96
+ print_err(str(exc))
97
+ sys.exit(1)
98
+ write_default_configs(repo_root)
99
+ print_info("Installed LeftBlock commit-msg hook.")
100
+
101
+ # Generate secret if needed
102
+ secret_dir = os.path.dirname(SECRET_FILE_DEFAULT)
103
+ if not os.path.exists(SECRET_FILE_DEFAULT):
104
+ os.makedirs(secret_dir, exist_ok=True)
105
+ secret = os.urandom(16).hex()
106
+ with open(SECRET_FILE_DEFAULT, 'w') as f:
107
+ f.write(secret)
108
+ os.chmod(SECRET_FILE_DEFAULT, 0o600)
109
+ print_info(f"Generated new shared secret at {SECRET_FILE_DEFAULT}")
110
+ else:
111
+ print_info(f"Shared secret already exists at {SECRET_FILE_DEFAULT}")
112
+
113
+ def configured_checks(repo_root, config_path=None):
114
+ try:
115
+ config = load_config(repo_root, config_path)
116
+ checkers = validate_checkers(config)
117
+ except (OSError, ValueError, yaml.YAMLError):
118
+ print_err("Missing or invalid checker configuration. Inspection blocked; review the selected policy.")
119
+ sys.exit(1)
120
+ # Inherited whole files at the same path can be omitted during a merge.
121
+ # Changed/resolved files and renamed paths keep their full staged diff.
122
+ # Any failure to scope falls back to the full staged scan (stricter).
123
+ scoped_index = None
124
+ try:
125
+ base_env = checker_environment()
126
+ except (MergeScopeError, ValueError):
127
+ return config, [CheckerResult(
128
+ "git-environment", False, "Invalid Git checker environment.", error=True,
129
+ )]
130
+ try:
131
+ state = merge_state(repo_root)
132
+ if state:
133
+ scoped_index = build_scoped_index(repo_root, state[1], head=state[0])
134
+ entries = index_entries(repo_root, scoped_index)
135
+ env = checker_environment(scoped_index)
136
+ except (MergeScopeError, OSError, ValueError):
137
+ if scoped_index and os.path.exists(scoped_index):
138
+ os.remove(scoped_index)
139
+ scoped_index = None
140
+ try:
141
+ results = execute_checkers(checkers, cwd=repo_root, env=env if scoped_index else base_env)
142
+ if scoped_index:
143
+ try:
144
+ stable = merge_state(repo_root) == state and index_entries(repo_root, scoped_index) == entries
145
+ except (MergeScopeError, OSError, ValueError):
146
+ stable = False
147
+ if not stable:
148
+ results.append(CheckerResult(
149
+ "merge-scope", False,
150
+ "Merge state or inspected index changed during inspection. Retry the check.",
151
+ error=True,
152
+ ))
153
+ return config, results
154
+ finally:
155
+ if scoped_index and os.path.exists(scoped_index):
156
+ os.remove(scoped_index)
157
+
158
+
159
+ def print_failures(failures):
160
+ for result in failures:
161
+ print_warn(f"Checker {json.dumps(str(result.name))} did not pass.")
162
+ if isinstance(result.findings, list):
163
+ for finding in result.findings:
164
+ print(" " + json.dumps(finding, ensure_ascii=True), file=sys.stderr)
165
+ else:
166
+ print_warn(str(result.findings))
167
+
168
+
169
+ def cmd_check(args):
170
+ """Noninteractive staged inspection for existing pre-commit hook managers."""
171
+ repo_root = run_cmd(["git", "rev-parse", "--show-toplevel"]).stdout.strip()
172
+ tree_hash = get_tree_hash()
173
+ _, results = configured_checks(repo_root, getattr(args, "config", None))
174
+ if get_tree_hash() != tree_hash:
175
+ print_err("Staged tree changed during inspection. Check blocked; run it again.")
176
+ sys.exit(1)
177
+ failures = [result for result in results if not result.clean]
178
+ if failures:
179
+ print_failures(failures)
180
+ print_err("Inspection blocked. No approval override is available for 'check'.")
181
+ sys.exit(1)
182
+ print_info("All configured checks passed for staged changes.")
183
+
184
+
185
+ def cmd_hook(args):
186
+ """Run checkers on staged files and sign the commit."""
187
+ repo_root = run_cmd(["git", "rev-parse", "--show-toplevel"]).stdout.strip()
188
+ try:
189
+ secret = get_secret()
190
+ except FileNotFoundError:
191
+ print_err("Shared secret not found. Run 'leftblock install' first.")
192
+ sys.exit(1)
193
+
194
+ tree_hash = get_tree_hash()
195
+ print_info(f"Scanning tree {tree_hash}...")
196
+
197
+ config, results = configured_checks(repo_root)
198
+ if get_tree_hash() != tree_hash:
199
+ print_err("Staged tree changed during inspection. Commit blocked; run the checks again.")
200
+ sys.exit(1)
201
+
202
+ all_clean = True
203
+ failed_checkers = []
204
+ for res in results:
205
+ if not res.clean:
206
+ all_clean = False
207
+ failed_checkers.append(res)
208
+
209
+ validator_str = f"leftblock-v{config.get('version', '1')}"
210
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
211
+
212
+ if all_clean:
213
+ result_str = "clean"
214
+ print_info("✓ All checks passed.")
215
+ else:
216
+ print_warn(f"✗ Found issues in {len(failed_checkers)} checker(s):")
217
+ print_failures(failed_checkers)
218
+ if any(result.error for result in failed_checkers):
219
+ print_err("A checker could not complete. Commit blocked; fix the checker before retrying.")
220
+ sys.exit(1)
221
+
222
+ if not sys.stdin.isatty():
223
+ print_err("Findings require human approval but no interactive terminal is available. Commit blocked.")
224
+ sys.exit(1)
225
+
226
+ print_warn("Commit will be blocked unless you approve these findings.")
227
+ password = getpass.getpass(f"\033[1m[leftblock]\033[0m Enter approval passphrase to override: ").strip()
228
+
229
+ if password != secret:
230
+ print_err("Incorrect passphrase. Commit blocked.")
231
+ sys.exit(1)
232
+
233
+ result_str = f"override:{len(failed_checkers)}-checkers"
234
+ print_info("✓ Override approved.")
235
+
236
+ if get_tree_hash() != tree_hash:
237
+ print_err("Staged tree changed before signing. Commit blocked; run the checks again.")
238
+ sys.exit(1)
239
+ hmac_sig = compute_hmac(tree_hash, validator_str, result_str, timestamp, secret)
240
+
241
+ # Append trailers
242
+ msg_file = args.commit_msg_file
243
+ with open(msg_file, 'r') as f:
244
+ msg_content = f.read()
245
+
246
+ proc = subprocess.run(
247
+ ["git", "interpret-trailers",
248
+ f"--trailer=Inspected-by: {validator_str}",
249
+ f"--trailer=Inspection-tree: {tree_hash}",
250
+ f"--trailer=Inspection-result: {result_str}",
251
+ f"--trailer=Inspection-at: {timestamp}",
252
+ f"--trailer=Inspection-sig: hmac-sha256:{hmac_sig}"],
253
+ input=msg_content, text=True, capture_output=True, check=True
254
+ )
255
+
256
+ with open(msg_file, 'w') as f:
257
+ f.write(proc.stdout)
258
+
259
+ print_info(f"✓ Commit signed: hmac-sha256:{hmac_sig[:16]}...")
260
+
261
+ def cmd_approve(args):
262
+ """Retroactively approve commits by generating a paperwork commit."""
263
+ repo_root = run_cmd(["git", "rev-parse", "--show-toplevel"]).stdout.strip()
264
+
265
+ try:
266
+ secret = get_secret()
267
+ except FileNotFoundError:
268
+ print_err("Shared secret not found. Run 'leftblock install'.")
269
+ sys.exit(1)
270
+
271
+ range_str = args.range
272
+ if ".." in range_str:
273
+ commits = run_cmd(["git", "rev-list", "--reverse", range_str]).stdout.splitlines()
274
+ else:
275
+ commits = [run_cmd(["git", "rev-parse", range_str]).stdout.strip()]
276
+
277
+ if not commits:
278
+ print_info("No commits found in range.")
279
+ sys.exit(0)
280
+
281
+ try:
282
+ config = load_config(repo_root)
283
+ checkers = validate_checkers(config)
284
+ except (OSError, ValueError, yaml.YAMLError):
285
+ print_err("Missing or invalid checker configuration. Approval blocked.")
286
+ sys.exit(1)
287
+
288
+ validator_str = f"leftblock-v{config.get('version', '1')}"
289
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
290
+
291
+ trailers = []
292
+
293
+ for sha in commits:
294
+ short = sha[:7]
295
+ print_info(f"Checking commit {short}...")
296
+ actual_tree = get_commit_tree_hash(sha)
297
+
298
+ # Set up a worktree for the commit
299
+ import tempfile
300
+ wt_dir = tempfile.mkdtemp()
301
+ run_cmd(["git", "worktree", "add", "--detach", wt_dir, sha])
302
+
303
+ try:
304
+ # We want to scan the diff introduced by this commit, so we soft reset to its parent.
305
+ parent = run_cmd(["git", "rev-parse", f"{sha}~1"], check=False).stdout.strip()
306
+ if not parent:
307
+ # Root commit
308
+ empty_tree = run_cmd(["git", "hash-object", "-t", "tree", "/dev/null"]).stdout.strip()
309
+ run_cmd(["git", "reset", "--soft", empty_tree], cwd=wt_dir)
310
+ else:
311
+ run_cmd(["git", "reset", "--soft", parent], cwd=wt_dir)
312
+
313
+ results = execute_checkers(checkers, cwd=wt_dir)
314
+ finally:
315
+ run_cmd(["git", "worktree", "remove", "-f", wt_dir])
316
+
317
+ all_clean = True
318
+ failed_checkers = []
319
+ for res in results:
320
+ if not res.clean:
321
+ all_clean = False
322
+ failed_checkers.append(res)
323
+
324
+ if all_clean:
325
+ result_str = "clean"
326
+ else:
327
+ print_warn(f"✗ Found issues in {short} for {len(failed_checkers)} checker(s).")
328
+ if any(result.error for result in failed_checkers):
329
+ print_err("A checker could not complete. Approval blocked.")
330
+ sys.exit(1)
331
+ if not sys.stdin.isatty():
332
+ print_err("Findings require human approval but no interactive terminal is available. Approval blocked.")
333
+ sys.exit(1)
334
+
335
+ password = getpass.getpass(f"\033[1m[leftblock]\033[0m Enter approval passphrase to override for {short}: ").strip()
336
+ if password != secret:
337
+ print_err("Incorrect passphrase. Approval blocked.")
338
+ sys.exit(1)
339
+ result_str = f"override:{len(failed_checkers)}-checkers"
340
+
341
+ hmac_sig = compute_hmac(actual_tree, validator_str, result_str, timestamp, secret)
342
+
343
+ trailers.append(f"--trailer=Inspected-by: {validator_str}")
344
+ trailers.append(f"--trailer=Inspection-tree: {actual_tree}")
345
+ trailers.append(f"--trailer=Inspection-result: {result_str}")
346
+ trailers.append(f"--trailer=Inspection-at: {timestamp}")
347
+ trailers.append(f"--trailer=Inspection-sig: hmac-sha256:{hmac_sig}")
348
+
349
+ # Create the approval commit
350
+ commit_msg = "chore: retroactive leftblock approval\n"
351
+ cmd = ["git", "commit", "--allow-empty", "-F", "-"] + trailers
352
+ proc = subprocess.run(cmd, input=commit_msg, text=True, capture_output=True, check=True)
353
+ print_info(f"Created approval commit:\n{proc.stdout.strip()}")
354
+
355
+ def cmd_verify(args):
356
+ """Verify signatures for a commit range."""
357
+ try:
358
+ secret = get_secret()
359
+ except FileNotFoundError:
360
+ print_err("Shared secret not found. Run 'leftblock install'.")
361
+ sys.exit(1)
362
+
363
+ range_str = args.range
364
+ if ".." in range_str:
365
+ commits = run_cmd(["git", "rev-list", range_str]).stdout.splitlines()
366
+ else:
367
+ commits = [run_cmd(["git", "rev-parse", range_str]).stdout.strip()]
368
+
369
+ # Pool all valid signatures found in the range
370
+ valid_trees = set()
371
+
372
+ for sha in commits:
373
+ # Get all trailers from the commit message
374
+ msg = run_cmd(["git", "log", "-1", sha, "--format=%B"]).stdout
375
+ # Basic parsing of trailers. git log format for multiple identical keys is tricky,
376
+ # so we parse the raw message.
377
+ import re
378
+ trees = re.findall(r"^Inspection-tree:\s*(.+)$", msg, re.MULTILINE)
379
+ sigs = re.findall(r"^Inspection-sig:\s*hmac-sha256:(.+)$", msg, re.MULTILINE)
380
+ validators = re.findall(r"^Inspected-by:\s*(.+)$", msg, re.MULTILINE)
381
+ results = re.findall(r"^Inspection-result:\s*(.+)$", msg, re.MULTILINE)
382
+ timestamps = re.findall(r"^Inspection-at:\s*(.+)$", msg, re.MULTILINE)
383
+
384
+ # Zip them up (assuming they appear in blocks)
385
+ for t, s, v, r, ts in zip(trees, sigs, validators, results, timestamps):
386
+ expected = compute_hmac(t, v, r, ts, secret)
387
+ if s == expected:
388
+ valid_trees.add(t)
389
+
390
+ failures = 0
391
+ for sha in commits:
392
+ short = sha[:7]
393
+ actual_tree = get_commit_tree_hash(sha)
394
+
395
+ if actual_tree not in valid_trees:
396
+ print_err(f"{short}: No valid signature found for tree {actual_tree[:12]}.")
397
+ failures += 1
398
+ continue
399
+
400
+ subject = run_cmd(["git", "log", "-1", sha, "--format=%s"]).stdout.strip()[:50]
401
+ print(f"\033[1;32mPASS:\033[0m {short}: {subject} [verified]")
402
+
403
+ if failures > 0:
404
+ print_err(f"Verification failed: {failures}/{len(commits)} commit(s) invalid.")
405
+ sys.exit(1)
406
+ else:
407
+ print(f"\033[1;32mAll {len(commits)} commit(s) verified.\033[0m")
408
+
409
+ def main():
410
+ parser = argparse.ArgumentParser(description="LeftBlock - A pluggable inspection gate for Git commits")
411
+ subparsers = parser.add_subparsers(dest="command", required=True)
412
+
413
+ install_p = subparsers.add_parser("install", help="Install the commit-msg hook and setup secrets")
414
+ install_p.add_argument("--local", action="store_true", help="Keep policy in the common Git directory (with --check-only)")
415
+ install_p.add_argument("--check-only", action="store_true", help="Install a pre-commit check without signing (with --local)")
416
+ install_p.add_argument("--gitleaks", help="Existing Gitleaks executable for a local check-only installation")
417
+
418
+ check_p = subparsers.add_parser("check", help="Inspect staged changes without signing or allowing overrides")
419
+ check_p.add_argument("--config", help="Explicit local checker policy; defaults to .leftblock.yml")
420
+
421
+ hook_p = subparsers.add_parser("hook", help="Run checkers (used internally by git hook)")
422
+ hook_p.add_argument("commit_msg_file", help="Path to the commit message file")
423
+
424
+ verify_p = subparsers.add_parser("verify", help="Verify commits")
425
+ verify_p.add_argument("range", nargs="?", default="HEAD", help="Commit range (e.g. HEAD~3..HEAD)")
426
+
427
+ approve_p = subparsers.add_parser("approve", help="Retroactively approve commits")
428
+ approve_p.add_argument("range", help="Commit range to approve")
429
+
430
+ args = parser.parse_args()
431
+
432
+ if args.command == "install":
433
+ cmd_install(args)
434
+ elif args.command == "check":
435
+ cmd_check(args)
436
+ elif args.command == "hook":
437
+ cmd_hook(args)
438
+ elif args.command == "verify":
439
+ cmd_verify(args)
440
+ elif args.command == "approve":
441
+ cmd_approve(args)
442
+
443
+ if __name__ == "__main__":
444
+ main()
leftblock/core.py ADDED
@@ -0,0 +1,162 @@
1
+ import os
2
+ import sys
3
+ import subprocess
4
+ import hmac
5
+ import hashlib
6
+ import json
7
+ import time
8
+ import shlex
9
+ from datetime import datetime
10
+ from typing import List, Dict, Any, Tuple
11
+ import tempfile
12
+ import yaml
13
+
14
+ SECRET_FILE_DEFAULT = os.environ.get("LEFTBLOCK_SECRET_PATH", os.path.expanduser("~/.config/leftblock/secret"))
15
+
16
+ def get_secret(secret_path: str = SECRET_FILE_DEFAULT) -> str:
17
+ if not os.path.exists(secret_path):
18
+ raise FileNotFoundError(f"Secret file not found at {secret_path}")
19
+ with open(secret_path, 'r') as f:
20
+ return f.read().strip()
21
+
22
+ def compute_hmac(tree_hash: str, validator: str, result: str, timestamp: str, secret: str) -> str:
23
+ msg = f"{tree_hash}|{validator}|{result}|{timestamp}".encode('utf-8')
24
+ return hmac.new(secret.encode('utf-8'), msg, hashlib.sha256).hexdigest()
25
+
26
+ def run_cmd(cmd: List[str], check: bool = True, capture_output: bool = True, **kwargs) -> subprocess.CompletedProcess:
27
+ return subprocess.run(cmd, check=check, capture_output=capture_output, text=True, **kwargs)
28
+
29
+ def get_tree_hash() -> str:
30
+ return run_cmd(["git", "write-tree"]).stdout.strip()
31
+
32
+ def get_commit_tree_hash(commit_sha: str) -> str:
33
+ output = run_cmd(["git", "cat-file", "-p", commit_sha]).stdout
34
+ for line in output.splitlines():
35
+ if line.startswith("tree "):
36
+ return line.split(" ")[1].strip()
37
+ return ""
38
+
39
+ def load_config(repo_root: str, config_path: str = None) -> Dict[str, Any]:
40
+ if config_path is None:
41
+ config_path = os.path.join(repo_root, ".leftblock.yml")
42
+ if not os.path.exists(config_path):
43
+ return {}
44
+ with open(config_path, 'r') as f:
45
+ return yaml.safe_load(f) or {}
46
+
47
+
48
+ def validate_checkers(config: Dict[str, Any]) -> List[Dict[str, Any]]:
49
+ """Reject missing or unusable policy instead of signing an uninspected tree."""
50
+ if not isinstance(config, dict):
51
+ raise ValueError("Invalid checker configuration")
52
+ checkers = config.get("checkers")
53
+ if not isinstance(checkers, list) or not checkers:
54
+ raise ValueError("No checkers configured")
55
+ for checker in checkers:
56
+ if not isinstance(checker, dict):
57
+ raise ValueError("Invalid checker configuration")
58
+ ctype = checker.get("type", "command")
59
+ key = "command" if ctype == "command" else "procedure"
60
+ if ctype not in ("command", "tactus") or not isinstance(checker.get(key), str) or not checker[key].strip():
61
+ raise ValueError("Invalid checker configuration")
62
+ if ctype == "command":
63
+ success = checker.get("success_codes", [0])
64
+ findings = checker.get("finding_codes", [1])
65
+ for codes in (success, findings):
66
+ if not isinstance(codes, list) or not codes or any(type(c) is not int for c in codes):
67
+ raise ValueError("Invalid checker exit-code policy")
68
+ if set(success) & set(findings):
69
+ raise ValueError("Checker success and finding codes must not overlap")
70
+ return checkers
71
+
72
+ class CheckerResult:
73
+ def __init__(self, name: str, clean: bool, findings: Any = None, error: bool = False):
74
+ self.name = name
75
+ self.clean = clean
76
+ self.findings = findings
77
+ self.error = error
78
+
79
+
80
+ def index_environment(env=None):
81
+ """Resolve a caller-relative alternate index before changing checker cwd."""
82
+ source = os.environ if env is None else env
83
+ index = source.get("GIT_INDEX_FILE")
84
+ if index and not os.path.isabs(index):
85
+ return dict(source, GIT_INDEX_FILE=os.path.abspath(index))
86
+ return env
87
+
88
+
89
+ def run_command_checker(checker: Dict[str, Any], cwd: str = None, env: Dict[str, str] = None) -> CheckerResult:
90
+ name = checker.get('name', 'unknown')
91
+ cmd = checker.get('command', '')
92
+ success_codes = checker.get('success_codes', [0])
93
+ finding_codes = checker.get('finding_codes', [1])
94
+ report_file = None
95
+ try:
96
+ # Split before substitution so a TMPDIR with spaces stays one argument.
97
+ argv = shlex.split(cmd)
98
+ if "{report_file}" in cmd:
99
+ fd, report_file = tempfile.mkstemp(suffix=".json")
100
+ os.close(fd)
101
+ argv = [arg.replace("{report_file}", report_file) for arg in argv]
102
+ proc = run_cmd(argv, check=False, capture_output=True, cwd=cwd, env=index_environment(env))
103
+ if proc.returncode in success_codes:
104
+ return CheckerResult(name, True)
105
+ if proc.returncode not in finding_codes:
106
+ return CheckerResult(name, False, "Checker failed; output suppressed.", error=True)
107
+ findings = "Findings detected; checker output suppressed."
108
+ if report_file:
109
+ try:
110
+ with open(report_file, 'r') as f:
111
+ report = json.load(f)
112
+ if not isinstance(report, list) or not report or any(not isinstance(item, dict) for item in report):
113
+ raise ValueError("Invalid report")
114
+ # Never carry matched values, source lines, descriptions or raw logs
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
+ ]
120
+ except (OSError, ValueError, TypeError):
121
+ return CheckerResult(name, False, "Checker report unavailable or invalid; output suppressed.", error=True)
122
+ return CheckerResult(name, False, findings)
123
+ except (OSError, ValueError):
124
+ return CheckerResult(name, False, "Checker could not run; check its installation and configuration.", error=True)
125
+ finally:
126
+ if report_file and os.path.exists(report_file):
127
+ os.remove(report_file)
128
+
129
+
130
+ def run_tactus_checker(checker: Dict[str, Any], cwd: str = None) -> CheckerResult:
131
+ name = checker.get('name', 'tactus-scan')
132
+ procedure = checker.get('procedure')
133
+ if not procedure:
134
+ return CheckerResult(name, False, "No procedure specified for tactus checker", error=True)
135
+ try:
136
+ proc = run_cmd(
137
+ ["tactus", "run", procedure], check=False, capture_output=True,
138
+ cwd=cwd, env=index_environment(),
139
+ )
140
+ except OSError:
141
+ return CheckerResult(name, False, "Tactus could not run; check its installation.", error=True)
142
+ is_clean = proc.returncode == 0
143
+ return CheckerResult(
144
+ name, is_clean,
145
+ "Tactus reported a failure; output suppressed." if not is_clean else None,
146
+ error=proc.returncode not in (0, 1),
147
+ )
148
+
149
+ def execute_checkers(checkers: List[Dict[str, Any]], cwd: str = None, env: Dict[str, str] = None) -> List[CheckerResult]:
150
+ results = []
151
+ for checker in checkers:
152
+ ctype = checker.get('type', 'command')
153
+ if ctype == 'command':
154
+ results.append(run_command_checker(checker, cwd=cwd, env=env))
155
+ elif ctype == 'tactus':
156
+ results.append(run_tactus_checker(checker, cwd=cwd))
157
+ else:
158
+ results.append(CheckerResult(checker.get('name', 'unknown'), False, f"Unknown checker type: {ctype}"))
159
+ return results
160
+
161
+ def get_trailer(commit_sha: str, key: str) -> str:
162
+ return run_cmd(["git", "log", "-1", commit_sha, f"--format=%(trailers:key={key},valueonly)"]).stdout.strip()