scpe-protocol 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reference/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """SCPE scpe/0.1 reference implementation.
2
+
3
+ Two auditable, stdlib-only files:
4
+ producer.py the signing side (pack / attest / verify / submit)
5
+ standalone/verify_envelope.py the verifier (SPEC.md §8, verbatim)
6
+
7
+ Importing this package pulls in nothing heavy: `import reference.producer` only
8
+ loads the producer module (stdlib imports), and the standalone verifier stays
9
+ runnable as a plain file (`python reference/standalone/verify_envelope.py ...`).
10
+ """
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ """SCPE LEVEL 1 — the AI-disclosure signal detector.
3
+
4
+ Zero-friction entry point: no envelope, no signing key, no GitHub key fetch. Given the
5
+ free text a contributor already writes (a PR body and/or the commit messages of a
6
+ range), this module answers exactly one question — did they disclose AI use at all,
7
+ in either of the two shapes real projects already require today:
8
+
9
+ * a commit-trailer, e.g. OpenSSL's `Assisted-by: <tool>` (or a bare "none"/"no" to
10
+ explicitly disclose that NO AI was used — that is still a disclosure);
11
+ * a PR-template checkbox, e.g. MicroPython's "I used generative AI" /
12
+ "I did not use generative AI".
13
+
14
+ It does NOT judge whether the disclosure is honest (see SPEC.md §2, THREAT_MODEL.md) —
15
+ only whether the signal is present, and in which form.
16
+
17
+ Like reference/producer.py and reference/standalone/verify_envelope.py this is a
18
+ small, self-contained, stdlib-only module: no imports beyond the standard library,
19
+ no `eval`/`exec`, no shell-out. Every input here is UNTRUSTED (a stranger's PR body
20
+ and commit messages), so parsing is defensive throughout:
21
+
22
+ * inputs are truncated to MAX_LEN before any regex runs, and non-str input is
23
+ coerced to "" rather than raising, so a hostile/oversized payload cannot be used
24
+ to exhaust memory or CPU;
25
+ * every pattern is anchored per-line (`^...$`, MULTILINE) with no nested/overlapping
26
+ quantifiers, so matching stays linear in the input size — no catastrophic
27
+ backtracking (ReDoS) on adversarial input;
28
+ * a match only ever extracts a substring; nothing here is ever passed to `eval`,
29
+ `exec`, a shell, or a template engine, so injected shell metacharacters, HTML,
30
+ or control characters in a disclosure value are inert — they come back as inert
31
+ data, never as code.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import re
36
+
37
+ # Never regex-scan more than this many characters of a single untrusted source (a PR
38
+ # body or one commit message) — bounds CPU/memory regardless of how large the input is.
39
+ MAX_LEN = 20_000
40
+ # Never scan more than this many lines when looking for a checkbox — same rationale,
41
+ # expressed in lines instead of characters for the line-oriented checkbox scan.
42
+ MAX_LINES = 2_000
43
+
44
+ # `Assisted-by:` / `Assisted-By:` / `ASSISTED-BY:` ... — case-insensitive, one per
45
+ # line, value = the rest of the line. `.` does not match a newline by default, so a
46
+ # value can never smuggle a second line/trailer past this pattern.
47
+ _TRAILER_RE = re.compile(r"^[ \t]*assisted-by[ \t]*:[ \t]*(.*?)[ \t]*$", re.IGNORECASE | re.MULTILINE)
48
+
49
+ # A PR-template checkbox line, e.g. "- [x] I used generative AI" / "- [x] I did not
50
+ # use generative AI". Only a CHECKED box (`[x]`/`[X]`) counts as a signal — an
51
+ # unchecked template line means the contributor never filled it in.
52
+ _CHECKBOX_NOT_USED_RE = re.compile(
53
+ r"^[ \t]*-[ \t]*\[[xX]\][ \t]*i[ \t]+(?:did[ \t]+not|have[ \t]+not|haven't)[ \t]+"
54
+ r"use[d]?[ \t]+(?:generative[ \t]+)?ai\b",
55
+ re.IGNORECASE,
56
+ )
57
+ _CHECKBOX_USED_RE = re.compile(
58
+ r"^[ \t]*-[ \t]*\[[xX]\][ \t]*i[ \t]+(?:have[ \t]+)?used[ \t]+(?:generative[ \t]+)?ai\b",
59
+ re.IGNORECASE,
60
+ )
61
+
62
+
63
+ def _bounded(text) -> str:
64
+ """Coerce to str and truncate — the one gate every untrusted source passes through
65
+ before it ever reaches a regex."""
66
+ if not isinstance(text, str):
67
+ return ""
68
+ return text[:MAX_LEN]
69
+
70
+
71
+ def _normalize_value(value: str) -> str:
72
+ """A trailer that explicitly says no AI was used ("none"/"no"/"n/a"/empty) is still
73
+ a disclosure — normalize its value to the canonical "none" rather than echoing
74
+ whatever casing/spelling the contributor used."""
75
+ v = value.strip()
76
+ return "none" if v.lower() in ("", "none", "no", "n/a", "nil") else v
77
+
78
+
79
+ def _find_trailer(text) -> str | None:
80
+ """The LAST `Assisted-by:` trailer in `text`, normalized — or None if there isn't
81
+ one. Last-wins: if a contributor amends/overrides an earlier trailer, the final
82
+ line is the one that stands, matching how git trailers are conventionally read."""
83
+ matches = _TRAILER_RE.findall(_bounded(text))
84
+ return _normalize_value(matches[-1]) if matches else None
85
+
86
+
87
+ def _find_checkbox(text) -> tuple[str, str] | None:
88
+ """The first checked disclosure checkbox in `text`, scanned line-by-line (bounded
89
+ by MAX_LINES) so a pathological single "line" longer than MAX_LEN was already
90
+ truncated by `_bounded` before we ever split it. Returns (form, value) or None."""
91
+ for line in _bounded(text).splitlines()[:MAX_LINES]:
92
+ if _CHECKBOX_NOT_USED_RE.match(line):
93
+ return ("checkbox", "none")
94
+ if _CHECKBOX_USED_RE.match(line):
95
+ return ("checkbox", "ai")
96
+ return None
97
+
98
+
99
+ def detect_disclosure(pr_body: str = "", commit_messages: list[str] | None = None) -> dict:
100
+ """Detect the AI-disclosure signal for one contribution.
101
+
102
+ Args:
103
+ pr_body: the pull request description (untrusted).
104
+ commit_messages: the commit messages of the PR's range, most-recent-last if
105
+ known (untrusted); order only matters as a tie-breaker (see below).
106
+
107
+ Returns:
108
+ {"present": bool, "form": "trailer" | "checkbox" | "none", "value": str}
109
+
110
+ `present` is True whenever a recognized signal exists at all — including an
111
+ explicit "no AI was used" trailer/checkbox, since that IS a disclosure, just
112
+ a negative one. `value` is the free-text tool/model name for a trailer (or
113
+ "none"/"ai" for a checkbox), never executed or interpreted — pure data.
114
+
115
+ Precedence when more than one signal is present (checked in this order, first
116
+ match wins): the LAST `Assisted-by:` trailer across `commit_messages` (closest to
117
+ the actual authored work), then a trailer in `pr_body`, then a checked PR-template
118
+ checkbox in `pr_body`. Trailers outrank the checkbox because a trailer is a
119
+ specific, machine-conventional claim (OpenSSL-style); the checkbox is a coarser,
120
+ template-driven signal (MicroPython-style).
121
+ """
122
+ for msg in reversed(commit_messages or []):
123
+ value = _find_trailer(msg)
124
+ if value is not None:
125
+ return {"present": True, "form": "trailer", "value": value}
126
+
127
+ value = _find_trailer(pr_body)
128
+ if value is not None:
129
+ return {"present": True, "form": "trailer", "value": value}
130
+
131
+ checkbox = _find_checkbox(pr_body)
132
+ if checkbox is not None:
133
+ form, value = checkbox
134
+ return {"present": True, "form": form, "value": value}
135
+
136
+ return {"present": False, "form": "none", "value": ""}
137
+
138
+
139
+ if __name__ == "__main__": # pragma: no cover — manual/local use only
140
+ import json
141
+ import sys
142
+
143
+ body = sys.stdin.read() if not sys.stdin.isatty() else ""
144
+ print(json.dumps(detect_disclosure(pr_body=body)))
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ """SCPE LEVEL 1 — the Action-facing wrapper around disclosure.py.
3
+
4
+ Invoked by action.yml's untrusted step (no secrets, no signature, no envelope —
5
+ `pipx install scpe` is never even run for this path). It reads the ambient GitHub
6
+ context the composite action's step already passed through as env vars, asks
7
+ disclosure.detect_disclosure() the one question it answers, and writes results.json
8
+ in the schema the trusted `seal` job (docs/workflows/scpe.yml) already reads:
9
+ `status`, `require`, `gate_pass` (existing contract) plus `disclosure`, `level`,
10
+ `fail_message`, `comment` (new, level-1-only fields the trusted job falls back
11
+ around when absent, so level 2/default behavior is untouched).
12
+
13
+ Environment (all optional; missing values degrade to "no disclosure found" rather
14
+ than raising — the untrusted job must always finish and hand a result to the
15
+ trusted job, per spec §8 "unattested is a state, not an error"):
16
+ PR_BODY the pull request description
17
+ BASE_SHA the PR's base commit (for `git log BASE..HEAD`)
18
+ HEAD_SHA the PR's head commit
19
+ REPO_DIR the checked-out repo to read commit messages from (default ".")
20
+ REQUIRE "true" to gate (fail when absent); anything else = informational
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
31
+ from disclosure import detect_disclosure # noqa: E402 (path set up above, on purpose)
32
+
33
+ FAIL_MESSAGE = (
34
+ "⚠️ Missing AI-use disclosure — this repository requires "
35
+ "contributors to declare AI use"
36
+ )
37
+
38
+
39
+ def _commit_messages(repo_dir: str, base: str, head: str) -> list[str]:
40
+ """Commit messages in `base..head`, oldest-first. Never raises: any git failure
41
+ degrades to an empty list rather than crashing the untrusted job — the PR body
42
+ is still checked either way. Honest limitation: `actions/checkout@v4`'s default
43
+ `fetch-depth: 1` won't have `base` locally, so on a shallow checkout this
44
+ silently returns [] and the trailer check falls back to the PR body only — a
45
+ repo wanting the commit-trailer form reliably checked should set
46
+ `fetch-depth: 0` (or enough depth to cover the PR) on its checkout step."""
47
+ if not base or not head:
48
+ return []
49
+ try:
50
+ out = subprocess.run(
51
+ ["git", "-C", repo_dir, "log", f"{base}..{head}", "--format=%B%x00"],
52
+ capture_output=True, text=True, timeout=30, check=False,
53
+ )
54
+ except (OSError, subprocess.SubprocessError):
55
+ return []
56
+ if out.returncode != 0 or not out.stdout:
57
+ return []
58
+ return [m for m in out.stdout.split("\x00") if m.strip()]
59
+
60
+
61
+ def build_results(*, pr_body: str, base: str, head: str, repo_dir: str, require: bool) -> dict:
62
+ commits = _commit_messages(repo_dir, base, head)
63
+ result = detect_disclosure(pr_body=pr_body, commit_messages=commits)
64
+ status = "verified" if result["present"] else "unattested"
65
+ gate_pass = (not require) or result["present"]
66
+ comment = (
67
+ f"✅ AI-use disclosure found ({result['form']}: {result['value'] or 'none'})"
68
+ if result["present"] else
69
+ "ℹ️ No AI-use disclosure found (informational — set "
70
+ '`require: "true"` on this Action to enforce).'
71
+ )
72
+ return {
73
+ "level": "1",
74
+ "disclosure": result,
75
+ "status": status,
76
+ "require": require,
77
+ "gate_pass": gate_pass,
78
+ "fail_message": FAIL_MESSAGE,
79
+ "comment": comment,
80
+ }
81
+
82
+
83
+ def main() -> int:
84
+ require = (os.environ.get("REQUIRE") or "false").strip().lower() == "true"
85
+ data = build_results(
86
+ pr_body=os.environ.get("PR_BODY") or "",
87
+ base=os.environ.get("BASE_SHA") or "",
88
+ head=os.environ.get("HEAD_SHA") or "",
89
+ repo_dir=os.environ.get("REPO_DIR") or ".",
90
+ require=require,
91
+ )
92
+ Path("results.json").write_text(json.dumps(data), encoding="utf-8")
93
+ return 0
94
+
95
+
96
+ if __name__ == "__main__":
97
+ sys.exit(main())