@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,130 @@
1
+ import { realpathSync, rmSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { bundleDriver } from "./bundle";
4
+ import { generateConfigDriverSource } from "./driver";
5
+ import { formatConfigWireOffenders, type ConfigWireOffender } from "./config-wire";
6
+ import { forkSandboxed } from "./fork";
7
+ import { ENV_VAR } from "../../env";
8
+
9
+ /**
10
+ * chant #1113 — evaluates a project's `chant.config.ts` inside the same
11
+ * sandboxed child `--sandbox` already uses for run-fallback source, and brings
12
+ * back plain JSON.
13
+ *
14
+ * This closes the residual chant #1093 documented and #1113 filed: `loadConfig`
15
+ * imported the project's own `chant.config.ts` into the CLI process, so a
16
+ * hostile repo's config executed with full CLI trust even under `--sandbox`.
17
+ * The config is project-authored code like any other file in the repo; the
18
+ * only reason it was ever treated differently is that the CLI has to read it
19
+ * before it knows anything else about the project.
20
+ *
21
+ * Deliberately the same machinery, not a parallel one:
22
+ * - `./bundle.ts` bundles the generated driver (`./driver.ts`'s
23
+ * `generateConfigDriverSource`) with esbuild, so the child needs no runtime
24
+ * module resolution and no TypeScript loader.
25
+ * - `./fork.ts` spawns it with the identical `--permission` profile
26
+ * `runFallbackFilesSandboxed` uses — one function, so the two cannot drift.
27
+ * - `./child-errors.ts` classifies whatever it throws, so a permission denial
28
+ * names the config file instead of leaking `ERR_ACCESS_DENIED`.
29
+ *
30
+ * The one deliberate difference from the run-fallback child is `CHANT_ENV`.
31
+ * `../../cli/main.ts` sets it from `--env` *before* loading the config,
32
+ * specifically because a config may branch on the environment; dropping it
33
+ * would silently produce a different configuration under `--sandbox` than
34
+ * without. It is a value the user typed on the command line, not an ambient
35
+ * secret, so forwarding exactly that one key — and nothing else from
36
+ * `process.env` — keeps the scrub meaningful while keeping `--env` honest.
37
+ */
38
+
39
+ /** How long to wait for the config child. A config is one small module; anything approaching this is hung, not slow. */
40
+ const CONFIG_CHILD_TIMEOUT_MS = 60_000;
41
+
42
+ interface ConfigChildResponse {
43
+ kind: "chant-config";
44
+ ok: boolean;
45
+ config?: unknown;
46
+ offenders?: ConfigWireOffender[];
47
+ error?: { name: string; file: string; message: string; type: string };
48
+ }
49
+
50
+ function isConfigChildResponse(value: unknown): value is ConfigChildResponse {
51
+ return (
52
+ typeof value === "object" &&
53
+ value !== null &&
54
+ (value as { kind?: unknown }).kind === "chant-config" &&
55
+ typeof (value as { ok?: unknown }).ok === "boolean"
56
+ );
57
+ }
58
+
59
+ export interface SandboxConfigResult {
60
+ /** The evaluated configuration, as plain JSON. Interpreted (default/config/namespace selection already applied in the child; Zod validation still to come) by `../../config.ts`'s `normalizeConfig`, in the parent, unchanged. */
61
+ config: unknown;
62
+ /** esbuild bundling wall-clock time. */
63
+ bundleMs: number;
64
+ /** Bundle size in bytes. */
65
+ bundleBytes: number;
66
+ }
67
+
68
+ /**
69
+ * Evaluate `configPath` in a sandboxed child and return its configuration as
70
+ * plain data.
71
+ *
72
+ * Throws — rather than degrading to an in-process import or to defaults — when
73
+ * the config cannot be evaluated inside the boundary or cannot cross it as
74
+ * JSON. Under `--sandbox` a config that "almost" loaded is not a safe thing to
75
+ * proceed with, and quietly falling back would give away the property the flag
76
+ * exists to provide.
77
+ *
78
+ * @param configPath - Absolute path to the project's `chant.config.ts`.
79
+ * @param projectRoot - Directory the child is granted `--allow-fs-read` for
80
+ * (the config's own project root, i.e. `findProjectConfig`'s `dir`).
81
+ */
82
+ export async function evaluateConfigSandboxed(
83
+ configPath: string,
84
+ projectRoot: string,
85
+ ): Promise<SandboxConfigResult> {
86
+ const driverSource = generateConfigDriverSource(configPath);
87
+ const { bundlePath, bundleDir, externalReadPaths, durationMs, bytes } = await bundleDriver(driverSource);
88
+
89
+ try {
90
+ let projectRealpath: string;
91
+ try {
92
+ projectRealpath = realpathSync(resolve(projectRoot));
93
+ } catch {
94
+ projectRealpath = resolve(projectRoot);
95
+ }
96
+
97
+ const env: Record<string, string> = { PATH: process.env.PATH ?? "" };
98
+ // See the module doc: the one forwarded variable, and only when set.
99
+ const activeEnv = process.env[ENV_VAR];
100
+ if (activeEnv) env[ENV_VAR] = activeEnv;
101
+
102
+ const response = await forkSandboxed(
103
+ {
104
+ bundlePath,
105
+ bundleDir,
106
+ projectRealpath,
107
+ externalReadPaths,
108
+ env,
109
+ timeoutMs: CONFIG_CHILD_TIMEOUT_MS,
110
+ label: `sandboxed evaluation of ${configPath}`,
111
+ },
112
+ isConfigChildResponse,
113
+ );
114
+
115
+ if (!response.ok) {
116
+ if (response.offenders && response.offenders.length > 0) {
117
+ throw new Error(formatConfigWireOffenders(configPath, response.offenders));
118
+ }
119
+ throw new Error(
120
+ response.error?.message
121
+ ? `Failed to evaluate ${configPath} inside the --sandbox boundary: ${response.error.message}`
122
+ : `Failed to evaluate ${configPath} inside the --sandbox boundary`,
123
+ );
124
+ }
125
+
126
+ return { config: response.config ?? {}, bundleMs: durationMs, bundleBytes: bytes };
127
+ } finally {
128
+ rmSync(bundleDir, { recursive: true, force: true });
129
+ }
130
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { scanConfigWireSafety, formatConfigWireOffenders } from "./config-wire";
3
+
4
+ /**
5
+ * chant #1113 — the contract for what a `chant.config.ts` may hold if it is to
6
+ * be evaluated inside the `--sandbox` boundary. Only JSON crosses a process
7
+ * boundary, and `JSON.stringify` is lossy without complaining, so this scan is
8
+ * what turns "silently dropped" into "named and refused".
9
+ */
10
+ describe("scanConfigWireSafety", () => {
11
+ test("a real ChantConfig has nothing that cannot cross", () => {
12
+ expect(
13
+ scanConfigWireSafety({
14
+ lexicons: ["aws", "k8s"],
15
+ environments: ["staging", "prod"],
16
+ sourceDir: "src",
17
+ stacks: [{ name: "net", src: "src/net" }],
18
+ ownership: { stack: "storefront", env: "prod", enabled: true },
19
+ build: { fold: true, sandbox: true },
20
+ buildParams: { tier: { type: "string", default: "light", enum: ["light", "prod"] } },
21
+ lint: { rules: { COR001: "error", COR002: ["warning", { max: 3 }] }, policies: ["policies/org.ts"] },
22
+ vulnPolicy: { failSeverity: "critical", license: { allow: ["MIT"], deny: [] } },
23
+ // A lexicon's passthrough extension (the temporal lexicon's shape) —
24
+ // still pure data.
25
+ temporal: { profiles: { local: { address: "localhost:7233", tls: false } }, defaultProfile: "local" },
26
+ }),
27
+ ).toEqual([]);
28
+ });
29
+
30
+ test("null, empty objects and empty arrays are data", () => {
31
+ expect(scanConfigWireSafety({ a: null, b: {}, c: [], d: 0, e: false, f: "" })).toEqual([]);
32
+ });
33
+
34
+ test.each([
35
+ ["a function", { hooks: { before: () => 1 } }, "hooks.before", "a function"],
36
+ ["a Date", { meta: { at: new Date(0) } }, "meta.at", "a Date"],
37
+ ["a RegExp", { match: /x/ }, "match", "a RegExp"],
38
+ ["a Map", { m: new Map() }, "m", "a Map"],
39
+ ["a Set", { s: new Set() }, "s", "a Set"],
40
+ ["a bigint", { n: 1n }, "n", "a bigint"],
41
+ ["NaN", { n: Number.NaN }, "n", "NaN"],
42
+ ["Infinity", { n: Number.POSITIVE_INFINITY }, "n", "a non-finite number"],
43
+ ["a symbol", { s: Symbol("x") }, "s", "a symbol"],
44
+ ])("%s is reported with its key path", (_label, config, path, found) => {
45
+ expect(scanConfigWireSafety(config)).toEqual([{ path, found }]);
46
+ });
47
+
48
+ test("array positions are reported with an index", () => {
49
+ expect(scanConfigWireSafety({ stacks: [{ name: "a", src: "s" }, { name: "b", src: () => "s" }] })).toEqual([
50
+ { path: "stacks[1].src", found: "a function" },
51
+ ]);
52
+ });
53
+
54
+ test("a class instance is reported by its constructor name", () => {
55
+ class Policy {
56
+ value = 1;
57
+ }
58
+ expect(scanConfigWireSafety({ p: new Policy() })).toEqual([{ path: "p", found: "a Policy instance" }]);
59
+ });
60
+
61
+ test("a circular reference is reported, not walked forever", () => {
62
+ const a: Record<string, unknown> = { name: "a" };
63
+ a.self = a;
64
+ expect(scanConfigWireSafety({ a })).toEqual([{ path: "a.self", found: "a circular reference" }]);
65
+ });
66
+
67
+ test("every offender is reported, not just the first", () => {
68
+ expect(scanConfigWireSafety({ a: () => 1, b: { c: new Date(0) } })).toEqual([
69
+ { path: "a", found: "a function" },
70
+ { path: "b.c", found: "a Date" },
71
+ ]);
72
+ });
73
+
74
+ /**
75
+ * The one accepted lossy case, and its boundary: `{ x: undefined }` reads
76
+ * identically to `{}` for every consumer of `ChantConfig` (every field is
77
+ * optional), while an `undefined` in an ARRAY becomes `null` and changes the
78
+ * element — so only the latter is reported. See the module doc.
79
+ */
80
+ test("an undefined property is dropped like an omitted key; an undefined array slot is not", () => {
81
+ expect(scanConfigWireSafety({ sourceDir: undefined, lexicons: ["aws"] })).toEqual([]);
82
+ expect(scanConfigWireSafety({ lexicons: ["aws", undefined] })).toEqual([
83
+ { path: "lexicons[1]", found: "undefined" },
84
+ ]);
85
+ });
86
+
87
+ test("a module namespace (a config of top-level named exports) is walked, not rejected wholesale", () => {
88
+ const namespace = Object.create(null) as Record<string, unknown>;
89
+ namespace.lexicons = ["aws"];
90
+ expect(scanConfigWireSafety(namespace)).toEqual([]);
91
+ });
92
+
93
+ test("a non-object config says nothing about serializability — normalizeConfig rejects it", () => {
94
+ expect(scanConfigWireSafety("nope")).toEqual([]);
95
+ expect(scanConfigWireSafety(null)).toEqual([]);
96
+ });
97
+ });
98
+
99
+ describe("formatConfigWireOffenders", () => {
100
+ test("names the config file, every offending key, and what to do", () => {
101
+ const message = formatConfigWireOffenders("/p/chant.config.ts", [
102
+ { path: "hooks.before", found: "a function" },
103
+ { path: "meta.at", found: "a Date" },
104
+ ]);
105
+ expect(message).toContain("/p/chant.config.ts");
106
+ expect(message).toContain("hooks.before: a function");
107
+ expect(message).toContain("meta.at: a Date");
108
+ expect(message).toContain("drop --sandbox");
109
+ });
110
+ });
@@ -0,0 +1,195 @@
1
+ /**
2
+ * chant #1113 — what a `chant.config.ts` may contain if it is to be evaluated
3
+ * inside the sandbox boundary and handed back to the CLI as data.
4
+ *
5
+ * Under `--sandbox` the config file is evaluated in a child process (see
6
+ * `./config-run.ts`), so the only thing that can come back is what survives a
7
+ * process boundary: JSON. Node's IPC channel serializes with `JSON.stringify`
8
+ * by default, and `JSON.stringify` is *lossy without complaining* — a function
9
+ * property vanishes, a `Date` becomes a string, a `Map` becomes `{}`, `NaN`
10
+ * becomes `null`. Silently handing the CLI a config that differs from the one
11
+ * the project wrote is the worst available outcome for a security feature, so
12
+ * this module walks the value FIRST and reports every offending key path.
13
+ * `./config-run.ts` turns a non-empty report into a build error that names the
14
+ * keys; nothing is ever dropped quietly.
15
+ *
16
+ * This module is bundled INTO the generated config driver (`./driver.ts`'s
17
+ * `generateConfigDriverSource`) and runs inside the sandboxed child, next to
18
+ * the project code it is inspecting — so it must not import anything, and must
19
+ * not touch the filesystem, the environment or the process.
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
+ *
27
+ * ## What `ChantConfig` legally holds
28
+ *
29
+ * Every field of `ChantConfig` (`../../config.ts`) is JSON data: string arrays
30
+ * (`lexicons`, `capabilities`, `environments`), strings (`sourceDir`), nested
31
+ * plain objects of strings/booleans (`ownership`, `build`, `release`, `sbom`,
32
+ * `signing`, `vulnPolicy`), arrays of plain objects (`stacks`), and records of
33
+ * plain objects (`buildParams`). `lint` is a `LintConfig`, whose rule values
34
+ * are a severity string or a `[severity, options]` tuple, and whose `plugins`
35
+ * / `policies` are file *paths* — chant loads those modules itself, they are
36
+ * not functions embedded in the config. So the declared type admits nothing
37
+ * that fails the check below.
38
+ *
39
+ * The one way to get there is `ChantConfigSchema`'s `.passthrough()`, which
40
+ * accepts unknown extra keys of any type (that is how a lexicon extends the
41
+ * config — `temporal:` in the temporal lexicon's `TemporalChantConfig`, itself
42
+ * pure data). A project that parks a function under such a key gets a clear
43
+ * error naming it, rather than a config that silently lost it.
44
+ *
45
+ * ## The one accepted lossy case: `undefined` object properties
46
+ *
47
+ * `{ sourceDir: undefined }` and `{}` are indistinguishable to every reader of
48
+ * `ChantConfig` — each field is optional and every resolver tests
49
+ * `config.x === undefined` / `?.` — so an `undefined`-valued property is
50
+ * dropped rather than rejected, exactly as omitting the key would be. An
51
+ * `undefined` inside an ARRAY is a different story (`JSON.stringify` rewrites
52
+ * it to `null`, changing the element), and is reported.
53
+ */
54
+
55
+ /** One value in the config that cannot cross the sandbox boundary as data. */
56
+ export interface ConfigWireOffender {
57
+ /** Dotted/bracketed path from the config root, e.g. `lint.rules.foo` or `stacks[0].name`. */
58
+ path: string;
59
+ /** What was found there, phrased for an error message (e.g. `a function`, `a Date`). */
60
+ found: string;
61
+ }
62
+
63
+ /** Deepest nesting `scanConfigWireSafety` will walk before reporting the path as too deep (also the cycle backstop for exotic self-referential structures). */
64
+ const MAX_DEPTH = 64;
65
+
66
+ function describe(value: unknown): string {
67
+ const t = typeof value;
68
+ if (t === "function") return "a function";
69
+ if (t === "symbol") return "a symbol";
70
+ if (t === "bigint") return "a bigint";
71
+ if (t === "number") return Number.isNaN(value) ? "NaN" : "a non-finite number";
72
+ if (value instanceof Date) return "a Date";
73
+ if (value instanceof RegExp) return "a RegExp";
74
+ if (value instanceof Map) return "a Map";
75
+ if (value instanceof Set) return "a Set";
76
+ if (value instanceof Promise) return "a Promise";
77
+ if (value instanceof Error) return "an Error";
78
+ const ctor = (value as { constructor?: { name?: unknown } })?.constructor?.name;
79
+ return typeof ctor === "string" && ctor !== "Object" ? `a ${ctor} instance` : "a non-plain object";
80
+ }
81
+
82
+ /** A plain object literal (or a null-prototype object) — anything else with `typeof "object"` is a class instance chant refuses to guess at. */
83
+ function isPlainObject(value: object): boolean {
84
+ const proto = Object.getPrototypeOf(value);
85
+ return proto === Object.prototype || proto === null;
86
+ }
87
+
88
+ function walk(
89
+ value: unknown,
90
+ path: string,
91
+ depth: number,
92
+ seen: Set<object>,
93
+ out: ConfigWireOffender[],
94
+ ): void {
95
+ if (value === null) return;
96
+
97
+ const t = typeof value;
98
+ if (t === "string" || t === "boolean") return;
99
+ if (t === "number") {
100
+ if (!Number.isFinite(value as number)) out.push({ path, found: describe(value) });
101
+ return;
102
+ }
103
+ if (t === "function" || t === "symbol" || t === "bigint" || t === "undefined") {
104
+ // `undefined` reaches here only from an array slot — an object property
105
+ // holding `undefined` is skipped by the caller (see the module doc).
106
+ out.push({ path, found: t === "undefined" ? "undefined" : describe(value) });
107
+ return;
108
+ }
109
+
110
+ const obj = value as object;
111
+ if (seen.has(obj)) {
112
+ out.push({ path, found: "a circular reference" });
113
+ return;
114
+ }
115
+ if (depth > MAX_DEPTH) {
116
+ out.push({ path, found: `nested more than ${MAX_DEPTH} levels deep` });
117
+ return;
118
+ }
119
+
120
+ if (Array.isArray(obj)) {
121
+ seen.add(obj);
122
+ for (let i = 0; i < obj.length; i++) walk(obj[i], `${path}[${i}]`, depth + 1, seen, out);
123
+ seen.delete(obj);
124
+ return;
125
+ }
126
+
127
+ if (!isPlainObject(obj)) {
128
+ out.push({ path, found: describe(obj) });
129
+ return;
130
+ }
131
+
132
+ seen.add(obj);
133
+ for (const key of Object.keys(obj)) {
134
+ const child = (obj as Record<string, unknown>)[key];
135
+ if (child === undefined) continue; // dropped, equivalent to omitting the key — see the module doc
136
+ const childPath = path ? `${path}.${key}` : key;
137
+ walk(child, childPath, depth + 1, seen, out);
138
+ }
139
+ seen.delete(obj);
140
+ }
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
+
157
+ /**
158
+ * Report every value in `config` that cannot cross the sandbox boundary as
159
+ * JSON. An empty array means a `JSON.parse(JSON.stringify(config))` round-trip
160
+ * preserves the configuration exactly (modulo `undefined` object properties,
161
+ * which are dropped — see the module doc).
162
+ *
163
+ * `config` itself may be a module namespace object (`await import(...)` with
164
+ * no `default` export), which is not a plain object; its own enumerable keys
165
+ * are walked the same way rather than being reported wholesale.
166
+ */
167
+ export function scanConfigWireSafety(config: unknown): ConfigWireOffender[] {
168
+ const out: ConfigWireOffender[] = [];
169
+ if (config === null || typeof config !== "object") {
170
+ // A non-object config is what `normalizeConfig` already rejects in the
171
+ // parent; nothing to say about serializability.
172
+ return out;
173
+ }
174
+ const seen = new Set<object>();
175
+ seen.add(config);
176
+ for (const key of Object.keys(config as Record<string, unknown>)) {
177
+ const child = (config as Record<string, unknown>)[key];
178
+ if (child === undefined) continue;
179
+ walk(child, key, 1, seen, out);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /** Render a {@link ConfigWireOffender} list as the body of a build error — one line per offending key, most specific information first. */
185
+ export function formatConfigWireOffenders(
186
+ configPath: string,
187
+ offenders: readonly ConfigWireOffender[],
188
+ ): string {
189
+ const lines = offenders.map((o) => ` ${o.path || "<root>"}: ${o.found}`);
190
+ return [
191
+ `Cannot evaluate ${configPath} inside the --sandbox boundary: it holds values that are not data.`,
192
+ ...lines,
193
+ `Under --sandbox the config is evaluated in an isolated child process and only JSON crosses back, so every value must be a string, number, boolean, null, array or plain object. Move the offending value out of chant.config.ts (lint rule plugins and policy checks are referenced by path, not embedded), or drop --sandbox for this build.`,
194
+ ].join("\n");
195
+ }
@@ -41,6 +41,11 @@ const RESOLVE_MODULE = join(DISCOVERY_DIR, "resolve.ts");
41
41
  const ENTITY_WIRE_CODEC_MODULE = join(DISCOVERY_DIR, "entity-wire-codec.ts");
42
42
  const CHILD_ERRORS_MODULE = join(HERE, "child-errors.ts");
43
43
  const PROVENANCE_MODULE = join(dirname(DISCOVERY_DIR), "provenance.ts");
44
+ // chant #1113 — the config driver's serializability contract (see ./config-wire.ts).
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");
44
49
 
45
50
  export interface GenerateDriverOptions {
46
51
  /** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
@@ -145,3 +150,198 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
145
150
 
146
151
  return lines.join("\n");
147
152
  }
153
+
154
+ /**
155
+ * chant #1113 — generate the driver module that evaluates a project's
156
+ * `chant.config.ts` INSIDE the sandboxed child and hands back plain JSON.
157
+ *
158
+ * Same machinery as {@link generateDriverSource} above, deliberately: one
159
+ * literal-specifier dynamic `import()` esbuild can trace and inline, one IPC
160
+ * message back, `./child-errors.ts` for classification so a permission denial
161
+ * names the config file rather than leaking `ERR_ACCESS_DENIED`. The only
162
+ * difference is what crosses — a config is data, not an entity graph, so there
163
+ * is no naming/`AttrRef` step and no `EntitySetWire`; the child instead runs
164
+ * `./config-wire.ts`'s serializability scan and refuses to hand back a config
165
+ * that `JSON.stringify` would silently mangle.
166
+ *
167
+ * The child returns the raw module namespace's chosen export as-is — the
168
+ * `default ?? config ?? namespace` selection and Zod validation
169
+ * (`normalizeConfig`) stay in the parent, where they were, so `--sandbox`
170
+ * changes where the file is *evaluated* and nothing about how its result is
171
+ * interpreted.
172
+ */
173
+ export function generateConfigDriverSource(configPath: string): string {
174
+ return [
175
+ `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
176
+ `import { scanConfigWireSafety } from ${lit(CONFIG_WIRE_MODULE)};`,
177
+ ``,
178
+ `function send(payload) {`,
179
+ ` if (typeof process.send === "function") process.send(payload);`,
180
+ ` else console.log(JSON.stringify(payload));`,
181
+ `}`,
182
+ ``,
183
+ `async function main() {`,
184
+ ` let namespace;`,
185
+ ` try {`,
186
+ ` namespace = await import(${lit(configPath)});`,
187
+ ` } catch (err) {`,
188
+ ` send({ kind: "chant-config", ok: false, error: classifyChildError(${lit(configPath)}, err).toJSON() });`,
189
+ ` return;`,
190
+ ` }`,
191
+ ``,
192
+ // Mirrors loadChantConfig's own selection so the scan sees exactly the
193
+ // object the parent will normalize. A namespace object is not a plain
194
+ // object; scanConfigWireSafety walks its own keys rather than rejecting it.
195
+ ` const selected = namespace.default ?? namespace.config ?? namespace;`,
196
+ ``,
197
+ ` const offenders = scanConfigWireSafety(selected);`,
198
+ ` if (offenders.length > 0) {`,
199
+ ` send({ kind: "chant-config", ok: false, offenders });`,
200
+ ` return;`,
201
+ ` }`,
202
+ ``,
203
+ ` try {`,
204
+ // Round-trip here, not just at the IPC boundary: this is what proves the
205
+ // payload really is JSON before it leaves the child, and turns anything
206
+ // the scan somehow missed into a named error instead of a quiet drop.
207
+ ` const config = JSON.parse(JSON.stringify(selected ?? {}));`,
208
+ ` send({ kind: "chant-config", ok: true, config });`,
209
+ ` } catch (err) {`,
210
+ ` send({ kind: "chant-config", ok: false, error: classifyChildError(${lit(configPath)}, err, "resolution").toJSON() });`,
211
+ ` }`,
212
+ `}`,
213
+ ``,
214
+ `main().catch((err) => {`,
215
+ ` send({ kind: "chant-config", ok: false, error: classifyChildError(${lit(configPath)}, err).toJSON() });`,
216
+ `});`,
217
+ ].join("\n");
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
+ }