ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
hil_convergence_safety_roles.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Validate the authenticated bench-role entry points."""
4
5from __future__ import annotations
6
7import re
8from typing import cast
9
10import yaml
11
12ROLE_PREFIX_LENGTH = 2
13LOCAL_AUTH_ARGC = 10
14FLEET_PAYLOAD_KEYS = 18
15REMOTE_VERIFY_ARGC = 9
16BENCH_HOLDER_DECISION = (
17 "hil_bench_maintenance_record.resource == 'bench'",
18 "hil_bench_maintenance_record.lock_id == hil_bench_maintenance_lock_id",
19 "hil_bench_maintenance_record.hold_kind == 'wrapped'",
20)
21BENCH_HOLDER_FILE_PROOF = (
22 "hil_bench_maintenance_record_stat.stat.exists",
23 "hil_bench_maintenance_record_stat.stat.isreg | default(false)",
24 "not hil_bench_maintenance_record_stat.stat.islnk | default(false)",
25)
26
27
28class RoleError(ValueError):
29 """A required uniquely named task is missing or duplicated."""
30
31
32def _service_installer_errors(source: str) -> list[str]:
33 """Require fixed privileged Bash argv for both generated user services."""
34 try:
35 tasks = yaml.safe_load(source)
36 except yaml.YAMLError:
37 return ["dev_box transaction: malformed YAML"]
38 if not isinstance(tasks, list):
39 return ["dev_box transaction: task list is malformed"]
40 expected = {
41 "Install the shared CI status poller": [
42 "/bin/bash",
43 "-p",
44 "scripts/ci/monitor.sh",
45 "install-service",
46 ],
47 "Install the workspace reaper": [
48 "/bin/bash",
49 "-p",
50 "scripts/dev/agent_workspace.sh",
51 "install-timer",
52 ],
53 }
54 found: dict[str, object] = {}
55 for task in tasks:
56 if isinstance(task, dict) and task.get("name") in expected:
57 command = task.get("ansible.builtin.command")
58 found[str(task["name"])] = command.get("argv") if isinstance(command, dict) else None
59 return [] if found == expected else ["dev_box transaction: service installer argv is not exact"]
60
61
62def _dev_shell_command_errors(source: str) -> list[str]:
63 """Reject PATH/startup-sensitive Bash commands in the dev-box transaction."""
64 try:
65 tasks = yaml.safe_load(source)
66 except yaml.YAMLError:
67 return ["dev_box transaction: malformed YAML"]
68 failures: list[str] = []
69 for task in tasks if isinstance(tasks, list) else []:
70 if not isinstance(task, dict):
71 continue
72 command = task.get("ansible.builtin.command")
73 if isinstance(command, dict):
74 raw = command.get("cmd")
75 argv = command.get("argv")
76 if isinstance(raw, str) and re.search(r"(^|\s)bash\s", raw):
77 failures.append(str(task.get("name", "unnamed command")))
78 if (
79 isinstance(argv, list)
80 and any(
81 isinstance(item, str) and item.startswith("scripts/") and item.endswith(".sh")
82 for item in argv
83 )
84 and argv[:2] != ["/bin/bash", "-p"]
85 ):
86 failures.append(str(task.get("name", "unnamed command")))
87 guard = next(
88 (
89 task
90 for task in tasks
91 if isinstance(task, dict)
92 if task.get("name") == "Keep the system Bash startup guard safe under nounset"
93 ),
94 {},
95 )
96 replace = guard.get("ansible.builtin.replace") if isinstance(guard, dict) else None
97 if not isinstance(replace, dict) or replace.get("validate") != "/bin/bash -p -n %s":
98 failures.append("Bash startup guard validator")
99 return [f"dev_box transaction: unsafe Bash boundary: {name}" for name in failures]
100
101
102def _hil_just_errors(source: str) -> list[str]:
103 """Require every HIL recipe shell boundary to enter fixed privileged Bash."""
104 errors: list[str] = []
105 # PATH stays a fixed deterministic expression: the trusted host-tool
106 # helper builds it from a closed location list (never the caller PATH).
107 expected_environment = {
108 'export BASH_ENV := "/dev/null"',
109 'export ENV := "/dev/null"',
110 'export PYTHONHOME := ""',
111 'export PYTHONPATH := ""',
112 'export PYTHONNOUSERSITE := "1"',
113 'export RA8_TOOL_VENV := ""',
114 "export PATH := `/bin/bash -p scripts/ci/lib/host_tool_path.sh --print-path`",
115 }
116 if expected_environment - set(source.splitlines()):
117 errors.append("just/hil.just: public environment sanitizer is incomplete")
118 shebangs = [line.strip() for line in source.splitlines() if line.lstrip().startswith("#!")]
119 if any(line != "#!/bin/bash -p" for line in shebangs):
120 errors.append("just/hil.just: recipe shebang is not fixed privileged Bash")
121 executable = [line for line in source.splitlines() if not line.lstrip().startswith("#")]
122 if any(re.search(r"(?<!/)\bbash\b", line) for line in executable):
123 errors.append("just/hil.just: recipe invokes Bash through caller PATH")
124 return errors
125
126
127def dev_box_shell_boundary_errors(transaction_source: str, hil_just: str) -> list[str]:
128 """Return service, transaction-shell, and HIL-Just boundary findings."""
129 return (
130 _service_installer_errors(transaction_source)
131 + _dev_shell_command_errors(transaction_source)
132 + _hil_just_errors(hil_just)
133 )
134
135
136def _pin_authority_errors(transaction_source: str, dockerfile_source: str) -> list[str]:
137 """Require every role-consumed pin to exist in the Dockerfile authority."""
138 try:
139 tasks = yaml.safe_load(transaction_source)
140 except yaml.YAMLError:
141 return ["dev box pins: malformed transaction"]
142 if not isinstance(tasks, list):
143 return ["dev box pins: transaction is not a task list"]
144 matches = [
145 task
146 for task in tasks
147 if isinstance(task, dict)
148 and task.get("name") == "Assert every pin this role consumes is actually declared there"
149 ]
150 consumed = matches[0].get("loop") if len(matches) == 1 else None
151 if not isinstance(consumed, list) or any(not isinstance(pin, str) for pin in consumed):
152 return ["dev box pins: consumed-pin census is missing or malformed"]
153 declared = set(re.findall(r"(?m)^ARG ([A-Z0-9_]+)=", dockerfile_source))
154 missing = sorted(pin for pin in consumed if pin not in declared)
155 if missing:
156 return ["dev box pins: consumed names absent from Dockerfile: " + ", ".join(missing)]
157 return []
158
159
160def _shell_authority_errors(root_justfile: str) -> list[str]:
161 """Require Just to enter every recipe with fixed privileged Bash."""
162 definitions = [
163 line.strip() for line in root_justfile.splitlines() if line.startswith("set shell")
164 ]
165 expected = ['set shell := ["/bin/bash", "-puc"]']
166 return [] if definitions == expected else ["justfile: public shell authority is not exact"]
167
168
169def pin_and_shell_authority_errors(
170 transaction_source: str, dockerfile_source: str, root_justfile: str
171) -> list[str]:
172 """Return pin-declaration and public Just-shell authority findings."""
173 return _pin_authority_errors(transaction_source, dockerfile_source) + _shell_authority_errors(
174 root_justfile
175 )
176
177
178def startup_authority_selftest(prefix: tuple[str, ...]) -> list[str]:
179 """Prove presentation indentation does not change wrapper semantics."""
180 indented = tuple(f" {line}" for line in prefix)
181 return (
182 []
183 if [line.strip() for line in indented] == [line.strip() for line in prefix]
184 else ["indented canonical startup authority changed semantics"]
185 )
186
187
188def _tasks(source: str, label: str) -> tuple[list[dict[str, object]], list[str]]:
189 """Parse one role task list with attribution."""
190 try:
191 value = yaml.safe_load(source)
192 except yaml.YAMLError:
193 return [], [f"{label}: malformed YAML"]
194 if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
195 return [], [f"{label}: expected a task list"]
196 return cast(list[dict[str, object]], value), []
197
198
199def _normalized(value: object) -> str:
200 """Collapse presentation whitespace without weakening expression bytes."""
201 return " ".join(str(value).split())
202
203
204def _conditions(task: dict[str, object]) -> tuple[str, ...]:
205 """Return normalized Ansible assert conditions, or an empty tuple."""
206 assertion = task.get("ansible.builtin.assert")
207 values = assertion.get("that") if isinstance(assertion, dict) else None
208 if not isinstance(values, list) or any(not isinstance(value, str) for value in values):
209 return ()
210 return tuple(_normalized(value) for value in values)
211
212
213def _fact_value(task: object, key: str) -> str:
214 """Return one normalized set_fact value, empty on shape drift."""
215 fact = task.get("ansible.builtin.set_fact") if isinstance(task, dict) else None
216 value = fact.get(key) if isinstance(fact, dict) else None
217 return _normalized(value)
218
219
220def _named(tasks: list[dict[str, object]], name: str) -> dict[str, object]:
221 """Return one uniquely named top-level task."""
222 matches = [task for task in tasks if task.get("name") == name]
223 if len(matches) != 1:
224 message = f"task {name!r} is missing or duplicated"
225 raise RoleError(message)
226 return matches[0]
227
228
229def _include_guard_errors(
230 tasks: list[dict[str, object]],
231 name: str,
232 module: str,
233 expected: dict[str, object],
234 label: str,
235) -> list[str]:
236 """Require an always-selected guard and its authenticated fact assertion."""
237 if len(tasks) < ROLE_PREFIX_LENGTH:
238 return [f"{label}: authenticated role prefix is incomplete"]
239 include = tasks[0]
240 assertion = tasks[1]
241 fact = (
242 "hil_bench_transaction_authenticated | default(false) | bool"
243 if "whole-bench" in name
244 else "dev_box_hil_mutation_authenticated | default(false) | bool"
245 )
246 if (
247 include.get("name") != name
248 or include.get(module) != expected
249 or include.get("tags") != ["always"]
250 or _conditions(assertion) != (fact,)
251 ):
252 return [f"{label}: independently selected role bypasses authentication"]
253 return []
254
255
256def _health_errors(tasks: list[dict[str, object]], defaults: str) -> list[str]:
257 """Require the final health check to reuse the outer hold."""
258 name = "Health check -- the EK-RA8D2 must be reachable over J-Link (EIL==HIL ground truth)"
259 try:
260 health = _named(tasks, name)
261 except RoleError as exc:
262 return [f"hil_bench role: {exc}"]
263 shell = health.get("ansible.builtin.shell")
264 command = shell.get("cmd") if isinstance(shell, dict) else None
265 expected = (
266 "device=$(/bin/bash -p {{ (hil_bench_repo_dir ~ "
267 "'/scripts/hil/lib/rig_contract.sh') | quote }} --default JLINK_DEVICE) && "
268 r"printf 'si 1\nspeed {{ hil_bench_jlink_speed }}\nr\nh\nq\n' "
269 '> /tmp/hil_bench_ping.jlink && JLinkExe -device "$device" -if SWD '
270 "-speed {{ hil_bench_jlink_speed }} "
271 "-autoconnect 1 -CommandFile /tmp/hil_bench_ping.jlink"
272 )
273 errors = []
274 if (
275 _normalized(command) != expected
276 or health.get("register") != "hil_bench_jlink_probe"
277 or health.get("changed_when") is not False
278 or health.get("failed_when") != "'Cortex-M85' not in hil_bench_jlink_probe.stdout"
279 ):
280 errors.append("hil_bench role: final health check can deadlock or escaped the outer hold")
281 document = yaml.safe_load(defaults)
282 if not isinstance(document, dict) or document.get("hil_bench_maintenance_lock_id") != "":
283 errors.append("hil_bench defaults: direct apply does not fail closed")
284 if isinstance(document, dict) and "hil_bench_jlink_device" in document:
285 errors.append("hil_bench defaults: duplicates the rig-contract J-Link device")
286 return errors
287
288
289def _binding_errors(tasks: list[dict[str, object]]) -> list[str]:
290 """Require controller-payload and kernel-lock authentication at the prefix."""
291 errors = []
292 hold_expected = _normalized(
293 "ansible_check_mode or hil_bench_maintenance_lock_id is match('^[0-9a-f]{16}$')"
294 )
295 if _conditions(tasks[0]) != (hold_expected,):
296 errors.append("hil_bench role: maintenance hold assertion is not exact")
297 binding = tasks[1].get("block")
298 if tasks[1].get("when") != "not ansible_check_mode" or not isinstance(binding, list):
299 return [*errors, "hil_bench role: live holder binding block is not exact"]
300 expected_names = (
301 "Authenticate the controller and immutable fleet bench payload",
302 "Authenticate the canonical kernel-held bench lock",
303 )
304 if tuple(task.get("name") for task in binding if isinstance(task, dict)) != expected_names:
305 return [*errors, "hil_bench role: live capability task sequence is not exact"]
306 by_name = {task.get("name"): task for task in binding if isinstance(task, dict)}
307 return [
308 *errors,
309 *_local_binding_errors(by_name[expected_names[0]]),
310 *_remote_binding_errors(by_name[expected_names[1]]),
311 ]
312
313
314def _local_binding_errors(local: object) -> list[str]:
315 """Require the exact controller transaction and fleet payload key set."""
316 local_command = local.get("ansible.builtin.command") if isinstance(local, dict) else None
317 local_argv = local_command.get("argv") if isinstance(local_command, dict) else None
318 if (
319 not isinstance(local, dict)
320 or local.get("delegate_to") != "localhost"
321 or local.get("become") is not False
322 or local.get("changed_when") is not False
323 or not isinstance(local_argv, list)
324 or len(local_argv) != LOCAL_AUTH_ARGC
325 or local_argv[:5]
326 != [
327 "{{ playbook_dir }}/../../../.venv/bin/python3",
328 "-I",
329 "{{ playbook_dir }}/../../../scripts/dev/fleet_transaction_auth.py",
330 "{{ inventory_hostname }}",
331 "hil_bench",
332 ]
333 ):
334 return ["hil_bench role: fleet transaction command is not exact"]
335 payload = (
336 str(local_argv[5])
337 if isinstance(local_argv, list) and len(local_argv) == LOCAL_AUTH_ARGC
338 else ""
339 )
340 keys = re.findall(r"'(hil_bench_[a-z0-9_]+)'\s*:", payload)
341 expected_tail = [
342 "{{ hil_bench_maintenance_lock_id }}",
343 "{{ hil_bench_maintenance_holder_pid | string }}",
344 "{{ hil_bench_maintenance_holder_start_ticks | string }}",
345 "{{ hil_bench_maintenance_holder_target }}",
346 ]
347 if (
348 len(keys) != FLEET_PAYLOAD_KEYS
349 or len(set(keys)) != FLEET_PAYLOAD_KEYS
350 or not isinstance(local_argv, list)
351 or local_argv[6:] != expected_tail
352 ):
353 return ["hil_bench role: immutable fleet payload key set is not exact"]
354 return []
355
356
357def _remote_binding_errors(remote: object) -> list[str]:
358 """Require the exact by-value verifier and both reviewed digests."""
359 remote_command = remote.get("ansible.builtin.command") if isinstance(remote, dict) else None
360 remote_argv = remote_command.get("argv") if isinstance(remote_command, dict) else None
361 if (
362 not isinstance(remote, dict)
363 or remote.get("delegate_to") is not None
364 or remote.get("changed_when") is not False
365 or not isinstance(remote_argv, list)
366 or len(remote_argv) != REMOTE_VERIFY_ARGC
367 or remote_argv[:4] != ["/usr/bin/python3", "-I", "-S", "-c"]
368 or remote_argv[5:7] != ["{{ hil_bench_maintenance_lock_id }}", "wrapped"]
369 ):
370 return ["hil_bench role: kernel lock verifier command is not exact"]
371 if isinstance(remote_argv, list) and len(remote_argv) == REMOTE_VERIFY_ARGC:
372 source = _normalized(remote_argv[4])
373 digest = _normalized(remote_argv[7])
374 broker_digest = _normalized(remote_argv[8])
375 expected_source = _normalized(
376 "{{ lookup('ansible.builtin.file', "
377 "playbook_dir ~ '/../../../scripts/hil/lib/bench_lock_verify.py', rstrip=false) }}"
378 )
379 expected_digest = _normalized(
380 "{{ lookup('ansible.builtin.file', "
381 "playbook_dir ~ '/../../../scripts/hil/lib/bench_host.sh', rstrip=false) "
382 "| hash('sha256') }}"
383 )
384 expected_broker_digest = _normalized(
385 "{{ lookup('ansible.builtin.file', "
386 "playbook_dir ~ '/../../../scripts/hil/lib/bench_lock_broker.py', rstrip=false) "
387 "| hash('sha256') }}"
388 )
389 if (
390 source != expected_source
391 or digest != expected_digest
392 or broker_digest != expected_broker_digest
393 ):
394 return ["hil_bench role: reviewed verifier/holder bytes are not exact"]
395 return []
396
397
398def _role_prefix_errors(source: str, label: str, role: str) -> list[str]:
399 """Require each independently includable bench role to authenticate first."""
400 tasks, errors = _tasks(source, label)
401 if errors:
402 return errors
403 if role == "hil_bench":
404 return _include_guard_errors(
405 tasks,
406 "Authenticate the whole-bench transaction before this role",
407 "ansible.builtin.include_tasks",
408 {"file": "transaction_guard.yml", "apply": {"tags": ["always"]}},
409 label,
410 )
411 role_label = "C6" if role == "c6_toolchain" else "AD2"
412 return _include_guard_errors(
413 tasks,
414 f"Authenticate the whole-bench transaction before the {role_label} role",
415 "ansible.builtin.include_role",
416 {
417 "name": "hil_bench",
418 "tasks_from": "transaction_guard.yml",
419 "apply": {"tags": ["always"]},
420 },
421 label,
422 )
423
424
425def _entry_errors(source: str, label: str, name: str, file: str) -> list[str]:
426 """Require a single dynamic, always-selected transaction entry point."""
427 tasks, errors = _tasks(source, label)
428 expected = {"file": file}
429 if errors:
430 return errors
431 if (
432 len(tasks) != 1
433 or tasks[0].get("name") != name
434 or tasks[0].get("ansible.builtin.include_tasks") != expected
435 or tasks[0].get("tags") != ["always"]
436 ):
437 return [f"{label}: transaction entry can expose internal mutators to selectors"]
438 return []
439
440
441def _entry_point_errors(inputs: dict[str, str]) -> list[str]:
442 """Validate each public dynamic transaction entry."""
443 entries = (
444 (
445 "dev_main_entry",
446 "dev_box/tasks/main.yml",
447 "Enter the authenticated dev-box transaction",
448 "transaction.yml",
449 ),
450 (
451 "dev_entry",
452 "dev_box/tasks/hil_runner.yml",
453 "Enter the authenticated HIL-listener transaction",
454 "hil_runner_transaction.yml",
455 ),
456 (
457 "bench_entry",
458 "hil_bench/tasks/main.yml",
459 "Enter the authenticated bench transaction",
460 "transaction.yml",
461 ),
462 (
463 "c6_entry",
464 "c6_toolchain/tasks/main.yml",
465 "Enter the authenticated C6 transaction",
466 "transaction.yml",
467 ),
468 (
469 "ad2_entry",
470 "ad2_tools/tasks/main.yml",
471 "Enter the authenticated AD2 transaction",
472 "transaction.yml",
473 ),
474 )
475 findings: list[str] = []
476 for key, label, name, file in entries:
477 findings.extend(_entry_errors(inputs[key], label, name, file))
478 return findings
479
480
481def errors(inputs: dict[str, str]) -> list[str]:
482 """Require live-holder binding before task one and no nested health lock."""
483 tasks, findings = _tasks(inputs["bench_role"], "hil_bench/tasks/main.yml")
484 if findings:
485 return findings
486 guard_tasks, guard_errors = _tasks(
487 inputs["bench_guard"], "hil_bench/tasks/transaction_guard.yml"
488 )
489 if guard_errors:
490 return guard_errors
491 if len(guard_tasks) < ROLE_PREFIX_LENGTH:
492 return ["hil_bench role: live holder prefix is incomplete"]
493 expected = [
494 "Require the fleet-owned maintenance transaction for a mutating converge",
495 "Bind this apply to the exact live wrapped bench holder",
496 ]
497 if [task.get("name") for task in guard_tasks[:2]] != expected:
498 findings.append("hil_bench role: lock assertion/binding is not before every mutator")
499 findings.extend(_binding_errors(guard_tasks))
500 findings.extend(_health_errors(tasks, inputs["bench_defaults"]))
501 findings.extend(
502 _role_prefix_errors(inputs["bench_role"], "hil_bench/tasks/main.yml", "hil_bench")
503 )
504 findings.extend(
505 _role_prefix_errors(inputs["c6_role"], "c6_toolchain/tasks/main.yml", "c6_toolchain")
506 )
507 findings.extend(
508 _role_prefix_errors(inputs["ad2_role"], "ad2_tools/tasks/main.yml", "ad2_tools")
509 )
510 return [*findings, *_entry_point_errors(inputs)]