ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
python_lock_policy_scan.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""First-party dependency-authority, installer, and CLI-consumer scanners."""
4
5from __future__ import annotations
6
7import ast
8import copy
9import os
10import re
11import shlex
12import subprocess
13import sys
14from collections.abc import Callable, Mapping
15from pathlib import Path
16
17sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
18
19from git_environment import isolated_git_environment, trusted_git_executable
20from hil_convergence_safety_policy import load_bench_transaction
21from python_lock_policy_process import (
22 forbidden_argv,
23 is_process_call,
24 literal_bindings,
25 literal_command_words,
26 literal_string,
27 process_aliases,
28 process_command_argument,
29 propagate_member_aliases,
30 shell_installer_label,
31)
32
33GALAXY_MANIFEST = Path("infra/ansible/requirements.yml")
34DERIVED_EXPORTS = {
35 Path("infra/ansible/roles/k3s_node/files/requirements.lock"),
36 Path("infra/ansible/roles/hil_bench/files/requirements.lock"),
37}
38VENDOR_BOUNDARIES = (
39 Path("libs/third_party/mbedtls"),
40 Path("libs/third_party/nimble"),
41)
42SECONDARY_AUTHORITY_NAMES = {
43 "Pipfile",
44 "Pipfile.lock",
45 "poetry.lock",
46 "setup.cfg",
47 "setup.py",
48}
49IGNORED_SCAN_PARTS = {
50 ".ansible",
51 ".git",
52 ".tools",
53 ".venv",
54 "__pycache__",
55 "_deps",
56}
57BUILD_BOUNDARIES = (
58 Path("build"),
59 Path("docs/build"),
60 Path("tests/build"),
61 Path("tests/build-cov"),
62 Path("tests/build-fuzz"),
63 Path("tests/build-ubsan"),
64)
65MIN_UV_ARGV_SIZE = 2
66HIL_UV_AUTH_ARGV = (
67 "/usr/bin/python3",
68 "{{ hil_bench_python_context }}/bootstrap_uv.py",
69 "--verify-cache",
70 "--manifest",
71 "{{ hil_bench_python_context }}/uv_release.json",
72 "--cache-root",
73 "{{ hil_bench_uv_cache }}",
74)
75HIL_UV_PROBE_ARGV = (
76 "/usr/bin/python3",
77 "{{ hil_bench_python_context }}/bootstrap_uv.py",
78 "--manifest",
79 "{{ hil_bench_python_context }}/uv_release.json",
80 "--cache-root",
81 "{{ hil_bench_uv_cache }}",
82 "--run",
83 "--no-config",
84 "pip",
85 "check",
86 "--python",
87 "{{ hil_bench_python_venv }}/bin/python3",
88)
89HIL_UV_SYNC_ARGV = (
90 "/usr/bin/python3",
91 "{{ hil_bench_python_context }}/bootstrap_uv.py",
92 "--manifest",
93 "{{ hil_bench_python_context }}/uv_release.json",
94 "--cache-root",
95 "{{ hil_bench_uv_cache }}",
96 "--ensure-and-run",
97 "--directory",
98 "{{ hil_bench_python_context }}",
99 "--no-config",
100 "sync",
101 "--locked",
102 "--only-group",
103 "hil",
104 "--no-install-project",
105 "--python",
106 "/usr/bin/python3",
107)
108CLI_DISTRIBUTIONS = {
109 "ansible-playbook": "ansible-core",
110 "cmake-format": "cmakelang",
111 "cmake-lint": "cmakelang",
112 "gcovr": "gcovr",
113 "ruff": "ruff",
114 "vela": "ethos-u-vela",
115 "yamllint": "yamllint",
116}
117
118
119def git_executable() -> str:
120 """Return the absolute Git authority required for index enumeration."""
121 return trusted_git_executable()
122
123
124def _ansible_tasks(document: object) -> list[Mapping[str, object]]:
125 """Flatten task records, including block, rescue, and always sections."""
126 if not isinstance(document, list):
127 return []
128 tasks: list[Mapping[str, object]] = []
129 for item in document:
130 if not isinstance(item, Mapping):
131 continue
132 tasks.append(item)
133 for section in ("block", "rescue", "always"):
134 tasks.extend(_ansible_tasks(item.get(section)))
135 return tasks
136
137
138def _task_argv(task: Mapping[str, object]) -> tuple[str, ...]:
139 """Return one Ansible command task's literal argv, or an empty tuple."""
140 command = task.get("ansible.builtin.command")
141 if not isinstance(command, Mapping):
142 return ()
143 argv = command.get("argv")
144 if not isinstance(argv, list) or any(not isinstance(word, str) for word in argv):
145 return ()
146 return tuple(argv)
147
148
149def _exact_argv(actual: tuple[str, ...], expected: object) -> bool:
150 """Match only immutable argv authorities with identical elements and order."""
151 return isinstance(expected, tuple) and actual == expected
152
153
154def _has_sequence(words: tuple[str, ...], sequence: tuple[str, ...]) -> bool:
155 """Return whether one exact contiguous argv sequence occurs."""
156 return any(words[index : index + len(sequence)] == sequence for index in range(len(words)))
157
158
159def _registered_task(
160 tasks: list[Mapping[str, object]], register: str
161) -> tuple[Mapping[str, object] | None, list[str]]:
162 """Return the unique task owning a register, reporting missing or duplicates."""
163 matches = [task for task in tasks if task.get("register") == register]
164 if len(matches) != 1:
165 return None, [f"HIL Python policy needs one {register} task; found {len(matches)}"]
166 return matches[0], []
167
168
169def _named_task(
170 tasks: list[Mapping[str, object]], name: str
171) -> tuple[Mapping[str, object] | None, list[str]]:
172 """Return the unique task carrying one exact display name."""
173 matches = [task for task in tasks if task.get("name") == name]
174 if len(matches) != 1:
175 return None, [f"HIL Python policy needs one {name!r} task; found {len(matches)}"]
176 return matches[0], []
177
178
179def _uv_auth_task_findings(task: Mapping[str, object]) -> list[str]:
180 """Validate the cached-uv authentication preflight task."""
181 findings: list[str] = []
182 argv = _task_argv(task)
183 if not _exact_argv(argv, HIL_UV_AUTH_ARGV):
184 findings.append("HIL uv preflight does not authenticate the pinned cached release")
185 if (
186 task.get("changed_when") is not False
187 or task.get("failed_when") is not False
188 or task.get("check_mode") is not False
189 or "ignore_errors" in task
190 ):
191 findings.append("HIL uv authentication preflight is not read-only/fail-observable")
192 return findings
193
194
195def _uv_probe_task_findings(task: Mapping[str, object]) -> list[str]:
196 """Validate the dependency probe executes only through authenticated bytes."""
197 findings: list[str] = []
198 argv = _task_argv(task)
199 if not _exact_argv(argv, HIL_UV_PROBE_ARGV):
200 findings.append("HIL dependency preflight bypasses authenticated uv execution")
201 environment = task.get("environment")
202 if environment != {"UV_PYTHON_DOWNLOADS": "never"}:
203 findings.append("HIL dependency preflight permits uv Python downloads")
204 if (
205 task.get("changed_when") is not False
206 or task.get("failed_when") is not False
207 or task.get("check_mode") is not False
208 or "ignore_errors" in task
209 ):
210 findings.append("HIL dependency preflight is not read-only/fail-observable")
211 return findings
212
213
214def _uv_apply_task_findings(tasks: list[Mapping[str, object]]) -> list[str]:
215 """Require HIL sync and final pip check to stay behind the bootstrap runner."""
216 findings: list[str] = []
217 specifications = (
218 (
219 "Synchronize the exact uv-locked HIL dependency group",
220 HIL_UV_SYNC_ARGV,
221 {
222 "UV_PROJECT_ENVIRONMENT": "{{ hil_bench_python_venv }}",
223 "UV_PYTHON_DOWNLOADS": "never",
224 },
225 ),
226 (
227 "Check the HIL Python dependency graph",
228 HIL_UV_PROBE_ARGV,
229 {"UV_PYTHON_DOWNLOADS": "never"},
230 ),
231 )
232 for name, expected_argv, expected_environment in specifications:
233 task, errors = _named_task(tasks, name)
234 findings.extend(errors)
235 if task is None:
236 continue
237 argv = _task_argv(task)
238 if not _exact_argv(argv, expected_argv):
239 findings.append(f"HIL task {name!r} bypasses authenticated uv execution")
240 environment = task.get("environment")
241 if environment != expected_environment:
242 findings.append(f"HIL task {name!r} permits uv Python downloads")
243 if name.startswith("Synchronize"):
244 if task.get("changed_when") != "hil_bench_python_sync.rc == 0":
245 findings.append(f"HIL task {name!r} masks authenticated uv status")
246 elif task.get("changed_when") is not False:
247 findings.append(f"HIL task {name!r} masks authenticated uv status")
248 if "failed_when" in task or "ignore_errors" in task:
249 findings.append(f"HIL task {name!r} masks authenticated uv status")
250 return findings
251
252
253def _rebuild_decision_findings(tasks: list[Mapping[str, object]]) -> list[str]:
254 """Require cached-uv and dependency-probe results to drive HIL rebuilding."""
255 rebuilds = []
256 for task in tasks:
257 facts = task.get("ansible.builtin.set_fact")
258 if isinstance(facts, Mapping) and "hil_bench_python_rebuild" in facts:
259 rebuilds.append(facts)
260 if len(rebuilds) != 1:
261 return [f"HIL Python policy needs one rebuild decision; found {len(rebuilds)}"]
262 expression = str(rebuilds[0]["hil_bench_python_rebuild"])
263 return [
264 f"HIL rebuild decision ignores {register}"
265 for register in ("hil_bench_uv_preflight.rc", "hil_bench_uv_pip_probe.rc")
266 if register not in expression
267 ]
268
269
270def hil_preflight_findings(document: object) -> list[str]:
271 """Require the HIL idempotency preflight to use authenticated pinned uv."""
272 tasks = _ansible_tasks(document)
273 findings = [] if tasks else ["HIL bench task document has no tasks"]
274 for task in tasks:
275 argv = _task_argv(task)
276 if (
277 argv
278 and "hil_bench_python_venv" in argv[0]
279 and _has_sequence(argv, ("-m", "pip", "check"))
280 ):
281 findings.append("HIL preflight calls python -m pip in a uv-created environment")
282 auth, errors = _registered_task(tasks, "hil_bench_uv_preflight")
283 findings.extend(errors)
284 if auth is not None:
285 findings.extend(_uv_auth_task_findings(auth))
286 probe, errors = _registered_task(tasks, "hil_bench_uv_pip_probe")
287 findings.extend(errors)
288 if probe is not None:
289 findings.extend(_uv_probe_task_findings(probe))
290 findings.extend(_uv_apply_task_findings(tasks))
291 findings.extend(_rebuild_decision_findings(tasks))
292 return findings
293
294
295def load_hil_tasks(root: Path) -> object:
296 """Follow the exact public role entry to its authoritative transaction."""
297 return load_bench_transaction(root)
298
299
300def _runner_removal_findings(document: object) -> list[str]:
301 """Prove preflight and convergence cannot omit bootstrap runner modes."""
302 failures: list[str] = []
303 cases = (
304 (
305 "hil_bench_uv_pip_probe",
306 "",
307 "--run",
308 "HIL preflight without bootstrap --run passed",
309 ),
310 (
311 "",
312 "Synchronize the exact uv-locked HIL dependency group",
313 "--ensure-and-run",
314 "HIL apply sync without bootstrap runner passed",
315 ),
316 )
317 for register, name, token, message in cases:
318 mutated = copy.deepcopy(document)
319 tasks = _ansible_tasks(mutated)
320 task, _ = _registered_task(tasks, register) if register else _named_task(tasks, name)
321 command = task.get("ansible.builtin.command") if isinstance(task, dict) else None
322 argv = command.get("argv") if isinstance(command, dict) else None
323 if not isinstance(argv, list) or token not in argv:
324 failures.append(f"could not mutate {message.lower()}")
325 continue
326 argv.remove(token)
327 if not any("bypasses authenticated" in item for item in hil_preflight_findings(mutated)):
328 failures.append(message)
329 return failures
330
331
332def _runner_shape_findings(document: object) -> list[str]:
333 """Prove exact argv ordering and child-status propagation are load-bearing."""
334 failures: list[str] = []
335 selectors = (
336 ("hil_bench_uv_preflight", ""),
337 ("hil_bench_uv_pip_probe", ""),
338 ("", "Synchronize the exact uv-locked HIL dependency group"),
339 ("", "Check the HIL Python dependency graph"),
340 )
341 for register, name in selectors:
342 for attack in ("raw", "reorder", "mask"):
343 mutated = copy.deepcopy(document)
344 tasks = _ansible_tasks(mutated)
345 task, _ = _registered_task(tasks, register) if register else _named_task(tasks, name)
346 if not isinstance(task, dict):
347 failures.append(f"could not select HIL uv task {register or name!r}")
348 continue
349 command = task.get("ansible.builtin.command")
350 argv = command.get("argv") if isinstance(command, dict) else None
351 if not isinstance(argv, list) or len(argv) < MIN_UV_ARGV_SIZE:
352 failures.append(f"could not mutate HIL uv task {register or name!r}")
353 continue
354 if attack == "raw":
355 argv[0] = "/opt/ra8-uv-cache/uv"
356 elif attack == "reorder":
357 argv[-2], argv[-1] = argv[-1], argv[-2]
358 else:
359 task["ignore_errors"] = True
360 if not hil_preflight_findings(mutated):
361 failures.append(f"HIL uv {attack} mutation passed: {register or name}")
362 return failures
363
364
365def _argv_type_contract_findings(document: object) -> list[str]:
366 """Bind immutable expected argv authorities to the real HIL task parser."""
367 tasks = _ansible_tasks(document)
368 auth, _ = _registered_task(tasks, "hil_bench_uv_preflight")
369 probe, _ = _registered_task(tasks, "hil_bench_uv_pip_probe")
370 sync, _ = _named_task(tasks, "Synchronize the exact uv-locked HIL dependency group")
371 final, _ = _named_task(tasks, "Check the HIL Python dependency graph")
372 cases = (
373 ("authentication", auth, HIL_UV_AUTH_ARGV),
374 ("dependency probe", probe, HIL_UV_PROBE_ARGV),
375 ("synchronization", sync, HIL_UV_SYNC_ARGV),
376 ("final dependency check", final, HIL_UV_PROBE_ARGV),
377 )
378 failures: list[str] = []
379 for label, task, expected in cases:
380 if task is None or not _exact_argv(_task_argv(task), expected):
381 failures.append(f"real HIL {label} argv violates the immutable type contract")
382 if _exact_argv(_task_argv(task or {}), list(expected)):
383 failures.append(f"mutable-list HIL {label} argv authority passed")
384 return failures
385
386
387def hil_preflight_selftest(root: Path) -> list[str]:
388 """Prove valid HIL tasks pass and unauthenticated or pip-based probes fail."""
389 document = load_hil_tasks(root)
390 failures = ["live HIL uv preflight policy failed"] if hil_preflight_findings(document) else []
391 failures.extend(_runner_removal_findings(document))
392 failures.extend(_runner_shape_findings(document))
393 failures.extend(_argv_type_contract_findings(document))
394 raw_pip = copy.deepcopy(document)
395 probe, _ = _registered_task(_ansible_tasks(raw_pip), "hil_bench_uv_pip_probe")
396 if not isinstance(probe, dict):
397 failures.append("could not mutate HIL dependency probe fixture")
398 else:
399 command = probe.get("ansible.builtin.command")
400 if isinstance(command, dict):
401 command["argv"] = ["{{ hil_bench_python_venv }}/bin/python3", "-m", "pip", "check"]
402 if not any("python -m pip" in item for item in hil_preflight_findings(raw_pip)):
403 failures.append("python -m pip HIL preflight passed")
404 unverified = copy.deepcopy(document)
405 auth, _ = _registered_task(_ansible_tasks(unverified), "hil_bench_uv_preflight")
406 if not isinstance(auth, dict):
407 failures.append("could not mutate HIL uv authentication fixture")
408 else:
409 command = auth.get("ansible.builtin.command")
410 argv = command.get("argv") if isinstance(command, dict) else None
411 if isinstance(argv, list):
412 argv.remove("--verify-cache")
413 if not any("authenticate" in item for item in hil_preflight_findings(unverified)):
414 failures.append("unauthenticated cached uv preflight passed")
415 unbound = copy.deepcopy(document)
416 for task in _ansible_tasks(unbound):
417 facts = task.get("ansible.builtin.set_fact")
418 if isinstance(facts, dict) and "hil_bench_python_rebuild" in facts:
419 facts["hil_bench_python_rebuild"] = "{{ false }}"
420 if not any("rebuild decision ignores" in item for item in hil_preflight_findings(unbound)):
421 failures.append("unbound HIL preflight result passed")
422 return failures
423
424
425def repository_policy_paths(root: Path) -> list[Path]:
426 """Return tracked policy inputs, or all files for synthetic non-Git fixtures."""
427 if not (root / ".git").exists():
428 return sorted(path for path in root.rglob("*") if path.is_file())
429 result = subprocess.run( # noqa: S603 -- absolute executable, fixed argv, no shell
430 [git_executable(), "ls-files", "--cached", "-z", "--", "."],
431 cwd=root,
432 check=False,
433 capture_output=True,
434 )
435 if result.returncode != 0:
436 detail = result.stderr.decode("utf-8", errors="replace").strip()
437 message = f"cannot enumerate tracked policy inputs: {detail}"
438 raise OSError(message)
439 relatives = [os.fsdecode(item) for item in result.stdout.split(b"\0") if item]
440 return [root / relative for relative in relatives if (root / relative).is_file()]
441
442
443def read_authored_text(path: Path, subject: str) -> tuple[str | None, str | None]:
444 """Read authored UTF-8 text or return one path-specific policy finding."""
445 try:
446 return path.read_text(encoding="utf-8"), None
447 except UnicodeError as error:
448 return None, f"{path}: {subject} is not valid UTF-8: {error}"
449 except OSError as error:
450 return None, f"{path}: cannot read {subject}: {error}"
451
452
453def python_source_paths(root: Path) -> list[Path]:
454 """Return tracked first-party Python sources outside generated/vendor trees."""
455 paths: list[Path] = []
456 for path in repository_policy_paths(root):
457 if path.suffix != ".py" or ignored_policy_path(root, path):
458 continue
459 relative = path.relative_to(root)
460 if "third_party" in relative.parts or relative.is_relative_to(Path("libs/ra8_fonts")):
461 continue
462 paths.append(path)
463 return sorted(paths)
464
465
466def first_party_import_closure(
467 root: Path,
468 initial: list[Path],
469 imported_roots: Callable[[Path], tuple[set[str], list[str]]],
470) -> tuple[list[Path], list[str]]:
471 """Include adjacent authored modules and recursively inspect their imports."""
472 sources = set(initial)
473 pending = list(initial)
474 errors: list[str] = []
475 while pending:
476 source = pending.pop()
477 imported, import_errors = imported_roots(source)
478 errors.extend(import_errors)
479 for name in imported:
480 candidates = (
481 source.parent / f"{name}.py",
482 source.parent / name / "__init__.py",
483 )
484 for candidate in candidates:
485 if candidate in sources or not candidate.is_file() or candidate.is_symlink():
486 continue
487 try:
488 relative = candidate.resolve(strict=True).relative_to(root.resolve(strict=True))
489 except (OSError, ValueError):
490 continue
491 if ignored_policy_path(root, root / relative):
492 continue
493 text, error = read_authored_text(candidate, "adjacent Python module")
494 if error is not None:
495 errors.append(error)
496 continue
497 header = "\n".join((text or "").splitlines()[:5])
498 if "SPDX-License-Identifier:" not in header or "Copyright" not in header:
499 continue
500 sources.add(candidate)
501 pending.append(candidate)
502 return sorted(sources), errors
503
504
505def adjacent_import_closure_selftest(
506 root: Path,
507 source: Path,
508 imported_roots: Callable[[Path], tuple[set[str], list[str]]],
509) -> list[str]:
510 """Prove an authored adjacent module recursively enters the import census."""
511 source.write_text(
512 "# SPDX-License-Identifier: MIT\n# Copyright (c) 2026 Test\nimport adjacent\n",
513 encoding="utf-8",
514 )
515 adjacent = root / "adjacent.py"
516 adjacent.write_text(
517 "# SPDX-License-Identifier: MIT\n# Copyright (c) 2026 Test\nimport rogue_external\n",
518 encoding="utf-8",
519 )
520 closure, errors = first_party_import_closure(root, [source], imported_roots)
521 discovered = set().union(*(imported_roots(path)[0] for path in closure))
522 if errors or adjacent not in closure or "rogue_external" not in discovered:
523 return ["adjacent authored-module import closure was not scanned recursively"]
524 return []
525
526
527def ignored_policy_path(root: Path, path: Path) -> bool:
528 """Return whether a path is generated, cached, or outside first-party policy."""
529 relative = path.relative_to(root)
530 return any(part in IGNORED_SCAN_PARTS for part in relative.parts) or any(
531 relative == boundary or relative.is_relative_to(boundary) for boundary in BUILD_BOUNDARIES
532 )
533
534
535def is_dependency_authority_candidate(root: Path, path: Path) -> bool:
536 """Recognize dependency metadata without treating arbitrary text/data as a lock."""
537 if path.name in SECONDARY_AUTHORITY_NAMES or path.name in {
538 "pyproject.toml",
539 "uv.lock",
540 }:
541 return True
542 if re.fullmatch(
543 r"(?:requirements|constraints)(?:[._-][^.]+)*\.(?:in|lock|txt)",
544 path.name,
545 ):
546 return True
547 relative = path.relative_to(root)
548 authority_directories = {"constraints", "requirements"}
549 return any(part in authority_directories for part in relative.parts[:-1]) and path.suffix in {
550 ".in",
551 ".lock",
552 ".txt",
553 }
554
555
556def requirement_findings(root: Path) -> list[str]:
557 """Reject secondary first-party dependency authorities while preserving vendors."""
558 allowed = {
559 root / GALAXY_MANIFEST,
560 *(root / relative for relative in DERIVED_EXPORTS),
561 }
562 findings: list[str] = []
563 candidates = [
564 path
565 for path in repository_policy_paths(root)
566 if path.is_file()
567 and not ignored_policy_path(root, path)
568 and is_dependency_authority_candidate(root, path)
569 ]
570 for path in candidates:
571 if path in allowed or path in {root / "pyproject.toml", root / "uv.lock"}:
572 continue
573 relative = path.relative_to(root)
574 if any(relative.is_relative_to(boundary) for boundary in VENDOR_BOUNDARIES):
575 continue
576 findings.append(f"stale first-party dependency authority: {relative}")
577 return sorted(findings)
578
579
580def python_installer_findings(path: Path, root: Path) -> list[str]:
581 """Inspect Python process-launch APIs for literal package installers."""
582 source, error = read_authored_text(path, "Python installer policy input")
583 if error is not None:
584 return [error]
585 try:
586 tree = ast.parse(source or "", filename=str(path))
587 except SyntaxError as error:
588 return [f"{path.relative_to(root)}: cannot inspect process calls: {error}"]
589 findings: list[str] = []
590 aliases = process_aliases(tree)
591 bindings = literal_bindings(tree)
592 for node in ast.walk(tree):
593 if not isinstance(node, ast.Call) or not is_process_call(node.func, aliases):
594 continue
595 argument = process_command_argument(node)
596 if argument is None:
597 continue
598 words = literal_command_words(argument, aliases, bindings)
599 label = forbidden_argv(words or [])
600 shell_text = literal_string(argument, bindings)
601 if label is None and shell_text is not None:
602 label = shell_installer_label(shell_text)
603 if label is None and words is not None:
604 label = next((shell_installer_label(word) for word in words if word), None)
605 if label is not None:
606 findings.append(
607 f"{path.relative_to(root)}:{node.lineno}: forbidden {label} process call"
608 )
609 return findings
610
611
612def unsafe_install_findings(root: Path) -> list[str]:
613 """Reject raw Python provisioning outside the locked uv/Ansible boundaries."""
614 findings: list[str] = []
615 suffixes = {".bash", ".bat", ".cmd", ".ps1", ".sh", ".yaml", ".yml", ".zsh"}
616 for path in repository_policy_paths(root):
617 if (
618 not path.is_file()
619 or ignored_policy_path(root, path)
620 or "third_party" in path.relative_to(root).parts
621 ):
622 continue
623 if path.suffix == ".py":
624 findings.extend(python_installer_findings(path, root))
625 continue
626 if path.suffix not in suffixes and path.name not in {"Dockerfile", "justfile"}:
627 continue
628 source, error = read_authored_text(path, "installer policy input")
629 if error is not None:
630 findings.append(error)
631 continue
632 label = shell_installer_label(source or "")
633 if label is not None:
634 findings.append(f"{path.relative_to(root)}: forbidden {label}")
635 return sorted(findings)
636
637
638def command_distribution(token: str) -> str | None:
639 """Map one literal executable token to its owning Python distribution."""
640 command = token.strip("();|&").replace("\\", "/").rsplit("/", maxsplit=1)[-1]
641 return CLI_DISTRIBUTIONS.get(command)
642
643
644def shutil_which_aliases(
645 tree: ast.AST, bindings: Mapping[str, ast.AST]
646) -> tuple[set[str], set[str]]:
647 """Return imported shutil module and which-function aliases."""
648 modules: set[str] = set()
649 functions: set[str] = set()
650 for node in ast.walk(tree):
651 if isinstance(node, ast.Import):
652 modules.update(
653 alias.asname or alias.name for alias in node.names if alias.name == "shutil"
654 )
655 elif isinstance(node, ast.ImportFrom) and node.module == "shutil":
656 functions.update(
657 alias.asname or alias.name for alias in node.names if alias.name == "which"
658 )
659 propagate_member_aliases(bindings, modules, functions, "which")
660 return modules, functions
661
662
663def python_cli_consumers(path: Path) -> tuple[set[str], list[str]]:
664 """Discover Python CLI consumers and report malformed authored inputs."""
665 source, error = read_authored_text(path, "Python CLI policy input")
666 if error is not None:
667 return set(), [error]
668 try:
669 tree = ast.parse(source or "", filename=str(path))
670 except SyntaxError as error:
671 return set(), [f"{path}: cannot inspect Python CLI calls: {error}"]
672 aliases = process_aliases(tree)
673 bindings = literal_bindings(tree)
674 shutil_modules, which_functions = shutil_which_aliases(tree, bindings)
675
676 consumers: set[str] = set()
677 for node in ast.walk(tree):
678 if not isinstance(node, ast.Call):
679 continue
680 function = node.func
681 is_which = (isinstance(function, ast.Name) and function.id in which_functions) or (
682 isinstance(function, ast.Attribute)
683 and isinstance(function.value, ast.Name)
684 and function.value.id in shutil_modules
685 and function.attr == "which"
686 )
687 if is_which and node.args:
688 command = literal_string(node.args[0], bindings)
689 if command is not None:
690 package = command_distribution(command)
691 if package is not None:
692 consumers.add(package)
693 if is_process_call(function, aliases):
694 argument = process_command_argument(node)
695 words = (
696 literal_command_words(argument, aliases, bindings) if argument is not None else None
697 )
698 if words:
699 package = command_distribution(words[0])
700 if package is not None:
701 consumers.add(package)
702 return consumers, []
703
704
705def discover_cli_consumers(root: Path) -> tuple[set[str], list[str]]:
706 """Discover live CLI use independently from direct-pin and proof registries."""
707 consumers: set[str] = set()
708 findings: list[str] = []
709 shell_suffixes = {".bash", ".cmd", ".just", ".ps1", ".sh", ".yaml", ".yml", ".zsh"}
710 for path in repository_policy_paths(root):
711 if (
712 not path.is_file()
713 or ignored_policy_path(root, path)
714 or "third_party" in path.relative_to(root).parts
715 ):
716 continue
717 if path.suffix == ".py":
718 discovered, errors = python_cli_consumers(path)
719 consumers.update(discovered)
720 findings.extend(errors)
721 continue
722 if path.suffix not in shell_suffixes and path.name not in {
723 "Dockerfile",
724 "justfile",
725 }:
726 continue
727 source, error = read_authored_text(path, "CLI policy input")
728 if error is not None:
729 findings.append(error)
730 continue
731 for line in (source or "").splitlines():
732 try:
733 tokens = shlex.split(line, comments=True, posix=True)
734 except ValueError:
735 continue
736 consumers.update(
737 package
738 for package in (command_distribution(token) for token in tokens)
739 if package is not None
740 )
741 return consumers, findings
742
743
744def scanner_selection_selftest(root: Path) -> list[str]:
745 """Prove tracked text is scanned while ignored artifacts stay out of scope."""
746 with isolated_git_environment():
747 return scanner_selection_cases(root)
748
749
750def scanner_selection_cases(root: Path) -> list[str]:
751 """Exercise Git-index selection and UTF-8 findings in an isolated fixture."""
752 failures: list[str] = []
753 root.mkdir(parents=True, exist_ok=True)
754 subprocess.run( # noqa: S603 -- absolute executable, fixed selftest argv, no shell
755 [git_executable(), "init", "-q"], cwd=root, check=True
756 )
757 (root / ".gitignore").write_text("._*\n", encoding="ascii")
758 (root / "valid.py").write_text("import subprocess\n", encoding="utf-8")
759 (root / "invalid.py").write_bytes(b"# bad utf-8: \xa3\n")
760 (root / "._ignored.py").write_bytes(b"# ignored artifact: \xa3\n")
761 subprocess.run( # noqa: S603 -- absolute executable, fixed selftest argv, no shell
762 [git_executable(), "add", "--", ".gitignore", "valid.py", "invalid.py"],
763 cwd=root,
764 check=True,
765 )
766 names = {path.name for path in repository_policy_paths(root)}
767 if names != {".gitignore", "invalid.py", "valid.py"}:
768 failures.append(f"tracked policy enumeration mismatch: {sorted(names)}")
769 valid, valid_error = read_authored_text(root / "valid.py", "fixture")
770 if valid is None or valid_error is not None:
771 failures.append("valid tracked UTF-8 input failed")
772 invalid, invalid_error = read_authored_text(root / "invalid.py", "fixture")
773 if invalid is not None or invalid_error is None or "not valid UTF-8" not in invalid_error:
774 failures.append("tracked non-UTF-8 input did not fail clearly")
775 if any("._ignored.py" in item for item in unsafe_install_findings(root)):
776 failures.append("ignored AppleDouble artifact entered the installer scan")
777 _, cli_errors = discover_cli_consumers(root)
778 if not any("invalid.py" in item and "not valid UTF-8" in item for item in cli_errors):
779 failures.append("tracked non-UTF-8 CLI input did not produce a finding")
780 return failures
781
782
783def cli_consumer_findings(root: Path, pins: Mapping[str, str]) -> list[str]:
784 """Require every live locked CLI consumer to retain an exact direct pin."""
785 discovered, findings = discover_cli_consumers(root)
786 missing = set(CLI_DISTRIBUTIONS.values()) - discovered
787 if missing:
788 findings.append(f"CLI consumer census missed live package(s): {sorted(missing)}")
789 findings.extend(
790 f"CLI dependency {package} is invoked but not directly pinned"
791 for package in sorted(discovered - set(pins))
792 )
793 return findings