scrypto-audit-kit 0.5.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.
@@ -0,0 +1,56 @@
1
+ """scrypto-audit-kit — the deterministic Python core as an importable library.
2
+
3
+ `pip install scrypto-audit-kit` makes this package importable:
4
+
5
+ from scrypto_audit_kit import static_analysis, sak_lib, attest, gen_tests
6
+
7
+ findings = static_analysis.analyze_package("path/to/your/scrypto/package")
8
+ verdict = sak_lib.gate_verdict({"findings": findings}, fail_on="high")
9
+
10
+ The deterministic tools (static_analysis, gen_tests, attest) and helpers (sak_lib) need
11
+ no API key and no toolchain. The full LLM audit (audit.sh) and the MCP server's
12
+ audit_package tool additionally need a kit clone + aider + ANTHROPIC_API_KEY — see
13
+ docs/sdk.md for what runs where.
14
+
15
+ Implementation note: the modules in this directory cross-import by bare name
16
+ (`import sak_lib`) — the historical layout, which keeps a bare `git clone` runnable with
17
+ no install. So that those bare imports resolve when the directory is imported as the
18
+ `scrypto_audit_kit` package, we put the package directory on sys.path here. Converting the
19
+ cross-imports to relative imports is tracked for a future release (see ROADMAP.md).
20
+ """
21
+ import os as _os
22
+ import sys as _sys
23
+
24
+ _PKG_DIR = _os.path.dirname(_os.path.abspath(__file__))
25
+ if _PKG_DIR not in _sys.path:
26
+ _sys.path.insert(0, _PKG_DIR)
27
+
28
+
29
+ def _read_version():
30
+ """Kit version: the VERSION file (clone / editable install), else installed metadata."""
31
+ version_file = _os.path.join(_os.path.dirname(_PKG_DIR), "VERSION")
32
+ try:
33
+ with open(version_file, encoding="utf-8") as fh:
34
+ return fh.read().strip()
35
+ except OSError:
36
+ pass
37
+ try:
38
+ from importlib.metadata import PackageNotFoundError, version
39
+ try:
40
+ return version("scrypto-audit-kit")
41
+ except PackageNotFoundError:
42
+ return "unknown"
43
+ except ImportError:
44
+ return "unknown"
45
+
46
+
47
+ __version__ = _read_version()
48
+
49
+ # Re-export the deterministic, no-API-key core. These bind the same module objects the
50
+ # internal cross-imports use, so `scrypto_audit_kit.sak_lib` is the one the tools call.
51
+ import sak_lib as sak_lib # noqa: E402
52
+ import static_analysis as static_analysis # noqa: E402
53
+ import attest as attest # noqa: E402
54
+ import gen_tests as gen_tests # noqa: E402
55
+
56
+ __all__ = ["sak_lib", "static_analysis", "attest", "gen_tests", "__version__"]
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """Turn a pre-audit report.json into an on-chain attestation payload + a Radix manifest.
3
+
4
+ The bridge from a kit run to the L3 attestation registry (see attestation/). It reads the
5
+ report's source_hash, hashes the report (and optionally the built wasm), counts findings by
6
+ severity, derives the level, and renders a transaction manifest that calls `attest(...)` on a
7
+ deployed registry component. Stdlib only. Importable + CLI; also the attestation_payload MCP tool.
8
+ """
9
+ import argparse
10
+ import hashlib
11
+ import json
12
+ import sys
13
+
14
+ import sak_lib
15
+
16
+
17
+ def _sha256_file(path):
18
+ h = hashlib.sha256()
19
+ with open(path, "rb") as fh:
20
+ for chunk in iter(lambda: fh.read(65536), b""):
21
+ h.update(chunk)
22
+ return h.hexdigest()
23
+
24
+
25
+ def _u16(n):
26
+ """Clamp a count to the u16 the on-chain field accepts (so >65535 findings can't break it)."""
27
+ try:
28
+ return max(0, min(int(n), 65535))
29
+ except (TypeError, ValueError):
30
+ return 0
31
+
32
+
33
+ def _derive_level(report):
34
+ """L1-static when only the static tier ran, else L2-hybrid. Derived from the harness-stamped
35
+ model (authoritative), not from whether findings happen to be tagged — so a clean full run
36
+ (0 findings) is still L2, not a spurious downgrade."""
37
+ model = str(report.get("kit", {}).get("model", "")).lower()
38
+ return "L1-static" if "static-only" in model else "L2-hybrid"
39
+
40
+
41
+ def build_payload(report_path, wasm_path="", level=""):
42
+ """Compute the AttestationInput payload (the fields the registry's attest() expects)."""
43
+ report = sak_lib.load_report(report_path)
44
+ counts = sak_lib.severity_counts(report.get("findings", []))
45
+ kit = report.get("kit", {})
46
+ return {
47
+ "source_hash": report.get("target", {}).get("source_hash", ""),
48
+ "report_hash": _sha256_file(report_path),
49
+ "wasm_hash": _sha256_file(wasm_path) if wasm_path else "",
50
+ "kit_version": kit.get("version", "unknown"),
51
+ "checklist_version": kit.get("checklist_version", "unknown"),
52
+ "level": level or _derive_level(report),
53
+ "critical": _u16(counts.get("critical", 0)),
54
+ "high": _u16(counts.get("high", 0)),
55
+ "medium": _u16(counts.get("medium", 0)),
56
+ "low": _u16(counts.get("low", 0)),
57
+ "info": _u16(counts.get("info", 0)),
58
+ }
59
+
60
+
61
+ _STR_FIELDS = ["source_hash", "report_hash", "wasm_hash", "kit_version", "checklist_version", "level"]
62
+ _U16_FIELDS = ["critical", "high", "medium", "low", "info"]
63
+
64
+
65
+ def render_manifest(payload, component, account, fee="10"):
66
+ """Render a Radix transaction manifest calling attest(AttestationInput) + depositing the NFT."""
67
+ def quote(v):
68
+ s = str(v).replace("\\", "\\\\").replace('"', '\\"')
69
+ s = s.replace("\n", " ").replace("\r", " ") # no control chars in a manifest string literal
70
+ return '"' + s + '"'
71
+
72
+ rows = [f" {quote(payload[k])}," for k in _STR_FIELDS]
73
+ rows += [f" {int(payload[k])}u16," for k in _U16_FIELDS]
74
+ rows[-1] = rows[-1].rstrip(",") # the final tuple element takes no trailing comma
75
+ tuple_body = "\n".join(rows)
76
+ return f"""# scrypto-audit-kit attestation — generated; review before submitting.
77
+ # The account deposit method may need adjusting to your network's current API.
78
+ CALL_METHOD
79
+ Address("{account}")
80
+ "lock_fee"
81
+ Decimal("{fee}")
82
+ ;
83
+ CALL_METHOD
84
+ Address("{component}")
85
+ "attest"
86
+ Tuple(
87
+ {tuple_body}
88
+ )
89
+ ;
90
+ CALL_METHOD
91
+ Address("{account}")
92
+ "try_deposit_batch_or_abort"
93
+ Expression("ENTIRE_WORKTOP")
94
+ Enum<0u8>()
95
+ ;
96
+ """
97
+
98
+
99
+ def main():
100
+ ap = argparse.ArgumentParser(description="Attestation payload + manifest from a report.json.")
101
+ ap.add_argument("report", help="path to a report.json")
102
+ ap.add_argument("--wasm", default="", help="path to the built blueprint wasm to hash")
103
+ ap.add_argument("--level", default="", help="override the derived level (L1-static / L2-hybrid / ...)")
104
+ ap.add_argument("--component", default="", help="deployed attestation registry component address")
105
+ ap.add_argument("--account", default="", help="your account address (pays the fee, receives the NFT)")
106
+ ap.add_argument("--out-manifest", default="", help="write the manifest to this file")
107
+ ap.add_argument("--json", action="store_true", help="print the payload JSON (even if --component is given)")
108
+ args = ap.parse_args()
109
+
110
+ payload = build_payload(args.report, args.wasm, args.level)
111
+ sys.stderr.write(f"[attest] level {payload['level']} · {payload['critical']}C/{payload['high']}H · "
112
+ f"source {payload['source_hash'][:12]}\n")
113
+
114
+ if args.component and args.account and not args.json:
115
+ manifest = render_manifest(payload, args.component, args.account)
116
+ if args.out_manifest:
117
+ with open(args.out_manifest, "w", encoding="utf-8") as fh:
118
+ fh.write(manifest)
119
+ else:
120
+ sys.stdout.write(manifest)
121
+ else:
122
+ json.dump(payload, sys.stdout, indent=2)
123
+ sys.stdout.write("\n")
124
+ return 0
125
+
126
+
127
+ if __name__ == "__main__":
128
+ sys.exit(main())
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env python3
2
+ """Compatibility shim → ci_gate.main().
3
+
4
+ The gate logic lives in ci_gate.py (an importable, un-hyphenated module so it can back the
5
+ `sak-gate` console entry point). This hyphenated path is kept verbatim because the pre-audit
6
+ CI workflow (.github/workflows/pre-audit.yml), the docs, and tests/test_security.py invoke
7
+ `bin/ci-gate.py` directly — they all keep working unchanged.
8
+ """
9
+ import sys
10
+
11
+ from ci_gate import main # bin/ is sys.path[0] when this runs as a script
12
+
13
+ if __name__ == "__main__":
14
+ sys.exit(main())
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env python3
2
+ """CI gate: fail the build when a pre-audit report has findings at/above a severity.
3
+
4
+ Reads a report.json (or every *.json in a directory) and exits non-zero when any
5
+ finding's severity is >= --fail-on. Backs an honest "pre-audit passing" badge.
6
+ Stdlib only; shares its logic with the MCP server via sak_lib.
7
+
8
+ This is the importable, un-hyphenated home of the gate CLI (it backs the `sak-gate`
9
+ console entry point). The historical `bin/ci-gate.py` is a thin shim that calls main()
10
+ here, so existing invocations (the pre-audit CI workflow, tests) keep working.
11
+ """
12
+ import argparse
13
+ import sys
14
+
15
+ import sak_lib
16
+
17
+
18
+ def main():
19
+ ap = argparse.ArgumentParser(description=__doc__)
20
+ ap.add_argument("--reports", required=True, help="a report.json file or a directory of them")
21
+ ap.add_argument("--fail-on", default="high", help="none | critical | high | medium | low (default: high)")
22
+ ap.add_argument("--allow-missing", action="store_true",
23
+ help="pass when no report is found (default: fail closed)")
24
+ args = ap.parse_args()
25
+
26
+ # Fail CLOSED: a missing, unreadable, or malformed report must not pass the gate.
27
+ # Shares sak_lib.collect_findings with the MCP gate so both surfaces fail closed identically.
28
+ try:
29
+ findings = sak_lib.collect_findings(args.reports, allow_missing=args.allow_missing)
30
+ except sak_lib.GateError as exc:
31
+ print(f"::error::{exc} — failing closed (use --allow-missing to opt out)", file=sys.stderr)
32
+ return 1
33
+ if findings is None:
34
+ print(f"warn: no report.json at {args.reports} — passing (--allow-missing)", file=sys.stderr)
35
+ return 0
36
+
37
+ try:
38
+ verdict = sak_lib.gate_verdict({"findings": findings}, args.fail_on)
39
+ except ValueError as exc:
40
+ print(f"error: {exc}", file=sys.stderr)
41
+ return 2
42
+
43
+ print(f"pre-audit gate: {verdict['total']} findings "
44
+ f"({sak_lib.counts_summary(verdict['counts'])}); threshold = {verdict['fail_on']}")
45
+ if not verdict["passed"]:
46
+ print(f"::error::pre-audit gate failed — found {verdict['worst']} finding(s) "
47
+ f"at or above '{verdict['fail_on']}'")
48
+ return 1
49
+ print("pre-audit gate passed.")
50
+ return 0
51
+
52
+
53
+ if __name__ == "__main__":
54
+ sys.exit(main())
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env python3
2
+ """Split a pre-audit run into a clean markdown report + a machine-readable report.json.
3
+
4
+ The model emits a human markdown report followed by a fenced ```json block conforming
5
+ to schema/audit-report.schema.json. This script:
6
+
7
+ 1. pulls the LAST valid JSON block out of the raw output,
8
+ 2. stamps it with authoritative provenance (kit version, model, checklist version,
9
+ source hash, ...) — the harness knows these, the model does not,
10
+ 3. writes report.json,
11
+ 4. rewrites the markdown without the JSON appendix, with a one-line provenance header.
12
+
13
+ Soft-fails: if no valid JSON block is found, the markdown is still written and the script
14
+ exits 0 (the human report is never lost to a structured-output hiccup). Schema validation
15
+ is performed only if `jsonschema` is importable — it is never a hard dependency.
16
+
17
+ Stdlib only.
18
+ """
19
+ import argparse
20
+ import json
21
+ import re
22
+ import sys
23
+
24
+ import sak_lib
25
+
26
+ FENCE_RE = re.compile(r"```(?:json)?\s*\n(.*?)\n```", re.DOTALL)
27
+
28
+
29
+ def extract_json_blocks(text):
30
+ """Return (span, parsed_obj) for each fenced JSON block that parses, in document order."""
31
+ blocks = []
32
+ for m in FENCE_RE.finditer(text):
33
+ body = m.group(1).strip()
34
+ if not body.startswith("{"):
35
+ continue
36
+ try:
37
+ obj = json.loads(body)
38
+ except json.JSONDecodeError:
39
+ continue
40
+ blocks.append((m.span(), obj))
41
+ return blocks
42
+
43
+
44
+ def select_appendix(text, blocks, nonce):
45
+ """Pick the authoritative JSON block.
46
+
47
+ With a per-run `nonce`, a block is authentic ONLY if the `sak:nonce:<nonce>` marker is the
48
+ LAST non-whitespace content immediately before its opening fence — ADJACENCY, not a fuzzy
49
+ "within 200 chars" window. The old window let a small real block lend its marker to a LATER
50
+ attacker block (which then won as the last authed block); adjacency + scanning only the gap
51
+ since the previous block closes that. If MORE THAN ONE block authenticates, refuse (a run
52
+ carries exactly one) and prefer the FIRST otherwise. Without a nonce (static-only / legacy)
53
+ the last block is returned.
54
+
55
+ Returns (selected_block_or_None, authenticated, ambiguous).
56
+ """
57
+ if not nonce:
58
+ return (blocks[-1] if blocks else None), True, False
59
+ marker = f"sak:nonce:{nonce}"
60
+ authed = []
61
+ prev_end = 0
62
+ for span, obj in blocks:
63
+ start, end = span
64
+ preamble = text[prev_end:start] # only the gap since the previous block — no reach-back
65
+ prev_end = end
66
+ idx = preamble.rfind(marker)
67
+ if idx == -1:
68
+ continue
69
+ tail = preamble[idx + len(marker):].replace("-->", "", 1) # only the comment close may follow
70
+ if tail.strip() == "":
71
+ authed.append((span, obj))
72
+ if len(authed) > 1:
73
+ return None, False, True # ambiguous — refuse, fail safe
74
+ return (authed[0] if authed else None), bool(authed), False
75
+
76
+
77
+ def strip_json_appendix(text, span):
78
+ """Remove the JSON fenced block, plus the `<!-- machine-readable -->` marker and the
79
+ `---` rule that precede it, from the markdown."""
80
+ start, end = span
81
+ before = text[:start]
82
+ after = text[end:]
83
+ # the marker comment sits just above the block; the `---` rule sits just above that
84
+ before = re.sub(r"\s*<!--[^>]*?(?:machine-readable|sak:nonce:)[^>]*?-->\s*$", "\n", before, flags=re.IGNORECASE)
85
+ before = re.sub(r"\s*\n-{3,}[ \t]*$", "\n", before)
86
+ cleaned = before.rstrip() + "\n"
87
+ if after.strip():
88
+ cleaned += after.lstrip()
89
+ return cleaned
90
+
91
+
92
+ def soft_validate(obj, schema_path):
93
+ """Best-effort schema validation. Returns a list of human-readable problems (empty = ok/skipped)."""
94
+ try:
95
+ import jsonschema # type: ignore
96
+ except Exception:
97
+ return [] # not installed — skip silently
98
+ try:
99
+ with open(schema_path, encoding="utf-8") as fh:
100
+ schema = json.load(fh)
101
+ jsonschema.Draft202012Validator(schema).validate(obj)
102
+ return []
103
+ except FileNotFoundError:
104
+ return []
105
+ except jsonschema.ValidationError as exc: # type: ignore
106
+ return [exc.message]
107
+
108
+
109
+ def main():
110
+ ap = argparse.ArgumentParser(description=__doc__)
111
+ ap.add_argument("--raw", required=True, help="path to the raw markdown report (read)")
112
+ ap.add_argument("--out-json", required=True, help="path to write report.json")
113
+ ap.add_argument("--out-md", required=True, help="path to write the cleaned markdown (may equal --raw)")
114
+ ap.add_argument("--kit-version", default="unknown")
115
+ ap.add_argument("--model", default="unknown")
116
+ ap.add_argument("--checklist-version", default="unknown")
117
+ ap.add_argument("--reference-set", default="")
118
+ ap.add_argument("--repo", default="")
119
+ ap.add_argument("--package", default="")
120
+ ap.add_argument("--source-hash", default="")
121
+ ap.add_argument("--files", type=int, default=0)
122
+ ap.add_argument("--generated-at", default="")
123
+ ap.add_argument("--schema", default="", help="optional schema path for soft validation")
124
+ ap.add_argument("--static-json", default="", help="optional JSON file of static findings to merge in")
125
+ ap.add_argument("--nonce", default="", help="per-run nonce; only a JSON block carrying its marker is trusted")
126
+ args = ap.parse_args()
127
+
128
+ with open(args.raw, encoding="utf-8") as fh:
129
+ raw = fh.read()
130
+
131
+ static_findings = []
132
+ static_ran = False
133
+ if args.static_json:
134
+ try:
135
+ with open(args.static_json, encoding="utf-8") as fh:
136
+ static_findings = json.load(fh)
137
+ static_ran = True # the static pass ran (even if it found nothing) — a clean run is attestable
138
+ except (OSError, ValueError) as exc:
139
+ print(f"warn: could not read static findings {args.static_json}: {exc}", file=sys.stderr)
140
+
141
+ blocks = extract_json_blocks(raw)
142
+ selected, authenticated, ambiguous = select_appendix(raw, blocks, args.nonce)
143
+ if args.nonce and blocks and not authenticated:
144
+ # JSON block(s) present but none is uniquely nonce-authenticated (missing marker, or
145
+ # ambiguous — >1 authed). Possible injection: a blueprint getting the model to echo a
146
+ # forged block. Fail safe — never write report.json, and WITHHOLD the model's prose
147
+ # (a clean-looking narrative an operator might share) rather than emit it invisibly.
148
+ reason = "ambiguous nonce (more than one authenticated block)" if ambiguous else "no run nonce"
149
+ with open(args.out_md, "w", encoding="utf-8") as fh:
150
+ fh.write("# ⚠ Pre-audit REFUSED — possible prompt injection\n\n"
151
+ f"The model's JSON appendix was not authenticated by this run's nonce ({reason}). "
152
+ "A blueprint may have injected a forged 'all clear' block, so report.json was NOT "
153
+ "written and the model's prose is withheld. Re-run the audit.\n")
154
+ print(f"::error::SECURITY — the JSON appendix is not nonce-authenticated ({reason}); refusing it. "
155
+ "report.json NOT written; re-run the audit.", file=sys.stderr)
156
+ return 3
157
+
158
+ span = None
159
+ if selected is not None:
160
+ span, obj = selected
161
+ for f in obj.get("findings", []):
162
+ f.setdefault("source", "llm")
163
+ if static_findings: # hybrid run — merge static into the LLM findings
164
+ obj["findings"] = sak_lib.merge_findings(obj.get("findings", []), static_findings)
165
+ elif static_ran:
166
+ # No LLM block — assemble the report from the static pass alone (static-only tier). Build
167
+ # it even when the pass found NOTHING: a clean audit is a first-class, attestable result
168
+ # and the file-mode gate / L3 attestation both need a report.json to exist.
169
+ obj = sak_lib.build_report(static_findings)
170
+ else:
171
+ # Neither — keep the human report, skip JSON. Never fail the pipeline.
172
+ with open(args.out_md, "w", encoding="utf-8") as fh:
173
+ fh.write(raw if raw.endswith("\n") else raw + "\n")
174
+ print("warn: no machine-readable JSON block found — wrote markdown only", file=sys.stderr)
175
+ return 0
176
+
177
+ # Stamp authoritative provenance — overrides whatever the model put there.
178
+ obj["schema_version"] = "1.0"
179
+ kit = obj.setdefault("kit", {})
180
+ kit.update({
181
+ "version": args.kit_version,
182
+ "model": args.model,
183
+ "checklist_version": args.checklist_version,
184
+ "generated_at": args.generated_at,
185
+ })
186
+ if args.reference_set:
187
+ kit["reference_set"] = args.reference_set
188
+ target = obj.setdefault("target", {})
189
+ if args.repo:
190
+ target["repo"] = args.repo
191
+ if args.package:
192
+ target["package"] = args.package
193
+ if args.source_hash:
194
+ target["source_hash"] = args.source_hash
195
+ if args.files:
196
+ target["files_analyzed"] = args.files
197
+
198
+ problems = soft_validate(obj, args.schema) if args.schema else []
199
+ for p in problems:
200
+ print(f"warn: schema: {p}", file=sys.stderr)
201
+
202
+ with open(args.out_json, "w", encoding="utf-8") as fh:
203
+ json.dump(obj, fh, indent=2, ensure_ascii=False)
204
+ fh.write("\n")
205
+
206
+ # Cleaned markdown: the human body (minus the JSON appendix) + a static-findings section.
207
+ cleaned = strip_json_appendix(raw, span) if span is not None else (raw.rstrip() + "\n")
208
+ if static_findings:
209
+ cleaned = cleaned.rstrip() + "\n\n" + sak_lib.render_findings_md(
210
+ static_findings, "Static analysis (deterministic)")
211
+ header = (
212
+ f"<!-- scrypto-audit-kit {args.kit_version} · model {args.model} · "
213
+ f"checklist {args.checklist_version} · source {args.source_hash[:12]} · {args.generated_at} -->\n"
214
+ )
215
+ with open(args.out_md, "w", encoding="utf-8") as fh:
216
+ fh.write(header + cleaned)
217
+
218
+ findings = obj.get("findings", [])
219
+ by_sev = {}
220
+ for f in findings:
221
+ by_sev[f.get("severity", "?")] = by_sev.get(f.get("severity", "?"), 0) + 1
222
+ sev_str = ", ".join(f"{k}:{v}" for k, v in sorted(by_sev.items())) or "none"
223
+ extra = f"; {len(static_findings)} from static" if static_findings else ""
224
+ print(f"report.json: {len(findings)} findings ({sev_str}){extra}")
225
+ return 0
226
+
227
+
228
+ if __name__ == "__main__":
229
+ sys.exit(main())
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """Generate scrypto-test property-test scaffolds from a blueprint's surface.
3
+
4
+ Deterministic, no API. Reads the `enable_method_auth!` surface (roles + per-method gating)
5
+ and the struct's Vault fields, then emits compilable `#[test] #[ignore]` scaffolds with
6
+ `todo!()` bodies:
7
+
8
+ - auth negative-path tests for each role-restricted method (the most-missed coverage),
9
+ - happy-path tests for each public method,
10
+ - a value-conservation invariant test when the blueprint custodies vaults.
11
+
12
+ The scaffolds compile and are `#[ignore]`d, so they never break `cargo test`; the developer
13
+ (or an agent) removes `#[ignore]` and fills in each `todo!()`. The kit stays read-only — this
14
+ prints/returns the scaffolds, it never writes into the audited package.
15
+
16
+ Stdlib only; reuses the comment/string stripper from static_analysis. Importable + CLI.
17
+ """
18
+ import argparse
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+
24
+ import static_analysis
25
+
26
+ _GATING_RE = re.compile(r"(\w+)\s*=>\s*(PUBLIC|restrict_to:\s*\[\s*([\w, ]+?)\s*\])")
27
+ _IMPL_RE = re.compile(r"\bimpl\s+(\w+)")
28
+ _VAULT_RE = re.compile(r"(\w+)\s*:\s*(?:Vault|KeyValueStore<[^>]*\bVault\b[^>]*>)")
29
+
30
+
31
+ def _stripped_sources(pkg_dir):
32
+ src_dir = os.path.join(pkg_dir, "src")
33
+ base = src_dir if os.path.isdir(src_dir) else pkg_dir
34
+ chunks = []
35
+ for root, _dirs, files in os.walk(base):
36
+ for name in sorted(files):
37
+ if name.endswith(".rs"):
38
+ with open(os.path.join(root, name), encoding="utf-8", errors="replace") as fh:
39
+ chunks.append(static_analysis.strip_comments_and_strings(fh.read()))
40
+ return "\n".join(chunks)
41
+
42
+
43
+ def extract_surface(pkg_dir):
44
+ """Return {blueprint, methods:[{name,gating,roles}], roles:[...], vault_fields:[...]}."""
45
+ src = _stripped_sources(pkg_dir)
46
+
47
+ methods, roles = [], set()
48
+ for m in _GATING_RE.finditer(src):
49
+ name, gating = m.group(1), m.group(2)
50
+ if gating.startswith("PUBLIC"):
51
+ methods.append({"name": name, "gating": "public", "roles": []})
52
+ else:
53
+ mr = [r.strip() for r in m.group(3).split(",") if r.strip()]
54
+ roles.update(mr)
55
+ methods.append({"name": name, "gating": "restricted", "roles": mr})
56
+
57
+ impls = _IMPL_RE.findall(src)
58
+ blueprint = impls[0] if impls else "Blueprint"
59
+ vault_fields = sorted(set(_VAULT_RE.findall(src)))
60
+ return {"blueprint": blueprint, "methods": methods, "roles": sorted(roles), "vault_fields": vault_fields}
61
+
62
+
63
+ def propose_specs(surface):
64
+ """Turn a surface into an ordered list of property-test specs."""
65
+ specs = []
66
+ for meth in surface["methods"]:
67
+ if meth["gating"] == "restricted":
68
+ badge = " / ".join(meth["roles"]) or "required"
69
+ specs.append({
70
+ "name": f"{meth['name']}_rejects_unauthorized_caller",
71
+ "kind": "auth-negative",
72
+ "target": meth["name"],
73
+ "invariant": f"`{meth['name']}` must fail when called without the `{badge}` badge",
74
+ "hint": f"instantiate, then call {meth['name']} WITHOUT the {badge} badge and assert "
75
+ f"the call fails (an auth error)",
76
+ })
77
+ for meth in surface["methods"]:
78
+ if meth["gating"] == "public":
79
+ specs.append({
80
+ "name": f"{meth['name']}_happy_path",
81
+ "kind": "happy-path",
82
+ "target": meth["name"],
83
+ "invariant": f"`{meth['name']}` succeeds on a valid call and makes the expected state change",
84
+ "hint": f"instantiate, call {meth['name']} with valid inputs, assert the expected result/state",
85
+ })
86
+ if surface["vault_fields"]:
87
+ fields = ", ".join(surface["vault_fields"])
88
+ specs.append({
89
+ "name": "value_conservation_invariant",
90
+ "kind": "invariant",
91
+ "target": None,
92
+ "invariant": "custodied value is conserved across operations (no value created or lost)",
93
+ "hint": f"after each public call, assert the sum of vault balances ({fields}) matches the "
94
+ f"tracked totals — value is neither minted nor lost",
95
+ })
96
+ return specs
97
+
98
+
99
+ def render_rust(specs, surface):
100
+ """Render specs as a compilable, #[ignore]d scrypto-test scaffold file."""
101
+ out = [
102
+ "//! Property-test scaffolds generated by scrypto-audit-kit.",
103
+ f"//! Blueprint: {surface['blueprint']}. These compile and are #[ignore]d — remove the",
104
+ "//! #[ignore] and fill in each todo!() to turn a coverage gap into an executable check.",
105
+ "#![allow(unused)]",
106
+ "use scrypto_test::prelude::*;",
107
+ "",
108
+ ]
109
+ if not specs:
110
+ out.append("// No role-restricted methods, public methods, or vaults detected.")
111
+ out.append("")
112
+ return "\n".join(out)
113
+ for spec in specs:
114
+ out.append(f"/// {spec['invariant']}.")
115
+ out.append("#[test]")
116
+ out.append('#[ignore = "scaffold from scrypto-audit-kit — implement, then un-ignore"]')
117
+ out.append(f"fn {spec['name']}() {{")
118
+ out.append(f' todo!("{spec["hint"]}");')
119
+ out.append("}")
120
+ out.append("")
121
+ return "\n".join(out)
122
+
123
+
124
+ def propose_tests(pkg_dir):
125
+ """Top-level: returns {blueprint, count, specs, rust} for a package."""
126
+ surface = extract_surface(pkg_dir)
127
+ specs = propose_specs(surface)
128
+ return {
129
+ "blueprint": surface["blueprint"],
130
+ "count": len(specs),
131
+ "specs": specs,
132
+ "rust": render_rust(specs, surface),
133
+ }
134
+
135
+
136
+ def main():
137
+ ap = argparse.ArgumentParser(description="Generate scrypto-test property-test scaffolds.")
138
+ ap.add_argument("package", help="path to the Scrypto package (or a src dir)")
139
+ ap.add_argument("--json", action="store_true", help="print the specs as JSON instead of Rust")
140
+ ap.add_argument("--out", help="write the Rust scaffold to this file")
141
+ args = ap.parse_args()
142
+
143
+ result = propose_tests(args.package)
144
+ if args.json:
145
+ json.dump({k: result[k] for k in ("blueprint", "count", "specs")}, sys.stdout, indent=2)
146
+ sys.stdout.write("\n")
147
+ elif args.out:
148
+ with open(args.out, "w", encoding="utf-8") as fh:
149
+ fh.write(result["rust"])
150
+ else:
151
+ sys.stdout.write(result["rust"])
152
+ sys.stderr.write(f"[gen-tests] {result['count']} scaffold(s) for {result['blueprint']}\n")
153
+ return 0
154
+
155
+
156
+ if __name__ == "__main__":
157
+ sys.exit(main())