@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,138 @@
1
+ import { dirname } from "node:path";
2
+ import { importConfigModule, requireConfigModule, type ConfigModuleNamespace } from "./config-import";
3
+
4
+ /**
5
+ * chant #1113 — decides WHERE a project's `chant.config.ts` is evaluated.
6
+ *
7
+ * `chant.config.ts` is project-authored code. Every other piece of project
8
+ * source moved behind the `--sandbox` boundary in chant #1045 (run-fallback
9
+ * files) and #1093 (composite factories, constructors, intrinsic tags), and
10
+ * both PRs had to document the config file as a remaining hole: the CLI reads
11
+ * its configuration by importing it, in-process, before it knows anything else
12
+ * about the project. This module closes it.
13
+ *
14
+ * ## Why an armed process mode rather than a threaded option
15
+ *
16
+ * Config is loaded from a dozen call sites (`../cli/main.ts` before command
17
+ * dispatch, `./cli/plugins.ts`'s `resolveProjectLexicons`,
18
+ * `./cli/commands/build.ts`, `./lint/policy.ts`, every lifecycle handler …).
19
+ * Threading a `sandbox` option to each one is fail-OPEN: miss a site and
20
+ * project code executes in the CLI process with nothing to notice it. Arming
21
+ * the process once, from the `--sandbox` flag, before any config load happens,
22
+ * is fail-CLOSED — a call site that nobody remembered still routes through the
23
+ * child.
24
+ *
25
+ * ## The bootstrap limit, stated plainly
26
+ *
27
+ * `chant.config.ts`'s own `build.sandbox: true` **cannot** sandbox its own
28
+ * evaluation. Reading that field requires evaluating the file, so by the time
29
+ * chant knows the project asked for sandboxing, the project's code has already
30
+ * run. Only `--sandbox` on the command line — known from `parseArgs`, before
31
+ * any config is touched — arms this. `chant build` warns when sandboxing was
32
+ * enabled by config alone, rather than letting the difference stay invisible.
33
+ * (A `chant.config.json` project has no such limit: JSON is data, parsed and
34
+ * never executed, so `build.sandbox: true` there is fully honest.)
35
+ *
36
+ * ## Memoization
37
+ *
38
+ * Armed loads are memoized per config path for the life of the process. That
39
+ * is not an optimization detail with a semantic cost: the unarmed path is
40
+ * already memoized by Node's own ESM registry (`await import()` evaluates a
41
+ * given config file once per process, so `chant build --watch` has never
42
+ * re-read a config mid-session either). Matching that keeps the two paths
43
+ * behaviorally identical while keeping a build to one bundle+fork instead of
44
+ * one per call site.
45
+ */
46
+
47
+ /** Whether this process must evaluate project configs inside the sandbox boundary. */
48
+ let armed = false;
49
+
50
+ /** Armed-mode results, keyed by absolute config path. See the module doc on why this matches unarmed behavior rather than diverging from it. */
51
+ const memo = new Map<string, unknown>();
52
+
53
+ /**
54
+ * Arm sandboxed config evaluation for the rest of this process. Called from
55
+ * `../cli/main.ts` immediately after `parseArgs`, when `--sandbox` was passed
56
+ * — before the first config load. Idempotent; there is deliberately no
57
+ * disarm, because a security mode that can be turned off partway through a
58
+ * process is not one.
59
+ */
60
+ export function armSandboxConfigEvaluation(): void {
61
+ armed = true;
62
+ }
63
+
64
+ /** Whether {@link armSandboxConfigEvaluation} has been called. */
65
+ export function isSandboxConfigEvaluationArmed(): boolean {
66
+ return armed;
67
+ }
68
+
69
+ /**
70
+ * Test-only reset. Vitest gives each test file its own module registry, so
71
+ * this exists for suites that arm and disarm within one file.
72
+ */
73
+ export function resetSandboxConfigEvaluationForTests(): void {
74
+ armed = false;
75
+ memo.clear();
76
+ }
77
+
78
+ /**
79
+ * The export a config module's configuration lives on: an explicit `default`,
80
+ * a named `config`, else the module namespace itself (a config authored as a
81
+ * set of top-level named exports). Applied identically on both sides of the
82
+ * boundary — in the child by `./discovery/sandbox/driver.ts`'s config driver,
83
+ * here for the in-process path — so `--sandbox` changes only where the file is
84
+ * evaluated, never how its result is read.
85
+ */
86
+ export function selectConfigExport(namespace: ConfigModuleNamespace): unknown {
87
+ return namespace.default ?? namespace.config ?? namespace;
88
+ }
89
+
90
+ /**
91
+ * Evaluate a project's `chant.config.ts` and return its configuration object,
92
+ * pre-`normalizeConfig`. Sandboxed when armed, in-process otherwise.
93
+ *
94
+ * @param configPath - Absolute path to the config file.
95
+ * @param projectRoot - Directory to grant the sandboxed child read access to;
96
+ * defaults to the config file's own directory.
97
+ */
98
+ export async function evaluateProjectConfig(
99
+ configPath: string,
100
+ projectRoot: string = dirname(configPath),
101
+ ): Promise<unknown> {
102
+ if (!armed) {
103
+ return selectConfigExport(await importConfigModule(configPath));
104
+ }
105
+
106
+ if (memo.has(configPath)) return memo.get(configPath);
107
+
108
+ // Dynamic, not static, for the same reason `./discovery/index.ts` imports
109
+ // `./sandbox/run` dynamically: this pulls in `esbuild`, a large CJS package
110
+ // no unsandboxed build should pay to load.
111
+ const { evaluateConfigSandboxed } = await import("./discovery/sandbox/config-run");
112
+ const { config } = await evaluateConfigSandboxed(configPath, projectRoot);
113
+ memo.set(configPath, config);
114
+ return config;
115
+ }
116
+
117
+ /**
118
+ * Synchronous counterpart for `./lint/config.ts`'s `loadConfig` (`chant lint`
119
+ * is a sync pipeline and predates the async loader).
120
+ *
121
+ * When armed, this cannot spawn a child — so it uses the result of an earlier
122
+ * armed load of the same file, and refuses if there isn't one. In practice
123
+ * there always is: `../cli/main.ts` loads the project config before dispatching
124
+ * to any command. Refusing rather than falling back to an in-process `require`
125
+ * is the point — a sync call site is exactly where a boundary would otherwise
126
+ * be lost by accident.
127
+ */
128
+ export function evaluateProjectConfigSync(configPath: string, dir: string): unknown {
129
+ if (!armed) {
130
+ return selectConfigExport(requireConfigModule(configPath, dir));
131
+ }
132
+
133
+ if (memo.has(configPath)) return memo.get(configPath);
134
+
135
+ throw new Error(
136
+ `Cannot read ${configPath} synchronously under --sandbox: it has not yet been evaluated inside the boundary, and evaluating it here would run project code in the chant process. This is a chant bug — the project config should have been loaded before this point.`,
137
+ );
138
+ }
package/src/config.ts CHANGED
@@ -7,6 +7,8 @@ import { DEFAULT_SBOM_FORMAT, type SbomFormat } from "./components/verbs/sbom-ge
7
7
  import type { Severity } from "./components/verbs/vuln-scan";
