@intentius/chant 0.25.0 → 0.26.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 (50) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/discovery/entity-wire-codec.d.ts +14 -9
  5. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  6. package/dist/discovery/graph.d.ts.map +1 -1
  7. package/dist/discovery/sandbox/config-wire.d.ts +16 -0
  8. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  9. package/dist/discovery/sandbox/driver.d.ts +29 -0
  10. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  11. package/dist/discovery/sandbox/fork.d.ts +18 -0
  12. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  13. package/dist/discovery/sandbox/policy-run.d.ts +33 -0
  14. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
  15. package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
  16. package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
  17. package/dist/intrinsic-interpolation.d.ts.map +1 -1
  18. package/dist/lexicon-output.d.ts.map +1 -1
  19. package/dist/lint/policy-import.d.ts +50 -0
  20. package/dist/lint/policy-import.d.ts.map +1 -0
  21. package/dist/lint/policy-sandbox.d.ts +89 -0
  22. package/dist/lint/policy-sandbox.d.ts.map +1 -0
  23. package/dist/lint/policy.d.ts +12 -1
  24. package/dist/lint/policy.d.ts.map +1 -1
  25. package/dist/stack-output.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/build.test.ts +77 -1
  28. package/src/build.ts +15 -2
  29. package/src/cli/commands/build.ts +33 -5
  30. package/src/cli/main.test.ts +93 -17
  31. package/src/cli/main.ts +101 -11
  32. package/src/discovery/entity-wire-codec.ts +14 -9
  33. package/src/discovery/graph.test.ts +40 -1
  34. package/src/discovery/graph.ts +8 -2
  35. package/src/discovery/sandbox/config-wire.ts +21 -0
  36. package/src/discovery/sandbox/driver.ts +132 -0
  37. package/src/discovery/sandbox/fork.ts +39 -1
  38. package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
  39. package/src/discovery/sandbox/policy-run.ts +180 -0
  40. package/src/discovery/sandbox/policy-wire.test.ts +310 -0
  41. package/src/discovery/sandbox/policy-wire.ts +277 -0
  42. package/src/intrinsic-interpolation.test.ts +27 -1
  43. package/src/intrinsic-interpolation.ts +10 -2
  44. package/src/lexicon-output.test.ts +36 -0
  45. package/src/lexicon-output.ts +12 -2
  46. package/src/lint/policy-import.ts +70 -0
  47. package/src/lint/policy-sandbox.ts +123 -0
  48. package/src/lint/policy.ts +20 -2
  49. package/src/stack-output.test.ts +118 -0
  50. package/src/stack-output.ts +21 -5
@@ -18,6 +18,12 @@
18
18
  * the project code it is inspecting — so it must not import anything, and must
19
19
  * not touch the filesystem, the environment or the process.
20
20
  *
21
+ * chant #1131 reuses the same walk for the OTHER direction: a `lint.policies`
22
+ * check runs inside a sandboxed child and its `PostSynthDiagnostic[]` has to
23
+ * come back as data (`./policy-wire.ts`). That is the same "JSON is lossy
24
+ * without complaining" problem with a different root value, so the walk is
25
+ * exported as {@link scanValueWireSafety} rather than copied.
26
+ *
21
27
  * ## What `ChantConfig` legally holds
22
28
  *
23
29
  * Every field of `ChantConfig` (`../../config.ts`) is JSON data: string arrays
@@ -133,6 +139,21 @@ function walk(
133
139
  seen.delete(obj);
134
140
  }
135
141
 
