@intentius/chant 0.25.0 → 0.27.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 (56) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.d.ts +6 -5
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/config.d.ts +13 -10
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/discovery/entity-wire-codec.d.ts +14 -9
  8. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  9. package/dist/discovery/graph.d.ts.map +1 -1
  10. package/dist/discovery/sandbox/config-wire.d.ts +16 -0
  11. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  12. package/dist/discovery/sandbox/driver.d.ts +29 -0
  13. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/fork.d.ts +18 -0
  15. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  16. package/dist/discovery/sandbox/policy-run.d.ts +33 -0
  17. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
  18. package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
  19. package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
  20. package/dist/intrinsic-interpolation.d.ts.map +1 -1
  21. package/dist/lexicon-output.d.ts.map +1 -1
  22. package/dist/lint/policy-import.d.ts +50 -0
  23. package/dist/lint/policy-import.d.ts.map +1 -0
  24. package/dist/lint/policy-sandbox.d.ts +89 -0
  25. package/dist/lint/policy-sandbox.d.ts.map +1 -0
  26. package/dist/lint/policy.d.ts +12 -1
  27. package/dist/lint/policy.d.ts.map +1 -1
  28. package/dist/stack-output.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/build.test.ts +77 -1
  31. package/src/build.ts +15 -2
  32. package/src/cli/commands/build.test.ts +16 -2
  33. package/src/cli/commands/build.ts +42 -13
  34. package/src/cli/main.test.ts +99 -17
  35. package/src/cli/main.ts +111 -13
  36. package/src/config.test.ts +28 -1
  37. package/src/config.ts +16 -12
  38. package/src/discovery/entity-wire-codec.ts +14 -9
  39. package/src/discovery/graph.test.ts +40 -1
  40. package/src/discovery/graph.ts +8 -2
  41. package/src/discovery/sandbox/config-wire.ts +21 -0
  42. package/src/discovery/sandbox/driver.ts +132 -0
  43. package/src/discovery/sandbox/fork.ts +39 -1
  44. package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
  45. package/src/discovery/sandbox/policy-run.ts +180 -0
  46. package/src/discovery/sandbox/policy-wire.test.ts +310 -0
  47. package/src/discovery/sandbox/policy-wire.ts +277 -0
  48. package/src/intrinsic-interpolation.test.ts +27 -1
  49. package/src/intrinsic-interpolation.ts +10 -2
  50. package/src/lexicon-output.test.ts +36 -0
  51. package/src/lexicon-output.ts +12 -2
  52. package/src/lint/policy-import.ts +70 -0
  53. package/src/lint/policy-sandbox.ts +123 -0
  54. package/src/lint/policy.ts +20 -2
  55. package/src/stack-output.test.ts +118 -0
  56. package/src/stack-output.ts +21 -5