8
8
  import type { VulnPolicy } from "./components/verbs/vuln-gate";
9
9
  import type { BuildParamsConfig } from "./build-params";
10
+ import { findProjectConfig } from "./project-root";
11
+ import { evaluateProjectConfig } from "./config-sandbox";
10
12
 
11
13
  /**
12
14
  * Zod schema for ChantConfig validation.
@@ -273,16 +275,24 @@ export const DEFAULT_CHANT_CONFIG: ChantConfig = {};
273
275
  /**
274
276
  * Load project configuration from a directory.
275
277
  *
276
- * Tries `chant.config.ts` first (via dynamic import), then `chant.config.json`.
277
- * Returns default config if neither exists.
278
+ * Tries `chant.config.ts` first, then `chant.config.json`. Returns default
279
+ * config if neither exists.
280
+ *
281
+ * chant #1113 — `chant.config.ts` is project-authored code, so *where* it is
282
+ * evaluated is a security question, and the answer is
283
+ * `./config-sandbox.ts`'s: in a sandboxed child when this process was armed by
284
+ * `chant build --sandbox`, in-process (exactly as before) otherwise. Either
285
+ * way the result is the same plain configuration object, and validation
286
+ * ({@link normalizeConfig}) happens here, in the trusted process.
287
+ * `chant.config.json` is data, not code — it is parsed in-process under
288
+ * `--sandbox` too, because there is nothing to execute.
278
289
  */
