From c87a14d755a406f50333bf6f8fd782ebd315ddec Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 20:13:20 +0200 Subject: [PATCH 1/8] fix(logging): enforce values-free operational logs --- api/src/job.ts | 4 +- api/src/logger.test.ts | 57 +++++ api/src/logger.ts | 29 ++- api/src/tool-call-socket-proxy.ts | 4 +- service/rollup.config.js | 2 +- service/src/fileServerLogger.ts | 5 +- service/src/logger.test.ts | 97 ++++++++ service/src/logger.ts | 7 +- service/src/toolCallServerLogger.ts | 3 +- service/tsconfig.esm.json | 2 +- service/tsconfig.json | 2 +- shared/operational-log.ts | 354 ++++++++++++++++++++++++++++ 12 files changed, 553 insertions(+), 13 deletions(-) create mode 100644 api/src/logger.test.ts create mode 100644 service/src/logger.test.ts create mode 100644 shared/operational-log.ts diff --git a/api/src/job.ts b/api/src/job.ts index eceffdfd..bc4e1bc0 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -740,7 +740,9 @@ export class Job { this.session = opts.session ?? undefined; this.uuid = opts.session_id ?? nanoid(); this.outputSessionId = opts.output_session_id ?? this.uuid; - this.log = rootLogger.child({ job: this.uuid }); + // Pino serializes child bindings outside the log-method sanitizer, so this + // binding must stay fixed and values-free. + this.log = rootLogger.child({ component: 'job' }); this.runtime = opts.runtime; this.files = opts.files.map((file, i) => ({ id: file.id, diff --git a/api/src/logger.test.ts b/api/src/logger.test.ts new file mode 100644 index 00000000..8694fb1b --- /dev/null +++ b/api/src/logger.test.ts @@ -0,0 +1,57 @@ +import { Writable } from 'node:stream'; +import { describe, expect, test } from 'bun:test'; +import { createOperationalLogger } from './logger'; + +const SENTINEL = 'PRIVATE_api_log_6Rw9mQ2p'; + +describe('Pino operational logging', () => { + test('sanitizes calls and keeps only approved child bindings', () => { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }); + const capture = createOperationalLogger(stream); + + const run: Record = { + durationMs: 17, + output: SENTINEL, + outputBytes: 256, + }; + Object.defineProperty(run, 'throwing', { + enumerable: true, + get() { + throw new Error(SENTINEL); + }, + }); + run.self = run; + const metadata = { + error: Object.assign(new Error(SENTINEL), { code: 'ENOSPC' }), + files: [{ filename: SENTINEL }], + method: 'get', + requestId: SENTINEL, + run, + status: 507, + }; + + capture.error(metadata, SENTINEL); + capture.child({ component: 'job' }).info({ success: true }, SENTINEL); + capture.flush(); + + expect(metadata.run).toBe(run); + expect(run.output).toBe(SENTINEL); + const output = chunks.join(''); + expect(output).not.toContain(SENTINEL); + expect(output).toContain('Operational event'); + expect(output).toContain('"errorCategory":"capacity"'); + expect(output).toContain('"method":"GET"'); + expect(output).toContain('"status":507'); + expect(output).toContain('"durationMs":17'); + expect(output).toContain('"outputBytes":256'); + expect(output).toContain('"count":1'); + expect(output).toContain('"component":"job"'); + expect(output).toContain('"success":true'); + }); +}); diff --git a/api/src/logger.ts b/api/src/logger.ts index d102eff6..fe4bd14b 100644 --- a/api/src/logger.ts +++ b/api/src/logger.ts @@ -1,6 +1,29 @@ import pino from 'pino'; import { config } from './config'; +import { + OPERATIONAL_LOG_MESSAGE, + sanitizeOperationalMetadata, +} from '../../shared/operational-log'; -export const logger = pino({ - level: config.log_level.toLowerCase(), -}); +export function createOperationalLogger(destination?: pino.DestinationStream): pino.Logger { + const options: pino.LoggerOptions = { + level: config.log_level.toLowerCase(), + formatters: { + bindings: sanitizeOperationalMetadata, + }, + hooks: { + logMethod(args, method) { + const metadata = sanitizeOperationalMetadata(args[0]); + if (Object.keys(metadata).length === 0) { + method.apply(this, [OPERATIONAL_LOG_MESSAGE]); + return; + } + method.apply(this, [metadata, OPERATIONAL_LOG_MESSAGE]); + }, + }, + }; + + return destination == null ? pino(options) : pino(options, destination); +} + +export const logger = createOperationalLogger(); diff --git a/api/src/tool-call-socket-proxy.ts b/api/src/tool-call-socket-proxy.ts index 78e689b7..6d432aa5 100644 --- a/api/src/tool-call-socket-proxy.ts +++ b/api/src/tool-call-socket-proxy.ts @@ -662,8 +662,8 @@ if (require.main === module) { .then(started => { handle = started; }) - .catch(error => { - console.error('tool-call socket proxy failed to start', error); + .catch(() => { + console.error('tool-call socket proxy failed to start'); process.exit(1); }); } diff --git a/service/rollup.config.js b/service/rollup.config.js index 2400f72d..870922d8 100644 --- a/service/rollup.config.js +++ b/service/rollup.config.js @@ -38,7 +38,7 @@ export default { commonjs(), typescript({ tsconfig: './tsconfig.esm.json', - include: ['src/**/*.ts', '../shared/telemetry-core.ts'], + include: ['src/**/*.ts', '../shared/operational-log.ts', '../shared/telemetry-core.ts'], sourceMap: true, declaration: false, declarationMap: false, diff --git a/service/src/fileServerLogger.ts b/service/src/fileServerLogger.ts index 5dfd7f5c..139ff3ac 100644 --- a/service/src/fileServerLogger.ts +++ b/service/src/fileServerLogger.ts @@ -1,14 +1,15 @@ import { format, transports, createLogger } from 'winston'; +import { sanitizeOperationalLogInfo } from '../../shared/operational-log'; const logger = createLogger({ level: process.env.LOG_LEVEL ?? 'info', defaultMeta: { service: 'file-server' }, format: format.combine( + format(sanitizeOperationalLogInfo)(), format.timestamp(), - format.errors({ stack: true }), format.json(), ), transports: [new transports.Console()], }); -export default logger; \ No newline at end of file +export default logger; diff --git a/service/src/logger.test.ts b/service/src/logger.test.ts new file mode 100644 index 00000000..44ee5d9a --- /dev/null +++ b/service/src/logger.test.ts @@ -0,0 +1,97 @@ +import { createHash } from 'node:crypto'; +import { Writable } from 'node:stream'; +import { describe, expect, test } from 'bun:test'; +import { createLogger, format, transports } from 'winston'; +import { sanitizeOperationalMetadata } from '../../shared/operational-log'; +import { operationalLogFormat } from './logger'; + +const SENTINEL = 'PRIVATE_service_log_9dQm2V7x'; + +describe('Winston operational logging', () => { + test('keeps only values-free operator metadata without mutating input', async () => { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }); + const capture = createLogger({ + format: format.combine(operationalLogFormat(), format.json()), + transports: [new transports.Stream({ stream })], + }); + + const run: Record = { + durationMs: 12, + output: SENTINEL, + outputBytes: 128, + }; + Object.defineProperty(run, 'throwing', { + enumerable: true, + get() { + throw new Error(SENTINEL); + }, + }); + run.self = run; + const metadata = { + error: Object.assign(new Error(SENTINEL), { code: 'ETIMEDOUT' }), + files: [{ filename: SENTINEL }], + method: 'post', + requestId: SENTINEL, + run, + status: 503, + }; + + capture.error(SENTINEL, metadata); + capture.end(); + await new Promise((resolve) => capture.on('finish', resolve)); + + expect(metadata.run).toBe(run); + expect(run.output).toBe(SENTINEL); + const output = chunks.join(''); + for (const variant of [ + SENTINEL, + encodeURIComponent(SENTINEL), + Buffer.from(SENTINEL).toString('base64'), + createHash('sha256').update(SENTINEL).digest('hex'), + ]) { + expect(output).not.toContain(variant); + } + expect(output).toContain('Operational event'); + expect(output).toContain('"errorCategory":"timeout"'); + expect(output).toContain('"method":"POST"'); + expect(output).toContain('"status":503'); + expect(output).toContain('"durationMs":12'); + expect(output).toContain('"outputBytes":128'); + expect(output).toContain('"count":1'); + }); + + test('is total for circular, repeated, buffered, and throwing values', () => { + const repeated = { count: 2, secret: SENTINEL }; + const value: Record = { + files: Buffer.from(SENTINEL), + metrics: repeated, + run: repeated, + }; + value.self = value; + Object.defineProperty(value, 'status', { + enumerable: true, + get() { + throw new Error(SENTINEL); + }, + }); + + expect(() => sanitizeOperationalMetadata(value)).not.toThrow(); + expect(sanitizeOperationalMetadata(value)).toEqual({ + files: { bytes: Buffer.byteLength(SENTINEL) }, + metrics: { count: 2 }, + run: { count: 2 }, + }); + expect(sanitizeOperationalMetadata({ + error: new Error(SENTINEL), + errorCategory: SENTINEL, + reason: SENTINEL, + stage: SENTINEL, + })).toEqual({ errorCategory: 'internal' }); + }); +}); diff --git a/service/src/logger.ts b/service/src/logger.ts index ee75f053..5bfd4eae 100644 --- a/service/src/logger.ts +++ b/service/src/logger.ts @@ -1,11 +1,16 @@ import { format, transports, createLogger } from 'winston'; +import { sanitizeOperationalLogInfo } from '../../shared/operational-log'; + +// A runtime string no longer carries evidence that it was a source literal. +// Normalize all messages and retain only structured, code-declared metadata. +export const operationalLogFormat = format(sanitizeOperationalLogInfo); const logger = createLogger({ level: process.env.LOG_LEVEL ?? 'info', defaultMeta: { service: process.env.SERVICE_NAME ?? 'service-api' }, format: format.combine( + operationalLogFormat(), format.timestamp(), - format.errors({ stack: true }), format.json(), ), transports: [new transports.Console()], diff --git a/service/src/toolCallServerLogger.ts b/service/src/toolCallServerLogger.ts index 5d188d02..9275290a 100644 --- a/service/src/toolCallServerLogger.ts +++ b/service/src/toolCallServerLogger.ts @@ -1,11 +1,12 @@ import { format, transports, createLogger } from 'winston'; +import { sanitizeOperationalLogInfo } from '../../shared/operational-log'; const logger = createLogger({ level: process.env.LOG_LEVEL ?? 'info', defaultMeta: { service: 'tool-call-server' }, format: format.combine( + format(sanitizeOperationalLogInfo)(), format.timestamp(), - format.errors({ stack: true }), format.json(), ), transports: [new transports.Console()], diff --git a/service/tsconfig.esm.json b/service/tsconfig.esm.json index f251a020..777fd54b 100644 --- a/service/tsconfig.esm.json +++ b/service/tsconfig.esm.json @@ -18,7 +18,7 @@ "@/*": ["src/*"] } }, - "include": ["src/**/*.ts", "../shared/telemetry-core.ts"], + "include": ["src/**/*.ts", "../shared/operational-log.ts", "../shared/telemetry-core.ts"], "exclude": [ "node_modules", "**/*.spec.ts", diff --git a/service/tsconfig.json b/service/tsconfig.json index c3e88635..ca8d4935 100644 --- a/service/tsconfig.json +++ b/service/tsconfig.json @@ -13,7 +13,7 @@ "@/*": ["src/*"] } }, - "include": ["src/**/*.ts", "../shared/telemetry-core.ts"], + "include": ["src/**/*.ts", "../shared/operational-log.ts", "../shared/telemetry-core.ts"], "exclude": [ "node_modules", "**/*.spec.ts", diff --git a/shared/operational-log.ts b/shared/operational-log.ts new file mode 100644 index 00000000..46d9b5a4 --- /dev/null +++ b/shared/operational-log.ts @@ -0,0 +1,354 @@ +export const OPERATIONAL_LOG_MESSAGE = 'Operational event'; + +export const ERROR_CATEGORIES = [ + 'validation', + 'authentication', + 'authorization', + 'configuration', + 'timeout', + 'capacity', + 'dependency', + 'internal', +] as const; + +type ErrorCategory = typeof ERROR_CATEGORIES[number]; + +const BOOLEAN_KEYS = new Set([ + 'authenticated', + 'enabled', + 'hasApiKeyHeader', + 'hasBearerToken', + 'hasSyntheticToken', + 'inherited', + 'liveBackup', + 'modified', + 'present', + 'retryable', + 'success', +]); + +const CONTAINER_KEYS = new Set([ + 'counts', + 'files', + 'limits', + 'metrics', + 'run', + 'usage', +]); + +const COMPONENTS = new Set([ + 'api', + 'egress-gateway', + 'file-server', + 'job', + 'sandbox-api', + 'sandbox-runner', + 'service-api', + 'tool-call-server', + 'worker', +]); + +const NUMBER_KEYS = new Set([ + 'agentCount', + 'attempt', + 'attempts', + 'backoffMs', + 'bodyBytes', + 'byteSize', + 'bytes', + 'code', + 'concurrency', + 'count', + 'cpuTimeMs', + 'durationMs', + 'fileCount', + 'inheritedCount', + 'inputBytes', + 'jobWindow', + 'memoryBytes', + 'modifiedCount', + 'outputBytes', + 'port', + 'processed', + 'released', + 'removed', + 'retries', + 'size', + 'skillCount', + 'status', + 'statusCode', + 'timeoutMs', + 'userCount', + 'wallTimeMs', +]); + +const REASONS = new Set([ + 'bad_signature', + 'capacity', + 'config', + 'expired', + 'future_iat', + 'invalid_manifest', + 'invalid_token', + 'malformed', + 'malformed_claims', + 'missing_config', + 'not_allowed', + 'not_yet_valid', + 'scope_mismatch', + 'timeout', + 'ttl_too_long', + 'unknown_kid', + 'weak_config', + 'wrong_alg', + 'wrong_audience', + 'wrong_issuer', + 'wrong_source', +]); + +const ROUTES = new Set([ + 'unmatched', + 'v1.download', + 'v1.exec', + 'v1.exec.programmatic', + 'v1.files.delete', + 'v1.files.list', + 'v1.files.metadata', + 'v1.files.object', + 'v1.health', + 'v1.upload', + 'v1.upload.batch', + 'v2.execute', + 'v2.lifecycle', + 'v2.runtimes', +]); + +const STAGES = new Set([ + 'authentication', + 'checkpoint', + 'cleanup', + 'download', + 'execution', + 'restore', + 'setup', + 'shutdown', + 'startup', + 'upload', + 'warmup', +]); + +const OUTCOMES = new Set([ + 'completed', + 'failed', + 'ignored', + 'rejected', + 'retried', + 'timed_out', +]); + +const METHODS = new Set([ + 'DELETE', + 'GET', + 'HEAD', + 'OPTIONS', + 'PATCH', + 'POST', + 'PUT', +]); +const HOOKS = new Set(['pause', 'resume', 'run', 'terminate']); +const SIGNALS = new Set([ + 'SIGABRT', + 'SIGALRM', + 'SIGBUS', + 'SIGFPE', + 'SIGHUP', + 'SIGILL', + 'SIGINT', + 'SIGKILL', + 'SIGPIPE', + 'SIGQUIT', + 'SIGSEGV', + 'SIGTERM', + 'SIGTRAP', +]); + +const LANGUAGE_CLASSES = new Map([ + ['bash', 'shell'], + ['bun', 'javascript'], + ['c', 'compiled'], + ['cpp', 'compiled'], + ['go', 'compiled'], + ['java', 'compiled'], + ['javascript', 'javascript'], + ['node', 'javascript'], + ['php', 'interpreted'], + ['python', 'python'], + ['r', 'interpreted'], + ['ruby', 'interpreted'], + ['rust', 'compiled'], + ['shell', 'shell'], + ['typescript', 'javascript'], +]); + +function safeGet(value: object, key: string): unknown { + try { + return (value as Record)[key]; + } catch { + return undefined; + } +} + +function safeKeys(value: object): string[] { + try { + return Object.keys(value); + } catch { + return []; + } +} + +function errorCategory(error: unknown): ErrorCategory { + if (error == null || (typeof error !== 'object' && typeof error !== 'function')) { + return 'internal'; + } + + const name = safeGet(error, 'name'); + const code = safeGet(error, 'code'); + + if (typeof name === 'string') { + if (/AuthProviderConfigError|ConfigurationError/.test(name)) return 'configuration'; + if (/CodeApiJwtAuthError|AuthenticationError/.test(name)) return 'authentication'; + if (/AuthorizationError|FileRefAuthorizationError/.test(name)) return 'authorization'; + if (/Capacity|PayloadTooLarge|StateTooLarge/.test(name)) return 'capacity'; + if (/Timeout|AbortError/.test(name)) return 'timeout'; + if (/AxiosError|DependencyError/.test(name)) return 'dependency'; + if (/Manifest|SyntaxError|TypeError|ValidationError/.test(name)) return 'validation'; + } + + switch (code) { + case 'EACCES': + case 'EPERM': + return 'authorization'; + case 'ENOSPC': + return 'capacity'; + case 'ETIMEDOUT': + case 'ABORT_ERR': + return 'timeout'; + case 'ECONNREFUSED': + case 'ECONNRESET': + case 'ENETUNREACH': + case 'ENOTFOUND': + case 'EPIPE': + return 'dependency'; + default: + return 'internal'; + } +} + +function component(value: unknown): string | undefined { + return typeof value === 'string' && COMPONENTS.has(value) ? value : undefined; +} + +function classifiedString(key: string, value: unknown): [string, string] | undefined { + if (typeof value !== 'string') return undefined; + + if (key === 'component' || key === 'service') { + const safe = component(value); + return safe == null ? undefined : [key, safe]; + } + if (key === 'method') { + const method = value.toUpperCase(); + return METHODS.has(method) ? [key, method] : undefined; + } + if (key === 'route') return ROUTES.has(value) ? [key, value] : undefined; + if (key === 'reason') return REASONS.has(value) ? [key, value] : undefined; + if (key === 'stage') return STAGES.has(value) ? [key, value] : undefined; + if (key === 'outcome') return OUTCOMES.has(value) ? [key, value] : undefined; + if (key === 'hook') return HOOKS.has(value) ? [key, value] : undefined; + if (key === 'signal') return SIGNALS.has(value) ? [key, value] : undefined; + if (key === 'errorCategory') { + return (ERROR_CATEGORIES as readonly string[]).includes(value) ? [key, value] : undefined; + } + if (key === 'language' || key === 'languageClass') { + const safe = LANGUAGE_CLASSES.get(value.toLowerCase()); + return safe == null ? undefined : ['languageClass', safe]; + } + if (key === 'queue' || key === 'worker' || key === 'workerClass') { + const safe = value.toLowerCase() === 'python' ? 'python' : 'other'; + return ['workerClass', safe]; + } + return undefined; +} + +function sanitizeObject( + value: object, + ancestors: Set, +): Record { + if (ancestors.has(value)) return {}; + ancestors.add(value); + + const safe: Record = {}; + for (const key of safeKeys(value)) { + const member = safeGet(value, key); + + if (key === 'err' || key === 'error' || key === 'cause') { + safe.errorCategory = errorCategory(member); + continue; + } + if (NUMBER_KEYS.has(key) && typeof member === 'number' && Number.isFinite(member)) { + safe[key] = member; + continue; + } + if (BOOLEAN_KEYS.has(key) && typeof member === 'boolean') { + safe[key] = member; + continue; + } + + const classified = classifiedString(key, member); + if (classified != null) { + safe[classified[0]] = classified[1]; + continue; + } + + if (!CONTAINER_KEYS.has(key) || member == null || typeof member !== 'object') continue; + if (Buffer.isBuffer(member)) { + safe[key] = { bytes: member.byteLength }; + } else if (Array.isArray(member)) { + safe[key] = { count: member.length }; + } else { + const nested = sanitizeObject(member, ancestors); + if (Object.keys(nested).length > 0) safe[key] = nested; + } + } + + ancestors.delete(value); + return safe; +} + +export function sanitizeOperationalMetadata(value: unknown): Record { + try { + if (value == null || typeof value !== 'object') return {}; + if (Buffer.isBuffer(value)) return { bytes: value.byteLength }; + if (Array.isArray(value)) return { count: value.length }; + return sanitizeObject(value, new Set()); + } catch { + return {}; + } +} + +type SanitizedLogInfo = { + level: string; + message: string; + [key: string]: unknown; + [key: symbol]: unknown; +}; + +export function sanitizeOperationalLogInfo(info: unknown): SanitizedLogInfo { + const rawLevel = info != null && typeof info === 'object' ? safeGet(info, 'level') : undefined; + const level = typeof rawLevel === 'string' ? rawLevel : 'info'; + return { + ...sanitizeOperationalMetadata(info), + level, + message: OPERATIONAL_LOG_MESSAGE, + [Symbol.for('level')]: level, + }; +} From 45f0bf1442a77bc227a92091ec6ea7f80e09a66c Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 20:15:39 +0200 Subject: [PATCH 2/8] docs(fork): record values-free logging patch --- docs/fork/patches.md | 58 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/docs/fork/patches.md b/docs/fork/patches.md index d1c6802a..5765e481 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -29,6 +29,7 @@ States: Active, Review on sync, Draft, History only, Retired. | Keep PVC package initialization Argo-safe | Active | `646ed2e`, `12d3760`, `c1509a8` | Upstream `packages.source=pvc` mode | | Recover job completion when BullMQ events lag | Active | `b66e87e` | Upstream execution profiles and completion timeout | | Reconnect the egress ledger after Redis outages | Active | `5e459dd` | Managed Redis | +| Keep operational logs values-free | Active | `c87a14d` | Winston and Pino logging sinks | ## Publish exact-SHA UZH images @@ -280,6 +281,55 @@ Replay and drop condition: recreates the Redis client after terminal disconnect, with a readiness recovery test covering an outage longer than five attempts. +## Keep operational logs values-free + +Required behavior: + +- Normalize runtime log messages to fixed event text and retain only + code-declared operational metadata from an explicit allowlist. +- Remove identifiers, filenames, payloads, arbitrary errors and stacks, child + process output, network details, credentials, and caller-provided values + before Winston or Pino serializes them. +- Keep reason, stage, route, method, component, language, worker, and error + categories closed; unknown errors become `internal`. +- Sanitize without mutating caller-owned values or throwing on nested, + circular, repeated, buffered, array, error, or throwing-getter inputs. + +Owned paths: + +- `api/src/logger.test.ts` +- `api/src/logger.ts` +- `service/src/logger.test.ts` +- `service/src/logger.ts` +- `shared/operational-log.ts` + +Shared paths: + +- `api/src/job.ts` — removes the identifier-bearing Pino child binding. +- `api/src/tool-call-socket-proxy.ts` — keeps its standalone console failure + message fixed and removes the raw startup error. +- `service/src/fileServerLogger.ts` and + `service/src/toolCallServerLogger.ts` — apply the shared policy to their + separately constructed Winston sinks. +- `service/rollup.config.js`, `service/tsconfig.esm.json`, and + `service/tsconfig.json` — include the shared policy in service builds. + +Source and current-upstream evidence: + +- Commit `c87a14d755a406f50333bf6f8fd782ebd315ddec` defines the central policy, + sink integrations, bypass corrections, and capture tests. +- Upstream `297fead1a0cd997b0e3e6e55f77fbe83b376be1a` and the reconciled UZH + baseline `83c4f7b105b6b3e69eda12701ad4ec437acba08f` serialize runtime messages, + identifiers, child output, and arbitrary error details without this policy. + +Replay and drop condition: + +- Reapply the shared allowlist at every enabled Winston and Pino constructor, + then re-inventory direct console, raw stream, child binding, serializer, + transport, and child-process forwarding bypasses. +- Drop only when upstream provides an equivalent values-free sink policy with + capture tests and a current enabled-path inventory containing no unknowns. + ## Retired debris - Merge commit `356123a` is history-only transport for the package-init fix; @@ -295,6 +345,8 @@ Replay and drop condition: - Every one of the 23 paths in the active merge-base-to-fork final-tree diff is assigned above. The chart values, package resources, worker deployment, queue module, and two routers are named shared seams in every contributing patch. -- Fork-authored non-merge commits were collapsed into the seven logical final - behaviors above. The only fork merge commit is classified as history-only; - no fork-authored final-tree path is left unowned. +- Fork-authored non-merge commits were collapsed into the eight logical final + behaviors above. The values-free logging package adds twelve owned or shared + paths outside the original 23-path audit. The only fork merge commit is + classified as history-only; no fork-authored final-tree path is left + unowned. From bf83dbe26a82cbdde97a377e5b416a5cc17729ec Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 20:26:06 +0200 Subject: [PATCH 3/8] refactor(logging): tighten operational allowlist --- service/src/logger.test.ts | 7 ++++--- shared/operational-log.ts | 17 +++++------------ 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/service/src/logger.test.ts b/service/src/logger.test.ts index 44ee5d9a..dd351546 100644 --- a/service/src/logger.test.ts +++ b/service/src/logger.test.ts @@ -51,8 +51,6 @@ describe('Winston operational logging', () => { const output = chunks.join(''); for (const variant of [ SENTINEL, - encodeURIComponent(SENTINEL), - Buffer.from(SENTINEL).toString('base64'), createHash('sha256').update(SENTINEL).digest('hex'), ]) { expect(output).not.toContain(variant); @@ -89,9 +87,12 @@ describe('Winston operational logging', () => { }); expect(sanitizeOperationalMetadata({ error: new Error(SENTINEL), - errorCategory: SENTINEL, reason: SENTINEL, stage: SENTINEL, })).toEqual({ errorCategory: 'internal' }); + expect(sanitizeOperationalMetadata({ errorCategory: SENTINEL })) + .toEqual({ errorCategory: 'internal' }); + expect(sanitizeOperationalMetadata(new Error(SENTINEL))) + .toEqual({ errorCategory: 'internal' }); }); }); diff --git a/shared/operational-log.ts b/shared/operational-log.ts index 46d9b5a4..e9eb9473 100644 --- a/shared/operational-log.ts +++ b/shared/operational-log.ts @@ -68,7 +68,6 @@ const NUMBER_KEYS = new Set([ 'memoryBytes', 'modifiedCount', 'outputBytes', - 'port', 'processed', 'released', 'removed', @@ -137,15 +136,6 @@ const STAGES = new Set([ 'warmup', ]); -const OUTCOMES = new Set([ - 'completed', - 'failed', - 'ignored', - 'rejected', - 'retried', - 'timed_out', -]); - const METHODS = new Set([ 'DELETE', 'GET', @@ -262,11 +252,13 @@ function classifiedString(key: string, value: unknown): [string, string] | undef if (key === 'route') return ROUTES.has(value) ? [key, value] : undefined; if (key === 'reason') return REASONS.has(value) ? [key, value] : undefined; if (key === 'stage') return STAGES.has(value) ? [key, value] : undefined; - if (key === 'outcome') return OUTCOMES.has(value) ? [key, value] : undefined; if (key === 'hook') return HOOKS.has(value) ? [key, value] : undefined; if (key === 'signal') return SIGNALS.has(value) ? [key, value] : undefined; if (key === 'errorCategory') { - return (ERROR_CATEGORIES as readonly string[]).includes(value) ? [key, value] : undefined; + const safe = (ERROR_CATEGORIES as readonly string[]).includes(value) + ? value + : 'internal'; + return [key, safe]; } if (key === 'language' || key === 'languageClass') { const safe = LANGUAGE_CLASSES.get(value.toLowerCase()); @@ -327,6 +319,7 @@ function sanitizeObject( export function sanitizeOperationalMetadata(value: unknown): Record { try { if (value == null || typeof value !== 'object') return {}; + if (value instanceof Error) return { errorCategory: errorCategory(value) }; if (Buffer.isBuffer(value)) return { bytes: value.byteLength }; if (Array.isArray(value)) return { count: value.length }; return sanitizeObject(value, new Set()); From 7f06adccf070f0ed6ed003d4ed9a218c8a57eb97 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 20:48:03 +0200 Subject: [PATCH 4/8] docs(fork): complete logging patch provenance --- docs/fork/patches.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/fork/patches.md b/docs/fork/patches.md index 5765e481..56f8c4f1 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -29,7 +29,7 @@ States: Active, Review on sync, Draft, History only, Retired. | Keep PVC package initialization Argo-safe | Active | `646ed2e`, `12d3760`, `c1509a8` | Upstream `packages.source=pvc` mode | | Recover job completion when BullMQ events lag | Active | `b66e87e` | Upstream execution profiles and completion timeout | | Reconnect the egress ledger after Redis outages | Active | `5e459dd` | Managed Redis | -| Keep operational logs values-free | Active | `c87a14d` | Winston and Pino logging sinks | +| Keep operational logs values-free | Active | `c87a14d`, `bf83dbe` | Winston and Pino logging sinks | ## Publish exact-SHA UZH images @@ -316,8 +316,9 @@ Shared paths: Source and current-upstream evidence: -- Commit `c87a14d755a406f50333bf6f8fd782ebd315ddec` defines the central policy, - sink integrations, bypass corrections, and capture tests. +- Commits `c87a14d755a406f50333bf6f8fd782ebd315ddec` and + `bf83dbe26a82cbdde97a377e5b416a5cc17729ec` define the central policy, sink + integrations, strict allowlist, bypass corrections, and capture tests. - Upstream `297fead1a0cd997b0e3e6e55f77fbe83b376be1a` and the reconciled UZH baseline `83c4f7b105b6b3e69eda12701ad4ec437acba08f` serialize runtime messages, identifiers, child output, and arbitrary error details without this policy. From 689be7da1f948c8dae036a92a356ed80ae32e71e Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 23:12:33 +0200 Subject: [PATCH 5/8] fix(logging): keep download failures values-free --- service/src/service/router.ts | 7 ++----- service/src/utils.test.ts | 16 +++++++++++++++- service/src/utils.ts | 5 +++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/service/src/service/router.ts b/service/src/service/router.ts index 7854b958..a1f40e16 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -11,7 +11,7 @@ import { executionLimiter, uploadLimiter, downloadLimiter, fetchLimiter } from ' import { internalServiceHeaders } from '../internal-service-auth'; import { resolveSessionKey, resolveOutputBucketSessionKey, SessionKeyResolutionError, parseUploadSessionKeyInput, type SessionKeyInput } from '../session-key'; import { pyQueue, otherQueue, pyQueueEvents, otherQueueEvents, queueNames, connection, waitForJobFinished } from '../queue'; -import { sleep, getAxiosErrorDetails, publicExecutionFailure } from '../utils'; +import { sleep, getAxiosErrorDetails, PUBLIC_DOWNLOAD_FAILURE, publicExecutionFailure } from '../utils'; import { env, jobCompletionWaitTimeoutMs, planLimits, resolveLanguage } from '../config'; import { createPayload } from '../payload'; import { summarizeRequestedFiles } from '../execution-log'; @@ -333,10 +333,7 @@ router.get('/download/:session_id/:fileId', downloadLimiter, sessionAuth, async const errorDetails = getAxiosErrorDetails(error); logger.error(`[${INSTANCE_ID}] Session ID: ${session_id} | File ID: ${fileId} | Error downloading file:`, errorDetails); - return res.status(500).json({ - error: 'Error downloading file', - details: (error as Error).message - }); + return res.status(PUBLIC_DOWNLOAD_FAILURE.status).json(PUBLIC_DOWNLOAD_FAILURE.body); } }); diff --git a/service/src/utils.test.ts b/service/src/utils.test.ts index f1952dfc..e53e8355 100644 --- a/service/src/utils.test.ts +++ b/service/src/utils.test.ts @@ -1,6 +1,20 @@ import { describe, expect, test } from 'bun:test'; import type { AxiosError } from 'axios'; -import { isValidId, isValidResourceId, publicExecutionFailure, sandboxErrorMessageFromAxios } from './utils'; +import { + isValidId, + isValidResourceId, + PUBLIC_DOWNLOAD_FAILURE, + publicExecutionFailure, + sandboxErrorMessageFromAxios, +} from './utils'; + +test('download failures return a generic 500 without internal details', () => { + expect(PUBLIC_DOWNLOAD_FAILURE).toEqual({ + status: 500, + body: { error: 'Error downloading file' }, + }); + expect(PUBLIC_DOWNLOAD_FAILURE.body).not.toHaveProperty('details'); +}); describe('isValidId (21-char nanoid for sandbox-generated ids)', () => { test('accepts a canonical 21-char nanoid', () => { diff --git a/service/src/utils.ts b/service/src/utils.ts index 3078d6e8..7d11a56e 100644 --- a/service/src/utils.ts +++ b/service/src/utils.ts @@ -106,6 +106,11 @@ export function sandboxErrorMessageFromAxios(error: AxiosError): string { return errorCode ? `[${errorCode}] ${message}` : message; } +export const PUBLIC_DOWNLOAD_FAILURE = { + status: 500, + body: { error: 'Error downloading file' }, +} as const; + export function publicExecutionFailure(error: unknown): { status: number; body: { error: string; message: string } } | null { const message = error instanceof Error ? error.message : ''; From 9df61a5d3e1ac73bea594bf57e35edb7877f6a5c Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 23:14:15 +0200 Subject: [PATCH 6/8] docs(fork): assign download failure ownership --- docs/fork/patches.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/fork/patches.md b/docs/fork/patches.md index 56f8c4f1..cee5d765 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -29,7 +29,7 @@ States: Active, Review on sync, Draft, History only, Retired. | Keep PVC package initialization Argo-safe | Active | `646ed2e`, `12d3760`, `c1509a8` | Upstream `packages.source=pvc` mode | | Recover job completion when BullMQ events lag | Active | `b66e87e` | Upstream execution profiles and completion timeout | | Reconnect the egress ledger after Redis outages | Active | `5e459dd` | Managed Redis | -| Keep operational logs values-free | Active | `c87a14d`, `bf83dbe` | Winston and Pino logging sinks | +| Keep operational logs values-free | Active | `c87a14d`, `bf83dbe`, `689be7d` | Winston and Pino logging sinks and public failures | ## Publish exact-SHA UZH images @@ -294,6 +294,8 @@ Required behavior: categories closed; unknown errors become `internal`. - Sanitize without mutating caller-owned values or throwing on nested, circular, repeated, buffered, array, error, or throwing-getter inputs. +- Return a fixed download failure body with HTTP 500 and never include the + upstream error message or a `details` field. Owned paths: @@ -301,6 +303,8 @@ Owned paths: - `api/src/logger.ts` - `service/src/logger.test.ts` - `service/src/logger.ts` +- `service/src/utils.test.ts` +- `service/src/utils.ts` - `shared/operational-log.ts` Shared paths: @@ -311,6 +315,8 @@ Shared paths: - `service/src/fileServerLogger.ts` and `service/src/toolCallServerLogger.ts` — apply the shared policy to their separately constructed Winston sinks. +- `service/src/service/router.ts` — logs download failures through the + values-free sink and returns only the fixed public body. - `service/rollup.config.js`, `service/tsconfig.esm.json`, and `service/tsconfig.json` — include the shared policy in service builds. @@ -319,6 +325,8 @@ Source and current-upstream evidence: - Commits `c87a14d755a406f50333bf6f8fd782ebd315ddec` and `bf83dbe26a82cbdde97a377e5b416a5cc17729ec` define the central policy, sink integrations, strict allowlist, bypass corrections, and capture tests. +- Commit `689be7da1f948c8dae036a92a356ed80ae32e71e` defines the generic public + download failure while retaining detailed diagnostics in sanitized logs. - Upstream `297fead1a0cd997b0e3e6e55f77fbe83b376be1a` and the reconciled UZH baseline `83c4f7b105b6b3e69eda12701ad4ec437acba08f` serialize runtime messages, identifiers, child output, and arbitrary error details without this policy. @@ -329,7 +337,8 @@ Replay and drop condition: then re-inventory direct console, raw stream, child binding, serializer, transport, and child-process forwarding bypasses. - Drop only when upstream provides an equivalent values-free sink policy with - capture tests and a current enabled-path inventory containing no unknowns. + capture tests, a generic public download failure, and a current enabled-path + inventory containing no unknowns. ## Retired debris @@ -347,7 +356,7 @@ Replay and drop condition: assigned above. The chart values, package resources, worker deployment, queue module, and two routers are named shared seams in every contributing patch. - Fork-authored non-merge commits were collapsed into the eight logical final - behaviors above. The values-free logging package adds twelve owned or shared + behaviors above. The values-free logging package adds fifteen owned or shared paths outside the original 23-path audit. The only fork merge commit is classified as history-only; no fork-authored final-tree path is left unowned. From 42a97437fc7ef783d35b29cbd1b93b9d762c8afa Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 23:20:52 +0200 Subject: [PATCH 7/8] refactor(logging): inline the public download failure --- service/src/service/router.ts | 4 ++-- service/src/utils.test.ts | 16 +--------------- service/src/utils.ts | 5 ----- 3 files changed, 3 insertions(+), 22 deletions(-) diff --git a/service/src/service/router.ts b/service/src/service/router.ts index a1f40e16..85b3979a 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -11,7 +11,7 @@ import { executionLimiter, uploadLimiter, downloadLimiter, fetchLimiter } from ' import { internalServiceHeaders } from '../internal-service-auth'; import { resolveSessionKey, resolveOutputBucketSessionKey, SessionKeyResolutionError, parseUploadSessionKeyInput, type SessionKeyInput } from '../session-key'; import { pyQueue, otherQueue, pyQueueEvents, otherQueueEvents, queueNames, connection, waitForJobFinished } from '../queue'; -import { sleep, getAxiosErrorDetails, PUBLIC_DOWNLOAD_FAILURE, publicExecutionFailure } from '../utils'; +import { sleep, getAxiosErrorDetails, publicExecutionFailure } from '../utils'; import { env, jobCompletionWaitTimeoutMs, planLimits, resolveLanguage } from '../config'; import { createPayload } from '../payload'; import { summarizeRequestedFiles } from '../execution-log'; @@ -333,7 +333,7 @@ router.get('/download/:session_id/:fileId', downloadLimiter, sessionAuth, async const errorDetails = getAxiosErrorDetails(error); logger.error(`[${INSTANCE_ID}] Session ID: ${session_id} | File ID: ${fileId} | Error downloading file:`, errorDetails); - return res.status(PUBLIC_DOWNLOAD_FAILURE.status).json(PUBLIC_DOWNLOAD_FAILURE.body); + return res.status(500).json({ error: 'Error downloading file' }); } }); diff --git a/service/src/utils.test.ts b/service/src/utils.test.ts index e53e8355..f1952dfc 100644 --- a/service/src/utils.test.ts +++ b/service/src/utils.test.ts @@ -1,20 +1,6 @@ import { describe, expect, test } from 'bun:test'; import type { AxiosError } from 'axios'; -import { - isValidId, - isValidResourceId, - PUBLIC_DOWNLOAD_FAILURE, - publicExecutionFailure, - sandboxErrorMessageFromAxios, -} from './utils'; - -test('download failures return a generic 500 without internal details', () => { - expect(PUBLIC_DOWNLOAD_FAILURE).toEqual({ - status: 500, - body: { error: 'Error downloading file' }, - }); - expect(PUBLIC_DOWNLOAD_FAILURE.body).not.toHaveProperty('details'); -}); +import { isValidId, isValidResourceId, publicExecutionFailure, sandboxErrorMessageFromAxios } from './utils'; describe('isValidId (21-char nanoid for sandbox-generated ids)', () => { test('accepts a canonical 21-char nanoid', () => { diff --git a/service/src/utils.ts b/service/src/utils.ts index 7d11a56e..3078d6e8 100644 --- a/service/src/utils.ts +++ b/service/src/utils.ts @@ -106,11 +106,6 @@ export function sandboxErrorMessageFromAxios(error: AxiosError): string { return errorCode ? `[${errorCode}] ${message}` : message; } -export const PUBLIC_DOWNLOAD_FAILURE = { - status: 500, - body: { error: 'Error downloading file' }, -} as const; - export function publicExecutionFailure(error: unknown): { status: number; body: { error: string; message: string } } | null { const message = error instanceof Error ? error.message : ''; From d71f81a9c84634b27d30e85b097d88f6a3a495ba Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 23:22:07 +0200 Subject: [PATCH 8/8] docs(fork): record the minimal download failure patch --- docs/fork/patches.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/fork/patches.md b/docs/fork/patches.md index cee5d765..b482bbc1 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -29,7 +29,7 @@ States: Active, Review on sync, Draft, History only, Retired. | Keep PVC package initialization Argo-safe | Active | `646ed2e`, `12d3760`, `c1509a8` | Upstream `packages.source=pvc` mode | | Recover job completion when BullMQ events lag | Active | `b66e87e` | Upstream execution profiles and completion timeout | | Reconnect the egress ledger after Redis outages | Active | `5e459dd` | Managed Redis | -| Keep operational logs values-free | Active | `c87a14d`, `bf83dbe`, `689be7d` | Winston and Pino logging sinks and public failures | +| Keep operational logs values-free | Active | `c87a14d`, `bf83dbe`, `689be7d`, `42a9743` | Winston and Pino logging sinks and public failures | ## Publish exact-SHA UZH images @@ -303,8 +303,6 @@ Owned paths: - `api/src/logger.ts` - `service/src/logger.test.ts` - `service/src/logger.ts` -- `service/src/utils.test.ts` -- `service/src/utils.ts` - `shared/operational-log.ts` Shared paths: @@ -325,8 +323,10 @@ Source and current-upstream evidence: - Commits `c87a14d755a406f50333bf6f8fd782ebd315ddec` and `bf83dbe26a82cbdde97a377e5b416a5cc17729ec` define the central policy, sink integrations, strict allowlist, bypass corrections, and capture tests. -- Commit `689be7da1f948c8dae036a92a356ed80ae32e71e` defines the generic public - download failure while retaining detailed diagnostics in sanitized logs. +- Commits `689be7da1f948c8dae036a92a356ed80ae32e71e` and + `42a97437fc7ef783d35b29cbd1b93b9d762c8afa` define the final inline generic + public download failure while retaining detailed diagnostics in sanitized + logs. - Upstream `297fead1a0cd997b0e3e6e55f77fbe83b376be1a` and the reconciled UZH baseline `83c4f7b105b6b3e69eda12701ad4ec437acba08f` serialize runtime messages, identifiers, child output, and arbitrary error details without this policy. @@ -356,7 +356,7 @@ Replay and drop condition: assigned above. The chart values, package resources, worker deployment, queue module, and two routers are named shared seams in every contributing patch. - Fork-authored non-merge commits were collapsed into the eight logical final - behaviors above. The values-free logging package adds fifteen owned or shared + behaviors above. The values-free logging package adds thirteen owned or shared paths outside the original 23-path audit. The only fork merge commit is classified as history-only; no fork-authored final-tree path is left unowned.