Skip to content

Commit 4ff100f

Browse files
Derive the PROTECTED CORE roster from the policy doc; gate the decrypt tool (#587)
* Derive the PROTECTED CORE roster from the policy doc; gate the decrypt tool docs/upstreaming/license-provenance.md §2 declared `decrypt-saved-deck-export/decrypt.mjs` and its test part of the vote/federation trust anchor, and instructed that both be added to check_protected_core_license.py's PROTECTED_CORE_FILES "in the PR that merges #242 (or immediately after)". #242 merged (5ddf109), both files landed on master, and the CI list was never updated. Two files the policy calls a trust anchor have carried no gate at all since then. The one-line fix is to add them to the list. That is not what this does, because the defect is not the missing entry — it is that two hand-maintained lists were kept in sync by a convention written in prose. check_protected_core_license.py now holds NO list: it parses the marker-bounded roster region in §2 and derives its file set from the doc, which §2 already declared the source of truth. They cannot disagree because there is only one list. Adding the .mjs entries also exposed that neither existing rule could have fired on them: - the provenance-marker regex required a `#` comment leader, so a `// PROVENANCE: ..., AGPL-3.0` line in a JS file was invisible. It now accepts `#`, `//` and `*`. - the import walk was ast.parse()-only, which raises SyntaxError on .mjs and returned []. ES-module `import`/`export ... from` / dynamic `import()` / `require()` specifiers are now extracted, with only relative specifiers resolving (a bare specifier is a node: builtin or an npm package — out of scope for the same reason the Python side does not scan PyPI metadata). A roster check's characteristic failure is to check nothing and pass, so a missing/out-of-order marker, an empty region, a listed path that does not exist, and a roster entry in an unwalkable language are each HARD findings rather than silent skips. §2 also spells out the two "(+ its test)" prose parentheticals as explicit paths, since only backticked spans are machine-read, and replaces the stale "not yet in the CI script" bullet with a dated record of the gap it described. Roster derived: 11 files (was 9 hardcoded). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN * docs-lint: trigger the protected-core job on decrypt-tool changes The roster is derived from license-provenance.md (covered by `docs/**`), but the FILES it gates were not all trigger paths. `decrypt-saved-deck-export/**` had none: a PR pasting AGPL-marked code into the decrypt tool touches no doc and no cardpicker module, so the protected-core lint would not have run on it until the weekly cron. `federation-hash-tool/**` was already listed; this is its counterpart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent cb926c0 commit 4ff100f

4 files changed

Lines changed: 529 additions & 79 deletions

File tree

.github/scripts/check_protected_core_license.py

Lines changed: 193 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,50 @@
22
"""
33
PROTECTED CORE license lint, per docs/upstreaming/license-provenance.md §2.
44
5-
Fails if any PROTECTED_CORE_FILES entry (a) carries an AGPL provenance
6-
marker on itself, or (b) locally imports another in-repo module that
7-
does. The real invariant this enforces is "no AGPL-derived code in
8-
protected core" — NOT "everything here must be GPL-3.0": one entry
9-
(federation-hash-tool/hash_my_cards.py) is deliberately MIT-licensed
10-
(docs/federation/public-export-v1.md §5), and AGPL would poison either
11-
license, not just GPL-3.0.
12-
13-
Provenance marker convention (docs/upstreaming/license-provenance.md
14-
§3's absorption protocol): a `# PROVENANCE: <repo>, <commit/tag>,
15-
<license>` comment near the top of a vendored file. This lint only looks
16-
for the substring "AGPL" in that line - it does not attempt to scan
17-
transitive PyPI/npm dependency license metadata (a separate, much larger
18-
problem; tools like `pip-licenses` exist for that). Nothing in this repo
19-
is AGPL-marked as of this writing - this lint passes with zero findings
20-
today, correctly, and exists to catch the day that stops being true.
5+
Fails if any protected-core file (a) carries an AGPL provenance marker on
6+
itself, (b) locally imports another in-repo module that does, or (c) is
7+
listed in the policy but does not exist on disk.
8+
9+
The real invariant this enforces is "no AGPL-derived code in protected
10+
core" — NOT "everything here must be GPL-3.0": two entries
11+
(federation-hash-tool/hash_my_cards.py and decrypt-saved-deck-export/
12+
decrypt.mjs, plus their tests) are deliberately MIT-licensed
13+
(docs/federation/public-export-v1.md §5; PR #242), and AGPL would poison
14+
either license, not just GPL-3.0.
15+
16+
THE ROSTER IS DERIVED FROM THE POLICY DOC, NOT RESTATED HERE
17+
------------------------------------------------------------
18+
This script holds no file list. It parses the marker-bounded region in
19+
docs/upstreaming/license-provenance.md §2 and treats every backtick-quoted
20+
repo path inside it as a protected-core file.
21+
22+
That is a deliberate correction of a real, dated failure. §2 declared
23+
`decrypt-saved-deck-export/decrypt.mjs` and its test part of the trust
24+
anchor, and instructed that they be added to this script's hand-maintained
25+
`PROTECTED_CORE_FILES` "in the PR that merges #242 (or immediately
26+
after)". #242 merged (`5ddf109c`), both files landed on master, and the
27+
list was never updated — so two files the policy calls a trust anchor
28+
carried no gate at all until 2026-07-29. Two hand-maintained lists kept in
29+
sync by a prose convention is the defect; adding a third entry to the
30+
second list would only have deferred it. With the roster derived, the doc
31+
and the check cannot disagree, because there is only one list.
32+
33+
If the marker region is missing or yields no paths, that is a HARD
34+
FINDING, not a quiet pass — a roster check that silently checks nothing is
35+
worse than no check.
36+
37+
PROVENANCE MARKER CONVENTION (docs/upstreaming/license-provenance.md §3's
38+
absorption protocol): a `PROVENANCE: <repo>, <commit/tag>, <license>`
39+
comment near the top of a vendored file. The comment leader may be `#`
40+
(Python/shell), `//` (JS) or `*` (inside a JS block comment) — the roster
41+
spans two languages, and a regex that only recognised `#` would have let a
42+
`// PROVENANCE: ..., AGPL-3.0` line in a `.mjs` roster file pass unseen.
43+
This lint only looks for the substring "AGPL" in that line; it does not
44+
attempt to scan transitive PyPI/npm dependency license metadata (a
45+
separate, much larger problem; tools like `pip-licenses` exist for that).
46+
Nothing in this repo is AGPL-marked as of this writing - this lint passes
47+
with zero findings today, correctly, and exists to catch the day that
48+
stops being true.
2149
2250
Exit code is the number of findings (0 = clean), matching docs_lint.py's
2351
own convention.
@@ -29,19 +57,21 @@
2957

3058
REPO_ROOT = Path(__file__).resolve().parents[2]
3159

32-
# The exact PROTECTED CORE file list - docs/upstreaming/license-provenance.md
33-
# §2 is the source of truth; keep these in sync in the same PR.
34-
PROTECTED_CORE_FILES = [
35-
"MPCAutofill/cardpicker/vote_consensus.py",
36-
"MPCAutofill/cardpicker/printing_consensus.py",
37-
"MPCAutofill/cardpicker/tag_consensus.py",
38-
"MPCAutofill/cardpicker/artist_consensus.py",
39-
"MPCAutofill/cardpicker/local_phash.py",
40-
"MPCAutofill/cardpicker/local_fallback.py",
41-
"federation-hash-tool/hash_my_cards.py",
42-
"federation-hash-tool/tests/test_hash_my_cards.py",
43-
"MPCAutofill/cardpicker/tests/test_federation_hash_tool_parity.py",
44-
]
60+
# docs/upstreaming/license-provenance.md §2 is the source of truth for the
61+
# protected-core roster, and this script READS it (see the module docstring)
62+
# rather than restating it. These three constants are the entire contract
63+
# with the doc: the markers bound the region, and every backtick-quoted
64+
# path inside it is a roster entry.
65+
POLICY_DOC_REL = "docs/upstreaming/license-provenance.md"
66+
ROSTER_BEGIN_MARKER = "<!-- PROTECTED-CORE-ROSTER:BEGIN -->"
67+
ROSTER_END_MARKER = "<!-- PROTECTED-CORE-ROSTER:END -->"
68+
69+
# A backtick span is a roster path if it contains "/" and ends in one of
70+
# these. Prose inside the region routinely backticks non-paths (constant
71+
# names, PR refs); requiring both a separator and a known source extension
72+
# keeps those out without needing a second allowlist.
73+
ROSTER_PATH_EXTENSIONS = (".py", ".mjs", ".cjs", ".js", ".ts", ".tsx")
74+
ROSTER_PATH_RE = re.compile(r"`([\w./\-]+/[\w./\-]+)`")
4575

4676
# Local import roots: a dotted import prefix maps to a directory that acts
4777
# as its own package root, mirroring how MPCAutofill/manage.py makes
@@ -52,7 +82,29 @@
5282
REPO_ROOT / "federation-hash-tool",
5383
]
5484

55-
PROVENANCE_RE = re.compile(r"#\s*PROVENANCE:.*", re.IGNORECASE)
85+
# Accepts `#`, `//` and `*` comment leaders — see the module docstring's
86+
# marker-convention note for why the `#`-only form was a real hole.
87+
PROVENANCE_RE = re.compile(r"(?:#|//|\*)\s*PROVENANCE:.*", re.IGNORECASE)
88+
89+
PY_SUFFIXES = (".py",)
90+
JS_SUFFIXES = (".mjs", ".cjs", ".js")
91+
92+
# ES-module / CommonJS specifier extraction. Deliberately regex, not a JS
93+
# parser: the roster's JS entries are by policy zero-dependency, single-file
94+
# tools, and this repo's stated "narrow v1, no heavyweight library"
95+
# philosophy (docs_lint.py's own limitation note) applies. The cost is that
96+
# an exotic construct could be missed; the alternative is vendoring a JS
97+
# parser into CI to check two files.
98+
JS_IMPORT_RE = re.compile(
99+
r"""(?:
100+
\bimport\s+[^;'"]*?\bfrom\s*["']([^"']+)["'] # import x from "y"
101+
| \bexport\s+[^;'"]*?\bfrom\s*["']([^"']+)["'] # export * from "y"
102+
| \bimport\s*["']([^"']+)["'] # import "y" (side effect)
103+
| \bimport\s*\(\s*["']([^"']+)["'] # dynamic import("y")
104+
| \brequire\s*\(\s*["']([^"']+)["'] # require("y")
105+
)""",
106+
re.VERBOSE,
107+
)
56108

57109

58110
def is_agpl_marked(text: str) -> bool:
@@ -62,6 +114,54 @@ def is_agpl_marked(text: str) -> bool:
62114
return False
63115

64116

117+
def protected_core_files() -> tuple[list[str], list[str]]:
118+
"""
119+
DERIVE the protected-core roster from the policy doc. Returns
120+
(paths, findings) — findings is non-empty when the region itself is
121+
broken, which must fail loudly rather than yield an empty roster.
122+
123+
Order is preserved and duplicates collapsed, so a path that appears
124+
twice in the prose is checked once.
125+
"""
126+
doc = REPO_ROOT / POLICY_DOC_REL
127+
if not doc.is_file():
128+
return [], [
129+
f"::error::check_protected_core_license.py: policy doc {POLICY_DOC_REL} "
130+
f"is missing — the protected-core roster is derived from its §2 marker "
131+
f"region and cannot be built without it"
132+
]
133+
134+
text = doc.read_text()
135+
start = text.find(ROSTER_BEGIN_MARKER)
136+
end = text.find(ROSTER_END_MARKER)
137+
if start == -1 or end == -1 or end < start:
138+
return [], [
139+
f"::error file={POLICY_DOC_REL}::protected-core roster markers "
140+
f"{ROSTER_BEGIN_MARKER} / {ROSTER_END_MARKER} not found (or out of "
141+
f"order) in §2. This script derives its file list from that region; "
142+
f"without the markers it would check NOTHING and pass, so the missing "
143+
f"markers are themselves the finding."
144+
]
145+
146+
region = text[start + len(ROSTER_BEGIN_MARKER) : end]
147+
paths: list[str] = []
148+
for m in ROSTER_PATH_RE.finditer(region):
149+
candidate = m.group(1)
150+
if not candidate.endswith(ROSTER_PATH_EXTENSIONS):
151+
continue
152+
if candidate not in paths:
153+
paths.append(candidate)
154+
155+
if not paths:
156+
return [], [
157+
f"::error file={POLICY_DOC_REL}::protected-core roster region is "
158+
f"present but contains no backtick-quoted source paths. An empty "
159+
f"roster means this lint checks nothing; that is the finding."
160+
]
161+
162+
return paths, []
163+
164+
65165
def resolve_local_import(module: str) -> Path | None:
66166
"""module is a dotted path like 'cardpicker.models' - resolve it against
67167
each import root, trying both '<module_path>.py' and
@@ -93,22 +193,77 @@ def local_imports(path: Path) -> list[str]:
93193
return modules
94194

95195

196+
def js_imports(path: Path) -> list[str]:
197+
"""Every import/require specifier in a JS/ESM file, in source order."""
198+
specifiers = []
199+
for m in JS_IMPORT_RE.finditer(path.read_text()):
200+
specifiers.append(next(g for g in m.groups() if g is not None))
201+
return specifiers
202+
203+
204+
def resolve_js_import(specifier: str, importer: Path) -> Path | None:
205+
"""
206+
Resolve a JS specifier to an in-repo file, or None.
207+
208+
Only RELATIVE specifiers resolve. A bare specifier is either a `node:`
209+
builtin or an npm package — the same out-of-scope category as a PyPI
210+
import on the Python side, and the roster's JS entries are by policy
211+
dependency-free anyway. Tries the literal path first (ESM requires the
212+
extension), then the extension-less CommonJS / index forms so a future
213+
roster addition written in either style still resolves.
214+
"""
215+
if not specifier.startswith("."):
216+
return None
217+
base = (importer.parent / specifier).resolve()
218+
candidates = [base]
219+
for ext in JS_SUFFIXES:
220+
candidates.append(base.with_name(base.name + ext))
221+
candidates.append(base / ("index" + ext))
222+
for candidate in candidates:
223+
if candidate.is_file():
224+
try:
225+
candidate.relative_to(REPO_ROOT)
226+
except ValueError:
227+
return None # escaped the repo — not an in-repo module
228+
return candidate
229+
return None
230+
231+
96232
def check_file(rel_path: str) -> list[str]:
97233
findings = []
98234
path = REPO_ROOT / rel_path
99235
if not path.is_file():
100-
return [f"::error::check_protected_core_license.py: PROTECTED_CORE_FILES entry {rel_path!r} does not exist"]
236+
return [
237+
f"::error file={POLICY_DOC_REL}::protected-core roster lists "
238+
f"{rel_path!r}, which does not exist in the repo"
239+
]
101240

102241
text = path.read_text()
103242
if is_agpl_marked(text):
104243
findings.append(f"::error file={rel_path}::PROTECTED CORE file itself carries an AGPL provenance marker")
105244

106-
for module in local_imports(path):
107-
resolved = resolve_local_import(module)
245+
if path.suffix in PY_SUFFIXES:
246+
dependencies = [(module, resolve_local_import(module)) for module in local_imports(path)]
247+
elif path.suffix in JS_SUFFIXES:
248+
dependencies = [(spec, resolve_js_import(spec, path)) for spec in js_imports(path)]
249+
else:
250+
# A roster entry in a language this lint cannot walk is a hard
251+
# finding, not a silent skip: it would otherwise be an entry that
252+
# LOOKS gated and is not — the exact failure this script was
253+
# rewritten to eliminate.
254+
findings.append(
255+
f"::error file={rel_path}::protected-core roster entry has unsupported "
256+
f"suffix {path.suffix!r} — this lint can only walk imports for "
257+
f"{list(PY_SUFFIXES + JS_SUFFIXES)}. Extend "
258+
f"check_protected_core_license.py rather than leaving the entry "
259+
f"half-checked."
260+
)
261+
return findings
262+
263+
for module, resolved in dependencies:
108264
if resolved is None:
109265
continue
110-
imported_text = resolved.read_text()
111-
if is_agpl_marked(imported_text):
266+
if is_agpl_marked(resolved.read_text()):
112267
findings.append(
113268
f"::error file={rel_path}::imports {module!r} "
114269
f"({resolved.relative_to(REPO_ROOT)}), which carries an AGPL provenance marker"
@@ -118,8 +273,8 @@ def check_file(rel_path: str) -> list[str]:
118273

119274

120275
def main() -> int:
121-
all_findings = []
122-
for rel_path in PROTECTED_CORE_FILES:
276+
roster, all_findings = protected_core_files()
277+
for rel_path in roster:
123278
all_findings.extend(check_file(rel_path))
124279

125280
for finding in all_findings:
@@ -128,7 +283,7 @@ def main() -> int:
128283
if all_findings:
129284
print(f"\n{len(all_findings)} PROTECTED CORE license violation(s) found.")
130285
else:
131-
print(f"protected-core-license: clean ({len(PROTECTED_CORE_FILES)} files checked).")
286+
print(f"protected-core-license: clean ({len(roster)} files checked, derived from {POLICY_DOC_REL} §2).")
132287

133288
return len(all_findings)
134289

0 commit comments

Comments
 (0)