279
290
  export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
280
291
  // Try chant.config.ts first
281
292
  const tsPath = join(dir, "chant.config.ts");
282
293
  if (existsSync(tsPath)) {
283
- const mod = await import(tsPath);
284
- const config = mod.default ?? mod.config ?? mod;
285
- return { config: normalizeConfig(config, tsPath), configPath: tsPath };
294
+ const config = await evaluateProjectConfig(tsPath, dir);
295
+ return { config: normalizeConfig(config as Record<string, unknown>, tsPath), configPath: tsPath };
286
296
  }
287
297
 
288
298
  // Fall back to chant.config.json
@@ -297,6 +307,28 @@ export async function loadChantConfig(dir: string): Promise<ResolvedConfig> {
297
307
  return { config: DEFAULT_CHANT_CONFIG };
298
308
  }
299
309
 
310
+ /**
311
+ * Load project configuration by walking up from `startDir` to the project
312
+ * root (chant #1117), instead of trying only `startDir` itself.
313
+ *
314
+ * `chant build src/<stack>` (and anything else invoked with a subdirectory —
315
+ * `lint.policies`' `evaluateProjectPolicies`, `--components --generate`)
316
+ * builds scoped to that subdirectory, but `chant.config.ts` almost always
317
+ * lives at the project root, one or more levels up. Before this, callers
318
+ * either read `startDir` alone or bolted on a single `dirname()` fallback —
319
+ * fine for a one-level-deep stack, silently blind to anything deeper
320
+ * (loomster's `src/<stack>` layout is exactly one level too deep: `buildParams`'
321
+ * declared `env:` mappings never resolved, so `LOOM_TIER`/`LOOM_ENV` were inert
322
+ * under every `npm run synth:*` for two releases — loomster#162). Uses the
323
+ * same walk `chant lint`/`chant graph` already used ({@link findProjectConfig},
324
+ * shared with `./lint/config.ts`'s `findProjectRoot`) — one config-discovery
325
+ * contract for the whole CLI.
326
+ */
327
+ export async function loadChantConfigUpward(startDir: string): Promise<ResolvedConfig> {
328
+ const { dir } = findProjectConfig(startDir);
329
+ return loadChantConfig(dir);
330
+ }
331
+
300
332
  /**
301
333
  * Resolve the ownership marker to stamp from project config, or undefined when
302
334
  * ownership marking is off (no `stack`, or `enabled: false`).
@@ -58,7 +58,7 @@ import { DECLARABLE_MARKER, isResourceDeclarable, type Declarable } from "../dec
58
58
  import { AttrRef } from "../attrref";
59
59
  import { INTRINSIC_MARKER, type Intrinsic } from "../intrinsic";
60
60
  import { isAttrRefLike } from "../utils";
61
- import { isLexiconOutput, LexiconOutput } from "../lexicon-output";
61
+ import { isLexiconOutput, LexiconOutput, type LexiconOutputLiteral } from "../lexicon-output";
62
62
  import { isChildProject } from "../child-project";
63
63
 
64
64
  /**
@@ -137,7 +137,7 @@ export interface WireLexiconOutputEntity {
137
137
  form: "lexiconOutput";
138
138
  name: string;
139
139
  outputName: string;
140
- /** The wrapped `AttrRef` or `Intrinsic` — encodes to `{__attrRef}` or `{__intrinsic}` respectively (see {@link WireValue}). */
140
+ /** The wrapped `AttrRef`, `Intrinsic`, or literal — encodes to `{__attrRef}`, `{__intrinsic}`, or a plain JSON primitive respectively (chant #1121; see {@link WireValue}). */
141
141
  ref: WireValue;
142
142
  }
143
143
 
