@adrkit/evaluator 0.1.0

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.
Files changed (81) hide show
  1. package/README.md +23 -0
  2. package/dist/LICENSE +201 -0
  3. package/dist/NOTICE +11 -0
  4. package/dist/assertions/evaluate.d.ts +36 -0
  5. package/dist/assertions/jsonpath.d.ts +20 -0
  6. package/dist/assertions/limits.d.ts +21 -0
  7. package/dist/assertions/registry.d.ts +20 -0
  8. package/dist/assertions/rego.d.ts +27 -0
  9. package/dist/catalog.d.ts +39 -0
  10. package/dist/compare.d.ts +10 -0
  11. package/dist/crypto/sha256.d.ts +10 -0
  12. package/dist/identity/directory.d.ts +23 -0
  13. package/dist/index.d.ts +24 -0
  14. package/dist/index.js +2064 -0
  15. package/dist/keys.d.ts +27 -0
  16. package/dist/pass0.d.ts +19 -0
  17. package/dist/patch/project.d.ts +20 -0
  18. package/dist/report/aggregate.d.ts +21 -0
  19. package/dist/report/assemble.d.ts +16 -0
  20. package/dist/report/order.d.ts +19 -0
  21. package/dist/report/serialize.d.ts +42 -0
  22. package/dist/routing/accepted-assertion.d.ts +12 -0
  23. package/dist/routing/route.d.ts +18 -0
  24. package/dist/routing/target.d.ts +20 -0
  25. package/dist/rules/affects-overlap.d.ts +13 -0
  26. package/dist/rules/affects-resolvable.d.ts +14 -0
  27. package/dist/rules/assertions-compile.d.ts +12 -0
  28. package/dist/rules/assertions-pass.d.ts +13 -0
  29. package/dist/rules/context.d.ts +21 -0
  30. package/dist/rules/decider-resolvable.d.ts +10 -0
  31. package/dist/rules/expiry-sane.d.ts +11 -0
  32. package/dist/rules/id-unique.d.ts +11 -0
  33. package/dist/rules/kernel.d.ts +16 -0
  34. package/dist/rules/no-orphan-refs.d.ts +12 -0
  35. package/dist/rules/schema-valid.d.ts +11 -0
  36. package/dist/rules/scope-hierarchy.d.ts +14 -0
  37. package/dist/rules/supersession-consistent.d.ts +12 -0
  38. package/dist/targets/canonical.d.ts +37 -0
  39. package/dist/targets/package.d.ts +11 -0
  40. package/dist/targets/path.d.ts +10 -0
  41. package/dist/targets/registry.d.ts +11 -0
  42. package/dist/types.d.ts +360 -0
  43. package/package.json +54 -0
  44. package/src/assertions/evaluate.ts +214 -0
  45. package/src/assertions/jsonpath.ts +95 -0
  46. package/src/assertions/limits.ts +57 -0
  47. package/src/assertions/registry.ts +38 -0
  48. package/src/assertions/rego.ts +272 -0
  49. package/src/catalog.ts +263 -0
  50. package/src/compare.ts +13 -0
  51. package/src/crypto/sha256.ts +101 -0
  52. package/src/identity/directory.ts +69 -0
  53. package/src/index.ts +81 -0
  54. package/src/keys.ts +55 -0
  55. package/src/pass0.ts +163 -0
  56. package/src/patch/project.ts +51 -0
  57. package/src/report/aggregate.ts +59 -0
  58. package/src/report/assemble.ts +43 -0
  59. package/src/report/order.ts +53 -0
  60. package/src/report/serialize.ts +152 -0
  61. package/src/routing/accepted-assertion.ts +39 -0
  62. package/src/routing/route.ts +105 -0
  63. package/src/routing/target.ts +104 -0
  64. package/src/rules/affects-overlap.ts +55 -0
  65. package/src/rules/affects-resolvable.ts +83 -0
  66. package/src/rules/assertions-compile.ts +18 -0
  67. package/src/rules/assertions-pass.ts +21 -0
  68. package/src/rules/context.ts +31 -0
  69. package/src/rules/decider-resolvable.ts +55 -0
  70. package/src/rules/expiry-sane.ts +30 -0
  71. package/src/rules/id-unique.ts +57 -0
  72. package/src/rules/kernel.ts +33 -0
  73. package/src/rules/no-orphan-refs.ts +102 -0
  74. package/src/rules/schema-valid.ts +49 -0
  75. package/src/rules/scope-hierarchy.ts +108 -0
  76. package/src/rules/supersession-consistent.ts +138 -0
  77. package/src/targets/canonical.ts +114 -0
  78. package/src/targets/package.ts +41 -0
  79. package/src/targets/path.ts +32 -0
  80. package/src/targets/registry.ts +23 -0
  81. package/src/types.ts +445 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @adrkit/evaluator — JSONPath assertion engine (approved source profile, R1).
