@kontextmind/kxm 0.7.71 → 0.7.73

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.
@@ -0,0 +1,380 @@
1
+ import { createHash } from "node:crypto";
2
+ import { KxmConfigError, kxmCanonicalJson, syncEventSchemaErrors, type JsonValue } from "./project-config.ts";
3
+ import { redactSecrets } from "./redact.ts";
4
+ import type { KxmRunEvent } from "./runtime-store.ts";
5
+
6
+ /*
7
+ * Runtime → hub sync transform (`docs/contracts/synchronization.md`).
8
+ *
9
+ * A local run event never reaches the outbox. This module builds a new
10
+ * `kxm.sync-event.v1` object from it: pick allowlisted fields, rebuild every
11
+ * nested record key by key, replace registered secret values and credential
12
+ * shapes, strip absolute paths and control characters, bound text, then
13
+ * validate the result. Anything the allowlist does not name is dropped and
14
+ * recorded by name in `redaction.fieldsOmitted`; its value goes nowhere.
15
+ */
16
+
17
+ export const KXM_SYNC_EVENT_SCHEMA = "kxm.sync-event.v1";
18
+ export const KXM_SYNC_REDACTOR_VERSION = "redactor-v1";
19
+
20
+ /** The contract's default event policy. Normal projects never materialize it. */
21
+ export const KXM_DEFAULT_SYNC_POLICY = Object.freeze({
22
+ prompts: "title-only",
23
+ results: "bounded-summary",
24
+ evidence: "references",
25
+ artifacts: "metadata",
26
+ fileChanges: "paths-only",
27
+ rawLogs: false,
28
+ diffs: false,
29
+ environmentValues: false,
30
+ });
31
+
32
+ /** The policy revision recorded on every outbox transform. */
33
+ export const KXM_DEFAULT_SYNC_POLICY_REVISION = `sha256:${createHash("sha256")
34
+ .update(kxmCanonicalJson(KXM_DEFAULT_SYNC_POLICY as unknown as JsonValue), "utf8")
35
+ .digest("hex")}`;
36
+
37
+ export interface KxmSyncEvent {
38
+ schema: typeof KXM_SYNC_EVENT_SCHEMA;
39
+ sourceEventId: string;
40
+ eventType: string;
41
+ projectId: string;
42
+ runId: string;
43
+ homeRuntimeId: string;
44
+ sequence: number;
45
+ occurredAt: string;
46
+ configRevision: string;
47
+ memoryRevision: string;
48
+ executorPolicyRevision: string;
49
+ toolPolicyRevision: string;
50
+ syncPolicyRevision: string;
51
+ redaction: { version: string; fieldsOmitted: string[]; valuesReplaced: number };
52
+ payload: Record<string, unknown>;
53
+ }
54
+
55
+ /** Shortest value the redactor will register: a two-letter "secret" would
56
+ * shred ordinary words out of every summary. */
57
+ const MIN_REGISTERED_SECRET_CHARS = 4;
58
+ const REDACTED = "[redacted]";
59
+ const PATH_REDACTED = "[path]";
60
+
61
+ const CREDENTIAL_SHAPES: RegExp[] = [
62
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
63
+ /\bssh-(?:rsa|ed25519|dss|ecdsa-[a-z0-9-]+) AAAA[0-9A-Za-z+/=]+/g,
64
+ /\bAKIA[0-9A-Z]{16}\b/g,
65
+ /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g,
66
+ /\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*\S+/gi,
67
+ ];
68
+ // URL user-info (`https://user:token@host`) keeps the scheme and host.
69
+ const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi;
70
+ const ABSOLUTE_PATHS: RegExp[] = [
71
+ /\\\\[^\s\\"'`]+\\[^\s"'`]*/g, // UNC
72
+ /\b[A-Za-z]:[\\/][^\s"'`]*/g, // Windows drive
73
+ /(?<![\w.~:/-])~[\\/][^\s"'`]*/g, // home
74
+ /(?<![\w.~:/-])\/(?:[\w.@%+-]+\/)*[\w.@%+-]+\/?/g, // POSIX
75
+ ];
76
+ // eslint-disable-next-line no-control-regex
77
+ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/g;
78
+
79
+ /**
80
+ * In-memory redactor. The Runtime registers resolved secret values before a
81
+ * transform; they are never persisted to support later redaction, so a
82
+ * process restart forgets them.
83
+ */
84
+ export class KxmSyncRedactor {
85
+ private readonly values = new Set<string>();
86
+
87
+ register(value: string): void {
88
+ const trimmed = value.trim();
89
+ if (trimmed.length >= MIN_REGISTERED_SECRET_CHARS) this.values.add(trimmed);
90
+ }
91
+
92
+ get size(): number {
93
+ return this.values.size;
94
+ }
95
+
96
+ /** Scrub one free-text value; `replaced` counts every substitution. */
97
+ scrub(input: string): { text: string; replaced: number } {
98
+ let replaced = 0;
99
+ let text = input;
100
+ // Longest first, so a registered value containing another is removed whole.
101
+ for (const secret of [...this.values].sort((left, right) => right.length - left.length)) {
102
+ const parts = text.split(secret);
103
+ if (parts.length > 1) {
104
+ replaced += parts.length - 1;
105
+ text = parts.join(REDACTED);
106
+ }
107
+ }
108
+ const substitute = (pattern: RegExp, replacement: string | ((match: string, ...groups: string[]) => string)): void => {
109
+ text = text.replace(pattern, (...args: unknown[]) => {
110
+ replaced += 1;
111
+ return typeof replacement === "string" ? replacement : replacement(...(args as [string, ...string[]]));
112
+ });
113
+ };
114
+ for (const pattern of CREDENTIAL_SHAPES) substitute(pattern, REDACTED);
115
+ substitute(URL_USERINFO, (_match, scheme) => `${scheme}${REDACTED}@`);
116
+ const shaped = redactSecrets(text);
117
+ if (shaped !== text) {
118
+ replaced += shaped.split(REDACTED).length - text.split(REDACTED).length;
119
+ text = shaped;
120
+ }
121
+ for (const pattern of ABSOLUTE_PATHS) substitute(pattern, PATH_REDACTED);
122
+ text = text.replace(CONTROL_CHARACTERS, " ").replace(/\s+/g, " ").trim();
123
+ return { text, replaced };
124
+ }
125
+ }
126
+
127
+ type Picked = Record<string, unknown>;
128
+
129
+ function isRecord(value: unknown): value is Record<string, unknown> {
130
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
131
+ }
132
+
133
+ /** Copy only the named keys whose values have the expected JS type. */
134
+ function pickScalars(source: unknown, keys: readonly string[], redactor?: KxmSyncRedactor): Picked | undefined {
135
+ if (!isRecord(source)) return undefined;
136
+ const result: Picked = {};
137
+ for (const key of keys) {
138
+ const value = source[key];
139
+ if (typeof value === "number" || typeof value === "boolean") {
140
+ result[key] = value;
141
+ } else if (typeof value === "string") {
142
+ // Strings are scrubbed even in "scalar" positions: a secret echoed into
143
+ // a lease operation or receipt query must not survive to the hub.
144
+ if (redactor) {
145
+ const scrubbed = redactor.scrub(value);
146
+ if (scrubbed.text.length > 0) result[key] = scrubbed.text.slice(0, 200);
147
+ } else {
148
+ result[key] = value;
149
+ }
150
+ }
151
+ }
152
+ return Object.keys(result).length > 0 ? result : undefined;
153
+ }
154
+
155
+ class SyncPayloadBuilder {
156
+ readonly payload: Picked = {};
157
+ readonly omitted = new Set<string>();
158
+ replaced = 0;
159
+ readonly redactor: KxmSyncRedactor;
160
+
161
+ constructor(redactor: KxmSyncRedactor) {
162
+ this.redactor = redactor;
163
+ }
164
+
165
+ /** Bounded, scrubbed free text; empty after scrubbing means absent. */
166
+ text(value: unknown, max: number): string | undefined {
167
+ if (typeof value !== "string") return undefined;
168
+ const { text, replaced } = this.redactor.scrub(value);
169
+ this.replaced += replaced;
170
+ if (text.length === 0) return undefined;
171
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
172
+ }
173
+
174
+ /** A nested record rebuilt key by key, with the named keys scrubbed as text. */
175
+ record(source: unknown, keys: readonly string[], textKeys: Readonly<Record<string, number>> = {}): Picked | undefined {
176
+ const scalars = pickScalars(source, keys.filter((key) => !(key in textKeys)));
177
+ const result: Picked = { ...(scalars ?? {}) };
178
+ if (isRecord(source)) {
179
+ for (const [key, max] of Object.entries(textKeys)) {
180
+ const value = this.text(source[key], max);
181
+ if (value !== undefined) result[key] = value;
182
+ }
183
+ }
184
+ return Object.keys(result).length > 0 ? result : undefined;
185
+ }
186
+
187
+ set(key: string, value: unknown): void {
188
+ if (value !== undefined) this.payload[key] = value;
189
+ }
190
+ }
191
+
192
+ const SCALAR_FIELDS = [
193
+ "workflowId", "promptHash", "status", "previousStatus", "outcome", "stepId", "stepAttempt", "fromStepId", "toStepId",
194
+ "assignmentId", "attemptId", "agentId", "instanceNo", "scopeEpoch", "resolutionAction",
195
+ ] as const;
196
+ const REF_KEYS = ["id", "kind", "hash", "status", "producerId"] as const;
197
+ const ARTIFACT_KEYS = ["id", "kind", "hash", "size", "classification", "availability"] as const;
198
+ const LEASE_KEYS = ["id", "fencingToken", "acquiredAt", "expiresAt", "operation"] as const;
199
+ const MAX_REFS = 64;
200
+
201
+ /** Every source payload key the transform knows how to carry (possibly renamed). */
202
+ const CARRIED_FIELDS = new Set<string>([
203
+ ...SCALAR_FIELDS, "displayTitle", "summary", "reason", "executor", "model", "timing", "usage", "evidenceRefs",
204
+ "artifacts", "receipt", "error", "effect", "lease", "actor", "resolvedBy", "suppliedRefs", "counts",
205
+ ]);
206
+
207
+ /** Fields the sync schema makes conditionally required; a degraded object keeps them. */
208
+ const CONTROL_FIELDS = ["effect", "lease", "actor", "resolvedBy", "resolutionAction", "reason", "suppliedRefs", "outcome"] as const;
209
+
210
+ function buildPayload(source: Record<string, unknown>, builder: SyncPayloadBuilder): void {
211
+ for (const key of SCALAR_FIELDS) {
212
+ const value = source[key];
213
+ if (typeof value === "string" || typeof value === "number") builder.set(key, value);
214
+ }
215
+ builder.set("displayTitle", builder.text(source.displayTitle, 200));
216
+ builder.set("summary", builder.text(source.summary, 1000));
217
+ if (source.reason !== undefined) {
218
+ // `reason` is required on resolutions; a reason scrubbed to nothing still says one was given.
219
+ builder.set("reason", builder.text(source.reason, 1000) ?? REDACTED);
220
+ }
221
+ builder.set("executor", builder.record(source.executor, ["id", "kind", "harness"], {
222
+ executorVersion: 128, harnessVersion: 128, helperGeneration: 256,
223
+ }));
224
+ const model = builder.record(source.model, ["profile", "provider"], { model: 200, thinking: 64 });
225
+ if (model && isRecord(source.model) && Array.isArray(source.model.tags)) {
226
+ model.tags = source.model.tags.filter((tag): tag is string => typeof tag === "string").slice(0, 32);
227
+ }
228
+ builder.set("model", model);
229
+ builder.set("timing", builder.record(source.timing, ["startedAt", "finishedAt", "durationMs", "queueMs", "agentMs"]));
230
+ builder.set("usage", builder.record(source.usage, [
231
+ "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "cost", "currency", "costKind",
232
+ ]));
233
+ const refs = (value: unknown, keys: readonly string[]): Picked[] | undefined => Array.isArray(value)
234
+ ? value.slice(0, MAX_REFS).map((entry) => pickScalars(entry, keys)).filter((entry): entry is Picked => entry !== undefined)
235
+ : undefined;
236
+ builder.set("evidenceRefs", refs(source.evidenceRefs, REF_KEYS));
237
+ builder.set("suppliedRefs", refs(source.suppliedRefs, REF_KEYS));
238
+ builder.set("artifacts", refs(source.artifacts, ARTIFACT_KEYS));
239
+ if (isRecord(source.receipt)) {
240
+ // The local receipt may carry a URL (possibly secret-bearing); the safe receipt never does.
241
+ const receipt = builder.record(source.receipt, ["provider", "kind", "hash", "observedAt"], { id: 512 });
242
+ if (receipt) builder.set("receipts", [receipt]);
243
+ if (source.receipt.url !== undefined) builder.omitted.add("receipt.url");
244
+ }
245
+ builder.set("error", builder.record(source.error, ["class", "retryable"], { component: 128 }));
246
+ if (isRecord(source.effect)) {
247
+ const effect = pickScalars(source.effect, ["id", "idempotencyKeyHash"], builder.redactor) ?? {};
248
+ const policy = pickScalars(source.effect.policy, ["class", "sharedMutable", "idempotencyKey", "receiptQuery"], builder.redactor);
249
+ if (policy) effect.policy = policy;
250
+ builder.set("effect", effect);
251
+ }
252
+ builder.set("lease", pickScalars(source.lease, LEASE_KEYS, builder.redactor));
253
+ builder.set("actor", builder.record(source.actor, ["kind"], { id: 200 }));
254
+ builder.set("resolvedBy", builder.record(source.resolvedBy, ["kind"], { id: 200 }));
255
+ if (isRecord(source.counts)) {
256
+ const counts = Object.fromEntries(Object.entries(source.counts)
257
+ .filter((entry): entry is [string, number] => Number.isInteger(entry[1]) && (entry[1] as number) >= 0)
258
+ .slice(0, 32));
259
+ builder.set("counts", counts);
260
+ }
261
+ }
262
+
263
+ export interface KxmSyncTransformOptions {
264
+ redactor?: KxmSyncRedactor;
265
+ policyRevision?: string;
266
+ }
267
+
268
+ function envelope(
269
+ event: KxmRunEvent,
270
+ policyRevision: string,
271
+ payload: Picked,
272
+ fieldsOmitted: Iterable<string>,
273
+ valuesReplaced: number,
274
+ ): KxmSyncEvent {
275
+ return {
276
+ schema: KXM_SYNC_EVENT_SCHEMA,
277
+ sourceEventId: event.eventId,
278
+ eventType: event.eventType,
279
+ projectId: event.projectId,
280
+ runId: event.runId,
281
+ homeRuntimeId: event.homeRuntimeId,
282
+ sequence: event.sequence,
283
+ occurredAt: event.occurredAt,
284
+ configRevision: event.configRevision,
285
+ memoryRevision: event.memoryRevision,
286
+ executorPolicyRevision: event.executorPolicyRevision,
287
+ toolPolicyRevision: event.toolPolicyRevision,
288
+ syncPolicyRevision: policyRevision,
289
+ redaction: {
290
+ version: KXM_SYNC_REDACTOR_VERSION,
291
+ fieldsOmitted: [...new Set(fieldsOmitted)].sort().slice(0, 128),
292
+ valuesReplaced: Math.min(valuesReplaced, 100_000),
293
+ },
294
+ payload,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Derive the sync-safe object for one committed local event.
300
+ *
301
+ * If the full derivation does not validate (a field the transform carries
302
+ * fell outside its sync bounds), a degraded object keeps only identity and
303
+ * the control fields the schema requires and marks `degraded: true`, so the
304
+ * run sequence stays gapless on the hub. If even that fails the event is
305
+ * refused with `sync_event_invalid`: nothing unvalidated enters the outbox.
306
+ */
307
+ export function deriveKxmSyncEvent(event: KxmRunEvent, options: KxmSyncTransformOptions = {}): KxmSyncEvent {
308
+ const redactor = options.redactor ?? new KxmSyncRedactor();
309
+ const policyRevision = options.policyRevision ?? KXM_DEFAULT_SYNC_POLICY_REVISION;
310
+ const source = isRecord(event.payload) ? event.payload : {};
311
+ const builder = new SyncPayloadBuilder(redactor);
312
+ buildPayload(source, builder);
313
+ const omitted = new Set(builder.omitted);
314
+ for (const key of Object.keys(source)) if (!CARRIED_FIELDS.has(key)) omitted.add(key);
315
+ if ((event as unknown as Record<string, unknown>).protectedPayload !== undefined) omitted.add("protectedPayload");
316
+
317
+ const payload = Object.keys(builder.payload).length > 0 ? builder.payload : { counts: {} };
318
+ const full = envelope(event, policyRevision, payload, omitted, builder.replaced);
319
+ if (syncEventSchemaErrors(full) === undefined) return full;
320
+
321
+ const degradedPayload: Picked = { degraded: true };
322
+ for (const key of CONTROL_FIELDS) {
323
+ // A scrubbed-required field that was REMOVED (scrubbing produced an empty
324
+ // string and text() returned undefined) must be restored with a valid
325
+ // placeholder, not left absent — the schema requires it and a missing
326
+ // field aborts the caller's transaction with sync_event_invalid.
327
+ const value = builder.payload[key];
328
+ // A scrubbed-required field that was removed entirely (text() returned
329
+ // undefined) or is empty/whitespace must be restored with a valid
330
+ // placeholder. For nested objects, MERGE the surviving fields from the
331
+ // builder with [redacted] for any field the original had that scrubbing
332
+ // removed — not just the top level.
333
+ const restoreNested = (built: unknown, orig: unknown): unknown => {
334
+ if (!isRecord(orig)) return built !== undefined ? built : "[redacted]";
335
+ const result: Picked = {};
336
+ const builtRecord = isRecord(built) ? built : {};
337
+ for (const [k, v] of Object.entries(orig)) {
338
+ const builtValue = builtRecord[k];
339
+ if (builtValue === undefined || (typeof builtValue === "string" && builtValue.trim().length === 0)) {
340
+ result[k] = isRecord(v) ? restoreNested(undefined, v) : "[redacted]";
341
+ } else {
342
+ result[k] = builtValue;
343
+ }
344
+ }
345
+ return result;
346
+ };
347
+ if (value === undefined) {
348
+ const sourceValue = source[key];
349
+ if (sourceValue !== undefined) {
350
+ degradedPayload[key] = restoreNested(undefined, sourceValue);
351
+ }
352
+ } else if (typeof value === "string" && value.trim().length === 0) {
353
+ degradedPayload[key] = "[redacted]";
354
+ } else if (isRecord(value)) {
355
+ degradedPayload[key] = restoreNested(value, source[key]);
356
+ } else {
357
+ degradedPayload[key] = value;
358
+ }
359
+ }
360
+ const degradedOmitted = new Set(omitted);
361
+ for (const key of Object.keys(builder.payload)) if (!(key in degradedPayload)) degradedOmitted.add(key);
362
+ const degraded = envelope(event, policyRevision, degradedPayload, degradedOmitted, builder.replaced);
363
+ const errors = syncEventSchemaErrors(degraded);
364
+ if (errors === undefined) return degraded;
365
+ throw new KxmConfigError([{
366
+ phase: "schema",
367
+ code: "sync_event_invalid",
368
+ file: `${event.runId}#${event.sequence}`,
369
+ message: `event cannot be derived into kxm.sync-event.v1: ${errors}`,
370
+ }]);
371
+ }
372
+
373
+ /** The exact bytes of a sync object: what the outbox stores and the hub compares. */
374
+ export function kxmSyncEventBytes(event: KxmSyncEvent): string {
375
+ return kxmCanonicalJson(event as unknown as JsonValue);
376
+ }
377
+
378
+ export function kxmSyncEventHash(bytes: string): string {
379
+ return `sha256:${createHash("sha256").update(bytes, "utf8").digest("hex")}`;
380
+ }