@@ -0,0 +1,325 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, writeFile, rm, realpath, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { spawn } from "node:child_process";
6
+ import type { Declarable } from "../../declarable";
7
+ import { createResource } from "../../runtime";
8
+ import { resolveAttrRefs } from "../resolve";
9
+ import { ENV_VAR } from "../../env";
10
+ import { loadPolicyChecks } from "../../lint/policy";
11
+ import {
12
+ armSandboxPolicyExecution,
13
+ isSandboxPolicyExecutionArmed,
14
+ resetSandboxPolicyExecutionForTests,
15
+ runProjectPolicies,
16
+ type ProjectPolicyRun,
17
+ } from "../../lint/policy-sandbox";
18
+
19
+ /**
20
+ * chant #1131 — a `lint.policies` module is project-authored code, and under
21
+ * `--sandbox` neither its top level nor its `check` function may run in the
22
+ * CLI's process.
23
+ *
24
+ * Same shape of proof as `./fold-boundary.test.ts` (#1093) and
25
+ * `./config-boundary.test.ts` (#1113): the fixture policy sets a `globalThis`
26
+ * marker at module top level and another inside `check`, and a marker set in
27
+ * the sandboxed child cannot reach this process. **The unarmed half is asserted
28
+ * first**, so the probe is proven capable of firing before the armed half
29
+ * asserts it stays clean.
30
+ *
31
+ * Fixtures go to a fresh tmpdir per test — never the source tree — so no two
32
+ * tests share a module path and nothing bleeds through Node's module cache.
33
+ */
34
+
35
+ const TOP_MARKER = "__chant1131PolicyTopLevel";
36
+ const CHECK_MARKER = "__chant1131PolicyCheckRan";
37
+
38
+ type MarkerHost = Record<string, boolean | undefined>;
39
+
40
+ function marker(name: string): boolean | undefined {
41
+ return (globalThis as unknown as MarkerHost)[name];
42
+ }
43
+
44
+ /** A small, realistic build result: two entities with a cross-reference, plus one lexicon's serialized output. */
45
+ function buildResultFixture(): ProjectPolicyRun["buildResult"] {
46
+ const Vpc = createResource("Test::Vpc", "test", { vpcId: "VpcId" });
47
+ const Ingress = createResource("Test::Ingress", "test", {});
48
+ const vpc = new Vpc({ CidrBlock: "10.0.0.0/16" });
49
+ const ingress = new Ingress({ VpcId: (vpc as unknown as Record<string, unknown>).vpcId, tls: [] });
50
+ const entities = new Map<string, Declarable>([
51
+ ["Vpc", vpc as unknown as Declarable],
52
+ ["Ingress", ingress as unknown as Declarable],
53
+ ]);
54
+ resolveAttrRefs(entities);
55
+
56
+ return {
57
+ outputs: new Map<string, string>([["test", "kind: Ingress\nmetadata:\n name: storefront\n"]]),
58
+ entities,
59
+ warnings: [],
60
+ errors: [],
61
+ sourceFileCount: 2,
62
+ } as unknown as ProjectPolicyRun["buildResult"];
63
+ }
64
+
65
+ describe("lint.policies execution under --sandbox (chant #1131)", () => {
66
+ let testDir: string;
67
+ let savedEnv: string | undefined;
68
+
69
+ beforeEach(async () => {
70
+ const dir = join(tmpdir(), `chant-1131-policy-${Date.now()}-${Math.random()}`);
71
+ await mkdir(dir, { recursive: true });
72
+ testDir = await realpath(dir);
73
+ delete (globalThis as unknown as MarkerHost)[TOP_MARKER];
74
+ delete (globalThis as unknown as MarkerHost)[CHECK_MARKER];
75
+ savedEnv = process.env[ENV_VAR];
76
+ resetSandboxPolicyExecutionForTests();
77
+ });
78
+
79
+ afterEach(async () => {
80
+ delete (globalThis as unknown as MarkerHost)[TOP_MARKER];
81
+ delete (globalThis as unknown as MarkerHost)[CHECK_MARKER];
82
+ if (savedEnv === undefined) delete process.env[ENV_VAR];
83
+ else process.env[ENV_VAR] = savedEnv;
84
+ resetSandboxPolicyExecutionForTests();
85
+ await rm(testDir, { recursive: true, force: true });
86
+ });
87
+
88
+ /** A policy that announces its own execution twice: once at module top level, once per check call. */
89
+ async function writeMarkerPolicy(name = "org.ts", body?: string): Promise<string> {
90
+ await writeFile(
91
+ join(testDir, name),
92
+ body ??
93
+ `globalThis[${JSON.stringify(TOP_MARKER)}] = true;\n` +
94
+ `export const tlsRequired = {\n` +
95
+ ` id: "ORG-TLS",\n` +
96
+ ` description: "ingress must terminate TLS",\n` +
97
+ ` check(ctx) {\n` +
98
+ ` globalThis[${JSON.stringify(CHECK_MARKER)}] = true;\n` +
99
+ ` const out = [];\n` +
100
+ ` for (const [entityName, entity] of ctx.entities) {\n` +
101
+ ` const tls = entity.props && entity.props.tls;\n` +
102
+ ` if (Array.isArray(tls) && tls.length === 0) {\n` +
103
+ ` out.push({ checkId: "ORG-TLS", severity: "error", message: entityName + " has no TLS in " + ctx.env, entity: entityName });\n` +
104
+ ` }\n` +
105
+ ` }\n` +
106
+ ` if (ctx.outputs.get("test").includes("storefront")) {\n` +
107
+ ` out.push({ checkId: "ORG-TLS", severity: "warning", message: "saw storefront in the output" });\n` +
108
+ ` }\n` +
109
+ ` return out;\n` +
110
+ ` },\n` +
111
+ `};\n`,
112
+ );
113
+ return name;
114
+ }
115
+
116
+ function run(policies: string[], env = "prod"): ReturnType<typeof runProjectPolicies> {
117
+ return runProjectPolicies({ policies, configDir: testDir, buildResult: buildResultFixture(), env });
118
+ }
119
+
120
+ test("without --sandbox the policy DOES run in this process (both probes fire)", async () => {
121
+ await writeMarkerPolicy();
122
+
123
+ const diags = await run(["org.ts"]);
124
+
125
+ expect(isSandboxPolicyExecutionArmed()).toBe(false);
126
+ expect(marker(TOP_MARKER), "the policy module's top level ran in this process").toBe(true);
127
+ expect(marker(CHECK_MARKER), "the check function ran in this process").toBe(true);
128
+ expect(diags).toEqual([
129
+ { checkId: "ORG-TLS", severity: "error", message: "Ingress has no TLS in prod", entity: "Ingress" },
130
+ { checkId: "ORG-TLS", severity: "warning", message: "saw storefront in the output" },
131
+ ]);
132
+ });
133
+
134
+ test("armed, the same policy runs in the child: no markers here, IDENTICAL diagnostics", async () => {
135
+ await writeMarkerPolicy();
136
+ const plain = await run(["org.ts"]);
137
+
138
+ delete (globalThis as unknown as MarkerHost)[TOP_MARKER];
139
+ delete (globalThis as unknown as MarkerHost)[CHECK_MARKER];
140
+ armSandboxPolicyExecution();
141
+ const sandboxed = await run(["org.ts"]);
142
+
143
+ expect(marker(TOP_MARKER), "the policy module's top level must NOT run in the CLI process").toBeUndefined();
144
+ expect(marker(CHECK_MARKER), "the check function must NOT run in the CLI process").toBeUndefined();
145
+ expect(sandboxed).toEqual(plain);
146
+ });
147
+
148
+ test("several policy modules keep their declaration order across the boundary", async () => {
149
+ for (const [file, id] of [
150
+ ["a.ts", "A"],
151
+ ["b.ts", "B"],
152
+ ] as const) {
153
+ await writeFile(
154
+ join(testDir, file),
155
+ `export const c = { id: ${JSON.stringify(id)}, description: "d", check: () => [{ checkId: ${JSON.stringify(id)}, severity: "info", message: "m" }] };\n`,
156
+ );
157
+ }
158
+
159
+ const plain = await run(["a.ts", "b.ts"]);
160
+ armSandboxPolicyExecution();
161
+ const sandboxed = await run(["a.ts", "b.ts"]);
162
+
163
+ expect(plain.map((d) => d.checkId)).toEqual(["A", "B"]);
164
+ expect(sandboxed).toEqual(plain);
165
+ });
166
+
167
+ test("a policy that reads outside the project is denied, naming the policy file", async () => {
168
+ await writeMarkerPolicy(
169
+ "org.ts",
170
+ `import { readFileSync } from "node:fs";\n` +
171
+ `const stolen = readFileSync("/etc/hosts", "utf-8");\n` +
172
+ `export const c = { id: "X", description: "d", check: () => [{ checkId: "X", severity: "info", message: stolen.slice(0, 3) }] };\n`,
173
+ );
174
+ armSandboxPolicyExecution();
175
+
176
+ await expect(run(["org.ts"])).rejects.toThrow(/sandbox denied FileSystemRead \(\/etc\/hosts\)/);
177
+ await expect(run(["org.ts"])).rejects.toThrow(/org\.ts/);
178
+ });
179
+
180
+ test("a policy that writes a file is denied — the boundary costs this pattern, deliberately", async () => {
181
+ // A policy that drops a compliance report next to the build is a plausible
182
+ // thing to have written. Under `--sandbox` it fails, loudly, rather than
183
+ // succeeding and making the flag a lie.
184
+ await writeMarkerPolicy(
185
+ "org.ts",
186
+ `import { writeFileSync } from "node:fs";\n` +
187
+ `export const c = { id: "X", description: "d", check: () => { writeFileSync(${JSON.stringify(join(testDir, "report.json"))}, "{}"); return []; } };\n`,
188
+ );
189
+ armSandboxPolicyExecution();
190
+
191
+ await expect(run(["org.ts"])).rejects.toThrow(/sandbox denied FileSystemWrite/);
192
+ await expect(readFile(join(testDir, "report.json"))).rejects.toThrow();
193
+ });
194
+
195
+ test("a policy that tries to spawn a process is denied", async () => {
196
+ await writeMarkerPolicy(
197
+ "org.ts",
198
+ `import { execSync } from "node:child_process";\n` +
199
+ `export const c = { id: "X", description: "d", check: () => { execSync("echo pwned"); return []; } };\n`,
200
+ );
201
+ armSandboxPolicyExecution();
202
+
203
+ await expect(run(["org.ts"])).rejects.toThrow(/sandbox denied/);
204
+ });
205
+
206
+ test("the child's environment is scrubbed — but --env still reaches a policy", async () => {
207
+ process.env[ENV_VAR] = "prod";
208
+ process.env.CHANT_1131_SECRET = "hunter2";
209
+ try {
210
+ await writeMarkerPolicy(
211
+ "org.ts",
212
+ `export const c = {\n` +
213
+ ` id: "ENV", description: "d",\n` +
214
+ ` check: (ctx) => [\n` +
215
+ ` { checkId: "ENV", severity: "info", message: "ctx.env=" + ctx.env },\n` +
216
+ ` { checkId: "ENV", severity: "info", message: "CHANT_ENV=" + (process.env[${JSON.stringify(ENV_VAR)}] ?? "unset") },\n` +
217
+ ` { checkId: "ENV", severity: "info", message: "secret=" + (process.env.CHANT_1131_SECRET ?? "scrubbed") },\n` +
218
+ ` ],\n` +
219
+ `};\n`,
220
+ );
221
+ armSandboxPolicyExecution();
222
+
223
+ const diags = await run(["org.ts"], "prod");
224
+
225
+ expect(diags.map((d) => d.message)).toEqual(["ctx.env=prod", "CHANT_ENV=prod", "secret=scrubbed"]);
226
+ } finally {
227
+ delete process.env.CHANT_1131_SECRET;
228
+ }
229
+ });
230
+
231
+ test("a diagnostic that is not data is refused, naming the policy module and the key path", async () => {
232
+ await writeMarkerPolicy(
233
+ "org.ts",
234
+ `export const c = { id: "X", description: "d", check: () => [{ checkId: "X", severity: "error", message: "m", fix: () => 1 }] };\n`,
235
+ );
236
+ armSandboxPolicyExecution();
237
+
238
+ const promise = run(["org.ts"]);
239
+ await expect(promise).rejects.toThrow(/org\.ts \[0\]\.fix: a function/);
240
+ await expect(run(["org.ts"])).rejects.toThrow(/Cannot run lint\.policies inside the --sandbox boundary/);
241
+ });
242
+
243
+ test("a diagnostic with a bad severity is refused rather than printed as one", async () => {
244
+ await writeMarkerPolicy(
245
+ "org.ts",
246
+ `export const c = { id: "X", description: "d", check: () => [{ checkId: "X", severity: "fatal", message: "m" }] };\n`,
247
+ );
248
+ armSandboxPolicyExecution();
249
+
250
+ await expect(run(["org.ts"])).rejects.toThrow(/severity: not one of "error", "warning", "info"/);
251
+ });
252
+
253
+ test("a policy module that throws at import fails naming the file", async () => {
254
+ await writeMarkerPolicy("org.ts", `throw new Error("policy exploded");\n`);
255
+ armSandboxPolicyExecution();
256
+
257
+ await expect(run(["org.ts"])).rejects.toThrow(/policy exploded/);
258
+ });
259
+
260
+ test("a check that throws fails the build rather than silently producing nothing", async () => {
261
+ await writeMarkerPolicy(
262
+ "org.ts",
263
+ `export const c = { id: "X", description: "d", check: () => { throw new Error("check exploded"); } };\n`,
264
+ );
265
+ armSandboxPolicyExecution();
266
+
267
+ await expect(run(["org.ts"])).rejects.toThrow(/check exploded/);
268
+ });
269
+
270
+ test("armed, loadPolicyChecks refuses rather than importing project code here", async () => {
271
+ await writeMarkerPolicy();
272
+ armSandboxPolicyExecution();
273
+
274
+ await expect(loadPolicyChecks(["org.ts"], testDir)).rejects.toThrow(/Cannot load lint\.policies .* under --sandbox/);
275
+ expect(marker(TOP_MARKER)).toBeUndefined();
276
+ });
277
+
278
+ test("no policies declared means no child at all", async () => {
279
+ armSandboxPolicyExecution();
280
+
281
+ expect(await run([])).toEqual([]);
282
+ });
283
+
284
+ test("a program that runs a policy child EXITS — the child is not left holding the event loop", async () => {
285
+ // Found the hard way while measuring #1131's cost: unlike the run and
286
+ // config drivers, the policy driver keeps a `message` listener registered
287
+ // in order to RECEIVE its input, and a `message` listener refs the IPC
288
+ // channel — so the child stayed alive after answering, and its live channel
289
+ // kept the parent alive too. `chant build` hid it (`cli/main.ts` ends in
290
+ // `process.exit`); anything embedding chant as a library hung forever.
291
+ //
292
+ // Asserted the only way that actually proves it: run a real program in a
293
+ // real process and require it to exit on its own.
294
+ await writeFile(
295
+ join(testDir, "org.ts"),
296
+ `export const c = { id: "X", description: "d", check: () => [] };\n`,
297
+ );
298
+ const script = join(testDir, "driver.mts");
299
+ await writeFile(
300
+ script,
301
+ `import { runPoliciesSandboxed } from ${JSON.stringify(join(import.meta.dirname, "policy-run.ts"))};\n` +
302
+ `await runPoliciesSandboxed({\n` +
303
+ ` policyPaths: [${JSON.stringify(join(testDir, "org.ts"))}],\n` +
304
+ ` buildResult: { outputs: new Map(), entities: new Map(), warnings: [], errors: [], sourceFileCount: 0 },\n` +
305
+ ` projectRoot: ${JSON.stringify(testDir)},\n` +
306
+ `});\n` +
307
+ `console.log("done");\n`,
308
+ );
309
+
310
+ const exited = await new Promise<{ code: number | null; timedOut: boolean }>((resolvePromise) => {
311
+ const child = spawn(process.execPath, ["--import", "tsx", script], { stdio: "ignore" });
312
+ const timer = setTimeout(() => {
313
+ child.kill("SIGKILL");
314
+ resolvePromise({ code: null, timedOut: true });
315
+ }, 60_000);
316
+ child.on("exit", (code) => {
317
+ clearTimeout(timer);
318
+ resolvePromise({ code, timedOut: false });
319
+ });
320
+ });
321
+
322
+ expect(exited.timedOut, "the process did not exit — a sandbox child is holding the event loop open").toBe(false);
323
+ expect(exited.code).toBe(0);
324
+ });
325
+ });
@@ -0,0 +1,180 @@
1
+ import { realpathSync, rmSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { PostSynthDiagnostic } from "../../lint/post-synth";
4
+ import { bundleDriver } from "./bundle";
5
+ import { generatePolicyDriverSource } from "./driver";
6
+ import { forkSandboxed } from "./fork";
7
+ import {
8
+ encodePolicyBuildResult,
9
+ formatPolicyWireOffenders,
10
+ type EncodablePolicyBuildResult,
11
+ type PolicyDiagnosticOffender,
12
+ } from "./policy-wire";
13
+ import { ENV_VAR } from "../../env";
14
+
15
+ /**
16
+ * chant #1131 — runs a project's `lint.policies` checks inside a sandboxed
17
+ * child, over the build result the parent has already merged and serialized,
18
+ * and brings back plain `PostSynthDiagnostic`s.
19
+ *
20
+ * This closes the last residual chant #1093 and #1113 documented. `--sandbox`
21
+ * isolated project *source* (#1045), then project *fold-time* code (#1093),
22
+ * then the project's `chant.config.ts` (#1113) — but `chant build` still
23
+ * imported every `lint.policies` module and called its `check` function in the
24
+ * CLI's own process, after discovery, with the CLI's full filesystem, network,
25
+ * environment and process-spawn access. The config crossing as data did not
26
+ * take the policies with it: they are declared in it as *paths*.
27
+ *
28
+ * ## Why a second child rather than the first one
29
+ *
30
+ * A policy is a callback over the *whole* resolved build, and no such thing
31
+ * exists inside the #1045 discovery child. That child sees only the
32
+ * run-fallback subset — folded files are collected in the parent, the merge
33
+ * happens in the parent, and serialization (which is what most policies
34
+ * actually read) happens later still. Running policies there would hand them a
35
+ * partial, unserialized view and call it the same check. So the boundary moves
36
+ * to where the complete result exists: after the merge, in a child of its own.
37
+ *
38
+ * Deliberately the same machinery, not a parallel one — the same three modules
39
+ * `./config-run.ts` reuses:
40
+ * - `./bundle.ts` bundles the generated driver (`./driver.ts`'s
41
+ * `generatePolicyDriverSource`) with esbuild, so the child needs no runtime
42
+ * module resolution and no TypeScript loader.
43
+ * - `./fork.ts` spawns it with the identical `--permission` profile the
44
+ * run-fallback and config children get — one function, so the three cannot
45
+ * drift.
46
+ * - `./child-errors.ts` (inside the driver) classifies whatever it throws, so
47
+ * a permission denial names the policy file instead of leaking
48
+ * `ERR_ACCESS_DENIED`.
49
+ *
50
+ * The one addition is direction: the policy child receives its input over the
51
+ * same IPC channel it answers on (`SandboxForkOptions.send`). See that field's
52
+ * doc for why sending before the child boots is safe.
53
+ *
54
+ * ## What the child can and cannot do
55
+ *
56
+ * Exactly what a run-fallback file can: read the bundle directory and the
57
+ * project directory, nothing else; no writes, no spawning, no worker threads;
58
+ * an environment of `PATH` plus `CHANT_ENV` when set. That last one matches
59
+ * `./config-run.ts` for the same reason — `--env` is a value the user typed on
60
+ * the command line, and a policy that branches on the environment is the
61
+ * documented pattern (`ctx.env` carries it too, so this is belt-and-braces for
62
+ * a policy reading `process.env.CHANT_ENV` directly rather than a new
63
+ * capability).
64
+ *
65
+ * A policy that writes a report file, shells out to a scanner, or reads
66
+ * `~/.aws/credentials` therefore now FAILS, loudly, naming the file and the
67
+ * operation. That is the point of the flag, and it is a real behavior change
68
+ * for such a policy under `--sandbox` — see `docs/.../architecture/sandbox.mdx`.
69
+ */
70
+
71
+ /** A policy child is one bundle, one import per policy module, and a pass over data. Generous relative to that, tight enough that a hung policy is reported rather than waited on. */
72
+ const POLICY_CHILD_TIMEOUT_MS = 120_000;
73
+
74
+ interface PolicyChildResponse {
75
+ kind: "chant-policy";
76
+ ok: boolean;
77
+ diagnostics?: PostSynthDiagnostic[];
78
+ offenders?: PolicyDiagnosticOffender[];
79
+ error?: { name: string; file: string; message: string; type: string };
80
+ }
81
+
82
+ function isPolicyChildResponse(value: unknown): value is PolicyChildResponse {
83
+ return (
84
+ typeof value === "object" &&
85
+ value !== null &&
86
+ (value as { kind?: unknown }).kind === "chant-policy" &&
87
+ typeof (value as { ok?: unknown }).ok === "boolean"
88
+ );
89
+ }
90
+
91
+ export interface SandboxPolicyResult {
92
+ /** What the policy pack reported — plain data, validated inside the child before it crossed (see `./policy-wire.ts`'s `scanPolicyDiagnostics`). */
93
+ diagnostics: PostSynthDiagnostic[];
94
+ /** esbuild bundling wall-clock time. */
95
+ bundleMs: number;
96
+ /** Bundle size in bytes. */
97
+ bundleBytes: number;
98
+ /** Encoded build-result payload size in bytes — what crossed the IPC channel. */
99
+ payloadBytes: number;
100
+ }
101
+
102
+ export interface SandboxPolicyOptions {
103
+ /** Absolute paths to the project's `lint.policies` modules, in declaration order. */
104
+ policyPaths: readonly string[];
105
+ /** The merged, serialized build result the checks run over. */
106
+ buildResult: EncodablePolicyBuildResult;
107
+ /** The environment/stack this build was evaluated for (`--env`, else `ownership.env`) — becomes `ctx.env`. */
108
+ env?: string;
109
+ /** Directory the child is granted `--allow-fs-read` for: the project root (the `chant.config.*` directory, which `lint.policies` paths are resolved against). */
110
+ projectRoot: string;
111
+ }
112
+
113
+ /**
114
+ * Evaluate `policyPaths` against `buildResult` inside a sandboxed child.
115
+ *
116
+ * Throws — rather than degrading to an in-process run — when the policies
117
+ * cannot be evaluated inside the boundary or their diagnostics cannot cross it
118
+ * as JSON. Under `--sandbox` a policy pack that "almost" ran is not a safe
119
+ * thing to proceed with, and quietly falling back would give away the property
120
+ * the flag exists to provide.
121
+ */
122
+ export async function runPoliciesSandboxed(options: SandboxPolicyOptions): Promise<SandboxPolicyResult> {
123
+ const { policyPaths, buildResult, env, projectRoot } = options;
124
+
125
+ // Encoded FIRST, in the parent, before anything is bundled or spawned: this
126
+ // is where a build result that cannot cross is rejected by name (a
127
+ // `nestedStack()` child project, a serializer output that isn't data), and
128
+ // there is no point paying for a bundle to find that out.
129
+ const payload = {
130
+ kind: "chant-policy-request" as const,
131
+ buildResult: encodePolicyBuildResult(buildResult),
132
+ env: env ?? null,
133
+ };
134
+ const payloadBytes = Buffer.byteLength(JSON.stringify(payload), "utf-8");
135
+
136
+ const driverSource = generatePolicyDriverSource(policyPaths);
137
+ const { bundlePath, bundleDir, externalReadPaths, durationMs, bytes } = await bundleDriver(driverSource);
138
+
139
+ try {
140
+ let projectRealpath: string;
141
+ try {
142
+ projectRealpath = realpathSync(resolve(projectRoot));
143
+ } catch {
144
+ projectRealpath = resolve(projectRoot);
145
+ }
146
+
147
+ const childEnv: Record<string, string> = { PATH: process.env.PATH ?? "" };
148
+ const activeEnv = process.env[ENV_VAR];
149
+ if (activeEnv) childEnv[ENV_VAR] = activeEnv;
150
+
151
+ const response = await forkSandboxed(
152
+ {
153
+ bundlePath,
154
+ bundleDir,
155
+ projectRealpath,
156
+ externalReadPaths,
157
+ env: childEnv,
158
+ timeoutMs: POLICY_CHILD_TIMEOUT_MS,
159
+ label: `sandboxed evaluation of lint.policies (${policyPaths.length} module(s))`,
160
+ send: payload,
161
+ },
162
+ isPolicyChildResponse,
163
+ );
164
+
165
+ if (!response.ok) {
166
+ if (response.offenders && response.offenders.length > 0) {
167
+ throw new Error(formatPolicyWireOffenders("a policy check's return value", response.offenders));
168
+ }
169
+ throw new Error(
170
+ response.error?.message
171
+ ? `Failed to run lint.policies inside the --sandbox boundary: ${response.error.message}`
172
+ : `Failed to run lint.policies inside the --sandbox boundary`,
173
+ );
174
+ }
175
+
176
+ return { diagnostics: response.diagnostics ?? [], bundleMs: durationMs, bundleBytes: bytes, payloadBytes };
177
+ } finally {
178
+ rmSync(bundleDir, { recursive: true, force: true });
179
+ }
180
+ }