@intentius/chant 0.19.1 → 0.20.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-params.d.ts +108 -0
- package/dist/build-params.d.ts.map +1 -0
- package/dist/build.d.ts +79 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +31 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts +41 -0
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon-intrinsics.d.ts +54 -0
- package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +27 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lint.d.ts.map +1 -1
- package/dist/cli/handlers/run-client.d.ts +1 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +16 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +10 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-sections.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +12 -5
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts +62 -7
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/sandbox/driver.d.ts +12 -0
- package/dist/components/sandbox/driver.d.ts.map +1 -0
- package/dist/components/sandbox/run.d.ts +42 -0
- package/dist/components/sandbox/run.d.ts.map +1 -0
- package/dist/composite.d.ts +5 -0
- package/dist/composite.d.ts.map +1 -1
- package/dist/config.d.ts +71 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts +166 -0
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
- package/dist/discovery/entity-wire.d.ts +50 -0
- package/dist/discovery/entity-wire.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts +210 -0
- package/dist/discovery/fold-import.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +74 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/sandbox/bundle.d.ts +18 -0
- package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
- package/dist/discovery/sandbox/child-errors.d.ts +15 -0
- package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
- package/dist/discovery/sandbox/driver.d.ts +13 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -0
- package/dist/discovery/sandbox/run.d.ts +69 -0
- package/dist/discovery/sandbox/run.d.ts.map +1 -0
- package/dist/errors.d.ts +9 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +226 -0
- package/dist/fold/fold.d.ts.map +1 -0
- package/dist/fold/subset.d.ts +102 -0
- package/dist/fold/subset.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon-output.d.ts +7 -2
- package/dist/lexicon-output.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -2
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +81 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.d.ts +11 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/component-checks.d.ts +7 -1
- package/dist/lint/component-checks.d.ts.map +1 -1
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
- package/dist/params.d.ts +60 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/provenance.d.ts +21 -0
- package/dist/provenance.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/build-params.test.ts +144 -0
- package/src/build-params.ts +207 -0
- package/src/build.test.ts +38 -0
- package/src/build.ts +144 -7
- package/src/cli/commands/build.test.ts +220 -2
- package/src/cli/commands/build.ts +111 -3
- package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
- package/src/cli/commands/check-lexicon-examples.ts +103 -0
- package/src/cli/commands/check-lexicon-intrinsics.test.ts +145 -0
- package/src/cli/commands/check-lexicon-intrinsics.ts +312 -0
- package/src/cli/commands/check-lexicon.test.ts +34 -0
- package/src/cli/commands/check-lexicon.ts +119 -1
- package/src/cli/commands/lint.ts +31 -3
- package/src/cli/commands/onboard.ts +1 -1
- package/src/cli/component-security-boundary.test.ts +170 -0
- package/src/cli/handlers/build.ts +24 -3
- package/src/cli/handlers/components.ts +9 -2
- package/src/cli/handlers/dev.ts +1 -1
- package/src/cli/handlers/graph.ts +7 -5
- package/src/cli/handlers/lifecycle.ts +2 -2
- package/src/cli/handlers/lint.ts +2 -0
- package/src/cli/handlers/misc.ts +2 -2
- package/src/cli/handlers/run-client.ts +1 -1
- package/src/cli/handlers/run.ts +20 -5
- package/src/cli/main.test.ts +22 -0
- package/src/cli/main.ts +39 -0
- package/src/cli/plugins.ts +20 -4
- package/src/cli/registry.ts +10 -0
- package/src/cli/security-boundary.test.ts +135 -0
- package/src/codegen/docs-sections.test.ts +61 -0
- package/src/codegen/docs-sections.ts +7 -3
- package/src/components/cli-support.ts +22 -10
- package/src/components/discover.ts +127 -25
- package/src/components/sandbox/driver.ts +114 -0
- package/src/components/sandbox/run.test.ts +185 -0
- package/src/components/sandbox/run.ts +177 -0
- package/src/composite.test.ts +21 -0
- package/src/composite.ts +20 -1
- package/src/config.ts +81 -0
- package/src/discovery/collect.ts +17 -3
- package/src/discovery/entity-wire-codec.ts +485 -0
- package/src/discovery/entity-wire.test.ts +240 -0
- package/src/discovery/entity-wire.ts +67 -0
- package/src/discovery/fold-import.test.ts +1026 -0
- package/src/discovery/fold-import.ts +1805 -0
- package/src/discovery/index.test.ts +191 -1
- package/src/discovery/index.ts +233 -1
- package/src/discovery/sandbox/bundle.ts +218 -0
- package/src/discovery/sandbox/child-errors.ts +65 -0
- package/src/discovery/sandbox/driver.ts +147 -0
- package/src/discovery/sandbox/run.test.ts +179 -0
- package/src/discovery/sandbox/run.ts +196 -0
- package/src/errors.ts +9 -1
- package/src/fold/fold.test.ts +535 -0
- package/src/fold/fold.ts +648 -0
- package/src/fold/subset-doc-parity.test.ts +183 -0
- package/src/fold/subset.test.ts +241 -0
- package/src/fold/subset.ts +302 -0
- package/src/index.ts +2 -0
- package/src/lexicon-output.ts +7 -2
- package/src/lexicon-schema.test.ts +14 -0
- package/src/lexicon-schema.ts +3 -1
- package/src/lexicon.ts +82 -1
- package/src/lifecycle/git.test.ts +10 -5
- package/src/lifecycle/release-ledger.test.ts +28 -0
- package/src/lifecycle/release-ledger.ts +11 -0
- package/src/lint/component-checks.ts +8 -1
- package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
- package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
- package/src/params.test.ts +22 -0
- package/src/params.ts +66 -0
- package/src/provenance.ts +22 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import * as esbuild from "esbuild";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* chant #1045 Phase 2 — bundles the generated sandbox driver (`./driver.ts`)
|
|
9
|
+
* into one self-contained ESM file with esbuild, so the sandboxed child never
|
|
10
|
+
* needs to resolve a module from disk at runtime (see `./driver.ts`'s doc for
|
|
11
|
+
* why that's what lets the permission allowlist stay this narrow).
|
|
12
|
+
*
|
|
13
|
+
* A fresh temp directory per call — not a shared cache directory — keeps
|
|
14
|
+
* concurrent builds (nested stacks, parallel test workers) from racing on the
|
|
15
|
+
* same output path; callers are responsible for cleaning it up (`./run.ts`
|
|
16
|
+
* does, in a `finally`) once the child has reported back.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const HERE = import.meta.dirname;
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Packages that must NOT be bundled — see {@link resolveExternalPackages}'s
|
|
24
|
+
* doc for why. Every lexicon that ships a lint rule doing AST-based analysis
|
|
25
|
+
* (`aws`, `azure`, `fly`, `gcp`, `github`, `gitlab`, `helm`, `k8s` all do)
|
|
26
|
+
* imports `typescript` at the top of that rule module, and lexicon plugin
|
|
27
|
+
* objects re-export their lint rules eagerly — so bundling ANY run-fallback
|
|
28
|
+
* file that imports a lexicon package pulls `typescript` in transitively,
|
|
29
|
+
* every time, not just for the rare file that uses the compiler itself.
|
|
30
|
+
*/
|
|
31
|
+
const EXTERNAL_PACKAGES = ["typescript"];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve each of {@link EXTERNAL_PACKAGES} to its real, absolute path on
|
|
35
|
+
* disk (from THIS file's own location — the same package.json dependency
|
|
36
|
+
* chant's own `fold-import.ts` already resolves "typescript" through) and
|
|
37
|
+
* return an esbuild plugin that rewrites any import of that bare specifier
|
|
38
|
+
* to the resolved absolute path, marked external.
|
|
39
|
+
*
|
|
40
|
+
* Why external at all: `typescript` is itself a large, over-eager CJS
|
|
41
|
+
* package — its own internal `getNodeSystem()`-style helpers call
|
|
42
|
+
* `require("fs")`/`require("path")` from deep inside factory functions, not
|
|
43
|
+
* from top-level imports esbuild can safely rewrite. Bundled into ESM
|
|
44
|
+
* output, that nested `require` has no real binding and esbuild's shim
|
|
45
|
+
* throws `Dynamic require of "fs" is not supported` — a build that just
|
|
46
|
+
* transitively imports `typescript` never even gets to execute the run-
|
|
47
|
+
* fallback file it was meant for. Left external and resolved to an absolute
|
|
48
|
+
* path, Node loads the REAL `typescript` package directly (no bare-specifier
|
|
49
|
+
* `node_modules` walk needed, so no broader `--allow-fs-read` grant beyond
|
|
50
|
+
* that one resolved path — see `./run.ts`), with its own native `require`
|
|
51
|
+
* intact — no interop shim, no problem.
|
|
52
|
+
*
|
|
53
|
+
* `typescript` is trusted, chant-shipped infrastructure (a dependency of
|
|
54
|
+
* chant itself and every lexicon that lints), never something project
|
|
55
|
+
* source controls the content of, so resolving and exposing it this way
|
|
56
|
+
* doesn't weaken the boundary around untrusted project code.
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* chant #1020 hang fix — process-wide memo for resolving {@link
|
|
60
|
+
* EXTERNAL_PACKAGES} from {@link HERE}. Both are fixed constants (this
|
|
61
|
+
* file's own location, and a hardcoded package list) — the answer can never
|
|
62
|
+
* change for the lifetime of the process, so caching it is exactly as safe
|
|
63
|
+
* as Node's own assumption that a running process's `node_modules` doesn't
|
|
64
|
+
* shift under it. `require.resolve(name, { paths: [HERE] })` is ordinarily
|
|
65
|
+
* cheap, but profiling traced it to the same pathological slowness (upwards
|
|
66
|
+
* of a minute) as `../fold-import.ts`'s `resolveModulePath` — see that
|
|
67
|
+
* file's `bareSpecifierPathCache` doc for the full mechanism: this is the
|
|
68
|
+
* SAME underlying cost (`createRequire`/`require.resolve`'s bare-specifier
|
|
69
|
+
* branch, first call inside a session started right after vitest's
|
|
70
|
+
* `vi.resetModules()`), just reached via `bundleDriver()` (called once per
|
|
71
|
+
* `runFallbackFilesSandboxed()`) instead of a cross-file fold resolution.
|
|
72
|
+
*
|
|
73
|
+
* Parked on `globalThis`, not a plain module-level `const`: unlike
|
|
74
|
+
* `fold-import.ts` (reached only via a STATIC import chain from the test
|
|
75
|
+
* file's own top-level import, so vite-node never re-evaluates it),
|
|
76
|
+
* `./bundle.ts` is reached only through `./run.ts`'s dynamic
|
|
77
|
+
* `await import("./sandbox/run")` (deliberately dynamic — see that file's
|
|
78
|
+
* own doc on why). `vi.resetModules()` DOES re-evaluate a module reached
|
|
79
|
+
* only dynamically, which would re-run this file's whole top level and
|
|
80
|
+
* silently replace a plain module-level cache with a fresh, empty `Map` on
|
|
81
|
+
* every retry — measured to happen intermittently (which call site hits the
|
|
82
|
+
* slow, uncached path first varies run to run), reproducing the exact same
|
|
83
|
+
* multi-minute stall this fix exists to remove. A `globalThis` slot is
|
|
84
|
+
* outside any module's own scope, so it survives being re-evaluated.
|
|
85
|
+
*/
|
|
86
|
+
function getExternalPackagePathCache(): Map<string, string | undefined> {
|
|
87
|
+
const g = globalThis as { __chantExternalPackagePathCache?: Map<string, string | undefined> };
|
|
88
|
+
return (g.__chantExternalPackagePathCache ??= new Map());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* chant #1020 hang fix — best-effort fast path mirroring
|
|
93
|
+
* `../fold-import.ts`'s `fastResolveBareSpecifier` (see its own doc for the
|
|
94
|
+
* full mechanism and why this is faster than `require.resolve` at all):
|
|
95
|
+
* walks `node_modules` from `fromDir` with plain `existsSync`/`readFileSync`
|
|
96
|
+
* and reads `package.json`'s "main" field by hand (`typescript` ships no
|
|
97
|
+
* "exports" field, just "main" — verified), never throwing. Returns
|
|
98
|
+
* `undefined` for anything it doesn't confidently recognize, so the caller
|
|
99
|
+
* falls back to the slow-but-authoritative `require.resolve`.
|
|
100
|
+
*/
|
|
101
|
+
function fastResolvePackage(specifier: string, fromDir: string): string | undefined {
|
|
102
|
+
let dir = fromDir;
|
|
103
|
+
for (;;) {
|
|
104
|
+
const packageDir = join(dir, "node_modules", specifier);
|
|
105
|
+
if (existsSync(packageDir)) {
|
|
106
|
+
const pkgJsonPath = join(packageDir, "package.json");
|
|
107
|
+
if (!existsSync(pkgJsonPath)) return undefined;
|
|
108
|
+
let pkg: unknown;
|
|
109
|
+
try {
|
|
110
|
+
pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
if (typeof pkg !== "object" || pkg === null || (pkg as Record<string, unknown>).exports !== undefined) {
|
|
115
|
+
return undefined; // has an "exports" field — not the simple shape this fast path handles
|
|
116
|
+
}
|
|
117
|
+
const main = (pkg as Record<string, unknown>).main;
|
|
118
|
+
const entry = typeof main === "string" ? main : "index.js";
|
|
119
|
+
const resolved = resolvePath(packageDir, entry);
|
|
120
|
+
if (!existsSync(resolved) || !statSync(resolved).isFile()) return undefined;
|
|
121
|
+
try {
|
|
122
|
+
return realpathSync(resolved);
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const parent = dirname(dir);
|
|
128
|
+
if (parent === dir) return undefined;
|
|
129
|
+
dir = parent;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function externalTrustedPackagesPlugin(): { plugin: esbuild.Plugin; readPaths: string[] } {
|
|
134
|
+
const readPaths: string[] = [];
|
|
135
|
+
const resolved = new Map<string, string>();
|
|
136
|
+
const pathCache = getExternalPackagePathCache();
|
|
137
|
+
|
|
138
|
+
for (const name of EXTERNAL_PACKAGES) {
|
|
139
|
+
try {
|
|
140
|
+
let path = pathCache.get(name);
|
|
141
|
+
if (path === undefined && !pathCache.has(name)) {
|
|
142
|
+
// chant #1020 hang fix — try the same fast, plain-fs node_modules
|
|
143
|
+
// walk `../fold-import.ts`'s `fastResolveBareSpecifier` uses before
|
|
144
|
+
// falling back to `require.resolve`'s pathologically-slow-when-cold
|
|
145
|
+
// path (see this file's own `getExternalPackagePathCache` doc).
|
|
146
|
+
path = fastResolvePackage(name, HERE) ?? require.resolve(name, { paths: [HERE] });
|
|
147
|
+
pathCache.set(name, path);
|
|
148
|
+
}
|
|
149
|
+
if (path === undefined) continue; // cached miss — see the catch below
|
|
150
|
+
resolved.set(name, path);
|
|
151
|
+
readPaths.push(dirname(path));
|
|
152
|
+
} catch {
|
|
153
|
+
pathCache.set(name, undefined);
|
|
154
|
+
// Not installed/resolvable from here — nothing to externalize; a run-
|
|
155
|
+
// fallback file that imports it will simply fail to resolve inside the
|
|
156
|
+
// sandbox, same as any other genuinely missing dependency would.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const plugin: esbuild.Plugin = {
|
|
161
|
+
name: "chant-sandbox-external-trusted-packages",
|
|
162
|
+
setup(build) {
|
|
163
|
+
for (const [name, path] of resolved) {
|
|
164
|
+
const filter = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`);
|
|
165
|
+
build.onResolve({ filter }, () => ({ path, external: true }));
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return { plugin, readPaths };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface BundleResult {
|
|
174
|
+
/** Absolute, REALPATH'd path to the bundled entry file — this and {@link bundleDir} are what the caller grants `--allow-fs-read` to. macOS's `/tmp` is a symlink to `/private/tmp`; an un-canonicalized path here would silently fail to match Node's own (already-canonicalized) permission check. */
|
|
175
|
+
bundlePath: string;
|
|
176
|
+
/** Absolute, REALPATH'd directory containing {@link bundlePath} — remove with `rmSync(bundleDir, { recursive: true, force: true })` once done. */
|
|
177
|
+
bundleDir: string;
|
|
178
|
+
/** Directories the child ALSO needs `--allow-fs-read` for — the resolved locations of {@link EXTERNAL_PACKAGES}, which are deliberately left unbundled (see {@link externalTrustedPackagesPlugin}'s doc). Empty when none of those packages were actually imported. */
|
|
179
|
+
externalReadPaths: string[];
|
|
180
|
+
/** Wall-clock bundling time — chant#1045 asks this be measured and reported, not silently accepted. */
|
|
181
|
+
durationMs: number;
|
|
182
|
+
/** Bundle size in bytes. */
|
|
183
|
+
bytes: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Bundle `driverSource` (see {@link import("./driver").generateDriverSource})
|
|
188
|
+
* into a single self-contained ESM file.
|
|
189
|
+
*/
|
|
190
|
+
export async function bundleDriver(driverSource: string): Promise<BundleResult> {
|
|
191
|
+
const bundleDir = realpathSync(mkdtempSync(join(tmpdir(), "chant-sandbox-")));
|
|
192
|
+
const entryPath = join(bundleDir, "driver.mts");
|
|
193
|
+
writeFileSync(entryPath, driverSource, "utf-8");
|
|
194
|
+
const outfile = join(bundleDir, "child.mjs");
|
|
195
|
+
|
|
196
|
+
const { plugin, readPaths } = externalTrustedPackagesPlugin();
|
|
197
|
+
|
|
198
|
+
const start = performance.now();
|
|
199
|
+
try {
|
|
200
|
+
await esbuild.build({
|
|
201
|
+
entryPoints: [entryPath],
|
|
202
|
+
outfile,
|
|
203
|
+
bundle: true,
|
|
204
|
+
platform: "node",
|
|
205
|
+
format: "esm",
|
|
206
|
+
absWorkingDir: bundleDir,
|
|
207
|
+
logLevel: "silent",
|
|
208
|
+
plugins: [plugin],
|
|
209
|
+
});
|
|
210
|
+
} catch (err) {
|
|
211
|
+
rmSync(bundleDir, { recursive: true, force: true });
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
const durationMs = performance.now() - start;
|
|
215
|
+
const bytes = statSync(outfile).size;
|
|
216
|
+
|
|
217
|
+
return { bundlePath: outfile, bundleDir, externalReadPaths: readPaths, durationMs, bytes };
|
|
218
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { DiscoveryError, type DiscoveryErrorType } from "../../errors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* chant #1045 Phase 2 — turns whatever the sandboxed child's per-file import
|
|
5
|
+
* (or its own collect/resolve/encode step) throws into an actionable chant
|
|
6
|
+
* {@link DiscoveryError} that names the file AND the operation, instead of
|
|
7
|
+
* leaking a raw `ERR_ACCESS_DENIED` with no context.
|
|
8
|
+
*
|
|
9
|
+
* This module is bundled INTO the generated sandbox driver (see `./driver.ts`)
|
|
10
|
+
* — it runs inside the sandboxed child, alongside the untrusted project
|
|
11
|
+
* source it's classifying errors for — so it must not import anything that
|
|
12
|
+
* isn't already safe to bundle (no filesystem/process access of its own).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Node's Permission Model error shape for `ERR_ACCESS_DENIED` — see
|
|
17
|
+
* https://nodejs.org/api/permissions.html#error-classes. `permission` names
|
|
18
|
+
* the guarded API family (`FileSystemRead`, `FileSystemWrite`,
|
|
19
|
+
* `ChildProcess`, `WorkerThreads`, `SqliteWrite`, …); `resource` is the
|
|
20
|
+
* specific path or target that was denied. Verified empirically on Node
|
|
21
|
+
* v24.13.1 (chant#1045 Phase 2 prototype) — both fields are present on the
|
|
22
|
+
* thrown `Error` alongside `code`.
|
|
23
|
+
*/
|
|
24
|
+
interface NodePermissionError {
|
|
25
|
+
code?: unknown;
|
|
26
|
+
permission?: unknown;
|
|
27
|
+
resource?: unknown;
|
|
28
|
+
message?: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPermissionDenied(err: unknown): err is NodePermissionError {
|
|
32
|
+
return typeof err === "object" && err !== null && (err as NodePermissionError).code === "ERR_ACCESS_DENIED";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Classify one error raised while executing run-fallback project source (or
|
|
37
|
+
* chant's own collect/resolve/encode step) inside the sandboxed child.
|
|
38
|
+
*
|
|
39
|
+
* @param file - The project source file being executed when `err` was
|
|
40
|
+
* thrown, or `""` for an error not attributable to one specific file (e.g.
|
|
41
|
+
* `collectEntities`/`resolveAttrRefs` over the whole batch).
|
|
42
|
+
* @param fallbackType - The {@link DiscoveryErrorType} to use when `err`
|
|
43
|
+
* isn't a permission denial — mirrors how `discover()` itself types a
|
|
44
|
+
* plain import failure `"import"` vs. a collection/resolution failure
|
|
45
|
+
* `"resolution"`.
|
|
46
|
+
*/
|
|
47
|
+
export function classifyChildError(
|
|
48
|
+
file: string,
|
|
49
|
+
err: unknown,
|
|
50
|
+
fallbackType: DiscoveryErrorType = "import",
|
|
51
|
+
): DiscoveryError {
|
|
52
|
+
if (isPermissionDenied(err)) {
|
|
53
|
+
const operation = typeof err.permission === "string" ? err.permission : "an unrecognized sandboxed operation";
|
|
54
|
+
const resource = typeof err.resource === "string" ? ` (${err.resource})` : "";
|
|
55
|
+
const where = file ? `"${file}"` : "sandboxed run-fallback code";
|
|
56
|
+
return new DiscoveryError(
|
|
57
|
+
file,
|
|
58
|
+
`sandbox denied ${operation}${resource}: ${where} attempted an operation outside the sandbox's allowlist`,
|
|
59
|
+
"permission",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
64
|
+
return new DiscoveryError(file, message, fallbackType);
|
|
65
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* chant #1045 Phase 2 — generates the source of the "driver" module that runs
|
|
5
|
+
* INSIDE the sandboxed child. See `./run.ts` for the orchestration this feeds
|
|
6
|
+
* (bundle the generated source with esbuild, then spawn a permission-limited
|
|
7
|
+
* child on the bundle) and `../fold-import.ts`'s long doc comment for why
|
|
8
|
+
* every run-fallback file for a build must execute together, in one process,
|
|
9
|
+
* sharing one module graph.
|
|
10
|
+
*
|
|
11
|
+
* The driver imports each run-fallback file via a LITERAL string specifier
|
|
12
|
+
* dynamic `import()` — not a variable — so esbuild's bundler can still trace
|
|
13
|
+
* and inline it (verified: esbuild lowers a literal-specifier `import()` to a
|
|
14
|
+
* lazily-invoked local module initializer, not a runtime resolution), while
|
|
15
|
+
* keeping the SAME per-file try/catch resilience `discover()`'s own run loop
|
|
16
|
+
* has today (one file throwing at import time doesn't take down the whole
|
|
17
|
+
* batch). A literal, per-build-generated import graph is exactly what lets
|
|
18
|
+
* the whole bundle be self-contained: esbuild resolves every project file,
|
|
19
|
+
* lexicon package, and node_modules dependency transitively, ONCE, so the
|
|
20
|
+
* sandboxed child never needs to resolve a module from disk at runtime.
|
|
21
|
+
*
|
|
22
|
+
* Chant's own trusted collection/resolution/encoding step
|
|
23
|
+
* (`collectEntities`/`resolveAttrRefs`/`encodeEntitySet`) runs INSIDE the
|
|
24
|
+
* child too, after every file has been imported — this is what lets naming
|
|
25
|
+
* and AttrRef resolution happen "inside the boundary" for the run-fallback
|
|
26
|
+
* set (mirrors `discoverEntitySetJson`'s doc in `../entity-wire.ts`), so the
|
|
27
|
+
* child can hand back a fully-named, ref-resolved `EntitySetWire` rather than
|
|
28
|
+
* raw, identity-bearing module exports that couldn't cross the process
|
|
29
|
+
* boundary at all.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const HERE = import.meta.dirname;
|
|
33
|
+
const DISCOVERY_DIR = join(HERE, "..");
|
|
34
|
+
|
|
35
|
+
/** Absolute paths to chant's OWN trusted modules the generated driver imports — always resolved relative to THIS file's own location on disk, so this works whether chant is running from the monorepo or from a consumer's `node_modules` (both route runtime resolution at `src/*.ts` — see `packages/core/package.json`'s `development`/`default` export conditions). */
|
|
36
|
+
const COLLECT_MODULE = join(DISCOVERY_DIR, "collect.ts");
|
|
37
|
+
const RESOLVE_MODULE = join(DISCOVERY_DIR, "resolve.ts");
|
|
38
|
+
// The pure codec, NOT `entity-wire.ts` — that file also exports
|
|
39
|
+
// `discoverEntitySetJson`, which pulls in `discover()` → `fold-import` →
|
|
40
|
+
// the `typescript` compiler package. See `../entity-wire.ts`'s module doc.
|
|
41
|
+
const ENTITY_WIRE_CODEC_MODULE = join(DISCOVERY_DIR, "entity-wire-codec.ts");
|
|
42
|
+
const CHILD_ERRORS_MODULE = join(HERE, "child-errors.ts");
|
|
43
|
+
const PROVENANCE_MODULE = join(dirname(DISCOVERY_DIR), "provenance.ts");
|
|
44
|
+
|
|
45
|
+
export interface GenerateDriverOptions {
|
|
46
|
+
/** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
|
|
47
|
+
files: readonly string[];
|
|
48
|
+
/** The build root — threaded to `collectEntities` exactly as `discover()` threads its own `path` argument, so cross-directory stack-prefix disambiguation (chant #932) is computed the same way for this subset. */
|
|
49
|
+
buildRoot: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Escape a value for embedding as a JS/TS source-level literal. */
|
|
53
|
+
function lit(value: unknown): string {
|
|
54
|
+
return JSON.stringify(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate the driver module's full TypeScript source. Written to a tmp file
|
|
59
|
+
* and bundled (see `./bundle.ts`) before being handed to a sandboxed child —
|
|
60
|
+
* never executed directly by the parent process.
|
|
61
|
+
*/
|
|
62
|
+
export function generateDriverSource(options: GenerateDriverOptions): string {
|
|
63
|
+
const { files, buildRoot } = options;
|
|
64
|
+
|
|
65
|
+
const lines: string[] = [
|
|
66
|
+
`import { collectEntities } from ${lit(COLLECT_MODULE)};`,
|
|
67
|
+
`import { resolveAttrRefs } from ${lit(RESOLVE_MODULE)};`,
|
|
68
|
+
`import { encodeEntitySet } from ${lit(ENTITY_WIRE_CODEC_MODULE)};`,
|
|
69
|
+
`import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
|
|
70
|
+
`import { getProvenance } from ${lit(PROVENANCE_MODULE)};`,
|
|
71
|
+
``,
|
|
72
|
+
`const BUILD_ROOT = ${lit(buildRoot)};`,
|
|
73
|
+
``,
|
|
74
|
+
`function send(payload) {`,
|
|
75
|
+
` if (typeof process.send === "function") process.send(payload);`,
|
|
76
|
+
` else console.log(JSON.stringify(payload));`,
|
|
77
|
+
`}`,
|
|
78
|
+
``,
|
|
79
|
+
// collectEntities (bundled, real DiscoveryError instances) already names
|
|
80
|
+
// the exact offending file on a same-directory duplicate — reuse that
|
|
81
|
+
// instead of reporting an empty file, which is what forwarding a bare ""
|
|
82
|
+
// through classifyChildError would otherwise do.
|
|
83
|
+
`function errFile(err) {`,
|
|
84
|
+
` return err && typeof err === "object" && typeof err.file === "string" ? err.file : "";`,
|
|
85
|
+
`}`,
|
|
86
|
+
``,
|
|
87
|
+
`async function main() {`,
|
|
88
|
+
` const modules = [];`,
|
|
89
|
+
` const errors = [];`,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
lines.push(
|
|
94
|
+
` try {`,
|
|
95
|
+
` const mod = await import(${lit(file)});`,
|
|
96
|
+
` modules.push({ file: ${lit(file)}, exports: mod });`,
|
|
97
|
+
` } catch (err) {`,
|
|
98
|
+
` errors.push(classifyChildError(${lit(file)}, err).toJSON());`,
|
|
99
|
+
` }`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
lines.push(
|
|
104
|
+
``,
|
|
105
|
+
` let entities = new Map();`,
|
|
106
|
+
` try {`,
|
|
107
|
+
` entities = collectEntities(modules, BUILD_ROOT);`,
|
|
108
|
+
` } catch (err) {`,
|
|
109
|
+
` errors.push(classifyChildError(errFile(err), err, "resolution").toJSON());`,
|
|
110
|
+
` send({ entitySet: { entities: [] }, errors, provenanceByName: {} });`,
|
|
111
|
+
` return;`,
|
|
112
|
+
` }`,
|
|
113
|
+
``,
|
|
114
|
+
// Recorded BEFORE resolveAttrRefs/encode — not for the parent's own
|
|
115
|
+
// entities (it never sees this subset's raw exports at all), but so a
|
|
116
|
+
// parent-side merge collision against the fold-only set (chant#1045
|
|
117
|
+
// Phase 2 — a same-directory bare name genuinely exported twice, once
|
|
118
|
+
// folded, once run) can name the real run-fallback file instead of the
|
|
119
|
+
// entity name. See discover()'s merge step in ../index.ts.
|
|
120
|
+
` const provenanceByName = {};`,
|
|
121
|
+
` for (const [name, entity] of entities) {`,
|
|
122
|
+
` const prov = getProvenance(entity);`,
|
|
123
|
+
` if (prov?.sourceFile) provenanceByName[name] = prov.sourceFile;`,
|
|
124
|
+
` }`,
|
|
125
|
+
``,
|
|
126
|
+
` try {`,
|
|
127
|
+
` resolveAttrRefs(entities);`,
|
|
128
|
+
` } catch (err) {`,
|
|
129
|
+
` errors.push(classifyChildError("", err, "resolution").toJSON());`,
|
|
130
|
+
` }`,
|
|
131
|
+
``,
|
|
132
|
+
` try {`,
|
|
133
|
+
` const entitySet = encodeEntitySet(entities);`,
|
|
134
|
+
` send({ entitySet, errors, provenanceByName });`,
|
|
135
|
+
` } catch (err) {`,
|
|
136
|
+
` errors.push(classifyChildError("", err, "resolution").toJSON());`,
|
|
137
|
+
` send({ entitySet: { entities: [] }, errors, provenanceByName });`,
|
|
138
|
+
` }`,
|
|
139
|
+
`}`,
|
|
140
|
+
``,
|
|
141
|
+
`main().catch((err) => {`,
|
|
142
|
+
` send({ entitySet: { entities: [] }, errors: [classifyChildError("", err).toJSON()], provenanceByName: {}, fatal: true });`,
|
|
143
|
+
`});`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join, dirname, resolve } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { runFallbackFilesSandboxed } from "./run";
|
|
8
|
+
|
|
9
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
/** Absolute path to `packages/core/src/runtime.ts` — the real `createResource`
|
|
11
|
+
* factory, imported by the cross-file fixture below exactly as a lexicon
|
|
12
|
+
* package would be imported by real chant source. */
|
|
13
|
+
const runtimePath = resolve(thisDir, "../../runtime");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* chant #1045 Phase 2 — proves the actual isolation properties (not just
|
|
17
|
+
* byte-identical output, which `examples/sandbox-differential.test.ts`
|
|
18
|
+
* already covers): a run-fallback file cannot read outside the project
|
|
19
|
+
* directory, write anywhere, spawn a process, or read the ambient
|
|
20
|
+
* environment, and a permission denial names the file and the operation
|
|
21
|
+
* rather than leaking a raw `ERR_ACCESS_DENIED`.
|
|
22
|
+
*
|
|
23
|
+
* Fixtures are written to a fresh tmpdir per test, never into the source
|
|
24
|
+
* tree — a real chant convention here (never write test fixtures into the
|
|
25
|
+
* source tree), doubly so for this module, whose whole point is running
|
|
26
|
+
* untrusted-shaped project source.
|
|
27
|
+
*/
|
|
28
|
+
describe("runFallbackFilesSandboxed — isolation", () => {
|
|
29
|
+
let testDir: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
testDir = await realpath(
|
|
33
|
+
await (async () => {
|
|
34
|
+
const dir = join(tmpdir(), `chant-sandbox-run-test-${Date.now()}-${Math.random()}`);
|
|
35
|
+
await mkdir(dir, { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
})(),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await rm(testDir, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("denies filesystem reads outside the project directory, naming the file and the operation", async () => {
|
|
46
|
+
const file = join(testDir, "evil.ts");
|
|
47
|
+
await writeFile(
|
|
48
|
+
file,
|
|
49
|
+
`
|
|
50
|
+
import { readFileSync } from "node:fs";
|
|
51
|
+
readFileSync("/etc/hosts", "utf-8");
|
|
52
|
+
export const value = "unreachable";
|
|
53
|
+
`,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const result = await runFallbackFilesSandboxed([file], testDir);
|
|
57
|
+
|
|
58
|
+
expect(result.entities.size).toBe(0);
|
|
59
|
+
expect(result.errors).toHaveLength(1);
|
|
60
|
+
expect(result.errors[0].file).toBe(file);
|
|
61
|
+
expect(result.errors[0].type).toBe("permission");
|
|
62
|
+
expect(result.errors[0].message).toMatch(/FileSystemRead/);
|
|
63
|
+
expect(result.errors[0].message).toContain(file);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("denies filesystem writes anywhere", async () => {
|
|
67
|
+
const file = join(testDir, "evil.ts");
|
|
68
|
+
const targetPath = join(testDir, "..", "escaped.txt");
|
|
69
|
+
await writeFile(
|
|
70
|
+
file,
|
|
71
|
+
`
|
|
72
|
+
import { writeFileSync } from "node:fs";
|
|
73
|
+
writeFileSync(${JSON.stringify(targetPath)}, "pwned");
|
|
74
|
+
export const value = "unreachable";
|
|
75
|
+
`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const result = await runFallbackFilesSandboxed([file], testDir);
|
|
79
|
+
|
|
80
|
+
expect(result.errors).toHaveLength(1);
|
|
81
|
+
expect(result.errors[0].type).toBe("permission");
|
|
82
|
+
expect(result.errors[0].message).toMatch(/FileSystemWrite/);
|
|
83
|
+
expect(existsSync(targetPath)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("denies spawning a child process", async () => {
|
|
87
|
+
const file = join(testDir, "evil.ts");
|
|
88
|
+
const markerPath = join(testDir, "spawned.txt");
|
|
89
|
+
await writeFile(
|
|
90
|
+
file,
|
|
91
|
+
`
|
|
92
|
+
import { execSync } from "node:child_process";
|
|
93
|
+
execSync(${JSON.stringify(`touch ${markerPath}`)});
|
|
94
|
+
export const value = "unreachable";
|
|
95
|
+
`,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const result = await runFallbackFilesSandboxed([file], testDir);
|
|
99
|
+
|
|
100
|
+
expect(result.errors).toHaveLength(1);
|
|
101
|
+
expect(result.errors[0].type).toBe("permission");
|
|
102
|
+
expect(result.errors[0].message).toMatch(/ChildProcess/);
|
|
103
|
+
expect(existsSync(markerPath)).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("scrubs the ambient environment — a real secret set on the parent's process.env is invisible to project source", async () => {
|
|
107
|
+
const file = join(testDir, "evil.ts");
|
|
108
|
+
await writeFile(
|
|
109
|
+
file,
|
|
110
|
+
`
|
|
111
|
+
const seen = process.env.CHANT_SANDBOX_TEST_SECRET;
|
|
112
|
+
if (seen !== undefined) {
|
|
113
|
+
throw new Error("ambient env leaked: " + seen);
|
|
114
|
+
}
|
|
115
|
+
export const value = { envKeyCount: Object.keys(process.env).length };
|
|
116
|
+
`,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const previous = process.env.CHANT_SANDBOX_TEST_SECRET;
|
|
120
|
+
process.env.CHANT_SANDBOX_TEST_SECRET = "super-secret-should-not-cross-the-boundary";
|
|
121
|
+
try {
|
|
122
|
+
const result = await runFallbackFilesSandboxed([file], testDir);
|
|
123
|
+
// No error means the fixture's own "if visible, throw" branch never
|
|
124
|
+
// fired — the plain-object export itself isn't a Declarable, so it
|
|
125
|
+
// doesn't land in `result.entities` (collectEntities only collects
|
|
126
|
+
// Declarable/array/composite/LexiconOutput values); absence of the
|
|
127
|
+
// thrown error is the actual proof here.
|
|
128
|
+
expect(result.errors).toEqual([]);
|
|
129
|
+
} finally {
|
|
130
|
+
if (previous === undefined) delete process.env.CHANT_SANDBOX_TEST_SECRET;
|
|
131
|
+
else process.env.CHANT_SANDBOX_TEST_SECRET = previous;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("runs multiple run-fallback files together, sharing module identity for a cross-file reference", async () => {
|
|
136
|
+
await writeFile(
|
|
137
|
+
join(testDir, "resources.ts"),
|
|
138
|
+
`
|
|
139
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
140
|
+
export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
|
|
141
|
+
`,
|
|
142
|
+
);
|
|
143
|
+
await writeFile(
|
|
144
|
+
join(testDir, "storage.ts"),
|
|
145
|
+
`
|
|
146
|
+
import { Bucket } from "./resources";
|
|
147
|
+
export const dataBucket = new Bucket({ name: "data" });
|
|
148
|
+
`,
|
|
149
|
+
);
|
|
150
|
+
const policyFile = join(testDir, "policy.ts");
|
|
151
|
+
await writeFile(
|
|
152
|
+
policyFile,
|
|
153
|
+
`
|
|
154
|
+
import { dataBucket } from "./storage";
|
|
155
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
156
|
+
const Policy = createResource("Test::Policy", "aws", {});
|
|
157
|
+
export const readPolicy = new Policy({ resource: dataBucket.arn });
|
|
158
|
+
`,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const result = await runFallbackFilesSandboxed(
|
|
162
|
+
[join(testDir, "resources.ts"), join(testDir, "storage.ts"), policyFile],
|
|
163
|
+
testDir,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(result.errors).toEqual([]);
|
|
167
|
+
expect([...result.entities.keys()].sort()).toEqual(["dataBucket", "readPolicy"]);
|
|
168
|
+
|
|
169
|
+
// The cross-file AttrRef (policy.ts's `dataBucket.arn`) must resolve to
|
|
170
|
+
// storage.ts's `dataBucket` BY NAME — proof the two files shared one
|
|
171
|
+
// module graph inside the child, not two separately-imported copies
|
|
172
|
+
// (see ../fold-import.ts's `planFoldTaint` doc for why that distinction
|
|
173
|
+
// is exactly what would otherwise break).
|
|
174
|
+
const readPolicy = result.entities.get("readPolicy") as unknown as { props: { resource: unknown } };
|
|
175
|
+
const ref = readPolicy.props.resource as { getLogicalName?: () => string | undefined; attribute?: string };
|
|
176
|
+
expect(ref.getLogicalName?.()).toBe("dataBucket");
|
|
177
|
+
expect(ref.attribute).toBe("Arn");
|
|
178
|
+
});
|
|
179
|
+
});
|