UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

381 lines (350 loc) • 16.6 kB
import { createHash } from "node:crypto"; import { KxmConfigError, kxmCanonicalJson, syncEventSchemaErrors, type JsonValue } from "./project-config.ts"; import { redactSecrets } from "./redact.ts"; import type { KxmRunEvent } from "./runtime-store.ts"; /* * Runtime → hub sync transform (`docs/contracts/synchronization.md`). * * A local run event never reaches the outbox. This module builds a new * `kxm.sync-event.v1` object from it: pick allowlisted fields, rebuild every * nested record key by key, replace registered secret values and credential * shapes, strip absolute paths and control characters, bound text, then * validate the result. Anything the allowlist does not name is dropped and * recorded by name in `redaction.fieldsOmitted`; its value goes nowhere. */ export const KXM_SYNC_EVENT_SCHEMA = "kxm.sync-event.v1"; export const KXM_SYNC_REDACTOR_VERSION = "redactor-v1"; /** The contract's default event policy. Normal projects never materialize it. */ export const KXM_DEFAULT_SYNC_POLICY = Object.freeze({ prompts: "title-only", results: "bounded-summary", evidence: "references", artifacts: "metadata", fileChanges: "paths-only", rawLogs: false, diffs: false, environmentValues: false, }); /** The policy revision recorded on every outbox transform. */ export const KXM_DEFAULT_SYNC_POLICY_REVISION = `sha256:${createHash("sha256") .update(kxmCanonicalJson(KXM_DEFAULT_SYNC_POLICY as unknown as JsonValue), "utf8") .digest("hex")}`; export interface KxmSyncEvent { schema: typeof KXM_SYNC_EVENT_SCHEMA; sourceEventId: string; eventType: string; projectId: string; runId: string; homeRuntimeId: string; sequence: number; occurredAt: string; configRevision: string; memoryRevision: string; executorPolicyRevision: string; toolPolicyRevision: string; syncPolicyRevision: string; redaction: { version: string; fieldsOmitted: string[]; valuesReplaced: number }; payload: Record<string, unknown>; } /** Shortest value the redactor will register: a two-letter "secret" would * shred ordinary words out of every summary. */ const MIN_REGISTERED_SECRET_CHARS = 4; const REDACTED = "[redacted]"; const PATH_REDACTED = "[path]"; const CREDENTIAL_SHAPES: RegExp[] = [ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, /\bssh-(?:rsa|ed25519|dss|ecdsa-[a-z0-9-]+) AAAA[0-9A-Za-z+/=]+/g, /\bAKIA[0-9A-Z]{16}\b/g, /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, /\b(?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*\S+/gi, ]; // URL user-info (`https://user:token@host`) keeps the scheme and host. const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi; const ABSOLUTE_PATHS: RegExp[] = [ /\\\\[^\s\\"'`]+\\[^\s"'`]*/g, // UNC /\b[A-Za-z]:[\\/][^\s"'`]*/g, // Windows drive /(?<![\w.~:/-])~[\\/][^\s"'`]*/g, // home /(?<![\w.~:/-])\/(?:[\w.@%+-]+\/)*[\w.@%+-]+\/?/g, // POSIX ]; // eslint-disable-next-line no-control-regex const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/g; /** * In-memory redactor. The Runtime registers resolved secret values before a * transform; they are never persisted to support later redaction, so a * process restart forgets them. */ export class KxmSyncRedactor { private readonly values = new Set<string>(); register(value: string): void { const trimmed = value.trim(); if (trimmed.length >= MIN_REGISTERED_SECRET_CHARS) this.values.add(trimmed); } get size(): number { return this.values.size; } /** Scrub one free-text value; `replaced` counts every substitution. */ scrub(input: string): { text: string; replaced: number } { let replaced = 0; let text = input; // Longest first, so a registered value containing another is removed whole. for (const secret of [...this.values].sort((left, right) => right.length - left.length)) { const parts = text.split(secret); if (parts.length > 1) { replaced += parts.length - 1; text = parts.join(REDACTED); } } const substitute = (pattern: RegExp, replacement: string | ((match: string, ...groups: string[]) => string)): void => { text = text.replace(pattern, (...args: unknown[]) => { replaced += 1; return typeof replacement === "string" ? replacement : replacement(...(args as [string, ...string[]])); }); }; for (const pattern of CREDENTIAL_SHAPES) substitute(pattern, REDACTED); substitute(URL_USERINFO, (_match, scheme) => `${scheme}${REDACTED}@`); const shaped = redactSecrets(text); if (shaped !== text) { replaced += shaped.split(REDACTED).length - text.split(REDACTED).length; text = shaped; } for (const pattern of ABSOLUTE_PATHS) substitute(pattern, PATH_REDACTED); text = text.replace(CONTROL_CHARACTERS, " ").replace(/\s+/g, " ").trim(); return { text, replaced }; } } type Picked = Record<string, unknown>; function isRecord(value: unknown): value is Record<string, unknown> { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } /** Copy only the named keys whose values have the expected JS type. */ function pickScalars(source: unknown, keys: readonly string[], redactor?: KxmSyncRedactor): Picked | undefined { if (!isRecord(source)) return undefined; const result: Picked = {}; for (const key of keys) { const value = source[key]; if (typeof value === "number" || typeof value === "boolean") { result[key] = value; } else if (typeof value === "string") { // Strings are scrubbed even in "scalar" positions: a secret echoed into // a lease operation or receipt query must not survive to the hub. if (redactor) { const scrubbed = redactor.scrub(value); if (scrubbed.text.length > 0) result[key] = scrubbed.text.slice(0, 200); } else { result[key] = value; } } } return Object.keys(result).length > 0 ? result : undefined; } class SyncPayloadBuilder { readonly payload: Picked = {}; readonly omitted = new Set<string>(); replaced = 0; readonly redactor: KxmSyncRedactor; constructor(redactor: KxmSyncRedactor) { this.redactor = redactor; } /** Bounded, scrubbed free text; empty after scrubbing means absent. */ text(value: unknown, max: number): string | undefined { if (typeof value !== "string") return undefined; const { text, replaced } = this.redactor.scrub(value); this.replaced += replaced; if (text.length === 0) return undefined; return text.length > max ? `${text.slice(0, max - 1)}…` : text; } /** A nested record rebuilt key by key, with the named keys scrubbed as text. */ record(source: unknown, keys: readonly string[], textKeys: Readonly<Record<string, number>> = {}): Picked | undefined { const scalars = pickScalars(source, keys.filter((key) => !(key in textKeys))); const result: Picked = { ...(scalars ?? {}) }; if (isRecord(source)) { for (const [key, max] of Object.entries(textKeys)) { const value = this.text(source[key], max); if (value !== undefined) result[key] = value; } } return Object.keys(result).length > 0 ? result : undefined; } set(key: string, value: unknown): void { if (value !== undefined) this.payload[key] = value; } } const SCALAR_FIELDS = [ "workflowId", "promptHash", "status", "previousStatus", "outcome", "stepId", "stepAttempt", "fromStepId", "toStepId", "assignmentId", "attemptId", "agentId", "instanceNo", "scopeEpoch", "resolutionAction", ] as const; const REF_KEYS = ["id", "kind", "hash", "status", "producerId"] as const; const ARTIFACT_KEYS = ["id", "kind", "hash", "size", "classification", "availability"] as const; const LEASE_KEYS = ["id", "fencingToken", "acquiredAt", "expiresAt", "operation"] as const; const MAX_REFS = 64; /** Every source payload key the transform knows how to carry (possibly renamed). */ const CARRIED_FIELDS = new Set<string>([ ...SCALAR_FIELDS, "displayTitle", "summary", "reason", "executor", "model", "timing", "usage", "evidenceRefs", "artifacts", "receipt", "error", "effect", "lease", "actor", "resolvedBy", "suppliedRefs", "counts", ]); /** Fields the sync schema makes conditionally required; a degraded object keeps them. */ const CONTROL_FIELDS = ["effect", "lease", "actor", "resolvedBy", "resolutionAction", "reason", "suppliedRefs", "outcome"] as const; function buildPayload(source: Record<string, unknown>, builder: SyncPayloadBuilder): void { for (const key of SCALAR_FIELDS) { const value = source[key]; if (typeof value === "string" || typeof value === "number") builder.set(key, value); } builder.set("displayTitle", builder.text(source.displayTitle, 200)); builder.set("summary", builder.text(source.summary, 1000)); if (source.reason !== undefined) { // `reason` is required on resolutions; a reason scrubbed to nothing still says one was given. builder.set("reason", builder.text(source.reason, 1000) ?? REDACTED); } builder.set("executor", builder.record(source.executor, ["id", "kind", "harness"], { executorVersion: 128, harnessVersion: 128, helperGeneration: 256, })); const model = builder.record(source.model, ["profile", "provider"], { model: 200, thinking: 64 }); if (model && isRecord(source.model) && Array.isArray(source.model.tags)) { model.tags = source.model.tags.filter((tag): tag is string => typeof tag === "string").slice(0, 32); } builder.set("model", model); builder.set("timing", builder.record(source.timing, ["startedAt", "finishedAt", "durationMs", "queueMs", "agentMs"])); builder.set("usage", builder.record(source.usage, [ "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "cost", "currency", "costKind", ])); const refs = (value: unknown, keys: readonly string[]): Picked[] | undefined => Array.isArray(value) ? value.slice(0, MAX_REFS).map((entry) => pickScalars(entry, keys)).filter((entry): entry is Picked => entry !== undefined) : undefined; builder.set("evidenceRefs", refs(source.evidenceRefs, REF_KEYS)); builder.set("suppliedRefs", refs(source.suppliedRefs, REF_KEYS)); builder.set("artifacts", refs(source.artifacts, ARTIFACT_KEYS)); if (isRecord(source.receipt)) { // The local receipt may carry a URL (possibly secret-bearing); the safe receipt never does. const receipt = builder.record(source.receipt, ["provider", "kind", "hash", "observedAt"], { id: 512 }); if (receipt) builder.set("receipts", [receipt]); if (source.receipt.url !== undefined) builder.omitted.add("receipt.url"); } builder.set("error", builder.record(source.error, ["class", "retryable"], { component: 128 })); if (isRecord(source.effect)) { const effect = pickScalars(source.effect, ["id", "idempotencyKeyHash"], builder.redactor) ?? {}; const policy = pickScalars(source.effect.policy, ["class", "sharedMutable", "idempotencyKey", "receiptQuery"], builder.redactor); if (policy) effect.policy = policy; builder.set("effect", effect); } builder.set("lease", pickScalars(source.lease, LEASE_KEYS, builder.redactor)); builder.set("actor", builder.record(source.actor, ["kind"], { id: 200 })); builder.set("resolvedBy", builder.record(source.resolvedBy, ["kind"], { id: 200 })); if (isRecord(source.counts)) { const counts = Object.fromEntries(Object.entries(source.counts) .filter((entry): entry is [string, number] => Number.isInteger(entry[1]) && (entry[1] as number) >= 0) .slice(0, 32)); builder.set("counts", counts); } } export interface KxmSyncTransformOptions { redactor?: KxmSyncRedactor; policyRevision?: string; } function envelope( event: KxmRunEvent, policyRevision: string, payload: Picked, fieldsOmitted: Iterable<string>, valuesReplaced: number, ): KxmSyncEvent { return { schema: KXM_SYNC_EVENT_SCHEMA, sourceEventId: event.eventId, eventType: event.eventType, projectId: event.projectId, runId: event.runId, homeRuntimeId: event.homeRuntimeId, sequence: event.sequence, occurredAt: event.occurredAt, configRevision: event.configRevision, memoryRevision: event.memoryRevision, executorPolicyRevision: event.executorPolicyRevision, toolPolicyRevision: event.toolPolicyRevision, syncPolicyRevision: policyRevision, redaction: { version: KXM_SYNC_REDACTOR_VERSION, fieldsOmitted: [...new Set(fieldsOmitted)].sort().slice(0, 128), valuesReplaced: Math.min(valuesReplaced, 100_000), }, payload, }; } /** * Derive the sync-safe object for one committed local event. * * If the full derivation does not validate (a field the transform carries * fell outside its sync bounds), a degraded object keeps only identity and * the control fields the schema requires and marks `degraded: true`, so the * run sequence stays gapless on the hub. If even that fails the event is * refused with `sync_event_invalid`: nothing unvalidated enters the outbox. */ export function deriveKxmSyncEvent(event: KxmRunEvent, options: KxmSyncTransformOptions = {}): KxmSyncEvent { const redactor = options.redactor ?? new KxmSyncRedactor(); const policyRevision = options.policyRevision ?? KXM_DEFAULT_SYNC_POLICY_REVISION; const source = isRecord(event.payload) ? event.payload : {}; const builder = new SyncPayloadBuilder(redactor); buildPayload(source, builder); const omitted = new Set(builder.omitted); for (const key of Object.keys(source)) if (!CARRIED_FIELDS.has(key)) omitted.add(key); if ((event as unknown as Record<string, unknown>).protectedPayload !== undefined) omitted.add("protectedPayload"); const payload = Object.keys(builder.payload).length > 0 ? builder.payload : { counts: {} }; const full = envelope(event, policyRevision, payload, omitted, builder.replaced); if (syncEventSchemaErrors(full) === undefined) return full; const degradedPayload: Picked = { degraded: true }; for (const key of CONTROL_FIELDS) { // A scrubbed-required field that was REMOVED (scrubbing produced an empty // string and text() returned undefined) must be restored with a valid // placeholder, not left absent — the schema requires it and a missing // field aborts the caller's transaction with sync_event_invalid. const value = builder.payload[key]; // A scrubbed-required field that was removed entirely (text() returned // undefined) or is empty/whitespace must be restored with a valid // placeholder. For nested objects, MERGE the surviving fields from the // builder with [redacted] for any field the original had that scrubbing // removed — not just the top level. const restoreNested = (built: unknown, orig: unknown): unknown => { if (!isRecord(orig)) return built !== undefined ? built : "[redacted]"; const result: Picked = {}; const builtRecord = isRecord(built) ? built : {}; for (const [k, v] of Object.entries(orig)) { const builtValue = builtRecord[k]; if (builtValue === undefined || (typeof builtValue === "string" && builtValue.trim().length === 0)) { result[k] = isRecord(v) ? restoreNested(undefined, v) : "[redacted]"; } else { result[k] = builtValue; } } return result; }; if (value === undefined) { const sourceValue = source[key]; if (sourceValue !== undefined) { degradedPayload[key] = restoreNested(undefined, sourceValue); } } else if (typeof value === "string" && value.trim().length === 0) { degradedPayload[key] = "[redacted]"; } else if (isRecord(value)) { degradedPayload[key] = restoreNested(value, source[key]); } else { degradedPayload[key] = value; } } const degradedOmitted = new Set(omitted); for (const key of Object.keys(builder.payload)) if (!(key in degradedPayload)) degradedOmitted.add(key); const degraded = envelope(event, policyRevision, degradedPayload, degradedOmitted, builder.replaced); const errors = syncEventSchemaErrors(degraded); if (errors === undefined) return degraded; throw new KxmConfigError([{ phase: "schema", code: "sync_event_invalid", file: `${event.runId}#${event.sequence}`, message: `event cannot be derived into kxm.sync-event.v1: ${errors}`, }]); } /** The exact bytes of a sync object: what the outbox stores and the hub compares. */ export function kxmSyncEventBytes(event: KxmSyncEvent): string { return kxmCanonicalJson(event as unknown as JsonValue); } export function kxmSyncEventHash(bytes: string): string { return `sha256:${createHash("sha256").update(bytes, "utf8").digest("hex")}`; }