@telorun/kernel 0.83.0 → 0.84.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.
@@ -30,6 +30,11 @@ import {
30
30
  import { RuntimeError } from "@telorun/sdk";
31
31
  import { evalPathCovers } from "@telorun/analyzer";
32
32
  import { effectOwnerOf, executeReturnedChain } from "./effect-scope.js";
33
+ import {
34
+ REDACTED,
35
+ redactSensitive,
36
+ sensitivePathsOfInstance,
37
+ } from "./instance-sensitive-paths.js";
33
38
  import {
34
39
  classifyInitFailures,
35
40
  isDeferral,
@@ -142,6 +147,12 @@ function localDependencyNames(refs: ResourceRef[]): string[] {
142
147
  * whole-value match is redacted. */
143
148
  const MIN_SUBSTRING_SCRUB_LEN = 5;
144
149
 
150
+ /** A live resource instance, as opposed to the raw config value a slot held
151
+ * before Phase-5 injection — the same duck-test the reference-resolving
152
+ * controllers use. */
153
+ const hasSnapshotMethod = (value: unknown): boolean =>
154
+ !!value && typeof (value as { snapshot?: unknown }).snapshot === "function";
155
+
145
156
  export function buildResolvedProperties(
146
157
  resource: ResourceManifest,
147
158
  secretValues: Set<string>,
@@ -752,6 +763,43 @@ export class EvaluationContext implements IEvaluationContext {
752
763
  return def?.status;
753
764
  }
754
765
 
766
+ /**
767
+ * The configured values a merge-form inheriting child publishes over the
768
+ * parent instance's reading, or undefined when the kind declares none.
769
+ *
770
+ * Such a child IS the parent instance — the inherited controller returns it
771
+ * verbatim — so `snapshot()` is the parent's and a field the parent never
772
+ * declared would be readable from nowhere. The field NAMES come from
773
+ * `publishedOwnFields`, stamped onto the definition at registration in the
774
+ * scope that declared the `extends` alias; resolving the chain here would
775
+ * resolve it in the READING module's scope, which may have no alias for the
776
+ * parent's library. Done here rather than by rebinding the instance's
777
+ * `snapshot()` because this is definition-derived data joined into a reading —
778
+ * exactly what `statusSchemaOf` already does — and a second kernel cannot
779
+ * rebind a trait method.
780
+ *
781
+ * Two values are withheld rather than published, both conservatively: a
782
+ * runtime-eval field is still a `CompiledValue` at this point (only compile
783
+ * paths are expanded at create), and a live instance is a collaborator rather
784
+ * than a reading. Publishing either would put a wrong-typed value in the CEL
785
+ * scope, which is the failure this overlay exists to remove.
786
+ */
787
+ private ownFieldOverlay(
788
+ kind: string,
789
+ resource: Record<string, unknown>,
790
+ ): Record<string, unknown> | undefined {
791
+ const def = this.getDefinition?.(this.resolveKindSafe(kind)) ?? this.getDefinition?.(kind);
792
+ const fields = (def as { publishedOwnFields?: string[] } | undefined)?.publishedOwnFields;
793
+ if (!fields?.length) return undefined;
794
+ let overlay: Record<string, unknown> | undefined;
795
+ for (const field of fields) {
796
+ const value = resource[field];
797
+ if (value === undefined || isCompiledValue(value) || hasSnapshotMethod(value)) continue;
798
+ (overlay ??= {})[field] = value;
799
+ }
800
+ return overlay;
801
+ }
802
+
755
803
  /**
756
804
  * Re-read a resource's `snapshot()` and republish it, joined with whatever
757
805
  * observed state the resource has reported. The single publication path — the
@@ -760,11 +808,14 @@ export class EvaluationContext implements IEvaluationContext {
760
808
  */
761
809
  async publishSnapshot(name: string): Promise<void> {
762
810
  const entry = this.resourceInstances.get(name) ?? this.createdInstances.get(name);
763
- if (!entry?.instance.snapshot) return;
764
- const snap = (await Promise.resolve(entry.instance.snapshot())) as
765
- | Record<string, unknown>
766
- | undefined;
811
+ if (!entry) return;
767
812
  const kind = entry.resource.kind as string;
813
+ const own = this.ownFieldOverlay(kind, entry.resource);
814
+ if (!entry.instance.snapshot && !own) return;
815
+ const snapshot = entry.instance.snapshot
816
+ ? ((await Promise.resolve(entry.instance.snapshot())) as Record<string, unknown> | undefined)
817
+ : undefined;
818
+ const snap = own ? { ...(snapshot ?? {}), ...own } : snapshot;
768
819
  const props = buildPublishedProps(snap, {
769
820
  kind,
770
821
  name,
@@ -1606,6 +1657,30 @@ export class EvaluationContext implements IEvaluationContext {
1606
1657
  : undefined;
1607
1658
  // Capture the root CEL scope once, on the trace's root span's terminal event.
1608
1659
  const rootScope = tracing && parentInvocationId === undefined ? this.traceRootScope() : undefined;
1660
+ // What a payload may say about this call. A credential is a dispatched
1661
+ // invocable, so its material is an invoke OUTPUT — and inputs and outputs
1662
+ // ride the debug wire on every call under `--inspect`, i.e. every watch
1663
+ // session, which the substring scrubbing does not reach (one call site, the
1664
+ // resource-Created event's properties). The kind that owns the contract
1665
+ // marks the field; nothing here knows which kind that is.
1666
+ const hide = (detail: Record<string, unknown>): Record<string, unknown> => {
1667
+ let out = detail;
1668
+ for (const direction of ["inputType", "outputType"] as const) {
1669
+ const key = direction === "inputType" ? "inputs" : "outputs";
1670
+ if (!(key in out)) continue;
1671
+ const paths = sensitivePathsOfInstance(instance, direction);
1672
+ // Unknown, because the contract would not resolve — withhold the whole
1673
+ // value rather than guess. The dispatch is about to raise that same
1674
+ // failure with its own code.
1675
+ if (paths === undefined) {
1676
+ out = { ...out, [key]: REDACTED };
1677
+ continue;
1678
+ }
1679
+ if (paths.length === 0) continue;
1680
+ out = { ...out, [key]: redactSensitive(out[key], paths) };
1681
+ }
1682
+ return out;
1683
+ };
1609
1684
  const span = (
1610
1685
  phase: "start" | "end",
1611
1686
  outcome: SpanOutcome | undefined,
@@ -1620,7 +1695,7 @@ export class EvaluationContext implements IEvaluationContext {
1620
1695
  "invoke",
1621
1696
  phase,
1622
1697
  outcome,
1623
- phase === "end" && rootScope ? { ...detail, context: rootScope } : detail,
1698
+ phase === "end" && rootScope ? { ...hide(detail), context: rootScope } : hide(detail),
1624
1699
  );
1625
1700
  // When tracing, a derived context carries the new id down the tree so nested
1626
1701
  // invokes read it as their parent; it is never `=== ambient`, so the call
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Instance → the contract paths its kind marked `x-telo-sensitive`, recorded at
3
+ * `create()` beside the handle and the declaration.
4
+ *
5
+ * The trace site has only the instance. It cannot re-derive this: the resolved
6
+ * contract is compiled inside `bindContract`'s closure and dropped, the
7
+ * definition carries the DECLARATION rather than the resolved schema, and an
8
+ * instance-manifest override would be missed by re-resolving from the kind. So
9
+ * the answer is recorded where both halves are in hand, exactly as
10
+ * `instance-declaration.ts` records the other direction.
11
+ *
12
+ * TWO entries per instance, kept apart by direction, because a contract may mark
13
+ * a field on the way in as well as on the way out — `forceRefresh` going to a
14
+ * credential is not sensitive, but a signing key handed to one would be, and a
15
+ * single merged list would redact an input path in an output payload where it
16
+ * names something else entirely.
17
+ *
18
+ * Weak and one-way: paths are obtainable FROM an instance, never an instance
19
+ * from paths, so nothing here extends a lifetime or hands out live state.
20
+ * Resolution is LAZY — the paths are read through a thunk rather than eagerly,
21
+ * because compiling a contract at create time would make every contract-bearing
22
+ * kind depend on type-registration order, which is the reason the binding defers
23
+ * it in the first place.
24
+ */
25
+
26
+ export type ContractDirection = "inputType" | "outputType";
27
+
28
+ interface SensitiveThunks {
29
+ inputType?: () => string[][];
30
+ outputType?: () => string[][];
31
+ }
32
+
33
+ const sensitive = new WeakMap<object, SensitiveThunks>();
34
+
35
+ /** Record how to obtain one direction's sensitive paths for a live instance.
36
+ * First record wins, matching the handle and declaration rules: a `base:` child
37
+ * IS its parent instance, and the parent's binding is the one that produced
38
+ * it. */
39
+ export function recordSensitivePaths(
40
+ instance: object,
41
+ direction: ContractDirection,
42
+ paths: () => string[][],
43
+ ): void {
44
+ const entry = sensitive.get(instance) ?? {};
45
+ if (entry[direction] !== undefined) return;
46
+ entry[direction] = paths;
47
+ sensitive.set(instance, entry);
48
+ }
49
+
50
+ /**
51
+ * The paths one direction of a live instance's contract marked sensitive.
52
+ *
53
+ * An EMPTY list means the contract marked nothing, or the kind declares no
54
+ * contract at all — carry the payload verbatim. `undefined` means the contract
55
+ * could not be resolved, so WHICH fields are sensitive is unknown and the
56
+ * payload must be withheld whole.
57
+ *
58
+ * Nothing is swallowed by that: resolving is exactly what the dispatch about to
59
+ * follow does, so the same failure surfaces from it a moment later, with its own
60
+ * code and its own message. What the catch avoids is a trace site becoming the
61
+ * place an unrelated contract defect first appears — and, far worse, emitting
62
+ * auth material onto the wire because a schema failed to compile.
63
+ */
64
+ export function sensitivePathsOfInstance(
65
+ instance: unknown,
66
+ direction: ContractDirection,
67
+ ): string[][] | undefined {
68
+ if (!instance || typeof instance !== "object") return [];
69
+ const thunk = sensitive.get(instance as object)?.[direction];
70
+ if (!thunk) return [];
71
+ try {
72
+ return thunk();
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ /** What a hidden value reads as. The key is KEPT and only the value replaced,
79
+ * per the logging spec §14: a payload that silently loses a key reads as a
80
+ * value that was never produced. */
81
+ export const REDACTED = "[redacted]";
82
+
83
+ /**
84
+ * `value` with the marked paths replaced by {@link REDACTED}.
85
+ *
86
+ * Copy-on-write ALONG THE PATHS ONLY, so the caller's own object — the very
87
+ * object being handed to a controller, or the one it just returned — is never
88
+ * mutated. A whole-payload clone would be the obvious alternative and is wrong
89
+ * twice: it costs a deep copy per span on the dispatch path, and it would
90
+ * rewrite live values (a stream handle, a resource instance) that only survive
91
+ * by identity.
92
+ */
93
+ export function redactSensitive(value: unknown, paths: readonly string[][]): unknown {
94
+ if (paths.length === 0) return value;
95
+ let out = value;
96
+ for (const path of paths) out = redactAt(out, path, 0);
97
+ return out;
98
+ }
99
+
100
+ function redactAt(node: unknown, path: readonly string[], index: number): unknown {
101
+ if (node === null || node === undefined) return node;
102
+ if (index === path.length) return REDACTED;
103
+ const segment = path[index];
104
+ if (segment === "[]") {
105
+ return Array.isArray(node) ? node.map((element) => redactAt(element, path, index + 1)) : node;
106
+ }
107
+ // The map-value wildcard: every own key, whatever it is named. What
108
+ // `additionalProperties` / `patternProperties` emit, since neither carries
109
+ // property names to walk.
110
+ if (segment === "{}") {
111
+ if (typeof node !== "object" || Array.isArray(node)) return node;
112
+ const source = node as Record<string, unknown>;
113
+ const out: Record<string, unknown> = {};
114
+ for (const [key, value] of Object.entries(source)) out[key] = redactAt(value, path, index + 1);
115
+ return out;
116
+ }
117
+ if (typeof node !== "object" || Array.isArray(node)) return node;
118
+ const object = node as Record<string, unknown>;
119
+ // A path the value does not carry is not a defect — the contract describes
120
+ // what MAY be there, and an optional field is routinely absent.
121
+ if (!(segment in object)) return node;
122
+ return { ...object, [segment]: redactAt(object[segment], path, index + 1) };
123
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type DeclaredScalarForm,
6
6
  type DeclaredScalarPath,
7
7
  defaultBearingPaths,
8
+ sensitivePaths,
8
9
  effectiveContractField,
9
10
  describeProjectionFailure,
10
11
  resolveSchemaProjections,
@@ -75,6 +76,9 @@ export interface BoundContract {
75
76
  * value is normalized at, in either direction. Empty when the contract
76
77
  * declares none. */
77
78
  scalarPaths(): DeclaredScalarPath[];
79
+ /** Paths the contract marked `x-telo-sensitive` — the values a trace payload
80
+ * carries as `[redacted]`. Empty when the contract marks none. */
81
+ sensitivePaths(): string[][];
78
82
  }
79
83
 
80
84
  const CONTRACT_ERROR: Record<ContractDirection, string> = {
@@ -149,6 +153,7 @@ export function resolveBoundContract(
149
153
  let compiled: { validate(value: unknown): void } | undefined;
150
154
  let paths: string[][] | undefined;
151
155
  let scalars: DeclaredScalarPath[] | undefined;
156
+ let sensitive: string[][] | undefined;
152
157
 
153
158
  const resolve = (): { validate(value: unknown): void } => {
154
159
  if (compiled !== undefined) return compiled;
@@ -193,6 +198,9 @@ export function resolveBoundContract(
193
198
  const stripped = withLiveValuesSkipped(projected, factory.resolveRef);
194
199
  paths = defaultBearingPaths(stripped, factory.resolveRef);
195
200
  scalars = declaredScalarPaths(stripped, factory.resolveRef);
201
+ // Read off the STRIPPED schema like the other two, so a marked node behind a
202
+ // live value is not reported: nothing walks into a stream to redact it.
203
+ sensitive = sensitivePaths(stripped, factory.resolveRef);
196
204
  // Compile by NAME whenever the declaration is one, so the type's CEL
197
205
  // `rules:` are composed in — including when a stream had to be stripped, in
198
206
  // which case the stream-bearing properties are dropped from the schema the
@@ -220,6 +228,10 @@ export function resolveBoundContract(
220
228
  resolve();
221
229
  return scalars ?? [];
222
230
  },
231
+ sensitivePaths: () => {
232
+ resolve();
233
+ return sensitive ?? [];
234
+ },
223
235
  };
224
236
  }
225
237
 
package/src/kernel.ts CHANGED
@@ -53,6 +53,7 @@ import { ResourceContextImpl } from "./resource-context.js";
53
53
  import { mintResourceHandle } from "./resource-handle.js";
54
54
  import { bindEffectOwner } from "./effect-scope.js";
55
55
  import { declarationOfInstance, recordInstanceDeclaration } from "./instance-declaration.js";
56
+ import { recordSensitivePaths } from "./instance-sensitive-paths.js";
56
57
  import { nodeHostVersions } from "./host-versions.js";
57
58
  import { nodeCelHandlers } from "./cel-handlers.js";
58
59
  import { parseRef, seedInvokeSource } from "./invoke-dispatch.js";
@@ -1479,6 +1480,26 @@ export class Kernel implements IKernel {
1479
1480
 
1480
1481
  if (!runtime.length) return { instance, ctx, resource: processedResource };
1481
1482
 
1483
+ // Runtime eval paths are expanded against a CALL's inputs, so they need a
1484
+ // call. `invoke` is the only entry point that takes any — `run()` and
1485
+ // `provide()` are parameterless — so a kind that declares one of these paths
1486
+ // and has no `invoke()` has annotated something nothing can ever expand: the
1487
+ // value stays a compiled expression for the life of the resource. Reported
1488
+ // here rather than dereferenced: this used to be a non-null assertion, and it
1489
+ // failed as `undefined is not an object (evaluating 'instance.invoke.bind')`
1490
+ // against the kernel's own source, naming neither the kind nor the field.
1491
+ // Method presence rather than declared capability, because the kernel
1492
+ // dispatches on the method — a Provider that implements `invoke` is bound
1493
+ // exactly like an Invocable.
1494
+ if (typeof instance.invoke !== "function") {
1495
+ throw new RuntimeError(
1496
+ "ERR_RUNTIME_EVAL_WITHOUT_INVOKE",
1497
+ `Kind ${resolvedKind} declares 'x-telo-eval: runtime' (at ${runtime.join(", ")}), but its resources have no invoke() — ` +
1498
+ `runtime evaluation expands a call's inputs, and run() / provide() take none. ` +
1499
+ `Use 'x-telo-eval: compile' for a value resolved once when the resource is created, or give the kind an invocable controller.`,
1500
+ );
1501
+ }
1502
+
1482
1503
  // Override invoke in-place so all lifecycle methods (init/invoke/teardown/snapshot)
1483
1504
  // share the same `this`. A wrapper object would split identity: state mutated by
1484
1505
  // init() on the wrapper would be invisible to the original invoke(), which still
@@ -1487,7 +1508,7 @@ export class Kernel implements IKernel {
1487
1508
  // Every argument is forwarded: `invoke(inputs, ctx)` carries the
1488
1509
  // InvokeContext (cancellation, tracing) as its second parameter, and a
1489
1510
  // wrapper that declares only `inputs` silently drops it.
1490
- const originalInvoke = instance.invoke!.bind(instance);
1511
+ const originalInvoke = instance.invoke.bind(instance);
1491
1512
  instance.invoke = async (inputs: any, ...rest: unknown[]) => {
1492
1513
  const expanded = evalContext.expandPaths(inputs as Record<string, unknown>, runtime);
1493
1514
  return (originalInvoke as (i: any, ...r: unknown[]) => Promise<unknown>)(expanded, ...rest);
@@ -1564,6 +1585,13 @@ export class Kernel implements IKernel {
1564
1585
  );
1565
1586
  if (!input && !output) return;
1566
1587
 
1588
+ // Recorded here because this is the only point holding both the instance and
1589
+ // its resolved contract — `bindContract` closes over the contract and drops
1590
+ // it, and the trace site has only the instance. Lazily, so a contract is
1591
+ // still compiled on first dispatch rather than at create time.
1592
+ if (input) recordSensitivePaths(instance, "inputType", () => input.sensitivePaths());
1593
+ if (output) recordSensitivePaths(instance, "outputType", () => output.sensitivePaths());
1594
+
1567
1595
  bindContract(instance, {
1568
1596
  input,
1569
1597
  output,