@intentius/chant 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +15 -10
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
- package/dist/discovery/graph.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-run.d.ts +24 -0
- package/dist/discovery/sandbox/config-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/config-wire.d.ts +84 -0
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -0
- package/dist/discovery/sandbox/driver.d.ts +49 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +70 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -0
- package/dist/discovery/sandbox/policy-run.d.ts +33 -0
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
- package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
- package/dist/discovery/sandbox/run.d.ts +10 -20
- package/dist/discovery/sandbox/run.d.ts.map +1 -1
- package/dist/intrinsic-interpolation.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/dist/lint/policy-import.d.ts +50 -0
- package/dist/lint/policy-import.d.ts.map +1 -0
- package/dist/lint/policy-sandbox.d.ts +89 -0
- package/dist/lint/policy-sandbox.d.ts.map +1 -0
- package/dist/lint/policy.d.ts +12 -1
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.test.ts +96 -1
- package/src/build.ts +36 -12
- package/src/cli/commands/build.ts +46 -5
- package/src/cli/main.test.ts +93 -17
- package/src/cli/main.ts +111 -11
- 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 +35 -21
- package/src/discovery/entity-wire.test.ts +26 -0
- package/src/discovery/graph.test.ts +40 -1
- package/src/discovery/graph.ts +8 -2
- 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 +195 -0
- package/src/discovery/sandbox/driver.ts +200 -0
- package/src/discovery/sandbox/fork.ts +148 -0
- package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
- package/src/discovery/sandbox/policy-run.ts +180 -0
- package/src/discovery/sandbox/policy-wire.test.ts +310 -0
- package/src/discovery/sandbox/policy-wire.ts +277 -0
- package/src/discovery/sandbox/run.ts +28 -85
- package/src/intrinsic-interpolation.test.ts +27 -1
- package/src/intrinsic-interpolation.ts +10 -2
- package/src/lexicon-output.test.ts +173 -1
- package/src/lexicon-output.ts +123 -14
- package/src/lint/config.ts +8 -4
- package/src/lint/policy-import.ts +70 -0
- package/src/lint/policy-sandbox.ts +123 -0
- package/src/lint/policy.ts +20 -2
- package/src/stack-output.test.ts +118 -0
- package/src/stack-output.ts +21 -5
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { realpathSync, rmSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { bundleDriver } from "./bundle";
|
|
4
|
+
import { generateConfigDriverSource } from "./driver";
|
|
5
|
+
import { formatConfigWireOffenders, type ConfigWireOffender } from "./config-wire";
|
|
6
|
+
import { forkSandboxed } from "./fork";
|
|
7
|
+
import { ENV_VAR } from "../../env";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* chant #1113 — evaluates a project's `chant.config.ts` inside the same
|
|
11
|
+
* sandboxed child `--sandbox` already uses for run-fallback source, and brings
|
|
12
|
+
* back plain JSON.
|
|
13
|
+
*
|
|
14
|
+
* This closes the residual chant #1093 documented and #1113 filed: `loadConfig`
|
|
15
|
+
* imported the project's own `chant.config.ts` into the CLI process, so a
|
|
16
|
+
* hostile repo's config executed with full CLI trust even under `--sandbox`.
|
|
17
|
+
* The config is project-authored code like any other file in the repo; the
|
|
18
|
+
* only reason it was ever treated differently is that the CLI has to read it
|
|
19
|
+
* before it knows anything else about the project.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately the same machinery, not a parallel one:
|
|
22
|
+
* - `./bundle.ts` bundles the generated driver (`./driver.ts`'s
|
|
23
|
+
* `generateConfigDriverSource`) with esbuild, so the child needs no runtime
|
|
24
|
+
* module resolution and no TypeScript loader.
|
|
25
|
+
* - `./fork.ts` spawns it with the identical `--permission` profile
|
|
26
|
+
* `runFallbackFilesSandboxed` uses — one function, so the two cannot drift.
|
|
27
|
+
* - `./child-errors.ts` classifies whatever it throws, so a permission denial
|
|
28
|
+
* names the config file instead of leaking `ERR_ACCESS_DENIED`.
|
|
29
|
+
*
|
|
30
|
+
* The one deliberate difference from the run-fallback child is `CHANT_ENV`.
|
|
31
|
+
* `../../cli/main.ts` sets it from `--env` *before* loading the config,
|
|
32
|
+
* specifically because a config may branch on the environment; dropping it
|
|
33
|
+
* would silently produce a different configuration under `--sandbox` than
|
|
34
|
+
* without. It is a value the user typed on the command line, not an ambient
|
|
35
|
+
* secret, so forwarding exactly that one key — and nothing else from
|
|
36
|
+
* `process.env` — keeps the scrub meaningful while keeping `--env` honest.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** How long to wait for the config child. A config is one small module; anything approaching this is hung, not slow. */
|
|
40
|
+
const CONFIG_CHILD_TIMEOUT_MS = 60_000;
|
|
41
|
+
|
|
42
|
+
interface ConfigChildResponse {
|
|
43
|
+
kind: "chant-config";
|
|
44
|
+
ok: boolean;
|
|
45
|
+
config?: unknown;
|
|
46
|
+
offenders?: ConfigWireOffender[];
|
|
47
|
+
error?: { name: string; file: string; message: string; type: string };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isConfigChildResponse(value: unknown): value is ConfigChildResponse {
|
|
51
|
+
return (
|
|
52
|
+
typeof value === "object" &&
|
|
53
|
+
value !== null &&
|
|
54
|
+
(value as { kind?: unknown }).kind === "chant-config" &&
|
|
55
|
+
typeof (value as { ok?: unknown }).ok === "boolean"
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SandboxConfigResult {
|
|
60
|
+
/** The evaluated configuration, as plain JSON. Interpreted (default/config/namespace selection already applied in the child; Zod validation still to come) by `../../config.ts`'s `normalizeConfig`, in the parent, unchanged. */
|
|
61
|
+
config: unknown;
|
|
62
|
+
/** esbuild bundling wall-clock time. */
|
|
63
|
+
bundleMs: number;
|
|
64
|
+
/** Bundle size in bytes. */
|
|
65
|
+
bundleBytes: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Evaluate `configPath` in a sandboxed child and return its configuration as
|
|
70
|
+
* plain data.
|
|
71
|
+
*
|
|
72
|
+
* Throws — rather than degrading to an in-process import or to defaults — when
|
|
73
|
+
* the config cannot be evaluated inside the boundary or cannot cross it as
|
|
74
|
+
* JSON. Under `--sandbox` a config that "almost" loaded is not a safe thing to
|
|
75
|
+
* proceed with, and quietly falling back would give away the property the flag
|
|
76
|
+
* exists to provide.
|
|
77
|
+
*
|
|
78
|
+
* @param configPath - Absolute path to the project's `chant.config.ts`.
|
|
79
|
+
* @param projectRoot - Directory the child is granted `--allow-fs-read` for
|
|
80
|
+
* (the config's own project root, i.e. `findProjectConfig`'s `dir`).
|
|
81
|
+
*/
|
|
82
|
+
export async function evaluateConfigSandboxed(
|
|
83
|
+
configPath: string,
|
|
84
|
+
projectRoot: string,
|
|
85
|
+
): Promise<SandboxConfigResult> {
|
|
86
|
+
const driverSource = generateConfigDriverSource(configPath);
|
|
87
|
+
const { bundlePath, bundleDir, externalReadPaths, durationMs, bytes } = await bundleDriver(driverSource);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
let projectRealpath: string;
|
|
91
|
+
try {
|
|
92
|
+
projectRealpath = realpathSync(resolve(projectRoot));
|
|
93
|
+
} catch {
|
|
94
|
+
projectRealpath = resolve(projectRoot);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const env: Record<string, string> = { PATH: process.env.PATH ?? "" };
|
|
98
|
+
// See the module doc: the one forwarded variable, and only when set.
|
|
99
|
+
const activeEnv = process.env[ENV_VAR];
|
|
100
|
+
if (activeEnv) env[ENV_VAR] = activeEnv;
|
|
101
|
+
|
|
102
|
+
const response = await forkSandboxed(
|
|
103
|
+
{
|
|
104
|
+
bundlePath,
|
|
105
|
+
bundleDir,
|
|
106
|
+
projectRealpath,
|
|
107
|
+
externalReadPaths,
|
|
108
|
+
env,
|
|
109
|
+
timeoutMs: CONFIG_CHILD_TIMEOUT_MS,
|
|
110
|
+
label: `sandboxed evaluation of ${configPath}`,
|
|
111
|
+
},
|
|
112
|
+
isConfigChildResponse,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
if (response.offenders && response.offenders.length > 0) {
|
|
117
|
+
throw new Error(formatConfigWireOffenders(configPath, response.offenders));
|
|
118
|
+
}
|
|
119
|
+
throw new Error(
|
|
120
|
+
response.error?.message
|
|
121
|
+
? `Failed to evaluate ${configPath} inside the --sandbox boundary: ${response.error.message}`
|
|
122
|
+
: `Failed to evaluate ${configPath} inside the --sandbox boundary`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { config: response.config ?? {}, bundleMs: durationMs, bundleBytes: bytes };
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(bundleDir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { scanConfigWireSafety, formatConfigWireOffenders } from "./config-wire";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* chant #1113 — the contract for what a `chant.config.ts` may hold if it is to
|
|
6
|
+
* be evaluated inside the `--sandbox` boundary. Only JSON crosses a process
|
|
7
|
+
* boundary, and `JSON.stringify` is lossy without complaining, so this scan is
|
|
8
|
+
* what turns "silently dropped" into "named and refused".
|
|
9
|
+
*/
|
|
10
|
+
describe("scanConfigWireSafety", () => {
|
|
11
|
+
test("a real ChantConfig has nothing that cannot cross", () => {
|
|
12
|
+
expect(
|
|
13
|
+
scanConfigWireSafety({
|
|
14
|
+
lexicons: ["aws", "k8s"],
|
|
15
|
+
environments: ["staging", "prod"],
|
|
16
|
+
sourceDir: "src",
|
|
17
|
+
stacks: [{ name: "net", src: "src/net" }],
|
|
18
|
+
ownership: { stack: "storefront", env: "prod", enabled: true },
|
|
19
|
+
build: { fold: true, sandbox: true },
|
|
20
|
+
buildParams: { tier: { type: "string", default: "light", enum: ["light", "prod"] } },
|
|
21
|
+
lint: { rules: { COR001: "error", COR002: ["warning", { max: 3 }] }, policies: ["policies/org.ts"] },
|
|
22
|
+
vulnPolicy: { failSeverity: "critical", license: { allow: ["MIT"], deny: [] } },
|
|
23
|
+
// A lexicon's passthrough extension (the temporal lexicon's shape) —
|
|
24
|
+
// still pure data.
|
|
25
|
+
temporal: { profiles: { local: { address: "localhost:7233", tls: false } }, defaultProfile: "local" },
|
|
26
|
+
}),
|
|
27
|
+
).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("null, empty objects and empty arrays are data", () => {
|
|
31
|
+
expect(scanConfigWireSafety({ a: null, b: {}, c: [], d: 0, e: false, f: "" })).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test.each([
|
|
35
|
+
["a function", { hooks: { before: () => 1 } }, "hooks.before", "a function"],
|
|
36
|
+
["a Date", { meta: { at: new Date(0) } }, "meta.at", "a Date"],
|
|
37
|
+
["a RegExp", { match: /x/ }, "match", "a RegExp"],
|
|
38
|
+
["a Map", { m: new Map() }, "m", "a Map"],
|
|
39
|
+
["a Set", { s: new Set() }, "s", "a Set"],
|
|
40
|
+
["a bigint", { n: 1n }, "n", "a bigint"],
|
|
41
|
+
["NaN", { n: Number.NaN }, "n", "NaN"],
|
|
42
|
+
["Infinity", { n: Number.POSITIVE_INFINITY }, "n", "a non-finite number"],
|
|
43
|
+
["a symbol", { s: Symbol("x") }, "s", "a symbol"],
|
|
44
|
+
])("%s is reported with its key path", (_label, config, path, found) => {
|
|
45
|
+
expect(scanConfigWireSafety(config)).toEqual([{ path, found }]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("array positions are reported with an index", () => {
|
|
49
|
+
expect(scanConfigWireSafety({ stacks: [{ name: "a", src: "s" }, { name: "b", src: () => "s" }] })).toEqual([
|
|
50
|
+
{ path: "stacks[1].src", found: "a function" },
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a class instance is reported by its constructor name", () => {
|
|
55
|
+
class Policy {
|
|
56
|
+
value = 1;
|
|
57
|
+
}
|
|
58
|
+
expect(scanConfigWireSafety({ p: new Policy() })).toEqual([{ path: "p", found: "a Policy instance" }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a circular reference is reported, not walked forever", () => {
|
|
62
|
+
const a: Record<string, unknown> = { name: "a" };
|
|
63
|
+
a.self = a;
|
|
64
|
+
expect(scanConfigWireSafety({ a })).toEqual([{ path: "a.self", found: "a circular reference" }]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("every offender is reported, not just the first", () => {
|
|
68
|
+
expect(scanConfigWireSafety({ a: () => 1, b: { c: new Date(0) } })).toEqual([
|
|
69
|
+
{ path: "a", found: "a function" },
|
|
70
|
+
{ path: "b.c", found: "a Date" },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The one accepted lossy case, and its boundary: `{ x: undefined }` reads
|
|
76
|
+
* identically to `{}` for every consumer of `ChantConfig` (every field is
|
|
77
|
+
* optional), while an `undefined` in an ARRAY becomes `null` and changes the
|
|
78
|
+
* element — so only the latter is reported. See the module doc.
|
|
79
|
+
*/
|
|
80
|
+
test("an undefined property is dropped like an omitted key; an undefined array slot is not", () => {
|
|
81
|
+
expect(scanConfigWireSafety({ sourceDir: undefined, lexicons: ["aws"] })).toEqual([]);
|
|
82
|
+
expect(scanConfigWireSafety({ lexicons: ["aws", undefined] })).toEqual([
|
|
83
|
+
{ path: "lexicons[1]", found: "undefined" },
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a module namespace (a config of top-level named exports) is walked, not rejected wholesale", () => {
|
|
88
|
+
const namespace = Object.create(null) as Record<string, unknown>;
|
|
89
|
+
namespace.lexicons = ["aws"];
|
|
90
|
+
expect(scanConfigWireSafety(namespace)).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a non-object config says nothing about serializability — normalizeConfig rejects it", () => {
|
|
94
|
+
expect(scanConfigWireSafety("nope")).toEqual([]);
|
|
95
|
+
expect(scanConfigWireSafety(null)).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("formatConfigWireOffenders", () => {
|
|
100
|
+
test("names the config file, every offending key, and what to do", () => {
|
|
101
|
+
const message = formatConfigWireOffenders("/p/chant.config.ts", [
|
|
102
|
+
{ path: "hooks.before", found: "a function" },
|
|
103
|
+
{ path: "meta.at", found: "a Date" },
|
|
104
|
+
]);
|
|
105
|
+
expect(message).toContain("/p/chant.config.ts");
|
|
106
|
+
expect(message).toContain("hooks.before: a function");
|
|
107
|
+
expect(message).toContain("meta.at: a Date");
|
|
108
|
+
expect(message).toContain("drop --sandbox");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #1113 — what a `chant.config.ts` may contain if it is to be evaluated
|
|
3
|
+
* inside the sandbox boundary and handed back to the CLI as data.
|
|
4
|
+
*
|
|
5
|
+
* Under `--sandbox` the config file is evaluated in a child process (see
|
|
6
|
+
* `./config-run.ts`), so the only thing that can come back is what survives a
|
|
7
|
+
* process boundary: JSON. Node's IPC channel serializes with `JSON.stringify`
|
|
8
|
+
* by default, and `JSON.stringify` is *lossy without complaining* — a function
|
|
9
|
+
* property vanishes, a `Date` becomes a string, a `Map` becomes `{}`, `NaN`
|
|
10
|
+
* becomes `null`. Silently handing the CLI a config that differs from the one
|
|
11
|
+
* the project wrote is the worst available outcome for a security feature, so
|
|
12
|
+
* this module walks the value FIRST and reports every offending key path.
|
|
13
|
+
* `./config-run.ts` turns a non-empty report into a build error that names the
|
|
14
|
+
* keys; nothing is ever dropped quietly.
|
|
15
|
+
*
|
|
16
|
+
* This module is bundled INTO the generated config driver (`./driver.ts`'s
|
|
17
|
+
* `generateConfigDriverSource`) and runs inside the sandboxed child, next to
|
|
18
|
+
* the project code it is inspecting — so it must not import anything, and must
|
|
19
|
+
* not touch the filesystem, the environment or the process.
|
|
20
|
+
*
|
|
21
|
+
* chant #1131 reuses the same walk for the OTHER direction: a `lint.policies`
|
|
22
|
+
* check runs inside a sandboxed child and its `PostSynthDiagnostic[]` has to
|
|
23
|
+
* come back as data (`./policy-wire.ts`). That is the same "JSON is lossy
|
|
24
|
+
* without complaining" problem with a different root value, so the walk is
|
|
25
|
+
* exported as {@link scanValueWireSafety} rather than copied.
|
|
26
|
+
*
|
|
27
|
+
* ## What `ChantConfig` legally holds
|
|
28
|
+
*
|
|
29
|
+
* Every field of `ChantConfig` (`../../config.ts`) is JSON data: string arrays
|
|
30
|
+
* (`lexicons`, `capabilities`, `environments`), strings (`sourceDir`), nested
|
|
31
|
+
* plain objects of strings/booleans (`ownership`, `build`, `release`, `sbom`,
|
|
32
|
+
* `signing`, `vulnPolicy`), arrays of plain objects (`stacks`), and records of
|
|
33
|
+
* plain objects (`buildParams`). `lint` is a `LintConfig`, whose rule values
|
|
34
|
+
* are a severity string or a `[severity, options]` tuple, and whose `plugins`
|
|
35
|
+
* / `policies` are file *paths* — chant loads those modules itself, they are
|
|
36
|
+
* not functions embedded in the config. So the declared type admits nothing
|
|
37
|
+
* that fails the check below.
|
|
38
|
+
*
|
|
39
|
+
* The one way to get there is `ChantConfigSchema`'s `.passthrough()`, which
|
|
40
|
+
* accepts unknown extra keys of any type (that is how a lexicon extends the
|
|
41
|
+
* config — `temporal:` in the temporal lexicon's `TemporalChantConfig`, itself
|
|
42
|
+
* pure data). A project that parks a function under such a key gets a clear
|
|
43
|
+
* error naming it, rather than a config that silently lost it.
|
|
44
|
+
*
|
|
45
|
+
* ## The one accepted lossy case: `undefined` object properties
|
|
46
|
+
*
|
|
47
|
+
* `{ sourceDir: undefined }` and `{}` are indistinguishable to every reader of
|
|
48
|
+
* `ChantConfig` — each field is optional and every resolver tests
|
|
49
|
+
* `config.x === undefined` / `?.` — so an `undefined`-valued property is
|
|
50
|
+
* dropped rather than rejected, exactly as omitting the key would be. An
|
|
51
|
+
* `undefined` inside an ARRAY is a different story (`JSON.stringify` rewrites
|
|
52
|
+
* it to `null`, changing the element), and is reported.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/** One value in the config that cannot cross the sandbox boundary as data. */
|
|
56
|
+
export interface ConfigWireOffender {
|
|
57
|
+
/** Dotted/bracketed path from the config root, e.g. `lint.rules.foo` or `stacks[0].name`. */
|
|
58
|
+
path: string;
|
|
59
|
+
/** What was found there, phrased for an error message (e.g. `a function`, `a Date`). */
|
|
60
|
+
found: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Deepest nesting `scanConfigWireSafety` will walk before reporting the path as too deep (also the cycle backstop for exotic self-referential structures). */
|
|
64
|
+
const MAX_DEPTH = 64;
|
|
65
|
+
|
|
66
|
+
function describe(value: unknown): string {
|
|
67
|
+
const t = typeof value;
|
|
68
|
+
if (t === "function") return "a function";
|
|
69
|
+
if (t === "symbol") return "a symbol";
|
|
70
|
+
if (t === "bigint") return "a bigint";
|
|
71
|
+
if (t === "number") return Number.isNaN(value) ? "NaN" : "a non-finite number";
|
|
72
|
+
if (value instanceof Date) return "a Date";
|
|
73
|
+
if (value instanceof RegExp) return "a RegExp";
|
|
74
|
+
if (value instanceof Map) return "a Map";
|
|
75
|
+
if (value instanceof Set) return "a Set";
|
|
76
|
+
if (value instanceof Promise) return "a Promise";
|
|
77
|
+
if (value instanceof Error) return "an Error";
|
|
78
|
+
const ctor = (value as { constructor?: { name?: unknown } })?.constructor?.name;
|
|
79
|
+
return typeof ctor === "string" && ctor !== "Object" ? `a ${ctor} instance` : "a non-plain object";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A plain object literal (or a null-prototype object) — anything else with `typeof "object"` is a class instance chant refuses to guess at. */
|
|
83
|
+
function isPlainObject(value: object): boolean {
|
|
84
|
+
const proto = Object.getPrototypeOf(value);
|
|
85
|
+
return proto === Object.prototype || proto === null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function walk(
|
|
89
|
+
value: unknown,
|
|
90
|
+
path: string,
|
|
91
|
+
depth: number,
|
|
92
|
+
seen: Set<object>,
|
|
93
|
+
out: ConfigWireOffender[],
|
|
94
|
+
): void {
|
|
95
|
+
if (value === null) return;
|
|
96
|
+
|
|
97
|
+
const t = typeof value;
|
|
98
|
+
if (t === "string" || t === "boolean") return;
|
|
99
|
+
if (t === "number") {
|
|
100
|
+
if (!Number.isFinite(value as number)) out.push({ path, found: describe(value) });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (t === "function" || t === "symbol" || t === "bigint" || t === "undefined") {
|
|
104
|
+
// `undefined` reaches here only from an array slot — an object property
|
|
105
|
+
// holding `undefined` is skipped by the caller (see the module doc).
|
|
106
|
+
out.push({ path, found: t === "undefined" ? "undefined" : describe(value) });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const obj = value as object;
|
|
111
|
+
if (seen.has(obj)) {
|
|
112
|
+
out.push({ path, found: "a circular reference" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (depth > MAX_DEPTH) {
|
|
116
|
+
out.push({ path, found: `nested more than ${MAX_DEPTH} levels deep` });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (Array.isArray(obj)) {
|
|
121
|
+
seen.add(obj);
|
|
122
|
+
for (let i = 0; i < obj.length; i++) walk(obj[i], `${path}[${i}]`, depth + 1, seen, out);
|
|
123
|
+
seen.delete(obj);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!isPlainObject(obj)) {
|
|
128
|
+
out.push({ path, found: describe(obj) });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
seen.add(obj);
|
|
133
|
+
for (const key of Object.keys(obj)) {
|
|
134
|
+
const child = (obj as Record<string, unknown>)[key];
|
|
135
|
+
if (child === undefined) continue; // dropped, equivalent to omitting the key — see the module doc
|
|
136
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
137
|
+
walk(child, childPath, depth + 1, seen, out);
|
|
138
|
+
}
|
|
139
|
+
seen.delete(obj);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* chant #1131 — report every value ANYWHERE in `value` that cannot cross the
|
|
144
|
+
* sandbox boundary as JSON, with paths rooted at `rootPath`.
|
|
145
|
+
*
|
|
146
|
+
* {@link scanConfigWireSafety} is the config-shaped entry point (it tolerates a
|
|
147
|
+
* module namespace object at the root); this one takes the value as given, so
|
|
148
|
+
* an array root (`diagnostics`) or a plain object root both work. Same rules,
|
|
149
|
+
* same `undefined`-object-property allowance — see the module doc.
|
|
150
|
+
*/
|
|
151
|
+
export function scanValueWireSafety(value: unknown, rootPath = ""): ConfigWireOffender[] {
|
|
152
|
+
const out: ConfigWireOffender[] = [];
|
|
153
|
+
walk(value, rootPath, 0, new Set<object>(), out);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Report every value in `config` that cannot cross the sandbox boundary as
|
|
159
|
+
* JSON. An empty array means a `JSON.parse(JSON.stringify(config))` round-trip
|
|
160
|
+
* preserves the configuration exactly (modulo `undefined` object properties,
|
|
161
|
+
* which are dropped — see the module doc).
|
|
162
|
+
*
|
|
163
|
+
* `config` itself may be a module namespace object (`await import(...)` with
|
|
164
|
+
* no `default` export), which is not a plain object; its own enumerable keys
|
|
165
|
+
* are walked the same way rather than being reported wholesale.
|
|
166
|
+
*/
|
|
167
|
+
export function scanConfigWireSafety(config: unknown): ConfigWireOffender[] {
|
|
168
|
+
const out: ConfigWireOffender[] = [];
|
|
169
|
+
if (config === null || typeof config !== "object") {
|
|
170
|
+
// A non-object config is what `normalizeConfig` already rejects in the
|
|
171
|
+
// parent; nothing to say about serializability.
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
const seen = new Set<object>();
|
|
175
|
+
seen.add(config);
|
|
176
|
+
for (const key of Object.keys(config as Record<string, unknown>)) {
|
|
177
|
+
const child = (config as Record<string, unknown>)[key];
|
|
178
|
+
if (child === undefined) continue;
|
|
179
|
+
walk(child, key, 1, seen, out);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Render a {@link ConfigWireOffender} list as the body of a build error — one line per offending key, most specific information first. */
|
|
185
|
+
export function formatConfigWireOffenders(
|
|
186
|
+
configPath: string,
|
|
187
|
+
offenders: readonly ConfigWireOffender[],
|
|
188
|
+
): string {
|
|
189
|
+
const lines = offenders.map((o) => ` ${o.path || "<root>"}: ${o.found}`);
|
|
190
|
+
return [
|
|
191
|
+
`Cannot evaluate ${configPath} inside the --sandbox boundary: it holds values that are not data.`,
|
|
192
|
+
...lines,
|
|
193
|
+
`Under --sandbox the config is evaluated in an isolated child process and only JSON crosses back, so every value must be a string, number, boolean, null, array or plain object. Move the offending value out of chant.config.ts (lint rule plugins and policy checks are referenced by path, not embedded), or drop --sandbox for this build.`,
|
|
194
|
+
].join("\n");
|
|
195
|
+
}
|
|
@@ -41,6 +41,11 @@ const RESOLVE_MODULE = join(DISCOVERY_DIR, "resolve.ts");
|
|
|
41
41
|
const ENTITY_WIRE_CODEC_MODULE = join(DISCOVERY_DIR, "entity-wire-codec.ts");
|
|
42
42
|
const CHILD_ERRORS_MODULE = join(HERE, "child-errors.ts");
|
|
43
43
|
const PROVENANCE_MODULE = join(dirname(DISCOVERY_DIR), "provenance.ts");
|
|
44
|
+
// chant #1113 — the config driver's serializability contract (see ./config-wire.ts).
|
|
45
|
+
const CONFIG_WIRE_MODULE = join(HERE, "config-wire.ts");
|
|
46
|
+
// chant #1131 — the policy driver's build-result decoding + diagnostics contract.
|
|
47
|
+
const POLICY_WIRE_MODULE = join(HERE, "policy-wire.ts");
|
|
48
|
+
const POST_SYNTH_MODULE = join(dirname(DISCOVERY_DIR), "lint", "post-synth.ts");
|
|
44
49
|
|
|
45
50
|
export interface GenerateDriverOptions {
|
|
46
51
|
/** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
|
|
@@ -145,3 +150,198 @@ export function generateDriverSource(options: GenerateDriverOptions): string {
|
|
|
145
150
|
|
|
146
151
|
return lines.join("\n");
|
|
147
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* chant #1113 — generate the driver module that evaluates a project's
|
|
156
|
+
* `chant.config.ts` INSIDE the sandboxed child and hands back plain JSON.
|
|
157
|
+
*
|
|
158
|
+
* Same machinery as {@link generateDriverSource} above, deliberately: one
|
|
159
|
+
* literal-specifier dynamic `import()` esbuild can trace and inline, one IPC
|
|
160
|
+
* message back, `./child-errors.ts` for classification so a permission denial
|
|
161
|
+
* names the config file rather than leaking `ERR_ACCESS_DENIED`. The only
|
|
162
|
+
* difference is what crosses — a config is data, not an entity graph, so there
|
|
163
|
+
* is no naming/`AttrRef` step and no `EntitySetWire`; the child instead runs
|
|
164
|
+
* `./config-wire.ts`'s serializability scan and refuses to hand back a config
|
|
165
|
+
* that `JSON.stringify` would silently mangle.
|
|
166
|
+
*
|
|
167
|
+
* The child returns the raw module namespace's chosen export as-is — the
|
|
168
|
+
* `default ?? config ?? namespace` selection and Zod validation
|
|
169
|
+
* (`normalizeConfig`) stay in the parent, where they were, so `--sandbox`
|
|
170
|
+
* changes where the file is *evaluated* and nothing about how its result is
|
|
171
|
+
* interpreted.
|
|
172
|
+
*/
|
|
173
|
+
export function generateConfigDriverSource(configPath: string): string {
|
|
174
|
+
return [
|
|
175
|
+
`import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
|
|
176
|
+
`import { scanConfigWireSafety } from ${lit(CONFIG_WIRE_MODULE)};`,
|
|
177
|
+
``,
|
|
178
|
+
`function send(payload) {`,
|
|
179
|
+
` if (typeof process.send === "function") process.send(payload);`,
|
|
180
|
+
` else console.log(JSON.stringify(payload));`,
|
|
181
|
+
`}`,
|
|
182
|
+
``,
|
|
183
|
+
`async function main() {`,
|
|
184
|
+
` let namespace;`,
|
|
185
|
+
` try {`,
|
|
186
|
+
` namespace = await import(${lit(configPath)});`,
|
|
187
|
+
` } catch (err) {`,
|
|
188
|
+
` send({ kind: "chant-config", ok: false, error: classifyChildError(${lit(configPath)}, err).toJSON() });`,
|
|
189
|
+
` return;`,
|
|
190
|
+
` }`,
|
|
191
|
+
``,
|
|
192
|
+
// Mirrors loadChantConfig's own selection so the scan sees exactly the
|
|
193
|
+
// object the parent will normalize. A namespace object is not a plain
|
|
194
|
+
// object; scanConfigWireSafety walks its own keys rather than rejecting it.
|
|
195
|
+
` const selected = namespace.default ?? namespace.config ?? namespace;`,
|
|
196
|
+
``,
|
|
197
|
+
` const offenders = scanConfigWireSafety(selected);`,
|
|
198
|
+
` if (offenders.length > 0) {`,
|
|
199
|
+
` send({ kind: "chant-config", ok: false, offenders });`,
|
|
200
|
+
` return;`,
|
|
201
|
+
` }`,
|
|
202
|
+
``,
|
|
203
|
+
` try {`,
|
|
204
|
+
// Round-trip here, not just at the IPC boundary: this is what proves the
|
|
205
|
+
// payload really is JSON before it leaves the child, and turns anything
|
|
206
|
+
// the scan somehow missed into a named error instead of a quiet drop.
|
|
207
|
+
` const config = JSON.parse(JSON.stringify(selected ?? {}));`,
|
|
208
|
+
` send({ kind: "chant-config", ok: true, config });`,
|
|
209
|
+
` } catch (err) {`,
|
|
210
|
+
` send({ kind: "chant-config", ok: false, error: classifyChildError(${lit(configPath)}, err, "resolution").toJSON() });`,
|
|
211
|
+
` }`,
|
|
212
|
+
`}`,
|
|
213
|
+
``,
|
|
214
|
+
`main().catch((err) => {`,
|
|
215
|
+
` send({ kind: "chant-config", ok: false, error: classifyChildError(${lit(configPath)}, err).toJSON() });`,
|
|
216
|
+
`});`,
|
|
217
|
+
].join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* chant #1131 — generate the driver module that imports a project's
|
|
222
|
+
* `lint.policies` modules INSIDE the sandboxed child, runs their checks over
|
|
223
|
+
* the build result the parent hands it, and sends back plain
|
|
224
|
+
* `PostSynthDiagnostic`s.
|
|
225
|
+
*
|
|
226
|
+
* Same machinery again: literal-specifier dynamic `import()`s esbuild can trace
|
|
227
|
+
* and inline, `./child-errors.ts` for classification so a permission denial
|
|
228
|
+
* names the policy file, one IPC message back. Two things are specific to this
|
|
229
|
+
* one:
|
|
230
|
+
*
|
|
231
|
+
* - **It receives before it sends.** The run and config drivers are fully
|
|
232
|
+
* parameterized by their generated source; a policy check needs the finished
|
|
233
|
+
* build result, which is neither known at bundle time nor something to bake
|
|
234
|
+
* into a source literal. It arrives as one IPC message (see `./fork.ts`'s
|
|
235
|
+
* `send`). The `process.on("message", …)` registration is top-level and
|
|
236
|
+
* synchronous, so it is in place before the event loop can deliver anything
|
|
237
|
+
* — a message the parent sent before the child finished booting is queued on
|
|
238
|
+
* the channel, not lost.
|
|
239
|
+
* - **Checks run wrapped, not raw.** `runPostSynthChecks` (chant's own, from
|
|
240
|
+
* `../../lint/post-synth.ts`) is invoked ONCE over every check from every
|
|
241
|
+
* policy module, exactly as `cli/commands/build.ts` invokes it in-process —
|
|
242
|
+
* so the checks share one `PostSynthContext` and run in one order, and the
|
|
243
|
+
* diagnostics come back in the same sequence. The wrapper around each check
|
|
244
|
+
* is what makes a bad return value attributable: it scans that check's own
|
|
245
|
+
* output and throws a `PolicyWireError` naming the module the check was
|
|
246
|
+
* loaded from, rather than reporting an offending index in a merged array.
|
|
247
|
+
*/
|
|
248
|
+
export function generatePolicyDriverSource(policyPaths: readonly string[]): string {
|
|
249
|
+
const lines: string[] = [
|
|
250
|
+
`import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
|
|
251
|
+
`import { decodePolicyBuildResult, scanPolicyDiagnostics, PolicyWireError } from ${lit(POLICY_WIRE_MODULE)};`,
|
|
252
|
+
`import { runPostSynthChecks, isPostSynthCheck } from ${lit(POST_SYNTH_MODULE)};`,
|
|
253
|
+
``,
|
|
254
|
+
`function send(payload) {`,
|
|
255
|
+
` if (typeof process.send === "function") process.send(payload);`,
|
|
256
|
+
` else console.log(JSON.stringify(payload));`,
|
|
257
|
+
`}`,
|
|
258
|
+
``,
|
|
259
|
+
`function fail(file, err, type) {`,
|
|
260
|
+
` if (err instanceof PolicyWireError) {`,
|
|
261
|
+
` send({ kind: "chant-policy", ok: false, offenders: err.offenders });`,
|
|
262
|
+
` return;`,
|
|
263
|
+
` }`,
|
|
264
|
+
` send({ kind: "chant-policy", ok: false, error: classifyChildError(file, err, type).toJSON() });`,
|
|
265
|
+
`}`,
|
|
266
|
+
``,
|
|
267
|
+
// The wrapper described in the doc above: same id/description so any
|
|
268
|
+
// chant-side reporting keyed off them is unchanged, same ctx, same return
|
|
269
|
+
// value — plus the per-check serializability scan.
|
|
270
|
+
`function guard(check, policy) {`,
|
|
271
|
+
` return {`,
|
|
272
|
+
` id: check.id,`,
|
|
273
|
+
` description: check.description,`,
|
|
274
|
+
` check(ctx) {`,
|
|
275
|
+
` const produced = check.check(ctx);`,
|
|
276
|
+
` const offenders = scanPolicyDiagnostics(produced, policy);`,
|
|
277
|
+
` if (offenders.length > 0) throw new PolicyWireError(offenders);`,
|
|
278
|
+
` return produced;`,
|
|
279
|
+
` },`,
|
|
280
|
+
` };`,
|
|
281
|
+
`}`,
|
|
282
|
+
``,
|
|
283
|
+
`async function main(request) {`,
|
|
284
|
+
` let buildResult;`,
|
|
285
|
+
` try {`,
|
|
286
|
+
` buildResult = decodePolicyBuildResult(request.buildResult);`,
|
|
287
|
+
` } catch (err) {`,
|
|
288
|
+
` fail("", err, "resolution");`,
|
|
289
|
+
` return;`,
|
|
290
|
+
` }`,
|
|
291
|
+
``,
|
|
292
|
+
` const checks = [];`,
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
// One block per policy module, in the order `lint.policies` declares them —
|
|
296
|
+
// the same order `loadPolicyChecks` collects in, so the diagnostics sequence
|
|
297
|
+
// matches the in-process one exactly.
|
|
298
|
+
for (const policyPath of policyPaths) {
|
|
299
|
+
lines.push(
|
|
300
|
+
` try {`,
|
|
301
|
+
` const mod = await import(${lit(policyPath)});`,
|
|
302
|
+
` for (const value of Object.values(mod)) {`,
|
|
303
|
+
` if (isPostSynthCheck(value)) checks.push(guard(value, ${lit(policyPath)}));`,
|
|
304
|
+
` }`,
|
|
305
|
+
` } catch (err) {`,
|
|
306
|
+
` fail(${lit(policyPath)}, err, "import");`,
|
|
307
|
+
` return;`,
|
|
308
|
+
` }`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
lines.push(
|
|
313
|
+
``,
|
|
314
|
+
` let diagnostics;`,
|
|
315
|
+
` try {`,
|
|
316
|
+
` diagnostics = runPostSynthChecks(checks, buildResult, request.env ?? undefined);`,
|
|
317
|
+
` } catch (err) {`,
|
|
318
|
+
` fail("", err, "resolution");`,
|
|
319
|
+
` return;`,
|
|
320
|
+
` }`,
|
|
321
|
+
``,
|
|
322
|
+
` send({ kind: "chant-policy", ok: true, diagnostics });`,
|
|
323
|
+
`}`,
|
|
324
|
+
``,
|
|
325
|
+
// Registered synchronously at module top level — see the doc above on why
|
|
326
|
+
// that is what makes the parent's send-before-boot safe.
|
|
327
|
+
//
|
|
328
|
+
// Removed again as soon as the input arrives, and that is not tidiness: a
|
|
329
|
+
// `message` listener REFS the IPC channel, so leaving it registered would
|
|
330
|
+
// keep this child's event loop alive after it had already answered — and a
|
|
331
|
+
// live channel keeps the PARENT's alive too. `chant build` would not have
|
|
332
|
+
// noticed (`cli/main.ts` ends in `process.exit`); anything embedding chant
|
|
333
|
+
// as a library would have hung. `./fork.ts` kills the child on receipt as
|
|
334
|
+
// the other half of the same fix.
|
|
335
|
+
`let started = false;`,
|
|
336
|
+
`function onRequest(request) {`,
|
|
337
|
+
` if (started) return;`,
|
|
338
|
+
` if (!request || request.kind !== "chant-policy-request") return;`,
|
|
339
|
+
` started = true;`,
|
|
340
|
+
` process.off("message", onRequest);`,
|
|
341
|
+
` main(request).catch((err) => fail("", err, "resolution"));`,
|
|
342
|
+
`}`,
|
|
343
|
+
`process.on("message", onRequest);`,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return lines.join("\n");
|
|
347
|
+
}
|