@@ -156,15 +156,22 @@ const CORE_FIELD_NAMES = new Set(["lexicon", "entityType", "kind", "props", "att
156
156
 
157
157
  /**
158
158
  * Read a `LexiconOutput`'s internal ref without re-deriving it — see the
159
- * `_intrinsic`/`_sourceParent` fields in `../lexicon-output.ts`. When it was
160
- * built from an `AttrRef`, `LexiconOutput` keeps only the parent `WeakRef` +
161
- * attribute name (not the original `AttrRef` instance), so a fresh one is
162
- * synthesized here — its logical name must be set explicitly (from
163
- * `entityNames`, the same map every other reference in this module resolves
164
- * names through) since it never went through `resolveAttrRefs`.
159
+ * `_intrinsic`/`_sourceParent`/`_literalValue` fields in `../lexicon-output.ts`.
160
+ * When it was built from an `AttrRef`, `LexiconOutput` keeps only the parent
161
+ * `WeakRef` + attribute name (not the original `AttrRef` instance), so a
162
+ * fresh one is synthesized here — its logical name must be set explicitly
163
+ * (from `entityNames`, the same map every other reference in this module
164
+ * resolves names through) since it never went through `resolveAttrRefs`.
165
+ *
166
+ * chant #1121 — a literal-valued output (a real string/number/boolean the
167
+ * author's code already computed, not a reference) has neither a parent nor
168
+ * an intrinsic to hand back; its `_literalValue` round-trips as a plain wire
169
+ * primitive instead, and `LexiconOutput`'s own constructor reconstructs it
170
+ * identically on decode (see `decodeEntitySet` below).
165
171
  */
166
- function lexiconOutputRef(output: LexiconOutput, entityNames: Map<unknown, string>): AttrRef | Intrinsic {
172
+ function lexiconOutputRef(output: LexiconOutput, entityNames: Map<unknown, string>): AttrRef | Intrinsic | LexiconOutputLiteral {
167
173
  if (output._intrinsic) return output._intrinsic;
174
+ if (output._literalValue !== null) return output._literalValue;
168
175
  const parent = output._sourceParent?.deref();
169
176
  const parentName = parent ? entityNames.get(parent) : undefined;
170
177
  if (!parent || output.sourceAttribute === null || !parentName) {
@@ -475,9 +482,11 @@ export function decodeEntitySet(wire: EntitySetWire): Map<string, Declarable> {
475
482
  }
476
483
 
477
484
  // LexiconOutput — constructed via its real constructor from the decoded
478
- // ref, so it derives sourceLexicon/_sourceParent/sourceAttribute exactly
479
- // the way constructing it from a live AttrRef/Intrinsic would.
480
- const ref = decodeValue(entry.ref, registry) as AttrRef | Intrinsic;
485
+ // ref, so it derives sourceLexicon/_sourceParent/sourceAttribute/
486
+ // _literalValue exactly the way constructing it from a live
487
+ // AttrRef/Intrinsic/literal would (chant #1121 for the literal case: a
488
+ // plain wire primitive decodes back to itself, see `decodeValue` above).
489
+ const ref = decodeValue(entry.ref, registry) as AttrRef | Intrinsic | LexiconOutputLiteral;
481
490
  const output = new LexiconOutput(ref, entry.outputName);
482
491
  registry.set(entry.name, output);
483
492
  result.set(entry.name, output as unknown as Declarable);
@@ -197,6 +197,32 @@ describe("entity-wire round trip (chant #1045 Phase 1)", () => {
197
197
  expect(isAttrRefLike(embedded[0])).toBe(true);
198
198
  });
199
199
 
200
+ // chant #1121 — before this fix, encoding a literal-valued LexiconOutput
201
+ // (neither an AttrRef nor an Intrinsic) threw
202
+ // `encodeEntitySet: LexiconOutput "..." has neither a resolvable AttrRef
203
+ // parent nor an intrinsic` — this is the sandboxed-child boundary a
204
+ // `chant build --sandbox` run crosses for every entity, so a project using
205
+ // `output("literal", "name")` could not build under `--sandbox` at all.
206
+ test.each([
207
+ ["string", "fold-output-repro"],
208
+ ["number", 42],
209
+ ["boolean", true],
210
+ ] as const)("LexiconOutput built from a %s literal round-trips via its real constructor", (_kind, value) => {
211
+ const lexOutput = new LexiconOutput(value, "LiteralOut");
212
+ const entities = new Map<string, Declarable>([["LiteralOut", lexOutput as unknown as Declarable]]);
213
+
214
+ const wire = encodeEntitySet(entities);
215
+ assertPureJson(wire);
216
+
217
+ const decoded = decodeEntitySet(JSON.parse(JSON.stringify(wire)) as EntitySetWire);
218
+ const decodedOutput = decoded.get("LiteralOut") as unknown as LexiconOutput;
219
+ expect(isLexiconOutput(decodedOutput)).toBe(true);
220
+ expect(decodedOutput.outputName).toBe("LiteralOut");
221
+ expect(decodedOutput.sourceEntity).toBe("");
222
+ expect(decodedOutput.sourceAttribute).toBeNull();
223
+ expect(decodedOutput.getOutputValue()).toBe(value);
224
+ });
225
+
200
226
  test("a lexicon-specific marker symbol (not core-owned) round-trips generically", () => {
201
227
  const MARKER = Symbol.for("chant.test.customMarker");
202
228
  const custom: Declarable = {
@@ -0,0 +1,239 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { loadChantConfig } from "../../config";
6
+ import {
7
+ armSandboxConfigEvaluation,
8
+ evaluateProjectConfigSync,
9
+ isSandboxConfigEvaluationArmed,
10
+ resetSandboxConfigEvaluationForTests,
11
+ } from "../../config-sandbox";
12
+ import { ENV_VAR } from "../../env";
13
+
14
+ /**
15
+ * chant #1113 — `chant.config.ts` is project-authored code, and under
16
+ * `--sandbox` it must not execute in the CLI's process.
17
+ *
18
+ * Same shape of proof as `./fold-boundary.test.ts` (chant #1093): the fixture
19
+ * config sets a `globalThis` marker at module top level, and a marker set
20
+ * inside the sandboxed child cannot reach this process. Every pair asserts the
21
+ * UNARMED half fires the marker first, so the probe is proven capable of
22
+ * failing before the armed half asserts it stays clean.
23
+ *
24
+ * Fixtures go to a fresh tmpdir per test — never the source tree — so no two
25
+ * tests share a module path and nothing bleeds through Node's module cache.
26
+ */
27
+
28
+ const MARKER = "__chant1113ConfigEvaluated";
29
+
30
+ type MarkerHost = Record<string, boolean | undefined>;
31
+
32
+ function marker(): boolean | undefined {
33
+ return (globalThis as unknown as MarkerHost)[MARKER];
34
+ }
35
+
36
+ describe("chant.config.ts evaluation under --sandbox (chant #1113)", () => {
37
+ let testDir: string;
38
+ let savedEnv: string | undefined;
39
+
40
+ beforeEach(async () => {
41
+ const dir = join(tmpdir(), `chant-1113-config-${Date.now()}-${Math.random()}`);
42
+ await mkdir(dir, { recursive: true });
43
+ testDir = await realpath(dir);
44
+ delete (globalThis as unknown as MarkerHost)[MARKER];
45
+ savedEnv = process.env[ENV_VAR];
46
+ resetSandboxConfigEvaluationForTests();
47
+ });
48
+
49
+ afterEach(async () => {
50
+ delete (globalThis as unknown as MarkerHost)[MARKER];
51
+ if (savedEnv === undefined) delete process.env[ENV_VAR];
52
+ else process.env[ENV_VAR] = savedEnv;
53
+ resetSandboxConfigEvaluationForTests();
54
+ await rm(testDir, { recursive: true, force: true });
55
+ });
56
+
57
+ /** A config that announces its own evaluation, the way any project file's top level can. */
58
+ async function writeMarkerConfig(body = `{ lexicons: ["aws"], ownership: { stack: "s" } }`): Promise<void> {
59
+ await writeFile(
60
+ join(testDir, "chant.config.ts"),
61
+ `globalThis[${JSON.stringify(MARKER)}] = true;\nexport default ${body};\n`,
62
+ );
63
+ }
64
+
65
+ test("without --sandbox the config DOES evaluate in this process (the probe fires)", async () => {
66
+ await writeMarkerConfig();
67
+
68
+ const { config } = await loadChantConfig(testDir);
69
+
70
+ expect(isSandboxConfigEvaluationArmed()).toBe(false);
71
+ expect(marker(), "the config's top level ran in this process").toBe(true);
72
+ expect(config.lexicons).toEqual(["aws"]);
73
+ expect(config.ownership).toEqual({ stack: "s" });
74
+ });
75
+
76
+ test("armed, the same config evaluates in the child: no marker here, same config", async () => {
77
+ await writeMarkerConfig();
78
+ armSandboxConfigEvaluation();
79
+
80
+ const { config, configPath } = await loadChantConfig(testDir);
81
+
82
+ expect(marker(), "the config's top level must NOT run in the CLI process").toBeUndefined();
83
+ expect(configPath).toBe(join(testDir, "chant.config.ts"));
84
+ expect(config.lexicons).toEqual(["aws"]);
85
+ expect(config.ownership).toEqual({ stack: "s" });
86
+ });
87
+
88
+ test("a config authored as a named `config` export crosses the same way", async () => {
89
+ await writeFile(
90
+ join(testDir, "chant.config.ts"),
91
+ `globalThis[${JSON.stringify(MARKER)}] = true;\nexport const config = { lexicons: ["k8s"] };\n`,
92
+ );
93
+ armSandboxConfigEvaluation();
94
+
95
+ const { config } = await loadChantConfig(testDir);
96
+
97
+ expect(marker()).toBeUndefined();
98
+ expect(config.lexicons).toEqual(["k8s"]);
99
+ });
100
+
101
+ test("nested data — buildParams, stacks, lint rules — survives the round trip", async () => {
102
+ await writeMarkerConfig(
103
+ `{
104
+ lexicons: ["aws"],
105
+ stacks: [{ name: "net", src: "src/net" }, { name: "app", src: "src/app" }],
106
+ buildParams: { tier: { type: "string", default: "light", enum: ["light", "prod"] } },
107
+ lint: { rules: { COR001: "error", COR002: ["warning", { max: 3 }] }, policies: ["policies/org.ts"] },
108
+ }`,
109
+ );
110
+ armSandboxConfigEvaluation();
111
+
112
+ const { config } = await loadChantConfig(testDir);
113
+
114
+ expect(marker()).toBeUndefined();
115
+ expect(config.stacks).toEqual([
116
+ { name: "net", src: "src/net" },
117
+ { name: "app", src: "src/app" },
118
+ ]);
119
+ expect(config.buildParams).toEqual({
120
+ tier: { type: "string", default: "light", enum: ["light", "prod"] },
121
+ });
122
+ expect(config.lint?.rules).toEqual({ COR001: "error", COR002: ["warning", { max: 3 }] });
123
+ expect(config.lint?.policies).toEqual(["policies/org.ts"]);
124
+ });
125
+
126
+ test("chant.config.json needs no child — it is data, parsed in-process either way", async () => {
127
+ await writeFile(
128
+ join(testDir, "chant.config.json"),
129
+ JSON.stringify({ lexicons: ["gcp"], build: { sandbox: true } }),
130
+ );
131
+ armSandboxConfigEvaluation();
132
+
133
+ const { config, configPath } = await loadChantConfig(testDir);
134
+
135
+ expect(configPath).toBe(join(testDir, "chant.config.json"));
136
+ expect(config.lexicons).toEqual(["gcp"]);
137
+ });
138
+
139
+ test("a value that cannot cross as JSON is refused, naming the key", async () => {
140
+ await writeFile(
141
+ join(testDir, "chant.config.ts"),
142
+ `export default { lexicons: ["aws"], hooks: { beforeBuild: () => 1 } };\n`,
143
+ );
144
+ armSandboxConfigEvaluation();
145
+
146
+ await expect(loadChantConfig(testDir)).rejects.toThrow(/hooks\.beforeBuild: a function/);
147
+ expect(marker()).toBeUndefined();
148
+ });
149
+
150
+ test("a Date is refused too — JSON.stringify would silently turn it into a string", async () => {
151
+ await writeFile(
152
+ join(testDir, "chant.config.ts"),
153
+ `export default { lexicons: ["aws"], meta: { generatedAt: new Date(0) } };\n`,
154
+ );
155
+ armSandboxConfigEvaluation();
156
+
157
+ await expect(loadChantConfig(testDir)).rejects.toThrow(/meta\.generatedAt: a Date/);
158
+ });
159
+
160
+ test("a config that reads outside the project is denied, naming the config file", async () => {
161
+ await writeFile(
162
+ join(testDir, "chant.config.ts"),
163
+ `import { readFileSync } from "node:fs";\n` +
164
+ `const stolen = readFileSync("/etc/hosts", "utf-8");\n` +
165
+ `export default { lexicons: [stolen.slice(0, 3)] };\n`,
166
+ );
167
+ armSandboxConfigEvaluation();
168
+
169
+ await expect(loadChantConfig(testDir)).rejects.toThrow(/sandbox denied FileSystemRead/);
170
+ });
171
+
172
+ test("a config that tries to spawn a process is denied", async () => {
173
+ await writeFile(
174
+ join(testDir, "chant.config.ts"),
175
+ `import { execSync } from "node:child_process";\n` +
176
+ `execSync("echo pwned");\n` +
177
+ `export default { lexicons: ["aws"] };\n`,
178
+ );
179
+ armSandboxConfigEvaluation();
180
+
181
+ await expect(loadChantConfig(testDir)).rejects.toThrow(/sandbox denied/);
182
+ });
183
+
184
+ test("the child's environment is scrubbed — but --env still reaches the config", async () => {
185
+ process.env[ENV_VAR] = "prod";
186
+ process.env.CHANT_1113_SECRET = "hunter2";
187
+ try {
188
+ await writeFile(
189
+ join(testDir, "chant.config.ts"),
190
+ `export default {\n` +
191
+ ` environments: [process.env[${JSON.stringify(ENV_VAR)}] ?? "none"],\n` +
192
+ ` sourceDir: process.env.CHANT_1113_SECRET ?? "scrubbed",\n` +
193
+ `};\n`,
194
+ );
195
+ armSandboxConfigEvaluation();
196
+
197
+ const { config } = await loadChantConfig(testDir);
198
+
199
+ expect(config.environments, "--env is forwarded so the config resolves the same either way").toEqual(["prod"]);
200
+ expect(config.sourceDir, "nothing else from the CLI's environment is visible").toBe("scrubbed");
201
+ } finally {
202
+ delete process.env.CHANT_1113_SECRET;
203
+ }
204
+ });
205
+
206
+ test("an armed load is memoized, so repeated loads cost one child, not one each", async () => {
207
+ await writeMarkerConfig();
208
+ armSandboxConfigEvaluation();
209
+
210
+ const first = await loadChantConfig(testDir);
211
+ const second = await loadChantConfig(testDir);
212
+
213
+ expect(second.config).toBe(first.config);
214
+ expect(marker()).toBeUndefined();
215
+ });
216
+
217
+ test("the sync lint loader reuses that result rather than requiring the file", async () => {
218
+ await writeMarkerConfig(`{ lexicons: ["aws"], lint: { rules: { COR001: "warning" } } }`);
219
+ armSandboxConfigEvaluation();
220
+
221
+ await loadChantConfig(testDir);
222
+ const config = evaluateProjectConfigSync(join(testDir, "chant.config.ts"), testDir) as {
223
+ lint?: { rules?: Record<string, unknown> };
224
+ };
225
+
226
+ expect(config.lint?.rules?.COR001).toBe("warning");
227
+ expect(marker()).toBeUndefined();
228
+ });
229
+
230
+ test("the sync lint loader refuses rather than importing when nothing was evaluated yet", async () => {
231
+ await writeMarkerConfig();
232
+ armSandboxConfigEvaluation();
233
+
234
+ expect(() => evaluateProjectConfigSync(join(testDir, "chant.config.ts"), testDir)).toThrow(
235
+ /Cannot read .* synchronously under --sandbox/,
236
+ );
237
+ expect(marker()).toBeUndefined();
238
+ });
239
+ });
@@ -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
+ }