ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_shell_just_invocations.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Require shell and GUI entry points to use the repository-owned Just launcher.
5
6Just recipes export the exact invoking executable through ``RA8_JUST``. Shell
7scripts then enter Just through ``scripts/dev/run_just.sh``, which preserves
8that executable even when a noninteractive PATH cannot find it. Direct calls
9inside a devcontainer command are a different namespace: the image owns its
10PATH and its pinned Just, so those argv tails remain bare by design.
11
12VS Code tasks, the IDE runbook, and the project MCP configuration are included
13because GUI processes commonly inherit a reduced PATH and must not bypass Just
14with raw script entry points. Workflow YAML remains outside this check: those
15calls are process entry points whose jobs provision Just before use. The sibling
16``check_justfiles.py`` owns recipe bodies and the devcontainer boundary.
17"""
18
19from __future__ import annotations
20
21import json
22import re
23import shutil
24import subprocess
25import sys
26from pathlib import Path
27
28sys.path.insert(0, str(Path(__file__).resolve().parent))
29
30from shell_entrypoint_policy import PRIVILEGED_PATHS, SHELL_POLICIES
31from shell_invocation_policy import (
32 scan_caller_text,
33)
34from shell_invocation_policy import (
35 selftest_failures as structural_caller_selftest_failures,
36)
37
38REPO_ROOT = Path(__file__).resolve().parents[2]
39SELF = "scripts/checks/check_shell_just_invocations.py"
40VSCODE_TASKS = ".vscode/tasks.json"
41IDE_DOCUMENT = "docs/IDE.md"
42MCP_CONFIG = ".mcp.json"
43ROOT_JUSTFILE = "justfile"
44WORKSPACE_JUSTFILE = "just/ws.just"
45SENSITIVE_BOUNDARY_LINES = (
46 (
47 "just/ci_gate.just",
48 (('["/bin/bash", "-p", "scripts/ci.sh", "--list-gates"],', 1),),
49 ),
50 (
51 "scripts/ci/check_ci_parity.py",
52 (('["/bin/bash", "-p", str(CI_SH), "--list-gates"],', 1),),
53 ),
54 (
55 "just/hw.just",
56 (
57 ("#!/bin/bash -p", 4),
58 (
59 "# SHEBANG-SECURITY: -p blocks BASH_ENV and exported-function startup injection.",
60 4,
61 ),
62 ('/bin/bash -p scripts/dev/openocd_flash.sh "$hex"', 1),
63 ('/bin/bash -p scripts/dev/flash.sh "$hex"', 1),
64 ('/bin/bash -p scripts/dev/openocd_debug.sh "$elf"', 1),
65 ('/bin/bash -p scripts/dev/debug.sh "$elf"', 1),
66 ('/bin/bash -p scripts/dev/ozone.sh "$elf"', 1),
67 ("/bin/bash -p scripts/dev/monitor.sh", 1),
68 ),
69 ),
70 ("just/emu.just", (("/bin/bash -p scripts/emu/setup_macos.sh", 1),)),
71 (
72 "scripts/emu/setup_macos.sh",
73 (
74 ('/bin/bash -p "${installer}"', 1),
75 (
76 'RA8_UNICORN_PREFIX="$prefix" /bin/bash -p "$root/scripts/ci/install_unicorn.sh"',
77 1,
78 ),
79 ),
80 ),
81 (
82 "infra/network/verify_bench_wifi.sh",
83 (
84 (
85 'setsid /bin/bash -p -c "sleep ${RESTORE_AFTER}; ${RESTORE_CMD}" '
86 ">/dev/null 2>&1 </dev/null &",
87 1,
88 ),
89 ),
90 ),
91 (
92 "scripts/builders/docs.sh",
93 (('DOXYGEN_BIN="$(/bin/bash -p "${SCRIPT_DIR}/provision_doxygen.sh")"', 1),),
94 ),
95 (
96 "scripts/ci/gates/checks.sh",
97 (("/bin/bash -p scripts/ci/devcontainer_image.sh --selftest-offline", 1),),
98 ),
99 (
100 "scripts/ci/gates/hygiene.sh",
101 (
102 ("/bin/bash -p scripts/dev/setup_python.sh --selftest", 1),
103 ("/bin/bash -p scripts/ci/monitor.sh selftest", 1),
104 ),
105 ),
106 (
107 "scripts/ci/lib/container.sh",
108 (('/bin/bash -p "$repo/scripts/ci/devcontainer_image.sh" "${args[@]}"', 1),),
109 ),
110 (
111 "scripts/dev/remote_gdb_server.sh",
112 (
113 ('/bin/bash -p "$ROOT/scripts/hil/flash.sh" "$APP_ID"', 1),
114 ('REMOTE_START_COMMAND="$(', 1),
115 ('"$PYTHON" -I "$ARGS_GUARD" remote-command \\', 1),
116 ('-- "$PI_HOST" "$REMOTE_START_COMMAND" <"$REMOTE_GUARD" &', 1),
117 ('REMOTE_CLEANUP_COMMAND="$(', 0),
118 ('"$PYTHON" -I "$ARGS_GUARD" remote-command cleanup \\', 0),
119 ('"$PYTHON" -I "$ARGS_GUARD" remote-command start \\', 0),
120 ('/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=5 -- "$PI_HOST" \\', 0),
121 ("\"$REMOTE_CLEANUP_COMMAND\" <<'REMOTE_CLEANUP'", 0),
122 ('-- "$PI_HOST" "$REMOTE_START_COMMAND" <<\'REMOTE\' &', 0),
123 ),
124 ),
125 (
126 "scripts/dev/remote_gdb_args.py",
127 (
128 (
129 'return shlex.join(["/usr/bin/python3", "-I", "-", "--", *fields])',
130 1,
131 ),
132 ('return shlex.join(["/bin/bash", "-p", "-s", "--", *fields])', 0),
133 ),
134 ),
135 (
136 "just/docs.just",
137 (("/bin/bash -p scripts/builders/publish_docs.sh", 1),),
138 ),
139)
140MIN_LAUNCHER_ARGV = 3
141EXCLUDED_PREFIXES = (
142 "docs/sbom/upstream/",
143 "libs/third_party/",
144 "apps/shared_libs/third_party/",
145 "port/netxduo/",
146 "port/nimble/",
147 "port/threadx/",
148 "port/usbx/",
149 "tests/fixtures/",
150)
151MIN_SHELL_FILES = 140
152MIN_PROTECTED_SCRIPTS = 65
153MIN_CALLER_FILES = 650
154PROTECTED_SHEBANG = "#!/bin/bash -p"
155PROTECTED_REASON = "# SHEBANG-SECURITY: -p blocks BASH_ENV and exported-function startup injection."
156FUTURE_SCRIPT_FIXTURE = "scripts/secrets/future_ceremony.sh" # PATHREF-OK: future-path test fixture
157
158COMMAND_PREFIX = (
159 r"(?:^\s*|(?:&&|\|\||;|\|)\s*|\$\‍(\s*)"
160 r"(?:if\s+|elif\s+|while\s+|until\s+)?!?(?:\s*)"
161 r"(?:(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;&|]+)\s+)*"
162 r"(?:(?:exec|time)\s+)?"
163)
164BARE_JUST_RE = re.compile(COMMAND_PREFIX + r"just(?=\s|$)")
165ARRAY_JUST_RE = re.compile(r"^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\‍(\s*(?:[\"'])?just(?:[\"'])?(?=\s|\‍))")
166SHELL_COMMAND_RE = re.compile(
167 r"\b(?:ba|z|da)?sh\s+(?:-[A-Za-z]*c|--command)\s+([\"'])\s*just(?=\s)"
168)
169COMMAND_SUB_JUST_RE = re.compile(r"\$\‍(\s*just(?=\s|$)")
170HEREDOC_RE = re.compile(r"<<-?\s*([\"']?)([A-Za-z_][A-Za-z0-9_]*)\1")
171SCRIPT_PATH_RE = re.compile(
172 r"(?:\$\{workspaceFolder\}/)?(?P<path>scripts/[A-Za-z0-9_./-]+\.(?:sh|py))"
173)
174UNPRIVILEGED_RUN_JUST_RE = re.compile(
175 r"(?<![A-Za-z0-9_/])bash\s+(?:\"[^\"]*\"|'[^']*'|[^\s;|&]*)scripts/dev/run_just\.sh"
176)
177
178
179def _is_shell(path: Path, text: str) -> bool:
180 """Return whether ``path`` is a first-party shell entry point."""
181 if path.suffix in {".sh", ".bash", ".zsh"}:
182 return True
183 first = text.partition("\n")[0]
184 return first.startswith("#!") and re.search(r"\b(?:ba|z|da)?sh\b", first) is not None
185
186
187def scoped_files() -> list[str]:
188 """Return tracked and untracked first-party shell entry points."""
189 git_bin = shutil.which("git") or "git"
190 proc = subprocess.run( # noqa: S603 -- resolved Git executable and fixed arguments
191 [git_bin, "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
192 cwd=REPO_ROOT,
193 check=True,
194 capture_output=True,
195 )
196 rels: list[str] = []
197 for rel in proc.stdout.decode("utf-8", errors="strict").split("\0"):
198 if not rel or rel.startswith(EXCLUDED_PREFIXES):
199 continue
200 path = REPO_ROOT / rel
201 if not path.is_file():
202 continue
203 try:
204 text = path.read_text(encoding="utf-8")
205 except UnicodeDecodeError:
206 continue
207 if _is_shell(path, text):
208 rels.append(rel)
209 return sorted(set(rels))
210
211
212def bare_just_line(line: str) -> bool:
213 """Return whether one active shell line launches Just through PATH."""
214 stripped = line.lstrip()
215 if not stripped or stripped.startswith("#"):
216 return False
217 if "devcontainer_run.sh" in line and re.search(r"--\s+.*\bjust\b", line) is not None:
218 return False
219 masked = _mask_quotes(line)
220 return any(
221 pattern.search(candidate) is not None
222 for pattern, candidate in (
223 (BARE_JUST_RE, masked),
224 (ARRAY_JUST_RE, line),
225 (SHELL_COMMAND_RE, line),
226 (COMMAND_SUB_JUST_RE, _mask_single_quotes(line)),
227 )
228 )
229
230
231def _mask_quotes(line: str) -> str:
232 """Mask quoted prose while retaining token width for shell assignments."""
233 masked: list[str] = []
234 quote = ""
235 escaped = False
236 for char in line:
237 if quote:
238 masked.append("_")
239 if escaped:
240 escaped = False
241 elif char == "\\" and quote == '"':
242 escaped = True
243 elif char == quote:
244 quote = ""
245 elif char in "'\"":
246 quote = char
247 masked.append("_")
248 else:
249 masked.append(char)
250 return "".join(masked)
251
252
253def _mask_single_quotes(line: str) -> str:
254 """Mask single-quoted literals, where command substitution is inactive."""
255 masked: list[str] = []
256 in_single = False
257 for char in line:
258 if char == "'":
259 in_single = not in_single
260 masked.append("_")
261 elif in_single:
262 masked.append("_")
263 else:
264 masked.append(char)
265 return "".join(masked)
266
267
268def scan_text(text: str) -> list[int]:
269 """Return line numbers containing bare Just commands outside heredocs."""
270 findings: list[int] = []
271 heredoc_end: str | None = None
272 lines = text.splitlines()
273 index = 0
274 while index < len(lines):
275 number = index + 1
276 line = lines[index]
277 index += 1
278 if heredoc_end is not None:
279 if line.strip() == heredoc_end:
280 heredoc_end = None
281 continue
282 logical = line
283 while logical.rstrip().endswith("\\") and index < len(lines):
284 logical = logical.rstrip()[:-1] + " " + lines[index].lstrip()
285 index += 1
286 if bare_just_line(logical) or UNPRIVILEGED_RUN_JUST_RE.search(logical):
287 findings.append(number)
288 match = HEREDOC_RE.search(logical)
289 if match is not None:
290 heredoc_end = match.group(2)
291 return findings
292
293
294def scan(rels: list[str]) -> list[str]:
295 """Return every shell, editor, and MCP entry-point bypass finding."""
296 findings: list[str] = []
297 for rel in rels:
298 text = (REPO_ROOT / rel).read_text(encoding="utf-8")
299 findings.extend(f"{rel}:{line}" for line in scan_text(text))
300
301 findings.extend(_fixed_surface_findings())
302 protected = protected_script_paths(rels)
303 if len(protected) < MIN_PROTECTED_SCRIPTS:
304 findings.append(
305 f"protected shell population collapsed to {len(protected)}; "
306 f"expected at least {MIN_PROTECTED_SCRIPTS}"
307 )
308 if protected != PRIVILEGED_PATHS:
309 findings.extend(
310 f"typed/header protected population drift: {path}"
311 for path in sorted(protected ^ PRIVILEGED_PATHS)
312 )
313 findings.extend(scan_structural_callers(rels))
314 findings.extend(scan_sensitive_boundary_files())
315 return findings
316
317
318def _fixed_surface_findings() -> list[str]:
319 """Validate required VS Code, IDE, MCP, and fixed Just surfaces."""
320 findings: list[str] = []
321 tasks_path = REPO_ROOT / VSCODE_TASKS
322 if not tasks_path.is_file():
323 findings.append(f"{VSCODE_TASKS}: required GUI task configuration is missing")
324 else:
325 task_findings = scan_vscode_tasks(tasks_path.read_text(encoding="utf-8"))
326 findings.extend(f"{VSCODE_TASKS}: {finding}" for finding in task_findings)
327
328 ide_path = REPO_ROOT / IDE_DOCUMENT
329 if not ide_path.is_file():
330 findings.append(f"{IDE_DOCUMENT}: required IDE runbook is missing")
331 else:
332 findings.extend(
333 f"{IDE_DOCUMENT}:{line}: launches a raw script instead of run_just.sh"
334 for line in scan_ide_document(ide_path.read_text(encoding="utf-8"))
335 )
336
337 mcp_path = REPO_ROOT / MCP_CONFIG
338 if not mcp_path.is_file():
339 findings.append(f"{MCP_CONFIG}: required project MCP configuration is missing")
340 else:
341 mcp_findings = scan_mcp_config(mcp_path.read_text(encoding="utf-8"))
342 findings.extend(f"{MCP_CONFIG}: {finding}" for finding in mcp_findings)
343 root_just = REPO_ROOT / ROOT_JUSTFILE
344 if not root_just.is_file():
345 findings.append(f"{ROOT_JUSTFILE}: required root Justfile is missing")
346 else:
347 root_findings = scan_root_justfile(root_just.read_text(encoding="utf-8"))
348 findings.extend(f"{ROOT_JUSTFILE}: {finding}" for finding in root_findings)
349 workspace_just = REPO_ROOT / WORKSPACE_JUSTFILE
350 if not workspace_just.is_file():
351 findings.append(f"{WORKSPACE_JUSTFILE}: required workspace Justfile is missing")
352 else:
353 workspace_findings = scan_workspace_justfile(workspace_just.read_text(encoding="utf-8"))
354 findings.extend(f"{WORKSPACE_JUSTFILE}: {finding}" for finding in workspace_findings)
355 return findings
356
357
358def is_protected_script_text(text: str) -> bool:
359 """Return whether a shell entry owns the exact reviewed startup boundary."""
360 return text.splitlines()[:4] == [
361 PROTECTED_SHEBANG,
362 "# SPDX-License-Identifier: MIT",
363 "# Copyright (c) 2026 Brighton Sikarskie",
364 PROTECTED_REASON,
365 ]
366
367
368def protected_script_paths(rels: list[str]) -> set[str]:
369 """Derive the protected script population from exact reviewed file headers."""
370 protected: set[str] = set()
371 for rel in rels:
372 path = REPO_ROOT / rel
373 try:
374 text = path.read_text(encoding="utf-8")
375 except (OSError, UnicodeError):
376 continue
377 if is_protected_script_text(text):
378 protected.add(rel)
379 return protected
380
381
382def caller_files(shell_rels: list[str]) -> list[str]:
383 """Return all first-party executable and configuration caller surfaces."""
384 git_bin = shutil.which("git") or "git"
385 proc = subprocess.run( # noqa: S603 -- resolved Git executable and fixed arguments
386 [git_bin, "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
387 cwd=REPO_ROOT,
388 check=True,
389 capture_output=True,
390 )
391 callers = set(shell_rels)
392 for rel in proc.stdout.decode("utf-8", errors="strict").split("\0"):
393 if not rel or rel.startswith(EXCLUDED_PREFIXES):
394 continue
395 path = Path(rel)
396 if (
397 rel == ".env.example"
398 or path.name == "justfile"
399 or path.name.endswith("Dockerfile")
400 or path.suffix
401 in {
402 ".json",
403 ".just",
404 ".md",
405 ".py",
406 ".yaml",
407 ".yml",
408 }
409 ):
410 callers.add(rel)
411 return sorted(callers)
412
413
414def scan_structural_callers(shell_rels: list[str]) -> list[str]:
415 """Validate all supported first-party caller formats structurally."""
416 findings: list[str] = []
417 callers = caller_files(shell_rels)
418 if len(callers) < MIN_CALLER_FILES:
419 findings.append(
420 f"structural caller scope collapsed to {len(callers)}; "
421 f"expected at least {MIN_CALLER_FILES}"
422 )
423 for rel in callers:
424 path = REPO_ROOT / rel
425 try:
426 text = path.read_text(encoding="utf-8")
427 except (OSError, UnicodeError):
428 continue
429 findings.extend(
430 f"{rel}:{finding.line}: {finding.message}"
431 for finding in scan_caller_text(rel, text, SHELL_POLICIES)
432 )
433 return findings
434
435
436def scan_root_justfile(text: str) -> list[str]:
437 """Require fixed Bash at setup and writable-container boundaries."""
438 required = (
439 "/bin/bash -p scripts/ci/devcontainer_image.sh ensure",
440 "/bin/bash -p scripts/dev/setup_python.sh setup",
441 "/bin/bash -p scripts/dev/setup_ansible.sh",
442 "/bin/bash -p scripts/ci/devcontainer_run.sh -- /bin/bash -p",
443 "/usr/bin/env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin "
444 "/bin/bash -p -c 'if [[ -x /usr/bin/nproc ]]",
445 "$(/usr/bin/git config core.hooksPath)",
446 )
447 return [
448 f"root recipe lost exact privileged entry: {line}" for line in required if line not in text
449 ]
450
451
452def scan_workspace_justfile(text: str) -> list[str]:
453 """Require fixed Bash for workspace lifecycle and monitor boundaries."""
454 required = (
455 "/bin/bash -p scripts/dev/agent_workspace.sh create",
456 "/bin/bash -p scripts/dev/agent_workspace.sh release",
457 "/bin/bash -p scripts/dev/agent_workspace.sh list",
458 "/bin/bash -p scripts/dev/agent_workspace.sh doctor",
459 "/bin/bash -p scripts/dev/agent_workspace.sh reap",
460 "/bin/bash -p scripts/ci/monitor.sh status",
461 "/bin/bash -p scripts/ci/monitor.sh quota",
462 )
463 findings = [
464 f"workspace recipe lost exact privileged entry: {line}"
465 for line in required
466 if line not in text
467 ]
468 multiline_recipes = ("new", "reap", "status")
469 if text.count("#!/bin/bash -p") != len(multiline_recipes):
470 findings.append("workspace multiline recipes must own exactly three privileged shebangs")
471 return findings
472
473
474def scan_sensitive_boundary_text(rel: str, text: str) -> list[str]:
475 """Require exact protected argv at hardware, installer, and nested boundaries."""
476 contract = next(
477 (required for path, required in SENSITIVE_BOUNDARY_LINES if path == rel),
478 None,
479 )
480 if contract is None:
481 return []
482 lines = [line.strip() for line in text.splitlines() if line.strip()]
483 return [
484 f"{rel}: expected {count} exact occurrence(s) of {line!r}, found {lines.count(line)}"
485 for line, count in contract
486 if lines.count(line) != count
487 ]
488
489
490def scan_sensitive_boundary_files() -> list[str]:
491 """Scan every fixed sensitive caller, failing closed when one disappears."""
492 findings: list[str] = []
493 for rel, _required in SENSITIVE_BOUNDARY_LINES:
494 path = REPO_ROOT / rel
495 if not path.is_file():
496 findings.append(f"{rel}: required sensitive boundary is missing")
497 continue
498 findings.extend(scan_sensitive_boundary_text(rel, path.read_text(encoding="utf-8")))
499 return findings
500
501
502def _direct_just_command(command: object) -> bool:
503 """Return whether a VS Code task command directly selects Just."""
504 if not isinstance(command, str):
505 return False
506 first = command.strip().split(maxsplit=1)[0] if command.strip() else ""
507 return first == "just" or first.endswith("/just")
508
509
510def _raw_script_paths(values: list[object]) -> list[str]:
511 """Return first-party script paths other than the canonical launcher."""
512 paths: list[str] = []
513 for value in values:
514 if not isinstance(value, str):
515 continue
516 for match in SCRIPT_PATH_RE.finditer(value):
517 path = match.group("path")
518 if path != "scripts/dev/run_just.sh":
519 paths.append(path)
520 return paths
521
522
523def scan_vscode_tasks(text: str) -> list[str]:
524 """Return labels of VS Code tasks that bypass ``run_just.sh``."""
525 try:
526 document = json.loads(text)
527 except json.JSONDecodeError as exc:
528 return [f"invalid JSON ({exc.msg})"]
529 if not isinstance(document, dict) or not isinstance(document.get("tasks"), list):
530 return ["top-level tasks array is missing"]
531 findings: list[str] = []
532 for index, task in enumerate(document["tasks"], start=1):
533 if not isinstance(task, dict):
534 findings.append(f"task {index} is not an object")
535 continue
536 label = task.get("label", f"task {index}")
537 if _direct_just_command(task.get("command")):
538 findings.append(f"task {label!r} launches Just directly")
539 continue
540 args = task.get("args", [])
541 values = [task.get("command"), *(args if isinstance(args, list) else [])]
542 paths = _raw_script_paths(values)
543 if paths:
544 findings.append(f"task {label!r} launches raw script {paths[0]!r}")
545 elif any(_is_just_launcher(value) for value in values) and not _valid_vscode_launcher(
546 task.get("command"), args
547 ):
548 findings.append(f"task {label!r} misplaces scripts/dev/run_just.sh")
549 return findings
550
551
552def scan_ide_document(text: str) -> list[int]:
553 """Return IDE runbook lines that invoke first-party scripts directly."""
554 return [
555 number
556 for number, line in enumerate(text.splitlines(), start=1)
557 if _raw_script_paths([line])
558 ]
559
560
561def _is_just_launcher(value: object) -> bool:
562 """Return whether one argv value names the repository Just launcher."""
563 return isinstance(value, str) and value.replace("\\", "/").endswith("scripts/dev/run_just.sh")
564
565
566def _valid_vscode_launcher(command: object, args: object) -> bool:
567 """Require the launcher in executable position, followed by a recipe."""
568 if not isinstance(args, list) or not args:
569 return False
570 return (
571 command == "/bin/bash"
572 and len(args) >= MIN_LAUNCHER_ARGV
573 and args[0] == "-p"
574 and _is_just_launcher(args[1])
575 and isinstance(args[2], str)
576 and bool(args[2])
577 )
578
579
580def scan_mcp_config(text: str) -> list[str]:
581 """Return project MCP servers that do not enter through the MCP Just recipe."""
582 try:
583 document = json.loads(text)
584 except json.JSONDecodeError as exc:
585 return [f"invalid JSON ({exc.msg})"]
586 servers = document.get("mcpServers") if isinstance(document, dict) else None
587 if not isinstance(servers, dict) or not servers:
588 return ["top-level mcpServers object is missing or empty"]
589 findings: list[str] = []
590 for name, server in servers.items():
591 if not isinstance(server, dict):
592 findings.append(f"server {name!r} is not an object")
593 continue
594 args = server.get("args")
595 if server.get("command") != "/bin/bash" or args != [
596 "-p",
597 "scripts/dev/run_just.sh",
598 "tools::mcp_server",
599 ]:
600 findings.append(
601 f"server {name!r} must use exact /bin/bash -p, run_just.sh, MCP recipe argv"
602 )
603 return findings
604
605
606def _shell_selftest_failures() -> tuple[list[str], int]:
607 """Exercise shell parsing in both directions."""
608 cases = (
609 ("just ci\n", [1], "direct command fires"),
610 ("if just apps::build blink; then :; fi\n", [1], "conditional command fires"),
611 ("CC=clang just tools::build\n", [1], "environment-prefixed command fires"),
612 ("false || just hil::probe\n", [1], "chained command fires"),
613 ("cmd=(just quality::run)\n", [1], "deferred command array fires"),
614 ("bash -uc 'just tests::build'\n", [1], "nested shell command fires"),
615 ('bash "$root/scripts/dev/run_just.sh" ci\n', [1], "unprivileged launcher fires"),
616 (
617 '/bin/bash -p "$root/scripts/dev/run_just.sh" ci\n',
618 [],
619 "privileged launcher stays quiet",
620 ),
621 (
622 "bash scripts/ci/devcontainer_run.sh -- just quality::local::test\n",
623 [],
624 "container-owned command stays quiet",
625 ),
626 (
627 "exec bash scripts/ci/devcontainer_run.sh -- \\\n just quality::local::test\n",
628 [],
629 "continued container-owned command stays quiet",
630 ),
631 ('version="$(just --version)"\n', [1], "unresolved version probe fires"),
632 ('echo "run just ci"\n', [], "help prose stays quiet"),
633 ("cat <<'HELP'\ncd repo && just ci\nHELP\n", [], "heredoc guidance stays quiet"),
634 )
635 return [label for text, expected, label in cases if scan_text(text) != expected], len(cases)
636
637
638def _task_selftest_failures() -> tuple[list[str], int]:
639 """Exercise VS Code entry-point parsing in both directions."""
640 task_cases = (
641 ('{"tasks":[{"label":"bad","command":"just"}]}', 1, "bare GUI command fires"),
642 (
643 '{"tasks":[{"label":"bad","command":"/usr/local/bin/just"}]}',
644 1,
645 "absolute GUI command fires",
646 ),
647 (
648 '{"tasks":[{"label":"safe","command":"/bin/bash",'
649 '"args":["-p","scripts/dev/run_just.sh","ci"]}]}',
650 0,
651 "GUI launcher call stays quiet",
652 ),
653 (
654 '{"tasks":[{"label":"bad","command":"/bin/bash",'
655 '"args":["scripts/dev/run_just.sh","ci"]}]}',
656 1,
657 "GUI launcher without privileged mode fires",
658 ),
659 (
660 '{"tasks":[{"label":"bad","command":"scripts/dev/run_just.sh","args":["ci"]}]}',
661 1,
662 "direct GUI launcher fires",
663 ),
664 (
665 '{"tasks":[{"label":"bad","command":"bash","args":["scripts/hil/flash.sh"]}]}',
666 1,
667 "raw GUI script fires",
668 ),
669 (
670 f'{{"tasks":[{{"label":"bad","command":"bash","args":["{FUTURE_SCRIPT_FIXTURE}"]}}]}}',
671 1,
672 "future raw GUI script fires",
673 ),
674 (
675 '{"tasks":[{"label":"bad","command":"/bin/echo",'
676 '"args":["scripts/dev/run_just.sh","ci"]}]}',
677 1,
678 "misplaced GUI launcher fires",
679 ),
680 )
681 failures = [
682 label for text, expected, label in task_cases if len(scan_vscode_tasks(text)) != expected
683 ]
684 return failures, len(task_cases)
685
686
687def _ide_selftest_failures() -> tuple[list[str], int]:
688 """Exercise IDE runbook entry-point parsing in both directions."""
689 ide_cases = (
690 ("GDB args: scripts/dev/remote_gdb_server.sh run\n", [1], "raw IDE script fires"),
691 (
692 "GDB args: scripts/dev/run_just.sh hil::remote_gdb run\n",
693 [],
694 "IDE launcher stays quiet",
695 ),
696 (
697 f"GDB args: {FUTURE_SCRIPT_FIXTURE} run\n",
698 [1],
699 "future raw IDE script fires",
700 ),
701 )
702 failures = [label for text, expected, label in ide_cases if scan_ide_document(text) != expected]
703 return failures, len(ide_cases)
704
705
706def _mcp_selftest_failures() -> tuple[list[str], int]:
707 """Exercise exact MCP executable and argv parsing in both directions."""
708 mcp_cases = (
709 (
710 '{"mcpServers":{"bad":{"command":"python3","args":["server.py"]}}}',
711 1,
712 "raw MCP command fires",
713 ),
714 (
715 f'{{"mcpServers":{{"bad":{{"command":"bash","args":["{FUTURE_SCRIPT_FIXTURE}"]}}}}',
716 1,
717 "future raw MCP script fires",
718 ),
719 (
720 '{"mcpServers":{"safe":{"command":"/bin/bash",'
721 '"args":["-p","scripts/dev/run_just.sh","tools::mcp_server"]}}}',
722 0,
723 "MCP launcher stays quiet",
724 ),
725 (
726 '{"mcpServers":{"bad":{"command":"/bin/bash",'
727 '"args":["scripts/dev/run_just.sh","tools::mcp_server"]}}}',
728 1,
729 "MCP launcher without privileged mode fires",
730 ),
731 (
732 '{"mcpServers":{"bad":{"command":"/bin/bash",'
733 '"args":["scripts/dev/run_just.sh","tools::mcp"]}}}',
734 1,
735 "wrong MCP recipe fires",
736 ),
737 (
738 '{"mcpServers":{"bad":{"command":"/bin/echo",'
739 '"args":["scripts/dev/run_just.sh","tools::mcp_server"]}}}',
740 1,
741 "misplaced MCP launcher fires",
742 ),
743 (
744 '{"mcpServers":{"bad":{"command":"/bin/bash",'
745 '"args":["tools::mcp_server","scripts/dev/run_just.sh"]}}}',
746 1,
747 "reordered MCP argv fires",
748 ),
749 )
750 failures = [
751 label for text, expected, label in mcp_cases if len(scan_mcp_config(text)) != expected
752 ]
753 return failures, len(mcp_cases)
754
755
756def _root_just_selftest_failures() -> tuple[list[str], int]:
757 """Exercise fixed root-recipe Bash ownership in both directions."""
758 safe = (
759 "/bin/bash -p scripts/ci/devcontainer_image.sh ensure\n"
760 "/bin/bash -p scripts/dev/setup_python.sh setup\n"
761 "/bin/bash -p scripts/dev/setup_ansible.sh\n"
762 "/bin/bash -p scripts/ci/devcontainer_run.sh -- /bin/bash -p\n"
763 "/usr/bin/env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin "
764 "/bin/bash -p -c 'if [[ -x /usr/bin/nproc ]]\n"
765 "$(/usr/bin/git config core.hooksPath)\n"
766 )
767 cases = (
768 (safe, 0, "privileged root recipes stay quiet"),
769 (safe.replace("/bin/bash -p", "bash", 1), 1, "PATH Bash in root recipe fires"),
770 (safe.replace(" -- /bin/bash -p", " -- bash"), 1, "PATH inner shell fires"),
771 (
772 safe.replace("/usr/bin/env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin", "env"),
773 1,
774 "caller environment in CPU probe fires",
775 ),
776 (
777 safe.replace("/usr/bin/git config", "git config"),
778 1,
779 "PATH Git in hook status fires",
780 ),
781 )
782 failures = [
783 label for text, expected, label in cases if len(scan_root_justfile(text)) != expected
784 ]
785 return failures, len(cases)
786
787
788def _workspace_just_selftest_failures() -> tuple[list[str], int]:
789 """Exercise exact workspace lifecycle and monitor entry points."""
790 safe = (
791 "#!/bin/bash -p\n#!/bin/bash -p\n#!/bin/bash -p\n"
792 "/bin/bash -p scripts/dev/agent_workspace.sh create\n"
793 "/bin/bash -p scripts/dev/agent_workspace.sh release\n"
794 "/bin/bash -p scripts/dev/agent_workspace.sh list\n"
795 "/bin/bash -p scripts/dev/agent_workspace.sh doctor\n"
796 "/bin/bash -p scripts/dev/agent_workspace.sh reap\n"
797 "/bin/bash -p scripts/ci/monitor.sh status\n"
798 "/bin/bash -p scripts/ci/monitor.sh quota\n"
799 )
800 cases = (
801 (safe, 0, "privileged workspace recipes stay quiet"),
802 (
803 safe.replace("/bin/bash -p scripts/ci/monitor.sh", "bash scripts/ci/monitor.sh", 1),
804 1,
805 "PATH Bash monitor entry fires",
806 ),
807 (
808 safe.replace(
809 "/bin/bash -p scripts/dev/agent_workspace.sh",
810 "bash scripts/dev/agent_workspace.sh",
811 1,
812 ),
813 1,
814 "PATH Bash lifecycle entry fires",
815 ),
816 (safe.replace("#!/bin/bash -p", "#!/usr/bin/env bash", 1), 1, "PATH shebang fires"),
817 )
818 failures = [
819 label for text, expected, label in cases if len(scan_workspace_justfile(text)) != expected
820 ]
821 return failures, len(cases)
822
823
824def _sensitive_boundary_selftest_failures() -> tuple[list[str], int]:
825 """Exercise every exact nested boundary in both directions."""
826 failures: list[str] = []
827 count = 1
828 for rel, requirements in SENSITIVE_BOUNDARY_LINES:
829 safe_lines = [line for line, copies in requirements for _ in range(copies)]
830 safe = "\n".join(safe_lines) + "\n"
831 count += 3
832 if scan_sensitive_boundary_text(rel, safe):
833 failures.append(f"exact sensitive boundary {rel} was rejected")
834 if "/bin/bash -p" in safe:
835 weakened = safe.replace("/bin/bash -p", "bash", 1)
836 elif '"/usr/bin/python3", "-I"' in safe:
837 weakened = safe.replace('"/usr/bin/python3", "-I"', '"python3"', 1)
838 else:
839 weakened = safe.replace('"/bin/bash", "-p"', '"bash"', 1)
840 if not scan_sensitive_boundary_text(rel, weakened):
841 failures.append(f"weakened sensitive boundary {rel} was accepted")
842 duplicated = safe + requirements[0][0] + "\n"
843 if not scan_sensitive_boundary_text(rel, duplicated):
844 failures.append(f"duplicated sensitive boundary {rel} was accepted")
845
846 if scan_sensitive_boundary_text("scripts/emu/matrix.sh", "bash ordinary.sh\n"):
847 failures.append("ordinary emulator wrapper was pulled into the sensitive registry")
848
849 contracts = dict(SENSITIVE_BOUNDARY_LINES)
850 server_rel = "scripts/dev/remote_gdb_server.sh"
851 args_rel = "scripts/dev/remote_gdb_args.py"
852 server_safe = (
853 "\n".join(line for line, copies in contracts[server_rel] for _ in range(copies)) + "\n"
854 )
855 old_cleanup = server_safe + 'REMOTE_CLEANUP_COMMAND="$(\n'
856 if not scan_sensitive_boundary_text(server_rel, old_cleanup):
857 failures.append("obsolete remote PID-sweep authority was accepted")
858 heredoc = server_safe.replace(
859 '-- "$PI_HOST" "$REMOTE_START_COMMAND" <"$REMOTE_GUARD" &',
860 '-- "$PI_HOST" "$REMOTE_START_COMMAND" <<\'REMOTE\' &',
861 )
862 if not scan_sensitive_boundary_text(server_rel, heredoc):
863 failures.append("inline remote heredoc replaced the fixed supervisor payload")
864 args_safe = (
865 "\n".join(line for line, copies in contracts[args_rel] for _ in range(copies)) + "\n"
866 )
867 reordered = args_safe.replace(
868 '["/usr/bin/python3", "-I", "-", "--", *fields]',
869 '["/usr/bin/python3", "-I", "-", *fields, "--"]',
870 )
871 if not scan_sensitive_boundary_text(args_rel, reordered):
872 failures.append("reordered remote supervisor argv was accepted")
873 count += 3
874 return failures, count
875
876
877def _structural_caller_selftest_failures() -> tuple[list[str], int]:
878 """Exercise the format-aware caller authority and header derivation."""
879 failures, count = structural_caller_selftest_failures()
880 headers = (
881 (
882 f"{PROTECTED_SHEBANG}\n"
883 "# SPDX-License-Identifier: MIT\n"
884 "# Copyright (c) 2026 Brighton Sikarskie\n"
885 f"{PROTECTED_REASON}\n"
886 ),
887 (
888 f"{PROTECTED_SHEBANG}\n{PROTECTED_REASON}\n"
889 "# SPDX-License-Identifier: MIT\n"
890 "# Copyright (c) 2026 Brighton Sikarskie\n"
891 ),
892 (f"{PROTECTED_SHEBANG}\n# SPDX-License-Identifier: MIT\n{PROTECTED_REASON}\n"),
893 "#!/usr/bin/env bash\n# ordinary entry\n",
894 )
895 if [is_protected_script_text(text) for text in headers] != [True, False, False, False]:
896 failures.append("protected script population derivation is not two-sided")
897 return failures, count + len(headers)
898
899
900def _entrypoint_selftest_failures() -> tuple[list[str], int]:
901 """Combine VS Code, IDE, and MCP entry-point tests."""
902 groups = (
903 _task_selftest_failures(),
904 _ide_selftest_failures(),
905 _mcp_selftest_failures(),
906 _root_just_selftest_failures(),
907 _workspace_just_selftest_failures(),
908 _sensitive_boundary_selftest_failures(),
909 _structural_caller_selftest_failures(),
910 )
911 return [failure for failures, _count in groups for failure in failures], sum(
912 count for _failures, count in groups
913 )
914
915
916def selftest() -> int:
917 """Prove shell and GUI entry-point checks in both directions."""
918 shell_failures, shell_count = _shell_selftest_failures()
919 entry_failures, entry_count = _entrypoint_selftest_failures()
920 failures = shell_failures + entry_failures
921 for failure in failures:
922 print(f"check_shell_just_invocations.py --selftest: FAIL: {failure}", file=sys.stderr)
923 if failures:
924 return 1
925 print(f"check_shell_just_invocations.py --selftest: PASS ({shell_count + entry_count} cases)")
926 return 0
927
928
929def main() -> int:
930 """Run detector self-tests or scan the live first-party shell scope."""
931 if sys.argv[1:] == ["--selftest"]:
932 return selftest()
933 if sys.argv[1:]:
934 print("usage: check_shell_just_invocations.py [--selftest]", file=sys.stderr)
935 return 2
936 try:
937 rels = scoped_files()
938 except (OSError, subprocess.CalledProcessError, UnicodeError) as exc:
939 print(f"cannot enumerate first-party shell files: {exc}", file=sys.stderr)
940 return 2
941 if len(rels) < MIN_SHELL_FILES or not (REPO_ROOT / SELF).is_file():
942 print(
943 f"shell scope collapsed to {len(rels)} files; expected at least "
944 f"{MIN_SHELL_FILES} with checker {SELF}",
945 file=sys.stderr,
946 )
947 return 2
948 findings = scan(rels)
949 if findings:
950 print("shell or GUI entry point(s) bypass scripts/dev/run_just.sh:", file=sys.stderr)
951 for finding in findings:
952 print(f" {finding}", file=sys.stderr)
953 return 1
954 print(f"check_shell_just_invocations.py: clean ({len(rels)} shell files + GUI entry points)")
955 return 0
956
957
958if __name__ == "__main__":
959 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298