diff --git a/src/app/application.ts b/src/app/application.ts index 493bb7a..c1553c5 100644 --- a/src/app/application.ts +++ b/src/app/application.ts @@ -412,7 +412,10 @@ export class BraidApplication { readonly text: string readonly runId?: string }): QueueReceipt { - return queueRunInput(this.#portViews.queue, input) + return queueRunInput(this.#portViews.queue, { + ...input, + operationId: operationId(input.operationId, 'queue'), + }) } async steer(input: { @@ -420,7 +423,10 @@ export class BraidApplication { readonly runId?: string readonly text: string }): Promise { - return steerRun(this.#portViews.control, input) + return steerRun(this.#portViews.control, { + ...input, + operationId: operationId(input.operationId, 'steer'), + }) } async cancelRun(input: { @@ -430,7 +436,10 @@ export class BraidApplication { readonly terminalStatus?: 'cancelled' | 'aborted' readonly legacy?: boolean }): Promise { - return cancelRun(this.#portViews.control, input) + return cancelRun(this.#portViews.control, { + ...input, + operationId: operationId(input.operationId, 'cancel'), + }) } cancel(input: CancelInput): CancelReceipt { @@ -479,7 +488,10 @@ export class BraidApplication { readonly operationId: string readonly runId?: string }): Promise { - return detachRun(this.#portViews.control, input) + return detachRun(this.#portViews.control, { + ...input, + operationId: operationId(input.operationId, 'detach'), + }) } async respondInteraction(input: { @@ -488,7 +500,10 @@ export class BraidApplication { readonly interactionId: string readonly response: InteractionResponse }): Promise { - return this.#interactions.respond(input) + return this.#interactions.respond({ + ...input, + operationId: operationId(input.operationId, 'respond-interaction'), + }) } async #executeControl( @@ -520,7 +535,10 @@ export class BraidApplication { readonly text: string readonly runId?: string }): Promise { - return continueNative(this.#portViews.nativeContinuation, input) + return continueNative(this.#portViews.nativeContinuation, { + ...input, + operationId: operationId(input.operationId, 'continue'), + }) } shutdown(input: { @@ -584,7 +602,7 @@ export class BraidApplication { this.#assertAdmissionOpen() return admitRun( this.#portViews.admission, - input, + { ...input, operationId: operationId(input.operationId, 'admit') }, conversationId, branchId, contextTransfer, diff --git a/src/bin/plain.ts b/src/bin/plain.ts index ee20638..f9b9bdf 100644 --- a/src/bin/plain.ts +++ b/src/bin/plain.ts @@ -19,7 +19,7 @@ export interface PlainOptions { } function nextOperationId(): string { - return `op-plain-${Date.now().toString(36)}-${randomUUID()}` + return `op-${randomUUID()}` } export async function runPlain( diff --git a/src/domain/ids-core.ts b/src/domain/ids-core.ts index b5c5c14..effa6e2 100644 --- a/src/domain/ids-core.ts +++ b/src/domain/ids-core.ts @@ -1,4 +1,5 @@ -import type { IdForKind, IdKind, Digest, ReplayCursor } from './ids-types.js' +import type { Digest, IdForKind, IdKind, ReplayCursor } from './ids-types.js' +import { redactSensitiveText } from './secret-sanitizer.js' export const prefixes: Readonly> = { workspace: ['workspace-'], @@ -44,7 +45,8 @@ export function parsePrefixedId(kind: K, value: unknown): IdFo if ( typeof value !== 'string' || !idPattern.test(value) || - !prefixes[kind].some((prefix) => value.startsWith(prefix)) + !prefixes[kind].some((prefix) => value.startsWith(prefix)) || + redactSensitiveText(value) !== value ) { throw new TypeError(`Invalid ${kind} identifier`) } @@ -55,7 +57,8 @@ export function isPrefixedId(kind: K, value: unknown): value i return ( typeof value === 'string' && idPattern.test(value) && - prefixes[kind].some((prefix) => value.startsWith(prefix)) + prefixes[kind].some((prefix) => value.startsWith(prefix)) && + redactSensitiveText(value) === value ) } diff --git a/test/application.test.ts b/test/application.test.ts index 713b4de..61f0f2b 100644 --- a/test/application.test.ts +++ b/test/application.test.ts @@ -544,15 +544,36 @@ test('async admission reserves one run before the provider becomes visible', asy assert.equal(app.storageFailure(), undefined) }) -test('send rejects malformed operation identities and oversized input before journaling', () => { +test('public mutations reject unsafe operation identities before work or journaling', async () => { const app = createBraidApplication({ fixture: 'deterministic' }) app.initialize('/workspace') const eventCount = app.events().length + const unsafeOperationId = `op-plain-sk-${'a'.repeat(24)}` assert.throws( () => app.send({ operationId: 'token=do-not-store', text: 'hello' }), (error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID', ) + assert.throws( + () => app.queueInput({ operationId: unsafeOperationId, text: 'queue this' }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID', + ) + await assert.rejects( + app.steer({ operationId: unsafeOperationId, text: 'steer this' }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID', + ) + await assert.rejects( + app.cancelRun({ operationId: unsafeOperationId }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID', + ) + await assert.rejects( + app.detachRun({ operationId: unsafeOperationId }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID', + ) + await assert.rejects( + app.continueNative({ operationId: unsafeOperationId, text: 'continue this' }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_OPERATION_ID', + ) assert.throws( () => app.send({ operationId: 'op-too-large', text: 'x'.repeat(1024 * 1024 + 1) }), (error: unknown) => error instanceof AppError && error.code === 'MESSAGE_TOO_LARGE', diff --git a/test/domain-ids.test.ts b/test/domain-ids.test.ts index 806db7d..13109a8 100644 --- a/test/domain-ids.test.ts +++ b/test/domain-ids.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' +import type { ConversationId } from '../src/domain/ids.js' import { createAnalysisId, createAnalysisRunId, @@ -43,7 +44,6 @@ import { parseConversationId, parseRunId, } from '../src/domain/ids.js' -import type { ConversationId } from '../src/domain/ids.js' test('every domain identifier has a constructor and a nominal runtime prefix', () => { const constructors: readonly [(value: string) => string, string][] = [ @@ -105,6 +105,13 @@ test('identifier validators reject values from another domain', () => { assert.throws(() => parseConversationId('run-1'), /Invalid conversation identifier/u) }) +test('identifier validators reject values that secret redaction would change', () => { + assert.throws( + () => createOperationId(`op-plain-sk-${'a'.repeat(24)}`), + /Invalid operation identifier/u, + ) +}) + test('brands prevent accidental compile-time substitution', () => { const run = createRunId('run-compile') // @ts-expect-error RunId and ConversationId are intentionally nominally distinct. diff --git a/test/rpc.test.ts b/test/rpc.test.ts index 5dbfd47..c3389cb 100644 --- a/test/rpc.test.ts +++ b/test/rpc.test.ts @@ -6,6 +6,7 @@ import { createBraidApplication, DETERMINISTIC_PROFILE } from '../src/app/compos import { MemoryJournal } from '../src/app/journal.js' import { runPlain } from '../src/bin/plain.js' import { canonicalDigest } from '../src/domain/canonical.js' +import { redactSensitiveText } from '../src/domain/secret-sanitizer.js' import { FixedClock } from '../src/ports/clock.js' import { DEFAULT_RUN_CAPABILITIES, @@ -893,6 +894,23 @@ test('plain output failure cancels the delayed run before the outer close', asyn assert.equal(app.events().length, eventsAfterClose) }) +test('plain mode generates redaction-stable operation identifiers', async () => { + const app = createBraidApplication({ fixture: 'deterministic' }) + async function* input(): AsyncGenerator { + yield 'verify generated operation identity\n' + } + + await runPlain(controllerFor(app), '/workspace', input(), { write: () => true }) + + const operationId = app.state().runs[0]?.operationId + assert(operationId) + assert.match( + operationId, + /^op-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, + ) + assert.equal(redactSensitiveText(operationId), operationId) +}) + test('JSONL requires initialize and stable operation identity', async () => { const app = createBraidApplication({ fixture: 'deterministic' }) let output = ''