@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.
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts +14 -9
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
- package/dist/discovery/graph.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +16 -0
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts +29 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +18 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/sandbox/policy-run.d.ts +33 -0
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
- package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
- package/dist/intrinsic-interpolation.d.ts.map +1 -1
- package/dist/lexicon-output.d.ts.map +1 -1
- package/dist/lint/policy-import.d.ts +50 -0
- package/dist/lint/policy-import.d.ts.map +1 -0
- package/dist/lint/policy-sandbox.d.ts +89 -0
- package/dist/lint/policy-sandbox.d.ts.map +1 -0
- package/dist/lint/policy.d.ts +12 -1
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.test.ts +77 -1
- package/src/build.ts +15 -2
- package/src/cli/commands/build.ts +33 -5
- package/src/cli/main.test.ts +93 -17
- package/src/cli/main.ts +101 -11
- package/src/discovery/entity-wire-codec.ts +14 -9
- package/src/discovery/graph.test.ts +40 -1
- package/src/discovery/graph.ts +8 -2
- package/src/discovery/sandbox/config-wire.ts +21 -0
- package/src/discovery/sandbox/driver.ts +132 -0
- package/src/discovery/sandbox/fork.ts +39 -1
- package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
- package/src/discovery/sandbox/policy-run.ts +180 -0
- package/src/discovery/sandbox/policy-wire.test.ts +310 -0
- package/src/discovery/sandbox/policy-wire.ts +277 -0
- package/src/intrinsic-interpolation.test.ts +27 -1
- package/src/intrinsic-interpolation.ts +10 -2
- package/src/lexicon-output.test.ts +36 -0
- package/src/lexicon-output.ts +12 -2
- package/src/lint/policy-import.ts +70 -0
- package/src/lint/policy-sandbox.ts +123 -0
- package/src/lint/policy.ts +20 -2
- package/src/stack-output.test.ts +118 -0
- 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
|
+
});
|