@intentius/chant 0.24.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 (72) 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/config-import.d.ts +33 -0
  5. package/dist/config-import.d.ts.map +1 -0
  6. package/dist/config-sandbox.d.ts +47 -0
  7. package/dist/config-sandbox.d.ts.map +1 -0
  8. package/dist/config.d.ts +11 -2
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/discovery/entity-wire-codec.d.ts +15 -10
  11. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  12. package/dist/discovery/graph.d.ts.map +1 -1
  13. package/dist/discovery/sandbox/config-run.d.ts +24 -0
  14. package/dist/discovery/sandbox/config-run.d.ts.map +1 -0
  15. package/dist/discovery/sandbox/config-wire.d.ts +84 -0
  16. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -0
  17. package/dist/discovery/sandbox/driver.d.ts +49 -0
  18. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  19. package/dist/discovery/sandbox/fork.d.ts +70 -0
  20. package/dist/discovery/sandbox/fork.d.ts.map +1 -0
  21. package/dist/discovery/sandbox/policy-run.d.ts +33 -0
  22. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
  23. package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
  24. package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
  25. package/dist/discovery/sandbox/run.d.ts +10 -20
  26. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  27. package/dist/intrinsic-interpolation.d.ts.map +1 -1
  28. package/dist/lexicon-output.d.ts +62 -6
  29. package/dist/lexicon-output.d.ts.map +1 -1
  30. package/dist/lint/config.d.ts +6 -0
  31. package/dist/lint/config.d.ts.map +1 -1
  32. package/dist/lint/policy-import.d.ts +50 -0
  33. package/dist/lint/policy-import.d.ts.map +1 -0
  34. package/dist/lint/policy-sandbox.d.ts +89 -0
  35. package/dist/lint/policy-sandbox.d.ts.map +1 -0
  36. package/dist/lint/policy.d.ts +12 -1
  37. package/dist/lint/policy.d.ts.map +1 -1
  38. package/dist/stack-output.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/build.test.ts +96 -1
  41. package/src/build.ts +36 -12
  42. package/src/cli/commands/build.ts +46 -5
  43. package/src/cli/main.test.ts +93 -17
  44. package/src/cli/main.ts +111 -11
  45. package/src/config-import.ts +43 -0
  46. package/src/config-sandbox.ts +138 -0
  47. package/src/config.ts +14 -5
  48. package/src/discovery/entity-wire-codec.ts +35 -21
  49. package/src/discovery/entity-wire.test.ts +26 -0
  50. package/src/discovery/graph.test.ts +40 -1
  51. package/src/discovery/graph.ts +8 -2
  52. package/src/discovery/sandbox/config-boundary.test.ts +239 -0
  53. package/src/discovery/sandbox/config-run.ts +130 -0
  54. package/src/discovery/sandbox/config-wire.test.ts +110 -0
  55. package/src/discovery/sandbox/config-wire.ts +195 -0
  56. package/src/discovery/sandbox/driver.ts +200 -0
  57. package/src/discovery/sandbox/fork.ts +148 -0
  58. package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
  59. package/src/discovery/sandbox/policy-run.ts +180 -0
  60. package/src/discovery/sandbox/policy-wire.test.ts +310 -0
  61. package/src/discovery/sandbox/policy-wire.ts +277 -0
  62. package/src/discovery/sandbox/run.ts +28 -85
  63. package/src/intrinsic-interpolation.test.ts +27 -1
  64. package/src/intrinsic-interpolation.ts +10 -2
  65. package/src/lexicon-output.test.ts +173 -1
  66. package/src/lexicon-output.ts +123 -14
  67. package/src/lint/config.ts +8 -4
  68. package/src/lint/policy-import.ts +70 -0
  69. package/src/lint/policy-sandbox.ts +123 -0
  70. package/src/lint/policy.ts +20 -2
  71. package/src/stack-output.test.ts +118 -0
  72. package/src/stack-output.ts +21 -5
