archkeep-rule-sdk 0.13.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,60 @@
1
+ """Author a Archkeep custom architecture rule in Python.
2
+
3
+ `import archkeep_rule_sdk` reaches the same names in both places a rule ever
4
+ runs: here, under the author's own CPython, and inside the rule's `.wasm`
5
+ carrier, where the build tool registers `runtime.py`'s namespace under this
6
+ name in an embedded interpreter that has no filesystem to import from.
7
+
8
+ Which is why this file re-exports rather than adds: every public name is
9
+ `runtime`'s, so the module an author imports is the module the artifact runs.
10
+ A helper defined HERE would exist in the tests and be missing in the artifact,
11
+ and the difference would surface as an `unknown` verdict in a consumer's CI
12
+ rather than as a red test — the silent direction, one import away
13
+ (`../../../../AGENTS.md`).
14
+ """
15
+
16
+ from .runtime import (
17
+ EVIDENCE_KINDS,
18
+ VERDICTS,
19
+ Edge,
20
+ Evidence,
21
+ Finding,
22
+ ImportRecord,
23
+ Policy,
24
+ Project,
25
+ Resolved,
26
+ RuleError,
27
+ RuleInstance,
28
+ Spelling,
29
+ UndeclaredEvidence,
30
+ Verdict,
31
+ drive,
32
+ failed,
33
+ from_findings,
34
+ not_applicable,
35
+ passed,
36
+ unknown,
37
+ )
38
+
39
+ __all__ = [
40
+ "EVIDENCE_KINDS",
41
+ "VERDICTS",
42
+ "Edge",
43
+ "Evidence",
44
+ "Finding",
45
+ "ImportRecord",
46
+ "Policy",
47
+ "Project",
48
+ "Resolved",
49
+ "RuleError",
50
+ "RuleInstance",
51
+ "Spelling",
52
+ "UndeclaredEvidence",
53
+ "Verdict",
54
+ "drive",
55
+ "failed",
56
+ "from_findings",
57
+ "not_applicable",
58
+ "passed",
59
+ "unknown",
60
+ ]
@@ -0,0 +1,336 @@
1
+ """Compile one Python rule into the core-WebAssembly artifact a policy pins.
2
+
3
+ python -m archkeep_rule_sdk.build rule.py --out rule.wasm
4
+
5
+ What it does, in order: reads the rule's declaration out of `rule.py`, writes a
6
+ carrier crate into a build directory, runs `cargo build` for
7
+ `wasm32-unknown-unknown`, and copies the resulting module out beside a `.sha256`
8
+ holding its digest — the exact string a `customRules` row's `sha256` field
9
+ takes.
10
+
11
+ ## Why there is a Rust toolchain in a Python SDK's build story
12
+
13
+ There is no Python toolchain that emits a core-wasm module with an empty import
14
+ section. Pyodide targets a JavaScript host; componentize-py emits a
15
+ component-model binary over WASI. The engine's host speaks neither and refuses
16
+ a module that declares any import at all, so the Python is CARRIED by a Rust
17
+ crate that embeds an interpreter — `carrier/lib.rs.template` argues that in
18
+ full. `cargo` and the `wasm32-unknown-unknown` target are therefore an
19
+ author-side build requirement, once, and nothing a workspace RUNNING the rule
20
+ needs: a consumer's `check` reads bytes.
21
+
22
+ ## What this tool deliberately does not check
23
+
24
+ The rule's declaration — the name's grammar, the finding ids, the catalogue's
25
+ shape — is validated by the carrier crate at COMPILE time, by the SDK's
26
+ `archkeep_rule!` macro. This file checks only that the pieces are there
27
+ (`ARCHKEEP_RULE` exists and states three keys, `evaluate` is callable), because
28
+ the grammar already has an owner on the Rust side and a second copy here would
29
+ be a third statement of one rule (`../../../../AGENTS.md`). What this file adds
30
+ is the FRAME: a cargo failure that came from the declaration is reported as a
31
+ declaration failure naming `rule.py`, not as compiler noise.
32
+ """
33
+
34
+ import argparse
35
+ import hashlib
36
+ import importlib.util
37
+ import os
38
+ import shutil
39
+ import subprocess
40
+ import sys
41
+ import tempfile
42
+ from pathlib import Path
43
+
44
+ #: The build directory's crate name. Fixed rather than derived from the rule
45
+ #: name, because a Cargo package name has its own grammar and mapping one onto
46
+ #: the other would be a second naming rule for a crate nobody publishes.
47
+ CRATE_NAME = "archkeep_rule_carrier"
48
+
49
+ #: The target the artifact is built for. Not `wasm32-wasip1`: WASI is a set of
50
+ #: imports, and the host refuses a module that declares any.
51
+ WASM_TARGET = "wasm32-unknown-unknown"
52
+
53
+ #: `getrandom` has no backend on `wasm32-unknown-unknown` unless one is named.
54
+ #: The carrier defines `__getrandom_v03_custom`; this is what makes the crate
55
+ #: look for it. Passed through `RUSTFLAGS` because a `cfg` is a compiler flag
56
+ #: and cannot be stated in a manifest.
57
+ GETRANDOM_BACKEND_FLAG = '--cfg getrandom_backend="custom"'
58
+
59
+ _CARRIER = Path(__file__).resolve().parent / "carrier"
60
+
61
+ # Reading a rule's declaration means importing the author's `rule.py`, and an
62
+ # import writes a `__pycache__` beside it. A build tool that leaves untracked
63
+ # directories in the tree it was pointed at is a build tool whose output nobody
64
+ # can `git add -A` safely, so this process writes none.
65
+ sys.dont_write_bytecode = True
66
+
67
+
68
+ class BuildError(Exception):
69
+ """A build that could not produce an artifact, with the reason named."""
70
+
71
+
72
+ def read_declaration(rule_path: Path):
73
+ """The rule's `ARCHKEEP_RULE` declaration and its `evaluate`, from its source.
74
+
75
+ Loaded by importing the module rather than by parsing it, and the reason is
76
+ that this is the author's own file on the author's own machine: `cargo` is
77
+ about to compile it into a binary either way, so refusing to execute it here
78
+ would buy no safety while costing the only check that catches a rule whose
79
+ module body raises.
80
+ """
81
+ if not rule_path.is_file():
82
+ raise BuildError(f"{rule_path} is not a file")
83
+
84
+ spec = importlib.util.spec_from_file_location("archkeep_rule_under_build", rule_path)
85
+ if spec is None or spec.loader is None:
86
+ raise BuildError(f"{rule_path} could not be loaded as a Python module")
87
+ module = importlib.util.module_from_spec(spec)
88
+ try:
89
+ spec.loader.exec_module(module)
90
+ # Broad on purpose: this is the author's own module body, which may raise
91
+ # anything at all, and what a caller needs is the file and the reason.
92
+ except Exception as error:
93
+ raise BuildError(f"{rule_path} raised while loading: {type(error).__name__}: {error}")
94
+
95
+ declaration = getattr(module, "ARCHKEEP_RULE", None)
96
+ if not isinstance(declaration, dict):
97
+ raise BuildError(
98
+ f"{rule_path} states no ARCHKEEP_RULE mapping — a rule declares its name, the "
99
+ f"evidence kinds it needs, and its findings catalogue beside the function that "
100
+ f"reads them"
101
+ )
102
+ missing = [key for key in ("name", "needs", "findings") if key not in declaration]
103
+ if missing:
104
+ raise BuildError(
105
+ f"{rule_path}'s ARCHKEEP_RULE states no {', '.join(missing)} — the three keys are "
106
+ f"what the artifact answers archkeep_describe with"
107
+ )
108
+ if not callable(getattr(module, "evaluate", None)):
109
+ raise BuildError(
110
+ f"{rule_path} defines no evaluate(evidence) function — the carrier reads it by name"
111
+ )
112
+ return declaration
113
+
114
+
115
+ def render_carrier(declaration, rule_path: Path, crate_dir: Path, sdk_dependency: str) -> None:
116
+ """Writes the carrier crate for one rule into `crate_dir`."""
117
+ source_dir = crate_dir / "src"
118
+ source_dir.mkdir(parents=True, exist_ok=True)
119
+
120
+ manifest = (_CARRIER / "Cargo.toml.template").read_text(encoding="utf-8")
121
+ manifest = manifest.replace("{{CRATE_NAME}}", CRATE_NAME)
122
+ manifest = manifest.replace("{{SDK_DEPENDENCY}}", sdk_dependency)
123
+ (crate_dir / "Cargo.toml").write_text(manifest, encoding="utf-8")
124
+
125
+ lib = (_CARRIER / "lib.rs.template").read_text(encoding="utf-8")
126
+ lib = lib.replace("{{RULE_NAME}}", str(declaration["name"]))
127
+ needs = [str(kind) for kind in declaration["needs"]]
128
+ # Twice, in two spellings. `{{NEEDS}}` is the bare-word list the
129
+ # `archkeep_rule!` macro takes; `{{NEEDS_WIRE}}` is the same kinds as Rust
130
+ # string literals, which the carrier hands the Python runtime so both
131
+ # runners agree on what the rule may read. One list, rendered for the two
132
+ # places that need it — never two lists to keep in step.
133
+ lib = lib.replace("{{NEEDS_WIRE}}", ", ".join(_rust_string(kind) for kind in needs))
134
+ lib = lib.replace("{{NEEDS}}", ", ".join(needs))
135
+ lib = lib.replace("{{FINDINGS}}", _findings_literal(declaration["findings"]))
136
+ (source_dir / "lib.rs").write_text(lib, encoding="utf-8")
137
+
138
+ # The runtime the artifact runs is the file the author's own tests import,
139
+ # copied rather than re-rendered: a template of it would be a second
140
+ # version of the runtime, and the two would agree only until one was edited.
141
+ shutil.copyfile(Path(__file__).resolve().parent / "runtime.py", source_dir / "archkeep_rule_sdk.py")
142
+ shutil.copyfile(rule_path, source_dir / "rule.py")
143
+
144
+
145
+ def _findings_literal(findings) -> str:
146
+ """The catalogue as the Rust literal list `archkeep_rule!` takes.
147
+
148
+ The entries are written as Rust string literals through `json.dumps`-shaped
149
+ escaping done by `repr`-free means: a catalogue message is the author's
150
+ prose and may hold quotes, backslashes or newlines, and a naive f-string
151
+ would produce a crate that does not compile — with the error pointing at
152
+ generated code rather than at the message that caused it.
153
+ """
154
+ entries = []
155
+ for index, entry in enumerate(findings):
156
+ try:
157
+ id, message = entry["id"], entry["message"]
158
+ except (TypeError, KeyError):
159
+ raise BuildError(
160
+ f"ARCHKEEP_RULE['findings'][{index}] is {entry!r}, and a catalogue entry states "
161
+ f"an id and a message"
162
+ )
163
+ entries.append(f"({_rust_string(id)}, {_rust_string(message)})")
164
+ return ", ".join(entries)
165
+
166
+
167
+ def _rust_string(value) -> str:
168
+ """One Python string as a Rust string literal."""
169
+ if not isinstance(value, str):
170
+ raise BuildError(f"{value!r} is not a string, and a catalogue entry holds two of them")
171
+ escaped = (
172
+ value.replace("\\", "\\\\")
173
+ .replace('"', '\\"')
174
+ .replace("\n", "\\n")
175
+ .replace("\r", "\\r")
176
+ .replace("\t", "\\t")
177
+ )
178
+ return f'"{escaped}"'
179
+
180
+
181
+ def run_cargo(crate_dir: Path, offline: bool) -> Path:
182
+ """Builds the carrier and answers the path of the module cargo produced."""
183
+ environment = dict(os.environ)
184
+ flags = environment.get("RUSTFLAGS", "")
185
+ if GETRANDOM_BACKEND_FLAG not in flags:
186
+ environment["RUSTFLAGS"] = f"{flags} {GETRANDOM_BACKEND_FLAG}".strip()
187
+
188
+ command = ["cargo", "build", "--release", "--target", WASM_TARGET]
189
+ if offline:
190
+ command.append("--offline")
191
+
192
+ try:
193
+ # An argument list, never a built string: every value in play here comes
194
+ # from a path the author passed in, and a directory named `a;rm -rf .`
195
+ # stops being a name and becomes two commands
196
+ # (`../../../../SECURITY.md`).
197
+ completed = subprocess.run(command, cwd=crate_dir, env=environment, check=False)
198
+ except FileNotFoundError:
199
+ raise BuildError(
200
+ "cargo is not on PATH. Building a Python rule needs the Rust toolchain and the "
201
+ f"{WASM_TARGET} target — `rustup target add {WASM_TARGET}` — because there is no "
202
+ "Python toolchain that emits a core-wasm module with no imports. Running a built "
203
+ "rule needs neither."
204
+ )
205
+ if completed.returncode != 0:
206
+ raise BuildError(
207
+ f"cargo build failed (exit {completed.returncode}). When the failure names "
208
+ "ARCHKEEP_RULE, assert_valid or a const evaluation, it is the rule's declaration "
209
+ "that is wrong — the name's grammar, a duplicate finding id, or an empty "
210
+ "catalogue — and the carrier is where that is checked."
211
+ )
212
+
213
+ artifact = crate_dir / "target" / WASM_TARGET / "release" / f"{CRATE_NAME}.wasm"
214
+ if not artifact.is_file():
215
+ raise BuildError(
216
+ f"cargo reported success and wrote no {artifact.name} — nothing was produced, and "
217
+ "an absent artifact is never read as a built one"
218
+ )
219
+ return artifact
220
+
221
+
222
+ def build(rule_path: Path, out_path: Path, sdk_dependency: str, keep: Path | None, offline: bool) -> None:
223
+ """The whole build, from a `rule.py` to a `.wasm` and its digest."""
224
+ declaration = read_declaration(rule_path)
225
+
226
+ if keep is not None:
227
+ keep.mkdir(parents=True, exist_ok=True)
228
+ _build_in(declaration, rule_path, keep, out_path, sdk_dependency, offline)
229
+ return
230
+ with tempfile.TemporaryDirectory(prefix="archkeep-rule-carrier-") as scratch:
231
+ _build_in(declaration, rule_path, Path(scratch), out_path, sdk_dependency, offline)
232
+
233
+
234
+ def _build_in(declaration, rule_path, crate_dir, out_path, sdk_dependency, offline) -> None:
235
+ render_carrier(declaration, rule_path, crate_dir, sdk_dependency)
236
+ artifact = run_cargo(crate_dir, offline)
237
+
238
+ out_path.parent.mkdir(parents=True, exist_ok=True)
239
+ shutil.copyfile(artifact, out_path)
240
+ digest = hashlib.sha256(out_path.read_bytes()).hexdigest()
241
+ # Bare lowercase hex and a newline, nothing else: this string is pasted
242
+ # verbatim into a `customRules` row's `sha256` field, which is 64 hex
243
+ # characters with no filename beside it.
244
+ out_path.with_suffix(out_path.suffix + ".sha256").write_text(digest + "\n", encoding="utf-8")
245
+
246
+ print(
247
+ f"built {out_path} ({out_path.stat().st_size} bytes) for rule "
248
+ f"\"{declaration['name']}\", digest {digest}"
249
+ )
250
+
251
+
252
+ def main(argv=None) -> int:
253
+ parser = argparse.ArgumentParser(
254
+ prog="python -m archkeep_rule_sdk.build",
255
+ description="Compile a Python Archkeep rule into a core-WebAssembly artifact.",
256
+ )
257
+ parser.add_argument("rule", type=Path, help="the rule's Python source")
258
+ parser.add_argument(
259
+ "--out",
260
+ type=Path,
261
+ required=True,
262
+ help="where to write the .wasm; a .wasm.sha256 is written beside it",
263
+ )
264
+ parser.add_argument(
265
+ "--sdk-path",
266
+ type=Path,
267
+ default=None,
268
+ help=(
269
+ "build against a local checkout of the Rust SDK crate instead of the published "
270
+ "one — how this repository builds its own reference artifact"
271
+ ),
272
+ )
273
+ parser.add_argument(
274
+ "--keep-crate",
275
+ type=Path,
276
+ default=None,
277
+ help=(
278
+ "write the generated carrier crate here and leave it behind, so cargo's "
279
+ "incremental cache survives between builds"
280
+ ),
281
+ )
282
+ parser.add_argument(
283
+ "--offline",
284
+ action="store_true",
285
+ help="pass --offline to cargo, for a build with no registry access",
286
+ )
287
+ arguments = parser.parse_args(argv)
288
+
289
+ if arguments.sdk_path is None:
290
+ # The published crate, at this package's own version: the SDKs and the
291
+ # engine share one version chain (`../../../../docs/skills/versioning.md`),
292
+ # so a rule built by this tool is built against the binding that shipped
293
+ # with it.
294
+ sdk_dependency = f'"{_own_version()}"'
295
+ else:
296
+ sdk_dependency = f'{{ path = "{arguments.sdk_path.resolve().as_posix()}" }}'
297
+
298
+ try:
299
+ build(
300
+ arguments.rule.resolve(),
301
+ arguments.out.resolve(),
302
+ sdk_dependency,
303
+ arguments.keep_crate.resolve() if arguments.keep_crate else None,
304
+ arguments.offline,
305
+ )
306
+ except BuildError as error:
307
+ print(f"archkeep: {error}", file=sys.stderr)
308
+ return 1
309
+ return 0
310
+
311
+
312
+ def _own_version() -> str:
313
+ """This package's version, read from the metadata that installed it.
314
+
315
+ Falls back loudly rather than to a literal: a version guessed here would
316
+ pin a rule's binding to a crate that may not exist, and the failure would
317
+ arrive as an unresolvable dependency with no clue where the number came
318
+ from.
319
+ """
320
+ try:
321
+ from importlib.metadata import version
322
+
323
+ return version("archkeep-rule-sdk")
324
+ # Broad on purpose: importlib.metadata raises several unrelated types when
325
+ # a distribution is not installed, and every one of them means the same
326
+ # thing here — the version to build against is not knowable.
327
+ except Exception as error:
328
+ raise BuildError(
329
+ "this package's own version could not be read from its installed metadata "
330
+ f"({type(error).__name__}: {error}), so the crate version to build against is "
331
+ "unknown — pass --sdk-path to build against a checkout instead"
332
+ )
333
+
334
+
335
+ if __name__ == "__main__":
336
+ raise SystemExit(main())
@@ -0,0 +1,70 @@
1
+ # GENERATED by `python -m archkeep_rule_sdk.build` — do not edit.
2
+ #
3
+ # The carrier crate for one Python rule. It is not published and not depended
4
+ # on: it exists for the length of one `cargo build`, and its output is the
5
+ # `.wasm` a workspace pins by digest.
6
+ #
7
+ # Three dependencies, and each is load-bearing:
8
+ #
9
+ # archkeep-rule-sdk the contract binding — the typed evidence model, the four
10
+ # verdict constructors, and the `archkeep_rule!` macro that
11
+ # writes the ABI exports. The Python carrier is a client of
12
+ # the Rust one rather than a second implementation of it,
13
+ # so the two SDKs cannot disagree about the wire.
14
+ # rustpython-vm the interpreter, with `default-features = false`. The
15
+ # default set includes `wasmbind`, which pulls
16
+ # `wasm-bindgen` and would put JavaScript imports in the
17
+ # module's import section — the one thing the host refuses
18
+ # outright. `compiler` is kept because the carrier compiles
19
+ # the author's source at startup; it is what makes the
20
+ # artifact reproducible from the `.py` in the tree rather
21
+ # than from bytecode nobody can read.
22
+ # getrandom named explicitly so the custom backend in `src/lib.rs`
23
+ # resolves. The build passes
24
+ # `--cfg getrandom_backend="custom"`; without it the crate
25
+ # has no backend on `wasm32-unknown-unknown` and fails to
26
+ # compile with a message about enabling one.
27
+ #
28
+ # `serde_json` is here because the carrier converts between JSON values and
29
+ # Python objects in both directions. It is the same version the SDK crate
30
+ # resolves, so the `Value` in one is the `Value` in the other.
31
+ [package]
32
+ name = "{{CRATE_NAME}}"
33
+ version = "0.0.0"
34
+ edition = "2024"
35
+ # The floor the SDK crate sets, restated because a carrier is compiled by the
36
+ # author's own toolchain and the failure is clearer here than inside a
37
+ # dependency.
38
+ rust-version = "1.85"
39
+ publish = false
40
+
41
+ [dependencies]
42
+ archkeep-rule-sdk = {{SDK_DEPENDENCY}}
43
+ rustpython-vm = { version = "0.5.0", default-features = false, features = ["compiler"] }
44
+ getrandom = "0.3"
45
+ serde_json = "1"
46
+
47
+ [lib]
48
+ # A cdylib, which is what makes rustc's `wasm32-unknown-unknown` linkage export
49
+ # the module's linear memory under the name `memory` — the fourth ABI export,
50
+ # the one no macro writes.
51
+ crate-type = ["cdylib"]
52
+
53
+ # The profile the artifact is built with, stated here rather than in the build
54
+ # command so that a rebuild by hand produces what the build tool produces.
55
+ #
56
+ # `panic = "abort"` is a contract requirement rather than a size tweak:
57
+ # unwinding across the ABI's `extern "C"` boundary is undefined behaviour, so a
58
+ # panic must trap instead — and a trap is a failure the host NAMES rather than a
59
+ # verdict it invents.
60
+ #
61
+ # The size options matter more here than they do for a Rust rule, and for the
62
+ # same reason: the artifact is committed to the workspace that declares it and
63
+ # pinned by a sha256 in the policy row. An interpreter is large no matter what
64
+ # is done to it; these options are what keep "large" at single-digit megabytes.
65
+ [profile.release]
66
+ panic = "abort"
67
+ opt-level = "z"
68
+ lto = true
69
+ codegen-units = 1
70
+ strip = true