3
+ *
4
+ * Uses exact `jsonpath-rfc9535@1.3.0` in process. `compile` validates the restricted
5
+ * RFC 9535 source profile: it rejects source over 8 KiB, anything the RFC parser
6
+ * rejects (parent/backtick/type selectors, scripts, JSONPath-Plus/JS extensions), and
7
+ * the `match()`/`search()` functions plus any function outside `length`/`count`/`value`
8
+ * (attacker-controlled regex is outside this release's ReDoS boundary). It stores an
9
+ * immutable `{ source, ast }` payload.
10
+ *
11
+ * The package's `query` accepts a source string and internally reparses it; it cannot
12
+ * consume the exported AST. `evaluate` therefore truthfully calls `query` with the
13
+ * already-validated source — the same immutable payload still travels directly from
14
+ * compile to evaluate. There is NO second evaluator-level compile, hidden mutable
15
+ * cache, recompile, or unsafe payload cast. A result passes iff the nodelist is
16
+ * non-empty (selecting `false` still passes). Evaluation input is bounded to canonical
17
+ * JSON ≤ 1 MiB / depth 64 / 100,000 nodes.
18
+ */
19
+
20
+ import { query } from 'jsonpath-rfc9535';
21
+ import parseJsonPath from 'jsonpath-rfc9535/parser';
22
+ import { ASSERTION_INPUT_LIMITS, withinJsonLimits } from './limits.ts';
23
+ import type {
24
+ CompileOutcome,
25
+ CompiledAssertion,
26
+ EvalOutcome,
27
+ JsonPathCompiledPayload,
28
+ JsonValue,
29
+ SourceAssertionEnginePort,
30
+ } from '../types.ts';
31
+
32
+ const MAX_SOURCE_BYTES = 8 * 1024;
33
+ const ALLOWED_FUNCTIONS: ReadonlySet<string> = new Set(['length', 'count', 'value']);
34
+
35
+ /** Collect every FunctionExpr name in the AST so disallowed functions can be rejected. */
36
+ function functionNames(node: unknown, out: Set<string>): void {
37
+ if (Array.isArray(node)) {
38
+ for (const item of node) functionNames(item, out);
39
+ return;
40
+ }
41
+ if (node === null || typeof node !== 'object') return;
42
+ const record = node as Record<string, unknown>;
43
+ if (record.type === 'FunctionExpr' && typeof record.name === 'string') {
44
+ out.add(record.name);
45
+ }
46
+ for (const value of Object.values(record)) functionNames(value, out);
47
+ }
48
+
49
+ function usesOnlyAllowedFunctions(ast: unknown): boolean {
50
+ const names = new Set<string>();
51
+ functionNames(ast, names);
52
+ for (const name of names) {
53
+ if (!ALLOWED_FUNCTIONS.has(name)) return false;
54
+ }
55
+ return true;
56
+ }
57
+
58
+ export function createJsonPathEngine(): SourceAssertionEnginePort<'jsonpath', JsonPathCompiledPayload> {
59
+ return {
60
+ engine: 'jsonpath',
61
+ profile: 'source',
62
+ compile(effectiveSource: string, sourceRef?: string): CompileOutcome<'jsonpath', JsonPathCompiledPayload> {
63
+ if (new TextEncoder().encode(effectiveSource).length > MAX_SOURCE_BYTES) {
64
+ return { ok: false, reason: 'assertions-compile.parse-error' };
65
+ }
66
+ let ast: unknown;
67
+ try {
68
+ ast = parseJsonPath(effectiveSource);
69
+ } catch {
70
+ return { ok: false, reason: 'assertions-compile.parse-error' };
71
+ }
72
+ if (!usesOnlyAllowedFunctions(ast)) {
73
+ return { ok: false, reason: 'assertions-compile.parse-error' };
74
+ }
75
+ const payload: JsonPathCompiledPayload = { source: effectiveSource, ast };
76
+ return {
77
+ ok: true,
78
+ compiled: { engine: 'jsonpath', payload, ...(sourceRef !== undefined ? { sourceRef } : {}) },
79
+ };
80
+ },
81
+ evaluate(compiled: CompiledAssertion<'jsonpath', JsonPathCompiledPayload>, input: JsonValue): EvalOutcome {
82
+ if (!withinJsonLimits(input, ASSERTION_INPUT_LIMITS)) {
83
+ return { ok: false, reason: 'assertions-pass.evaluation-error' };
84
+ }
85
+ try {
86
+ // Truthful reparse inside the package (documented in R1). The immutable payload
87
+ // still carries source→evaluate directly; no evaluator-level recompile.
88
+ const nodes = query(input as Parameters<typeof query>[0], compiled.payload.source);
89
+ return { ok: true, pass: nodes.length > 0 };
90
+ } catch {
91
+ return { ok: false, reason: 'assertions-pass.evaluation-error' };
92
+ }
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @adrkit/evaluator — deterministic JSON resource limits.
3
+ *
4
+ * Shared bounds for assertion evaluation input and Rego envelope data (research §R1):
5
+ * canonical size, nesting depth, and node count. Exceeding a bound is a deterministic
6
+ * rejection, never a hang OR a stack overflow: depth and node count are enforced by an
7
+ * ITERATIVE traversal FIRST, so hostile deep JSON returns `false` before any recursive
8
+ * or serializing work runs (finding #5). Only after the structure is proven bounded is
9
+ * the canonical byte size measured.
10
+ */
11
+
12
+ import { canonicalStringify } from '../report/serialize.ts';
13
+ import type { JsonValue } from '../types.ts';
14
+
15
+ export interface JsonLimits {
16
+ readonly maxBytes: number;
17
+ readonly maxDepth: number;
18
+ readonly maxNodes: number;
19
+ }
20
+
21
+ export const ASSERTION_INPUT_LIMITS: JsonLimits = { maxBytes: 1024 * 1024, maxDepth: 64, maxNodes: 100_000 };
22
+ export const REGO_DATA_LIMITS: JsonLimits = { maxBytes: 1024 * 1024, maxDepth: 64, maxNodes: 100_000 };
23
+
24
+ /** Compact canonical JSON (code-unit key order, no whitespace) — used for hashing/sizing. */
25
+ export function canonicalJsonString(value: unknown): string {
26
+ return canonicalStringify(value, false);
27
+ }
28
+
29
+ /** True iff the depth and node count are within bounds — iterative, no serialization. */
30
+ function withinStructuralLimits(value: JsonValue, limits: JsonLimits): boolean {
31
+ let nodes = 0;
32
+ const stack: { value: JsonValue; depth: number }[] = [{ value, depth: 1 }];
33
+ while (stack.length > 0) {
34
+ const { value: current, depth } = stack.pop() as { value: JsonValue; depth: number };
35
+ nodes += 1;
36
+ if (nodes > limits.maxNodes) return false;
37
+ if (depth > limits.maxDepth) return false;
38
+ if (Array.isArray(current)) {
39
+ for (const item of current) stack.push({ value: item, depth: depth + 1 });
40
+ } else if (current !== null && typeof current === 'object') {
41
+ // Own enumerable keys (null-prototype dictionaries retain __proto__/constructor).
42
+ for (const entry of Object.values(current)) {
43
+ stack.push({ value: entry, depth: depth + 1 });
44
+ }
45
+ }
46
+ }
47
+ return true;
48
+ }
49
+
50
+ export function withinJsonLimits(value: JsonValue, limits: JsonLimits): boolean {
51
+ // Bound depth + node count FIRST so a hostile deep/large structure is rejected before
52
+ // any serialization is attempted.
53
+ if (!withinStructuralLimits(value, limits)) return false;
54
+ // Now the structure is proven bounded; measuring canonical bytes is safe.
55
+ const bytes = new TextEncoder().encode(canonicalJsonString(value)).length;
56
+ return bytes <= limits.maxBytes;
57
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @adrkit/evaluator — assertion engine registry.
3
+ *
4
+ * The registry has one typed optional property per engine. Each property pairs an
5
+ * `AssertionEnginePort<E, Payload>` with its engine-owned opaque payload type. A
6
+ * rule dispatches on `assertion.engine`, obtains the matching property (whose static
7
+ * type carries that engine's payload), compiles/validates once, and passes the exact
8
+ * `CompiledAssertion<E, Payload>` straight into the SAME port's `evaluate`. There is
9
+ * no hidden mutable ref cache, no recompile, and no `any`/`unknown` cast (R7).
10
+ */
11
+
12
+ import type {
13
+ AssertionEngineRegistry,
14
+ AssertionEnginePort,
15
+ } from '../types.ts';
16
+
17
+ export interface AssertionEnginePorts<RegoPayload, JsonPathPayload, GrepPayload, CustomPayload> {
18
+ readonly rego?: AssertionEnginePort<'rego', RegoPayload>;
19
+ readonly jsonpath?: AssertionEnginePort<'jsonpath', JsonPathPayload>;
20
+ readonly grep?: AssertionEnginePort<'grep', GrepPayload>;
21
+ readonly custom?: AssertionEnginePort<'custom', CustomPayload>;
22
+ }
23
+
24
+ export function createAssertionEngineRegistry<RegoPayload, JsonPathPayload, GrepPayload, CustomPayload>(
25
+ ports: AssertionEnginePorts<RegoPayload, JsonPathPayload, GrepPayload, CustomPayload> = {},
26
+ ): AssertionEngineRegistry<RegoPayload, JsonPathPayload, GrepPayload, CustomPayload> {
27
+ // Copy only the declared ports; a missing property is `engine-absent` inert.
28
+ const registry: AssertionEngineRegistry<RegoPayload, JsonPathPayload, GrepPayload, CustomPayload> = {
29
+ ...(ports.rego ? { rego: ports.rego } : {}),
30
+ ...(ports.jsonpath ? { jsonpath: ports.jsonpath } : {}),
31
+ ...(ports.grep ? { grep: ports.grep } : {}),
32
+ ...(ports.custom ? { custom: ports.custom } : {}),
33
+ };
34
+ return registry;
35
+ }
36
+
37
+ /** A registry with no engines — every assertion is `engine-absent` inert. */
38
+ export const emptyAssertionEngineRegistry: AssertionEngineRegistry<never, never, never, never> = {};
@@ -0,0 +1,272 @@
1
+ /**
2
+ * @adrkit/evaluator — Rego-Wasm policy envelope validation (R1, inert by default).
3
+ *
4
+ * adrkit registers NO Rego runtime and executes NO Wasm. A Rego assertion is therefore
5
+ * `engine-absent` inert unless a trusted caller registers a compiled-artifact port. This
6
+ * module only VALIDATES the fixed, strict
7
+ * `application/vnd.adrkit.rego-wasm-policy.v1+json` envelope so the CLI can reject
8
+ * malformed artifacts (exit 2) and a trusted port can validate before it evaluates. It
9
+ * never runs the module, shells out, or claims opa-wasm compiles raw Rego.
10
+ */
11
+
12
+ import { sha256Hex, sha256HexUtf8 } from '../crypto/sha256.ts';
13
+ import { REGO_DATA_LIMITS, canonicalJsonString, withinJsonLimits } from './limits.ts';
14
+ import type { JsonValue, RegoWasmPolicyEnvelopeV1 } from '../types.ts';
15
+
16
+ const MEDIA_TYPE = 'application/vnd.adrkit.rego-wasm-policy.v1+json';
17
+ const SCHEMA_VERSION = 'adrkit.rego-wasm-policy/v1';
18
+ const CAPABILITIES_PROFILE = 'adrkit.rego-wasm.capabilities/v1';
19
+ const MAX_SOURCE_BYTES = 64 * 1024;
20
+ const MAX_MODULE_BYTES = 4 * 1024 * 1024;
21
+ // Canonical base64 encodes 3 bytes → 4 chars; a string longer than this necessarily
22
+ // decodes to more than 4 MiB, so reject it BEFORE atob/byte allocation (finding #6).
23
+ const MAX_MODULE_BASE64_LEN = Math.ceil(MAX_MODULE_BYTES / 3) * 4; // 5,592,408
24
+ // 6.75 MiB, measured over the COMPLETE canonical envelope (including envelopeSha256).
25
+ const MAX_ENVELOPE_BYTES = Math.floor(6.75 * 1024 * 1024); // 7,077,888 bytes
26
+ const HEX_64 = /^[0-9a-f]{64}$/;
27
+ const ENVELOPE_KEYS = [
28
+ 'mediaType',
29
+ 'schemaVersion',
30
+ 'source',
31
+ 'sourceSha256',
32
+ 'moduleBase64',
33
+ 'moduleSha256',
34
+ 'data',
35
+ 'entrypoint',
36
+ 'abi',
37
+ 'compiler',
38
+ 'requiredHostBuiltins',
39
+ 'envelopeSha256',
40
+ ] as const;
41
+
42
+ export type EnvelopeValidation =
43
+ | { readonly ok: true; readonly envelope: RegoWasmPolicyEnvelopeV1 }
44
+ | { readonly ok: false; readonly message: string };
45
+
46
+ function fail(message: string): EnvelopeValidation {
47
+ return { ok: false, message: `Rego-Wasm envelope: ${message}` };
48
+ }
49
+
50
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
51
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
52
+ }
53
+
54
+ function decodeCanonicalBase64(b64: string): Uint8Array | undefined {
55
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(b64) || b64.length % 4 !== 0) return undefined;
56
+ let binary: string;
57
+ try {
58
+ binary = atob(b64);
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ if (btoa(binary) !== b64) return undefined; // canonical round-trip
63
+ const bytes = new Uint8Array(binary.length);
64
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
65
+ return bytes;
66
+ }
67
+
68
+ function hasWasmMagic(bytes: Uint8Array): boolean {
69
+ return bytes.length >= 8 && bytes[0] === 0x00 && bytes[1] === 0x61 && bytes[2] === 0x73 && bytes[3] === 0x6d;
70
+ }
71
+
72
+ /**
73
+ * Deterministic, synchronous structural validation of the module WITHOUT executing it.
74
+ * `WebAssembly.validate` compiles/validates the full binary structure (sections, types,
75
+ * function bodies) and returns a boolean; it never instantiates the module, resolves
76
+ * imports, or runs a start function, so it stays inside the pure boundary (R1).
77
+ */
78
+ function isStructurallyValidWasm(bytes: Uint8Array): boolean {
79
+ try {
80
+ return WebAssembly.validate(bytes);
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /** Narrow an `unknown` to an acyclic JSON tree without recursive stack growth. */
87
+ function isJsonValue(root: unknown): root is JsonValue {
88
+ type WalkTask =
89
+ | { readonly kind: 'enter'; readonly value: unknown }
90
+ | { readonly kind: 'exit'; readonly value: object };
91
+
92
+ const active = new Set<object>();
93
+ const stack: WalkTask[] = [{ kind: 'enter', value: root }];
94
+
95
+ while (stack.length > 0) {
96
+ const task = stack.pop();
97
+ if (!task) continue;
98
+ if (task.kind === 'exit') {
99
+ active.delete(task.value);
100
+ continue;
101
+ }
102
+
103
+ const value = task.value;
104
+ if (value === null) continue;
105
+ const type = typeof value;
106
+ if (type === 'boolean' || type === 'string') continue;
107
+ if (type === 'number') {
108
+ if (!Number.isFinite(value)) return false;
109
+ continue;
110
+ }
111
+ if (typeof value !== 'object' || value === null) return false;
112
+
113
+ const objectValue = value;
114
+ if (active.has(objectValue)) return false;
115
+ active.add(objectValue);
116
+ stack.push({ kind: 'exit', value: objectValue });
117
+
118
+ if (Array.isArray(objectValue)) {
119
+ for (let index = objectValue.length - 1; index >= 0; index -= 1) {
120
+ if (!(index in objectValue)) return false;
121
+ stack.push({ kind: 'enter', value: objectValue[index] });
122
+ }
123
+ continue;
124
+ }
125
+
126
+ let prototype: object | null;
127
+ let descriptors: PropertyDescriptorMap;
128
+ try {
129
+ prototype = Object.getPrototypeOf(objectValue);
130
+ descriptors = Object.getOwnPropertyDescriptors(objectValue);
131
+ } catch {
132
+ return false;
133
+ }
134
+ if (prototype !== Object.prototype && prototype !== null) return false;
135
+ for (const key of Reflect.ownKeys(descriptors)) {
136
+ if (typeof key !== 'string') return false;
137
+ const descriptor = descriptors[key];
138
+ if (!descriptor?.enumerable || !('value' in descriptor)) return false;
139
+ stack.push({ kind: 'enter', value: descriptor.value });
140
+ }
141
+ }
142
+
143
+ return true;
144
+ }
145
+
146
+ function isCanonicalEntrypoint(entrypoint: string): boolean {
147
+ if (!entrypoint.startsWith('/') || entrypoint.length < 2) return false;
148
+ if (entrypoint.endsWith('/') || entrypoint.includes('//')) return false;
149
+ return entrypoint
150
+ .slice(1)
151
+ .split('/')
152
+ .every((segment) => segment.length > 0);
153
+ }
154
+
155
+ /**
156
+ * Validate a caller-supplied Rego-Wasm policy envelope. Structural, size, canonical
157
+ * base64, Wasm magic + `WebAssembly.validate` structure, strict ABI (1.3, no extra
158
+ * keys), compiler/capability, empty host-builtins, JSON `data` limits, and every
159
+ * SHA-256 binding are checked. On success it constructs the typed envelope from
160
+ * validated locals — no unsafe cast crosses the trusted boundary. The module is never
161
+ * executed.
162
+ */
163
+ export function validateRegoWasmPolicyEnvelopeV1(artifact: unknown): EnvelopeValidation {
164
+ if (!isPlainObject(artifact)) return fail('must be an object');
165
+ for (const key of Object.keys(artifact)) {
166
+ if (!(ENVELOPE_KEYS as readonly string[]).includes(key)) return fail(`unknown key "${key}"`);
167
+ }
168
+ for (const key of ENVELOPE_KEYS) {
169
+ if (!(key in artifact)) return fail(`missing key "${key}"`);
170
+ }
171
+
172
+ if (artifact.mediaType !== MEDIA_TYPE) return fail('mediaType mismatch');
173
+ if (artifact.schemaVersion !== SCHEMA_VERSION) return fail('schemaVersion mismatch');
174
+
175
+ const source = artifact.source;
176
+ if (typeof source !== 'string') return fail('source must be a string');
177
+ if (new TextEncoder().encode(source).length > MAX_SOURCE_BYTES) return fail('source exceeds 64 KiB');
178
+
179
+ const sourceSha256 = artifact.sourceSha256;
180
+ if (typeof sourceSha256 !== 'string' || !HEX_64.test(sourceSha256)) return fail('sourceSha256 must be 64 lowercase hex');
181
+ if (sha256HexUtf8(source) !== sourceSha256) return fail('sourceSha256 does not match source');
182
+
183
+ const moduleBase64 = artifact.moduleBase64;
184
+ if (typeof moduleBase64 !== 'string') return fail('moduleBase64 must be a string');
185
+ // Bound the ENCODED length before decoding so a hostile oversized string cannot force
186
+ // a large atob/byte allocation.
187
+ if (moduleBase64.length > MAX_MODULE_BASE64_LEN) return fail('moduleBase64 exceeds the maximum encoded length for a 4 MiB module');
188
+ const moduleBytes = decodeCanonicalBase64(moduleBase64);
189
+ if (!moduleBytes) return fail('moduleBase64 is not canonical base64');
190
+ if (moduleBytes.length > MAX_MODULE_BYTES) return fail('module exceeds 4 MiB');
191
+ if (!hasWasmMagic(moduleBytes)) return fail('module is not a valid Wasm binary (magic)');
192
+ if (!isStructurallyValidWasm(moduleBytes)) return fail('module is not a structurally valid Wasm binary');
193
+
194
+ const moduleSha256 = artifact.moduleSha256;
195
+ if (typeof moduleSha256 !== 'string' || !HEX_64.test(moduleSha256)) return fail('moduleSha256 must be 64 lowercase hex');
196
+ if (sha256Hex(moduleBytes) !== moduleSha256) return fail('moduleSha256 does not match module');
197
+
198
+ // `data` must be a real JSON value before any size/depth/node measurement.
199
+ const data = artifact.data;
200
+ if (!isJsonValue(data)) return fail('data must be a JSON value');
201
+ if (!withinJsonLimits(data, REGO_DATA_LIMITS)) return fail('data exceeds size/depth/node limits');
202
+
203
+ const entrypoint = artifact.entrypoint;
204
+ if (typeof entrypoint !== 'string' || !isCanonicalEntrypoint(entrypoint)) {
205
+ return fail('entrypoint must be a canonical /slash/path');
206
+ }
207
+
208
+ const abi = artifact.abi;
209
+ if (!isPlainObject(abi)) return fail('abi must be an object');
210
+ for (const key of Object.keys(abi)) {
211
+ if (key !== 'major' && key !== 'minor') return fail(`unknown abi key "${key}"`);
212
+ }
213
+ if (abi.major !== 1 || abi.minor !== 3) return fail('unsupported ABI (expected 1.3)');
214
+
215
+ const compiler = artifact.compiler;
216
+ if (!isPlainObject(compiler)) return fail('compiler must be an object');
217
+ for (const key of Object.keys(compiler)) {
218
+ if (!['name', 'version', 'capabilitiesProfile', 'capabilitiesSha256'].includes(key)) {
219
+ return fail(`unknown compiler key "${key}"`);
220
+ }
221
+ }
222
+ if (compiler.name !== 'opa') return fail('compiler.name must be "opa"');
223
+ const compilerVersion = compiler.version;
224
+ if (typeof compilerVersion !== 'string' || compilerVersion.length === 0) return fail('compiler.version required');
225
+ if (compiler.capabilitiesProfile !== CAPABILITIES_PROFILE) return fail('unsupported capabilities profile');
226
+ const capabilitiesSha256 = compiler.capabilitiesSha256;
227
+ if (typeof capabilitiesSha256 !== 'string' || !HEX_64.test(capabilitiesSha256)) {
228
+ return fail('compiler.capabilitiesSha256 must be 64 lowercase hex');
229
+ }
230
+
231
+ if (!Array.isArray(artifact.requiredHostBuiltins) || artifact.requiredHostBuiltins.length !== 0) {
232
+ return fail('requiredHostBuiltins must be empty in v1');
233
+ }
234
+
235
+ const envelopeSha256 = artifact.envelopeSha256;
236
+ if (typeof envelopeSha256 !== 'string' || !HEX_64.test(envelopeSha256)) return fail('envelopeSha256 must be 64 lowercase hex');
237
+
238
+ // Construct the typed envelope from validated locals — no unsafe cast crosses here.
239
+ const envelope: RegoWasmPolicyEnvelopeV1 = {
240
+ mediaType: MEDIA_TYPE,
241
+ schemaVersion: SCHEMA_VERSION,
242
+ source,
243
+ sourceSha256,
244
+ moduleBase64,
245
+ moduleSha256,
246
+ data,
247
+ entrypoint,
248
+ abi: { major: 1, minor: 3 },
249
+ compiler: {
250
+ name: 'opa',
251
+ version: compilerVersion,
252
+ capabilitiesProfile: CAPABILITIES_PROFILE,
253
+ capabilitiesSha256,
254
+ },
255
+ requiredHostBuiltins: [],
256
+ envelopeSha256,
257
+ };
258
+
259
+ // Size limit applies to the COMPLETE canonical envelope (including the hash field).
260
+ if (new TextEncoder().encode(canonicalJsonString(envelope)).length > MAX_ENVELOPE_BYTES) {
261
+ return fail('decoded envelope exceeds 6.75 MiB');
262
+ }
263
+
264
+ // The hash binds every prior field, excluding envelopeSha256 itself.
265
+ const { envelopeSha256: _boundHash, ...priorFields } = envelope;
266
+ void _boundHash;
267
+ if (sha256HexUtf8(canonicalJsonString(priorFields)) !== envelopeSha256) {
268
+ return fail('envelopeSha256 does not bind the prior fields');
269
+ }
270
+
271
+ return { ok: true, envelope };
272
+ }