@telorun/kernel 0.39.1 → 0.40.1

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 (47) hide show
  1. package/README.md +6 -2
  2. package/dist/application-env.d.ts +7 -0
  3. package/dist/application-env.d.ts.map +1 -1
  4. package/dist/application-env.js +19 -0
  5. package/dist/application-env.js.map +1 -1
  6. package/dist/controller-loaders/bundle-builder.d.ts.map +1 -1
  7. package/dist/controller-loaders/bundle-builder.js +4 -3
  8. package/dist/controller-loaders/bundle-builder.js.map +1 -1
  9. package/dist/controller-loaders/napi-loader.d.ts.map +1 -1
  10. package/dist/controller-loaders/napi-loader.js +4 -2
  11. package/dist/controller-loaders/napi-loader.js.map +1 -1
  12. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  13. package/dist/controller-loaders/npm-loader.js +7 -5
  14. package/dist/controller-loaders/npm-loader.js.map +1 -1
  15. package/dist/evaluation-context.d.ts +1 -2
  16. package/dist/evaluation-context.d.ts.map +1 -1
  17. package/dist/evaluation-context.js +1 -2
  18. package/dist/evaluation-context.js.map +1 -1
  19. package/dist/host-env.d.ts +66 -0
  20. package/dist/host-env.d.ts.map +1 -0
  21. package/dist/host-env.js +130 -0
  22. package/dist/host-env.js.map +1 -0
  23. package/dist/kernel.d.ts +1 -0
  24. package/dist/kernel.d.ts.map +1 -1
  25. package/dist/kernel.js +18 -3
  26. package/dist/kernel.js.map +1 -1
  27. package/dist/manifest-sources/local-manifest-cache-source.d.ts.map +1 -1
  28. package/dist/manifest-sources/local-manifest-cache-source.js +2 -1
  29. package/dist/manifest-sources/local-manifest-cache-source.js.map +1 -1
  30. package/dist/module-context.d.ts +1 -2
  31. package/dist/module-context.d.ts.map +1 -1
  32. package/dist/module-context.js +1 -30
  33. package/dist/module-context.js.map +1 -1
  34. package/dist/resource-context.d.ts.map +1 -1
  35. package/dist/resource-context.js +4 -1
  36. package/dist/resource-context.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/application-env.ts +19 -0
  39. package/src/controller-loaders/bundle-builder.ts +4 -3
  40. package/src/controller-loaders/napi-loader.ts +5 -2
  41. package/src/controller-loaders/npm-loader.ts +7 -5
  42. package/src/evaluation-context.ts +1 -2
  43. package/src/host-env.ts +142 -0
  44. package/src/kernel.ts +22 -2
  45. package/src/manifest-sources/local-manifest-cache-source.ts +3 -1
  46. package/src/module-context.ts +0 -27
  47. package/src/resource-context.ts +4 -1