@@ -0,0 +1,148 @@
1
+ import { fork } from "node:child_process";
2
+
3
+ /**
4
+ * The one place chant starts a sandboxed child process.
5
+ *
6
+ * Extracted from `./run.ts` by chant #1113, which added a SECOND thing that
7
+ * has to run behind the same boundary (`chant.config.ts` evaluation, see
8
+ * `./config-run.ts`). Both callers must get the identical `--permission`
9
+ * profile and the identical environment scrub — if the two drifted, the
10
+ * weaker one would silently become the boundary. Keeping the spawn itself in
11
+ * one function is the cheapest way to make "same profile" a fact rather than
12
+ * a claim.
13
+ *
14
+ * Isolation mechanics (verified on Node v24.13.1 — see the chant#1045 PR
15
+ * description for the full write-up):
16
+ * - `--permission --allow-fs-read=<bundle dir>,<project dir>[,<trusted
17
+ * external package dirs>]` — no filesystem write, no child-process, no
18
+ * worker-thread access. Bundling with esbuild first (`./bundle.ts`) means
19
+ * the child needs NO TypeScript loader (no `tsx`, so no `--allow-worker`
20
+ * and no writable temp dir either).
21
+ * - The env is a spawn-time scrub, not `--permission`: Node's Permission
22
+ * Model does not gate `process.env` at all (confirmed: every key stays
23
+ * readable even under `--permission`). See {@link SandboxForkOptions.env}.
24
+ * - Network egress is NOT addressed — Node has no flag for it. See
25
+ * `docs/.../architecture/sandbox.mdx` for the residual-risk statement.
26
+ */
27
+
28
+ export interface SandboxForkOptions {
29
+ /** Absolute, realpath'd path to the bundled ESM entry file to run. */
30
+ bundlePath: string;
31
+ /** Absolute, realpath'd directory holding {@link bundlePath} — granted `--allow-fs-read`. */
32
+ bundleDir: string;
33
+ /** Absolute, realpath'd project directory — granted `--allow-fs-read`. */
34
+ projectRealpath: string;
35
+ /** Additional directories to grant `--allow-fs-read` (the resolved locations of `./bundle.ts`'s deliberately-unbundled trusted packages). */
36
+ externalReadPaths: readonly string[];
37
+ /**
38
+ * The child's ENTIRE environment. Callers pass an explicit, closed set —
39
+ * never a spread of `process.env`. `./run.ts` passes `PATH` only;
40
+ * `./config-run.ts` adds `CHANT_ENV` (see its doc for why that one
41
+ * variable, and only that one, is forwarded).
42
+ */
43
+ env: Record<string, string>;
44
+ /** How long to wait for the child's one IPC message before killing it. */
45
+ timeoutMs: number;
46
+ /** What timed out / exited early, for the error message (e.g. `"sandboxed run"`). */
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>;
66
+ }
67
+
68
+ /**
69
+ * Fork `bundlePath` under `--permission` with a scrubbed environment, and
70
+ * resolve with the first IPC message that satisfies `isResponse` (or reject
71
+ * on crash / timeout / fork error).
72
+ */
73
+ export function forkSandboxed<T>(
74
+ options: SandboxForkOptions,
75
+ isResponse: (value: unknown) => value is T,
76
+ ): Promise<T> {
77
+ const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label, send } = options;
78
+
79
+ return new Promise((resolvePromise, reject) => {
80
+ const readAllowances = [bundleDir, projectRealpath, ...externalReadPaths].map(
81
+ (p) => `--allow-fs-read=${p}`,
82
+ );
83
+ const child = fork(bundlePath, [], {
84
+ execArgv: ["--permission", ...readAllowances],
85
+ env,
86
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
87
+ });
88
+
89
+ let settled = false;
90
+ let stderrBuf = "";
91
+
92
+ const timeout = setTimeout(() => {
93
+ if (settled) return;
94
+ settled = true;
95
+ child.kill();
96
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
97
+ }, timeoutMs);
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
+
109
+ child.stderr?.on("data", (chunk: Buffer) => {
110
+ stderrBuf += chunk.toString();
111
+ });
112
+
113
+ child.on("message", (msg: unknown) => {
114
+ if (settled || !isResponse(msg)) return;
115
+ settled = true;
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();
127
+ resolvePromise(msg);
128
+ });
129
+
130
+ child.on("error", (err) => {
131
+ if (settled) return;
132
+ settled = true;
133
+ clearTimeout(timeout);
134
+ reject(err);
135
+ });
136
+
137
+ child.on("exit", (code, signal) => {
138
+ if (settled) return;
139
+ settled = true;
140
+ clearTimeout(timeout);
141
+ reject(
142
+ new Error(
143
+ `${label}: child exited before reporting results (code ${code}, signal ${signal})${stderrBuf.trim() ? `: ${stderrBuf.trim()}` : ""}`,
144
+ ),
145
+ );
146
+ });
147
+ });
148
+ }
@@ -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
+ }