@intentius/chant 0.24.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.
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/config-import.d.ts +33 -0
- package/dist/config-import.d.ts.map +1 -0
- package/dist/config-sandbox.d.ts +47 -0
- package/dist/config-sandbox.d.ts.map +1 -0
- package/dist/config.d.ts +11 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts +1 -1
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-run.d.ts +24 -0
- package/dist/discovery/sandbox/config-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/config-wire.d.ts +68 -0
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -0
- package/dist/discovery/sandbox/driver.d.ts +20 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +52 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -0
- package/dist/discovery/sandbox/run.d.ts +10 -20
- package/dist/discovery/sandbox/run.d.ts.map +1 -1
- package/dist/lexicon-output.d.ts +62 -6
- package/dist/lexicon-output.d.ts.map +1 -1
- package/dist/lint/config.d.ts +6 -0
- package/dist/lint/config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.test.ts +19 -0
- package/src/build.ts +21 -10
- package/src/cli/commands/build.ts +13 -0
- package/src/cli/main.ts +10 -0
- package/src/config-import.ts +43 -0
- package/src/config-sandbox.ts +138 -0
- package/src/config.ts +14 -5
- package/src/discovery/entity-wire-codec.ts +21 -12
- package/src/discovery/entity-wire.test.ts +26 -0
- package/src/discovery/sandbox/config-boundary.test.ts +239 -0
- package/src/discovery/sandbox/config-run.ts +130 -0
- package/src/discovery/sandbox/config-wire.test.ts +110 -0
- package/src/discovery/sandbox/config-wire.ts +174 -0
- package/src/discovery/sandbox/driver.ts +68 -0
- package/src/discovery/sandbox/fork.ts +110 -0
- package/src/discovery/sandbox/run.ts +28 -85
- package/src/lexicon-output.test.ts +137 -1
- package/src/lexicon-output.ts +112 -13
- package/src/lint/config.ts +8 -4
|
@@ -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,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
|
+
}
|