@@ -794,8 +794,7 @@ export class EvaluationContext implements IEvaluationContext {
794
794
  * trace — `variables`, masked `secrets`, resource `snapshots`, `ports`. Attached
795
795
  * to a trace's *root* span so the consumer can inspect what data the execution
796
796
  * could reference (beyond its own inputs/outputs). Only a `ModuleContext` owns a
797
- * root scope; child scopes return undefined. Host `env` is deliberately omitted
798
- * (it is the raw process environment — too broad/sensitive to dump).
797
+ * root scope; child scopes return undefined.
799
798
  */
800
799
  protected traceRootScope(): Record<string, unknown> | undefined {
801
800
  return undefined;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Host-environment access for the kernel, and the controller `process.env`
3
+ * guardrail.
4
+ *
5
+ * Controllers (and their dependencies) should not bypass a declared binding by
6
+ * reading its raw host env var: host configuration reaches a controller only
7
+ * through `ctx.env` (the snapshot the kernel threads in) or its declared
8
+ * `variables` / `secrets` / `ports`. To enforce that, {@link lockControllerEnv}
9
+ * replaces the global `process.env` with a Proxy that denies reads of exactly
10
+ * the env-var names the manifest binds — every other key (`NODE_ENV`, an SDK's
11
+ * own `AWS_*` / `SMITHY_*` config, `~/.aws` path lookups, …) passes through
12
+ * untouched. The kernel carries no allowlist of vendor env conventions; the
13
+ * denied set is derived from the manifest.
14
+ *
15
+ * This is a **guardrail, not an isolation boundary**: controllers run in the
16
+ * same process, so a determined one can still reach the OS environment by other
17
+ * means (a child process, `/proc/self/environ`, re-`defineProperty`). The Proxy
18
+ * stops casual / accidental bypass of a declared binding and makes it visible.
19
+ *
20
+ * The kernel itself still needs a handful of `TELO_*` / cache env vars. Those
21
+ * reads go through {@link hostEnv}, which returns a reference captured at module
22
+ * import — before any lock — so they keep working once the Proxy is installed.
23
+ */
24
+
25
+ // The real environment, captured before any lock — and stored on `globalThis`
26
+ // under a process-global symbol so it is shared across *every* `@telorun/kernel`
27
+ // module instance in the process. This matters because a controller can load a
28
+ // second copy of the kernel: the `@telorun/test` suite runner imports `Kernel`
29
+ // to spawn child kernels in-process. That second copy's module body runs *after*
30
+ // the main kernel already locked `process.env`, so a plain `process.env` capture
31
+ // there would grab the (proxied) env. Reading the shared snapshot instead keeps
32
+ // every instance's `hostEnv()` — and the subprocess env it feeds — real.
33
+ //
34
+ // `lockControllerEnv` only rebinds the `process.env` *property*; it never mutates
35
+ // the original object, so this reference keeps yielding real values after a lock.
36
+ const REAL_ENV_KEY = Symbol.for("@telorun/kernel:host-env:real-env");
37
+ const LOCKED_KEY = Symbol.for("@telorun/kernel:host-env:locked");
38
+ const DENIED_KEYS_KEY = Symbol.for("@telorun/kernel:host-env:denied-keys");
39
+ const globals = globalThis as Record<symbol, unknown>;
40
+ const REAL_ENV: NodeJS.ProcessEnv = (globals[REAL_ENV_KEY] ??=
41
+ process.env) as NodeJS.ProcessEnv;
42
+
43
+ // Process-global set of denied env-var names — the union of every booted
44
+ // kernel's declared `variables` / `secrets` / `ports` env keys. Shared on
45
+ // `globalThis` so a second in-process `@telorun/kernel` copy (the test suite
46
+ // runner spawns child kernels) and the already-installed Proxy read the same
47
+ // live set: a kernel that boots *after* the Proxy is installed still contributes
48
+ // its declared keys (see {@link lockControllerEnv}).
49
+ const DENIED_KEYS: Set<string> = (globals[DENIED_KEYS_KEY] ??= new Set<string>()) as Set<string>;
50
+
51
+ /** The real host environment. Kernel-internal use only — never handed to
52
+ * controllers (they get the sanctioned `ctx.env` snapshot instead). */
53
+ export function hostEnv(): NodeJS.ProcessEnv {
54
+ return REAL_ENV;
55
+ }
56
+
57
+ /**
58
+ * Build the guardrail Proxy over `backing`. A key in `deniedKeys` reads back
59
+ * `undefined` — even when set — and is hidden from `in` / enumeration, with the
60
+ * first read of each such key reported via `warn`. Every other key passes
61
+ * through transparently (real value, no warning, visible to `in` /
62
+ * enumeration). Writes pass through to `backing`.
63
+ *
64
+ * `deniedKeys` is consulted live on every trap, so keys added after the Proxy
65
+ * is installed take effect immediately (a later kernel's bindings).
66
+ *
67
+ * Exported for testing; production installs it via {@link lockControllerEnv}.
68
+ */
69
+ export function createLockedEnv(
70
+ backing: NodeJS.ProcessEnv,
71
+ deniedKeys: ReadonlySet<string>,
72
+ warn: (key: string) => void,
73
+ ): NodeJS.ProcessEnv {
74
+ const warned = new Set<string>();
75
+ const denied = (key: string | symbol): boolean =>
76
+ typeof key === "string" && deniedKeys.has(key);
77
+
78
+ return new Proxy(backing, {
79
+ get(target, key) {
80
+ if (!denied(key)) return Reflect.get(target, key);
81
+ const name = key as string;
82
+ if (!warned.has(name)) {
83
+ warned.add(name);
84
+ warn(name);
85
+ }
86
+ return undefined;
87
+ },
88
+ has(target, key) {
89
+ return !denied(key) && Reflect.has(target, key);
90
+ },
91
+ ownKeys(target) {
92
+ return Reflect.ownKeys(target).filter((key) => !denied(key));
93
+ },
94
+ getOwnPropertyDescriptor(target, key) {
95
+ return denied(key) ? undefined : Reflect.getOwnPropertyDescriptor(target, key);
96
+ },
97
+ set(target, key, value) {
98
+ return Reflect.set(target, key, value);
99
+ },
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Install the {@link createLockedEnv} guardrail over the global `process.env`,
105
+ * denying reads of `deniedKeys` — the booting kernel's declared `variables` /
106
+ * `secrets` / `ports` env-var names.
107
+ *
108
+ * The denied set is process-global and additive: every call unions its keys
109
+ * into the shared {@link DENIED_KEYS} set, even after the Proxy is installed, so
110
+ * each kernel that boots in the process contributes its bindings. Several
111
+ * `Kernel` instances can boot in one process (the test suite runs child kernels
112
+ * in-process), each with its own manifest; the union denies any key declared by
113
+ * any of them — strictly safe, since a controller has no reason to read a
114
+ * sibling app's declared key from the raw env.
115
+ *
116
+ * The Proxy install itself is idempotent — the flag lives on `globalThis`, so a
117
+ * second `@telorun/kernel` instance sees the first instance's lock and does not
118
+ * re-wrap. The property is left non-writable so a casual `process.env = {…}`
119
+ * cannot drop the guardrail (`configurable` stays true so tooling can still
120
+ * manage it).
121
+ *
122
+ * `warn` binds to the first kernel that boots in the process. In the common
123
+ * one-kernel-per-process case that is the right sink; with in-process child
124
+ * kernels a stray read warns to the first kernel's stderr (and dedup is
125
+ * process-global). Acceptable — child kernels receive an explicit env and have
126
+ * no reason to read a declared key from `process.env`.
127
+ */
128
+ export function lockControllerEnv(
129
+ deniedKeys: Iterable<string>,
130
+ warn: (key: string) => void,
131
+ ): void {
132
+ for (const key of deniedKeys) DENIED_KEYS.add(key);
133
+ if (globals[LOCKED_KEY]) return;
134
+ globals[LOCKED_KEY] = true;
135
+
136
+ Object.defineProperty(process, "env", {
137
+ value: createLockedEnv(REAL_ENV, DENIED_KEYS, warn),
138
+ writable: false,
139
+ enumerable: true,
140
+ configurable: true,
141
+ });
142
+ }
package/src/kernel.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  import { parseArgs } from "util";
33
33
  import { ControllerRegistry } from "./controller-registry.js";
34
34
  import { EventBus } from "./events.js";
35
+ import { hostEnv, lockControllerEnv } from "./host-env.js";
35
36
  import { KernelTracer } from "./tracing.js";
36
37
  import { ModuleContext } from "./module-context.js";
37
38
  import { ResourceContextImpl } from "./resource-context.js";
@@ -48,6 +49,7 @@ import {
48
49
  resolveCacheRoot,
49
50
  } from "./manifest-sources/local-manifest-cache-source.js";
50
51
  import {
52
+ collectDeclaredEnvKeys,
51
53
  precompileApplicationEnvSchemas,
52
54
  precompileDefinitionSchemas,
53
55
  resolveApplicationEnv,
@@ -119,6 +121,9 @@ export class Kernel implements IKernel {
119
121
  * per name. Surfaced via {@link getResolvedPorts} so a host can advertise where
120
122
  * the running app is reachable. */
121
123
  private _resolvedPorts: Array<{ name: string; port: number; protocol: "tcp" | "udp" }> = [];
124
+ // Host env-var names the root Application binds via `variables`/`secrets`/
125
+ // `ports`; the denied set for the `process.env` guardrail installed at boot.
126
+ private _declaredEnvKeys: string[] = [];
122
127
  /** The `.telo` cache root for this load, resolved once in `load()` and
123
128
  * threaded to the validator, analysis stamp, and npm install root. */
124
129
  private _cacheRoot?: string | null;
@@ -148,7 +153,7 @@ export class Kernel implements IKernel {
148
153
  this.stdin = options.stdin ?? process.stdin;
149
154
  this.stdout = options.stdout ?? process.stdout;
150
155
  this.stderr = options.stderr ?? process.stderr;
151
- this.env = options.env ?? process.env;
156
+ this.env = options.env ?? hostEnv();
152
157
  this.argv = options.argv ?? [];
153
158
  this.registryUrl = options.registryUrl;
154
159
  this.loader = new Loader(defaultSources(this.registryUrl), { celHandlers: nodeCelHandlers });
@@ -338,7 +343,6 @@ export class Kernel implements IKernel {
338
343
  [],
339
344
  this._createInstance.bind(this),
340
345
  (event, payload, metadata) => this.eventBus.emit(event, payload, metadata),
341
- this.env,
342
346
  );
343
347
  this.rootContext.tracer = this.tracer;
344
348
  // Initialize built-in Runtime definitions first
@@ -551,6 +555,9 @@ export class Kernel implements IKernel {
551
555
  }
552
556
 
553
557
  if (rootApplicationManifest) {
558
+ this._declaredEnvKeys = collectDeclaredEnvKeys(
559
+ rootApplicationManifest as Record<string, any>,
560
+ );
554
561
  const { variables, secrets, ports } = resolveApplicationEnv(
555
562
  rootApplicationManifest as Record<string, any>,
556
563
  this.env,
@@ -606,6 +613,19 @@ export class Kernel implements IKernel {
606
613
  }
607
614
  this._bootCalled = true;
608
615
 
616
+ // Lock the ambient host environment before any controller runs: a key the
617
+ // manifest binds via `variables`/`secrets`/`ports` must be read through
618
+ // `ctx.env` or the declared binding, never the raw `process.env` var. Every
619
+ // other key passes through. Only real runs reach boot() — analyzeOnly loads
620
+ // return earlier — so analysis/editor are unaffected. The denied set is
621
+ // process-global and additive across in-process kernels.
622
+ lockControllerEnv(this._declaredEnvKeys, (key) => {
623
+ this.stderr.write(
624
+ `[telo] controller read process.env.${key} directly — ${key} is a declared ` +
625
+ `binding; read it through ctx.env or its variable/secret, not raw process.env.\n`,
626
+ );
627
+ });
628
+
609
629
  // Call register hooks for controllers actually loaded at this point (built-ins).
610
630
  // User-module kinds load their controllers during Phase 3 (Telo.Definition.init),
611
631
  // and registerController() fires their register hook there.
@@ -5,6 +5,8 @@ import * as fs from "fs/promises";
5
5
  import * as path from "path";
6
6
  import { fileURLToPath, pathToFileURL } from "url";
7
7
 
8
+ import { hostEnv } from "../host-env.js";
9
+
8
10
  const CACHE_SUBDIR = ".telo/manifests";
9
11
  const HTTP_NAMESPACE = "__http";
10
12
  const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
@@ -274,7 +276,7 @@ export function resolveEntryDir(entryPath: string): string | null {
274
276
  * Consumers append the conventional subdirs: `manifests/`, `manifests/__validators/`,
275
277
  * `npm/`. */
276
278
  export function resolveCacheRoot(entryPath: string): string | null {
277
- const override = process.env.TELO_CACHE_DIR;
279
+ const override = hostEnv().TELO_CACHE_DIR;
278
280
  if (override && override.trim()) return path.resolve(override.trim());
279
281
  const entryDir = resolveEntryDir(entryPath);
280
282
  return entryDir ? path.join(entryDir, ".telo") : null;
@@ -12,31 +12,6 @@ import type {
12
12
  import type { EmitEvent, InstanceFactory } from "@telorun/sdk";
13
13
  import { EvaluationContext } from "./evaluation-context.js";
14
14
 
15
- /** Wraps process.env so that missing keys return null instead of throwing in CEL.
16
- * cel-js uses Object.hasOwn(obj, key) before accessing obj[key], so we must
17
- * intercept getOwnPropertyDescriptor to report every string key as "own".
18
- * The `constructor` key is special-cased to return `Object` so cel-js's dyn
19
- * value-type matcher recognises the proxy as a plain map; Node's process.env
20
- * has an anonymous-function constructor that cel-js otherwise rejects with
21
- * "Unsupported type: object". */
22
- function lenientEnv(env: Record<string, string | undefined>): Record<string, string | null> {
23
- return new Proxy(env as Record<string, string | null>, {
24
- get(target, key) {
25
- if (typeof key !== "string") return (target as any)[key];
26
- if (key === "constructor") return Object as unknown as string;
27
- return key in target ? (target[key] ?? null) : null;
28
- },
29
- has() {
30
- return true;
31
- },
32
- getOwnPropertyDescriptor(target, key) {
33
- if (typeof key !== "string") return Object.getOwnPropertyDescriptor(target, key);
34
- const value = key in target ? (target[key] ?? null) : null;
35
- return { configurable: true, enumerable: true, writable: true, value };
36
- },
37
- });
38
- }
39
-
40
15
  function collectSecretValues(secrets: Record<string, unknown>): Set<string> {
41
16
  const values = new Set<string>();
42
17
  for (const value of Object.values(secrets)) {
@@ -155,7 +130,6 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
155
130
  private targets: BootTarget[] = [],
156
131
  createInstance: InstanceFactory = async () => null,
157
132
  emit: EmitEvent,
158
- private readonly _hostEnv?: Record<string, string | undefined>,
159
133
  ) {
160
134
  super(source, {}, createInstance, new Set(), emit);
161
135
  this._variables = variables;
@@ -461,7 +435,6 @@ export class ModuleContext extends EvaluationContext implements IModuleContext {
461
435
  secrets: this._secrets,
462
436
  resources: this._resources,
463
437
  ports: this._ports,
464
- ...(this._hostEnv ? { env: lenientEnv(this._hostEnv) } : {}),
465
438
  };
466
439
  this._secretValues = collectSecretValues(this._secrets);
467
440
  }
@@ -18,6 +18,7 @@ import {
18
18
  type TypeRule,
19
19
  } from "@telorun/sdk";
20
20
  import { isRefSentinel } from "@telorun/templating";
21
+ import { hostEnv } from "./host-env.js";
21
22
  import { stripCompiledValues } from "./schema-compiled-values.js";
22
23
  import AjvModule from "ajv";
23
24
  import addFormats from "ajv-formats";
@@ -55,7 +56,9 @@ export class ResourceContextImpl implements ResourceContext {
55
56
  args?: ParsedArgs,
56
57
  ownerPrefix = "",
57
58
  ) {
58
- this.env = env ?? process.env;
59
+ // `ctx.env` is the sanctioned host-env channel for controllers — always the
60
+ // real environment (kernel passes its snapshot), never the locked Proxy.
61
+ this.env = env ?? hostEnv();
59
62
  this.stdin = stdin ?? process.stdin;
60
63
  this.stdout = stdout ?? process.stdout;
61
64
  this.stderr = stderr ?? process.stderr;