@mcpg-dev/pulumi-policy 0.1.0-beta.11

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.
package/BUILD.bazel ADDED
@@ -0,0 +1,24 @@
1
+ load("@aspect_rules_js//js:defs.bzl", "js_library")
2
+ load("@npm//:defs.bzl", "npm_link_all_packages")
3
+
4
+ # This package's npm dependencies, linked the way node resolves them.
5
+ npm_link_all_packages(name = "node_modules")
6
+
7
+ # Every workspace package needs a `pkg` target: npm_link_all_packages resolves
8
+ # a `workspace:*` dependency to one, and the root link covers the whole
9
+ # workspace — so a package without it breaks the graph for everyone, not just
10
+ # for itself.
11
+ js_library(
12
+ name = "pkg",
13
+ srcs = glob(
14
+ ["**/*"],
15
+ allow_empty = True,
16
+ exclude = [
17
+ "node_modules/**",
18
+ "dist/**",
19
+ ".next/**",
20
+ "BUILD.bazel",
21
+ ],
22
+ ),
23
+ visibility = ["//visibility:public"],
24
+ )
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # @mcpg/pulumi-policy
2
+
3
+ A Pulumi **CrossGuard** policy pack that enforces the MCPG Kubernetes operator's
4
+ admission rules at `pulumi preview`, before anything reaches a cluster. It
5
+ inspects the `mcpg.dev` custom resources a program declares — gateways,
6
+ plugins, plugin sets, revocation lists, clusters, routes, tenants and plugin
7
+ mirrors — and reports the violations the operator's admission webhook would
8
+ return, locally and with no API-server round trip.
9
+
10
+ ## Quick start
11
+
12
+ The pack is referenced at preview/update time rather than imported into your
13
+ program:
14
+
15
+ ```bash
16
+ npm i --save-dev @mcpg/pulumi-policy
17
+ pulumi preview --policy-pack node_modules/@mcpg/pulumi-policy
18
+ ```
19
+
20
+ A violation is printed as `[<rule-id>] <message>` — for example
21
+ `[gateway.workloadIdentity.oneOf] workloadIdentity must be exactly one of
22
+ aws|gcp|azure|spiffe`. The rule id is stable, and is the same identifier the
23
+ MCPG Terraform provider's plan-time validators emit.
24
+
25
+ `main` points at `bin/index.js`, so a source checkout has to be compiled before
26
+ Pulumi can load it:
27
+
28
+ ```bash
29
+ npm install && npm run build # tsc → bin/
30
+ ```
31
+
32
+ ## Rules
33
+
34
+ | Kind | What it checks |
35
+ |---|---|
36
+ | `MCPGGateway` | `spec.image` present; `replicas` ≥ 1 when set; at most one of `workloadIdentity.{aws,gcp,azure,spiffe}`; a present `ingress` block carries a non-empty `ingressClassName`, at least one host, and non-empty `paths` per host. |
37
+ | `MCPGPlugin` | `pluginId` non-empty and reverse-DNS; `version` non-empty; `pluginClass` is a known plugin class; `oci.image` carries a registry component and a tag or digest; the artifact is digest-pinned **or** carries a cosign identity; `trust.signingKeyRef.secretName` present (Ed25519 signing is the mandatory baseline); a cosign block has a non-empty, anchored `certificateIdentityRegexp` and an `oidcIssuer`; an SLSA block sets `configMapName`, `sourceUri` and `sourceTag`. |
38
+ | `MCPGPluginSet` | `entries` non-empty; each entry has a reverse-DNS `id` and a `pluginRef.name`; ids unique; `capabilityGrants` keys name a declared entry and grant a non-empty capability list. |
39
+ | `MCPGRevocationList` | `version == 1`; every `artifactSha256` is 64 hex characters; every revocation carries a `reason`; no duplicate hashes. |
40
+ | `MCPGCluster` | the `single_node` backend takes no `config`; every other backend requires one; a plaintext coordinator address (`redis://`, `http://` Consul, non-`https://` etcd endpoints, NATS with `tls.require_tls: false`) is rejected unless `config.allow_insecure_transport: true`; `credentialRefs` entries carry `name` + `secretName` and unique names. |
41
+ | `MCPGRoute` | `gatewayRef.name` non-empty; `match.tools` lists at least one tool; tool ids non-empty and unique; no empty entry in `identityChain` / `policyChain` / `auditChain`. |
42
+ | `MCPGTenant` | `namespaces` non-empty, with entries non-empty and unique; each `allowedPlugins` entry sets `name` or `registryPrefix`; quota fields are non-negative; `identityAttribute.key` non-empty when that block is set. |
43
+ | `MCPGPluginMirror` | `endpoint.service.{namespace,name,port}` set; `upstream.registry` present and shaped like a registry host; `upstream.namespace` present; `auth.secretRef.secretName` present when `auth` is set. |
44
+
45
+ Two behaviours exist specifically so the preview verdict matches the cluster's:
46
+
47
+ - A `certificateIdentityRegexp` using lookahead, lookbehind or a back-reference
48
+ is rejected even though JavaScript would compile it, because the operator
49
+ compiles the pattern with an RE2-style engine that will not.
50
+ - SHA-256 hashes are accepted in either case, and duplicate detection is
51
+ case-insensitive, matching the operator's lowercase-then-deduplicate
52
+ behaviour.
53
+
54
+ Cross-resource and client-backed admission checks are deliberately **not**
55
+ mirrored: the per-tenant replica cap and plugin allowlist need cluster state,
56
+ and `image.tag` is filled in by the mutating webhook, so enforcing it at preview
57
+ would false-reject a program that relies on defaulting. Treat the pack as a
58
+ high-coverage local gate, not a guarantee of admission acceptance.
59
+
60
+ ## Validator library
61
+
62
+ The rules live in [`src/validators.ts`](src/validators.ts) as pure functions
63
+ with no Pulumi runtime dependency, which is what lets the same logic back both
64
+ the policy pack and a cross-tool contract test:
65
+
66
+ - `validateByType(type, spec)` — dispatch by Pulumi type token.
67
+ - `validateGatewaySpec`, `validatePluginSpec`, `validatePluginSetSpec`,
68
+ `validateRevocationListSpec`, `validateClusterSpec`, `validateRouteSpec`,
69
+ `validateTenantSpec`, `validatePluginMirrorSpec` — one per kind, each
70
+ returning `Finding[]` (`{ rule, message }`); an empty array means accept.
71
+ - `isSha256Hex`, `isAnchoredRegexp`, `countWorkloadIdentities`,
72
+ `findDuplicates` — the shared predicates.
73
+
74
+ The MCPG Terraform provider ships a Go port of these same functions, and a
75
+ shared fixture corpus asserts both sides return identical verdicts, so a rule
76
+ relaxed on one side fails the build.
77
+
78
+ ## Configuration
79
+
80
+ The pack takes no configuration. It registers one policy,
81
+ `mcpg-admission-mirror`, declared at `enforcementLevel: "mandatory"`, so a
82
+ violation blocks the update instead of emitting an advisory.
83
+
84
+ Resources are dispatched on the Pulumi type-token suffix (`…:MCPGGateway`,
85
+ `…:MCPGPlugin`, …) and read from `props.spec`. Any resource whose token matches
86
+ no rule set yields no findings, so the pack is safe to apply across a whole
87
+ stack.
88
+
89
+ ## Build and test
90
+
91
+ ```bash
92
+ npm run build # tsc → bin/
93
+ node --test bin/validators.test.js # unit tests over the pure validators
94
+ ```
95
+
96
+ Inside the MCPG workspace, from the repo root:
97
+
98
+ ```bash
99
+ pnpm --filter ./iac/pulumi/policy exec tsc -b --noEmit # tsc --noEmit
100
+ pnpm --filter ./iac/pulumi/policy test # builds, then runs the validator tests
101
+ ```
102
+
103
+ ## Licence
104
+
105
+ Apache-2.0.
106
+
107
+ ## See also
108
+
109
+ - <https://mcpg.dev/docs/self-hosting/pulumi> — installing MCPG with Pulumi,
110
+ including the `@mcpg/pulumi` components and the `@mcpg/pulumi-crds` typed SDK
111
+ this pack guards.
112
+ - <https://mcpg.dev/docs/reference/operator-crds> — the `mcpg.dev` CRDs and the
113
+ admission rules being mirrored.
114
+ - <https://mcpg.dev/docs/security/plugin-security> — signing, trust roots and
115
+ revocation, which the plugin and revocation-list rules enforce.
116
+ - <https://mcpg.dev/docs/self-hosting/terraform-provider> — the same rules
117
+ enforced as Terraform plan-time validation.
package/bin/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/bin/index.js ADDED
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const policy_1 = require("@pulumi/policy");
4
+ const validators_1 = require("./validators");
5
+ function report(findings, reportViolation) {
6
+ for (const f of findings) {
7
+ reportViolation(`[${f.rule}] ${f.message}`);
8
+ }
9
+ }
10
+ // CrossGuard pack mirroring the operator's admission webhook. Evaluated at
11
+ // `pulumi preview` (local — no operator round-trip). The same accept/reject
12
+ // verdicts the admission webhook would produce must hold here (the shared
13
+ // contract corpus under iac/contract/).
14
+ new policy_1.PolicyPack("mcpg-admission-mirror", {
15
+ policies: [
16
+ {
17
+ name: "mcpg-admission-mirror",
18
+ description: "Mirror the MCPG operator's CRD admission rules at preview time.",
19
+ enforcementLevel: "mandatory",
20
+ validateResource: (args, reportViolation) => {
21
+ const spec = args.props?.spec;
22
+ report((0, validators_1.validateByType)(args.type, spec), reportViolation);
23
+ },
24
+ },
25
+ ],
26
+ });
@@ -0,0 +1,25 @@
1
+ export interface Finding {
2
+ rule: string;
3
+ message: string;
4
+ }
5
+ export declare function isSha256Hex(s: string): boolean;
6
+ /** cosign certificateIdentityRegexp must be anchored with ^ and $, compile, AND
7
+ * be RE2-compatible (the operator compiles it with the Rust regex crate). */
8
+ export declare function isAnchoredRegexp(s: string): boolean;
9
+ export declare function countWorkloadIdentities(wi: any): number;
10
+ export declare function findDuplicates<T>(items: T[], key: (t: T) => string): string[];
11
+ export declare function validateGatewaySpec(spec: any): Finding[];
12
+ export declare function validatePluginSpec(spec: any): Finding[];
13
+ export declare function validatePluginSetSpec(spec: any): Finding[];
14
+ export declare function validateRevocationListSpec(spec: any): Finding[];
15
+ /** Mirrors the MCPGCluster admission rules (validators/cluster.rs). */
16
+ export declare function validateClusterSpec(spec: any): Finding[];
17
+ /** Mirrors the MCPGRoute admission rules (validators/route.rs). Tenant-unset is
18
+ * an admit-with-warning in the webhook, so it is NOT a reject here. */
19
+ export declare function validateRouteSpec(spec: any): Finding[];
20
+ /** Mirrors the MCPGTenant admission rules (validators/tenant.rs). */
21
+ export declare function validateTenantSpec(spec: any): Finding[];
22
+ /** Mirrors the MCPGPluginMirror admission rules (validators/plugin_mirror.rs). */
23
+ export declare function validatePluginMirrorSpec(spec: any): Finding[];
24
+ /** Dispatch by Pulumi resource type token (…:MCPGGateway etc.). */
25
+ export declare function validateByType(type: string, spec: any): Finding[];
@@ -0,0 +1,378 @@
1
+ "use strict";
2
+ // Admission-mirror validation helpers — PURE functions so they can be shared
3
+ // by the CrossGuard pack (preview-time) and the contract corpus, and unit
4
+ // tested without a Pulumi runtime. Mirror k8s/operator/src/admission/validators/.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isSha256Hex = isSha256Hex;
7
+ exports.isAnchoredRegexp = isAnchoredRegexp;
8
+ exports.countWorkloadIdentities = countWorkloadIdentities;
9
+ exports.findDuplicates = findDuplicates;
10
+ exports.validateGatewaySpec = validateGatewaySpec;
11
+ exports.validatePluginSpec = validatePluginSpec;
12
+ exports.validatePluginSetSpec = validatePluginSetSpec;
13
+ exports.validateRevocationListSpec = validateRevocationListSpec;
14
+ exports.validateClusterSpec = validateClusterSpec;
15
+ exports.validateRouteSpec = validateRouteSpec;
16
+ exports.validateTenantSpec = validateTenantSpec;
17
+ exports.validatePluginMirrorSpec = validatePluginMirrorSpec;
18
+ exports.validateByType = validateByType;
19
+ const blank = (s) => typeof s !== "string" || s.trim() === "";
20
+ // pluginClass values the operator accepts (mcpg_plugin_protocol::abi::ALL_KINDS,
21
+ // the single source of truth in libs/plugin-protocol/src/abi.rs).
22
+ const KNOWN_PLUGIN_CLASSES = [
23
+ "tool_gate", "transform", "identity_provider", "backend", "watch_strategy",
24
+ "http_route", "audit_sink", "log_sink", "telemetry_sink", "metrics_sink",
25
+ "store", "cache", "secret_provider", "config_provider", "policy_engine",
26
+ "cluster", "transport", "catalog_provider", "credential_issuer",
27
+ "approval_notifier", "content_store",
28
+ ];
29
+ // Constructs RE2 (the Go provider + the Rust `regex` crate the operator uses)
30
+ // cannot compile but JS RegExp can — lookahead/lookbehind + back-references.
31
+ // Rejecting them keeps the Pulumi verdict identical to the operator.
32
+ const RE2_INCOMPATIBLE = /\(\?<?[=!]|\\[1-9]|\\k</;
33
+ function isSha256Hex(s) {
34
+ // The operator accepts any 64 ascii-hexdigit string (case-insensitive) and
35
+ // lowercases for dedup — uppercase hashes are valid.
36
+ return /^[0-9a-fA-F]{64}$/.test(s);
37
+ }
38
+ /** cosign certificateIdentityRegexp must be anchored with ^ and $, compile, AND
39
+ * be RE2-compatible (the operator compiles it with the Rust regex crate). */
40
+ function isAnchoredRegexp(s) {
41
+ if (typeof s !== "string" || !s.startsWith("^") || !s.endsWith("$"))
42
+ return false;
43
+ if (RE2_INCOMPATIBLE.test(s))
44
+ return false;
45
+ try {
46
+ new RegExp(s);
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ const WORKLOAD_IDENTITY_KEYS = ["aws", "gcp", "azure", "spiffe"];
54
+ function countWorkloadIdentities(wi) {
55
+ if (!wi || typeof wi !== "object")
56
+ return 0;
57
+ return WORKLOAD_IDENTITY_KEYS.filter((k) => wi[k] != null).length;
58
+ }
59
+ function findDuplicates(items, key) {
60
+ const seen = new Set();
61
+ const dups = new Set();
62
+ for (const it of items) {
63
+ const k = key(it);
64
+ if (seen.has(k))
65
+ dups.add(k);
66
+ seen.add(k);
67
+ }
68
+ return [...dups];
69
+ }
70
+ function validateGatewaySpec(spec) {
71
+ const f = [];
72
+ if (!spec?.image)
73
+ f.push({ rule: "gateway.image.required", message: "spec.image is required" });
74
+ // replicas, when set, must be ≥ 1 (gateway.rs:65). Absent ⇒ defaulted ⇒ ok.
75
+ if (typeof spec?.replicas === "number" && spec.replicas < 1) {
76
+ f.push({ rule: "gateway.replicas.min", message: "spec.replicas must be ≥ 1" });
77
+ }
78
+ if (spec?.workloadIdentity && countWorkloadIdentities(spec.workloadIdentity) > 1) {
79
+ f.push({ rule: "gateway.workloadIdentity.oneOf", message: "workloadIdentity must be exactly one of aws|gcp|azure|spiffe" });
80
+ }
81
+ // Ingress sub-shape (gateway.rs:114-130) when an ingress block is present.
82
+ const ing = spec?.ingress;
83
+ if (ing) {
84
+ if (blank(ing.ingressClassName))
85
+ f.push({ rule: "gateway.ingress.ingressClassName", message: "spec.ingress.ingressClassName must not be empty" });
86
+ const hosts = ing.hosts ?? [];
87
+ if (hosts.length === 0)
88
+ f.push({ rule: "gateway.ingress.hosts", message: "spec.ingress.hosts must not be empty when ingress is set" });
89
+ hosts.forEach((h, i) => {
90
+ if (blank(h?.host))
91
+ f.push({ rule: "gateway.ingress.host", message: `spec.ingress.hosts[${i}].host is empty` });
92
+ if (!Array.isArray(h?.paths) || h.paths.length === 0)
93
+ f.push({ rule: "gateway.ingress.paths", message: `spec.ingress.hosts[${i}].paths must not be empty` });
94
+ });
95
+ }
96
+ // NB: image.tag-non-empty is enforced by the operator AFTER the mutating
97
+ // webhook defaults an empty/absent tag, so it is not a plan-time reject
98
+ // (would false-reject the rely-on-defaulting path). The per-gateway replica
99
+ // cap (tenant_guard) is a cross-resource, client-backed check — both are
100
+ // intentionally NOT mirrored at plan time.
101
+ return f;
102
+ }
103
+ function validatePluginSpec(spec) {
104
+ const f = [];
105
+ const oci = spec?.oci ?? {};
106
+ const trust = spec?.trust ?? {};
107
+ // Identity + class + version (plugin.rs:56-83).
108
+ if (blank(spec?.pluginId))
109
+ f.push({ rule: "plugin.pluginId.nonEmpty", message: "spec.pluginId must not be empty" });
110
+ else if (!spec.pluginId.includes("."))
111
+ f.push({ rule: "plugin.pluginId.reverseDns", message: "spec.pluginId is not reverse-DNS form (e.g. dev.mcpg.identity.workload)" });
112
+ if (blank(spec?.version))
113
+ f.push({ rule: "plugin.version.nonEmpty", message: "spec.version must not be empty" });
114
+ if (!KNOWN_PLUGIN_CLASSES.includes(spec?.pluginClass))
115
+ f.push({ rule: "plugin.pluginClass.known", message: `spec.pluginClass is not a known PluginClass` });
116
+ // OCI image reference shape (plugin.rs:84-99).
117
+ const img = typeof oci.image === "string" ? oci.image.trim() : "";
118
+ if (img === "")
119
+ f.push({ rule: "plugin.oci.image.nonEmpty", message: "spec.oci.image must not be empty" });
120
+ else {
121
+ if (!img.includes("/"))
122
+ f.push({ rule: "plugin.oci.image.registry", message: "spec.oci.image lacks a registry component (<registry>/<path>)" });
123
+ if (!img.includes(":") && !img.includes("@"))
124
+ f.push({ rule: "plugin.oci.image.tagOrDigest", message: "spec.oci.image lacks a tag or digest pin (:tag or @sha256:...)" });
125
+ }
126
+ // Trust anchor: digest pin OR cosign identity (plugin.rs:101-112).
127
+ const digestPinned = img.includes("@sha256:");
128
+ const hasCosign = trust.cosignIdentity != null;
129
+ if (!digestPinned && !hasCosign) {
130
+ f.push({ rule: "plugin.trust.anchor", message: "plugin must be digest-pinned OR carry a cosign identity" });
131
+ }
132
+ // signingKeyRef (Ed25519) is the MANDATORY baseline (plugin.rs:119-124).
133
+ const skr = trust.signingKeyRef;
134
+ if (blank(skr?.secretName)) {
135
+ f.push({ rule: "plugin.trust.signingKeyRef.required", message: "spec.trust.signingKeyRef.secretName is required (Ed25519 signing is the mandatory trust baseline)" });
136
+ }
137
+ // key defaults to release.pub; only an explicit empty key is invalid.
138
+ if (skr && skr.key !== undefined && blank(skr.key)) {
139
+ f.push({ rule: "plugin.trust.signingKeyRef.key", message: "spec.trust.signingKeyRef.key must not be empty when set" });
140
+ }
141
+ // Cosign sub-shape (plugin.rs:126-163).
142
+ if (hasCosign) {
143
+ if (blank(trust.cosignIdentity.certificateIdentityRegexp)) {
144
+ f.push({ rule: "plugin.cosign.regexpNonEmpty", message: "cosign certificateIdentityRegexp must not be empty" });
145
+ }
146
+ else if (!isAnchoredRegexp(trust.cosignIdentity.certificateIdentityRegexp)) {
147
+ f.push({ rule: "plugin.cosign.anchoredRegexp", message: "cosign certificateIdentityRegexp must be anchored with ^ and $ and compile (RE2)" });
148
+ }
149
+ if (blank(trust.cosignIdentity.oidcIssuer)) {
150
+ f.push({ rule: "plugin.cosign.oidcIssuer", message: "cosign oidcIssuer is required when cosign is set" });
151
+ }
152
+ }
153
+ // SLSA provenance sub-shape (plugin.rs:166-178).
154
+ const slsa = trust.slsaProvenance;
155
+ if (slsa) {
156
+ if (blank(slsa.configMapName))
157
+ f.push({ rule: "plugin.slsa.configMapName", message: "spec.trust.slsaProvenance.configMapName must not be empty" });
158
+ if (blank(slsa.sourceUri))
159
+ f.push({ rule: "plugin.slsa.sourceUri", message: "spec.trust.slsaProvenance.sourceUri must not be empty" });
160
+ if (blank(slsa.sourceTag))
161
+ f.push({ rule: "plugin.slsa.sourceTag", message: "spec.trust.slsaProvenance.sourceTag must not be empty" });
162
+ }
163
+ return f;
164
+ }
165
+ function validatePluginSetSpec(spec) {
166
+ const f = [];
167
+ const entries = spec?.entries ?? [];
168
+ if (entries.length === 0)
169
+ f.push({ rule: "pluginSet.entries.nonEmpty", message: "entries must be non-empty" });
170
+ const ids = new Set();
171
+ entries.forEach((e, i) => {
172
+ const id = e?.id;
173
+ if (blank(id))
174
+ f.push({ rule: "pluginSet.entries.id.nonEmpty", message: `spec.entries[${i}].id must not be empty` });
175
+ else if (!id.includes("."))
176
+ f.push({ rule: "pluginSet.entries.id.reverseDns", message: `spec.entries[${i}].id is not reverse-DNS form` });
177
+ if (blank(e?.pluginRef?.name))
178
+ f.push({ rule: "pluginSet.entries.pluginRef.name", message: `spec.entries[${i}].pluginRef.name must not be empty` });
179
+ if (typeof id === "string")
180
+ ids.add(id);
181
+ });
182
+ const dups = findDuplicates(entries, (e) => String(e?.id));
183
+ if (dups.length)
184
+ f.push({ rule: "pluginSet.entries.uniqueId", message: `duplicate entry ids: ${dups.join(",")}` });
185
+ // capabilityGrants is a MAP (id → [capabilities]); keys must name an entry
186
+ // id and each grant list must be non-empty (plugin_set.rs:85-100).
187
+ const grants = spec?.capabilityGrants;
188
+ if (grants && typeof grants === "object" && !Array.isArray(grants)) {
189
+ for (const id of Object.keys(grants)) {
190
+ if (!ids.has(id))
191
+ f.push({ rule: "pluginSet.capabilityGrants.unknownId", message: `capabilityGrants['${id}'] names an id not in entries` });
192
+ else if (!Array.isArray(grants[id]) || grants[id].length === 0)
193
+ f.push({ rule: "pluginSet.capabilityGrants.empty", message: `capabilityGrants['${id}'] must not be empty` });
194
+ }
195
+ }
196
+ return f;
197
+ }
198
+ function validateRevocationListSpec(spec) {
199
+ const f = [];
200
+ if (spec?.version !== 1)
201
+ f.push({ rule: "revocationList.version", message: "version must be 1" });
202
+ const revs = spec?.revocations ?? [];
203
+ for (const r of revs) {
204
+ if (!isSha256Hex(String(r?.artifactSha256 ?? ""))) {
205
+ f.push({ rule: "revocationList.sha256", message: "artifactSha256 must be 64 hex chars" });
206
+ }
207
+ // empty reason defeats the audit trail (revocation_list.rs:86).
208
+ if (blank(r?.reason)) {
209
+ f.push({ rule: "revocationList.reason", message: "revocation reason must not be empty" });
210
+ }
211
+ }
212
+ // dedup is case-insensitive in the operator (hashes lowercased), so ABCD…
213
+ // and abcd… collide.
214
+ const dups = findDuplicates(revs, (r) => String(r?.artifactSha256 ?? "").toLowerCase());
215
+ if (dups.length)
216
+ f.push({ rule: "revocationList.noDuplicates", message: `duplicate hashes: ${dups.join(",")}` });
217
+ return f;
218
+ }
219
+ /** Mirrors the MCPGCluster admission rules (validators/cluster.rs). */
220
+ function validateClusterSpec(spec) {
221
+ const f = [];
222
+ const backend = spec?.backend ?? "single_node";
223
+ const singleNode = backend === "" || backend === "single_node";
224
+ const configEmpty = !spec?.config || Object.keys(spec.config).length === 0;
225
+ if (singleNode && !configEmpty) {
226
+ f.push({ rule: "cluster.singleNode.noConfig", message: "spec.config must be empty for the single_node backend (it takes no parameters)" });
227
+ }
228
+ if (!singleNode && configEmpty) {
229
+ f.push({ rule: "cluster.backend.configRequired", message: "spec.config must not be empty for an external backend — it needs at least a connection address" });
230
+ }
231
+ // Transport security: reject a plaintext coordinator unless opted out
232
+ // with `spec.config.allow_insecure_transport: true`. Mirrors the gateway
233
+ // boot guard + the operator admission webhook (validators/cluster.rs).
234
+ if (!singleNode && !configEmpty && spec?.config?.allow_insecure_transport !== true) {
235
+ const c = spec.config;
236
+ const lead = (s) => (typeof s === "string" ? s.replace(/^\s+/, "") : "");
237
+ let insecure = null;
238
+ if (backend === "redis" && lead(c?.url).startsWith("redis://")) {
239
+ insecure = "the redis `url` uses the plaintext `redis://` scheme (use `rediss://`)";
240
+ }
241
+ else if (backend === "consul" && lead(c?.address).startsWith("http://")) {
242
+ insecure = "the consul `address` uses the plaintext `http://` scheme (use `https://`)";
243
+ }
244
+ else if (backend === "etcd" && Array.isArray(c?.endpoints) &&
245
+ c.endpoints.some((e) => !lead(e).startsWith("https://"))) {
246
+ insecure = "an etcd `endpoint` is not an `https://` URL (use `https://`)";
247
+ }
248
+ else if (backend === "nats" && c?.tls?.require_tls === false) {
249
+ insecure = "nats `tls.require_tls` is set to `false` (plaintext)";
250
+ }
251
+ if (insecure) {
252
+ f.push({ rule: "cluster.transport.insecure", message: `spec.config: ${insecure}. Set spec.config.allow_insecure_transport: true to accept plaintext (local/dev only).` });
253
+ }
254
+ }
255
+ const seen = new Set();
256
+ const refs = spec?.credentialRefs ?? [];
257
+ refs.forEach((c, i) => {
258
+ if (blank(c?.name)) {
259
+ f.push({ rule: "cluster.credentialRefs.name", message: `spec.credentialRefs[${i}].name must not be empty` });
260
+ return;
261
+ }
262
+ if (blank(c?.secretName)) {
263
+ f.push({ rule: "cluster.credentialRefs.secretName", message: `spec.credentialRefs[${i}].secretName must not be empty` });
264
+ }
265
+ if (seen.has(c.name)) {
266
+ f.push({ rule: "cluster.credentialRefs.uniqueName", message: `spec.credentialRefs name '${c.name}' is duplicated` });
267
+ }
268
+ seen.add(c.name);
269
+ });
270
+ return f;
271
+ }
272
+ /** Mirrors the MCPGRoute admission rules (validators/route.rs). Tenant-unset is
273
+ * an admit-with-warning in the webhook, so it is NOT a reject here. */
274
+ function validateRouteSpec(spec) {
275
+ const f = [];
276
+ if (blank(spec?.gatewayRef?.name))
277
+ f.push({ rule: "route.gatewayRef.name", message: "spec.gatewayRef.name must not be empty" });
278
+ const tools = spec?.match?.tools ?? [];
279
+ if (tools.length === 0)
280
+ f.push({ rule: "route.match.tools.nonEmpty", message: "spec.match.tools must list at least one tool" });
281
+ const seen = new Set();
282
+ tools.forEach((t, i) => {
283
+ const id = typeof t?.id === "string" ? t.id.trim() : "";
284
+ if (id === "") {
285
+ f.push({ rule: "route.match.tools.id", message: `spec.match.tools[${i}].id must not be empty` });
286
+ return;
287
+ }
288
+ if (seen.has(id))
289
+ f.push({ rule: "route.match.tools.uniqueId", message: `spec.match.tools contains duplicate tool id '${id}'` });
290
+ seen.add(id);
291
+ });
292
+ for (const chain of ["identityChain", "policyChain", "auditChain"]) {
293
+ const ids = spec?.[chain] ?? [];
294
+ ids.forEach((id, i) => {
295
+ if (blank(id))
296
+ f.push({ rule: "route.chain.nonEmptyId", message: `spec.${chain}[${i}] must not be empty` });
297
+ });
298
+ }
299
+ return f;
300
+ }
301
+ /** Mirrors the MCPGTenant admission rules (validators/tenant.rs). */
302
+ function validateTenantSpec(spec) {
303
+ const f = [];
304
+ const namespaces = spec?.namespaces ?? [];
305
+ if (namespaces.length === 0)
306
+ f.push({ rule: "tenant.namespaces.nonEmpty", message: "spec.namespaces must not be empty" });
307
+ const seen = new Set();
308
+ for (const ns of namespaces) {
309
+ if (blank(ns)) {
310
+ f.push({ rule: "tenant.namespaces.nonEmptyEntry", message: "spec.namespaces[] entries must not be empty" });
311
+ continue;
312
+ }
313
+ if (seen.has(ns))
314
+ f.push({ rule: "tenant.namespaces.unique", message: `spec.namespaces lists '${ns}' more than once` });
315
+ seen.add(ns);
316
+ }
317
+ (spec?.allowedPlugins ?? []).forEach((a, i) => {
318
+ const nameSet = !blank(a?.name);
319
+ const prefixSet = !blank(a?.registryPrefix);
320
+ if (!nameSet && !prefixSet)
321
+ f.push({ rule: "tenant.allowedPlugins.matcher", message: `spec.allowedPlugins[${i}] must set name or registryPrefix` });
322
+ });
323
+ if (spec?.quotas) {
324
+ for (const field of ["maxGateways", "maxPluginSets", "maxRoutes", "maxReplicasPerGateway"]) {
325
+ const v = spec.quotas[field];
326
+ if (typeof v === "number" && v < 0)
327
+ f.push({ rule: "tenant.quotas.nonNegative", message: `spec.quotas.${field} must be ≥ 0` });
328
+ }
329
+ }
330
+ if (spec?.identityAttribute && blank(spec.identityAttribute.key)) {
331
+ f.push({ rule: "tenant.identityAttribute.key", message: "spec.identityAttribute.key must not be empty when set" });
332
+ }
333
+ return f;
334
+ }
335
+ /** Mirrors the MCPGPluginMirror admission rules (validators/plugin_mirror.rs). */
336
+ function validatePluginMirrorSpec(spec) {
337
+ const f = [];
338
+ const svc = spec?.endpoint?.service ?? {};
339
+ if (blank(svc.namespace))
340
+ f.push({ rule: "pluginMirror.endpoint.service.namespace", message: "spec.endpoint.service.namespace must not be empty" });
341
+ if (blank(svc.name))
342
+ f.push({ rule: "pluginMirror.endpoint.service.name", message: "spec.endpoint.service.name must not be empty" });
343
+ if (!svc.port)
344
+ f.push({ rule: "pluginMirror.endpoint.service.port", message: "spec.endpoint.service.port must be in 1..=65535" });
345
+ const up = spec?.upstream ?? {};
346
+ if (blank(up.registry)) {
347
+ f.push({ rule: "pluginMirror.upstream.registry", message: "spec.upstream.registry must not be empty" });
348
+ }
349
+ else if (!up.registry.includes(".") && !up.registry.includes(":")) {
350
+ f.push({ rule: "pluginMirror.upstream.registryHost", message: `spec.upstream.registry '${up.registry}' does not look like a registry host` });
351
+ }
352
+ if (blank(up.namespace))
353
+ f.push({ rule: "pluginMirror.upstream.namespace", message: "spec.upstream.namespace must not be empty" });
354
+ if (spec?.auth && blank(spec.auth.secretRef?.secretName)) {
355
+ f.push({ rule: "pluginMirror.auth.secretName", message: "spec.auth.secretRef.secretName must not be empty when auth is set" });
356
+ }
357
+ return f;
358
+ }
359
+ /** Dispatch by Pulumi resource type token (…:MCPGGateway etc.). */
360
+ function validateByType(type, spec) {
361
+ if (type.endsWith(":MCPGGateway"))
362
+ return validateGatewaySpec(spec);
363
+ if (type.endsWith(":MCPGPlugin"))
364
+ return validatePluginSpec(spec);
365
+ if (type.endsWith(":MCPGPluginSet"))
366
+ return validatePluginSetSpec(spec);
367
+ if (type.endsWith(":MCPGRevocationList"))
368
+ return validateRevocationListSpec(spec);
369
+ if (type.endsWith(":MCPGCluster"))
370
+ return validateClusterSpec(spec);
371
+ if (type.endsWith(":MCPGRoute"))
372
+ return validateRouteSpec(spec);
373
+ if (type.endsWith(":MCPGTenant"))
374
+ return validateTenantSpec(spec);
375
+ if (type.endsWith(":MCPGPluginMirror"))
376
+ return validatePluginMirrorSpec(spec);
377
+ return [];
378
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ // Unit tests for the admission-mirror validators (pure; node:test, no deps).
7
+ const node_test_1 = require("node:test");
8
+ const strict_1 = __importDefault(require("node:assert/strict"));
9
+ const validators_1 = require("./validators");
10
+ (0, node_test_1.test)("gateway: two workload identities rejected", () => {
11
+ const f = (0, validators_1.validateGatewaySpec)({ image: {}, workloadIdentity: { aws: {}, gcp: {} } });
12
+ strict_1.default.ok(f.some((x) => x.rule === "gateway.workloadIdentity.oneOf"));
13
+ });
14
+ (0, node_test_1.test)("gateway: minimal accepted", () => {
15
+ strict_1.default.deepEqual((0, validators_1.validateGatewaySpec)({ image: { repository: "x", tag: "y" } }), []);
16
+ });
17
+ (0, node_test_1.test)("plugin: tag-only with no cosign rejected", () => {
18
+ const f = (0, validators_1.validatePluginSpec)({ oci: { image: "ghcr.io/x:1.0" }, trust: {} });
19
+ strict_1.default.ok(f.some((x) => x.rule === "plugin.trust.anchor"));
20
+ });
21
+ (0, node_test_1.test)("plugin: cosign regexp must be anchored", () => {
22
+ const f = (0, validators_1.validatePluginSpec)({
23
+ oci: { image: "ghcr.io/x:1.0" },
24
+ trust: { cosignIdentity: { certificateIdentityRegexp: "https://github.com/.+", oidcIssuer: "x" } },
25
+ });
26
+ strict_1.default.ok(f.some((x) => x.rule === "plugin.cosign.anchoredRegexp"));
27
+ });
28
+ (0, node_test_1.test)("plugin: fully-formed digest-pinned accepted", () => {
29
+ strict_1.default.deepEqual((0, validators_1.validatePluginSpec)({
30
+ pluginId: "dev.mcpg.backend.sql",
31
+ pluginClass: "backend",
32
+ version: "1.4.2",
33
+ oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) },
34
+ trust: { signingKeyRef: { secretName: "k" } },
35
+ }), []);
36
+ });
37
+ (0, node_test_1.test)("plugin: bad class + non-reverse-DNS id rejected (adm-3)", () => {
38
+ const f = (0, validators_1.validatePluginSpec)({
39
+ pluginId: "noDotId",
40
+ pluginClass: "frobnicator",
41
+ version: "1.0",
42
+ oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) },
43
+ trust: { signingKeyRef: { secretName: "k" } },
44
+ });
45
+ strict_1.default.ok(f.some((x) => x.rule === "plugin.pluginId.reverseDns"));
46
+ strict_1.default.ok(f.some((x) => x.rule === "plugin.pluginClass.known"));
47
+ });
48
+ (0, node_test_1.test)("plugin: missing signingKeyRef rejected (trust-9)", () => {
49
+ const f = (0, validators_1.validatePluginSpec)({ oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) }, trust: {} });
50
+ strict_1.default.ok(f.some((x) => x.rule === "plugin.trust.signingKeyRef.required"));
51
+ });
52
+ (0, node_test_1.test)("revocationList: bad sha + duplicates rejected", () => {
53
+ const f = (0, validators_1.validateRevocationListSpec)({
54
+ version: 1,
55
+ revocations: [{ artifactSha256: "xyz" }, { artifactSha256: "a".repeat(64) }, { artifactSha256: "a".repeat(64) }],
56
+ });
57
+ strict_1.default.ok(f.some((x) => x.rule === "revocationList.sha256"));
58
+ strict_1.default.ok(f.some((x) => x.rule === "revocationList.noDuplicates"));
59
+ });
60
+ (0, node_test_1.test)("helpers", () => {
61
+ // case-insensitive 64-hex (the operator accepts uppercase — adm-2/trust-5/parity-5).
62
+ strict_1.default.equal((0, validators_1.isSha256Hex)("a".repeat(64)), true);
63
+ strict_1.default.equal((0, validators_1.isSha256Hex)("A".repeat(64)), true);
64
+ strict_1.default.equal((0, validators_1.isSha256Hex)("a".repeat(63)), false);
65
+ strict_1.default.equal((0, validators_1.isSha256Hex)("g".repeat(64)), false);
66
+ strict_1.default.equal((0, validators_1.isAnchoredRegexp)("^x$"), true);
67
+ strict_1.default.equal((0, validators_1.isAnchoredRegexp)("x"), false);
68
+ // RE2-incompatible lookahead rejected to match Go/operator (parity-1).
69
+ strict_1.default.equal((0, validators_1.isAnchoredRegexp)("^(?=.*x).*$"), false);
70
+ });
71
+ (0, node_test_1.test)("dispatch: ours validated, non-MCPG ignored", () => {
72
+ strict_1.default.ok((0, validators_1.validateByType)("kubernetes:mcpg.dev/v1alpha1:MCPGGateway", {}).length > 0);
73
+ strict_1.default.deepEqual((0, validators_1.validateByType)("kubernetes:core/v1:ConfigMap", {}), []);
74
+ });
package/copy.bara.sky ADDED
@@ -0,0 +1,98 @@
1
+ # GENERATED by tools/open-source/gen-copybara.mjs — DO NOT EDIT.
2
+ # One-way mirror of the @mcpg-dev/pulumi-policy npm package from the mcpg monorepo to
3
+ # its own public repository. Contains NO version literals — regenerate
4
+ # (`--write-npm`) only when the package's identity or layout changes.
5
+ #
6
+ # Licence: Apache-2.0. Repo: github.com/mcpg-dev/pulumi-mcpg-policy.
7
+ # History is SQUASHED and re-authored to the bot, so no internal commit
8
+ # history, subjects, or contributor identities are exposed.
9
+
10
+ # Reads "version" from the package's own package.json at MIRROR time: sets
11
+ # the public release message and the PKG_VERSION label the destination
12
+ # resolves in tag_name — so no version is baked into this file. Matched at
13
+ # two-space indentation so a nested "version" (the CRD SDK's generated
14
+ # provider block carries one) can never be mistaken for the package's.
15
+ def _release_meta(ctx):
16
+ content = ctx.read_path(ctx.new_path("package.json"))
17
+ version = ""
18
+ for line in content.split("\n"):
19
+ if not line.startswith(' "version"'):
20
+ continue
21
+ rest = line.split(":", 1)
22
+ if len(rest) < 2:
23
+ continue
24
+ version = rest[1].strip().strip(",").strip('"')
25
+ break
26
+ if version == "":
27
+ fail("mirror: could not read top-level \"version\" from package.json")
28
+ ctx.set_message("Release v" + version + "\n")
29
+ ctx.add_label("PKG_VERSION", version, hidden = True)
30
+
31
+ core.workflow(
32
+ name = "default",
33
+ mode = "SQUASH",
34
+
35
+ origin = git.origin(
36
+ url = "https://github.com/mcpg-dev/source-code.git",
37
+ ref = "main",
38
+ ),
39
+ # push the code to main AND create the v{version} tag the public repo's
40
+ # release.yml fires on to self-publish to npm.
41
+ destination = git.github_destination(
42
+ url = "https://github.com/mcpg-dev/pulumi-mcpg-policy.git",
43
+ push = "main",
44
+ tag_name = "v${PKG_VERSION}",
45
+ tag_msg = "Release v${PKG_VERSION}",
46
+ ),
47
+
48
+ # The package subtree, minus internal artifacts: nx project.json,
49
+ # CHANGELOG.md (internal commits/PRs), AI-agent working rules, build
50
+ # output, and installed dependencies. Sources and tests DO travel — the
51
+ # tests use only node:test and node:assert, so they build and run in the
52
+ # public repo with nothing extra.
53
+ origin_files = glob(["iac/pulumi/policy/**"], exclude = [
54
+ "iac/pulumi/policy/copy.bara.sky", "iac/pulumi/policy/project.json",
55
+ "iac/pulumi/policy/BUILD.bazel", "iac/pulumi/policy/**/BUILD.bazel",
56
+ "iac/pulumi/policy/CHANGELOG.md", "iac/pulumi/policy/**/AGENTS.md",
57
+ "iac/pulumi/policy/bin/**", "iac/pulumi/policy/node_modules/**",
58
+ "iac/pulumi/policy/**/*.tsbuildinfo",
59
+ # Destination-managed names never travel from the origin (the
60
+ # destination excludes them, and Copybara refuses uncovered writes).
61
+ "iac/pulumi/policy/.gitignore", "iac/pulumi/policy/CONTRIBUTING.md",
62
+ "iac/pulumi/policy/package-lock.json", "iac/pulumi/policy/.github/**",
63
+ "iac/pulumi/policy/**/DEFERRED.md", "iac/pulumi/policy/**/TODO.md",
64
+ "iac/pulumi/policy/**/NOTES.md", "iac/pulumi/policy/**/ROADMAP.md",
65
+ "iac/pulumi/policy/**/INTERNAL*.md",
66
+ ]),
67
+
68
+ # Repo-only scaffold (CI, release shim, CONTRIBUTING, .gitignore) is
69
+ # seeded at bootstrap and excluded here so the mirror never deletes or
70
+ # clobbers it. LICENSE is NOT excluded — the mirror manages it.
71
+ destination_files = glob(["**"], exclude = [
72
+ ".github/**", ".gitignore", "CONTRIBUTING.md", "package-lock.json",
73
+ ]),
74
+
75
+ # Re-author every commit to the bot — no real contributor identity leaks.
76
+ authoring = authoring.overwrite("mcpg-bot <oss-bot@mcpg.dev>"),
77
+
78
+ transformations = [
79
+ # 1) subdir -> repo root.
80
+ core.move("iac/pulumi/policy", ""),
81
+
82
+ # 2) point "repository" at this package's own public repo. Left
83
+ # naming the monorepo, a published package sends every reader
84
+ # to a repository they cannot open.
85
+ core.transform([
86
+ core.replace(
87
+ before = "\"repository\": \"https://github.com/mcpg-dev/source-code\"",
88
+ after = "\"repository\": \"https://github.com/mcpg-dev/pulumi-mcpg-policy\"",
89
+ paths = glob(["package.json"]),
90
+ ),
91
+ ], reversal = []),
92
+
93
+ # 3) release message + PKG_VERSION label from the mirrored
94
+ # manifest — replaces the squashed history's message entirely: NO
95
+ # commit list, NO authors, NO subjects.
96
+ _release_meta,
97
+ ],
98
+ )
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@mcpg-dev/pulumi-policy",
3
+ "version": "0.1.0-beta.11",
4
+ "repository": "https://github.com/mcpg-dev/source-code",
5
+ "description": "Pulumi CrossGuard policy pack mirroring MCPG operator admission rules.",
6
+ "license": "Apache-2.0",
7
+ "main": "bin/index.js",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "typecheck": "tsc --noEmit",
11
+ "test": "node --test bin/validators.test.js"
12
+ },
13
+ "dependencies": {
14
+ "@pulumi/policy": "^1.13.0",
15
+ "@pulumi/pulumi": "^3.142.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^20",
19
+ "typescript": "^5.4.0"
20
+ }
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { PolicyPack } from "@pulumi/policy";
2
+ import { validateByType, Finding } from "./validators";
3
+
4
+ function report(findings: Finding[], reportViolation: (message: string) => void): void {
5
+ for (const f of findings) {
6
+ reportViolation(`[${f.rule}] ${f.message}`);
7
+ }
8
+ }
9
+
10
+ // CrossGuard pack mirroring the operator's admission webhook. Evaluated at
11
+ // `pulumi preview` (local — no operator round-trip). The same accept/reject
12
+ // verdicts the admission webhook would produce must hold here (the shared
13
+ // contract corpus under iac/contract/).
14
+ new PolicyPack("mcpg-admission-mirror", {
15
+ policies: [
16
+ {
17
+ name: "mcpg-admission-mirror",
18
+ description: "Mirror the MCPG operator's CRD admission rules at preview time.",
19
+ enforcementLevel: "mandatory",
20
+ validateResource: (args, reportViolation) => {
21
+ const spec = (args.props as any)?.spec;
22
+ report(validateByType(args.type, spec), reportViolation);
23
+ },
24
+ },
25
+ ],
26
+ });
@@ -0,0 +1,89 @@
1
+ // Unit tests for the admission-mirror validators (pure; node:test, no deps).
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import {
5
+ validateByType,
6
+ validateGatewaySpec,
7
+ validatePluginSpec,
8
+ validateRevocationListSpec,
9
+ isAnchoredRegexp,
10
+ isSha256Hex,
11
+ } from "./validators";
12
+
13
+ test("gateway: two workload identities rejected", () => {
14
+ const f = validateGatewaySpec({ image: {}, workloadIdentity: { aws: {}, gcp: {} } });
15
+ assert.ok(f.some((x) => x.rule === "gateway.workloadIdentity.oneOf"));
16
+ });
17
+
18
+ test("gateway: minimal accepted", () => {
19
+ assert.deepEqual(validateGatewaySpec({ image: { repository: "x", tag: "y" } }), []);
20
+ });
21
+
22
+ test("plugin: tag-only with no cosign rejected", () => {
23
+ const f = validatePluginSpec({ oci: { image: "ghcr.io/x:1.0" }, trust: {} });
24
+ assert.ok(f.some((x) => x.rule === "plugin.trust.anchor"));
25
+ });
26
+
27
+ test("plugin: cosign regexp must be anchored", () => {
28
+ const f = validatePluginSpec({
29
+ oci: { image: "ghcr.io/x:1.0" },
30
+ trust: { cosignIdentity: { certificateIdentityRegexp: "https://github.com/.+", oidcIssuer: "x" } },
31
+ });
32
+ assert.ok(f.some((x) => x.rule === "plugin.cosign.anchoredRegexp"));
33
+ });
34
+
35
+ test("plugin: fully-formed digest-pinned accepted", () => {
36
+ assert.deepEqual(
37
+ validatePluginSpec({
38
+ pluginId: "dev.mcpg.backend.sql",
39
+ pluginClass: "backend",
40
+ version: "1.4.2",
41
+ oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) },
42
+ trust: { signingKeyRef: { secretName: "k" } },
43
+ }),
44
+ [],
45
+ );
46
+ });
47
+
48
+ test("plugin: bad class + non-reverse-DNS id rejected (adm-3)", () => {
49
+ const f = validatePluginSpec({
50
+ pluginId: "noDotId",
51
+ pluginClass: "frobnicator",
52
+ version: "1.0",
53
+ oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) },
54
+ trust: { signingKeyRef: { secretName: "k" } },
55
+ });
56
+ assert.ok(f.some((x) => x.rule === "plugin.pluginId.reverseDns"));
57
+ assert.ok(f.some((x) => x.rule === "plugin.pluginClass.known"));
58
+ });
59
+
60
+ test("plugin: missing signingKeyRef rejected (trust-9)", () => {
61
+ const f = validatePluginSpec({ oci: { image: "ghcr.io/x@sha256:" + "a".repeat(64) }, trust: {} });
62
+ assert.ok(f.some((x) => x.rule === "plugin.trust.signingKeyRef.required"));
63
+ });
64
+
65
+ test("revocationList: bad sha + duplicates rejected", () => {
66
+ const f = validateRevocationListSpec({
67
+ version: 1,
68
+ revocations: [{ artifactSha256: "xyz" }, { artifactSha256: "a".repeat(64) }, { artifactSha256: "a".repeat(64) }],
69
+ });
70
+ assert.ok(f.some((x) => x.rule === "revocationList.sha256"));
71
+ assert.ok(f.some((x) => x.rule === "revocationList.noDuplicates"));
72
+ });
73
+
74
+ test("helpers", () => {
75
+ // case-insensitive 64-hex (the operator accepts uppercase — adm-2/trust-5/parity-5).
76
+ assert.equal(isSha256Hex("a".repeat(64)), true);
77
+ assert.equal(isSha256Hex("A".repeat(64)), true);
78
+ assert.equal(isSha256Hex("a".repeat(63)), false);
79
+ assert.equal(isSha256Hex("g".repeat(64)), false);
80
+ assert.equal(isAnchoredRegexp("^x$"), true);
81
+ assert.equal(isAnchoredRegexp("x"), false);
82
+ // RE2-incompatible lookahead rejected to match Go/operator (parity-1).
83
+ assert.equal(isAnchoredRegexp("^(?=.*x).*$"), false);
84
+ });
85
+
86
+ test("dispatch: ours validated, non-MCPG ignored", () => {
87
+ assert.ok(validateByType("kubernetes:mcpg.dev/v1alpha1:MCPGGateway", {}).length > 0);
88
+ assert.deepEqual(validateByType("kubernetes:core/v1:ConfigMap", {}), []);
89
+ });
@@ -0,0 +1,335 @@
1
+ // Admission-mirror validation helpers — PURE functions so they can be shared
2
+ // by the CrossGuard pack (preview-time) and the contract corpus, and unit
3
+ // tested without a Pulumi runtime. Mirror k8s/operator/src/admission/validators/.
4
+
5
+ export interface Finding {
6
+ rule: string;
7
+ message: string;
8
+ }
9
+
10
+ const blank = (s: any): boolean => typeof s !== "string" || s.trim() === "";
11
+
12
+ // pluginClass values the operator accepts (mcpg_plugin_protocol::abi::ALL_KINDS,
13
+ // the single source of truth in libs/plugin-protocol/src/abi.rs).
14
+ const KNOWN_PLUGIN_CLASSES = [
15
+ "tool_gate", "transform", "identity_provider", "backend", "watch_strategy",
16
+ "http_route", "audit_sink", "log_sink", "telemetry_sink", "metrics_sink",
17
+ "store", "cache", "secret_provider", "config_provider", "policy_engine",
18
+ "cluster", "transport", "catalog_provider", "credential_issuer",
19
+ "approval_notifier", "content_store",
20
+ ];
21
+
22
+ // Constructs RE2 (the Go provider + the Rust `regex` crate the operator uses)
23
+ // cannot compile but JS RegExp can — lookahead/lookbehind + back-references.
24
+ // Rejecting them keeps the Pulumi verdict identical to the operator.
25
+ const RE2_INCOMPATIBLE = /\(\?<?[=!]|\\[1-9]|\\k</;
26
+
27
+ export function isSha256Hex(s: string): boolean {
28
+ // The operator accepts any 64 ascii-hexdigit string (case-insensitive) and
29
+ // lowercases for dedup — uppercase hashes are valid.
30
+ return /^[0-9a-fA-F]{64}$/.test(s);
31
+ }
32
+
33
+ /** cosign certificateIdentityRegexp must be anchored with ^ and $, compile, AND
34
+ * be RE2-compatible (the operator compiles it with the Rust regex crate). */
35
+ export function isAnchoredRegexp(s: string): boolean {
36
+ if (typeof s !== "string" || !s.startsWith("^") || !s.endsWith("$")) return false;
37
+ if (RE2_INCOMPATIBLE.test(s)) return false;
38
+ try {
39
+ new RegExp(s);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ const WORKLOAD_IDENTITY_KEYS = ["aws", "gcp", "azure", "spiffe"];
47
+ export function countWorkloadIdentities(wi: any): number {
48
+ if (!wi || typeof wi !== "object") return 0;
49
+ return WORKLOAD_IDENTITY_KEYS.filter((k) => wi[k] != null).length;
50
+ }
51
+
52
+ export function findDuplicates<T>(items: T[], key: (t: T) => string): string[] {
53
+ const seen = new Set<string>();
54
+ const dups = new Set<string>();
55
+ for (const it of items) {
56
+ const k = key(it);
57
+ if (seen.has(k)) dups.add(k);
58
+ seen.add(k);
59
+ }
60
+ return [...dups];
61
+ }
62
+
63
+ export function validateGatewaySpec(spec: any): Finding[] {
64
+ const f: Finding[] = [];
65
+ if (!spec?.image) f.push({ rule: "gateway.image.required", message: "spec.image is required" });
66
+ // replicas, when set, must be ≥ 1 (gateway.rs:65). Absent ⇒ defaulted ⇒ ok.
67
+ if (typeof spec?.replicas === "number" && spec.replicas < 1) {
68
+ f.push({ rule: "gateway.replicas.min", message: "spec.replicas must be ≥ 1" });
69
+ }
70
+ if (spec?.workloadIdentity && countWorkloadIdentities(spec.workloadIdentity) > 1) {
71
+ f.push({ rule: "gateway.workloadIdentity.oneOf", message: "workloadIdentity must be exactly one of aws|gcp|azure|spiffe" });
72
+ }
73
+ // Ingress sub-shape (gateway.rs:114-130) when an ingress block is present.
74
+ const ing = spec?.ingress;
75
+ if (ing) {
76
+ if (blank(ing.ingressClassName)) f.push({ rule: "gateway.ingress.ingressClassName", message: "spec.ingress.ingressClassName must not be empty" });
77
+ const hosts: any[] = ing.hosts ?? [];
78
+ if (hosts.length === 0) f.push({ rule: "gateway.ingress.hosts", message: "spec.ingress.hosts must not be empty when ingress is set" });
79
+ hosts.forEach((h, i) => {
80
+ if (blank(h?.host)) f.push({ rule: "gateway.ingress.host", message: `spec.ingress.hosts[${i}].host is empty` });
81
+ if (!Array.isArray(h?.paths) || h.paths.length === 0) f.push({ rule: "gateway.ingress.paths", message: `spec.ingress.hosts[${i}].paths must not be empty` });
82
+ });
83
+ }
84
+ // NB: image.tag-non-empty is enforced by the operator AFTER the mutating
85
+ // webhook defaults an empty/absent tag, so it is not a plan-time reject
86
+ // (would false-reject the rely-on-defaulting path). The per-gateway replica
87
+ // cap (tenant_guard) is a cross-resource, client-backed check — both are
88
+ // intentionally NOT mirrored at plan time.
89
+ return f;
90
+ }
91
+
92
+ export function validatePluginSpec(spec: any): Finding[] {
93
+ const f: Finding[] = [];
94
+ const oci = spec?.oci ?? {};
95
+ const trust = spec?.trust ?? {};
96
+
97
+ // Identity + class + version (plugin.rs:56-83).
98
+ if (blank(spec?.pluginId)) f.push({ rule: "plugin.pluginId.nonEmpty", message: "spec.pluginId must not be empty" });
99
+ else if (!spec.pluginId.includes(".")) f.push({ rule: "plugin.pluginId.reverseDns", message: "spec.pluginId is not reverse-DNS form (e.g. dev.mcpg.identity.workload)" });
100
+ if (blank(spec?.version)) f.push({ rule: "plugin.version.nonEmpty", message: "spec.version must not be empty" });
101
+ if (!KNOWN_PLUGIN_CLASSES.includes(spec?.pluginClass)) f.push({ rule: "plugin.pluginClass.known", message: `spec.pluginClass is not a known PluginClass` });
102
+
103
+ // OCI image reference shape (plugin.rs:84-99).
104
+ const img = typeof oci.image === "string" ? oci.image.trim() : "";
105
+ if (img === "") f.push({ rule: "plugin.oci.image.nonEmpty", message: "spec.oci.image must not be empty" });
106
+ else {
107
+ if (!img.includes("/")) f.push({ rule: "plugin.oci.image.registry", message: "spec.oci.image lacks a registry component (<registry>/<path>)" });
108
+ if (!img.includes(":") && !img.includes("@")) f.push({ rule: "plugin.oci.image.tagOrDigest", message: "spec.oci.image lacks a tag or digest pin (:tag or @sha256:...)" });
109
+ }
110
+
111
+ // Trust anchor: digest pin OR cosign identity (plugin.rs:101-112).
112
+ const digestPinned = img.includes("@sha256:");
113
+ const hasCosign = trust.cosignIdentity != null;
114
+ if (!digestPinned && !hasCosign) {
115
+ f.push({ rule: "plugin.trust.anchor", message: "plugin must be digest-pinned OR carry a cosign identity" });
116
+ }
117
+
118
+ // signingKeyRef (Ed25519) is the MANDATORY baseline (plugin.rs:119-124).
119
+ const skr = trust.signingKeyRef;
120
+ if (blank(skr?.secretName)) {
121
+ f.push({ rule: "plugin.trust.signingKeyRef.required", message: "spec.trust.signingKeyRef.secretName is required (Ed25519 signing is the mandatory trust baseline)" });
122
+ }
123
+ // key defaults to release.pub; only an explicit empty key is invalid.
124
+ if (skr && skr.key !== undefined && blank(skr.key)) {
125
+ f.push({ rule: "plugin.trust.signingKeyRef.key", message: "spec.trust.signingKeyRef.key must not be empty when set" });
126
+ }
127
+
128
+ // Cosign sub-shape (plugin.rs:126-163).
129
+ if (hasCosign) {
130
+ if (blank(trust.cosignIdentity.certificateIdentityRegexp)) {
131
+ f.push({ rule: "plugin.cosign.regexpNonEmpty", message: "cosign certificateIdentityRegexp must not be empty" });
132
+ } else if (!isAnchoredRegexp(trust.cosignIdentity.certificateIdentityRegexp)) {
133
+ f.push({ rule: "plugin.cosign.anchoredRegexp", message: "cosign certificateIdentityRegexp must be anchored with ^ and $ and compile (RE2)" });
134
+ }
135
+ if (blank(trust.cosignIdentity.oidcIssuer)) {
136
+ f.push({ rule: "plugin.cosign.oidcIssuer", message: "cosign oidcIssuer is required when cosign is set" });
137
+ }
138
+ }
139
+
140
+ // SLSA provenance sub-shape (plugin.rs:166-178).
141
+ const slsa = trust.slsaProvenance;
142
+ if (slsa) {
143
+ if (blank(slsa.configMapName)) f.push({ rule: "plugin.slsa.configMapName", message: "spec.trust.slsaProvenance.configMapName must not be empty" });
144
+ if (blank(slsa.sourceUri)) f.push({ rule: "plugin.slsa.sourceUri", message: "spec.trust.slsaProvenance.sourceUri must not be empty" });
145
+ if (blank(slsa.sourceTag)) f.push({ rule: "plugin.slsa.sourceTag", message: "spec.trust.slsaProvenance.sourceTag must not be empty" });
146
+ }
147
+ return f;
148
+ }
149
+
150
+ export function validatePluginSetSpec(spec: any): Finding[] {
151
+ const f: Finding[] = [];
152
+ const entries: any[] = spec?.entries ?? [];
153
+ if (entries.length === 0) f.push({ rule: "pluginSet.entries.nonEmpty", message: "entries must be non-empty" });
154
+ const ids = new Set<string>();
155
+ entries.forEach((e, i) => {
156
+ const id = e?.id;
157
+ if (blank(id)) f.push({ rule: "pluginSet.entries.id.nonEmpty", message: `spec.entries[${i}].id must not be empty` });
158
+ else if (!id.includes(".")) f.push({ rule: "pluginSet.entries.id.reverseDns", message: `spec.entries[${i}].id is not reverse-DNS form` });
159
+ if (blank(e?.pluginRef?.name)) f.push({ rule: "pluginSet.entries.pluginRef.name", message: `spec.entries[${i}].pluginRef.name must not be empty` });
160
+ if (typeof id === "string") ids.add(id);
161
+ });
162
+ const dups = findDuplicates(entries, (e) => String(e?.id));
163
+ if (dups.length) f.push({ rule: "pluginSet.entries.uniqueId", message: `duplicate entry ids: ${dups.join(",")}` });
164
+ // capabilityGrants is a MAP (id → [capabilities]); keys must name an entry
165
+ // id and each grant list must be non-empty (plugin_set.rs:85-100).
166
+ const grants = spec?.capabilityGrants;
167
+ if (grants && typeof grants === "object" && !Array.isArray(grants)) {
168
+ for (const id of Object.keys(grants)) {
169
+ if (!ids.has(id)) f.push({ rule: "pluginSet.capabilityGrants.unknownId", message: `capabilityGrants['${id}'] names an id not in entries` });
170
+ else if (!Array.isArray(grants[id]) || grants[id].length === 0) f.push({ rule: "pluginSet.capabilityGrants.empty", message: `capabilityGrants['${id}'] must not be empty` });
171
+ }
172
+ }
173
+ return f;
174
+ }
175
+
176
+ export function validateRevocationListSpec(spec: any): Finding[] {
177
+ const f: Finding[] = [];
178
+ if (spec?.version !== 1) f.push({ rule: "revocationList.version", message: "version must be 1" });
179
+ const revs: any[] = spec?.revocations ?? [];
180
+ for (const r of revs) {
181
+ if (!isSha256Hex(String(r?.artifactSha256 ?? ""))) {
182
+ f.push({ rule: "revocationList.sha256", message: "artifactSha256 must be 64 hex chars" });
183
+ }
184
+ // empty reason defeats the audit trail (revocation_list.rs:86).
185
+ if (blank(r?.reason)) {
186
+ f.push({ rule: "revocationList.reason", message: "revocation reason must not be empty" });
187
+ }
188
+ }
189
+ // dedup is case-insensitive in the operator (hashes lowercased), so ABCD…
190
+ // and abcd… collide.
191
+ const dups = findDuplicates(revs, (r) => String(r?.artifactSha256 ?? "").toLowerCase());
192
+ if (dups.length) f.push({ rule: "revocationList.noDuplicates", message: `duplicate hashes: ${dups.join(",")}` });
193
+ return f;
194
+ }
195
+
196
+ /** Mirrors the MCPGCluster admission rules (validators/cluster.rs). */
197
+ export function validateClusterSpec(spec: any): Finding[] {
198
+ const f: Finding[] = [];
199
+ const backend: string = spec?.backend ?? "single_node";
200
+ const singleNode = backend === "" || backend === "single_node";
201
+ const configEmpty = !spec?.config || Object.keys(spec.config).length === 0;
202
+ if (singleNode && !configEmpty) {
203
+ f.push({ rule: "cluster.singleNode.noConfig", message: "spec.config must be empty for the single_node backend (it takes no parameters)" });
204
+ }
205
+ if (!singleNode && configEmpty) {
206
+ f.push({ rule: "cluster.backend.configRequired", message: "spec.config must not be empty for an external backend — it needs at least a connection address" });
207
+ }
208
+ // Transport security: reject a plaintext coordinator unless opted out
209
+ // with `spec.config.allow_insecure_transport: true`. Mirrors the gateway
210
+ // boot guard + the operator admission webhook (validators/cluster.rs).
211
+ if (!singleNode && !configEmpty && spec?.config?.allow_insecure_transport !== true) {
212
+ const c = spec.config;
213
+ const lead = (s: any) => (typeof s === "string" ? s.replace(/^\s+/, "") : "");
214
+ let insecure: string | null = null;
215
+ if (backend === "redis" && lead(c?.url).startsWith("redis://")) {
216
+ insecure = "the redis `url` uses the plaintext `redis://` scheme (use `rediss://`)";
217
+ } else if (backend === "consul" && lead(c?.address).startsWith("http://")) {
218
+ insecure = "the consul `address` uses the plaintext `http://` scheme (use `https://`)";
219
+ } else if (backend === "etcd" && Array.isArray(c?.endpoints) &&
220
+ c.endpoints.some((e: any) => !lead(e).startsWith("https://"))) {
221
+ insecure = "an etcd `endpoint` is not an `https://` URL (use `https://`)";
222
+ } else if (backend === "nats" && c?.tls?.require_tls === false) {
223
+ insecure = "nats `tls.require_tls` is set to `false` (plaintext)";
224
+ }
225
+ if (insecure) {
226
+ f.push({ rule: "cluster.transport.insecure", message: `spec.config: ${insecure}. Set spec.config.allow_insecure_transport: true to accept plaintext (local/dev only).` });
227
+ }
228
+ }
229
+ const seen = new Set<string>();
230
+ const refs: any[] = spec?.credentialRefs ?? [];
231
+ refs.forEach((c, i) => {
232
+ if (blank(c?.name)) {
233
+ f.push({ rule: "cluster.credentialRefs.name", message: `spec.credentialRefs[${i}].name must not be empty` });
234
+ return;
235
+ }
236
+ if (blank(c?.secretName)) {
237
+ f.push({ rule: "cluster.credentialRefs.secretName", message: `spec.credentialRefs[${i}].secretName must not be empty` });
238
+ }
239
+ if (seen.has(c.name)) {
240
+ f.push({ rule: "cluster.credentialRefs.uniqueName", message: `spec.credentialRefs name '${c.name}' is duplicated` });
241
+ }
242
+ seen.add(c.name);
243
+ });
244
+ return f;
245
+ }
246
+
247
+ /** Mirrors the MCPGRoute admission rules (validators/route.rs). Tenant-unset is
248
+ * an admit-with-warning in the webhook, so it is NOT a reject here. */
249
+ export function validateRouteSpec(spec: any): Finding[] {
250
+ const f: Finding[] = [];
251
+ if (blank(spec?.gatewayRef?.name)) f.push({ rule: "route.gatewayRef.name", message: "spec.gatewayRef.name must not be empty" });
252
+ const tools: any[] = spec?.match?.tools ?? [];
253
+ if (tools.length === 0) f.push({ rule: "route.match.tools.nonEmpty", message: "spec.match.tools must list at least one tool" });
254
+ const seen = new Set<string>();
255
+ tools.forEach((t, i) => {
256
+ const id = typeof t?.id === "string" ? t.id.trim() : "";
257
+ if (id === "") {
258
+ f.push({ rule: "route.match.tools.id", message: `spec.match.tools[${i}].id must not be empty` });
259
+ return;
260
+ }
261
+ if (seen.has(id)) f.push({ rule: "route.match.tools.uniqueId", message: `spec.match.tools contains duplicate tool id '${id}'` });
262
+ seen.add(id);
263
+ });
264
+ for (const chain of ["identityChain", "policyChain", "auditChain"]) {
265
+ const ids: any[] = spec?.[chain] ?? [];
266
+ ids.forEach((id, i) => {
267
+ if (blank(id)) f.push({ rule: "route.chain.nonEmptyId", message: `spec.${chain}[${i}] must not be empty` });
268
+ });
269
+ }
270
+ return f;
271
+ }
272
+
273
+ /** Mirrors the MCPGTenant admission rules (validators/tenant.rs). */
274
+ export function validateTenantSpec(spec: any): Finding[] {
275
+ const f: Finding[] = [];
276
+ const namespaces: any[] = spec?.namespaces ?? [];
277
+ if (namespaces.length === 0) f.push({ rule: "tenant.namespaces.nonEmpty", message: "spec.namespaces must not be empty" });
278
+ const seen = new Set<string>();
279
+ for (const ns of namespaces) {
280
+ if (blank(ns)) {
281
+ f.push({ rule: "tenant.namespaces.nonEmptyEntry", message: "spec.namespaces[] entries must not be empty" });
282
+ continue;
283
+ }
284
+ if (seen.has(ns)) f.push({ rule: "tenant.namespaces.unique", message: `spec.namespaces lists '${ns}' more than once` });
285
+ seen.add(ns);
286
+ }
287
+ (spec?.allowedPlugins ?? []).forEach((a: any, i: number) => {
288
+ const nameSet = !blank(a?.name);
289
+ const prefixSet = !blank(a?.registryPrefix);
290
+ if (!nameSet && !prefixSet) f.push({ rule: "tenant.allowedPlugins.matcher", message: `spec.allowedPlugins[${i}] must set name or registryPrefix` });
291
+ });
292
+ if (spec?.quotas) {
293
+ for (const field of ["maxGateways", "maxPluginSets", "maxRoutes", "maxReplicasPerGateway"]) {
294
+ const v = spec.quotas[field];
295
+ if (typeof v === "number" && v < 0) f.push({ rule: "tenant.quotas.nonNegative", message: `spec.quotas.${field} must be ≥ 0` });
296
+ }
297
+ }
298
+ if (spec?.identityAttribute && blank(spec.identityAttribute.key)) {
299
+ f.push({ rule: "tenant.identityAttribute.key", message: "spec.identityAttribute.key must not be empty when set" });
300
+ }
301
+ return f;
302
+ }
303
+
304
+ /** Mirrors the MCPGPluginMirror admission rules (validators/plugin_mirror.rs). */
305
+ export function validatePluginMirrorSpec(spec: any): Finding[] {
306
+ const f: Finding[] = [];
307
+ const svc = spec?.endpoint?.service ?? {};
308
+ if (blank(svc.namespace)) f.push({ rule: "pluginMirror.endpoint.service.namespace", message: "spec.endpoint.service.namespace must not be empty" });
309
+ if (blank(svc.name)) f.push({ rule: "pluginMirror.endpoint.service.name", message: "spec.endpoint.service.name must not be empty" });
310
+ if (!svc.port) f.push({ rule: "pluginMirror.endpoint.service.port", message: "spec.endpoint.service.port must be in 1..=65535" });
311
+ const up = spec?.upstream ?? {};
312
+ if (blank(up.registry)) {
313
+ f.push({ rule: "pluginMirror.upstream.registry", message: "spec.upstream.registry must not be empty" });
314
+ } else if (!up.registry.includes(".") && !up.registry.includes(":")) {
315
+ f.push({ rule: "pluginMirror.upstream.registryHost", message: `spec.upstream.registry '${up.registry}' does not look like a registry host` });
316
+ }
317
+ if (blank(up.namespace)) f.push({ rule: "pluginMirror.upstream.namespace", message: "spec.upstream.namespace must not be empty" });
318
+ if (spec?.auth && blank(spec.auth.secretRef?.secretName)) {
319
+ f.push({ rule: "pluginMirror.auth.secretName", message: "spec.auth.secretRef.secretName must not be empty when auth is set" });
320
+ }
321
+ return f;
322
+ }
323
+
324
+ /** Dispatch by Pulumi resource type token (…:MCPGGateway etc.). */
325
+ export function validateByType(type: string, spec: any): Finding[] {
326
+ if (type.endsWith(":MCPGGateway")) return validateGatewaySpec(spec);
327
+ if (type.endsWith(":MCPGPlugin")) return validatePluginSpec(spec);
328
+ if (type.endsWith(":MCPGPluginSet")) return validatePluginSetSpec(spec);
329
+ if (type.endsWith(":MCPGRevocationList")) return validateRevocationListSpec(spec);
330
+ if (type.endsWith(":MCPGCluster")) return validateClusterSpec(spec);
331
+ if (type.endsWith(":MCPGRoute")) return validateRouteSpec(spec);
332
+ if (type.endsWith(":MCPGTenant")) return validateTenantSpec(spec);
333
+ if (type.endsWith(":MCPGPluginMirror")) return validatePluginMirrorSpec(spec);
334
+ return [];
335
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2020",
4
+ "module": "commonjs",
5
+ "moduleResolution": "node",
6
+ "declaration": true,
7
+ "outDir": "bin",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true
13
+ },
14
+ "include": ["src/**/*.ts"]
15
+ }