142
+ /**
143
+ * chant #1131 — report every value ANYWHERE in `value` that cannot cross the
144
+ * sandbox boundary as JSON, with paths rooted at `rootPath`.
145
+ *
146
+ * {@link scanConfigWireSafety} is the config-shaped entry point (it tolerates a
147
+ * module namespace object at the root); this one takes the value as given, so
148
+ * an array root (`diagnostics`) or a plain object root both work. Same rules,
149
+ * same `undefined`-object-property allowance — see the module doc.
150
+ */
151
+ export function scanValueWireSafety(value: unknown, rootPath = ""): ConfigWireOffender[] {
152
+ const out: ConfigWireOffender[] = [];
153
+ walk(value, rootPath, 0, new Set<object>(), out);
154
+ return out;
155
+ }
156
+
136
157
  /**
137
158
  * Report every value in `config` that cannot cross the sandbox boundary as
138
159
  * JSON. An empty array means a `JSON.parse(JSON.stringify(config))` round-trip
@@ -43,6 +43,9 @@ const CHILD_ERRORS_MODULE = join(HERE, "child-errors.ts");
43
43
  const PROVENANCE_MODULE = join(dirname(DISCOVERY_DIR), "provenance.ts");
44
44
  // chant #1113 — the config driver's serializability contract (see ./config-wire.ts).
45
45
  const CONFIG_WIRE_MODULE = join(HERE, "config-wire.ts");
46
+ // chant #1131 — the policy driver's build-result decoding + diagnostics contract.
47
+ const POLICY_WIRE_MODULE = join(HERE, "policy-wire.ts");
48
+ const POST_SYNTH_MODULE = join(dirname(DISCOVERY_DIR), "lint", "post-synth.ts");
46
49
 
47
50
  export interface GenerateDriverOptions {
48
51
  /** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
@@ -213,3 +216,132 @@ export function generateConfigDriverSource(configPath: string): string {
213
216
  `});`,
214
217
  ].join("\n");
215
218
  }
219
+
220
+ /**
221
+ * chant #1131 — generate the driver module that imports a project's
222
+ * `lint.policies` modules INSIDE the sandboxed child, runs their checks over
223
+ * the build result the parent hands it, and sends back plain
224
+ * `PostSynthDiagnostic`s.
225
+ *
226
+ * Same machinery again: literal-specifier dynamic `import()`s esbuild can trace
227
+ * and inline, `./child-errors.ts` for classification so a permission denial
228
+ * names the policy file, one IPC message back. Two things are specific to this
229
+ * one:
230
+ *
231
+ * - **It receives before it sends.** The run and config drivers are fully
232
+ * parameterized by their generated source; a policy check needs the finished
233
+ * build result, which is neither known at bundle time nor something to bake
234
+ * into a source literal. It arrives as one IPC message (see `./fork.ts`'s
235
+ * `send`). The `process.on("message", …)` registration is top-level and
236
+ * synchronous, so it is in place before the event loop can deliver anything
237
+ * — a message the parent sent before the child finished booting is queued on
238
+ * the channel, not lost.
239
+ * - **Checks run wrapped, not raw.** `runPostSynthChecks` (chant's own, from
240
+ * `../../lint/post-synth.ts`) is invoked ONCE over every check from every
241
+ * policy module, exactly as `cli/commands/build.ts` invokes it in-process —
242
+ * so the checks share one `PostSynthContext` and run in one order, and the
243
+ * diagnostics come back in the same sequence. The wrapper around each check
244
+ * is what makes a bad return value attributable: it scans that check's own
245
+ * output and throws a `PolicyWireError` naming the module the check was
246
+ * loaded from, rather than reporting an offending index in a merged array.
247
+ */
248
+ export function generatePolicyDriverSource(policyPaths: readonly string[]): string {
249
+ const lines: string[] = [
250
+ `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
251
+ `import { decodePolicyBuildResult, scanPolicyDiagnostics, PolicyWireError } from ${lit(POLICY_WIRE_MODULE)};`,
252
+ `import { runPostSynthChecks, isPostSynthCheck } from ${lit(POST_SYNTH_MODULE)};`,
253
+ ``,
254
+ `function send(payload) {`,
255
+ ` if (typeof process.send === "function") process.send(payload);`,
256
+ ` else console.log(JSON.stringify(payload));`,
257
+ `}`,
258
+ ``,
259
+ `function fail(file, err, type) {`,
260
+ ` if (err instanceof PolicyWireError) {`,
261
+ ` send({ kind: "chant-policy", ok: false, offenders: err.offenders });`,
262
+ ` return;`,
263
+ ` }`,
264
+ ` send({ kind: "chant-policy", ok: false, error: classifyChildError(file, err, type).toJSON() });`,
265
+ `}`,
266
+ ``,
267
+ // The wrapper described in the doc above: same id/description so any
268
+ // chant-side reporting keyed off them is unchanged, same ctx, same return
269
+ // value — plus the per-check serializability scan.
270
+ `function guard(check, policy) {`,
271
+ ` return {`,
272
+ ` id: check.id,`,
273
+ ` description: check.description,`,
274
+ ` check(ctx) {`,
275
+ ` const produced = check.check(ctx);`,
276
+ ` const offenders = scanPolicyDiagnostics(produced, policy);`,
277
+ ` if (offenders.length > 0) throw new PolicyWireError(offenders);`,
278
+ ` return produced;`,
279
+ ` },`,
280
+ ` };`,
281
+ `}`,
282
+ ``,
283
+ `async function main(request) {`,
284
+ ` let buildResult;`,
285
+ ` try {`,
286
+ ` buildResult = decodePolicyBuildResult(request.buildResult);`,
287
+ ` } catch (err) {`,
288
+ ` fail("", err, "resolution");`,
289
+ ` return;`,
290
+ ` }`,
291
+ ``,
292
+ ` const checks = [];`,
293
+ ];
294
+
295
+ // One block per policy module, in the order `lint.policies` declares them —
296
+ // the same order `loadPolicyChecks` collects in, so the diagnostics sequence
297
+ // matches the in-process one exactly.
298
+ for (const policyPath of policyPaths) {
299
+ lines.push(
300
+ ` try {`,
301
+ ` const mod = await import(${lit(policyPath)});`,
302
+ ` for (const value of Object.values(mod)) {`,
303
+ ` if (isPostSynthCheck(value)) checks.push(guard(value, ${lit(policyPath)}));`,
304
+ ` }`,
305
+ ` } catch (err) {`,
306
+ ` fail(${lit(policyPath)}, err, "import");`,
307
+ ` return;`,
308
+ ` }`,
309
+ );
310
+ }
311
+
312
+ lines.push(
313
+ ``,
314
+ ` let diagnostics;`,
315
+ ` try {`,
316
+ ` diagnostics = runPostSynthChecks(checks, buildResult, request.env ?? undefined);`,
317
+ ` } catch (err) {`,
318
+ ` fail("", err, "resolution");`,
319
+ ` return;`,
320
+ ` }`,
321
+ ``,
322
+ ` send({ kind: "chant-policy", ok: true, diagnostics });`,
323
+ `}`,
324
+ ``,
325
+ // Registered synchronously at module top level — see the doc above on why
326
+ // that is what makes the parent's send-before-boot safe.
327
+ //
328
+ // Removed again as soon as the input arrives, and that is not tidiness: a
329
+ // `message` listener REFS the IPC channel, so leaving it registered would
330
+ // keep this child's event loop alive after it had already answered — and a
331
+ // live channel keeps the PARENT's alive too. `chant build` would not have
332
+ // noticed (`cli/main.ts` ends in `process.exit`); anything embedding chant
333
+ // as a library would have hung. `./fork.ts` kills the child on receipt as
334
+ // the other half of the same fix.
335
+ `let started = false;`,
336
+ `function onRequest(request) {`,
337
+ ` if (started) return;`,
338
+ ` if (!request || request.kind !== "chant-policy-request") return;`,
339
+ ` started = true;`,
340
+ ` process.off("message", onRequest);`,
341
+ ` main(request).catch((err) => fail("", err, "resolution"));`,
342
+ `}`,
343
+ `process.on("message", onRequest);`,
344
+ );
345
+
346
+ return lines.join("\n");
347
+ }
@@ -45,6 +45,24 @@ export interface SandboxForkOptions {
45
45
  timeoutMs: number;
46
46
  /** What timed out / exited early, for the error message (e.g. `"sandboxed run"`). */
47
47
  label: string;
48
+ /**
49
+ * chant #1131 — an optional payload sent to the child over the SAME IPC
50
+ * channel its response comes back on, immediately after the fork.
51
+ *
52
+ * The run and config children are fully described by their generated driver
53
+ * source, so they need nothing inbound. The policy child does: its input is
54
+ * the finished build result, which exists only after the parent has merged
55
+ * and serialized, long after the bundle was built. Sending it rather than
56
+ * baking it into a source literal keeps the bundle small (esbuild would
57
+ * otherwise parse a multi-megabyte literal) and keeps it off disk.
58
+ *
59
+ * Safe to send before the child has booted: `child.send` writes to the IPC
60
+ * pipe and Node queues the message until the child's channel is read, and
61
+ * the driver registers its `process.on("message", …)` synchronously at module
62
+ * top level — before the event loop can deliver anything. This is NOT a
63
+ * second protocol: same channel, same JSON, same one-message-back response.
64
+ */
65
+ send?: Record<string, unknown>;
48
66
  }
49
67
 
50
68
  /**
@@ -56,7 +74,7 @@ export function forkSandboxed<T>(
56
74
  options: SandboxForkOptions,
57
75
  isResponse: (value: unknown) => value is T,
58
76
  ): Promise<T> {
59
- const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label } = options;
77
+ const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label, send } = options;
60
78
 
61
79
  return new Promise((resolvePromise, reject) => {
62
80
  const readAllowances = [bundleDir, projectRealpath, ...externalReadPaths].map(
@@ -78,6 +96,16 @@ export function forkSandboxed<T>(
78
96
  reject(new Error(`${label} timed out after ${timeoutMs}ms`));
79
97
  }, timeoutMs);
80
98
 
99
+ if (send !== undefined) {
100
+ child.send(send, (err) => {
101
+ if (settled || !err) return;
102
+ settled = true;
103
+ clearTimeout(timeout);
104
+ child.kill();
105
+ reject(new Error(`${label}: failed to send the child its input: ${err.message}`));
106
+ });
107
+ }
108
+
81
109
  child.stderr?.on("data", (chunk: Buffer) => {
82
110
  stderrBuf += chunk.toString();
83
111
  });
@@ -86,6 +114,16 @@ export function forkSandboxed<T>(
86
114
  if (settled || !isResponse(msg)) return;
87
115
  settled = true;
88
116
  clearTimeout(timeout);
117
+ // chant #1131 — the child's entire job is to send this one message, so
118
+ // once it has arrived there is nothing left to wait for. Killing it here
119
+ // rather than hoping it exits on its own closes a real hang: an open
120
+ // handle on the child side (a `setInterval` in project source, an
121
+ // `http.Server` a policy started, or simply an IPC listener the driver
122
+ // registered to RECEIVE its input) keeps the child's event loop alive,
123
+ // and a live IPC channel then keeps the PARENT's alive too. `chant build`
124
+ // never noticed because `cli/main.ts` ends with `process.exit`; anything
125
+ // embedding chant as a library would have hung forever.
126
+ child.kill();
89
127
  resolvePromise(msg);
90
128
  });
91
129
 
@@ -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
+ });