@intentius/chant 0.23.0 → 0.25.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 (62) 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/handlers/build.d.ts.map +1 -1
  4. package/dist/cli/handlers/run.d.ts +8 -0
  5. package/dist/cli/handlers/run.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/plugins.d.ts +8 -0
  8. package/dist/cli/plugins.d.ts.map +1 -1
  9. package/dist/config-import.d.ts +33 -0
  10. package/dist/config-import.d.ts.map +1 -0
  11. package/dist/config-sandbox.d.ts +47 -0
  12. package/dist/config-sandbox.d.ts.map +1 -0
  13. package/dist/config.d.ts +29 -2
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/discovery/entity-wire-codec.d.ts +1 -1
  16. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  17. package/dist/discovery/sandbox/config-run.d.ts +24 -0
  18. package/dist/discovery/sandbox/config-run.d.ts.map +1 -0
  19. package/dist/discovery/sandbox/config-wire.d.ts +68 -0
  20. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -0
  21. package/dist/discovery/sandbox/driver.d.ts +20 -0
  22. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  23. package/dist/discovery/sandbox/fork.d.ts +52 -0
  24. package/dist/discovery/sandbox/fork.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/lexicon-output.d.ts +62 -6
  28. package/dist/lexicon-output.d.ts.map +1 -1
  29. package/dist/lint/config.d.ts +7 -12
  30. package/dist/lint/config.d.ts.map +1 -1
  31. package/dist/project-root.d.ts +51 -0
  32. package/dist/project-root.d.ts.map +1 -0
  33. package/package.json +1 -1
  34. package/src/build.test.ts +19 -0
  35. package/src/build.ts +21 -10
  36. package/src/cli/commands/build.ts +42 -6
  37. package/src/cli/handlers/build.test.ts +11 -9
  38. package/src/cli/handlers/build.ts +7 -2
  39. package/src/cli/handlers/run.test.ts +31 -0
  40. package/src/cli/handlers/run.ts +15 -0
  41. package/src/cli/main.test.ts +23 -0
  42. package/src/cli/main.ts +27 -3
  43. package/src/cli/plugins.ts +10 -2
  44. package/src/config-import.ts +43 -0
  45. package/src/config-sandbox.ts +138 -0
  46. package/src/config.ts +37 -5
  47. package/src/discovery/entity-wire-codec.ts +21 -12
  48. package/src/discovery/entity-wire.test.ts +26 -0
  49. package/src/discovery/sandbox/config-boundary.test.ts +239 -0
  50. package/src/discovery/sandbox/config-run.ts +130 -0
  51. package/src/discovery/sandbox/config-wire.test.ts +110 -0
  52. package/src/discovery/sandbox/config-wire.ts +174 -0
  53. package/src/discovery/sandbox/driver.ts +68 -0
  54. package/src/discovery/sandbox/fork.ts +110 -0
  55. package/src/discovery/sandbox/run.ts +28 -85
  56. package/src/lexicon-output.test.ts +137 -1
  57. package/src/lexicon-output.ts +112 -13
  58. package/src/lint/config.test.ts +9 -4
  59. package/src/lint/config.ts +15 -27
  60. package/src/lint/policy.ts +5 -5
  61. package/src/project-root.test.ts +105 -0
  62. package/src/project-root.ts +78 -0
