4"""Gate: no first-party file may introduce a permanent anti-recovery brick.
6Owner policy (2026-07-23): this is a personal, open-source project. It must
7NEVER contain code that permanently disables device recovery on the RA8D2.
8Nothing sets any of this today -- the point of this gate is to keep it that
9way forever, by failing CI the moment a change would.
11What is FORBIDDEN (a *violation* -- the gate fails)
12---------------------------------------------------
13Only *actions* that move an RA8D2 into a state its own recovery scripts cannot
14undo. Three shapes, all as executed commands / programmatic writes, never as
17 * Setting the ``ce`` Security Flag ("Disable Initialize Command") -- via
18 ``rfp-cli`` (``-set-security-flag ce``), an option-byte / OSIS / security
19 register write, or any ``set ce`` / ``ce = true`` / ``--disable-initialize``
20 programmatic path. That flag permanently disables the boot-firmware
21 Initialize (erase-chip) recovery that ``scripts/hil/dlm_reset.sh`` relies on.
22 * Transitioning the DLM to a terminal/unrecoverable lock -- ``LCK_BOOT`` (or
23 any lock that renders the SWD-DP permanently unresponsive), e.g.
24 ``rfp-cli -dlm LCK_BOOT`` or a programmatic ``dlm_program(k_dlm_lck_boot)``.
25 * Any ``rfp-cli -dlm <state>`` / security-flag write that moves the device to
26 a state the recovery scripts (``scripts/hil/dlm_reset.sh`` /
27 ``recover.sh``) cannot undo.
29What is ALLOWED (must NOT be flagged -- these are pro-recovery / defensive)
30--------------------------------------------------------------------------
31 * The recovery scripts themselves (``scripts/hil/dlm_reset.sh``,
32 ``dlm_reset_local.sh``, ``recover.sh``, ``reflash.sh``). They READ / CHECK
33 that ``ce`` is unset and WARN if recovery failed -- the exact opposite of
34 the forbidden action. They are excluded by path AND the patterns below do
35 not fire on their content (the ``--selftest`` proves both).
36 * Reading / checking DLM state (``rfp-cli -rfo``), and *recoverable* DLM
37 transitions (``OEM_PL0`` / ``OEM_PL1`` debug-lock, which the Initialize
38 command or an authenticated regression can undo).
39 * Any comment, warning string, or negated phrasing that merely *discusses*
40 the danger: "verify ``ce`` is unset", "``ce`` may be set", "must NOT have
41 been set", "do NOT run ``rfp-cli -dlm LCK_BOOT``".
43How the ACTION / not-an-action distinction is drawn
44---------------------------------------------------
45Comments are blanked before matching, so a comment that shows the forbidden
46command as a warning cannot trip the gate. The patterns split into two tiers:
48 * HARD -- unambiguous executed commands / writes (``rfp-cli -dlm LCK_BOOT``,
49 ``set ce``, ``ce = true``, ``-set-security-flag ce``, ``--disable-initialize``).
50 These fire on any non-comment occurrence; a comment nearby does not excuse
52 * SOFT -- occurrences of a terminal-state NAME near a set-verb (spaced
53 "disable initialize", "... regress ... LCK_BOOT"). A NAME is a violation
54 only when nothing on the same code line marks it as a check / negation
55 (``DEFENSIVE_RE``). The defensive cues are word-boundaried, so a snake_case
56 or camelCase identifier that merely contains "never" / "verify" does NOT
57 earn the exemption -- a function that really does the transition is still a
58 violation regardless of what it is named.
60Documentation files (``.md`` / ``.txt``) are prose, not executed, so every
61match in them is treated as SOFT: a doc that WARNS against the brick stays
62clean, while a doc that advocates running it does not.
66 check_no_antirecovery.py # scan the whole tracked tree
67 check_no_antirecovery.py FILE ... # scan listed files
68 check_no_antirecovery.py --selftest # prove the detector fires on brick
69 # actions and stays silent on the real
70 # recovery scripts + defensive checks
72Exit 0 when no anti-recovery action is found, 1 otherwise (or on a failing
73selftest, or on an empty/unreadable scan set).
76from __future__
import annotations
83from pathlib
import Path
85REPO_ROOT = Path(__file__).resolve().parents[2]
89EXCLUDED_PREFIXES: tuple[str, ...] = (
91 "apps/shared_libs/third_party/",
99RECOVERY_SCRIPTS: frozenset[str] = frozenset(
101 "scripts/hil/dlm_reset.sh",
102 "scripts/hil/dlm_reset_local.sh",
103 "scripts/hil/recover.sh",
104 "scripts/hil/reflash.sh",
111SELF_PATH =
"scripts/checks/check_no_antirecovery.py"
113EXCLUDED_FILES: frozenset[str] = RECOVERY_SCRIPTS | {SELF_PATH}
118HASH_EXTS: frozenset[str] = frozenset(
134C_EXTS: frozenset[str] = frozenset({
".c",
".h",
".cpp",
".hpp",
".cc",
".hh",
".cxx",
".ld"})
135XML_EXTS: frozenset[str] = frozenset({
".xml",
".html",
".svg",
".rpj"})
136PROSE_EXTS: frozenset[str] = frozenset({
".md",
".markdown",
".txt",
".rst"})
137NONE_EXTS: frozenset[str] = frozenset({
".json"})
139HASH_BASENAMES: frozenset[str] = frozenset({
"justfile",
"Justfile",
"CMakeLists.txt",
"Dockerfile"})
143HASH_STEM_HINTS: frozenset[str] = frozenset({
"pre-commit",
"pre-push"})
146_EXT_LANG: dict[str, str] = {
147 **dict.fromkeys(HASH_EXTS,
"hash"),
148 **dict.fromkeys(C_EXTS,
"c"),
149 **dict.fromkeys(XML_EXTS,
"xml"),
150 **dict.fromkeys(PROSE_EXTS,
"prose"),
151 **dict.fromkeys(NONE_EXTS,
"none"),
164_TERMINAL =
r"lck[_-]?boot"
169_ASSIGN =
r"(?<![=!<>])=(?!=)"
172_TRUE =
r"(?:1\b|true\b|on\b|yes\b|set\b|enabled?\b)"
178_SET_VERB =
r"set|enable|program|transition|move|write|lock|enter|switch|regress|activate|go\s*to"
179_VERB = rf
"(?<![A-Za-z])(?:{_SET_VERB})(?![A-Za-z])"
181RULES: list[tuple[str, re.Pattern[str], bool]] = [
183 (
"ce-set", re.compile(
r"\bset[_ -]ce\b", re.IGNORECASE),
True),
184 (
"ce-set", re.compile(rf
"\bce\b\s*(?:{_ASSIGN}|:)\s*{_TRUE}", re.IGNORECASE),
True),
188 r"(?:set[_ -]security[_ -]flags?|--?security[_ -]flags?[= ])[^\n]{0,24}\bce\b",
193 (
"ce-set", re.compile(
r"--disable[_-]initialize\b", re.IGNORECASE),
True),
196 re.compile(rf
"\bdisable[_-]initialize\b\s*(?:{_ASSIGN}|:)\s*{_TRUE}", re.IGNORECASE),
200 (
"dlm-terminal", re.compile(rf
"--?dlm[= ]+['\"]?\s*{_TERMINAL}\b", re.IGNORECASE),
True),
204 re.compile(rf
"{_VERB}[^\n]{{0,24}}\bdisable[ _-]?initialize\b", re.IGNORECASE),
209 re.compile(rf
"{_VERB}[^\n]{{0,24}}{_TERMINAL}\b", re.IGNORECASE),
215 re.compile(rf
"{_ASSIGN}\s*[^\n;{{}}]{{0,24}}?{_TERMINAL}\b", re.IGNORECASE),
226DEFENSIVE_RE = re.compile(
228 r"\b(?:unset|must\s+not|must\s+never|may\s+be\s+set|may\s+have\s+been\s+set|"
229 r"not\s+have\s+been\s+set|not\s+be\s+set|do\s+not|don't|never|should\s+not|"
230 r"shall\s+not|verify|verifies|ensure|is\s+set|was\s+set|been\s+set)\b",
234MAX_FINDINGS_SHOWN = 50
238def lang_of(rel: str) -> str:
239 """Classify a tracked path's comment style / prose-ness for scanning.
241 Returns one of "hash", "c", "xml", "prose", "none", or "skip" (binary /
242 out-of-scope). Extension wins; a few known extensionless basenames (the git
243 hooks) map to "hash".
246 lang = _EXT_LANG.get(p.suffix.lower())
249 if p.name
in HASH_BASENAMES:
251 if p.suffix ==
"" and p.name
in HASH_STEM_HINTS:
256def _blank_hash_line(line: str) -> str:
257 """Blank a shell/python/yaml ``#`` comment, respecting quotes.
259 A ``#`` starts a comment only outside quotes and at a word boundary (line
260 start after whitespace, or preceded by whitespace) -- so ``${x#y}`` and
261 ``http://a#b`` are left intact while ``foo # note`` is trimmed.
280 elif c ==
"#" and (i == 0
or line[i - 1].isspace()):
281 return line[:i] +
" " * (n - i)
286def _blank_c_line(line: str) -> str:
287 """Blank a C ``//`` line comment, respecting quotes."""
308 elif c ==
"/" and i + 1 < n
and line[i + 1] ==
"/":
309 return line[:i] +
" " * (n - i)
314def _blank_blocks(text: str, open_tok: str, close_tok: str) -> str:
315 """Blank ``open_tok ... close_tok`` spans, preserving newlines and offsets."""
319 start = text.find(open_tok, i)
323 out.append(text[i:start])
324 end = text.find(close_tok, start + len(open_tok))
325 end = n
if end < 0
else end + len(close_tok)
326 out.append(re.sub(
r"[^\n]",
" ", text[start:end]))
331def blank_comments(text: str, lang: str) -> str:
332 """Return `text` with comments blanked (strings preserved), for `lang`.
334 Strings are deliberately kept so a quoted CLI argument like
335 ``-dlm "LCK_BOOT"`` is still seen. Prose files are returned unchanged --
336 every match in them is treated as SOFT by the caller.
339 text = _blank_blocks(text,
"/*",
"*/")
340 return "\n".join(_blank_c_line(ln)
for ln
in text.split(
"\n"))
342 return "\n".join(_blank_hash_line(ln)
for ln
in text.split(
"\n"))
344 return _blank_blocks(text,
"<!--",
"-->")
348def scan_text(raw: str, rel: str, lang: str) -> list[dict]:
349 """Find forbidden anti-recovery ACTIONS in one file's text.
351 `lang == "prose"` forces every rule to be treated as SOFT (documentation is
352 described, not executed), so a doc that warns against the brick stays clean.
354 code = blank_comments(raw, lang)
355 prose = lang ==
"prose"
356 found: list[dict] = []
357 for lineno, line
in enumerate(code.split(
"\n"), start=1):
358 defended = DEFENSIVE_RE.search(line)
is not None
359 for rule, pattern, hard
in RULES:
360 m = pattern.search(line)
363 if (
not hard
or prose)
and defended:
370 "match": m.group(0).strip(),
371 "text": line.strip(),
378def tracked_files(explicit: list[str]) -> list[Path]:
379 """Enumerate the tracked first-party files in scope.
381 Tracked via ``git ls-files``, not globbed: a glob also sweeps build output
382 and changes the verdict depending on whether the caller has built. Excluded
383 prefixes, the recovery scripts, and this checker are dropped here.
386 return [Path(p)
for p
in explicit]
388 listed = subprocess.run(
389 [
"git",
"ls-files",
"-z"],
395 except (OSError, subprocess.CalledProcessError)
as exc:
397 f
"check_no_antirecovery.py: FATAL -- cannot list tracked files: {exc}\n"
398 " This gate enumerates via git and must not fall back to a glob."
401 for name
in listed.split(
"\0"):
404 if name
in EXCLUDED_FILES:
406 if any(name.startswith(pfx)
for pfx
in EXCLUDED_PREFIXES):
408 out.append(REPO_ROOT / name)
412def analyse(files: list[Path]) -> list[dict]:
413 """Return every forbidden-action finding across the given files."""
414 findings: list[dict] = []
416 rel = str(path.relative_to(REPO_ROOT))
if path.is_absolute()
else str(path)
421 raw = path.read_text(encoding=
"utf-8")
422 except (OSError, UnicodeDecodeError):
424 findings.extend(scan_text(raw, rel, lang))
431SELFTEST_CASES: list[tuple[str, str, bool]] = [
434 "rfp-cli -d ra -t jlink:$SN -if swd -s 1000000 -dlm LCK_BOOT\n",
439 "# the ce flag disables Initialize -- do the deed below\nset ce\n",
444 'rfp-cli -d ra -t "jlink:$SN" -if swd -set-security-flag ce\n',
449 "disable_initialize = true\n",
454 "rfp-cli -d ra --disable-initialize\n",
459 " dlm_program(k_dlm_lck_boot);\n",
464 "ra8_security.ce = true;\n",
469 'rfp-cli -d ra -t jlink -dlm "LCK_BOOT"\n',
470 "brick_dlm_quoted.sh",
474 "static void never_regress_to_lck_boot(void) { program_dlm(k_dlm_lck_boot); }\n",
480 "# verify the ce security flag is unset before any OEM_PLx transition\n"
481 'warn "ce flag may be set -- recovery would then fail"\n'
482 "# the ce flag must NOT have been set\n"
483 "# do NOT run: rfp-cli -dlm LCK_BOOT (that permanently bricks the board)\n"
484 'echo "policy: never regress the DLM to LCK_BOOT on this project"\n'
485 'if ! rfp-cli -rfo | grep -q "DLM State: OEM_PL2"; then echo bad; fi\n',
486 "defensive_check.sh",
490 "rfp-cli -d ra -t jlink -dlm OEM_PL1\n",
491 "recoverable_dlm.sh",
495 "This project must NEVER run `rfp-cli -dlm LCK_BOOT`; recovery relies on\n"
496 "the ce flag staying unset so Initialize can regress OEM_PL0 -> OEM_PL2.\n",
501 'ok "Boundary cleared; DLM regressed OEM_PL0 -> OEM_PL2"\n',
508def selftest(tmp: Path) -> int:
509 """Assert the detector fires on brick actions and stays silent otherwise.
511 Runs BOTH directions -- synthetic brick files must be detected, and the
512 REAL recovery scripts plus a defensive-check fixture must produce nothing.
513 The recovery scripts are scanned by CONTENT here (bypassing the path
514 exclusion) so the assertion proves the PATTERNS keep them silent, not
515 merely that they are excluded.
517 failures: list[str] = []
519 for idx, (body, fname, should_fire)
in enumerate(SELFTEST_CASES):
520 p = tmp / f
"{idx:02d}_{fname}"
521 p.write_text(body, encoding=
"utf-8")
522 fired = bool(scan_text(body, fname, lang_of(fname)))
523 if fired != should_fire:
524 verb =
"did not fire" if should_fire
else "fired"
525 failures.append(f
" synthetic [{fname}]: gate {verb} (unexpected)")
528 for rel
in sorted(RECOVERY_SCRIPTS):
531 failures.append(f
" recovery script missing: {rel}")
533 hits = scan_text(f.read_text(encoding=
"utf-8"), rel, lang_of(rel))
534 failures.extend(f
" recovery script {rel}:{h['line']} matched {h['match']!r}" for h
in hits)
537 print(
"check_no_antirecovery.py --selftest: FAILED\n", file=sys.stderr)
538 print(
"\n".join(failures), file=sys.stderr)
541 fires = sum(1
for c
in SELFTEST_CASES
if c[2])
542 total = len(SELFTEST_CASES)
544 f
"check_no_antirecovery.py --selftest: PASS "
545 f
"({total} synthetic cases: {fires} must fire, {total - fires} must stay silent; "
546 f
"{len(RECOVERY_SCRIPTS)} real recovery scripts silent by pattern)"
551def _report(findings: list[dict]) ->
None:
552 """List every forbidden action found, then explain the policy."""
554 f
"check_no_antirecovery.py: {len(findings)} anti-recovery action(s) found:\n",
557 for f
in sorted(findings, key=
lambda v: (v[
"path"], v[
"line"]))[:MAX_FINDINGS_SHOWN]:
559 if len(snippet) > SNIPPET_MAX_LEN:
560 snippet = snippet[: SNIPPET_MAX_LEN - 3] +
"..."
561 print(f
" [{f['rule']}] {f['path']}:{f['line']} {snippet}", file=sys.stderr)
562 if len(findings) > MAX_FINDINGS_SHOWN:
563 print(f
" ... {len(findings) - MAX_FINDINGS_SHOWN} more (truncated)", file=sys.stderr)
565 "\nOwner policy (2026-07-23): this project must NEVER permanently disable\n"
566 "device recovery. Forbidden actions:\n"
567 " [ce-set] setting the ce Security Flag / Disable Initialize Command\n"
568 " (rfp-cli -set-security-flag ce, set ce, ce = true,\n"
569 " --disable-initialize, an OSIS / option-byte write).\n"
570 " [dlm-terminal] transitioning the DLM to LCK_BOOT (or any lock that\n"
571 " renders the SWD-DP permanently unresponsive).\n"
572 "\nRecoverable OEM_PL0/PL1 debug-lock -- which Initialize or an\n"
573 "authenticated regression can undo -- is allowed, as is reading/checking\n"
574 "state (rfp-cli -rfo) and warning about the danger. If you are writing a\n"
575 "recovery/check path, phrase it as a check (it is then not an action), or\n"
576 "put it in scripts/hil/{dlm_reset,dlm_reset_local,recover,reflash}.sh.",
581def main(argv: list[str]) -> int:
582 """Scan first-party files for anti-recovery brick actions, or run the selftest.
584 CI runs ``--selftest`` before the scan: a detector whose patterns stopped
585 matching would report a clean tree, and only an assertion in both directions
586 tells that apart from a genuinely clean tree.
588 Returns 0 when no forbidden action is found, 1 on a finding, on an empty
589 scan set, or on a failing selftest.
591 ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
592 ap.add_argument(
"files", nargs=
"*", help=
"specific files to scan")
596 help=
"prove the detector fires on brick actions and not on recovery/defensive code",
598 args = ap.parse_args(argv[1:])
601 with tempfile.TemporaryDirectory()
as td:
602 return selftest(Path(td))
604 files = tracked_files(args.files)
607 "check_no_antirecovery.py: FATAL -- no tracked files in scope.\n"
608 " Run this from the repository root.",
613 findings = analyse(files)
616 f
"check_no_antirecovery.py: OK ({len(files)} files scanned, no anti-recovery actions)"
624if __name__ ==
"__main__":
625 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.