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 +10 -0
- reference/disclosure.py +144 -0
- reference/level1_lint.py +97 -0
- reference/producer.py +647 -0
- reference/standalone/__init__.py +6 -0
- reference/standalone/verify_envelope.py +470 -0
- scpe/__init__.py +2 -0
- scpe/analyze.py +67 -0
- scpe/attestation.py +361 -0
- scpe/backends.py +93 -0
- scpe/changes.py +90 -0
- scpe/checks.py +96 -0
- scpe/cli.py +1020 -0
- scpe/contribute.py +142 -0
- scpe/envelope.py +211 -0
- scpe/handshake.py +229 -0
- scpe/identity.py +245 -0
- scpe/inspect.py +73 -0
- scpe/label.py +153 -0
- scpe/mcp_server.py +334 -0
- scpe/optin.py +79 -0
- scpe/prompting.py +33 -0
- scpe/repo_snapshot.py +74 -0
- scpe/sandbox.py +196 -0
- scpe/scrub.py +48 -0
- scpe/seal.py +117 -0
- scpe/signing.py +55 -0
- scpe/workspace.py +189 -0
- scpe_protocol-0.1.2.dist-info/METADATA +221 -0
- scpe_protocol-0.1.2.dist-info/RECORD +35 -0
- scpe_protocol-0.1.2.dist-info/WHEEL +5 -0
- scpe_protocol-0.1.2.dist-info/entry_points.txt +4 -0
- scpe_protocol-0.1.2.dist-info/licenses/LICENSE +202 -0
- scpe_protocol-0.1.2.dist-info/licenses/LICENSE-SPEC +10 -0
- scpe_protocol-0.1.2.dist-info/top_level.txt +2 -0
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
|
+
"""
|
reference/disclosure.py
ADDED
|
@@ -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)))
|
reference/level1_lint.py
ADDED
|
@@ -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())
|