@@ -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,174 @@
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
+ * ## What `ChantConfig` legally holds
22
+ *
23
+ * Every field of `ChantConfig` (`../../config.ts`) is JSON data: string arrays
24
+ * (`lexicons`, `capabilities`, `environments`), strings (`sourceDir`), nested
25
+ * plain objects of strings/booleans (`ownership`, `build`, `release`, `sbom`,
26
+ * `signing`, `vulnPolicy`), arrays of plain objects (`stacks`), and records of
27
+ * plain objects (`buildParams`). `lint` is a `LintConfig`, whose rule values
28
+ * are a severity string or a `[severity, options]` tuple, and whose `plugins`
29
+ * / `policies` are file *paths* — chant loads those modules itself, they are
30
+ * not functions embedded in the config. So the declared type admits nothing
31
+ * that fails the check below.
32
+ *
33
+ * The one way to get there is `ChantConfigSchema`'s `.passthrough()`, which
34
+ * accepts unknown extra keys of any type (that is how a lexicon extends the
35
+ * config — `temporal:` in the temporal lexicon's `TemporalChantConfig`, itself
36
+ * pure data). A project that parks a function under such a key gets a clear
37
+ * error naming it, rather than a config that silently lost it.
38
+ *
39
+ * ## The one accepted lossy case: `undefined` object properties
40
+ *
41
+ * `{ sourceDir: undefined }` and `{}` are indistinguishable to every reader of
42
+ * `ChantConfig` — each field is optional and every resolver tests
43
+ * `config.x === undefined` / `?.` — so an `undefined`-valued property is
44
+ * dropped rather than rejected, exactly as omitting the key would be. An
45
+ * `undefined` inside an ARRAY is a different story (`JSON.stringify` rewrites
46
+ * it to `null`, changing the element), and is reported.
47
+ */
48
+
49
+ /** One value in the config that cannot cross the sandbox boundary as data. */
50
+ export interface ConfigWireOffender {
51
+ /** Dotted/bracketed path from the config root, e.g. `lint.rules.foo` or `stacks[0].name`. */
52
+ path: string;
53
+ /** What was found there, phrased for an error message (e.g. `a function`, `a Date`). */
54
+ found: string;
55
+ }
56
+
57
+ /** Deepest nesting `scanConfigWireSafety` will walk before reporting the path as too deep (also the cycle backstop for exotic self-referential structures). */
58
+ const MAX_DEPTH = 64;
59
+
60
+ function describe(value: unknown): string {
61
+ const t = typeof value;
62
+ if (t === "function") return "a function";
63
+ if (t === "symbol") return "a symbol";
64
+ if (t === "bigint") return "a bigint";
65
+ if (t === "number") return Number.isNaN(value) ? "NaN" : "a non-finite number";
66
+ if (value instanceof Date) return "a Date";
67
+ if (value instanceof RegExp) return "a RegExp";
68
+ if (value instanceof Map) return "a Map";
69
+ if (value instanceof Set) return "a Set";
70
+ if (value instanceof Promise) return "a Promise";
71
+ if (value instanceof Error) return "an Error";
72
+ const ctor = (value as { constructor?: { name?: unknown } })?.constructor?.name;
73
+ return typeof ctor === "string" && ctor !== "Object" ? `a ${ctor} instance` : "a non-plain object";
74
+ }
75
+
76
+ /** A plain object literal (or a null-prototype object) — anything else with `typeof "object"` is a class instance chant refuses to guess at. */
77
+ function isPlainObject(value: object): boolean {
78
+ const proto = Object.getPrototypeOf(value);
79
+ return proto === Object.prototype || proto === null;
80
+ }
81
+
82
+ function walk(
83
+ value: unknown,
84
+ path: string,
85
+ depth: number,
86
+ seen: Set<object>,
87
+ out: ConfigWireOffender[],
88
+ ): void {
89
+ if (value === null) return;
90
+
91
+ const t = typeof value;
92
+ if (t === "string" || t === "boolean") return;
93
+ if (t === "number") {
94
+ if (!Number.isFinite(value as number)) out.push({ path, found: describe(value) });
95
+ return;
96
+ }
97
+ if (t === "function" || t === "symbol" || t === "bigint" || t === "undefined") {
98
+ // `undefined` reaches here only from an array slot — an object property
99
+ // holding `undefined` is skipped by the caller (see the module doc).
100
+ out.push({ path, found: t === "undefined" ? "undefined" : describe(value) });
101
+ return;
102
+ }
103
+
104
+ const obj = value as object;
105
+ if (seen.has(obj)) {
106
+ out.push({ path, found: "a circular reference" });
107
+ return;
108
+ }
109
+ if (depth > MAX_DEPTH) {
110
+ out.push({ path, found: `nested more than ${MAX_DEPTH} levels deep` });
111
+ return;
112
+ }
113
+
114
+ if (Array.isArray(obj)) {
115
+ seen.add(obj);
116
+ for (let i = 0; i < obj.length; i++) walk(obj[i], `${path}[${i}]`, depth + 1, seen, out);
117
+ seen.delete(obj);
118
+ return;
119
+ }
120
+
121
+ if (!isPlainObject(obj)) {
122
+ out.push({ path, found: describe(obj) });
123
+ return;
124
+ }
125
+
126
+ seen.add(obj);
127
+ for (const key of Object.keys(obj)) {
128
+ const child = (obj as Record<string, unknown>)[key];
129
+ if (child === undefined) continue; // dropped, equivalent to omitting the key — see the module doc
130
+ const childPath = path ? `${path}.${key}` : key;
131
+ walk(child, childPath, depth + 1, seen, out);
132
+ }
133
+ seen.delete(obj);
134
+ }
135
+
136
+ /**
137
+ * Report every value in `config` that cannot cross the sandbox boundary as
138
+ * JSON. An empty array means a `JSON.parse(JSON.stringify(config))` round-trip
139
+ * preserves the configuration exactly (modulo `undefined` object properties,
140
+ * which are dropped — see the module doc).
141
+ *
142
+ * `config` itself may be a module namespace object (`await import(...)` with
143
+ * no `default` export), which is not a plain object; its own enumerable keys
144
+ * are walked the same way rather than being reported wholesale.
145
+ */
146
+ export function scanConfigWireSafety(config: unknown): ConfigWireOffender[] {
147
+ const out: ConfigWireOffender[] = [];
148
+ if (config === null || typeof config !== "object") {
149
+ // A non-object config is what `normalizeConfig` already rejects in the
150
+ // parent; nothing to say about serializability.
151
+ return out;
152
+ }
153
+ const seen = new Set<object>();
154
+ seen.add(config);
155
+ for (const key of Object.keys(config as Record<string, unknown>)) {
156
+ const child = (config as Record<string, unknown>)[key];
157
+ if (child === undefined) continue;
158
+ walk(child, key, 1, seen, out);
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /** Render a {@link ConfigWireOffender} list as the body of a build error — one line per offending key, most specific information first. */
164
+ export function formatConfigWireOffenders(
165
+ configPath: string,
166
+ offenders: readonly ConfigWireOffender[],
167
+ ): string {
168
+ const lines = offenders.map((o) => ` ${o.path || "<root>"}: ${o.found}`);
169
+ return [
170
+ `Cannot evaluate ${configPath} inside the --sandbox boundary: it holds values that are not data.`,
171
+ ...lines,
172
+ `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.`,
173
+ ].join("\n");
174
+ }
@@ -41,6 +41,8 @@ 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");
44
46
 
45
47
  export interface GenerateDriverOptions {
46
48
  /** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
@@ -145,3 +147,69 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
145
147
 
146
148
  return lines.join("\n");
147
149
  }
150
+
151
+ /**
152
+ * chant #1113 — generate the driver module that evaluates a project's
153
+ * `chant.config.ts` INSIDE the sandboxed child and hands back plain JSON.
154
+ *
155
+ * Same machinery as {@link generateDriverSource} above, deliberately: one
156
+ * literal-specifier dynamic `import()` esbuild can trace and inline, one IPC
157
+ * message back, `./child-errors.ts` for classification so a permission denial
158
+ * names the config file rather than leaking `ERR_ACCESS_DENIED`. The only
159
+ * difference is what crosses — a config is data, not an entity graph, so there
160
+ * is no naming/`AttrRef` step and no `EntitySetWire`; the child instead runs
161
+ * `./config-wire.ts`'s serializability scan and refuses to hand back a config
162
+ * that `JSON.stringify` would silently mangle.
163
+ *
164
+ * The child returns the raw module namespace's chosen export as-is — the
165
+ * `default ?? config ?? namespace` selection and Zod validation
166
+ * (`normalizeConfig`) stay in the parent, where they were, so `--sandbox`
167
+ * changes where the file is *evaluated* and nothing about how its result is
168
+ * interpreted.
169
+ */
170
+ export function generateConfigDriverSource(configPath: string): string {
171
+ return [
172
+ `import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
173
+ `import { scanConfigWireSafety } from ${lit(CONFIG_WIRE_MODULE)};`,
174
+ ``,
175
+ `function send(payload) {`,
176
+ ` if (typeof process.send === "function") process.send(payload);`,
177
+ ` else console.log(JSON.stringify(payload));`,
178
+ `}`,
179
+ ``,
180
+ `async function main() {`,
181
+ ` let namespace;`,
182
+ ` try {`,
183
+ ` namespace = await import(${lit(configPath)});`,
184
+ ` } catch (err) {`,
185
+ ` send({ kind: "chant-config", ok: false, error: classifyChildError(${lit(configPath)}, err).toJSON() });`,
186
+ ` return;`,
187
+ ` }`,
188
+ ``,
189
+ // Mirrors loadChantConfig's own selection so the scan sees exactly the
190
+ // object the parent will normalize. A namespace object is not a plain
191
+ // object; scanConfigWireSafety walks its own keys rather than rejecting it.
192
+ ` const selected = namespace.default ?? namespace.config ?? namespace;`,
193
+ ``,
194
+ ` const offenders = scanConfigWireSafety(selected);`,
195
+ ` if (offenders.length > 0) {`,
196
+ ` send({ kind: "chant-config", ok: false, offenders });`,
197
+ ` return;`,
198
+ ` }`,
199
+ ``,
200
+ ` try {`,
201
+ // Round-trip here, not just at the IPC boundary: this is what proves the
202
+ // payload really is JSON before it leaves the child, and turns anything
203
+ // the scan somehow missed into a named error instead of a quiet drop.
204
+ ` const config = JSON.parse(JSON.stringify(selected ?? {}));`,
205
+ ` send({ kind: "chant-config", ok: true, config });`,
206
+ ` } catch (err) {`,
207
+ ` send({ kind: "chant-config", ok: false, error: classifyChildError(${lit(configPath)}, err, "resolution").toJSON() });`,
208
+ ` }`,
209
+ `}`,
210
+ ``,
211
+ `main().catch((err) => {`,
212
+ ` send({ kind: "chant-config", ok: false, error: classifyChildError(${lit(configPath)}, err).toJSON() });`,
213
+ `});`,
214
+ ].join("\n");
215
+ }
@@ -0,0 +1,110 @@
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
+
50
+ /**
51
+ * Fork `bundlePath` under `--permission` with a scrubbed environment, and
52
+ * resolve with the first IPC message that satisfies `isResponse` (or reject
53
+ * on crash / timeout / fork error).
54
+ */
55
+ export function forkSandboxed<T>(
56
+ options: SandboxForkOptions,
57
+ isResponse: (value: unknown) => value is T,
58
+ ): Promise<T> {
59
+ const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label } = options;
60
+
61
+ return new Promise((resolvePromise, reject) => {
62
+ const readAllowances = [bundleDir, projectRealpath, ...externalReadPaths].map(
63
+ (p) => `--allow-fs-read=${p}`,
64
+ );
65
+ const child = fork(bundlePath, [], {
66
+ execArgv: ["--permission", ...readAllowances],
67
+ env,
68
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
69
+ });
70
+
71
+ let settled = false;
72
+ let stderrBuf = "";
73
+
74
+ const timeout = setTimeout(() => {
75
+ if (settled) return;
76
+ settled = true;
77
+ child.kill();
78
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
79
+ }, timeoutMs);
80
+
81
+ child.stderr?.on("data", (chunk: Buffer) => {
82
+ stderrBuf += chunk.toString();
83
+ });
84
+
85
+ child.on("message", (msg: unknown) => {
86
+ if (settled || !isResponse(msg)) return;
87
+ settled = true;
88
+ clearTimeout(timeout);
89
+ resolvePromise(msg);
90
+ });
91
+
92
+ child.on("error", (err) => {
93
+ if (settled) return;
94
+ settled = true;
95
+ clearTimeout(timeout);
96
+ reject(err);
97
+ });
98
+
99
+ child.on("exit", (code, signal) => {
100
+ if (settled) return;
101
+ settled = true;
102
+ clearTimeout(timeout);
103
+ reject(
104
+ new Error(
105
+ `${label}: child exited before reporting results (code ${code}, signal ${signal})${stderrBuf.trim() ? `: ${stderrBuf.trim()}` : ""}`,
106
+ ),
107
+ );
108
+ });
109
+ });
110
+ }
@@ -1,4 +1,3 @@
1
- import { fork } from "node:child_process";
2
1
  import { realpathSync, rmSync } from "node:fs";
3
2
  import { resolve } from "node:path";
4
3
  import type { Declarable } from "../../declarable";
@@ -7,6 +6,7 @@ import { decodeEntitySet, type EntitySetWire } from "../entity-wire-codec";
7
6
  import { bundleDriver } from "./bundle";
8
7
  import { classifyChildError } from "./child-errors";
9
8
  import { generateDriverSource } from "./driver";
9
+ import { forkSandboxed } from "./fork";
10
10
 
11
11
  /**
12
12
  * chant #1045 Phase 2 — runs every run-fallback file for a build TOGETHER, as
@@ -14,26 +14,16 @@ import { generateDriverSource } from "./driver";
14
14
  * the same shape `discover()`'s own in-process run path would have produced:
15
15
  * a named, ref-resolved entities map plus any errors.
16
16
  *
17
- * Isolation mechanics (verified on Node v24.13.1see the chant#1045 PR
18
- * description for the full write-up):
19
- * - `--permission --allow-fs-read=<bundle dir>,<project dir>[,<trusted
20
- * external package dirs>]` no filesystem write, no child-process, no
21
- * worker-thread access. Bundling with esbuild first (not a packaging
22
- * change see `./bundle.ts`) means the child needs NO TypeScript loader
23
- * (no `tsx`, so no `--allow-worker` and no writable temp dir either),
24
- * unlike the plain `tsx`-based run path. The "trusted external package
25
- * dirs" allowance is narrow and specific: `./bundle.ts` deliberately
26
- * leaves a couple of chant/lexicon-internal dependencies (`typescript`)
27
- * unbundled and resolves them to their real, fixed location instead —
28
- * project source never controls what's installed there.
29
- * - The env is a spawn-time scrub (`env: {}` below, plus `PATH` — see the
30
- * option below), not `--permission`: Node's Permission Model does not gate
31
- * `process.env` at all (confirmed: every key stays readable even under
32
- * `--permission`).
33
- * - Network egress is NOT addressed here — Node has no flag for it. See the
34
- * chant#1045 PR description / docs for the residual-risk statement and
35
- * deployment guidance (a container with no egress, a network namespace).
36
- * This function does not claim to close that gap.
17
+ * Isolation mechanics live in `./fork.ts` — the one function that spawns a
18
+ * sandboxed child, shared with chant #1113's config evaluation
19
+ * (`./config-run.ts`) so the two cannot drift apart. In short:
20
+ * `--permission --allow-fs-read=<bundle dir>,<project dir>[,<trusted external
21
+ * package dirs>]`, a spawn-time environment scrub, no writes, no spawning, no
22
+ * worker threads, and no network guarantee. The "trusted external package
23
+ * dirs" allowance is narrow and specific: `./bundle.ts` deliberately leaves a
24
+ * couple of chant/lexicon-internal dependencies (`typescript`) unbundled and
25
+ * resolves them to their real, fixed location instead — project source never
26
+ * controls what's installed there.
37
27
  *
38
28
  * What does NOT run inside the child: fold (`tryFoldFile`, `../fold-import`)
39
29
  * stays exactly where it is today, in the parent, unsandboxed — fold already
@@ -108,7 +98,23 @@ export async function runFallbackFilesSandboxed(
108
98
  projectRealpath = resolve(buildRoot);
109
99
  }
110
100
 
111
- const response = await runChildProcess(bundlePath, bundleDir, projectRealpath, externalReadPaths);
101
+ const response = await forkSandboxed(
102
+ {
103
+ bundlePath,
104
+ bundleDir,
105
+ projectRealpath,
106
+ externalReadPaths,
107
+ // chant #1045 Phase 2 — Node's Permission Model does not gate
108
+ // `process.env`; scrubbing it at spawn is the only way to keep the
109
+ // ambient environment out of untrusted project source's reach. `PATH`
110
+ // is kept only because some platforms' module resolution/dynamic
111
+ // linking consults it; it carries no project secrets.
112
+ env: { PATH: process.env.PATH ?? "" },
113
+ timeoutMs: CHILD_TIMEOUT_MS,
114
+ label: "sandboxed run",
115
+ },
116
+ isChildResponse,
117
+ );
112
118
 
113
119
  const errors = (response.errors ?? []).map(
114
120
  (e) => new DiscoveryError(e.file, e.message, e.type),
@@ -131,66 +137,3 @@ export async function runFallbackFilesSandboxed(
131
137
  rmSync(bundleDir, { recursive: true, force: true });
132
138
  }
133
139
  }
134
-
135
- /** Fork the bundle under `--permission`, with a scrubbed environment, and resolve with its one IPC message (or reject on crash/timeout/fork error). */
136
- function runChildProcess(
137
- bundlePath: string,
138
- bundleDir: string,
139
- projectRealpath: string,
140
- externalReadPaths: readonly string[],
141
- ): Promise<ChildResponse> {
142
- return new Promise((resolvePromise, reject) => {
143
- const readAllowances = [bundleDir, projectRealpath, ...externalReadPaths].map(
144
- (p) => `--allow-fs-read=${p}`,
145
- );
146
- const child = fork(bundlePath, [], {
147
- execArgv: ["--permission", ...readAllowances],
148
- // chant #1045 Phase 2 — Node's Permission Model does not gate
149
- // `process.env`; scrubbing it here is the only way to keep the ambient
150
- // environment out of untrusted project source's reach. `PATH` is kept
151
- // only because some platforms' module resolution/dynamic linking
152
- // consults it; it carries no project secrets.
153
- env: { PATH: process.env.PATH ?? "" },
154
- stdio: ["ignore", "pipe", "pipe", "ipc"],
155
- });
156
-
157
- let settled = false;
158
- let stderrBuf = "";
159
-
160
- const timeout = setTimeout(() => {
161
- if (settled) return;
162
- settled = true;
163
- child.kill();
164
- reject(new Error(`sandboxed run timed out after ${CHILD_TIMEOUT_MS}ms`));
165
- }, CHILD_TIMEOUT_MS);
166
-
167
- child.stderr?.on("data", (chunk: Buffer) => {
168
- stderrBuf += chunk.toString();
169
- });
170
-
171
- child.on("message", (msg: unknown) => {
172
- if (settled || !isChildResponse(msg)) return;
173
- settled = true;
174
- clearTimeout(timeout);
175
- resolvePromise(msg);
176
- });
177
-
178
- child.on("error", (err) => {
179
- if (settled) return;
180
- settled = true;
181
- clearTimeout(timeout);
182
- reject(err);
183
- });
184
-
185
- child.on("exit", (code, signal) => {
186
- if (settled) return;
187
- settled = true;
188
- clearTimeout(timeout);
189
- reject(
190
- new Error(
191
- `sandboxed child exited before reporting results (code ${code}, signal ${signal})${stderrBuf.trim() ? `: ${stderrBuf.trim()}` : ""}`,
192
- ),
193
- );
194
- });
195
- });
196
- }