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.
@@ -0,0 +1,245 @@
1
+ # LeftBlock shipped gitleaks ruleset.
2
+ #
3
+ # Extends gitleaks' default secret rules (AWS, Google API keys, private
4
+ # keys, generic API keys, ...) with personal-data and wallet rules so a
5
+ # freshly installed LeftBlock finds sensitive information with zero custom
6
+ # configuration:
7
+ #
8
+ # - personal email addresses (infrastructure identities allowlisted)
9
+ # - home-folder paths: /Users/<name>/, /home/<name>/, C:\Users\<name>
10
+ # - session UUIDs (bare UUIDs outside structural id fields)
11
+ # - actor/user IDs
12
+ # - OpenAI (sk-, sk-proj-, sk-svcacct-) and Anthropic (sk-ant-) keys
13
+ # (Gemini/Google AI keys AIza... are covered by gitleaks' default
14
+ # gcp-api-key rule)
15
+ # - crypto wallet material: 0x-prefixed 64-hex private keys, keyword-
16
+ # context bare 64-hex keys (generic-api-key covers labeled ones),
17
+ # WIF-format Bitcoin keys, BIP-39 mnemonics
18
+ #
19
+ # Public wallet addresses and hash-like hex (SHA-256, git SHAs) are NOT
20
+ # flagged by themselves: bare 64-hex without 0x or a key-like keyword is
21
+ # indistinguishable from SHA-256 and is intentionally left to the
22
+ # generic-api-key keyword context (see docs).
23
+ #
24
+ # Allowlists are scoped to exact structural values and field names
25
+ # (placeholder usernames/actor ids, Kanbus structural id fields, the
26
+ # 00000000-0000-4000-8000-... example UUID shape) — never whole paths.
27
+
28
+ title = "LeftBlock sensitive-data rules"
29
+
30
+ [extend]
31
+ useDefault = true
32
+
33
+ # Line-scoped global allowance: quoted, all-lowercase scanner keyword
34
+ # literals (fixture-corpus generators list trigger keywords like
35
+ # "-----begin rsa private key-----" as data). Real PEM material is
36
+ # mixed-case with base64 payload and is never shaped like this.
37
+ [allowlist]
38
+ description = "lowercase keyword-list literals in fixture generators"
39
+ regexTarget = "line"
40
+ regexes = [
41
+ '''"[a-z -]*private key-----"''',
42
+ ]
43
+ # Compiled bytecode duplicates string constants from the sources (which
44
+ # are themselves scanned) and produces garbage matches from folded
45
+ # literals; Python projects regenerate these caches constantly.
46
+ paths = [
47
+ '''(^|/)__pycache__/''',
48
+ '''\.pyc$''',
49
+ ]
50
+
51
+ # --------------------------------------------------------------------
52
+ # Personal data
53
+ # --------------------------------------------------------------------
54
+
55
+ [[rules]]
56
+ id = "leftblock-personal-email"
57
+ description = "Personal email address"
58
+ regex = '''[a-zA-Z0-9._%+\-']+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}'''
59
+ keywords = ["@"]
60
+
61
+ [rules.allowlist]
62
+ regexes = [
63
+ # Infrastructure and placeholder identities, not personal ones.
64
+ '''@users\.noreply\.github\.com''',
65
+ '''git@github\.com''',
66
+ '''@(example|github)\.(com|org|net|io|ai)''',
67
+ ]
68
+
69
+ [[rules]]
70
+ id = "leftblock-home-path-posix"
71
+ description = "Home-folder path (/Users/<name> or /home/<name>)"
72
+ # The path segment after the prefix is the secret. Requiring a
73
+ # non-path character before /Users or /home keeps mid-path segments
74
+ # like /tmp/home/... out (test fixtures use those legitimately).
75
+ regex = '''(?:^|[^a-zA-Z0-9._\-/])(?:/Users|/home)(/[a-zA-Z0-9._\-]+)'''
76
+ keywords = ["/Users/", "/home/"]
77
+
78
+ [rules.allowlist]
79
+ regexes = [
80
+ # Anonymized placeholder usernames, not real ones.
81
+ '''^/example(-[a-z0-9]+)?$''',
82
+ ]
83
+
84
+ [[rules]]
85
+ id = "leftblock-home-path-windows"
86
+ description = "Home-folder path (C:\\Users\\<name> or \\Users\\<name>)"
87
+ regex = '''\\+Users\\+([a-zA-Z0-9._\-]+)'''
88
+ keywords = ["Users"]
89
+
90
+ [rules.allowlist]
91
+ regexes = [
92
+ '''^\\?example(-[a-z0-9]+)?$''',
93
+ ]
94
+
95
+ [[rules]]
96
+ id = "leftblock-session-uuid"
97
+ description = "Bare UUID (potential session/agent identifier)"
98
+ regex = '''\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b'''
99
+ keywords = ["-"]
100
+
101
+ [[rules.allowlists]]
102
+ regexTarget = "match"
103
+ regexes = [
104
+ # Match only the example UUID itself, never another value on its line.
105
+ '''^00000000-0000-4000-8000-[0-9a-f]{12}$''',
106
+ '''^11111111-1111-4111-8111-1111111111[0-9a-f]{2}$''',
107
+ ]
108
+
109
+ # Kanbus serializes board records as indented JSON. Accept a complete
110
+ # structural field on its own line in board issue/event files. Prefixes
111
+ # alone, prose, array elements and a second value on that line do not
112
+ # qualify. This is a syntax-based exception, not JSON schema validation.
113
+ # Gitleaks includes the preceding newline in its line context; permit that
114
+ # single boundary byte, plus CRLF endings, without spanning properties.
115
+ [[rules.allowlists]]
116
+ description = "Kanbus board structural identifier fields"
117
+ condition = "AND"
118
+ paths = [
119
+ '''(^|/)project/(issues|events)/[^/]+\.json$''',
120
+ ]
121
+ regexTarget = "line"
122
+ regexes = [
123
+ '''^\n?[ \t]*"(id|comment_id|event_id|issue_id|parent_id|parent|target|target_id|from|to)"[ \t]*:[ \t]*"(?:[A-Za-z][A-Za-z0-9_]*-)*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",?[ \t]*\r?$''',
124
+ ]
125
+
126
+ # Event actor attribution is structural too. Keep this separate from
127
+ # session_id/provider_session_id/resumed_session, which remain detected.
128
+ [[rules.allowlists]]
129
+ description = "Kanbus event UUID actor attribution"
130
+ condition = "AND"
131
+ paths = [
132
+ '''(^|/)project/events/[^/]+\.json$''',
133
+ ]
134
+ regexTarget = "line"
135
+ regexes = [
136
+ '''^\n? "actor_id"[ \t]*:[ \t]*"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",?[ \t]*\r?$''',
137
+ ]
138
+
139
+ # Coordination events refer to a generated claim by its prefixed key.
140
+ # Require the canonical payload-property depth; bare session values,
141
+ # root/deeper properties, prose and neighboring values are not exempt.
142
+ [[rules.allowlists]]
143
+ description = "Kanbus coordination payload claim reference"
144
+ condition = "AND"
145
+ paths = [
146
+ '''(^|/)project/events/[^/]+\.json$''',
147
+ ]
148
+ regexTarget = "line"
149
+ regexes = [
150
+ '''^\n? "claim_id"[ \t]*:[ \t]*"(?:[A-Za-z][A-Za-z0-9_]*-)+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",?[ \t]*\r?$''',
151
+ ]
152
+
153
+ # The legacy Kanbus cache duplicates issue identifiers and stores numeric
154
+ # mtimes keyed by issue filename. Do not exempt the cache file: it also
155
+ # contains full descriptions and comments that still require scanning.
156
+ [[rules.allowlists]]
157
+ description = "Kanbus cache issue identifiers and numeric filename mtimes"
158
+ condition = "AND"
159
+ paths = [
160
+ '''(^|/)project/\.cache/index\.json$''',
161
+ ]
162
+ regexTarget = "line"
163
+ regexes = [
164
+ '''^\n? "(id|parent)"[ \t]*:[ \t]*"(?:[A-Za-z][A-Za-z0-9_]*-)+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",?[ \t]*\r?$''',
165
+ '''^\n? "(?:[A-Za-z][A-Za-z0-9_]*-)+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json"[ \t]*:[ \t]*-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?,?[ \t]*\r?$''',
166
+ ]
167
+
168
+ [[rules]]
169
+ id = "leftblock-actor-id"
170
+ description = "actor/user/author ID value"
171
+ regex = '''"(?:actor_id|user_id|author_id)"\s*:\s*"([^"]{4,})"'''
172
+ keywords = ["actor_id", "user_id", "author_id"]
173
+
174
+ [[rules.allowlists]]
175
+ regexes = [
176
+ # Anonymized placeholder ids, not real ones.
177
+ '''^example[-_a-z0-9]*$''',
178
+ ]
179
+
180
+ # Kanbus event files record the local actor that made each board change
181
+ # ("actor_id"). That is the board's attribution design, not a leaked user
182
+ # id; scoped to project/events and a two-space root property line. Nested
183
+ # payload actor values and attribution outside event files remain detected.
184
+ [[rules.allowlists]]
185
+ description = "Kanbus board event actor attribution"
186
+ condition = "AND"
187
+ paths = [
188
+ '''(^|/)project/events/[^/]+\.json$''',
189
+ ]
190
+ regexTarget = "line"
191
+ regexes = [
192
+ '''^\n? "actor_id"[ \t]*:[ \t]*"[^"]+",?[ \t]*\r?$''',
193
+ ]
194
+
195
+ # --------------------------------------------------------------------
196
+ # LLM provider API keys not covered by gitleaks defaults
197
+ # (OpenAI sk- / sk-proj- / sk-svcacct-, Anthropic sk-ant-. Google
198
+ # AI/Gemini keys AIza... are covered by the default gcp-api-key rule.)
199
+ # --------------------------------------------------------------------
200
+
201
+ [[rules]]
202
+ id = "leftblock-openai-key"
203
+ description = "OpenAI API key (sk-, sk-proj-, sk-svcacct-)"
204
+ regex = '''\bsk-(?:proj|svcacct)-[A-Za-z0-9_\-]{40,}|\bsk-[A-Za-z0-9]{20,}'''
205
+ keywords = ["sk-"]
206
+
207
+ [[rules]]
208
+ id = "leftblock-anthropic-key"
209
+ description = "Anthropic API key (sk-ant-)"
210
+ regex = '''\bsk-ant-[A-Za-z0-9_\-]{20,}'''
211
+ keywords = ["sk-ant-"]
212
+
213
+ # --------------------------------------------------------------------
214
+ # Crypto wallet material
215
+ # --------------------------------------------------------------------
216
+
217
+ [[rules]]
218
+ id = "leftblock-eth-private-key"
219
+ description = "Ethereum-style private key (0x-prefixed 64 hex chars)"
220
+ # Only the 0x-prefixed form is reported context-free: bare 64-hex
221
+ # strings are indistinguishable from SHA-256 digests and are covered
222
+ # by generic-api-key's keyword context instead.
223
+ regex = '''\b0x[0-9a-fA-F]{64}\b'''
224
+ keywords = ["0x"]
225
+
226
+ [[rules]]
227
+ id = "leftblock-wif-key"
228
+ description = "Bitcoin WIF private key (base58check)"
229
+ regex = '''\b[KLM][1-9A-HJ-NP-Za-km-z]{51}\b|\b5[1-9A-HJ-NP-Za-km-z]{50}\b'''
230
+ keywords = ["K", "L", "5"]
231
+
232
+ [[rules]]
233
+ id = "leftblock-bip39-mnemonic"
234
+ description = "BIP-39 seed phrase (12-24 lowercase words, seed/mnemonic context)"
235
+ # Separation from prose and from SHA-256-style hex noise: the phrase
236
+ # must follow seed/mnemonic/recovery-style context (assignment or
237
+ # label, optionally quoted), and BIP-39 wordlist words are 3-8 letters.
238
+ # Bare, unlabeled 12+ word lowercase sequences are NOT flagged — English
239
+ # prose is shape-identical and a bare phrase cannot be told apart from
240
+ # unpunctuated prose by regex alone (documented residual gap: prose that
241
+ # itself contains "seed"/"mnemonic" within the context window plus a
242
+ # long unpunctuated lowercase run; BIP-39 checksum validation belongs in
243
+ # a future validator pass).
244
+ regex = '''(?:seed[_-]?phrase|recovery[_-]?phrase|mnemonic|vault[_-]?seed|seed|secret[_-]?words)[^=\n"']{0,24}[=:\s][\s"']{0,3}(?:[a-z]{3,8} ){11,23}[a-z]{3,8}\b"?'''
245
+ keywords = ["seed", "mnemonic", "recovery"]
leftblock/hooks.py ADDED
@@ -0,0 +1,298 @@
1
+ """Install only LeftBlock-owned hooks without replacing a project's hook setup."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import stat
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+
15
+
16
+ STATE_NAME = ".leftblock-commit-msg.json"
17
+ HOOK_MARKER = "# LeftBlock managed commit-msg hook v1\n"
18
+ MANUAL_INTEGRATION = (
19
+ 'Keep the existing hook setup and compose `leftblock check` into its pre-commit '
20
+ 'hook, or `leftblock hook "$1"` into its commit-msg hook, propagating failures.'
21
+ )
22
+
23
+
24
+ def _git(repo_root, *args):
25
+ return subprocess.run(
26
+ ["git", *args], cwd=repo_root, text=True, capture_output=True, check=False
27
+ )
28
+
29
+
30
+ def _regular_file(path):
31
+ """Reject symlinks, directories, devices, and other non-regular targets."""
32
+ if os.path.lexists(path) and not stat.S_ISREG(path.lstat().st_mode):
33
+ raise ValueError("Hook or ownership state is not a regular file. " + MANUAL_INTEGRATION)
34
+
35
+
36
+ def _destination(repo_root, hook_name="commit-msg", state_name=STATE_NAME):
37
+ configured = _git(repo_root, "config", "--get-all", "core.hooksPath")
38
+ if configured.returncode == 0:
39
+ raise ValueError("core.hooksPath is configured; no hooks changed. " + MANUAL_INTEGRATION)
40
+ if configured.returncode != 1:
41
+ raise ValueError("Could not inspect Git hook configuration; no hooks changed.")
42
+ common = _git(repo_root, "rev-parse", "--path-format=absolute", "--git-common-dir")
43
+ if common.returncode or not common.stdout.strip():
44
+ raise ValueError("Could not locate the repository's common Git directory.")
45
+ hook_dir = Path(common.stdout.strip()) / "hooks"
46
+ if os.path.lexists(hook_dir) and (
47
+ hook_dir.is_symlink() or not hook_dir.is_dir()
48
+ ):
49
+ raise ValueError("The hooks directory is not a regular directory. " + MANUAL_INTEGRATION)
50
+ return hook_dir / hook_name, hook_dir / state_name
51
+
52
+
53
+ def _check_ownership(hook, state):
54
+ _regular_file(hook)
55
+ _regular_file(state)
56
+ if not hook.exists() and not state.exists():
57
+ return None
58
+ if not hook.exists() or not state.exists():
59
+ raise ValueError("An existing hook or ownership state is unmanaged. " + MANUAL_INTEGRATION)
60
+ try:
61
+ metadata = json.loads(state.read_text())
62
+ digest = metadata["sha256"]
63
+ valid_state = (
64
+ metadata["version"] == 1
65
+ and metadata["hook"] == "commit-msg"
66
+ and isinstance(digest, str)
67
+ and re.fullmatch(r"[0-9a-f]{64}", digest) is not None
68
+ )
69
+ except (ValueError, KeyError, TypeError):
70
+ valid_state = False
71
+ data = hook.read_bytes()
72
+ if not valid_state or (
73
+ HOOK_MARKER.encode() not in data.splitlines(keepends=True)[:3]
74
+ or hashlib.sha256(data).hexdigest() != digest
75
+ ):
76
+ raise ValueError("The existing hook was changed or is not managed by LeftBlock. " + MANUAL_INTEGRATION)
77
+ if not hook.stat().st_mode & stat.S_IXUSR:
78
+ raise ValueError("The existing LeftBlock hook was disabled; no hooks changed. " + MANUAL_INTEGRATION)
79
+ return data
80
+
81
+
82
+ def validate_hook_install(repo_root):
83
+ """Read-only preflight; raise instead of touching another hook setup."""
84
+ hook, state = _destination(repo_root)
85
+ _check_ownership(hook, state)
86
+ return hook
87
+
88
+
89
+ def _hook_script():
90
+ # Pin the installer environment so noninteractive Git does not need an
91
+ # activated virtualenv. Captured PATH keeps the configured scanner visible.
92
+ return (
93
+ "#!/bin/sh\n"
94
+ + HOOK_MARKER
95
+ + "# PATH captured at installation time for configured command checkers.\n"
96
+ + "export PATH=" + shlex.quote(os.environ.get("PATH", "")) + ":${PATH:-}\n"
97
+ + "exec " + shlex.quote(sys.executable) + ' -m leftblock.cli hook "$1"\n'
98
+ ).encode()
99
+
100
+
101
+ def _replace_file(path, data, mode):
102
+ fd, temporary = tempfile.mkstemp(prefix=".leftblock-", dir=str(path.parent))
103
+ try:
104
+ with os.fdopen(fd, "wb") as output:
105
+ output.write(data)
106
+ output.flush()
107
+ os.fchmod(output.fileno(), mode)
108
+ os.replace(temporary, path)
109
+ finally:
110
+ if os.path.exists(temporary):
111
+ os.unlink(temporary)
112
+
113
+
114
+ def install_hook(repo_root):
115
+ """Install or refresh an unchanged managed hook; never prompt to overwrite.
116
+
117
+ Hook managers, configured hooks paths, legacy hooks, and owner edits require
118
+ explicit composition by the project. Ownership state is local Git metadata,
119
+ not a security boundary against a user who can change the hook itself.
120
+ """
121
+ hook, state = _destination(repo_root)
122
+ previous = _check_ownership(hook, state)
123
+ runtime = subprocess.run(
124
+ [sys.executable, "-B", "-m", "leftblock.cli", "hook", "--help"],
125
+ cwd=repo_root, text=True, capture_output=True, check=False,
126
+ )
127
+ if runtime.returncode:
128
+ raise ValueError("Install LeftBlock in this Python environment before installing its hook.")
129
+ data = _hook_script()
130
+ if previous == data:
131
+ return True
132
+ hook.parent.mkdir(parents=True, exist_ok=True)
133
+ # Recheck immediately before writing to catch changes during installation.
134
+ if _check_ownership(hook, state) != previous:
135
+ raise ValueError("Hook changed during installation; no hooks changed.")
136
+ if previous is None:
137
+ # Exclusive creation also preserves a hook created after preflight.
138
+ fd = os.open(str(hook), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o755)
139
+ with os.fdopen(fd, "wb") as output:
140
+ output.write(data)
141
+ os.fchmod(output.fileno(), 0o755)
142
+ else:
143
+ _replace_file(hook, data, 0o755)
144
+ metadata = {
145
+ "version": 1,
146
+ "hook": "commit-msg",
147
+ "sha256": hashlib.sha256(data).hexdigest(),
148
+ }
149
+ _replace_file(state, (json.dumps(metadata, sort_keys=True) + "\n").encode(), 0o600)
150
+ return True
151
+
152
+
153
+ LOCAL_STATE_NAME = ".leftblock-pre-commit.json"
154
+ LOCAL_HOOK_MARKER = "# LeftBlock managed local pre-commit hook v1\n"
155
+
156
+
157
+ def _local_paths(repo_root):
158
+ hook, state = _destination(repo_root, "pre-commit", LOCAL_STATE_NAME)
159
+ policy_dir = hook.parent.parent / "leftblock"
160
+ if os.path.lexists(policy_dir) and (
161
+ policy_dir.is_symlink() or not policy_dir.is_dir()
162
+ ):
163
+ raise ValueError("The local policy directory is not a regular directory.")
164
+ return {
165
+ "hook": hook,
166
+ "state": state,
167
+ "policy": policy_dir / "policy.json",
168
+ "rules": policy_dir / "gitleaks.toml",
169
+ }
170
+
171
+
172
+ def _local_owned_contents(paths):
173
+ for path in paths.values():
174
+ _regular_file(path)
175
+ if not any(path.exists() for path in paths.values()):
176
+ return {name: None for name in paths}
177
+ if not all(path.exists() for path in paths.values()):
178
+ raise ValueError("Existing local pre-commit hook or policy is unmanaged; no files changed.")
179
+ contents = {name: path.read_bytes() for name, path in paths.items()}
180
+ try:
181
+ state = json.loads(contents["state"])
182
+ valid = state["version"] == 1 and state["hook"] == "pre-commit"
183
+ for name, key in (("hook", "sha256"), ("policy", "policy_sha256"), ("rules", "rules_sha256")):
184
+ valid = valid and state[key] == hashlib.sha256(contents[name]).hexdigest()
185
+ except (ValueError, TypeError, KeyError):
186
+ valid = False
187
+ if (
188
+ not valid
189
+ or LOCAL_HOOK_MARKER.encode() not in contents["hook"].splitlines(keepends=True)[:3]
190
+ or not paths["hook"].stat().st_mode & stat.S_IXUSR
191
+ ):
192
+ raise ValueError("Existing local hook or policy was edited or disabled; no files changed.")
193
+ return contents
194
+
195
+
196
+ def validate_local_check_install(repo_root):
197
+ """Read-only preflight for the opt-in, secret-free local pre-commit gate."""
198
+ paths = _local_paths(repo_root)
199
+ _local_owned_contents(paths)
200
+ return paths
201
+
202
+
203
+ def _local_bundle(paths, gitleaks=None):
204
+ binary = shutil.which(gitleaks or "gitleaks")
205
+ if not binary or not os.path.isfile(binary) or not os.access(binary, os.X_OK):
206
+ raise ValueError("An existing executable Gitleaks installation is required.")
207
+ runtime = subprocess.run(
208
+ [sys.executable, "-I", "-B", "-m", "leftblock.cli", "check", "--help"],
209
+ text=True, capture_output=True, check=False,
210
+ )
211
+ if runtime.returncode or "--config" not in runtime.stdout:
212
+ raise ValueError("Install LeftBlock with local policy support in this Python environment first.")
213
+ binary = os.path.abspath(binary)
214
+ rules = (Path(__file__).resolve().parent / "data" / "gitleaks.toml").read_bytes()
215
+ command = [
216
+ binary, "protect", "--staged", "--config", str(paths["rules"]),
217
+ "--redact", "--no-banner", "--report-format", "json",
218
+ "--report-path", "{report_file}",
219
+ ]
220
+ policy = {
221
+ "version": 1,
222
+ "checkers": [{
223
+ "name": "gitleaks", "type": "command",
224
+ "command": " ".join(shlex.quote(arg) for arg in command),
225
+ "success_codes": [0], "finding_codes": [1],
226
+ }],
227
+ }
228
+ # -I ignores PYTHONPATH and the repository's current directory. The
229
+ # installing Python must have LeftBlock installed in its own environment.
230
+ hook = (
231
+ "#!/bin/sh\n" + LOCAL_HOOK_MARKER
232
+ + "export PATH=" + shlex.quote(os.environ.get("PATH", "")) + ":${PATH:-}\n"
233
+ + "exec " + shlex.quote(sys.executable)
234
+ + " -I -B -m leftblock.cli check --config " + shlex.quote(str(paths["policy"])) + "\n"
235
+ ).encode()
236
+ bundle = {
237
+ "hook": hook,
238
+ "policy": (json.dumps(policy, indent=2) + "\n").encode(),
239
+ "rules": rules,
240
+ }
241
+ state = {
242
+ "version": 1, "hook": "pre-commit",
243
+ "sha256": hashlib.sha256(hook).hexdigest(),
244
+ "policy_sha256": hashlib.sha256(bundle["policy"]).hexdigest(),
245
+ "rules_sha256": hashlib.sha256(rules).hexdigest(),
246
+ }
247
+ bundle["state"] = (json.dumps(state, sort_keys=True) + "\n").encode()
248
+ return bundle
249
+
250
+
251
+ def install_local_check_hook(repo_root, gitleaks=None):
252
+ """Install a fixed Gitleaks baseline without tracked policy or credentials.
253
+
254
+ The pre-commit slot is independent of existing commit-msg receipts. An
255
+ existing root policy is not executed or changed by this opt-in local mode.
256
+ """
257
+ paths = validate_local_check_install(repo_root)
258
+ previous = _local_owned_contents(paths)
259
+ desired = _local_bundle(paths, gitleaks)
260
+ if previous == desired:
261
+ return True
262
+ created_dirs = []
263
+ written = []
264
+ try:
265
+ for directory in (paths["hook"].parent, paths["policy"].parent):
266
+ if not directory.exists():
267
+ directory.mkdir()
268
+ created_dirs.append(directory)
269
+ # Activate the hook last. Rollback only our own unchanged writes if any
270
+ # step fails; never remove an edit made concurrently by another process.
271
+ for name in ("rules", "policy", "state", "hook"):
272
+ path = paths[name]
273
+ _regular_file(path)
274
+ current = path.read_bytes() if path.exists() else None
275
+ if current != previous[name]:
276
+ raise ValueError("Local hook or policy changed during installation.")
277
+ mode = 0o755 if name == "hook" else 0o600
278
+ if previous[name] is None:
279
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
280
+ with os.fdopen(fd, "wb") as output:
281
+ output.write(desired[name])
282
+ os.fchmod(output.fileno(), mode)
283
+ else:
284
+ _replace_file(path, desired[name], mode)
285
+ written.append(name)
286
+ return True
287
+ except BaseException:
288
+ for name in reversed(written):
289
+ path = paths[name]
290
+ if path.is_file() and not path.is_symlink() and path.read_bytes() == desired[name]:
291
+ if previous[name] is None:
292
+ path.unlink()
293
+ else:
294
+ _replace_file(path, previous[name], 0o755 if name == "hook" else 0o600)
295
+ for directory in reversed(created_dirs):
296
+ if directory.is_dir() and not any(directory.iterdir()):
297
+ directory.rmdir()
298
+ raise