@intentius/chant 0.19.0 → 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.
Files changed (156) hide show
  1. package/dist/build-params.d.ts +108 -0
  2. package/dist/build-params.d.ts.map +1 -0
  3. package/dist/build.d.ts +79 -1
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts +31 -0
  6. package/dist/cli/commands/build.d.ts.map +1 -1
  7. package/dist/cli/commands/check-lexicon-examples.d.ts +41 -0
  8. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -0
  9. package/dist/cli/commands/check-lexicon-intrinsics.d.ts +54 -0
  10. package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -0
  11. package/dist/cli/commands/check-lexicon.d.ts +1 -1
  12. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts +27 -0
  14. package/dist/cli/commands/lint.d.ts.map +1 -1
  15. package/dist/cli/handlers/build.d.ts.map +1 -1
  16. package/dist/cli/handlers/components.d.ts.map +1 -1
  17. package/dist/cli/handlers/lint.d.ts.map +1 -1
  18. package/dist/cli/handlers/run-client.d.ts +1 -1
  19. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  20. package/dist/cli/handlers/run.d.ts.map +1 -1
  21. package/dist/cli/main.d.ts.map +1 -1
  22. package/dist/cli/plugins.d.ts +16 -0
  23. package/dist/cli/plugins.d.ts.map +1 -1
  24. package/dist/cli/registry.d.ts +10 -0
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/codegen/docs-sections.d.ts.map +1 -1
  27. package/dist/components/cli-support.d.ts +12 -5
  28. package/dist/components/cli-support.d.ts.map +1 -1
  29. package/dist/components/discover.d.ts +62 -7
  30. package/dist/components/discover.d.ts.map +1 -1
  31. package/dist/components/sandbox/driver.d.ts +12 -0
  32. package/dist/components/sandbox/driver.d.ts.map +1 -0
  33. package/dist/components/sandbox/run.d.ts +42 -0
  34. package/dist/components/sandbox/run.d.ts.map +1 -0
  35. package/dist/composite.d.ts +5 -0
  36. package/dist/composite.d.ts.map +1 -1
  37. package/dist/config.d.ts +71 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/discovery/collect.d.ts.map +1 -1
  40. package/dist/discovery/entity-wire-codec.d.ts +166 -0
  41. package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
  42. package/dist/discovery/entity-wire.d.ts +50 -0
  43. package/dist/discovery/entity-wire.d.ts.map +1 -0
  44. package/dist/discovery/fold-import.d.ts +210 -0
  45. package/dist/discovery/fold-import.d.ts.map +1 -0
  46. package/dist/discovery/index.d.ts +74 -1
  47. package/dist/discovery/index.d.ts.map +1 -1
  48. package/dist/discovery/sandbox/bundle.d.ts +18 -0
  49. package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
  50. package/dist/discovery/sandbox/child-errors.d.ts +15 -0
  51. package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
  52. package/dist/discovery/sandbox/driver.d.ts +13 -0
  53. package/dist/discovery/sandbox/driver.d.ts.map +1 -0
  54. package/dist/discovery/sandbox/run.d.ts +69 -0
  55. package/dist/discovery/sandbox/run.d.ts.map +1 -0
  56. package/dist/errors.d.ts +9 -1
  57. package/dist/errors.d.ts.map +1 -1
  58. package/dist/fold/fold.d.ts +226 -0
  59. package/dist/fold/fold.d.ts.map +1 -0
  60. package/dist/fold/subset.d.ts +102 -0
  61. package/dist/fold/subset.d.ts.map +1 -0
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/lexicon-output.d.ts +7 -2
  65. package/dist/lexicon-output.d.ts.map +1 -1
  66. package/dist/lexicon-schema.d.ts +2 -2
  67. package/dist/lexicon-schema.d.ts.map +1 -1
  68. package/dist/lexicon.d.ts +81 -1
  69. package/dist/lexicon.d.ts.map +1 -1
  70. package/dist/lifecycle/release-ledger.d.ts +11 -0
  71. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  72. package/dist/lint/component-checks.d.ts +7 -1
  73. package/dist/lint/component-checks.d.ts.map +1 -1
  74. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  75. package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
  76. package/dist/params.d.ts +60 -0
  77. package/dist/params.d.ts.map +1 -0
  78. package/dist/provenance.d.ts +21 -0
  79. package/dist/provenance.d.ts.map +1 -1
  80. package/dist/terraform/aws-resources.d.ts.map +1 -1
  81. package/package.json +2 -1
  82. package/src/build-params.test.ts +144 -0
  83. package/src/build-params.ts +207 -0
  84. package/src/build.test.ts +38 -0
  85. package/src/build.ts +144 -7
  86. package/src/cli/commands/build.test.ts +220 -2
  87. package/src/cli/commands/build.ts +111 -3
  88. package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
  89. package/src/cli/commands/check-lexicon-examples.ts +103 -0
  90. package/src/cli/commands/check-lexicon-intrinsics.test.ts +145 -0
  91. package/src/cli/commands/check-lexicon-intrinsics.ts +312 -0
  92. package/src/cli/commands/check-lexicon.test.ts +34 -0
  93. package/src/cli/commands/check-lexicon.ts +119 -1
  94. package/src/cli/commands/lint.ts +31 -3
  95. package/src/cli/commands/onboard.ts +1 -1
  96. package/src/cli/component-security-boundary.test.ts +170 -0
  97. package/src/cli/handlers/build.ts +24 -3
  98. package/src/cli/handlers/components.ts +9 -2
  99. package/src/cli/handlers/dev.ts +1 -1
  100. package/src/cli/handlers/graph.ts +7 -5
  101. package/src/cli/handlers/lifecycle.ts +2 -2
  102. package/src/cli/handlers/lint.ts +2 -0
  103. package/src/cli/handlers/misc.ts +2 -2
  104. package/src/cli/handlers/run-client.ts +1 -1
  105. package/src/cli/handlers/run.ts +20 -5
  106. package/src/cli/main.test.ts +22 -0
  107. package/src/cli/main.ts +39 -0
  108. package/src/cli/plugins.ts +20 -4
  109. package/src/cli/registry.ts +10 -0
  110. package/src/cli/security-boundary.test.ts +135 -0
  111. package/src/codegen/docs-sections.test.ts +61 -0
  112. package/src/codegen/docs-sections.ts +7 -3
  113. package/src/components/cli-support.ts +22 -10
  114. package/src/components/discover.ts +127 -25
  115. package/src/components/sandbox/driver.ts +114 -0
  116. package/src/components/sandbox/run.test.ts +185 -0
  117. package/src/components/sandbox/run.ts +177 -0
  118. package/src/composite.test.ts +21 -0
  119. package/src/composite.ts +20 -1
  120. package/src/config.ts +81 -0
  121. package/src/discovery/collect.ts +17 -3
  122. package/src/discovery/entity-wire-codec.ts +485 -0
  123. package/src/discovery/entity-wire.test.ts +240 -0
  124. package/src/discovery/entity-wire.ts +67 -0
  125. package/src/discovery/fold-import.test.ts +1026 -0
  126. package/src/discovery/fold-import.ts +1805 -0
  127. package/src/discovery/index.test.ts +191 -1
  128. package/src/discovery/index.ts +233 -1
  129. package/src/discovery/sandbox/bundle.ts +218 -0
  130. package/src/discovery/sandbox/child-errors.ts +65 -0
  131. package/src/discovery/sandbox/driver.ts +147 -0
  132. package/src/discovery/sandbox/run.test.ts +179 -0
  133. package/src/discovery/sandbox/run.ts +196 -0
  134. package/src/errors.ts +9 -1
  135. package/src/fold/fold.test.ts +535 -0
  136. package/src/fold/fold.ts +648 -0
  137. package/src/fold/subset-doc-parity.test.ts +183 -0
  138. package/src/fold/subset.test.ts +241 -0
  139. package/src/fold/subset.ts +302 -0
  140. package/src/index.ts +2 -0
  141. package/src/lexicon-output.ts +7 -2
  142. package/src/lexicon-schema.test.ts +14 -0
  143. package/src/lexicon-schema.ts +3 -1
  144. package/src/lexicon.ts +82 -1
  145. package/src/lifecycle/git.test.ts +10 -5
  146. package/src/lifecycle/release-ledger.test.ts +28 -0
  147. package/src/lifecycle/release-ledger.ts +11 -0
  148. package/src/lint/component-checks.ts +8 -1
  149. package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
  150. package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
  151. package/src/params.test.ts +22 -0
  152. package/src/params.ts +66 -0
  153. package/src/provenance.ts +22 -0
  154. package/src/terraform/adopt-state.test.ts +1 -1
  155. package/src/terraform/aws-resources.test.ts +11 -4
  156. package/src/terraform/aws-resources.ts +60 -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
+ });