precheck 0.1.0__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.
- precheck/__init__.py +5 -0
- precheck/__main__.py +6 -0
- precheck/cli.py +210 -0
- precheck/core.py +173 -0
- precheck/runtime.py +19 -0
- precheck/vacuity.py +210 -0
- precheck-0.1.0.dist-info/METADATA +227 -0
- precheck-0.1.0.dist-info/RECORD +12 -0
- precheck-0.1.0.dist-info/WHEEL +5 -0
- precheck-0.1.0.dist-info/entry_points.txt +2 -0
- precheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- precheck-0.1.0.dist-info/top_level.txt +1 -0
precheck/__init__.py
ADDED
precheck/__main__.py
ADDED
precheck/cli.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""precheck command line.
|
|
2
|
+
|
|
3
|
+
precheck init write a commitments template
|
|
4
|
+
precheck register freeze the commitments, before the run
|
|
5
|
+
precheck settle run the frozen checks, record verdicts
|
|
6
|
+
precheck audit mutate artefacts, find checks that can't fail
|
|
7
|
+
precheck verify walk the chain, detect edited history
|
|
8
|
+
precheck status one-screen summary
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from . import core
|
|
16
|
+
from .core import PrecheckError
|
|
17
|
+
from .runtime import run_check
|
|
18
|
+
from .vacuity import MUTATIONS, audit
|
|
19
|
+
|
|
20
|
+
TEMPLATE = {
|
|
21
|
+
"version": 1,
|
|
22
|
+
"note": ("Write the check BEFORE the work it judges. `check` is a shell "
|
|
23
|
+
"command whose exit code decides the claim; `artifacts` are the "
|
|
24
|
+
"files that claim is about (used by `precheck audit`)."),
|
|
25
|
+
"commitments": [
|
|
26
|
+
{
|
|
27
|
+
"id": "C1",
|
|
28
|
+
"statement": "replace me: what is being claimed",
|
|
29
|
+
"check": "replace me: a command that exits non-zero if it is false",
|
|
30
|
+
"artifacts": ["replace/me.txt"],
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fail(msg, code=1):
|
|
37
|
+
print(msg, file=sys.stderr)
|
|
38
|
+
return code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_init(args):
|
|
42
|
+
p = core.commitments_path(args.root)
|
|
43
|
+
if os.path.exists(p):
|
|
44
|
+
return _fail("%s already exists -- not overwriting" % p)
|
|
45
|
+
core.save_commitments(args.root, TEMPLATE)
|
|
46
|
+
print("wrote %s" % p)
|
|
47
|
+
print("edit it, then: precheck register")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_register(args):
|
|
52
|
+
data = core.load_commitments(args.root)
|
|
53
|
+
if not data.get("commitments"):
|
|
54
|
+
return _fail("commitments list is empty -- nothing to freeze")
|
|
55
|
+
h = core.commitments_hash(args.root)
|
|
56
|
+
entry = core.append(args.root, "register",
|
|
57
|
+
{"commitments_sha256": h,
|
|
58
|
+
"count": len(data["commitments"]),
|
|
59
|
+
"ids": [c.get("id") for c in data["commitments"]]})
|
|
60
|
+
print("froze %d commitment(s) at seq=%s sha256=%s"
|
|
61
|
+
% (len(data["commitments"]), entry["seq"], h[:12]))
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_settle(args):
|
|
66
|
+
data = core.load_commitments(args.root)
|
|
67
|
+
results = []
|
|
68
|
+
for c in data.get("commitments", []):
|
|
69
|
+
cid = c.get("id", "?")
|
|
70
|
+
code, out = run_check(c["check"], cwd=args.root, timeout=args.timeout)
|
|
71
|
+
if code is None:
|
|
72
|
+
verdict = "TIMEOUT"
|
|
73
|
+
elif code == 0:
|
|
74
|
+
verdict = "PASS"
|
|
75
|
+
else:
|
|
76
|
+
verdict = "FAIL"
|
|
77
|
+
results.append({"id": cid, "verdict": verdict, "exit": code,
|
|
78
|
+
"statement": c.get("statement", ""),
|
|
79
|
+
"check": c["check"]})
|
|
80
|
+
print("%-5s %s" % (verdict, c.get("statement") or cid))
|
|
81
|
+
if verdict != "PASS" and args.verbose:
|
|
82
|
+
print(" $ %s\n exit=%s\n%s" % (c["check"], code, out.rstrip()))
|
|
83
|
+
|
|
84
|
+
entry = core.append(args.root, "settle", {"results": results})
|
|
85
|
+
bad = [r for r in results if r["verdict"] != "PASS"]
|
|
86
|
+
print("\n%d/%d passed (seq=%s)" % (len(results) - len(bad), len(results),
|
|
87
|
+
entry["seq"]))
|
|
88
|
+
if args.json:
|
|
89
|
+
print(json.dumps(results, ensure_ascii=False, indent=2))
|
|
90
|
+
return 0 if not bad else 2
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_audit(args):
|
|
94
|
+
data = core.load_commitments(args.root)
|
|
95
|
+
kinds = tuple(k.strip() for k in args.mutations.split(",") if k.strip())
|
|
96
|
+
bad = [k for k in kinds if k not in MUTATIONS]
|
|
97
|
+
if bad:
|
|
98
|
+
return _fail("unknown mutation(s): %s (known: %s)"
|
|
99
|
+
% (", ".join(bad), ", ".join(MUTATIONS)))
|
|
100
|
+
|
|
101
|
+
def on_event(cid, art, kind, code, detail):
|
|
102
|
+
if args.verbose:
|
|
103
|
+
print(" %s %s on %s -> exit=%s [%s]" % (cid, kind, art, code, detail))
|
|
104
|
+
|
|
105
|
+
res = audit(args.root, data, mutations=kinds, seed=args.seed,
|
|
106
|
+
timeout=args.timeout, on_event=on_event)
|
|
107
|
+
entry = core.append(args.root, "audit",
|
|
108
|
+
{"examined": res["examined"],
|
|
109
|
+
"escaped": res["escaped"],
|
|
110
|
+
"skipped": res["skipped"],
|
|
111
|
+
"seed": args.seed,
|
|
112
|
+
"mutations": list(kinds)})
|
|
113
|
+
|
|
114
|
+
print("ran %d mutation(s) across the declared artefacts" % res["examined"])
|
|
115
|
+
for s in res["skipped"]:
|
|
116
|
+
print(" skipped %s (%s: %s)" % (s["artifact"], s["why"], s["id"]))
|
|
117
|
+
if res["escaped"]:
|
|
118
|
+
print("\n%d check/mutation pair(s) survived -- these prove nothing yet:"
|
|
119
|
+
% len(res["escaped"]))
|
|
120
|
+
for e in res["escaped"]:
|
|
121
|
+
print(" ? %s [%s on %s]" % (e["id"], e["mutation"], e["artifact"]))
|
|
122
|
+
print(" %s" % e.get("detail", ""))
|
|
123
|
+
print(" the check still exited 0")
|
|
124
|
+
print("\nThis is a question list, not a bug list. Some survivors are "
|
|
125
|
+
"legitimate.")
|
|
126
|
+
else:
|
|
127
|
+
print("every mutation was caught by its check.")
|
|
128
|
+
print("(seq=%s)" % entry["seq"])
|
|
129
|
+
if args.strict and res["escaped"]:
|
|
130
|
+
return 3
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_verify(args):
|
|
135
|
+
res = core.verify(args.root)
|
|
136
|
+
for f in res["findings"]:
|
|
137
|
+
mark = {"high": "!", "info": "."}.get(f["level"], ".")
|
|
138
|
+
print("%s %s" % (mark, f["message"]))
|
|
139
|
+
print("\n%d %s; chain %s"
|
|
140
|
+
% (res["entries"], "entry" if res["entries"] == 1 else "entries",
|
|
141
|
+
"consistent" if res["ok"] else "BROKEN"))
|
|
142
|
+
return 0 if res["ok"] else 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_status(args):
|
|
146
|
+
try:
|
|
147
|
+
data = core.load_commitments(args.root)
|
|
148
|
+
n = len(data.get("commitments", []))
|
|
149
|
+
except PrecheckError as e:
|
|
150
|
+
return _fail(str(e))
|
|
151
|
+
entries = core.read_ledger(args.root)
|
|
152
|
+
kinds = {}
|
|
153
|
+
for e in entries:
|
|
154
|
+
kinds[e.get("kind")] = kinds.get(e.get("kind"), 0) + 1
|
|
155
|
+
last = entries[-1] if entries else None
|
|
156
|
+
print("commitments : %d" % n)
|
|
157
|
+
print("ledger : %d entries %s" % (len(entries), kinds or ""))
|
|
158
|
+
if last:
|
|
159
|
+
print("head : seq=%s kind=%s %s" % (last.get("seq"),
|
|
160
|
+
last.get("kind"),
|
|
161
|
+
(last.get("hash") or "")[:12]))
|
|
162
|
+
v = core.verify(args.root)
|
|
163
|
+
print("chain : %s" % ("ok" if v["ok"] else "BROKEN"))
|
|
164
|
+
for f in v["findings"]:
|
|
165
|
+
if f["level"] == "high":
|
|
166
|
+
print(" ! %s" % f["message"])
|
|
167
|
+
return 0 if v["ok"] else 1
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def build_parser():
|
|
171
|
+
p = argparse.ArgumentParser(prog="precheck", description=__doc__,
|
|
172
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
173
|
+
p.add_argument("--root", default=".", help="repository root (default: .)")
|
|
174
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
175
|
+
|
|
176
|
+
sub.add_parser("init", help="write a commitments template").set_defaults(
|
|
177
|
+
func=cmd_init)
|
|
178
|
+
sub.add_parser("register", help="freeze commitments before the run"
|
|
179
|
+
).set_defaults(func=cmd_register)
|
|
180
|
+
|
|
181
|
+
s = sub.add_parser("settle", help="run the frozen checks")
|
|
182
|
+
s.add_argument("--timeout", type=int, default=600)
|
|
183
|
+
s.add_argument("--json", action="store_true")
|
|
184
|
+
s.add_argument("-v", "--verbose", action="store_true")
|
|
185
|
+
s.set_defaults(func=cmd_settle)
|
|
186
|
+
|
|
187
|
+
a = sub.add_parser("audit", help="find checks that cannot fail")
|
|
188
|
+
a.add_argument("--mutations", default=",".join(MUTATIONS))
|
|
189
|
+
a.add_argument("--seed", type=int, default=0)
|
|
190
|
+
a.add_argument("--timeout", type=int, default=600)
|
|
191
|
+
a.add_argument("--strict", action="store_true",
|
|
192
|
+
help="exit non-zero if any mutation survived (for CI)")
|
|
193
|
+
a.add_argument("-v", "--verbose", action="store_true")
|
|
194
|
+
a.set_defaults(func=cmd_audit)
|
|
195
|
+
|
|
196
|
+
sub.add_parser("verify", help="walk the chain").set_defaults(func=cmd_verify)
|
|
197
|
+
sub.add_parser("status", help="summary").set_defaults(func=cmd_status)
|
|
198
|
+
return p
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv=None):
|
|
202
|
+
args = build_parser().parse_args(argv)
|
|
203
|
+
try:
|
|
204
|
+
return args.func(args)
|
|
205
|
+
except PrecheckError as e:
|
|
206
|
+
return _fail("precheck: %s" % e)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
sys.exit(main())
|
precheck/core.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""precheck core: commitments, the hash chain, and verification.
|
|
2
|
+
|
|
3
|
+
The rule this tool exists to enforce:
|
|
4
|
+
|
|
5
|
+
The check must exist BEFORE the work it judges, and the actor under
|
|
6
|
+
judgement must not be able to edit it afterwards.
|
|
7
|
+
|
|
8
|
+
So: the commitment file is frozen by hash at registration time, and every
|
|
9
|
+
later verdict is appended to a hash-chained ledger. Editing either one is
|
|
10
|
+
detectable by a third party who has nothing but the repo.
|
|
11
|
+
"""
|
|
12
|
+
import hashlib, json, os, time
|
|
13
|
+
|
|
14
|
+
DIRNAME = ".precheck"
|
|
15
|
+
COMMITMENTS = "commitments.json"
|
|
16
|
+
LEDGER = "ledger.jsonl"
|
|
17
|
+
MARKER = b"# precheck ledger v1\n"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PrecheckError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonical(obj) -> bytes:
|
|
25
|
+
"""Deterministic bytes for hashing. Key order and spacing fixed."""
|
|
26
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"),
|
|
27
|
+
ensure_ascii=False).encode("utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sha256_bytes(data: bytes) -> str:
|
|
31
|
+
return hashlib.sha256(data).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sha256_file(path: str) -> str:
|
|
35
|
+
h = hashlib.sha256()
|
|
36
|
+
with open(path, "rb") as f:
|
|
37
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
38
|
+
h.update(chunk)
|
|
39
|
+
return h.hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def store_dir(root: str = ".") -> str:
|
|
43
|
+
return os.path.join(root, DIRNAME)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def commitments_path(root: str = ".") -> str:
|
|
47
|
+
return os.path.join(store_dir(root), COMMITMENTS)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ledger_path(root: str = ".") -> str:
|
|
51
|
+
return os.path.join(store_dir(root), LEDGER)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_commitments(root: str = ".") -> dict:
|
|
55
|
+
p = commitments_path(root)
|
|
56
|
+
if not os.path.exists(p):
|
|
57
|
+
raise PrecheckError(
|
|
58
|
+
"no commitments file at %s -- run `precheck init` then edit it" % p)
|
|
59
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
60
|
+
data = json.load(f)
|
|
61
|
+
if not isinstance(data, dict) or "commitments" not in data:
|
|
62
|
+
raise PrecheckError("commitments file must be an object with a "
|
|
63
|
+
"'commitments' list")
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_commitments(root: str, data: dict) -> None:
|
|
68
|
+
os.makedirs(store_dir(root), exist_ok=True)
|
|
69
|
+
with open(commitments_path(root), "w", encoding="utf-8", newline="\n") as f:
|
|
70
|
+
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=False)
|
|
71
|
+
f.write("\n")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def commitments_hash(root: str = ".") -> str:
|
|
75
|
+
"""Hash of the commitments file on disk, right now."""
|
|
76
|
+
return sha256_file(commitments_path(root))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_ledger(root: str = ".") -> list:
|
|
80
|
+
p = ledger_path(root)
|
|
81
|
+
if not os.path.exists(p):
|
|
82
|
+
return []
|
|
83
|
+
out = []
|
|
84
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
85
|
+
for lineno, line in enumerate(f, 1):
|
|
86
|
+
line = line.strip()
|
|
87
|
+
if not line or line.startswith("#"):
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
out.append(json.loads(line))
|
|
91
|
+
except ValueError as e:
|
|
92
|
+
raise PrecheckError("ledger line %d is not valid JSON: %s"
|
|
93
|
+
% (lineno, e))
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _entry_hash(entry: dict) -> str:
|
|
98
|
+
body = {k: v for k, v in entry.items() if k != "hash"}
|
|
99
|
+
return sha256_bytes(canonical(body))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def append(root: str, kind: str, payload: dict) -> dict:
|
|
103
|
+
"""Append one hash-chained entry to the ledger. Returns the entry."""
|
|
104
|
+
os.makedirs(store_dir(root), exist_ok=True)
|
|
105
|
+
entries = read_ledger(root)
|
|
106
|
+
entry = {
|
|
107
|
+
"seq": len(entries) + 1,
|
|
108
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
109
|
+
"kind": kind,
|
|
110
|
+
"prev": entries[-1]["hash"] if entries else None,
|
|
111
|
+
}
|
|
112
|
+
entry.update(payload)
|
|
113
|
+
entry["hash"] = _entry_hash(entry)
|
|
114
|
+
p = ledger_path(root)
|
|
115
|
+
new = not os.path.exists(p)
|
|
116
|
+
with open(p, "a", encoding="utf-8", newline="\n") as f:
|
|
117
|
+
if new:
|
|
118
|
+
f.write(MARKER.decode())
|
|
119
|
+
f.write(json.dumps(entry, sort_keys=True, ensure_ascii=False) + "\n")
|
|
120
|
+
return entry
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def verify(root: str = ".") -> dict:
|
|
124
|
+
"""Walk the chain and compare the frozen commitments to the live file.
|
|
125
|
+
|
|
126
|
+
Returns {"ok": bool, "entries": int, "findings": [...]}.
|
|
127
|
+
A finding is a question for a human, not a verdict: this function only
|
|
128
|
+
reports what it can check mechanically.
|
|
129
|
+
"""
|
|
130
|
+
findings = []
|
|
131
|
+
|
|
132
|
+
def add(level, code, msg):
|
|
133
|
+
findings.append({"level": level, "code": code, "message": msg})
|
|
134
|
+
|
|
135
|
+
entries = read_ledger(root)
|
|
136
|
+
if not entries:
|
|
137
|
+
add("high", "NO_LEDGER",
|
|
138
|
+
"no ledger yet -- nothing has been registered or settled")
|
|
139
|
+
return {"ok": False, "entries": 0, "findings": findings}
|
|
140
|
+
|
|
141
|
+
for i, e in enumerate(entries):
|
|
142
|
+
if e.get("seq") != i + 1:
|
|
143
|
+
add("high", "SEQ_GAP",
|
|
144
|
+
"entry %d has seq=%r, expected %d" % (i + 1, e.get("seq"), i + 1))
|
|
145
|
+
if e.get("hash") != _entry_hash(e):
|
|
146
|
+
add("high", "HASH_MISMATCH",
|
|
147
|
+
"entry %d does not hash to its own recorded hash "
|
|
148
|
+
"(the entry body was edited)" % (i + 1))
|
|
149
|
+
want = entries[i - 1]["hash"] if i else None
|
|
150
|
+
if e.get("prev") != want:
|
|
151
|
+
add("high", "BROKEN_LINK",
|
|
152
|
+
"entry %d points at prev=%r but the previous entry's hash is %r"
|
|
153
|
+
% (i + 1, e.get("prev"), want))
|
|
154
|
+
|
|
155
|
+
regs = [e for e in entries if e.get("kind") == "register"]
|
|
156
|
+
if not regs:
|
|
157
|
+
add("high", "NOT_REGISTERED",
|
|
158
|
+
"no register entry -- checks were never frozen before the run")
|
|
159
|
+
else:
|
|
160
|
+
frozen = regs[-1].get("commitments_sha256")
|
|
161
|
+
live = commitments_hash(root)
|
|
162
|
+
if frozen != live:
|
|
163
|
+
add("high", "COMMITMENTS_MODIFIED",
|
|
164
|
+
"commitments file hash is %s but %s was frozen at seq=%s; "
|
|
165
|
+
"every verdict recorded after that point is void"
|
|
166
|
+
% (live[:12], (frozen or "?")[:12], regs[-1].get("seq")))
|
|
167
|
+
else:
|
|
168
|
+
add("info", "COMMITMENTS_FROZEN",
|
|
169
|
+
"commitments unchanged since seq=%s (%s)"
|
|
170
|
+
% (regs[-1].get("seq"), (frozen or "")[:12]))
|
|
171
|
+
|
|
172
|
+
ok = not any(f["level"] == "high" for f in findings)
|
|
173
|
+
return {"ok": ok, "entries": len(entries), "findings": findings}
|
precheck/runtime.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Running a check command and capturing its verdict."""
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def run_check(cmd, cwd=".", timeout=600):
|
|
6
|
+
"""Run one check command through the shell.
|
|
7
|
+
|
|
8
|
+
Returns (exit_code, output). exit_code is None when the check timed out --
|
|
9
|
+
that is deliberately distinct from a non-zero failure, because "did not
|
|
10
|
+
finish" and "said no" are different facts.
|
|
11
|
+
"""
|
|
12
|
+
try:
|
|
13
|
+
p = subprocess.run(cmd, shell=True, cwd=cwd, timeout=timeout,
|
|
14
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
15
|
+
return p.returncode, p.stdout.decode("utf-8", "replace")
|
|
16
|
+
except subprocess.TimeoutExpired:
|
|
17
|
+
return None, "TIMEOUT after %ss" % timeout
|
|
18
|
+
except OSError as e:
|
|
19
|
+
return 127, "could not start check: %s" % e
|
precheck/vacuity.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Vacuity audit -- a check that cannot fail proves nothing.
|
|
2
|
+
|
|
3
|
+
Settle answers "did the check pass?". This module answers the harder
|
|
4
|
+
question: "could it have failed at all?"
|
|
5
|
+
|
|
6
|
+
For each declared artifact we apply a small, deterministic mutation
|
|
7
|
+
(drop a line, blank a value, change a number, truncate a file), re-run the
|
|
8
|
+
check, and restore the file byte-for-byte. A check that still passes on
|
|
9
|
+
mutated input did not distinguish good from broken.
|
|
10
|
+
|
|
11
|
+
Findings here are QUESTIONS, not accusations. A surviving mutation is often
|
|
12
|
+
legitimate: the mutated line may be irrelevant to that particular claim.
|
|
13
|
+
The tool reports what it saw and leaves the judgement to a human -- it never
|
|
14
|
+
labels a check "useless" on its own.
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import random
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
MUTATIONS = ("drop-line", "blank-value", "flip-number", "truncate")
|
|
22
|
+
|
|
23
|
+
_FIELD = re.compile(r"^(\s*[\w.\-]+\s*[:=]\s*)(.+)$", re.M)
|
|
24
|
+
_STRING = re.compile(r'"[^"\n]*"')
|
|
25
|
+
_NUMBER = re.compile(r"(?<![\w.])-?\d+(?:\.\d+)?(?![\w.])")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def decode(data: bytes):
|
|
29
|
+
"""Text of a file, or None if it is not text (we never mutate binary)."""
|
|
30
|
+
try:
|
|
31
|
+
return data.decode("utf-8")
|
|
32
|
+
except UnicodeDecodeError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _blank_like(value):
|
|
37
|
+
"""Neuter a scalar: empty, zero, false -- but keep its type."""
|
|
38
|
+
if isinstance(value, bool):
|
|
39
|
+
return False
|
|
40
|
+
if isinstance(value, (int, float)):
|
|
41
|
+
return 0
|
|
42
|
+
if isinstance(value, str):
|
|
43
|
+
return ""
|
|
44
|
+
if isinstance(value, list):
|
|
45
|
+
return []
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
return {}
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _pick_json_scalar(obj, rng, path=()):
|
|
52
|
+
"""Choose one leaf of a JSON document, deterministically."""
|
|
53
|
+
leaves = []
|
|
54
|
+
|
|
55
|
+
def walk(node, cur):
|
|
56
|
+
if isinstance(node, dict):
|
|
57
|
+
for k, v in node.items():
|
|
58
|
+
if isinstance(v, (dict, list)):
|
|
59
|
+
walk(v, cur + (k,))
|
|
60
|
+
else:
|
|
61
|
+
leaves.append((cur, k))
|
|
62
|
+
elif isinstance(node, list):
|
|
63
|
+
for i, v in enumerate(node):
|
|
64
|
+
if isinstance(v, (dict, list)):
|
|
65
|
+
walk(v, cur + (i,))
|
|
66
|
+
else:
|
|
67
|
+
leaves.append((cur, i))
|
|
68
|
+
|
|
69
|
+
walk(obj, ())
|
|
70
|
+
if not leaves:
|
|
71
|
+
return None
|
|
72
|
+
parent_path, key = rng.choice(leaves)
|
|
73
|
+
parent = obj
|
|
74
|
+
for step in parent_path:
|
|
75
|
+
parent = parent[step]
|
|
76
|
+
return parent, key
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def mutate(kind: str, text: str, rng: random.Random):
|
|
80
|
+
"""Apply one mutation.
|
|
81
|
+
|
|
82
|
+
Returns (mutated_text, detail) or (None, None) if not applicable.
|
|
83
|
+
`detail` is the human-readable change, e.g. "replicas: 3 -> 0" -- it is the
|
|
84
|
+
whole point of the audit, because it is what a reader can act on.
|
|
85
|
+
"""
|
|
86
|
+
lines = text.split("\n")
|
|
87
|
+
filled = [i for i, l in enumerate(lines) if l.strip()]
|
|
88
|
+
|
|
89
|
+
if kind == "drop-line":
|
|
90
|
+
if not filled:
|
|
91
|
+
return None, None
|
|
92
|
+
i = rng.choice(filled)
|
|
93
|
+
detail = "dropped line %d: %s" % (i + 1, lines[i].strip()[:60])
|
|
94
|
+
del lines[i]
|
|
95
|
+
return "\n".join(lines), detail
|
|
96
|
+
|
|
97
|
+
if kind == "truncate":
|
|
98
|
+
if len(filled) < 2:
|
|
99
|
+
return None, None
|
|
100
|
+
keep = max(1, len(lines) // 2)
|
|
101
|
+
return ("\n".join(lines[:keep]) + "\n",
|
|
102
|
+
"truncated to the first %d of %d lines" % (keep, len(lines)))
|
|
103
|
+
|
|
104
|
+
if kind == "blank-value":
|
|
105
|
+
try:
|
|
106
|
+
obj = json.loads(text)
|
|
107
|
+
except ValueError:
|
|
108
|
+
obj = None
|
|
109
|
+
if isinstance(obj, (dict, list)):
|
|
110
|
+
target = _pick_json_scalar(obj, rng)
|
|
111
|
+
if target is None:
|
|
112
|
+
return None, None
|
|
113
|
+
parent, key = target
|
|
114
|
+
old = parent[key]
|
|
115
|
+
parent[key] = _blank_like(old)
|
|
116
|
+
return (json.dumps(obj, ensure_ascii=False, indent=2) + "\n",
|
|
117
|
+
"%s: %r -> %r" % (key, old, parent[key]))
|
|
118
|
+
hits = list(_FIELD.finditer(text))
|
|
119
|
+
if hits:
|
|
120
|
+
h = rng.choice(hits)
|
|
121
|
+
return (text[:h.start(2)] + '""' + text[h.end(2):],
|
|
122
|
+
"%s: %r -> \"\"" % (h.group(1).strip(), h.group(2)[:40]))
|
|
123
|
+
hits = list(_STRING.finditer(text))
|
|
124
|
+
if hits:
|
|
125
|
+
h = rng.choice(hits)
|
|
126
|
+
return (text[:h.start()] + '""' + text[h.end():],
|
|
127
|
+
"%s -> \"\"" % h.group(0)[:40])
|
|
128
|
+
return None, None
|
|
129
|
+
|
|
130
|
+
if kind == "flip-number":
|
|
131
|
+
hits = list(_NUMBER.finditer(text))
|
|
132
|
+
if not hits:
|
|
133
|
+
return None, None
|
|
134
|
+
h = rng.choice(hits)
|
|
135
|
+
raw = h.group(0)
|
|
136
|
+
try:
|
|
137
|
+
value = float(raw) if "." in raw else int(raw)
|
|
138
|
+
except ValueError:
|
|
139
|
+
return None, None
|
|
140
|
+
new = value + (1 if value == 0 else (7 if value > 0 else -7))
|
|
141
|
+
new_text = text[:h.start()] + str(new) + text[h.end():]
|
|
142
|
+
start = text.rfind("\n", 0, h.start()) + 1
|
|
143
|
+
end = text.find("\n", h.start())
|
|
144
|
+
if end == -1:
|
|
145
|
+
end = len(text)
|
|
146
|
+
delta = len(str(new)) - len(raw)
|
|
147
|
+
return (new_text,
|
|
148
|
+
"%s -> %s" % (text[start:end].strip()[:60],
|
|
149
|
+
new_text[start:end + delta].strip()[:60]))
|
|
150
|
+
|
|
151
|
+
return None, None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def audit(root, commitments, mutations=MUTATIONS, seed=0, timeout=600,
|
|
155
|
+
runner=None, on_event=None):
|
|
156
|
+
"""Mutate each declared artifact, re-run each check, restore, report.
|
|
157
|
+
|
|
158
|
+
A check that exits 0 on mutated input is recorded as ESCAPED: we could
|
|
159
|
+
not make it say no, so it is not yet evidence for the claim.
|
|
160
|
+
"""
|
|
161
|
+
if runner is None:
|
|
162
|
+
from .runtime import run_check as runner
|
|
163
|
+
|
|
164
|
+
escaped = []
|
|
165
|
+
examined = 0
|
|
166
|
+
skipped = []
|
|
167
|
+
|
|
168
|
+
for c in commitments.get("commitments", []):
|
|
169
|
+
cid = c.get("id") or c.get("statement", "?")[:24]
|
|
170
|
+
for art in c.get("artifacts", []):
|
|
171
|
+
path = os.path.join(root, art)
|
|
172
|
+
if not os.path.isfile(path):
|
|
173
|
+
skipped.append({"id": cid, "artifact": art,
|
|
174
|
+
"why": "artifact not found"})
|
|
175
|
+
continue
|
|
176
|
+
with open(path, "rb") as f:
|
|
177
|
+
original = f.read()
|
|
178
|
+
text = decode(original)
|
|
179
|
+
if text is None:
|
|
180
|
+
skipped.append({"id": cid, "artifact": art,
|
|
181
|
+
"why": "not a text file"})
|
|
182
|
+
continue
|
|
183
|
+
for kind in mutations:
|
|
184
|
+
rng = random.Random("%s|%s|%s|%s" % (seed, cid, art, kind))
|
|
185
|
+
mutated, detail = mutate(kind, text, rng)
|
|
186
|
+
if mutated is None:
|
|
187
|
+
continue
|
|
188
|
+
examined += 1
|
|
189
|
+
try:
|
|
190
|
+
with open(path, "w", encoding="utf-8", newline="") as f:
|
|
191
|
+
f.write(mutated)
|
|
192
|
+
code, out = runner(c["check"], cwd=root, timeout=timeout)
|
|
193
|
+
finally:
|
|
194
|
+
with open(path, "wb") as f:
|
|
195
|
+
f.write(original)
|
|
196
|
+
if on_event:
|
|
197
|
+
on_event(cid, art, kind, code, detail)
|
|
198
|
+
if code == 0:
|
|
199
|
+
escaped.append({
|
|
200
|
+
"id": cid, "artifact": art, "mutation": kind,
|
|
201
|
+
"detail": detail,
|
|
202
|
+
"statement": c.get("statement", ""),
|
|
203
|
+
"check": c["check"],
|
|
204
|
+
"question": ("%s -- and the check still passed. Either "
|
|
205
|
+
"that line does not matter to the claim, "
|
|
206
|
+
"or the check would pass on broken input."
|
|
207
|
+
% detail),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
return {"examined": examined, "escaped": escaped, "skipped": skipped}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: precheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Make an agent prove its claims with checks it was not allowed to write. The check is frozen before the run, and audited for the ability to fail at all.
|
|
5
|
+
Author: Simin Yuan
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/simin-yuan/precheck
|
|
8
|
+
Project-URL: Issues, https://github.com/simin-yuan/precheck/issues
|
|
9
|
+
Keywords: agents,ai-agents,verification,audit,mutation-testing,llm,ci,trust,evidence
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="assets/banner.svg" alt="precheck" width="880">
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<h1 align="center">precheck</h1>
|
|
26
|
+
|
|
27
|
+
<p align="center"><b>Make an agent prove its claims with checks it was not allowed to write.</b></p>
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
<a href="https://github.com/simin-yuan/precheck/actions/workflows/tests.yml"><img alt="tests" src="https://github.com/simin-yuan/precheck/actions/workflows/tests.yml/badge.svg"></a>
|
|
31
|
+
<img alt="license" src="https://img.shields.io/badge/license-MIT-blue">
|
|
32
|
+
<img alt="python" src="https://img.shields.io/badge/python-3.8%2B-blue">
|
|
33
|
+
<img alt="dependencies" src="https://img.shields.io/badge/dependencies-0-brightgreen">
|
|
34
|
+
<img alt="network" src="https://img.shields.io/badge/network-none-lightgrey">
|
|
35
|
+
</p>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
An agent finishes and reports success. Today you can believe it, or re-read
|
|
40
|
+
every diff yourself. `precheck` is a third option.
|
|
41
|
+
|
|
42
|
+
It does two things a test runner does not:
|
|
43
|
+
|
|
44
|
+
1. **It freezes the check before the run.** The acceptance check is written and
|
|
45
|
+
hash-locked *before* the work happens, so the actor cannot tailor the test to
|
|
46
|
+
whatever it ended up producing. Edit the check afterwards and every verdict
|
|
47
|
+
after that point is void — provably, not by convention.
|
|
48
|
+
2. **It asks whether the check could have failed at all.** After a check passes,
|
|
49
|
+
`precheck` mutates the artifact it was judging and runs it again. A check that
|
|
50
|
+
still passes on broken input was never evidence for anything.
|
|
51
|
+
|
|
52
|
+
## What that looks like
|
|
53
|
+
|
|
54
|
+
Every line below is real output from `python demo.py` in this repository. (The
|
|
55
|
+
trailing `(exit N)` annotations it prints, and its temp-directory line, are
|
|
56
|
+
omitted here.)
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
$ precheck register
|
|
60
|
+
froze 1 commitment(s) at seq=1 sha256=e2d4561aa9f0
|
|
61
|
+
|
|
62
|
+
$ precheck settle
|
|
63
|
+
PASS the deployment config is valid and safe to ship
|
|
64
|
+
|
|
65
|
+
1/1 passed (seq=2)
|
|
66
|
+
|
|
67
|
+
$ precheck audit
|
|
68
|
+
ran 4 mutation(s) across the declared artefacts
|
|
69
|
+
|
|
70
|
+
2 check/mutation pair(s) survived -- these prove nothing yet:
|
|
71
|
+
? C1 [blank-value on config.json]
|
|
72
|
+
replicas: 3 -> 0
|
|
73
|
+
the check still exited 0
|
|
74
|
+
? C1 [flip-number on config.json]
|
|
75
|
+
"replicas": 3, -> "replicas": 10,
|
|
76
|
+
the check still exited 0
|
|
77
|
+
|
|
78
|
+
This is a question list, not a bug list. Some survivors are legitimate.
|
|
79
|
+
|
|
80
|
+
$ precheck verify
|
|
81
|
+
. commitments unchanged since seq=1 (e2d4561aa9f0)
|
|
82
|
+
|
|
83
|
+
3 entries; chain consistent
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The check caught a broken file. It did not catch a config that would take
|
|
87
|
+
production down: `replicas: 3` became `0` and the check still said yes. That is
|
|
88
|
+
the failure mode this tool exists for.
|
|
89
|
+
|
|
90
|
+
## Install
|
|
91
|
+
|
|
92
|
+
Not on PyPI yet. Two ways to use it today, both of which work right now:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
# install straight from the repository
|
|
96
|
+
pip install git+https://github.com/simin-yuan/precheck.git
|
|
97
|
+
|
|
98
|
+
# or run it in place -- it is pure standard library, no install needed
|
|
99
|
+
git clone https://github.com/simin-yuan/precheck
|
|
100
|
+
cd precheck && python demo.py
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Zero runtime dependencies, standard library only, no network calls.
|
|
104
|
+
Python 3.8+.
|
|
105
|
+
|
|
106
|
+
## Use it
|
|
107
|
+
|
|
108
|
+
Three commands you run in order, and one anyone can run afterwards.
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
precheck init # writes .precheck/commitments.json
|
|
112
|
+
# ... edit it: state the claim, the check, and the artifacts it is about
|
|
113
|
+
precheck register # freeze it -- do this BEFORE the work runs
|
|
114
|
+
# ... whatever produces the artifact runs here ...
|
|
115
|
+
precheck settle # run the frozen checks, record PASS / FAIL / TIMEOUT
|
|
116
|
+
precheck audit # mutate the artifacts; find checks that cannot fail
|
|
117
|
+
precheck verify # walk the hash chain; detect edited history
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`settle` exits `2` on any failure. `audit --strict` exits `3` if a check survived
|
|
121
|
+
a mutation. `verify` exits `1` if the chain is broken. All three drop straight
|
|
122
|
+
into CI.
|
|
123
|
+
|
|
124
|
+
### As a GitHub Action
|
|
125
|
+
|
|
126
|
+
```yaml
|
|
127
|
+
jobs:
|
|
128
|
+
verify-the-agent:
|
|
129
|
+
runs-on: ubuntu-latest
|
|
130
|
+
steps:
|
|
131
|
+
- uses: actions/checkout@v4
|
|
132
|
+
- uses: simin-yuan/precheck@main
|
|
133
|
+
with:
|
|
134
|
+
command: audit # settle, then mutate and re-run
|
|
135
|
+
strict: "true" # fail the job if a check could not fail
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Put `precheck register` in the job *before* the step that produces the artifact.
|
|
139
|
+
A check frozen after the fact is reported as `NOT_REGISTERED`, and `verify` fails.
|
|
140
|
+
|
|
141
|
+
### The commitments file
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"version": 1,
|
|
146
|
+
"commitments": [
|
|
147
|
+
{
|
|
148
|
+
"id": "C1",
|
|
149
|
+
"statement": "the deployment config is valid and safe to ship",
|
|
150
|
+
"check": "python check_config.py",
|
|
151
|
+
"artifacts": ["config.json"]
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`check` is any shell command whose exit code decides the claim. `artifacts` are
|
|
158
|
+
the files that claim is about — the audit mutates those.
|
|
159
|
+
|
|
160
|
+
### Mutations
|
|
161
|
+
|
|
162
|
+
`drop-line` · `blank-value` · `flip-number` · `truncate`
|
|
163
|
+
|
|
164
|
+
Deterministic given `--seed`, so a survivor is reproducible and can be argued
|
|
165
|
+
about. Binary files are never mutated.
|
|
166
|
+
|
|
167
|
+
## Why not just use pytest?
|
|
168
|
+
|
|
169
|
+
`pytest` asks *is the code right?* `precheck` asks *is your proof right?* They
|
|
170
|
+
are different questions, and the second one currently has no tooling:
|
|
171
|
+
|
|
172
|
+
- `pytest` has no opinion on who wrote the test, or when. `precheck` refuses a
|
|
173
|
+
check that was written after the result it judges.
|
|
174
|
+
- `pytest` cannot tell you that a passing test would also pass on a broken
|
|
175
|
+
file. `precheck` mutates and re-runs to find out.
|
|
176
|
+
- `pytest` results live in your terminal. `precheck` records them in a chain a
|
|
177
|
+
third party can verify without trusting you.
|
|
178
|
+
|
|
179
|
+
You keep using pytest. You point `precheck` at it.
|
|
180
|
+
|
|
181
|
+
## How the tamper-evidence works
|
|
182
|
+
|
|
183
|
+
Each ledger entry is canonical JSON (sorted keys, fixed separators), and holds
|
|
184
|
+
`prev` — the previous entry's sha256 — plus its own hash. Editing any byte of any
|
|
185
|
+
entry re-hashes to something different, and deleting an entry breaks the link
|
|
186
|
+
after it. `verify` re-walks the whole file and reports:
|
|
187
|
+
|
|
188
|
+
| finding | meaning |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `HASH_MISMATCH` | an entry's body was edited |
|
|
191
|
+
| `BROKEN_LINK` | an entry was deleted, or its predecessor was replaced |
|
|
192
|
+
| `SEQ_GAP` | entries were removed from the middle |
|
|
193
|
+
| `COMMITMENTS_MODIFIED` | the check was rewritten after it was frozen |
|
|
194
|
+
| `NOT_REGISTERED` | checks were never frozen at all |
|
|
195
|
+
|
|
196
|
+
Commit `.precheck/` with your code. That is the point — a reader can re-run
|
|
197
|
+
`precheck verify` on your repository and see for themselves.
|
|
198
|
+
|
|
199
|
+
## Limits — read this before you trust it
|
|
200
|
+
|
|
201
|
+
- **A surviving mutation is a question, not a bug.** The mutated line may be
|
|
202
|
+
genuinely irrelevant to the claim. `precheck` will not tell you which; a human
|
|
203
|
+
decides.
|
|
204
|
+
- **Surviving every mutation is not proof that a check is vacuous.** The
|
|
205
|
+
mutations are a fixed sample of four. A check can be useless in ways this
|
|
206
|
+
sample never touches.
|
|
207
|
+
- **The chain proves history was not edited after the fact. It does not prove
|
|
208
|
+
the first entry was honest.** Whoever writes the first `register` entry can
|
|
209
|
+
still write a weak check. Pre-registration raises the cost of gaming the
|
|
210
|
+
result; it does not make gaming impossible.
|
|
211
|
+
- **A weak artifact list defeats it.** If you declare no artifacts, there is
|
|
212
|
+
nothing to mutate and `audit` will honestly report that it examined nothing.
|
|
213
|
+
Declare the files the claim is actually about.
|
|
214
|
+
- **Exit codes only.** `precheck` has no idea what your check *means*. A check
|
|
215
|
+
that prints a lie and exits 0 is a check that passes.
|
|
216
|
+
|
|
217
|
+
## Status
|
|
218
|
+
|
|
219
|
+
Alpha. 19 unit tests over the chain, the freeze, and the audit; a runnable
|
|
220
|
+
end-to-end demo; no dependencies. The audit's mutation set is deliberately small
|
|
221
|
+
and readable — adding mutations that you cannot explain to a reader would make
|
|
222
|
+
the output less trustworthy, not more.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT © 2026 Simin Yuan
|
|
227
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
precheck/__init__.py,sha256=5Je6kXewdbhaKP8U3_ZV9rCdwXM7GsrkGnE6CI6MFRo,193
|
|
2
|
+
precheck/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
|
|
3
|
+
precheck/cli.py,sha256=GBOwjlnMIOX6HGST2cQYIo_oQ3VaV0vf4Z5PQf0YoSM,7962
|
|
4
|
+
precheck/core.py,sha256=-sE-S8hMLjm4DZmwCpTlNccpTjk3bA3lWXwIbYli6dE,5991
|
|
5
|
+
precheck/runtime.py,sha256=JVHwfYfP_c_0aZDW71SjnO_mc8pC-r11Iq5-lIjVXNI,765
|
|
6
|
+
precheck/vacuity.py,sha256=ACHxu4C2m8MUXdh3hhSCTVyohkjL-AWZcfgvrdWCWZE,7541
|
|
7
|
+
precheck-0.1.0.dist-info/licenses/LICENSE,sha256=tUi9TGPPA2R_6XEKLiH99YG2h-nCZCI4jO0aQj2RrN4,1067
|
|
8
|
+
precheck-0.1.0.dist-info/METADATA,sha256=JFqxZTr-El8yemtjMBIj45uDntnjAwJWINulYXt6sjg,8384
|
|
9
|
+
precheck-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
precheck-0.1.0.dist-info/entry_points.txt,sha256=gz5Sm9P59qYqSUpLtzgv0MjXW0KAMZoJbZKhnbdC8GA,47
|
|
11
|
+
precheck-0.1.0.dist-info/top_level.txt,sha256=oNtdDWmkxOPJtmWpo2p0gQnarhS0hvQukgybIWIre60,9
|
|
12
|
+
precheck-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simin Yuan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
precheck
|