From 8ed9edb28ae5bf110445d1d98fbb75ff55086957 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 01:09:36 -0400 Subject: [PATCH 01/24] docs: add agent guidance alias --- AGENTS.md | 7 +++++++ CLAUDE.md | 1 + 2 files changed, 8 insertions(+) create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 1b70c7347b..6e2c3834af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,3 +43,10 @@ Prefer the narrowest test layer that proves the behavior. This follows standard - Use `apps/vscode-e2e` only when the behavior depends on the real VS Code extension host, VS Code workspace APIs, extension activation, webview/extension messaging, file watcher behavior, or a complete user workflow. - Keep e2e tests focused on high-value smoke coverage across boundaries. Avoid placing detailed protocol, parsing, storage, retry, or edge-case assertions in e2e when they can be covered reliably at a lower layer. - When fixing a regression, add the regression test at the lowest layer that would have failed for the bug. Add an e2e test only if lower-level tests cannot represent the failure mode. + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 842265ce8618aafb0ebe8d425921ae38a4de44ce Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 01:21:21 -0400 Subject: [PATCH 02/24] feat(cli): define Zoo protocol contracts --- packages/zoo-protocol/eslint.config.mjs | 4 + packages/zoo-protocol/package.json | 24 +++ .../src/__tests__/contracts.test.ts | 203 ++++++++++++++++++ packages/zoo-protocol/src/host-commands.ts | 106 +++++++++ packages/zoo-protocol/src/host-events.ts | 85 ++++++++ packages/zoo-protocol/src/index.ts | 7 + packages/zoo-protocol/src/outcomes.ts | 87 ++++++++ packages/zoo-protocol/src/parity.ts | 71 ++++++ packages/zoo-protocol/src/public-events.ts | 167 ++++++++++++++ packages/zoo-protocol/src/redaction.ts | 32 +++ packages/zoo-protocol/src/version.ts | 68 ++++++ packages/zoo-protocol/tsconfig.json | 8 + packages/zoo-protocol/vitest.config.ts | 10 + pnpm-lock.yaml | 19 ++ 14 files changed, 891 insertions(+) create mode 100644 packages/zoo-protocol/eslint.config.mjs create mode 100644 packages/zoo-protocol/package.json create mode 100644 packages/zoo-protocol/src/__tests__/contracts.test.ts create mode 100644 packages/zoo-protocol/src/host-commands.ts create mode 100644 packages/zoo-protocol/src/host-events.ts create mode 100644 packages/zoo-protocol/src/index.ts create mode 100644 packages/zoo-protocol/src/outcomes.ts create mode 100644 packages/zoo-protocol/src/parity.ts create mode 100644 packages/zoo-protocol/src/public-events.ts create mode 100644 packages/zoo-protocol/src/redaction.ts create mode 100644 packages/zoo-protocol/src/version.ts create mode 100644 packages/zoo-protocol/tsconfig.json create mode 100644 packages/zoo-protocol/vitest.config.ts diff --git a/packages/zoo-protocol/eslint.config.mjs b/packages/zoo-protocol/eslint.config.mjs new file mode 100644 index 0000000000..694bf73664 --- /dev/null +++ b/packages/zoo-protocol/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@roo-code/config-eslint/base" + +/** @type {import("eslint").Linter.Config} */ +export default [...config] diff --git a/packages/zoo-protocol/package.json b/packages/zoo-protocol/package.json new file mode 100644 index 0000000000..07b949246c --- /dev/null +++ b/packages/zoo-protocol/package.json @@ -0,0 +1,24 @@ +{ + "name": "@roo-code/zoo-protocol", + "description": "Private versioned contracts shared by the Zoo CLI client and host.", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext=ts --max-warnings=0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "build": "tsc", + "clean": "rimraf dist .turbo" + }, + "dependencies": { + "zod": "3.25.76" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "22.20.1", + "vitest": "4.1.9" + } +} diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts new file mode 100644 index 0000000000..a4f245979b --- /dev/null +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -0,0 +1,203 @@ +import { + EXIT_CODES, + ZOO_HOST_PROTOCOL_VERSION, + assertAuthoritativeRootResult, + compareSemanticTraces, + exitCodeFor, + hostCommandSchema, + hostEventSchema, + hostHelloSchema, + negotiateProtocol, + parityScenarios, + redactText, + redactValue, + validateCommandLifecycle, + validateMonotonicSequence, + validateStreamLifecycle, + zooRunResultSchema, + zooStreamEventSchema, +} from "../index.js" + +const timestamp = "2026-08-05T12:00:00.000Z" + +describe("strict host contracts", () => { + it("accepts a valid start and rejects unknown fields", () => { + const command = { + v: ZOO_HOST_PROTOCOL_VERSION, + id: "command-1", + type: "task.start", + workspace: "/workspace", + prompt: "Fix the test", + overrides: { approval: "safe" }, + } + expect(hostCommandSchema.parse(command)).toEqual(command) + expect(hostCommandSchema.safeParse({ ...command, unexpected: true }).success).toBe(false) + }) + + it("enforces input and approval payload invariants", () => { + expect(hostCommandSchema.safeParse({ v: 1, id: "1", type: "task.input", taskId: "task" }).success).toBe(false) + expect( + hostCommandSchema.safeParse({ + v: 1, + id: "1", + type: "ask.respond", + taskId: "task", + askId: "ask", + response: "message", + }).success, + ).toBe(false) + }) + + it("negotiates versions and required capabilities", () => { + const hello = hostHelloSchema.parse({ + type: "hello", + hostId: "host-1", + supportedVersions: [1], + capabilities: ["task:start", "host:shutdown"], + buildVersion: "1.0.0", + }) + expect(negotiateProtocol(hello, [1], ["task:start"])).toEqual({ ok: true, version: 1 }) + expect(negotiateProtocol(hello, [2], ["task:start"])).toMatchObject({ ok: false }) + expect(negotiateProtocol(hello, [1], ["task:resume"])).toMatchObject({ ok: false }) + }) + + it("requires contiguous host sequence numbers", () => { + expect(validateMonotonicSequence(8, 9)).toEqual({ ok: true }) + expect(validateMonotonicSequence(8, 10)).toEqual({ ok: false, expected: 9 }) + }) + + it("models one ACK and terminal command response independently", () => { + const events = [ + hostEventSchema.parse({ v: 1, seq: 1, hostId: "host", type: "command.ack", commandId: "cmd" }), + hostEventSchema.parse({ v: 1, seq: 2, hostId: "host", type: "command.done", commandId: "cmd" }), + ] + expect(validateCommandLifecycle(["cmd"], events)).toEqual({ ok: true }) + expect(validateCommandLifecycle(["cmd"], [...events, events[1]!])).toMatchObject({ ok: false }) + }) +}) + +describe("public automation contracts", () => { + it("validates one-object results and semantic success", () => { + const result = { + schemaVersion: 1, + protocol: "zoo-run-result", + success: true, + outcome: "completed", + rootTaskId: "root", + workspace: "/workspace", + resumable: false, + content: "Finished", + elapsedMs: 25, + } + expect(zooRunResultSchema.parse(result)).toEqual(result) + expect(zooRunResultSchema.safeParse({ ...result, success: false }).success).toBe(false) + }) + + it("validates strict, ordered stream records", () => { + const event = { + v: 1, + seq: 1, + timestamp, + hostId: "host", + type: "message.upsert", + taskId: "root", + messageId: "message-1", + role: "assistant", + content: "hello", + complete: false, + } + expect(zooStreamEventSchema.parse(event)).toEqual(event) + expect(zooStreamEventSchema.safeParse({ ...event, seq: 0 }).success).toBe(false) + expect(zooStreamEventSchema.safeParse({ ...event, rawSecret: "no" }).success).toBe(false) + }) + + it("requires init, contiguous sequence, and exactly one terminal root result", () => { + const init = zooStreamEventSchema.parse({ + v: 1, + seq: 1, + timestamp, + hostId: "host", + type: "system.init", + protocol: "zoo-stream", + capabilities: ["task:start"], + clientVersion: "1.0.0", + hostVersion: "1.0.0", + }) + const result = zooStreamEventSchema.parse({ + v: 1, + seq: 2, + timestamp, + hostId: "host", + type: "task.result", + rootTaskId: "root", + result: { + schemaVersion: 1, + protocol: "zoo-run-result", + success: true, + outcome: "completed", + rootTaskId: "root", + workspace: "/workspace", + resumable: false, + elapsedMs: 10, + }, + }) + expect(validateStreamLifecycle([init, result])).toEqual({ ok: true }) + expect(validateStreamLifecycle([{ ...init, seq: 2 }, result])).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([init])).toMatchObject({ ok: false }) + }) + + it("maps every terminal outcome deterministically", () => { + expect(exitCodeFor({ outcome: "completed" })).toBe(EXIT_CODES.completed) + expect(exitCodeFor({ outcome: "needs_input" })).toBe(EXIT_CODES.needsInput) + expect(exitCodeFor({ outcome: "cancelled" })).toBe(EXIT_CODES.cancelled) + expect(exitCodeFor({ outcome: "timed_out" })).toBe(EXIT_CODES.timedOut) + expect(exitCodeFor({ outcome: "failed", errorCode: "invalid_mode" })).toBe(EXIT_CODES.usage) + expect(exitCodeFor({ outcome: "failed", errorCode: "provider_failed" })).toBe(EXIT_CODES.providerFailure) + expect(exitCodeFor({ outcome: "failed", errorCode: "host_crashed" })).toBe(EXIT_CODES.runtimeFailure) + expect(exitCodeFor({ outcome: "cancelled", signal: "SIGINT" })).toBe(EXIT_CODES.sigint) + expect(exitCodeFor({ outcome: "cancelled", signal: "SIGTERM" })).toBe(EXIT_CODES.sigterm) + }) +}) + +describe("redaction contracts", () => { + it("redacts secret-shaped keys and text before buffering", () => { + const input = { + provider: "openrouter", + apiKey: "sk-secret-value", + nested: { authorization: "Bearer abcdefgh", command: "API_TOKEN=abcdefgh run" }, + } + expect(redactValue(input)).toEqual({ + provider: "openrouter", + apiKey: "[REDACTED]", + nested: { authorization: "[REDACTED]", command: "[REDACTED] run" }, + }) + expect(redactText("Authorization: Bearer abcdefgh")).not.toContain("abcdefgh") + }) + + it("handles cycles without throwing", () => { + const input: Record = {} + input.self = input + expect(redactValue(input)).toEqual({ self: "[CIRCULAR]" }) + }) +}) + +describe("deterministic parity oracle", () => { + it.each(parityScenarios)("accepts the $id golden semantic trace", (scenario) => { + expect(compareSemanticTraces(scenario.expected, scenario.expected)).toEqual({ ok: true }) + }) + + it("detects child completion incorrectly settling the root", () => { + const trace = [ + { type: "task.created", taskId: "root" }, + { type: "task.result", taskId: "child", outcome: "completed" as const }, + ] + expect(assertAuthoritativeRootResult(trace, "root")).toBe(false) + expect(assertAuthoritativeRootResult(parityScenarios[2]!.expected, "root")).toBe(true) + }) + + it("reports semantic drift without timestamps", () => { + const expected = parityScenarios[0]!.expected + const result = compareSemanticTraces(expected, expected.slice(0, -1)) + expect(result).toMatchObject({ ok: false }) + }) +}) diff --git a/packages/zoo-protocol/src/host-commands.ts b/packages/zoo-protocol/src/host-commands.ts new file mode 100644 index 0000000000..9e91b08512 --- /dev/null +++ b/packages/zoo-protocol/src/host-commands.ts @@ -0,0 +1,106 @@ +import { z } from "zod" + +import { ZOO_HOST_PROTOCOL_VERSION } from "./version.js" + +export const approvalModeSchema = z.enum(["interactive", "safe", "auto"]) +export type ApprovalMode = z.infer + +export const reasoningEffortSchema = z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]) + +export const runOverridesSchema = z + .object({ + provider: z.string().min(1).optional(), + profile: z.string().min(1).optional(), + model: z.string().min(1).optional(), + mode: z.string().min(1).optional(), + reasoningEffort: reasoningEffortSchema.optional(), + approval: approvalModeSchema.optional(), + }) + .strict() + .refine(({ profile, provider }) => !(profile && provider), { + message: "profile and provider are mutually exclusive", + }) + +export type RunOverrides = z.infer + +const commandBaseSchema = z + .object({ + v: z.literal(ZOO_HOST_PROTOCOL_VERSION), + id: z.string().min(1), + }) + .strict() + +const taskStartCommandSchema = commandBaseSchema + .extend({ + type: z.literal("task.start"), + workspace: z.string().min(1), + prompt: z.string().trim().min(1), + overrides: runOverridesSchema.optional(), + }) + .strict() + +const taskResumeCommandSchema = commandBaseSchema + .extend({ + type: z.literal("task.resume"), + taskId: z.string().min(1), + overrides: runOverridesSchema.optional(), + }) + .strict() + +const taskInputCommandSchema = commandBaseSchema + .extend({ + type: z.literal("task.input"), + taskId: z.string().min(1), + text: z.string().min(1).optional(), + images: z.array(z.string().min(1)).min(1).optional(), + }) + .strict() + +const askRespondCommandSchema = commandBaseSchema + .extend({ + type: z.literal("ask.respond"), + taskId: z.string().min(1), + askId: z.string().min(1), + response: z.enum(["approve", "reject", "message"]), + text: z.string().min(1).optional(), + }) + .strict() + +const taskCancelCommandSchema = commandBaseSchema + .extend({ + type: z.literal("task.cancel"), + rootTaskId: z.string().min(1), + reason: z.enum(["user", "signal", "timeout"]), + }) + .strict() + +const historyListCommandSchema = commandBaseSchema + .extend({ type: z.literal("history.list"), workspace: z.string().min(1) }) + .strict() +const hostSnapshotCommandSchema = commandBaseSchema.extend({ type: z.literal("host.snapshot") }).strict() +const hostShutdownCommandSchema = commandBaseSchema.extend({ type: z.literal("host.shutdown") }).strict() + +const hostCommandDiscriminatedSchema = z.discriminatedUnion("type", [ + taskStartCommandSchema, + taskResumeCommandSchema, + taskInputCommandSchema, + askRespondCommandSchema, + taskCancelCommandSchema, + historyListCommandSchema, + hostSnapshotCommandSchema, + hostShutdownCommandSchema, +]) + +export const hostCommandSchema = hostCommandDiscriminatedSchema.superRefine((command, context) => { + if (command.type === "task.input" && command.text === undefined && command.images === undefined) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "task.input requires text or images" }) + } + if (command.type === "ask.respond" && (command.response === "message") !== (command.text !== undefined)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "message responses require text and other responses forbid it", + }) + } +}) + +export type HostCommand = z.infer diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts new file mode 100644 index 0000000000..24ceaa7efc --- /dev/null +++ b/packages/zoo-protocol/src/host-events.ts @@ -0,0 +1,85 @@ +import { z } from "zod" + +import { zooErrorSchema } from "./outcomes.js" +import { zooStreamEventSchema } from "./public-events.js" +import { ZOO_HOST_PROTOCOL_VERSION } from "./version.js" + +const base = { + v: z.literal(ZOO_HOST_PROTOCOL_VERSION), + seq: z.number().int().positive(), + hostId: z.string().min(1), +} + +const strictObject = (shape: T) => z.object(shape).strict() + +const commandAckSchema = strictObject({ ...base, type: z.literal("command.ack"), commandId: z.string().min(1) }) +const commandDoneSchema = strictObject({ + ...base, + type: z.literal("command.done"), + commandId: z.string().min(1), + data: z.unknown().optional(), +}) +const commandErrorSchema = strictObject({ + ...base, + type: z.literal("command.error"), + commandId: z.string().min(1), + error: zooErrorSchema, +}) +const heartbeatSchema = strictObject({ + ...base, + type: z.literal("host.heartbeat"), + monotonicMs: z.number().nonnegative(), +}) +const snapshotSchema = strictObject({ + ...base, + type: z.literal("host.snapshot"), + lastSeq: z.number().int().nonnegative(), + activeRootTaskId: z.string().min(1).optional(), +}) +const normalizedEventSchema = strictObject({ ...base, type: z.literal("event"), event: zooStreamEventSchema }) + +export const hostEventSchema = z.discriminatedUnion("type", [ + commandAckSchema, + commandDoneSchema, + commandErrorSchema, + heartbeatSchema, + snapshotSchema, + normalizedEventSchema, +]) + +export type HostEvent = z.infer + +export function validateMonotonicSequence( + previous: number, + next: number, +): { ok: true } | { ok: false; expected: number } { + const expected = previous + 1 + return next === expected ? { ok: true } : { ok: false, expected } +} + +export function validateCommandLifecycle( + commandIds: readonly string[], + events: readonly HostEvent[], +): { ok: true } | { ok: false; commandId: string; message: string } { + for (const commandId of commandIds) { + const commandEvents = events.filter( + (event) => + (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && + event.commandId === commandId, + ) + const acknowledgements = commandEvents.filter((event) => event.type === "command.ack") + const terminals = commandEvents.filter( + (event) => event.type === "command.done" || event.type === "command.error", + ) + if (acknowledgements.length !== 1) { + return { ok: false, commandId, message: `Expected one ACK, received ${acknowledgements.length}` } + } + if (terminals.length !== 1) { + return { ok: false, commandId, message: `Expected one DONE or ERROR, received ${terminals.length}` } + } + if (acknowledgements[0]!.seq >= terminals[0]!.seq) { + return { ok: false, commandId, message: "ACK must precede DONE or ERROR" } + } + } + return { ok: true } +} diff --git a/packages/zoo-protocol/src/index.ts b/packages/zoo-protocol/src/index.ts new file mode 100644 index 0000000000..c73f4b1cd2 --- /dev/null +++ b/packages/zoo-protocol/src/index.ts @@ -0,0 +1,7 @@ +export * from "./host-commands.js" +export * from "./host-events.js" +export * from "./outcomes.js" +export * from "./parity.js" +export * from "./public-events.js" +export * from "./redaction.js" +export * from "./version.js" diff --git a/packages/zoo-protocol/src/outcomes.ts b/packages/zoo-protocol/src/outcomes.ts new file mode 100644 index 0000000000..ae63f877c2 --- /dev/null +++ b/packages/zoo-protocol/src/outcomes.ts @@ -0,0 +1,87 @@ +import { z } from "zod" + +export const zooOutcomeSchema = z.enum(["completed", "needs_input", "cancelled", "timed_out", "failed"]) +export type ZooOutcome = z.infer + +export const zooErrorCodes = [ + "invalid_usage", + "invalid_workspace", + "invalid_provider", + "invalid_profile", + "invalid_model", + "invalid_mode", + "invalid_session", + "credentials_missing", + "permission_required", + "permission_denied", + "outside_workspace", + "protected_path", + "provider_failed", + "task_failed", + "host_start_failed", + "host_crashed", + "protocol_incompatible", + "protocol_gap", + "cancel_failed", + "cleanup_timed_out", + "task_timed_out", + "output_closed", +] as const + +export const zooErrorCodeSchema = z.enum(zooErrorCodes) +export type ZooErrorCode = z.infer + +export const zooErrorKindSchema = z.enum(["configuration", "provider", "runtime"]) +export type ZooErrorKind = z.infer + +export const zooErrorSchema = z + .object({ + code: zooErrorCodeSchema, + message: z.string().min(1), + kind: zooErrorKindSchema.optional(), + phase: z.string().min(1).optional(), + }) + .strict() + +export type ZooError = z.infer + +export const EXIT_CODES = { + completed: 0, + usage: 2, + needsInput: 3, + cancelled: 4, + providerFailure: 10, + runtimeFailure: 70, + timedOut: 124, + sigint: 130, + sigterm: 143, +} as const + +const usageErrors = new Set([ + "invalid_usage", + "invalid_workspace", + "invalid_provider", + "invalid_profile", + "invalid_model", + "invalid_mode", + "invalid_session", + "credentials_missing", +]) + +export type ExitContext = { + outcome: ZooOutcome + errorCode?: ZooErrorCode + signal?: "SIGINT" | "SIGTERM" +} + +export function exitCodeFor({ outcome, errorCode, signal }: ExitContext): number { + if (signal === "SIGINT") return EXIT_CODES.sigint + if (signal === "SIGTERM") return EXIT_CODES.sigterm + if (errorCode && usageErrors.has(errorCode)) return EXIT_CODES.usage + if (outcome === "completed") return EXIT_CODES.completed + if (outcome === "needs_input") return EXIT_CODES.needsInput + if (outcome === "cancelled") return EXIT_CODES.cancelled + if (outcome === "timed_out") return EXIT_CODES.timedOut + if (errorCode === "provider_failed") return EXIT_CODES.providerFailure + return EXIT_CODES.runtimeFailure +} diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts new file mode 100644 index 0000000000..c2b215fd7d --- /dev/null +++ b/packages/zoo-protocol/src/parity.ts @@ -0,0 +1,71 @@ +import type { ZooOutcome, ZooErrorCode } from "./outcomes.js" + +export type SemanticTraceEntry = { + type: string + taskId?: string + parentTaskId?: string + toolCallId?: string + content?: string + outcome?: ZooOutcome + errorCode?: ZooErrorCode +} + +export type ParityScenario = { + id: string + prompt: string + providerTurns: readonly string[] + expected: readonly SemanticTraceEntry[] +} + +export const parityScenarios: readonly ParityScenario[] = [ + { + id: "text-completion", + prompt: "Reply with the fixture greeting.", + providerTurns: ["Hello from Zoo."], + expected: [ + { type: "task.created", taskId: "root" }, + { type: "message.upsert", taskId: "root", content: "Hello from Zoo." }, + { type: "task.result", taskId: "root", outcome: "completed" }, + ], + }, + { + id: "tool-pairing", + prompt: "Read README.md and report its title.", + providerTurns: ["tool:read_file:call-1:README.md", "Zoo Code"], + expected: [ + { type: "task.created", taskId: "root" }, + { type: "tool.started", taskId: "root", toolCallId: "call-1" }, + { type: "tool.completed", taskId: "root", toolCallId: "call-1" }, + { type: "message.upsert", taskId: "root", content: "Zoo Code" }, + { type: "task.result", taskId: "root", outcome: "completed" }, + ], + }, + { + id: "delegation-root-authority", + prompt: "Delegate once, then finish the root task.", + providerTurns: ["delegate:child", "child:done", "root:accepted"], + expected: [ + { type: "task.created", taskId: "root" }, + { type: "task.delegated", taskId: "child", parentTaskId: "root" }, + { type: "task.lifecycle", taskId: "child" }, + { type: "message.upsert", taskId: "root", content: "root:accepted" }, + { type: "task.result", taskId: "root", outcome: "completed" }, + ], + }, +] + +export function compareSemanticTraces( + expected: readonly SemanticTraceEntry[], + actual: readonly SemanticTraceEntry[], +): { ok: true } | { ok: false; difference: string } { + const expectedJson = JSON.stringify(expected) + const actualJson = JSON.stringify(actual) + return expectedJson === actualJson + ? { ok: true } + : { ok: false, difference: `Expected ${expectedJson}\nReceived ${actualJson}` } +} + +export function assertAuthoritativeRootResult(trace: readonly SemanticTraceEntry[], rootTaskId: string): boolean { + const results = trace.filter((entry) => entry.type === "task.result") + return results.length === 1 && results[0]?.taskId === rootTaskId +} diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts new file mode 100644 index 0000000000..40398c6daf --- /dev/null +++ b/packages/zoo-protocol/src/public-events.ts @@ -0,0 +1,167 @@ +import { z } from "zod" + +import { zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" +import { ZOO_PUBLIC_SCHEMA_VERSION, zooCapabilitySchema } from "./version.js" + +const strictObject = (shape: T) => z.object(shape).strict() + +export const usageSchema = strictObject({ + inputTokens: z.number().int().nonnegative().optional(), + outputTokens: z.number().int().nonnegative().optional(), + cacheReads: z.number().int().nonnegative().optional(), + cacheWrites: z.number().int().nonnegative().optional(), +}) + +export const changedFileSchema = strictObject({ path: z.string().min(1), status: z.string().min(1) }) + +export const zooRunResultSchema = strictObject({ + schemaVersion: z.literal(ZOO_PUBLIC_SCHEMA_VERSION), + protocol: z.literal("zoo-run-result"), + success: z.boolean(), + outcome: zooOutcomeSchema, + rootTaskId: z.string().min(1).optional(), + currentTaskId: z.string().min(1).optional(), + workspace: z.string().min(1), + resumable: z.boolean(), + content: z.string().optional(), + error: zooErrorSchema.optional(), + usage: usageSchema.optional(), + cost: z.number().nonnegative().optional(), + elapsedMs: z.number().int().nonnegative(), + changedFiles: z.array(changedFileSchema).optional(), +}).superRefine((result, context) => { + if (result.success !== (result.outcome === "completed")) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "success must match completed outcome" }) + } + if (result.outcome === "failed" && result.error === undefined) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "failed results require an error" }) + } +}) + +export type ZooRunResult = z.infer + +const eventBase = { + v: z.literal(ZOO_PUBLIC_SCHEMA_VERSION), + seq: z.number().int().positive(), + timestamp: z.string().datetime({ offset: true }), + hostId: z.string().min(1), + rootTaskId: z.string().min(1).optional(), + taskId: z.string().min(1).optional(), + requestId: z.string().min(1).optional(), +} + +const event = (type: string, shape: T) => + strictObject({ ...eventBase, type: z.literal(type), ...shape }) + +const systemInitEventSchema = event("system.init", { + protocol: z.literal("zoo-stream"), + capabilities: z.array(zooCapabilitySchema), + clientVersion: z.string().min(1), + hostVersion: z.string().min(1), +}) +const systemWarningEventSchema = event("system.warning", { code: z.string().min(1), message: z.string().min(1) }) +const taskCreatedEventSchema = event("task.created", { parentTaskId: z.string().min(1).optional() }) +const taskStartedEventSchema = event("task.started", {}) +const taskLifecycleEventSchema = event("task.lifecycle", { + state: z.enum(["running", "waiting", "interrupted", "completed", "failed"]), +}) +const taskResumedEventSchema = event("task.resumed", {}) +const taskDelegatedEventSchema = event("task.delegated", { + parentTaskId: z.string().min(1), + childTaskId: z.string().min(1), +}) +const messageUpsertEventSchema = event("message.upsert", { + messageId: z.string().min(1), + role: z.enum(["assistant", "user", "reasoning"]), + content: z.string(), + complete: z.boolean(), +}) +const askRequiredEventSchema = event("ask.required", { + askId: z.string().min(1), + category: z.string().min(1), + subject: z.string().min(1), +}) +const askResolvedEventSchema = event("ask.resolved", { + askId: z.string().min(1), + decision: z.enum(["approve", "reject", "needs_input"]), + source: z.enum(["policy", "user", "auto", "deny"]), +}) +const toolEventState = { + toolCallId: z.string().min(1), + name: z.string().min(1), + arguments: z.record(z.unknown()).optional(), + output: z.string().optional(), +} +const toolStartedEventSchema = event("tool.started", toolEventState) +const toolUpdatedEventSchema = event("tool.updated", toolEventState) +const toolCompletedEventSchema = event("tool.completed", toolEventState) +const toolFailedEventSchema = event("tool.failed", { ...toolEventState, error: zooErrorSchema }) +const terminalOutputEventSchema = event("terminal.output", { + toolCallId: z.string().min(1), + stream: z.enum(["stdout", "stderr"]), + delta: z.string(), +}) +const terminalStatusEventSchema = event("terminal.status", { + toolCallId: z.string().min(1), + state: z.enum(["running", "background", "exited", "killed"]), + exitCode: z.number().int().nullable().optional(), +}) +const mcpEventState = { + operationId: z.string().min(1), + server: z.string().min(1), + operation: z.string().min(1), + output: z.string().optional(), +} +const mcpStartedEventSchema = event("mcp.started", mcpEventState) +const mcpCompletedEventSchema = event("mcp.completed", mcpEventState) +const mcpFailedEventSchema = event("mcp.failed", { ...mcpEventState, error: zooErrorSchema }) +const usageUpdatedEventSchema = event("usage.updated", { + usage: usageSchema, + cost: z.number().nonnegative().optional(), +}) +const taskResultEventSchema = event("task.result", { result: zooRunResultSchema }) + +export const zooStreamEventSchema = z.discriminatedUnion("type", [ + systemInitEventSchema, + systemWarningEventSchema, + taskCreatedEventSchema, + taskStartedEventSchema, + taskLifecycleEventSchema, + taskResumedEventSchema, + taskDelegatedEventSchema, + messageUpsertEventSchema, + askRequiredEventSchema, + askResolvedEventSchema, + toolStartedEventSchema, + toolUpdatedEventSchema, + toolCompletedEventSchema, + toolFailedEventSchema, + terminalOutputEventSchema, + terminalStatusEventSchema, + mcpStartedEventSchema, + mcpCompletedEventSchema, + mcpFailedEventSchema, + usageUpdatedEventSchema, + taskResultEventSchema, +]) + +export type ZooStreamEvent = z.infer + +export function validateStreamLifecycle( + events: readonly ZooStreamEvent[], +): { ok: true } | { ok: false; code: "protocol_gap" | "task_failed"; message: string } { + if (events[0]?.type !== "system.init") { + return { ok: false, code: "task_failed", message: "Stream must start with system.init" } + } + for (let index = 1; index < events.length; index += 1) { + const expected = events[index - 1]!.seq + 1 + if (events[index]!.seq !== expected) { + return { ok: false, code: "protocol_gap", message: `Expected sequence ${expected}` } + } + } + const results = events.filter((event) => event.type === "task.result") + if (results.length !== 1 || events.at(-1)?.type !== "task.result") { + return { ok: false, code: "task_failed", message: "Accepted stream must end with exactly one task.result" } + } + return { ok: true } +} diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts new file mode 100644 index 0000000000..c44386f5a8 --- /dev/null +++ b/packages/zoo-protocol/src/redaction.ts @@ -0,0 +1,32 @@ +const REDACTED = "[REDACTED]" as const +const sensitiveKey = /(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)/i +const secretPatterns: ReadonlyArray = [ + /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, + /\b(?:sk|xox[baprs]|gh[opusr])[-_][A-Za-z0-9_-]{8,}\b/g, + /\b[A-Za-z][A-Za-z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD)\s*=\s*[^\s]+/gi, + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, +] + +export type RedactedValue = null | boolean | number | string | RedactedValue[] | { [key: string]: RedactedValue } + +export function redactText(value: string): string { + return secretPatterns.reduce((redacted, pattern) => redacted.replace(pattern, REDACTED), value) +} + +export function redactValue(value: unknown, seen = new WeakSet()): RedactedValue { + if (value === null || typeof value === "boolean" || typeof value === "number") return value + if (typeof value === "string") return redactText(value) + if (typeof value !== "object") return String(value) + if (seen.has(value)) return "[CIRCULAR]" + seen.add(value) + + if (Array.isArray(value)) return value.map((entry) => redactValue(entry, seen)) + + const result: Record = {} + for (const [key, entry] of Object.entries(value)) { + result[key] = sensitiveKey.test(key) ? REDACTED : redactValue(entry, seen) + } + return result +} + +export { REDACTED } diff --git a/packages/zoo-protocol/src/version.ts b/packages/zoo-protocol/src/version.ts new file mode 100644 index 0000000000..551e4a03e5 --- /dev/null +++ b/packages/zoo-protocol/src/version.ts @@ -0,0 +1,68 @@ +import { z } from "zod" + +export const ZOO_HOST_PROTOCOL_VERSION = 1 as const +export const ZOO_PUBLIC_SCHEMA_VERSION = 1 as const + +export const zooCapabilities = [ + "task:start", + "task:resume", + "task:input", + "task:cancel", + "ask:respond", + "history:list", + "host:snapshot", + "host:shutdown", + "checkpoint:unavailable", +] as const + +export const zooCapabilitySchema = z.enum(zooCapabilities) +export type ZooCapability = z.infer + +export const hostHelloSchema = z + .object({ + type: z.literal("hello"), + hostId: z.string().min(1), + supportedVersions: z.array(z.number().int().positive()).nonempty(), + capabilities: z.array(zooCapabilitySchema), + buildVersion: z.string().min(1), + }) + .strict() + +export type HostHello = z.infer + +export const parentHelloSchema = z + .object({ + type: z.literal("hello.select"), + version: z.number().int().positive(), + clientVersion: z.string().min(1), + requiredCapabilities: z.array(zooCapabilitySchema), + }) + .strict() + +export type ParentHello = z.infer + +export type NegotiationResult = + | { ok: true; version: number } + | { ok: false; code: "protocol_incompatible"; message: string } + +export function negotiateProtocol( + host: HostHello, + supportedVersions: readonly number[], + requiredCapabilities: readonly ZooCapability[], +): NegotiationResult { + const missing = requiredCapabilities.filter((capability) => !host.capabilities.includes(capability)) + if (missing.length > 0) { + return { + ok: false, + code: "protocol_incompatible", + message: `Host is missing required capabilities: ${missing.join(", ")}`, + } + } + + const version = [...supportedVersions] + .sort((left, right) => right - left) + .find((candidate) => host.supportedVersions.includes(candidate)) + return version === undefined + ? { ok: false, code: "protocol_incompatible", message: "No mutually supported host protocol version" } + : { ok: true, version } +} diff --git a/packages/zoo-protocol/tsconfig.json b/packages/zoo-protocol/tsconfig.json new file mode 100644 index 0000000000..45cb8699a3 --- /dev/null +++ b/packages/zoo-protocol/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@roo-code/config-typescript/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/zoo-protocol/vitest.config.ts b/packages/zoo-protocol/vitest.config.ts new file mode 100644 index 0000000000..ea7ddf23e3 --- /dev/null +++ b/packages/zoo-protocol/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + watch: false, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c3dd070ac..82da4164ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -419,6 +419,25 @@ importers: specifier: 4.1.9 version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/zoo-protocol: + dependencies: + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + '@roo-code/config-eslint': + specifier: workspace:^ + version: link:../config-eslint + '@roo-code/config-typescript': + specifier: workspace:^ + version: link:../config-typescript + '@types/node': + specifier: 22.20.1 + version: 22.20.1 + vitest: + specifier: 4.1.9 + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + src: dependencies: '@anthropic-ai/sdk': From c56a4d9c2b2c362cd9ea4533c29dc78213ac6630 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 01:41:03 -0400 Subject: [PATCH 03/24] no-mistakes(review): Tighten Zoo protocol contracts and lifecycle validation --- .../zoo-protocol/eslint-suppressions.json | 2 + packages/zoo-protocol/package.json | 2 +- .../src/__tests__/contracts.test.ts | 71 +++++++++++- packages/zoo-protocol/src/host-events.ts | 29 ++++- packages/zoo-protocol/src/parity.ts | 37 ++++++- packages/zoo-protocol/src/public-events.ts | 104 ++++++++++++++---- packages/zoo-protocol/src/redaction.ts | 9 +- 7 files changed, 224 insertions(+), 30 deletions(-) create mode 100644 packages/zoo-protocol/eslint-suppressions.json diff --git a/packages/zoo-protocol/eslint-suppressions.json b/packages/zoo-protocol/eslint-suppressions.json new file mode 100644 index 0000000000..7a73a41bfd --- /dev/null +++ b/packages/zoo-protocol/eslint-suppressions.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/packages/zoo-protocol/package.json b/packages/zoo-protocol/package.json index 07b949246c..e0d7871fe9 100644 --- a/packages/zoo-protocol/package.json +++ b/packages/zoo-protocol/package.json @@ -3,7 +3,7 @@ "description": "Private versioned contracts shared by the Zoo CLI client and host.", "private": true, "type": "module", - "main": "./dist/index.js", + "exports": "./src/index.ts", "types": "./src/index.ts", "scripts": { "lint": "eslint src --ext=ts --max-warnings=0", diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index a4f245979b..6bf08bae55 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -11,6 +11,7 @@ import { parityScenarios, redactText, redactValue, + runDeterministicFakeProvider, validateCommandLifecycle, validateMonotonicSequence, validateStreamLifecycle, @@ -69,11 +70,29 @@ describe("strict host contracts", () => { it("models one ACK and terminal command response independently", () => { const events = [ hostEventSchema.parse({ v: 1, seq: 1, hostId: "host", type: "command.ack", commandId: "cmd" }), - hostEventSchema.parse({ v: 1, seq: 2, hostId: "host", type: "command.done", commandId: "cmd" }), + hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host", + type: "command.done", + commandId: "cmd", + data: { commandType: "host.shutdown" }, + }), ] expect(validateCommandLifecycle(["cmd"], events)).toEqual({ ok: true }) expect(validateCommandLifecycle(["cmd"], [...events, events[1]!])).toMatchObject({ ok: false }) }) + + it("rejects missing and mismatched command completion payloads", () => { + const done = { v: 1, seq: 1, hostId: "host", type: "command.done", commandId: "cmd" } + expect(hostEventSchema.safeParse(done).success).toBe(false) + expect( + hostEventSchema.safeParse({ + ...done, + data: { commandType: "task.start", task: { rootTaskId: "root" } }, + }).success, + ).toBe(false) + }) }) describe("public automation contracts", () => { @@ -91,6 +110,12 @@ describe("public automation contracts", () => { } expect(zooRunResultSchema.parse(result)).toEqual(result) expect(zooRunResultSchema.safeParse({ ...result, success: false }).success).toBe(false) + expect( + zooRunResultSchema.safeParse({ + ...result, + error: { code: "task_failed", message: "contradiction" }, + }).success, + ).toBe(false) }) it("validates strict, ordered stream records", () => { @@ -100,6 +125,7 @@ describe("public automation contracts", () => { timestamp, hostId: "host", type: "message.upsert", + rootTaskId: "root", taskId: "root", messageId: "message-1", role: "assistant", @@ -109,6 +135,7 @@ describe("public automation contracts", () => { expect(zooStreamEventSchema.parse(event)).toEqual(event) expect(zooStreamEventSchema.safeParse({ ...event, seq: 0 }).success).toBe(false) expect(zooStreamEventSchema.safeParse({ ...event, rawSecret: "no" }).success).toBe(false) + expect(zooStreamEventSchema.safeParse({ ...event, taskId: undefined }).success).toBe(false) }) it("requires init, contiguous sequence, and exactly one terminal root result", () => { @@ -130,6 +157,7 @@ describe("public automation contracts", () => { hostId: "host", type: "task.result", rootTaskId: "root", + taskId: "root", result: { schemaVersion: 1, protocol: "zoo-run-result", @@ -144,6 +172,36 @@ describe("public automation contracts", () => { expect(validateStreamLifecycle([init, result])).toEqual({ ok: true }) expect(validateStreamLifecycle([{ ...init, seq: 2 }, result])).toMatchObject({ ok: false }) expect(validateStreamLifecycle([init])).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([init, { ...result, taskId: "child" }])).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + init, + zooStreamEventSchema.parse({ + v: 1, + seq: 2, + timestamp, + hostId: "host", + type: "ask.required", + rootTaskId: "root", + taskId: "root", + askId: "ask-1", + category: "tool", + subject: "Run command", + }), + { ...result, seq: 3 }, + ]), + ).toMatchObject({ ok: false }) + const completedLifecycle = zooStreamEventSchema.parse({ + v: 1, + seq: 2, + timestamp, + hostId: "host", + type: "task.lifecycle", + rootTaskId: "root", + taskId: "root", + state: "failed", + }) + expect(validateStreamLifecycle([init, completedLifecycle, { ...result, seq: 3 }])).toMatchObject({ ok: false }) }) it("maps every terminal outcome deterministically", () => { @@ -172,6 +230,8 @@ describe("redaction contracts", () => { nested: { authorization: "[REDACTED]", command: "[REDACTED] run" }, }) expect(redactText("Authorization: Bearer abcdefgh")).not.toContain("abcdefgh") + expect(redactText("Authorization: abc123\nCookie: session=abc")).not.toMatch(/abc123|session=abc/) + expect(redactText('{"password":"hunter2"}')).toBe('{"password":"[REDACTED]"}') }) it("handles cycles without throwing", () => { @@ -183,7 +243,14 @@ describe("redaction contracts", () => { describe("deterministic parity oracle", () => { it.each(parityScenarios)("accepts the $id golden semantic trace", (scenario) => { - expect(compareSemanticTraces(scenario.expected, scenario.expected)).toEqual({ ok: true }) + expect(compareSemanticTraces(scenario.expected, runDeterministicFakeProvider(scenario))).toEqual({ ok: true }) + }) + + it("includes the prompt in fake-provider semantics", () => { + const scenario = { ...parityScenarios[0]!, prompt: "Changed prompt" } + expect(compareSemanticTraces(parityScenarios[0]!.expected, runDeterministicFakeProvider(scenario))).toMatchObject({ + ok: false, + }) }) it("detects child completion incorrectly settling the root", () => { diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index 24ceaa7efc..009af3b089 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -12,12 +12,39 @@ const base = { const strictObject = (shape: T) => z.object(shape).strict() +const taskReferenceSchema = strictObject({ + rootTaskId: z.string().min(1), + taskId: z.string().min(1), +}) + +const taskSummarySchema = strictObject({ + rootTaskId: z.string().min(1), + currentTaskId: z.string().min(1), + workspace: z.string().min(1), + state: z.enum(["running", "waiting", "interrupted", "completed", "failed"]), +}) + +export const commandDoneDataSchema = z.discriminatedUnion("commandType", [ + strictObject({ commandType: z.literal("task.start"), task: taskReferenceSchema }), + strictObject({ commandType: z.literal("task.resume"), task: taskReferenceSchema }), + strictObject({ commandType: z.literal("task.input"), taskId: z.string().min(1) }), + strictObject({ commandType: z.literal("ask.respond"), taskId: z.string().min(1), askId: z.string().min(1) }), + strictObject({ commandType: z.literal("task.cancel"), rootTaskId: z.string().min(1) }), + strictObject({ commandType: z.literal("history.list"), tasks: z.array(taskSummarySchema) }), + strictObject({ + commandType: z.literal("host.snapshot"), + lastSeq: z.number().int().nonnegative(), + activeRootTaskId: z.string().min(1).optional(), + }), + strictObject({ commandType: z.literal("host.shutdown") }), +]) + const commandAckSchema = strictObject({ ...base, type: z.literal("command.ack"), commandId: z.string().min(1) }) const commandDoneSchema = strictObject({ ...base, type: z.literal("command.done"), commandId: z.string().min(1), - data: z.unknown().optional(), + data: commandDoneDataSchema, }) const commandErrorSchema = strictObject({ ...base, diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index c2b215fd7d..6c7337afac 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -6,6 +6,7 @@ export type SemanticTraceEntry = { parentTaskId?: string toolCallId?: string content?: string + prompt?: string outcome?: ZooOutcome errorCode?: ZooErrorCode } @@ -23,7 +24,7 @@ export const parityScenarios: readonly ParityScenario[] = [ prompt: "Reply with the fixture greeting.", providerTurns: ["Hello from Zoo."], expected: [ - { type: "task.created", taskId: "root" }, + { type: "task.created", taskId: "root", prompt: "Reply with the fixture greeting." }, { type: "message.upsert", taskId: "root", content: "Hello from Zoo." }, { type: "task.result", taskId: "root", outcome: "completed" }, ], @@ -33,7 +34,7 @@ export const parityScenarios: readonly ParityScenario[] = [ prompt: "Read README.md and report its title.", providerTurns: ["tool:read_file:call-1:README.md", "Zoo Code"], expected: [ - { type: "task.created", taskId: "root" }, + { type: "task.created", taskId: "root", prompt: "Read README.md and report its title." }, { type: "tool.started", taskId: "root", toolCallId: "call-1" }, { type: "tool.completed", taskId: "root", toolCallId: "call-1" }, { type: "message.upsert", taskId: "root", content: "Zoo Code" }, @@ -45,7 +46,7 @@ export const parityScenarios: readonly ParityScenario[] = [ prompt: "Delegate once, then finish the root task.", providerTurns: ["delegate:child", "child:done", "root:accepted"], expected: [ - { type: "task.created", taskId: "root" }, + { type: "task.created", taskId: "root", prompt: "Delegate once, then finish the root task." }, { type: "task.delegated", taskId: "child", parentTaskId: "root" }, { type: "task.lifecycle", taskId: "child" }, { type: "message.upsert", taskId: "root", content: "root:accepted" }, @@ -65,6 +66,36 @@ export function compareSemanticTraces( : { ok: false, difference: `Expected ${expectedJson}\nReceived ${actualJson}` } } +export function runDeterministicFakeProvider(scenario: ParityScenario): readonly SemanticTraceEntry[] { + if (scenario.prompt.trim().length === 0) throw new Error("Fake-provider scenarios require a prompt") + + const trace: SemanticTraceEntry[] = [{ type: "task.created", taskId: "root", prompt: scenario.prompt }] + for (const turn of scenario.providerTurns) { + if (turn.startsWith("tool:")) { + const [, operation, toolCallId, argument] = turn.split(":") + if (operation !== "read_file" || !toolCallId || !argument) throw new Error(`Invalid tool fixture: ${turn}`) + trace.push({ type: "tool.started", taskId: "root", toolCallId }) + trace.push({ type: "tool.completed", taskId: "root", toolCallId }) + continue + } + if (turn.startsWith("delegate:")) { + const taskId = turn.slice("delegate:".length) + if (!taskId) throw new Error(`Invalid delegation fixture: ${turn}`) + trace.push({ type: "task.delegated", taskId, parentTaskId: "root" }) + continue + } + if (turn.endsWith(":done")) { + const taskId = turn.slice(0, -":done".length) + if (!taskId) throw new Error(`Invalid completion fixture: ${turn}`) + trace.push({ type: "task.lifecycle", taskId }) + continue + } + trace.push({ type: "message.upsert", taskId: "root", content: turn }) + } + trace.push({ type: "task.result", taskId: "root", outcome: "completed" }) + return trace +} + export function assertAuthoritativeRootResult(trace: readonly SemanticTraceEntry[], rootTaskId: string): boolean { const results = trace.filter((entry) => entry.type === "task.result") return results.length === 1 && results[0]?.taskId === rootTaskId diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 40398c6daf..b9d5a3728a 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -19,7 +19,7 @@ export const zooRunResultSchema = strictObject({ protocol: z.literal("zoo-run-result"), success: z.boolean(), outcome: zooOutcomeSchema, - rootTaskId: z.string().min(1).optional(), + rootTaskId: z.string().min(1), currentTaskId: z.string().min(1).optional(), workspace: z.string().min(1), resumable: z.boolean(), @@ -36,6 +36,9 @@ export const zooRunResultSchema = strictObject({ if (result.outcome === "failed" && result.error === undefined) { context.addIssue({ code: z.ZodIssueCode.custom, message: "failed results require an error" }) } + if (result.outcome === "completed" && result.error !== undefined) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "completed results cannot include an error" }) + } }) export type ZooRunResult = z.infer @@ -45,14 +48,21 @@ const eventBase = { seq: z.number().int().positive(), timestamp: z.string().datetime({ offset: true }), hostId: z.string().min(1), - rootTaskId: z.string().min(1).optional(), - taskId: z.string().min(1).optional(), requestId: z.string().min(1).optional(), } const event = (type: string, shape: T) => strictObject({ ...eventBase, type: z.literal(type), ...shape }) +const taskEvent = (type: string, shape: T) => + strictObject({ + ...eventBase, + type: z.literal(type), + rootTaskId: z.string().min(1), + taskId: z.string().min(1), + ...shape, + }) + const systemInitEventSchema = event("system.init", { protocol: z.literal("zoo-stream"), capabilities: z.array(zooCapabilitySchema), @@ -60,28 +70,28 @@ const systemInitEventSchema = event("system.init", { hostVersion: z.string().min(1), }) const systemWarningEventSchema = event("system.warning", { code: z.string().min(1), message: z.string().min(1) }) -const taskCreatedEventSchema = event("task.created", { parentTaskId: z.string().min(1).optional() }) -const taskStartedEventSchema = event("task.started", {}) -const taskLifecycleEventSchema = event("task.lifecycle", { +const taskCreatedEventSchema = taskEvent("task.created", { parentTaskId: z.string().min(1).optional() }) +const taskStartedEventSchema = taskEvent("task.started", {}) +const taskLifecycleEventSchema = taskEvent("task.lifecycle", { state: z.enum(["running", "waiting", "interrupted", "completed", "failed"]), }) -const taskResumedEventSchema = event("task.resumed", {}) -const taskDelegatedEventSchema = event("task.delegated", { +const taskResumedEventSchema = taskEvent("task.resumed", {}) +const taskDelegatedEventSchema = taskEvent("task.delegated", { parentTaskId: z.string().min(1), childTaskId: z.string().min(1), }) -const messageUpsertEventSchema = event("message.upsert", { +const messageUpsertEventSchema = taskEvent("message.upsert", { messageId: z.string().min(1), role: z.enum(["assistant", "user", "reasoning"]), content: z.string(), complete: z.boolean(), }) -const askRequiredEventSchema = event("ask.required", { +const askRequiredEventSchema = taskEvent("ask.required", { askId: z.string().min(1), category: z.string().min(1), subject: z.string().min(1), }) -const askResolvedEventSchema = event("ask.resolved", { +const askResolvedEventSchema = taskEvent("ask.resolved", { askId: z.string().min(1), decision: z.enum(["approve", "reject", "needs_input"]), source: z.enum(["policy", "user", "auto", "deny"]), @@ -92,16 +102,16 @@ const toolEventState = { arguments: z.record(z.unknown()).optional(), output: z.string().optional(), } -const toolStartedEventSchema = event("tool.started", toolEventState) -const toolUpdatedEventSchema = event("tool.updated", toolEventState) -const toolCompletedEventSchema = event("tool.completed", toolEventState) -const toolFailedEventSchema = event("tool.failed", { ...toolEventState, error: zooErrorSchema }) -const terminalOutputEventSchema = event("terminal.output", { +const toolStartedEventSchema = taskEvent("tool.started", toolEventState) +const toolUpdatedEventSchema = taskEvent("tool.updated", toolEventState) +const toolCompletedEventSchema = taskEvent("tool.completed", toolEventState) +const toolFailedEventSchema = taskEvent("tool.failed", { ...toolEventState, error: zooErrorSchema }) +const terminalOutputEventSchema = taskEvent("terminal.output", { toolCallId: z.string().min(1), stream: z.enum(["stdout", "stderr"]), delta: z.string(), }) -const terminalStatusEventSchema = event("terminal.status", { +const terminalStatusEventSchema = taskEvent("terminal.status", { toolCallId: z.string().min(1), state: z.enum(["running", "background", "exited", "killed"]), exitCode: z.number().int().nullable().optional(), @@ -112,14 +122,14 @@ const mcpEventState = { operation: z.string().min(1), output: z.string().optional(), } -const mcpStartedEventSchema = event("mcp.started", mcpEventState) -const mcpCompletedEventSchema = event("mcp.completed", mcpEventState) -const mcpFailedEventSchema = event("mcp.failed", { ...mcpEventState, error: zooErrorSchema }) -const usageUpdatedEventSchema = event("usage.updated", { +const mcpStartedEventSchema = taskEvent("mcp.started", mcpEventState) +const mcpCompletedEventSchema = taskEvent("mcp.completed", mcpEventState) +const mcpFailedEventSchema = taskEvent("mcp.failed", { ...mcpEventState, error: zooErrorSchema }) +const usageUpdatedEventSchema = taskEvent("usage.updated", { usage: usageSchema, cost: z.number().nonnegative().optional(), }) -const taskResultEventSchema = event("task.result", { result: zooRunResultSchema }) +const taskResultEventSchema = taskEvent("task.result", { result: zooRunResultSchema }) export const zooStreamEventSchema = z.discriminatedUnion("type", [ systemInitEventSchema, @@ -163,5 +173,55 @@ export function validateStreamLifecycle( if (results.length !== 1 || events.at(-1)?.type !== "task.result") { return { ok: false, code: "task_failed", message: "Accepted stream must end with exactly one task.result" } } + const resultEvent = results[0]! + const rootTaskId = resultEvent.result.rootTaskId + if (resultEvent.rootTaskId !== rootTaskId || resultEvent.taskId !== rootTaskId) { + return { ok: false, code: "task_failed", message: "task.result must identify the authoritative root task" } + } + + const pendingAsks = new Set() + const taskStates = new Map() + const terminalStates = new Set(["interrupted", "completed", "failed"]) + for (const streamEvent of events) { + if ("rootTaskId" in streamEvent && streamEvent.rootTaskId !== rootTaskId) { + return { ok: false, code: "task_failed", message: "All task events must identify the authoritative root task" } + } + if (!("taskId" in streamEvent) || streamEvent.type === "task.result") continue + + const previousState = taskStates.get(streamEvent.taskId) + if (previousState !== undefined && terminalStates.has(previousState)) { + return { ok: false, code: "task_failed", message: `Task ${streamEvent.taskId} emitted an event after termination` } + } + if (streamEvent.type === "task.lifecycle") { + taskStates.set(streamEvent.taskId, streamEvent.state) + } + if (streamEvent.type === "ask.required") { + const askKey = `${streamEvent.taskId}\u0000${streamEvent.askId}` + if (pendingAsks.has(askKey)) { + return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} is already pending` } + } + pendingAsks.add(askKey) + } + if (streamEvent.type === "ask.resolved") { + const askKey = `${streamEvent.taskId}\u0000${streamEvent.askId}` + if (!pendingAsks.delete(askKey)) { + return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was not pending` } + } + } + } + if (pendingAsks.size > 0 && resultEvent.result.outcome !== "needs_input") { + return { ok: false, code: "task_failed", message: "Terminal stream contains unresolved asks" } + } + const expectedState = { + completed: "completed", + needs_input: "waiting", + cancelled: "interrupted", + timed_out: "interrupted", + failed: "failed", + } as const + const rootState = taskStates.get(rootTaskId) + if (rootState !== undefined && rootState !== expectedState[resultEvent.result.outcome]) { + return { ok: false, code: "task_failed", message: "Root lifecycle state contradicts task.result" } + } return { ok: true } } diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index c44386f5a8..1445cb4b11 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -1,6 +1,10 @@ const REDACTED = "[REDACTED]" as const const sensitiveKey = /(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)/i +const doubleQuotedSecret = /("(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)"\s*:\s*)"(?:\\.|[^"\\])*"/gi +const singleQuotedSecret = /('(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)'\s*:\s*)'(?:\\.|[^'\\])*'/gi const secretPatterns: ReadonlyArray = [ + /\b(?:Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, + /(? = [ export type RedactedValue = null | boolean | number | string | RedactedValue[] | { [key: string]: RedactedValue } export function redactText(value: string): string { - return secretPatterns.reduce((redacted, pattern) => redacted.replace(pattern, REDACTED), value) + const structured = value + .replace(doubleQuotedSecret, `$1"${REDACTED}"`) + .replace(singleQuotedSecret, `$1'${REDACTED}'`) + return secretPatterns.reduce((redacted, pattern) => redacted.replace(pattern, REDACTED), structured) } export function redactValue(value: unknown, seen = new WeakSet()): RedactedValue { From 96b023186378a37d937f2df6bbffeef023ad02ae Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 01:56:15 -0400 Subject: [PATCH 04/24] no-mistakes(review): Tighten Zoo protocol lifecycle and redaction contracts --- .../src/__tests__/contracts.test.ts | 69 +++++++++++++++++-- packages/zoo-protocol/src/host-events.ts | 61 +++++++++++++++- packages/zoo-protocol/src/parity.ts | 14 +++- packages/zoo-protocol/src/public-events.ts | 8 ++- packages/zoo-protocol/src/redaction.ts | 7 +- packages/zoo-protocol/src/version.ts | 2 +- 6 files changed, 146 insertions(+), 15 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 6bf08bae55..2725599142 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -54,7 +54,7 @@ describe("strict host contracts", () => { type: "hello", hostId: "host-1", supportedVersions: [1], - capabilities: ["task:start", "host:shutdown"], + capabilities: ["task:start", "host:shutdown", "future:additive-capability"], buildVersion: "1.0.0", }) expect(negotiateProtocol(hello, [1], ["task:start"])).toEqual({ ok: true, version: 1 }) @@ -68,6 +68,7 @@ describe("strict host contracts", () => { }) it("models one ACK and terminal command response independently", () => { + const command = hostCommandSchema.parse({ v: 1, id: "cmd", type: "host.shutdown" }) const events = [ hostEventSchema.parse({ v: 1, seq: 1, hostId: "host", type: "command.ack", commandId: "cmd" }), hostEventSchema.parse({ @@ -79,8 +80,56 @@ describe("strict host contracts", () => { data: { commandType: "host.shutdown" }, }), ] - expect(validateCommandLifecycle(["cmd"], events)).toEqual({ ok: true }) - expect(validateCommandLifecycle(["cmd"], [...events, events[1]!])).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], events)).toEqual({ ok: true }) + expect(validateCommandLifecycle([command], [...events, events[1]!])).toMatchObject({ ok: false }) + }) + + it("correlates terminal responses with commands and hosts", () => { + const command = hostCommandSchema.parse({ + v: 1, + id: "cmd", + type: "ask.respond", + taskId: "task", + askId: "ask", + response: "approve", + }) + const acknowledgement = hostEventSchema.parse({ + v: 1, + seq: 1, + hostId: "host-a", + type: "command.ack", + commandId: "cmd", + }) + const completion = hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host-a", + type: "command.done", + commandId: "cmd", + data: { commandType: "ask.respond", taskId: "task", askId: "ask" }, + }) + const mismatchedIdentity = hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host-a", + type: "command.done", + commandId: "cmd", + data: { commandType: "ask.respond", taskId: "task", askId: "other" }, + }) + const mismatchedType = hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host-a", + type: "command.done", + commandId: "cmd", + data: { commandType: "host.shutdown" }, + }) + expect(validateCommandLifecycle([command], [acknowledgement, completion])).toEqual({ ok: true }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedIdentity])).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedType])).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [acknowledgement, { ...completion, hostId: "host-b" }])).toMatchObject({ + ok: false, + }) }) it("rejects missing and mismatched command completion payloads", () => { @@ -169,10 +218,12 @@ describe("public automation contracts", () => { elapsedMs: 10, }, }) + const childResult = zooStreamEventSchema.parse({ ...result, taskId: "child" }) expect(validateStreamLifecycle([init, result])).toEqual({ ok: true }) + expect(validateStreamLifecycle([init, { ...result, hostId: "other-host" }])).toMatchObject({ ok: false }) expect(validateStreamLifecycle([{ ...init, seq: 2 }, result])).toMatchObject({ ok: false }) expect(validateStreamLifecycle([init])).toMatchObject({ ok: false }) - expect(validateStreamLifecycle([init, { ...result, taskId: "child" }])).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([init, childResult])).toMatchObject({ ok: false }) expect( validateStreamLifecycle([ init, @@ -232,6 +283,9 @@ describe("redaction contracts", () => { expect(redactText("Authorization: Bearer abcdefgh")).not.toContain("abcdefgh") expect(redactText("Authorization: abc123\nCookie: session=abc")).not.toMatch(/abc123|session=abc/) expect(redactText('{"password":"hunter2"}')).toBe('{"password":"[REDACTED]"}') + expect(redactText('{"client_secret":"secret-value","access_token":"token-value"}')).toBe( + '{"client_secret":"[REDACTED]","access_token":"[REDACTED]"}', + ) }) it("handles cycles without throwing", () => { @@ -267,4 +321,11 @@ describe("deterministic parity oracle", () => { const result = compareSemanticTraces(expected, expected.slice(0, -1)) expect(result).toMatchObject({ ok: false }) }) + + it("ignores object property insertion order without ignoring event order", () => { + const expected = [{ type: "message.upsert", taskId: "root", content: "hello" }] + const reordered = [{ content: "hello", taskId: "root", type: "message.upsert" }] + expect(compareSemanticTraces(expected, reordered)).toEqual({ ok: true }) + expect(compareSemanticTraces(expected, [...reordered, ...reordered])).toMatchObject({ ok: false }) + }) }) diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index 009af3b089..418d4058d8 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -1,5 +1,6 @@ import { z } from "zod" +import type { HostCommand } from "./host-commands.js" import { zooErrorSchema } from "./outcomes.js" import { zooStreamEventSchema } from "./public-events.js" import { ZOO_HOST_PROTOCOL_VERSION } from "./version.js" @@ -65,7 +66,7 @@ const snapshotSchema = strictObject({ }) const normalizedEventSchema = strictObject({ ...base, type: z.literal("event"), event: zooStreamEventSchema }) -export const hostEventSchema = z.discriminatedUnion("type", [ +const hostEventDiscriminatedSchema = z.discriminatedUnion("type", [ commandAckSchema, commandDoneSchema, commandErrorSchema, @@ -74,6 +75,12 @@ export const hostEventSchema = z.discriminatedUnion("type", [ normalizedEventSchema, ]) +export const hostEventSchema = hostEventDiscriminatedSchema.superRefine((event, context) => { + if (event.type === "event" && event.event.hostId !== event.hostId) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "Normalized event hostId must match its host envelope" }) + } +}) + export type HostEvent = z.infer export function validateMonotonicSequence( @@ -85,10 +92,33 @@ export function validateMonotonicSequence( } export function validateCommandLifecycle( - commandIds: readonly string[], + commands: readonly HostCommand[], events: readonly HostEvent[], ): { ok: true } | { ok: false; commandId: string; message: string } { - for (const commandId of commandIds) { + const commandById = new Map() + for (const command of commands) { + if (commandById.has(command.id)) { + return { ok: false, commandId: command.id, message: "Command IDs must be unique" } + } + commandById.set(command.id, command) + } + + const firstHostId = events[0]?.hostId + for (const event of events) { + if (event.hostId !== firstHostId) { + const commandId = "commandId" in event ? event.commandId : commands[0]?.id ?? "unknown" + return { ok: false, commandId, message: "Command lifecycle cannot span multiple hosts" } + } + if ( + (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && + !commandById.has(event.commandId) + ) { + return { ok: false, commandId: event.commandId, message: "Response references an unknown command" } + } + } + + for (const command of commands) { + const commandId = command.id const commandEvents = events.filter( (event) => (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && @@ -107,6 +137,31 @@ export function validateCommandLifecycle( if (acknowledgements[0]!.seq >= terminals[0]!.seq) { return { ok: false, commandId, message: "ACK must precede DONE or ERROR" } } + const terminal = terminals[0]! + if (terminal.type === "command.done") { + const data = terminal.data + const matches = (() => { + switch (command.type) { + case "task.start": + return data.commandType === command.type + case "task.resume": + return data.commandType === command.type && data.task.taskId === command.taskId + case "task.input": + return data.commandType === command.type && data.taskId === command.taskId + case "ask.respond": + return data.commandType === command.type && data.taskId === command.taskId && data.askId === command.askId + case "task.cancel": + return data.commandType === command.type && data.rootTaskId === command.rootTaskId + case "history.list": + case "host.snapshot": + case "host.shutdown": + return data.commandType === command.type + } + })() + if (!matches) { + return { ok: false, commandId, message: "DONE payload does not match the originating command" } + } + } } return { ok: true } } diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index 6c7337afac..8e098aa41c 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -59,8 +59,18 @@ export function compareSemanticTraces( expected: readonly SemanticTraceEntry[], actual: readonly SemanticTraceEntry[], ): { ok: true } | { ok: false; difference: string } { - const expectedJson = JSON.stringify(expected) - const actualJson = JSON.stringify(actual) + const canonicalize = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalize) + if (value === null || typeof value !== "object") return value + return Object.fromEntries( + Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([key, entry]) => [key, canonicalize(entry)]), + ) + } + const expectedJson = JSON.stringify(canonicalize(expected)) + const actualJson = JSON.stringify(canonicalize(actual)) return expectedJson === actualJson ? { ok: true } : { ok: false, difference: `Expected ${expectedJson}\nReceived ${actualJson}` } diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index b9d5a3728a..583a9e11eb 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -51,10 +51,10 @@ const eventBase = { requestId: z.string().min(1).optional(), } -const event = (type: string, shape: T) => +const event = (type: Type, shape: T) => strictObject({ ...eventBase, type: z.literal(type), ...shape }) -const taskEvent = (type: string, shape: T) => +const taskEvent = (type: Type, shape: T) => strictObject({ ...eventBase, type: z.literal(type), @@ -163,6 +163,10 @@ export function validateStreamLifecycle( if (events[0]?.type !== "system.init") { return { ok: false, code: "task_failed", message: "Stream must start with system.init" } } + const hostId = events[0].hostId + if (events.some((streamEvent) => streamEvent.hostId !== hostId)) { + return { ok: false, code: "protocol_gap", message: "Stream cannot span multiple hosts" } + } for (let index = 1; index < events.length; index += 1) { const expected = events[index - 1]!.seq + 1 if (events[index]!.seq !== expected) { diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 1445cb4b11..14cf4c294b 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -1,10 +1,11 @@ const REDACTED = "[REDACTED]" as const const sensitiveKey = /(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)/i -const doubleQuotedSecret = /("(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)"\s*:\s*)"(?:\\.|[^"\\])*"/gi -const singleQuotedSecret = /('(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)'\s*:\s*)'(?:\\.|[^'\\])*'/gi +const sensitiveKeyName = String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)[A-Za-z0-9_-]*` +const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") +const singleQuotedSecret = new RegExp(`('${sensitiveKeyName}'\\s*:\\s*)'(?:\\\\.|[^'\\\\])*'`, "gi") const secretPatterns: ReadonlyArray = [ /\b(?:Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, - /(? Date: Wed, 5 Aug 2026 02:10:35 -0400 Subject: [PATCH 05/24] no-mistakes(review): Tighten Zoo protocol invariants and validation --- .../src/__tests__/contracts.test.ts | 156 +++++++++++++++++- packages/zoo-protocol/src/host-commands.ts | 11 +- packages/zoo-protocol/src/host-events.ts | 20 ++- packages/zoo-protocol/src/outcomes.ts | 21 ++- packages/zoo-protocol/src/public-events.ts | 66 ++++++++ packages/zoo-protocol/src/redaction.ts | 19 ++- packages/zoo-protocol/src/version.ts | 31 +++- 7 files changed, 300 insertions(+), 24 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 2725599142..8000dcc306 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -3,6 +3,7 @@ import { ZOO_HOST_PROTOCOL_VERSION, assertAuthoritativeRootResult, compareSemanticTraces, + exitContextSchema, exitCodeFor, hostCommandSchema, hostEventSchema, @@ -33,6 +34,8 @@ describe("strict host contracts", () => { } expect(hostCommandSchema.parse(command)).toEqual(command) expect(hostCommandSchema.safeParse({ ...command, unexpected: true }).success).toBe(false) + expect(hostCommandSchema.safeParse({ ...command, overrides: { reasoningEffort: "max" } }).success).toBe(true) + expect(hostCommandSchema.safeParse({ ...command, overrides: { reasoningEffort: "disabled" } }).success).toBe(true) }) it("enforces input and approval payload invariants", () => { @@ -54,12 +57,19 @@ describe("strict host contracts", () => { type: "hello", hostId: "host-1", supportedVersions: [1], - capabilities: ["task:start", "host:shutdown", "future:additive-capability"], + capabilities: { 1: ["task:start", "host:shutdown", "future:additive-capability"] }, buildVersion: "1.0.0", }) expect(negotiateProtocol(hello, [1], ["task:start"])).toEqual({ ok: true, version: 1 }) expect(negotiateProtocol(hello, [2], ["task:start"])).toMatchObject({ ok: false }) expect(negotiateProtocol(hello, [1], ["task:resume"])).toMatchObject({ ok: false }) + const multiVersionHello = hostHelloSchema.parse({ + ...hello, + supportedVersions: [1, 2], + capabilities: { 1: ["task:start"], 2: ["task:start", "task:resume"] }, + }) + expect(negotiateProtocol(multiVersionHello, [1], ["task:resume"])).toMatchObject({ ok: false }) + expect(negotiateProtocol(multiVersionHello, [2, 1], ["task:resume"])).toEqual({ ok: true, version: 2 }) }) it("requires contiguous host sequence numbers", () => { @@ -82,6 +92,8 @@ describe("strict host contracts", () => { ] expect(validateCommandLifecycle([command], events)).toEqual({ ok: true }) expect(validateCommandLifecycle([command], [...events, events[1]!])).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [events[1]!, events[0]!])).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [events[0]!, { ...events[1]!, seq: 3 }])).toMatchObject({ ok: false }) }) it("correlates terminal responses with commands and hosts", () => { @@ -142,6 +154,40 @@ describe("strict host contracts", () => { }).success, ).toBe(false) }) + + it("binds history completion data to its requested workspace", () => { + const command = hostCommandSchema.parse({ + v: 1, + id: "history", + type: "history.list", + workspace: "/workspace", + }) + const acknowledgement = hostEventSchema.parse({ + v: 1, + seq: 1, + hostId: "host", + type: "command.ack", + commandId: "history", + }) + const completion = hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host", + type: "command.done", + commandId: "history", + data: { commandType: "history.list", workspace: "/workspace", tasks: [] }, + }) + const mismatchedCompletion = hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host", + type: "command.done", + commandId: "history", + data: { commandType: "history.list", workspace: "/other", tasks: [] }, + }) + expect(validateCommandLifecycle([command], [acknowledgement, completion])).toEqual({ ok: true }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedCompletion])).toMatchObject({ ok: false }) + }) }) describe("public automation contracts", () => { @@ -253,6 +299,105 @@ describe("public automation contracts", () => { state: "failed", }) expect(validateStreamLifecycle([init, completedLifecycle, { ...result, seq: 3 }])).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([init, { ...init, seq: 2 }, { ...result, seq: 3 }])).toMatchObject({ ok: false }) + }) + + it("validates task-tree edges and approval command causation", () => { + const init = zooStreamEventSchema.parse({ + v: 1, + seq: 1, + timestamp, + hostId: "host", + type: "system.init", + protocol: "zoo-stream", + capabilities: ["ask:respond"], + clientVersion: "1.0.0", + hostVersion: "1.0.0", + }) + const result = zooStreamEventSchema.parse({ + v: 1, + seq: 4, + timestamp, + hostId: "host", + type: "task.result", + rootTaskId: "root", + taskId: "root", + result: { + schemaVersion: 1, + protocol: "zoo-run-result", + success: true, + outcome: "completed", + rootTaskId: "root", + workspace: "/workspace", + resumable: false, + elapsedMs: 10, + }, + }) + const created = zooStreamEventSchema.parse({ + v: 1, + seq: 2, + timestamp, + hostId: "host", + type: "task.created", + rootTaskId: "root", + taskId: "child", + parentTaskId: "root", + }) + const delegated = zooStreamEventSchema.parse({ + v: 1, + seq: 3, + timestamp, + hostId: "host", + type: "task.delegated", + rootTaskId: "root", + taskId: "child", + parentTaskId: "root", + childTaskId: "child", + }) + expect(validateStreamLifecycle([init, created, delegated, result])).toEqual({ ok: true }) + const mismatchedDelegation = zooStreamEventSchema.parse({ ...delegated, taskId: "root" }) + expect(validateStreamLifecycle([init, created, mismatchedDelegation, result])).toMatchObject({ ok: false }) + + const required = zooStreamEventSchema.parse({ + v: 1, + seq: 2, + timestamp, + hostId: "host", + type: "ask.required", + rootTaskId: "root", + taskId: "root", + askId: "ask", + category: "tool", + subject: "Run command", + }) + const resolved = zooStreamEventSchema.parse({ + v: 1, + seq: 3, + timestamp, + hostId: "host", + requestId: "respond", + type: "ask.resolved", + rootTaskId: "root", + taskId: "root", + askId: "ask", + decision: "approve", + source: "user", + }) + const response = hostCommandSchema.parse({ + v: 1, + id: "respond", + type: "ask.respond", + taskId: "root", + askId: "ask", + response: "approve", + }) + expect(validateStreamLifecycle([init, required, resolved, result], [response])).toEqual({ ok: true }) + const mismatchedResolution = zooStreamEventSchema.parse({ ...resolved, decision: "reject" }) + expect(validateStreamLifecycle([init, required, mismatchedResolution, result], [response])).toMatchObject({ ok: false }) + const deniedApproval = zooStreamEventSchema.parse({ ...resolved, source: "deny" }) + expect( + validateStreamLifecycle([init, required, deniedApproval, result]), + ).toMatchObject({ ok: false }) }) it("maps every terminal outcome deterministically", () => { @@ -265,6 +410,8 @@ describe("public automation contracts", () => { expect(exitCodeFor({ outcome: "failed", errorCode: "host_crashed" })).toBe(EXIT_CODES.runtimeFailure) expect(exitCodeFor({ outcome: "cancelled", signal: "SIGINT" })).toBe(EXIT_CODES.sigint) expect(exitCodeFor({ outcome: "cancelled", signal: "SIGTERM" })).toBe(EXIT_CODES.sigterm) + expect(exitContextSchema.safeParse({ outcome: "cancelled", errorCode: "invalid_mode" }).success).toBe(false) + expect(exitContextSchema.safeParse({ outcome: "completed", signal: "SIGINT" }).success).toBe(false) }) }) @@ -286,6 +433,8 @@ describe("redaction contracts", () => { expect(redactText('{"client_secret":"secret-value","access_token":"token-value"}')).toBe( '{"client_secret":"[REDACTED]","access_token":"[REDACTED]"}', ) + expect(redactText("--api-key abc123 run")).toBe("[REDACTED] run") + expect(redactText('API_TOKEN="abc def" run')).toBe("[REDACTED] run") }) it("handles cycles without throwing", () => { @@ -293,6 +442,11 @@ describe("redaction contracts", () => { input.self = input expect(redactValue(input)).toEqual({ self: "[CIRCULAR]" }) }) + + it("preserves repeated non-cyclic references", () => { + const shared = { value: "safe" } + expect(redactValue({ left: shared, right: shared })).toEqual({ left: { value: "safe" }, right: { value: "safe" } }) + }) }) describe("deterministic parity oracle", () => { diff --git a/packages/zoo-protocol/src/host-commands.ts b/packages/zoo-protocol/src/host-commands.ts index 9e91b08512..9be0e316ff 100644 --- a/packages/zoo-protocol/src/host-commands.ts +++ b/packages/zoo-protocol/src/host-commands.ts @@ -5,7 +5,16 @@ import { ZOO_HOST_PROTOCOL_VERSION } from "./version.js" export const approvalModeSchema = z.enum(["interactive", "safe", "auto"]) export type ApprovalMode = z.infer -export const reasoningEffortSchema = z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]) +export const reasoningEffortSchema = z.enum([ + "disabled", + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]) export const runOverridesSchema = z .object({ diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index 418d4058d8..b6b9111729 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -31,7 +31,11 @@ export const commandDoneDataSchema = z.discriminatedUnion("commandType", [ strictObject({ commandType: z.literal("task.input"), taskId: z.string().min(1) }), strictObject({ commandType: z.literal("ask.respond"), taskId: z.string().min(1), askId: z.string().min(1) }), strictObject({ commandType: z.literal("task.cancel"), rootTaskId: z.string().min(1) }), - strictObject({ commandType: z.literal("history.list"), tasks: z.array(taskSummarySchema) }), + strictObject({ + commandType: z.literal("history.list"), + workspace: z.string().min(1), + tasks: z.array(taskSummarySchema), + }), strictObject({ commandType: z.literal("host.snapshot"), lastSeq: z.number().int().nonnegative(), @@ -104,7 +108,7 @@ export function validateCommandLifecycle( } const firstHostId = events[0]?.hostId - for (const event of events) { + for (const [index, event] of events.entries()) { if (event.hostId !== firstHostId) { const commandId = "commandId" in event ? event.commandId : commands[0]?.id ?? "unknown" return { ok: false, commandId, message: "Command lifecycle cannot span multiple hosts" } @@ -115,6 +119,13 @@ export function validateCommandLifecycle( ) { return { ok: false, commandId: event.commandId, message: "Response references an unknown command" } } + if (index > 0) { + const expected = events[index - 1]!.seq + 1 + if (event.seq !== expected) { + const commandId = "commandId" in event ? event.commandId : commands[0]?.id ?? "unknown" + return { ok: false, commandId, message: `Expected host sequence ${expected}` } + } + } } for (const command of commands) { @@ -153,6 +164,11 @@ export function validateCommandLifecycle( case "task.cancel": return data.commandType === command.type && data.rootTaskId === command.rootTaskId case "history.list": + return ( + data.commandType === command.type && + data.workspace === command.workspace && + data.tasks.every((task) => task.workspace === command.workspace) + ) case "host.snapshot": case "host.shutdown": return data.commandType === command.type diff --git a/packages/zoo-protocol/src/outcomes.ts b/packages/zoo-protocol/src/outcomes.ts index ae63f877c2..1803e89ad0 100644 --- a/packages/zoo-protocol/src/outcomes.ts +++ b/packages/zoo-protocol/src/outcomes.ts @@ -68,13 +68,22 @@ const usageErrors = new Set([ "credentials_missing", ]) -export type ExitContext = { - outcome: ZooOutcome - errorCode?: ZooErrorCode - signal?: "SIGINT" | "SIGTERM" -} +export const exitContextSchema = z.discriminatedUnion("outcome", [ + z.object({ outcome: z.literal("completed") }).strict(), + z.object({ outcome: z.literal("needs_input") }).strict(), + z.object({ outcome: z.literal("cancelled"), signal: z.enum(["SIGINT", "SIGTERM"]).optional() }).strict(), + z + .object({ outcome: z.literal("timed_out"), errorCode: z.enum(["task_timed_out", "cleanup_timed_out"]).optional() }) + .strict(), + z.object({ outcome: z.literal("failed"), errorCode: zooErrorCodeSchema }).strict(), +]) + +export type ExitContext = z.infer -export function exitCodeFor({ outcome, errorCode, signal }: ExitContext): number { +export function exitCodeFor(context: ExitContext): number { + const { outcome } = exitContextSchema.parse(context) + const errorCode = "errorCode" in context ? context.errorCode : undefined + const signal = "signal" in context ? context.signal : undefined if (signal === "SIGINT") return EXIT_CODES.sigint if (signal === "SIGTERM") return EXIT_CODES.sigterm if (errorCode && usageErrors.has(errorCode)) return EXIT_CODES.usage diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 583a9e11eb..485bf37828 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -1,5 +1,6 @@ import { z } from "zod" +import type { HostCommand } from "./host-commands.js" import { zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" import { ZOO_PUBLIC_SCHEMA_VERSION, zooCapabilitySchema } from "./version.js" @@ -159,10 +160,14 @@ export type ZooStreamEvent = z.infer export function validateStreamLifecycle( events: readonly ZooStreamEvent[], + commands: readonly HostCommand[] = [], ): { ok: true } | { ok: false; code: "protocol_gap" | "task_failed"; message: string } { if (events[0]?.type !== "system.init") { return { ok: false, code: "task_failed", message: "Stream must start with system.init" } } + if (events[0].seq !== 1 || events.slice(1).some((streamEvent) => streamEvent.type === "system.init")) { + return { ok: false, code: "protocol_gap", message: "Stream must contain one sequence-1 system.init" } + } const hostId = events[0].hostId if (events.some((streamEvent) => streamEvent.hostId !== hostId)) { return { ok: false, code: "protocol_gap", message: "Stream cannot span multiple hosts" } @@ -186,12 +191,51 @@ export function validateStreamLifecycle( const pendingAsks = new Set() const taskStates = new Map() const terminalStates = new Set(["interrupted", "completed", "failed"]) + const taskParents = new Map([[rootTaskId, null]]) + const createdTasks = new Set() + const delegatedTasks = new Set() for (const streamEvent of events) { if ("rootTaskId" in streamEvent && streamEvent.rootTaskId !== rootTaskId) { return { ok: false, code: "task_failed", message: "All task events must identify the authoritative root task" } } if (!("taskId" in streamEvent) || streamEvent.type === "task.result") continue + if (streamEvent.type === "task.created") { + const parentTaskId = streamEvent.parentTaskId ?? null + if ( + (streamEvent.taskId === rootTaskId) !== (parentTaskId === null) || + (parentTaskId !== null && !taskParents.has(parentTaskId)) || + createdTasks.has(streamEvent.taskId) + ) { + return { ok: false, code: "task_failed", message: `Invalid creation edge for task ${streamEvent.taskId}` } + } + if (taskParents.has(streamEvent.taskId) && taskParents.get(streamEvent.taskId) !== parentTaskId) { + return { ok: false, code: "task_failed", message: `Conflicting parent for task ${streamEvent.taskId}` } + } + taskParents.set(streamEvent.taskId, parentTaskId) + createdTasks.add(streamEvent.taskId) + } else if (streamEvent.type === "task.delegated") { + if ( + streamEvent.taskId !== streamEvent.childTaskId || + streamEvent.childTaskId === rootTaskId || + streamEvent.childTaskId === streamEvent.parentTaskId || + !taskParents.has(streamEvent.parentTaskId) || + delegatedTasks.has(streamEvent.childTaskId) + ) { + return { ok: false, code: "task_failed", message: `Invalid delegation edge for task ${streamEvent.childTaskId}` } + } + if ( + taskParents.has(streamEvent.childTaskId) && + taskParents.get(streamEvent.childTaskId) !== streamEvent.parentTaskId + ) { + return { ok: false, code: "task_failed", message: `Conflicting parent for task ${streamEvent.childTaskId}` } + } + taskParents.set(streamEvent.childTaskId, streamEvent.parentTaskId) + delegatedTasks.add(streamEvent.childTaskId) + } else if (!taskParents.has(streamEvent.taskId)) { + return { ok: false, code: "task_failed", message: `Event references unknown task ${streamEvent.taskId}` } + } + const previousState = taskStates.get(streamEvent.taskId) if (previousState !== undefined && terminalStates.has(previousState)) { return { ok: false, code: "task_failed", message: `Task ${streamEvent.taskId} emitted an event after termination` } @@ -211,6 +255,28 @@ export function validateStreamLifecycle( if (!pendingAsks.delete(askKey)) { return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was not pending` } } + if ( + (streamEvent.source === "deny" && streamEvent.decision !== "reject") || + (streamEvent.source === "auto" && streamEvent.decision !== "approve") + ) { + return { ok: false, code: "task_failed", message: "Ask decision contradicts its resolution source" } + } + if (streamEvent.source === "user") { + const response = commands.find( + (command) => + command.type === "ask.respond" && + command.id === streamEvent.requestId && + command.taskId === streamEvent.taskId && + command.askId === streamEvent.askId, + ) + const expectedDecision = + response?.type === "ask.respond" + ? { approve: "approve", reject: "reject", message: "needs_input" }[response.response] + : undefined + if (expectedDecision !== streamEvent.decision) { + return { ok: false, code: "task_failed", message: "User ask resolution does not match its response command" } + } + } } } if (pendingAsks.size > 0 && resultEvent.result.outcome !== "needs_input") { diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 14cf4c294b..d715c5c53d 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -1,11 +1,13 @@ const REDACTED = "[REDACTED]" as const const sensitiveKey = /(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)/i const sensitiveKeyName = String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)[A-Za-z0-9_-]*` +const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)` const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") const singleQuotedSecret = new RegExp(`('${sensitiveKeyName}'\\s*:\\s*)'(?:\\\\.|[^'\\\\])*'`, "gi") const secretPatterns: ReadonlyArray = [ /\b(?:Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, - new RegExp(`(?()): Redac if (seen.has(value)) return "[CIRCULAR]" seen.add(value) - if (Array.isArray(value)) return value.map((entry) => redactValue(entry, seen)) - - const result: Record = {} - for (const [key, entry] of Object.entries(value)) { - result[key] = sensitiveKey.test(key) ? REDACTED : redactValue(entry, seen) + let result: RedactedValue + if (Array.isArray(value)) { + result = value.map((entry) => redactValue(entry, seen)) + } else { + const entries: Record = {} + for (const [key, entry] of Object.entries(value)) { + entries[key] = sensitiveKey.test(key) ? REDACTED : redactValue(entry, seen) + } + result = entries } + seen.delete(value) return result } diff --git a/packages/zoo-protocol/src/version.ts b/packages/zoo-protocol/src/version.ts index 7f7a4f8d6f..4152a98938 100644 --- a/packages/zoo-protocol/src/version.ts +++ b/packages/zoo-protocol/src/version.ts @@ -23,10 +23,22 @@ export const hostHelloSchema = z type: z.literal("hello"), hostId: z.string().min(1), supportedVersions: z.array(z.number().int().positive()).nonempty(), - capabilities: z.array(z.string().min(1)), + capabilities: z.record(z.string().regex(/^[1-9]\d*$/), z.array(z.string().min(1))), buildVersion: z.string().min(1), }) .strict() + .superRefine((hello, context) => { + const advertisedVersions = Object.keys(hello.capabilities).map(Number) + if ( + advertisedVersions.some((version) => !hello.supportedVersions.includes(version)) || + hello.supportedVersions.some((version) => !(String(version) in hello.capabilities)) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capabilities must be advertised for exactly the supported protocol versions", + }) + } + }) export type HostHello = z.infer @@ -50,7 +62,15 @@ export function negotiateProtocol( supportedVersions: readonly number[], requiredCapabilities: readonly ZooCapability[], ): NegotiationResult { - const missing = requiredCapabilities.filter((capability) => !host.capabilities.includes(capability)) + const version = [...supportedVersions] + .sort((left, right) => right - left) + .find((candidate) => host.supportedVersions.includes(candidate)) + if (version === undefined) { + return { ok: false, code: "protocol_incompatible", message: "No mutually supported host protocol version" } + } + + const capabilities = host.capabilities[String(version)] ?? [] + const missing = requiredCapabilities.filter((capability) => !capabilities.includes(capability)) if (missing.length > 0) { return { ok: false, @@ -59,10 +79,5 @@ export function negotiateProtocol( } } - const version = [...supportedVersions] - .sort((left, right) => right - left) - .find((candidate) => host.supportedVersions.includes(candidate)) - return version === undefined - ? { ok: false, code: "protocol_incompatible", message: "No mutually supported host protocol version" } - : { ok: true, version } + return { ok: true, version } } From 06f31d09a732a51268534b76c39ce176ea8b6b8f Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 02:47:13 -0400 Subject: [PATCH 06/24] no-mistakes(review): Tighten Zoo protocol contracts and lifecycle invariants --- .../src/__tests__/contracts.test.ts | 412 ++++++++++++------ packages/zoo-protocol/src/outcomes.ts | 14 +- packages/zoo-protocol/src/parity.ts | 164 ++++++- packages/zoo-protocol/src/public-events.ts | 162 ++++++- packages/zoo-protocol/src/redaction.ts | 8 +- packages/zoo-protocol/src/version.ts | 28 +- 6 files changed, 605 insertions(+), 183 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 8000dcc306..bbdc029e81 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -9,12 +9,14 @@ import { hostEventSchema, hostHelloSchema, negotiateProtocol, + parentHelloSchema, parityScenarios, redactText, redactValue, runDeterministicFakeProvider, validateCommandLifecycle, validateMonotonicSequence, + validateParentHello, validateStreamLifecycle, zooRunResultSchema, zooStreamEventSchema, @@ -22,6 +24,51 @@ import { const timestamp = "2026-08-05T12:00:00.000Z" +const initEvent = zooStreamEventSchema.parse({ + v: 1, + seq: 1, + timestamp, + hostId: "host", + type: "system.init", + protocol: "zoo-stream", + capabilities: ["task:start"], + clientVersion: "1.0.0", + hostVersion: "1.0.0", +}) + +function taskEvent(seq: number, type: string, fields: Record = {}) { + return zooStreamEventSchema.parse({ + v: 1, + seq, + timestamp, + hostId: "host", + type, + rootTaskId: "root", + taskId: "root", + ...fields, + }) +} + +function resultEvent(seq: number, result: Record = {}, event: Record = {}) { + const outcome = result.outcome ?? "completed" + const parsed = taskEvent(seq, "task.result", { + result: { + schemaVersion: 1, + protocol: "zoo-run-result", + success: outcome === "completed", + outcome, + rootTaskId: "root", + workspace: "/workspace", + resumable: false, + elapsedMs: 10, + ...result, + }, + ...event, + }) + if (parsed.type !== "task.result") throw new Error("Expected task.result fixture") + return parsed +} + describe("strict host contracts", () => { it("accepts a valid start and rejects unknown fields", () => { const command = { @@ -35,7 +82,9 @@ describe("strict host contracts", () => { expect(hostCommandSchema.parse(command)).toEqual(command) expect(hostCommandSchema.safeParse({ ...command, unexpected: true }).success).toBe(false) expect(hostCommandSchema.safeParse({ ...command, overrides: { reasoningEffort: "max" } }).success).toBe(true) - expect(hostCommandSchema.safeParse({ ...command, overrides: { reasoningEffort: "disabled" } }).success).toBe(true) + expect(hostCommandSchema.safeParse({ ...command, overrides: { reasoningEffort: "disabled" } }).success).toBe( + true, + ) }) it("enforces input and approval payload invariants", () => { @@ -70,6 +119,31 @@ describe("strict host contracts", () => { }) expect(negotiateProtocol(multiVersionHello, [1], ["task:resume"])).toMatchObject({ ok: false }) expect(negotiateProtocol(multiVersionHello, [2, 1], ["task:resume"])).toEqual({ ok: true, version: 2 }) + const lowerVersionCapabilities = hostHelloSchema.parse({ + ...hello, + supportedVersions: [1, 2], + capabilities: { 1: ["task:start", "task:resume"], 2: ["task:start"] }, + }) + expect(negotiateProtocol(lowerVersionCapabilities, [2, 1], ["task:resume"])).toEqual({ ok: true, version: 1 }) + }) + + it("binds parent selection to the host advertisement", () => { + const host = hostHelloSchema.parse({ + type: "hello", + hostId: "host-1", + supportedVersions: [1, 2], + capabilities: { 1: ["task:start"], 2: ["task:start", "task:resume"] }, + buildVersion: "1.0.0", + }) + const selected = parentHelloSchema.parse({ + type: "hello.select", + version: 2, + clientVersion: "1.0.0", + requiredCapabilities: ["task:resume"], + }) + expect(validateParentHello(host, selected)).toEqual({ ok: true, version: 2 }) + expect(validateParentHello(host, { ...selected, version: 3 })).toMatchObject({ ok: false }) + expect(validateParentHello(host, { ...selected, version: 1 })).toMatchObject({ ok: false }) }) it("requires contiguous host sequence numbers", () => { @@ -93,7 +167,9 @@ describe("strict host contracts", () => { expect(validateCommandLifecycle([command], events)).toEqual({ ok: true }) expect(validateCommandLifecycle([command], [...events, events[1]!])).toMatchObject({ ok: false }) expect(validateCommandLifecycle([command], [events[1]!, events[0]!])).toMatchObject({ ok: false }) - expect(validateCommandLifecycle([command], [events[0]!, { ...events[1]!, seq: 3 }])).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [events[0]!, { ...events[1]!, seq: 3 }])).toMatchObject({ + ok: false, + }) }) it("correlates terminal responses with commands and hosts", () => { @@ -139,7 +215,9 @@ describe("strict host contracts", () => { expect(validateCommandLifecycle([command], [acknowledgement, completion])).toEqual({ ok: true }) expect(validateCommandLifecycle([command], [acknowledgement, mismatchedIdentity])).toMatchObject({ ok: false }) expect(validateCommandLifecycle([command], [acknowledgement, mismatchedType])).toMatchObject({ ok: false }) - expect(validateCommandLifecycle([command], [acknowledgement, { ...completion, hostId: "host-b" }])).toMatchObject({ + expect( + validateCommandLifecycle([command], [acknowledgement, { ...completion, hostId: "host-b" }]), + ).toMatchObject({ ok: false, }) }) @@ -186,7 +264,9 @@ describe("strict host contracts", () => { data: { commandType: "history.list", workspace: "/other", tasks: [] }, }) expect(validateCommandLifecycle([command], [acknowledgement, completion])).toEqual({ ok: true }) - expect(validateCommandLifecycle([command], [acknowledgement, mismatchedCompletion])).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedCompletion])).toMatchObject({ + ok: false, + }) }) }) @@ -211,6 +291,22 @@ describe("public automation contracts", () => { error: { code: "task_failed", message: "contradiction" }, }).success, ).toBe(false) + expect( + zooRunResultSchema.safeParse({ + ...result, + success: false, + outcome: "needs_input", + error: { code: "provider_failed", message: "contradiction" }, + }).success, + ).toBe(false) + expect( + zooRunResultSchema.safeParse({ + ...result, + success: false, + outcome: "failed", + error: { code: "task_timed_out", message: "contradiction" }, + }).success, + ).toBe(false) }) it("validates strict, ordered stream records", () => { @@ -233,152 +329,80 @@ describe("public automation contracts", () => { expect(zooStreamEventSchema.safeParse({ ...event, taskId: undefined }).success).toBe(false) }) - it("requires init, contiguous sequence, and exactly one terminal root result", () => { - const init = zooStreamEventSchema.parse({ - v: 1, - seq: 1, - timestamp, - hostId: "host", - type: "system.init", - protocol: "zoo-stream", - capabilities: ["task:start"], - clientVersion: "1.0.0", - hostVersion: "1.0.0", + it("requires init, contiguous sequence, and a settled authoritative root", () => { + const created = taskEvent(2, "task.created") + const completed = taskEvent(3, "task.lifecycle", { state: "completed" }) + const result = resultEvent(4) + expect(validateStreamLifecycle([initEvent, created, completed, result])).toEqual({ ok: true }) + expect(validateStreamLifecycle([initEvent, resultEvent(2)])).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([initEvent, created, resultEvent(3)])).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([initEvent, created, completed, { ...result, hostId: "other-host" }]), + ).toMatchObject({ + ok: false, + }) + expect(validateStreamLifecycle([{ ...initEvent, seq: 2 }, created, completed, result])).toMatchObject({ + ok: false, + }) + expect(validateStreamLifecycle([initEvent])).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([initEvent, created, completed, { ...result, taskId: "child" }])).toMatchObject({ + ok: false, }) - const result = zooStreamEventSchema.parse({ - v: 1, - seq: 2, - timestamp, - hostId: "host", - type: "task.result", - rootTaskId: "root", - taskId: "root", - result: { - schemaVersion: 1, - protocol: "zoo-run-result", - success: true, - outcome: "completed", - rootTaskId: "root", - workspace: "/workspace", - resumable: false, - elapsedMs: 10, - }, - }) - const childResult = zooStreamEventSchema.parse({ ...result, taskId: "child" }) - expect(validateStreamLifecycle([init, result])).toEqual({ ok: true }) - expect(validateStreamLifecycle([init, { ...result, hostId: "other-host" }])).toMatchObject({ ok: false }) - expect(validateStreamLifecycle([{ ...init, seq: 2 }, result])).toMatchObject({ ok: false }) - expect(validateStreamLifecycle([init])).toMatchObject({ ok: false }) - expect(validateStreamLifecycle([init, childResult])).toMatchObject({ ok: false }) expect( - validateStreamLifecycle([ - init, - zooStreamEventSchema.parse({ - v: 1, - seq: 2, - timestamp, - hostId: "host", - type: "ask.required", - rootTaskId: "root", - taskId: "root", - askId: "ask-1", - category: "tool", - subject: "Run command", - }), - { ...result, seq: 3 }, - ]), + validateStreamLifecycle([initEvent, created, taskEvent(3, "task.lifecycle", { state: "failed" }), result]), ).toMatchObject({ ok: false }) - const completedLifecycle = zooStreamEventSchema.parse({ - v: 1, - seq: 2, - timestamp, - hostId: "host", - type: "task.lifecycle", - rootTaskId: "root", - taskId: "root", - state: "failed", + expect(validateStreamLifecycle([initEvent, { ...initEvent, seq: 2 }, resultEvent(3)])).toMatchObject({ + ok: false, }) - expect(validateStreamLifecycle([init, completedLifecycle, { ...result, seq: 3 }])).toMatchObject({ ok: false }) - expect(validateStreamLifecycle([init, { ...init, seq: 2 }, { ...result, seq: 3 }])).toMatchObject({ ok: false }) }) - it("validates task-tree edges and approval command causation", () => { - const init = zooStreamEventSchema.parse({ - v: 1, - seq: 1, - timestamp, - hostId: "host", - type: "system.init", - protocol: "zoo-stream", - capabilities: ["ask:respond"], - clientVersion: "1.0.0", - hostVersion: "1.0.0", - }) - const result = zooStreamEventSchema.parse({ - v: 1, - seq: 4, - timestamp, - hostId: "host", - type: "task.result", - rootTaskId: "root", - taskId: "root", - result: { - schemaVersion: 1, - protocol: "zoo-run-result", - success: true, - outcome: "completed", - rootTaskId: "root", - workspace: "/workspace", - resumable: false, - elapsedMs: 10, - }, - }) - const created = zooStreamEventSchema.parse({ - v: 1, - seq: 2, - timestamp, - hostId: "host", - type: "task.created", - rootTaskId: "root", + it("validates task-tree settlement and approval command causation", () => { + const rootCreated = taskEvent(2, "task.created") + const childCreated = taskEvent(3, "task.created", { taskId: "child", parentTaskId: "root" }) + const delegated = taskEvent(4, "task.delegated", { taskId: "child", parentTaskId: "root", + childTaskId: "child", }) - const delegated = zooStreamEventSchema.parse({ - v: 1, - seq: 3, - timestamp, - hostId: "host", - type: "task.delegated", - rootTaskId: "root", - taskId: "child", + const childCompleted = taskEvent(5, "task.lifecycle", { taskId: "child", state: "completed" }) + const rootCompleted = taskEvent(6, "task.lifecycle", { state: "completed" }) + expect( + validateStreamLifecycle([ + initEvent, + rootCreated, + childCreated, + delegated, + childCompleted, + rootCompleted, + resultEvent(7), + ]), + ).toEqual({ ok: true }) + expect( + validateStreamLifecycle([initEvent, rootCreated, childCreated, delegated, rootCompleted, resultEvent(6)]), + ).toMatchObject({ ok: false }) + const mismatchedDelegation = taskEvent(4, "task.delegated", { + taskId: "root", parentTaskId: "root", childTaskId: "child", }) - expect(validateStreamLifecycle([init, created, delegated, result])).toEqual({ ok: true }) - const mismatchedDelegation = zooStreamEventSchema.parse({ ...delegated, taskId: "root" }) - expect(validateStreamLifecycle([init, created, mismatchedDelegation, result])).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + rootCreated, + childCreated, + mismatchedDelegation, + rootCompleted, + resultEvent(6), + ]), + ).toMatchObject({ ok: false }) - const required = zooStreamEventSchema.parse({ - v: 1, - seq: 2, - timestamp, - hostId: "host", - type: "ask.required", - rootTaskId: "root", - taskId: "root", + const required = taskEvent(3, "ask.required", { askId: "ask", category: "tool", subject: "Run command", }) - const resolved = zooStreamEventSchema.parse({ - v: 1, - seq: 3, - timestamp, - hostId: "host", + const resolved = taskEvent(4, "ask.resolved", { requestId: "respond", - type: "ask.resolved", - rootTaskId: "root", - taskId: "root", askId: "ask", decision: "approve", source: "user", @@ -391,12 +415,96 @@ describe("public automation contracts", () => { askId: "ask", response: "approve", }) - expect(validateStreamLifecycle([init, required, resolved, result], [response])).toEqual({ ok: true }) + const completed = taskEvent(5, "task.lifecycle", { state: "completed" }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, required, resolved, completed, resultEvent(6)], + [response], + ), + ).toEqual({ + ok: true, + }) const mismatchedResolution = zooStreamEventSchema.parse({ ...resolved, decision: "reject" }) - expect(validateStreamLifecycle([init, required, mismatchedResolution, result], [response])).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, required, mismatchedResolution, completed, resultEvent(6)], + [response], + ), + ).toMatchObject({ ok: false }) const deniedApproval = zooStreamEventSchema.parse({ ...resolved, source: "deny" }) expect( - validateStreamLifecycle([init, required, deniedApproval, result]), + validateStreamLifecycle([initEvent, rootCreated, required, deniedApproval, completed, resultEvent(6)]), + ).toMatchObject({ ok: false }) + }) + + it("correlates cancellation and settles operation lifecycles", () => { + const created = taskEvent(2, "task.created") + const toolStarted = taskEvent(3, "tool.started", { toolCallId: "tool", name: "read" }) + const toolCompleted = taskEvent(4, "tool.completed", { toolCallId: "tool", name: "read" }) + const terminalStarted = taskEvent(5, "terminal.status", { toolCallId: "terminal", state: "running" }) + const terminalExited = taskEvent(6, "terminal.status", { toolCallId: "terminal", state: "exited", exitCode: 0 }) + const mcpStarted = taskEvent(7, "mcp.started", { operationId: "mcp", server: "test", operation: "read" }) + const mcpCompleted = taskEvent(8, "mcp.completed", { operationId: "mcp", server: "test", operation: "read" }) + const interrupted = taskEvent(9, "task.lifecycle", { state: "interrupted" }) + const cancelled = resultEvent(10, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) + const command = hostCommandSchema.parse({ + v: 1, + id: "cancel", + type: "task.cancel", + rootTaskId: "root", + reason: "user", + }) + if (command.type !== "task.cancel") throw new Error("Expected task.cancel fixture") + const stream = [ + initEvent, + created, + toolStarted, + toolCompleted, + terminalStarted, + terminalExited, + mcpStarted, + mcpCompleted, + interrupted, + cancelled, + ] + expect(validateStreamLifecycle(stream, [command])).toEqual({ ok: true }) + expect(validateStreamLifecycle(stream)).toMatchObject({ ok: false }) + expect(validateStreamLifecycle(stream, [{ ...command, reason: "signal" }])).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + created, + toolCompleted, + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ]), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + created, + toolStarted, + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ]), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + created, + taskEvent(3, "terminal.status", { toolCallId: "terminal", state: "exited", exitCode: 0 }), + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ]), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + created, + mcpCompleted, + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ]), ).toMatchObject({ ok: false }) }) @@ -412,6 +520,7 @@ describe("public automation contracts", () => { expect(exitCodeFor({ outcome: "cancelled", signal: "SIGTERM" })).toBe(EXIT_CODES.sigterm) expect(exitContextSchema.safeParse({ outcome: "cancelled", errorCode: "invalid_mode" }).success).toBe(false) expect(exitContextSchema.safeParse({ outcome: "completed", signal: "SIGINT" }).success).toBe(false) + expect(exitContextSchema.safeParse({ outcome: "failed", errorCode: "task_timed_out" }).success).toBe(false) }) }) @@ -430,6 +539,8 @@ describe("redaction contracts", () => { expect(redactText("Authorization: Bearer abcdefgh")).not.toContain("abcdefgh") expect(redactText("Authorization: abc123\nCookie: session=abc")).not.toMatch(/abc123|session=abc/) expect(redactText('{"password":"hunter2"}')).toBe('{"password":"[REDACTED]"}') + expect(redactText('{"password": hunter2}')).toBe('{"password": [REDACTED]}') + expect(redactText("{'api_key': abc123}")).toBe("{'api_key': [REDACTED]}") expect(redactText('{"client_secret":"secret-value","access_token":"token-value"}')).toBe( '{"client_secret":"[REDACTED]","access_token":"[REDACTED]"}', ) @@ -437,6 +548,28 @@ describe("redaction contracts", () => { expect(redactText('API_TOKEN="abc def" run')).toBe("[REDACTED] run") }) + it("redacts public event and result payloads during parsing", () => { + const message = taskEvent(1, "message.upsert", { + messageId: "message", + role: "assistant", + content: "Authorization: Bearer abcdefgh", + complete: true, + }) + expect(message.type === "message.upsert" && message.content).toBe("[REDACTED]") + const result = zooRunResultSchema.parse({ + schemaVersion: 1, + protocol: "zoo-run-result", + success: true, + outcome: "completed", + rootTaskId: "root", + workspace: "/workspace", + resumable: false, + content: "password=hunter2", + elapsedMs: 1, + }) + expect(result.content).toBe("[REDACTED]") + }) + it("handles cycles without throwing", () => { const input: Record = {} input.self = input @@ -445,7 +578,10 @@ describe("redaction contracts", () => { it("preserves repeated non-cyclic references", () => { const shared = { value: "safe" } - expect(redactValue({ left: shared, right: shared })).toEqual({ left: { value: "safe" }, right: { value: "safe" } }) + expect(redactValue({ left: shared, right: shared })).toEqual({ + left: { value: "safe" }, + right: { value: "safe" }, + }) }) }) @@ -456,7 +592,9 @@ describe("deterministic parity oracle", () => { it("includes the prompt in fake-provider semantics", () => { const scenario = { ...parityScenarios[0]!, prompt: "Changed prompt" } - expect(compareSemanticTraces(parityScenarios[0]!.expected, runDeterministicFakeProvider(scenario))).toMatchObject({ + expect( + compareSemanticTraces(parityScenarios[0]!.expected, runDeterministicFakeProvider(scenario)), + ).toMatchObject({ ok: false, }) }) diff --git a/packages/zoo-protocol/src/outcomes.ts b/packages/zoo-protocol/src/outcomes.ts index 1803e89ad0..89a9d5ae60 100644 --- a/packages/zoo-protocol/src/outcomes.ts +++ b/packages/zoo-protocol/src/outcomes.ts @@ -31,6 +31,13 @@ export const zooErrorCodes = [ export const zooErrorCodeSchema = z.enum(zooErrorCodes) export type ZooErrorCode = z.infer +export const failedErrorCodeSchema = z.enum( + zooErrorCodes.filter((code) => code !== "task_timed_out" && code !== "cleanup_timed_out") as [ + Exclude, + ...Exclude[], + ], +) + export const zooErrorKindSchema = z.enum(["configuration", "provider", "runtime"]) export type ZooErrorKind = z.infer @@ -73,9 +80,12 @@ export const exitContextSchema = z.discriminatedUnion("outcome", [ z.object({ outcome: z.literal("needs_input") }).strict(), z.object({ outcome: z.literal("cancelled"), signal: z.enum(["SIGINT", "SIGTERM"]).optional() }).strict(), z - .object({ outcome: z.literal("timed_out"), errorCode: z.enum(["task_timed_out", "cleanup_timed_out"]).optional() }) + .object({ + outcome: z.literal("timed_out"), + errorCode: z.enum(["task_timed_out", "cleanup_timed_out"]).optional(), + }) .strict(), - z.object({ outcome: z.literal("failed"), errorCode: zooErrorCodeSchema }).strict(), + z.object({ outcome: z.literal("failed"), errorCode: failedErrorCodeSchema }).strict(), ]) export type ExitContext = z.infer diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index 8e098aa41c..0bab5c28cf 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -1,10 +1,17 @@ -import type { ZooOutcome, ZooErrorCode } from "./outcomes.js" +import { type ZooErrorCode, type ZooOutcome, zooErrorCodeSchema } from "./outcomes.js" export type SemanticTraceEntry = { type: string taskId?: string + rootTaskId?: string parentTaskId?: string toolCallId?: string + state?: "running" | "waiting" | "interrupted" | "completed" | "failed" + askId?: string + decision?: "approve" | "reject" | "needs_input" + source?: "policy" | "user" | "auto" | "deny" + requestId?: string + cancellationReason?: "user" | "signal" | "timeout" content?: string prompt?: string outcome?: ZooOutcome @@ -24,9 +31,10 @@ export const parityScenarios: readonly ParityScenario[] = [ prompt: "Reply with the fixture greeting.", providerTurns: ["Hello from Zoo."], expected: [ - { type: "task.created", taskId: "root", prompt: "Reply with the fixture greeting." }, - { type: "message.upsert", taskId: "root", content: "Hello from Zoo." }, - { type: "task.result", taskId: "root", outcome: "completed" }, + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Reply with the fixture greeting." }, + { type: "message.upsert", rootTaskId: "root", taskId: "root", content: "Hello from Zoo." }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, + { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" }, ], }, { @@ -34,11 +42,17 @@ export const parityScenarios: readonly ParityScenario[] = [ prompt: "Read README.md and report its title.", providerTurns: ["tool:read_file:call-1:README.md", "Zoo Code"], expected: [ - { type: "task.created", taskId: "root", prompt: "Read README.md and report its title." }, - { type: "tool.started", taskId: "root", toolCallId: "call-1" }, - { type: "tool.completed", taskId: "root", toolCallId: "call-1" }, - { type: "message.upsert", taskId: "root", content: "Zoo Code" }, - { type: "task.result", taskId: "root", outcome: "completed" }, + { + type: "task.created", + rootTaskId: "root", + taskId: "root", + prompt: "Read README.md and report its title.", + }, + { type: "tool.started", rootTaskId: "root", taskId: "root", toolCallId: "call-1" }, + { type: "tool.completed", rootTaskId: "root", taskId: "root", toolCallId: "call-1" }, + { type: "message.upsert", rootTaskId: "root", taskId: "root", content: "Zoo Code" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, + { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" }, ], }, { @@ -46,11 +60,71 @@ export const parityScenarios: readonly ParityScenario[] = [ prompt: "Delegate once, then finish the root task.", providerTurns: ["delegate:child", "child:done", "root:accepted"], expected: [ - { type: "task.created", taskId: "root", prompt: "Delegate once, then finish the root task." }, - { type: "task.delegated", taskId: "child", parentTaskId: "root" }, - { type: "task.lifecycle", taskId: "child" }, - { type: "message.upsert", taskId: "root", content: "root:accepted" }, - { type: "task.result", taskId: "root", outcome: "completed" }, + { + type: "task.created", + rootTaskId: "root", + taskId: "root", + prompt: "Delegate once, then finish the root task.", + }, + { type: "task.created", rootTaskId: "root", taskId: "child", parentTaskId: "root" }, + { type: "task.delegated", rootTaskId: "root", taskId: "child", parentTaskId: "root" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "child", state: "completed" }, + { type: "message.upsert", rootTaskId: "root", taskId: "root", content: "root:accepted" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, + { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" }, + ], + }, + { + id: "approval-causation", + prompt: "Request approval.", + providerTurns: ["ask:ask-1", "approve:ask-1:user:respond-1"], + expected: [ + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Request approval." }, + { type: "ask.required", rootTaskId: "root", taskId: "root", askId: "ask-1" }, + { + type: "ask.resolved", + rootTaskId: "root", + taskId: "root", + askId: "ask-1", + decision: "approve", + source: "user", + requestId: "respond-1", + }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, + { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" }, + ], + }, + { + id: "cancelled", + prompt: "Cancel deterministically.", + providerTurns: ["cancel:cancel-1:user"], + expected: [ + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Cancel deterministically." }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted" }, + { + type: "task.result", + rootTaskId: "root", + taskId: "root", + outcome: "cancelled", + requestId: "cancel-1", + cancellationReason: "user", + }, + ], + }, + { + id: "provider-failure", + prompt: "Fail deterministically.", + providerTurns: ["fail:provider_failed"], + expected: [ + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Fail deterministically." }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "failed" }, + { + type: "task.result", + rootTaskId: "root", + taskId: "root", + outcome: "failed", + errorCode: "provider_failed", + }, ], }, ] @@ -79,34 +153,80 @@ export function compareSemanticTraces( export function runDeterministicFakeProvider(scenario: ParityScenario): readonly SemanticTraceEntry[] { if (scenario.prompt.trim().length === 0) throw new Error("Fake-provider scenarios require a prompt") - const trace: SemanticTraceEntry[] = [{ type: "task.created", taskId: "root", prompt: scenario.prompt }] + const trace: SemanticTraceEntry[] = [ + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: scenario.prompt }, + ] + let result: SemanticTraceEntry = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" } for (const turn of scenario.providerTurns) { if (turn.startsWith("tool:")) { const [, operation, toolCallId, argument] = turn.split(":") if (operation !== "read_file" || !toolCallId || !argument) throw new Error(`Invalid tool fixture: ${turn}`) - trace.push({ type: "tool.started", taskId: "root", toolCallId }) - trace.push({ type: "tool.completed", taskId: "root", toolCallId }) + trace.push({ type: "tool.started", rootTaskId: "root", taskId: "root", toolCallId }) + trace.push({ type: "tool.completed", rootTaskId: "root", taskId: "root", toolCallId }) continue } if (turn.startsWith("delegate:")) { const taskId = turn.slice("delegate:".length) if (!taskId) throw new Error(`Invalid delegation fixture: ${turn}`) - trace.push({ type: "task.delegated", taskId, parentTaskId: "root" }) + trace.push({ type: "task.created", rootTaskId: "root", taskId, parentTaskId: "root" }) + trace.push({ type: "task.delegated", rootTaskId: "root", taskId, parentTaskId: "root" }) continue } if (turn.endsWith(":done")) { const taskId = turn.slice(0, -":done".length) if (!taskId) throw new Error(`Invalid completion fixture: ${turn}`) - trace.push({ type: "task.lifecycle", taskId }) + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId, state: "completed" }) + continue + } + if (turn.startsWith("ask:")) { + trace.push({ type: "ask.required", rootTaskId: "root", taskId: "root", askId: turn.slice(4) }) + continue + } + if (turn.startsWith("approve:")) { + const [, askId, source, requestId] = turn.split(":") + if (!askId || source !== "user" || !requestId) throw new Error(`Invalid approval fixture: ${turn}`) + trace.push({ + type: "ask.resolved", + rootTaskId: "root", + taskId: "root", + askId, + decision: "approve", + source, + requestId, + }) continue } - trace.push({ type: "message.upsert", taskId: "root", content: turn }) + if (turn.startsWith("cancel:")) { + const [, requestId, cancellationReason] = turn.split(":") + if (!requestId || !["user", "signal", "timeout"].includes(cancellationReason ?? "")) + throw new Error(`Invalid cancellation fixture: ${turn}`) + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted" }) + result = { + type: "task.result", + rootTaskId: "root", + taskId: "root", + outcome: "cancelled", + requestId, + cancellationReason: cancellationReason as "user" | "signal" | "timeout", + } + continue + } + if (turn.startsWith("fail:")) { + const errorCode = zooErrorCodeSchema.parse(turn.slice(5)) + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "failed" }) + result = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "failed", errorCode } + continue + } + trace.push({ type: "message.upsert", rootTaskId: "root", taskId: "root", content: turn }) + } + if (result.outcome === "completed") { + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }) } - trace.push({ type: "task.result", taskId: "root", outcome: "completed" }) + trace.push(result) return trace } export function assertAuthoritativeRootResult(trace: readonly SemanticTraceEntry[], rootTaskId: string): boolean { const results = trace.filter((entry) => entry.type === "task.result") - return results.length === 1 && results[0]?.taskId === rootTaskId + return results.length === 1 && results[0]?.taskId === rootTaskId && results[0].rootTaskId === rootTaskId } diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 485bf37828..dcd0e54d46 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -1,7 +1,8 @@ import { z } from "zod" import type { HostCommand } from "./host-commands.js" -import { zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" +import { failedErrorCodeSchema, zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" +import { redactValue } from "./redaction.js" import { ZOO_PUBLIC_SCHEMA_VERSION, zooCapabilitySchema } from "./version.js" const strictObject = (shape: T) => z.object(shape).strict() @@ -15,7 +16,7 @@ export const usageSchema = strictObject({ export const changedFileSchema = strictObject({ path: z.string().min(1), status: z.string().min(1) }) -export const zooRunResultSchema = strictObject({ +const rawZooRunResultSchema = strictObject({ schemaVersion: z.literal(ZOO_PUBLIC_SCHEMA_VERSION), protocol: z.literal("zoo-run-result"), success: z.boolean(), @@ -30,6 +31,7 @@ export const zooRunResultSchema = strictObject({ cost: z.number().nonnegative().optional(), elapsedMs: z.number().int().nonnegative(), changedFiles: z.array(changedFileSchema).optional(), + cancellationReason: z.enum(["user", "signal", "timeout"]).optional(), }).superRefine((result, context) => { if (result.success !== (result.outcome === "completed")) { context.addIssue({ code: z.ZodIssueCode.custom, message: "success must match completed outcome" }) @@ -37,11 +39,30 @@ export const zooRunResultSchema = strictObject({ if (result.outcome === "failed" && result.error === undefined) { context.addIssue({ code: z.ZodIssueCode.custom, message: "failed results require an error" }) } - if (result.outcome === "completed" && result.error !== undefined) { - context.addIssue({ code: z.ZodIssueCode.custom, message: "completed results cannot include an error" }) + if ( + result.outcome === "failed" && + result.error !== undefined && + !failedErrorCodeSchema.safeParse(result.error.code).success + ) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "failed results require a non-timeout error code" }) + } + if ( + result.outcome === "timed_out" && + result.error !== undefined && + !["task_timed_out", "cleanup_timed_out"].includes(result.error.code) + ) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "timed_out results require a timeout error code" }) + } + if (!["failed", "timed_out"].includes(result.outcome) && result.error !== undefined) { + context.addIssue({ code: z.ZodIssueCode.custom, message: `${result.outcome} results cannot include an error` }) + } + if ((result.outcome === "cancelled") !== (result.cancellationReason !== undefined)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "cancelled results require a cancellation reason" }) } }) +export const zooRunResultSchema = z.preprocess((value) => redactValue(value), rawZooRunResultSchema) + export type ZooRunResult = z.infer const eventBase = { @@ -132,7 +153,7 @@ const usageUpdatedEventSchema = taskEvent("usage.updated", { }) const taskResultEventSchema = taskEvent("task.result", { result: zooRunResultSchema }) -export const zooStreamEventSchema = z.discriminatedUnion("type", [ +const rawZooStreamEventSchema = z.discriminatedUnion("type", [ systemInitEventSchema, systemWarningEventSchema, taskCreatedEventSchema, @@ -156,6 +177,8 @@ export const zooStreamEventSchema = z.discriminatedUnion("type", [ taskResultEventSchema, ]) +export const zooStreamEventSchema = z.preprocess((value) => redactValue(value), rawZooStreamEventSchema) + export type ZooStreamEvent = z.infer export function validateStreamLifecycle( @@ -191,12 +214,19 @@ export function validateStreamLifecycle( const pendingAsks = new Set() const taskStates = new Map() const terminalStates = new Set(["interrupted", "completed", "failed"]) - const taskParents = new Map([[rootTaskId, null]]) + const taskParents = new Map() const createdTasks = new Set() const delegatedTasks = new Set() + const toolStates = new Map() + const terminalOperationStates = new Map() + const mcpStates = new Map() for (const streamEvent of events) { if ("rootTaskId" in streamEvent && streamEvent.rootTaskId !== rootTaskId) { - return { ok: false, code: "task_failed", message: "All task events must identify the authoritative root task" } + return { + ok: false, + code: "task_failed", + message: "All task events must identify the authoritative root task", + } } if (!("taskId" in streamEvent) || streamEvent.type === "task.result") continue @@ -205,9 +235,14 @@ export function validateStreamLifecycle( if ( (streamEvent.taskId === rootTaskId) !== (parentTaskId === null) || (parentTaskId !== null && !taskParents.has(parentTaskId)) || + (parentTaskId !== null && terminalStates.has(taskStates.get(parentTaskId) ?? "")) || createdTasks.has(streamEvent.taskId) ) { - return { ok: false, code: "task_failed", message: `Invalid creation edge for task ${streamEvent.taskId}` } + return { + ok: false, + code: "task_failed", + message: `Invalid creation edge for task ${streamEvent.taskId}`, + } } if (taskParents.has(streamEvent.taskId) && taskParents.get(streamEvent.taskId) !== parentTaskId) { return { ok: false, code: "task_failed", message: `Conflicting parent for task ${streamEvent.taskId}` } @@ -219,16 +254,26 @@ export function validateStreamLifecycle( streamEvent.taskId !== streamEvent.childTaskId || streamEvent.childTaskId === rootTaskId || streamEvent.childTaskId === streamEvent.parentTaskId || - !taskParents.has(streamEvent.parentTaskId) || + !createdTasks.has(streamEvent.parentTaskId) || + !createdTasks.has(streamEvent.childTaskId) || + terminalStates.has(taskStates.get(streamEvent.parentTaskId) ?? "") || delegatedTasks.has(streamEvent.childTaskId) ) { - return { ok: false, code: "task_failed", message: `Invalid delegation edge for task ${streamEvent.childTaskId}` } + return { + ok: false, + code: "task_failed", + message: `Invalid delegation edge for task ${streamEvent.childTaskId}`, + } } if ( taskParents.has(streamEvent.childTaskId) && taskParents.get(streamEvent.childTaskId) !== streamEvent.parentTaskId ) { - return { ok: false, code: "task_failed", message: `Conflicting parent for task ${streamEvent.childTaskId}` } + return { + ok: false, + code: "task_failed", + message: `Conflicting parent for task ${streamEvent.childTaskId}`, + } } taskParents.set(streamEvent.childTaskId, streamEvent.parentTaskId) delegatedTasks.add(streamEvent.childTaskId) @@ -238,7 +283,11 @@ export function validateStreamLifecycle( const previousState = taskStates.get(streamEvent.taskId) if (previousState !== undefined && terminalStates.has(previousState)) { - return { ok: false, code: "task_failed", message: `Task ${streamEvent.taskId} emitted an event after termination` } + return { + ok: false, + code: "task_failed", + message: `Task ${streamEvent.taskId} emitted an event after termination`, + } } if (streamEvent.type === "task.lifecycle") { taskStates.set(streamEvent.taskId, streamEvent.state) @@ -274,10 +323,55 @@ export function validateStreamLifecycle( ? { approve: "approve", reject: "reject", message: "needs_input" }[response.response] : undefined if (expectedDecision !== streamEvent.decision) { - return { ok: false, code: "task_failed", message: "User ask resolution does not match its response command" } + return { + ok: false, + code: "task_failed", + message: "User ask resolution does not match its response command", + } } } } + + const toolKey = "toolCallId" in streamEvent ? `${streamEvent.taskId}\u0000${streamEvent.toolCallId}` : undefined + if (streamEvent.type === "tool.started") { + if (toolStates.has(toolKey!)) + return { ok: false, code: "task_failed", message: "Tool operation started twice" } + toolStates.set(toolKey!, "active") + } else if (["tool.updated", "tool.completed", "tool.failed"].includes(streamEvent.type)) { + if (toolStates.get(toolKey!) !== "active") { + return { ok: false, code: "task_failed", message: "Tool event requires an active operation" } + } + if (streamEvent.type === "tool.completed" || streamEvent.type === "tool.failed") + toolStates.set(toolKey!, "terminal") + } + + if (streamEvent.type === "terminal.status") { + const state = terminalOperationStates.get(toolKey!) + if (streamEvent.state === "running") { + if (state !== undefined) + return { ok: false, code: "task_failed", message: "Terminal operation started twice" } + terminalOperationStates.set(toolKey!, "active") + } else if (state !== "active") { + return { ok: false, code: "task_failed", message: "Terminal status requires an active operation" } + } else if (streamEvent.state === "exited" || streamEvent.state === "killed") { + terminalOperationStates.set(toolKey!, "terminal") + } + } else if (streamEvent.type === "terminal.output" && terminalOperationStates.get(toolKey!) !== "active") { + return { ok: false, code: "task_failed", message: "Terminal output requires an active operation" } + } + + const mcpKey = + "operationId" in streamEvent ? `${streamEvent.taskId}\u0000${streamEvent.operationId}` : undefined + if (streamEvent.type === "mcp.started") { + if (mcpStates.has(mcpKey!)) + return { ok: false, code: "task_failed", message: "MCP operation started twice" } + mcpStates.set(mcpKey!, "active") + } else if (streamEvent.type === "mcp.completed" || streamEvent.type === "mcp.failed") { + if (mcpStates.get(mcpKey!) !== "active") { + return { ok: false, code: "task_failed", message: "MCP result requires an active operation" } + } + mcpStates.set(mcpKey!, "terminal") + } } if (pendingAsks.size > 0 && resultEvent.result.outcome !== "needs_input") { return { ok: false, code: "task_failed", message: "Terminal stream contains unresolved asks" } @@ -289,9 +383,49 @@ export function validateStreamLifecycle( timed_out: "interrupted", failed: "failed", } as const + if (!createdTasks.has(rootTaskId) || [...taskParents.keys()].some((taskId) => !createdTasks.has(taskId))) { + return { ok: false, code: "task_failed", message: "Every task in the authoritative tree must be created" } + } const rootState = taskStates.get(rootTaskId) - if (rootState !== undefined && rootState !== expectedState[resultEvent.result.outcome]) { + if (rootState !== expectedState[resultEvent.result.outcome]) { return { ok: false, code: "task_failed", message: "Root lifecycle state contradicts task.result" } } + const allowedDescendantStates = { + completed: new Set(["completed"]), + needs_input: new Set(["waiting", "interrupted", "completed", "failed"]), + cancelled: new Set(["interrupted", "completed", "failed"]), + timed_out: new Set(["interrupted", "completed", "failed"]), + failed: new Set(["interrupted", "completed", "failed"]), + }[resultEvent.result.outcome] + if ( + [...createdTasks].some( + (taskId) => taskId !== rootTaskId && !allowedDescendantStates.has(taskStates.get(taskId) ?? "running"), + ) + ) { + return { + ok: false, + code: "task_failed", + message: "Every descendant task must reach a compatible settled state", + } + } + if ([...toolStates.values(), ...terminalOperationStates.values(), ...mcpStates.values()].includes("active")) { + return { ok: false, code: "task_failed", message: "Terminal stream contains active operations" } + } + if (resultEvent.result.outcome === "cancelled") { + const cancellation = commands.find( + (command) => + command.type === "task.cancel" && + command.id === resultEvent.requestId && + command.rootTaskId === rootTaskId && + command.reason === resultEvent.result.cancellationReason, + ) + if (cancellation === undefined) { + return { + ok: false, + code: "task_failed", + message: "Cancelled result does not match its cancellation command", + } + } + } return { ok: true } } diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index d715c5c53d..ad7340ed53 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -4,6 +4,10 @@ const sensitiveKeyName = String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|authorization|c const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)` const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") const singleQuotedSecret = new RegExp(`('${sensitiveKeyName}'\\s*:\\s*)'(?:\\\\.|[^'\\\\])*'`, "gi") +const quotedUnquotedSecret = new RegExp( + `((?:"${sensitiveKeyName}"|'${sensitiveKeyName}')\\s*:\\s*)(?!["'])[^\\s,;}]+`, + "gi", +) const secretPatterns: ReadonlyArray = [ /\b(?:Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, new RegExp(`--${sensitiveKeyName}(?:\\s*=\\s*|\\s+)${secretValue}`, "gi"), @@ -14,17 +18,19 @@ const secretPatterns: ReadonlyArray = [ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, ] -export type RedactedValue = null | boolean | number | string | RedactedValue[] | { [key: string]: RedactedValue } +export type RedactedValue = null | undefined | boolean | number | string | RedactedValue[] | { [key: string]: RedactedValue } export function redactText(value: string): string { const structured = value .replace(doubleQuotedSecret, `$1"${REDACTED}"`) .replace(singleQuotedSecret, `$1'${REDACTED}'`) + .replace(quotedUnquotedSecret, `$1${REDACTED}`) return secretPatterns.reduce((redacted, pattern) => redacted.replace(pattern, REDACTED), structured) } export function redactValue(value: unknown, seen = new WeakSet()): RedactedValue { if (value === null || typeof value === "boolean" || typeof value === "number") return value + if (value === undefined) return undefined if (typeof value === "string") return redactText(value) if (typeof value !== "object") return String(value) if (seen.has(value)) return "[CIRCULAR]" diff --git a/packages/zoo-protocol/src/version.ts b/packages/zoo-protocol/src/version.ts index 4152a98938..27db93ae0b 100644 --- a/packages/zoo-protocol/src/version.ts +++ b/packages/zoo-protocol/src/version.ts @@ -64,20 +64,34 @@ export function negotiateProtocol( ): NegotiationResult { const version = [...supportedVersions] .sort((left, right) => right - left) - .find((candidate) => host.supportedVersions.includes(candidate)) + .find( + (candidate) => + host.supportedVersions.includes(candidate) && + requiredCapabilities.every((capability) => host.capabilities[String(candidate)]?.includes(capability)), + ) if (version === undefined) { - return { ok: false, code: "protocol_incompatible", message: "No mutually supported host protocol version" } + return { + ok: false, + code: "protocol_incompatible", + message: "No mutually supported host protocol version provides all required capabilities", + } } - const capabilities = host.capabilities[String(version)] ?? [] - const missing = requiredCapabilities.filter((capability) => !capabilities.includes(capability)) + return { ok: true, version } +} + +export function validateParentHello(host: HostHello, parent: ParentHello): NegotiationResult { + if (!host.supportedVersions.includes(parent.version)) { + return { ok: false, code: "protocol_incompatible", message: "Parent selected an unadvertised protocol version" } + } + const capabilities = host.capabilities[String(parent.version)] ?? [] + const missing = parent.requiredCapabilities.filter((capability) => !capabilities.includes(capability)) if (missing.length > 0) { return { ok: false, code: "protocol_incompatible", - message: `Host is missing required capabilities: ${missing.join(", ")}`, + message: `Selected protocol version is missing required capabilities: ${missing.join(", ")}`, } } - - return { ok: true, version } + return { ok: true, version: parent.version } } From 3d28c00f5f8ea03c69b0b5dd72f29aa1b16c691b Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 03:02:56 -0400 Subject: [PATCH 07/24] no-mistakes(review): Tighten Zoo protocol lifecycle and negotiation contracts --- .../src/__tests__/contracts.test.ts | 112 +++++++++++++++++- packages/zoo-protocol/src/host-commands.ts | 2 +- packages/zoo-protocol/src/host-events.ts | 10 +- packages/zoo-protocol/src/parity.ts | 24 +++- packages/zoo-protocol/src/public-events.ts | 83 ++++++++++--- packages/zoo-protocol/src/redaction.ts | 4 +- packages/zoo-protocol/src/version.ts | 4 + 7 files changed, 213 insertions(+), 26 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index bbdc029e81..47bcaf2cd4 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -85,6 +85,9 @@ describe("strict host contracts", () => { expect(hostCommandSchema.safeParse({ ...command, overrides: { reasoningEffort: "disabled" } }).success).toBe( true, ) + const formattedPrompt = hostCommandSchema.parse({ ...command, prompt: " formatted prompt\n" }) + expect(formattedPrompt.type === "task.start" && formattedPrompt.prompt).toBe(" formatted prompt\n") + expect(hostCommandSchema.safeParse({ ...command, prompt: " \n\t" }).success).toBe(false) }) it("enforces input and approval payload invariants", () => { @@ -118,7 +121,7 @@ describe("strict host contracts", () => { capabilities: { 1: ["task:start"], 2: ["task:start", "task:resume"] }, }) expect(negotiateProtocol(multiVersionHello, [1], ["task:resume"])).toMatchObject({ ok: false }) - expect(negotiateProtocol(multiVersionHello, [2, 1], ["task:resume"])).toEqual({ ok: true, version: 2 }) + expect(negotiateProtocol(multiVersionHello, [2, 1], ["task:resume"])).toMatchObject({ ok: false }) const lowerVersionCapabilities = hostHelloSchema.parse({ ...hello, supportedVersions: [1, 2], @@ -141,14 +144,27 @@ describe("strict host contracts", () => { clientVersion: "1.0.0", requiredCapabilities: ["task:resume"], }) - expect(validateParentHello(host, selected)).toEqual({ ok: true, version: 2 }) + expect(validateParentHello(host, selected)).toMatchObject({ ok: false }) expect(validateParentHello(host, { ...selected, version: 3 })).toMatchObject({ ok: false }) expect(validateParentHello(host, { ...selected, version: 1 })).toMatchObject({ ok: false }) + expect( + validateParentHello(host, { ...selected, version: 1, requiredCapabilities: ["task:start"] }), + ).toEqual({ ok: true, version: 1 }) }) it("requires contiguous host sequence numbers", () => { expect(validateMonotonicSequence(8, 9)).toEqual({ ok: true }) expect(validateMonotonicSequence(8, 10)).toEqual({ ok: false, expected: 9 }) + expect(validateMonotonicSequence(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)).toMatchObject({ ok: false }) + expect( + hostEventSchema.safeParse({ + v: 1, + seq: Number.MAX_SAFE_INTEGER + 1, + hostId: "host", + type: "command.ack", + commandId: "cmd", + }).success, + ).toBe(false) }) it("models one ACK and terminal command response independently", () => { @@ -325,6 +341,7 @@ describe("public automation contracts", () => { } expect(zooStreamEventSchema.parse(event)).toEqual(event) expect(zooStreamEventSchema.safeParse({ ...event, seq: 0 }).success).toBe(false) + expect(zooStreamEventSchema.safeParse({ ...event, seq: Number.MAX_SAFE_INTEGER + 1 }).success).toBe(false) expect(zooStreamEventSchema.safeParse({ ...event, rawSecret: "no" }).success).toBe(false) expect(zooStreamEventSchema.safeParse({ ...event, taskId: undefined }).success).toBe(false) }) @@ -380,6 +397,16 @@ describe("public automation contracts", () => { expect( validateStreamLifecycle([initEvent, rootCreated, childCreated, delegated, rootCompleted, resultEvent(6)]), ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + rootCreated, + childCreated, + taskEvent(4, "task.lifecycle", { taskId: "child", state: "completed" }), + taskEvent(5, "task.lifecycle", { state: "completed" }), + resultEvent(6), + ]), + ).toMatchObject({ ok: false }) const mismatchedDelegation = taskEvent(4, "task.delegated", { taskId: "root", parentTaskId: "root", @@ -470,6 +497,33 @@ describe("public automation contracts", () => { expect(validateStreamLifecycle(stream, [command])).toEqual({ ok: true }) expect(validateStreamLifecycle(stream)).toMatchObject({ ok: false }) expect(validateStreamLifecycle(stream, [{ ...command, reason: "signal" }])).toMatchObject({ ok: false }) + expect( + zooStreamEventSchema.safeParse({ + ...terminalStarted, + exitCode: 0, + }).success, + ).toBe(false) + expect(zooStreamEventSchema.safeParse({ ...terminalExited, exitCode: undefined }).success).toBe(false) + expect( + validateStreamLifecycle([ + initEvent, + created, + toolStarted, + taskEvent(4, "tool.completed", { toolCallId: "tool", name: "write" }), + taskEvent(5, "task.lifecycle", { state: "completed" }), + resultEvent(6), + ]), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle([ + initEvent, + created, + mcpStarted, + taskEvent(4, "mcp.completed", { operationId: "mcp", server: "other", operation: "read" }), + taskEvent(5, "task.lifecycle", { state: "completed" }), + resultEvent(6), + ]), + ).toMatchObject({ ok: false }) expect( validateStreamLifecycle([ initEvent, @@ -508,6 +562,49 @@ describe("public automation contracts", () => { ).toMatchObject({ ok: false }) }) + it("abandons pending asks only for cancellation or timeout", () => { + const created = taskEvent(2, "task.created") + const required = taskEvent(3, "ask.required", { askId: "ask", category: "tool", subject: "Run" }) + const abandoned = taskEvent(4, "ask.abandoned", { askId: "ask", reason: "cancelled" }) + if (abandoned.type !== "ask.abandoned") throw new Error("Expected ask.abandoned fixture") + const interrupted = taskEvent(5, "task.lifecycle", { state: "interrupted" }) + const cancelled = resultEvent(6, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) + const command = hostCommandSchema.parse({ + v: 1, + id: "cancel", + type: "task.cancel", + rootTaskId: "root", + reason: "user", + }) + expect( + validateStreamLifecycle([initEvent, created, required, abandoned, interrupted, cancelled], [command]), + ).toEqual({ ok: true }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + required, + { ...abandoned, reason: "timed_out" }, + interrupted, + cancelled, + ], + [command], + ), + ).toMatchObject({ ok: false }) + }) + + it("requires currentTaskId to belong to the authoritative tree", () => { + expect( + validateStreamLifecycle([ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.lifecycle", { state: "completed" }), + resultEvent(4, { currentTaskId: "ghost" }), + ]), + ).toMatchObject({ ok: false }) + }) + it("maps every terminal outcome deterministically", () => { expect(exitCodeFor({ outcome: "completed" })).toBe(EXIT_CODES.completed) expect(exitCodeFor({ outcome: "needs_input" })).toBe(EXIT_CODES.needsInput) @@ -545,6 +642,8 @@ describe("redaction contracts", () => { '{"client_secret":"[REDACTED]","access_token":"[REDACTED]"}', ) expect(redactText("--api-key abc123 run")).toBe("[REDACTED] run") + expect(redactText("API Key: abc123")).toBe("[REDACTED]") + expect(redactText("Private Key: abc123")).toBe("[REDACTED]") expect(redactText('API_TOKEN="abc def" run')).toBe("[REDACTED] run") }) @@ -606,6 +705,15 @@ describe("deterministic parity oracle", () => { ] expect(assertAuthoritativeRootResult(trace, "root")).toBe(false) expect(assertAuthoritativeRootResult(parityScenarios[2]!.expected, "root")).toBe(true) + expect(assertAuthoritativeRootResult([{ type: "task.result", taskId: "root", rootTaskId: "root" }], "root")).toBe( + false, + ) + expect( + assertAuthoritativeRootResult( + [{ type: "task.result", taskId: "root", rootTaskId: "root", outcome: "failed" }], + "root", + ), + ).toBe(false) }) it("reports semantic drift without timestamps", () => { diff --git a/packages/zoo-protocol/src/host-commands.ts b/packages/zoo-protocol/src/host-commands.ts index 9be0e316ff..d5c0dfaabf 100644 --- a/packages/zoo-protocol/src/host-commands.ts +++ b/packages/zoo-protocol/src/host-commands.ts @@ -43,7 +43,7 @@ const taskStartCommandSchema = commandBaseSchema .extend({ type: z.literal("task.start"), workspace: z.string().min(1), - prompt: z.string().trim().min(1), + prompt: z.string().refine((prompt) => prompt.trim().length > 0, "Prompt cannot be blank"), overrides: runOverridesSchema.optional(), }) .strict() diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index b6b9111729..8336b2e802 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -7,7 +7,7 @@ import { ZOO_HOST_PROTOCOL_VERSION } from "./version.js" const base = { v: z.literal(ZOO_HOST_PROTOCOL_VERSION), - seq: z.number().int().positive(), + seq: z.number().int().safe().positive(), hostId: z.string().min(1), } @@ -38,7 +38,7 @@ export const commandDoneDataSchema = z.discriminatedUnion("commandType", [ }), strictObject({ commandType: z.literal("host.snapshot"), - lastSeq: z.number().int().nonnegative(), + lastSeq: z.number().int().safe().nonnegative(), activeRootTaskId: z.string().min(1).optional(), }), strictObject({ commandType: z.literal("host.shutdown") }), @@ -65,7 +65,7 @@ const heartbeatSchema = strictObject({ const snapshotSchema = strictObject({ ...base, type: z.literal("host.snapshot"), - lastSeq: z.number().int().nonnegative(), + lastSeq: z.number().int().safe().nonnegative(), activeRootTaskId: z.string().min(1).optional(), }) const normalizedEventSchema = strictObject({ ...base, type: z.literal("event"), event: zooStreamEventSchema }) @@ -92,7 +92,9 @@ export function validateMonotonicSequence( next: number, ): { ok: true } | { ok: false; expected: number } { const expected = previous + 1 - return next === expected ? { ok: true } : { ok: false, expected } + return Number.isSafeInteger(previous) && Number.isSafeInteger(next) && next === expected + ? { ok: true } + : { ok: false, expected } } export function validateCommandLifecycle( diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index 0bab5c28cf..f382779af5 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -1,4 +1,10 @@ -import { type ZooErrorCode, type ZooOutcome, zooErrorCodeSchema } from "./outcomes.js" +import { + failedErrorCodeSchema, + type ZooErrorCode, + type ZooOutcome, + zooErrorCodeSchema, + zooOutcomeSchema, +} from "./outcomes.js" export type SemanticTraceEntry = { type: string @@ -228,5 +234,19 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly export function assertAuthoritativeRootResult(trace: readonly SemanticTraceEntry[], rootTaskId: string): boolean { const results = trace.filter((entry) => entry.type === "task.result") - return results.length === 1 && results[0]?.taskId === rootTaskId && results[0].rootTaskId === rootTaskId + if (results.length !== 1) return false + const result = results[0]! + if ( + result.taskId !== rootTaskId || + result.rootTaskId !== rootTaskId || + !zooOutcomeSchema.safeParse(result.outcome).success + ) { + return false + } + if (result.outcome === "failed") return failedErrorCodeSchema.safeParse(result.errorCode).success + if (result.outcome === "timed_out") { + return result.errorCode === undefined || ["task_timed_out", "cleanup_timed_out"].includes(result.errorCode) + } + if (result.outcome === "cancelled") return result.cancellationReason !== undefined && result.errorCode === undefined + return result.errorCode === undefined && result.cancellationReason === undefined } diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index dcd0e54d46..1231e9402e 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -67,7 +67,7 @@ export type ZooRunResult = z.infer const eventBase = { v: z.literal(ZOO_PUBLIC_SCHEMA_VERSION), - seq: z.number().int().positive(), + seq: z.number().int().safe().positive(), timestamp: z.string().datetime({ offset: true }), hostId: z.string().min(1), requestId: z.string().min(1).optional(), @@ -118,6 +118,10 @@ const askResolvedEventSchema = taskEvent("ask.resolved", { decision: z.enum(["approve", "reject", "needs_input"]), source: z.enum(["policy", "user", "auto", "deny"]), }) +const askAbandonedEventSchema = taskEvent("ask.abandoned", { + askId: z.string().min(1), + reason: z.enum(["cancelled", "timed_out"]), +}) const toolEventState = { toolCallId: z.string().min(1), name: z.string().min(1), @@ -136,7 +140,7 @@ const terminalOutputEventSchema = taskEvent("terminal.output", { const terminalStatusEventSchema = taskEvent("terminal.status", { toolCallId: z.string().min(1), state: z.enum(["running", "background", "exited", "killed"]), - exitCode: z.number().int().nullable().optional(), + exitCode: z.number().int().safe().nullable().optional(), }) const mcpEventState = { operationId: z.string().min(1), @@ -164,6 +168,7 @@ const rawZooStreamEventSchema = z.discriminatedUnion("type", [ messageUpsertEventSchema, askRequiredEventSchema, askResolvedEventSchema, + askAbandonedEventSchema, toolStartedEventSchema, toolUpdatedEventSchema, toolCompletedEventSchema, @@ -175,7 +180,16 @@ const rawZooStreamEventSchema = z.discriminatedUnion("type", [ mcpFailedEventSchema, usageUpdatedEventSchema, taskResultEventSchema, -]) +]).superRefine((streamEvent, context) => { + if (streamEvent.type !== "terminal.status") return + const terminal = streamEvent.state === "exited" || streamEvent.state === "killed" + if (terminal === (streamEvent.exitCode === undefined)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: terminal ? "Terminal states require an exit code or null" : "Nonterminal states cannot include an exit code", + }) + } +}) export const zooStreamEventSchema = z.preprocess((value) => redactValue(value), rawZooStreamEventSchema) @@ -212,14 +226,15 @@ export function validateStreamLifecycle( } const pendingAsks = new Set() + const abandonedAsks = new Map() const taskStates = new Map() const terminalStates = new Set(["interrupted", "completed", "failed"]) const taskParents = new Map() const createdTasks = new Set() const delegatedTasks = new Set() - const toolStates = new Map() + const toolStates = new Map() const terminalOperationStates = new Map() - const mcpStates = new Map() + const mcpStates = new Map() for (const streamEvent of events) { if ("rootTaskId" in streamEvent && streamEvent.rootTaskId !== rootTaskId) { return { @@ -331,18 +346,30 @@ export function validateStreamLifecycle( } } } + if (streamEvent.type === "ask.abandoned") { + const askKey = `${streamEvent.taskId}\u0000${streamEvent.askId}` + if (!pendingAsks.delete(askKey)) { + return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was not pending` } + } + abandonedAsks.set(askKey, streamEvent.reason) + } const toolKey = "toolCallId" in streamEvent ? `${streamEvent.taskId}\u0000${streamEvent.toolCallId}` : undefined if (streamEvent.type === "tool.started") { if (toolStates.has(toolKey!)) return { ok: false, code: "task_failed", message: "Tool operation started twice" } - toolStates.set(toolKey!, "active") - } else if (["tool.updated", "tool.completed", "tool.failed"].includes(streamEvent.type)) { - if (toolStates.get(toolKey!) !== "active") { + toolStates.set(toolKey!, { state: "active", name: streamEvent.name }) + } else if ( + streamEvent.type === "tool.updated" || + streamEvent.type === "tool.completed" || + streamEvent.type === "tool.failed" + ) { + const tool = toolStates.get(toolKey!) + if (tool?.state !== "active" || tool.name !== streamEvent.name) { return { ok: false, code: "task_failed", message: "Tool event requires an active operation" } } if (streamEvent.type === "tool.completed" || streamEvent.type === "tool.failed") - toolStates.set(toolKey!, "terminal") + toolStates.set(toolKey!, { ...tool, state: "terminal" }) } if (streamEvent.type === "terminal.status") { @@ -365,17 +392,29 @@ export function validateStreamLifecycle( if (streamEvent.type === "mcp.started") { if (mcpStates.has(mcpKey!)) return { ok: false, code: "task_failed", message: "MCP operation started twice" } - mcpStates.set(mcpKey!, "active") + mcpStates.set(mcpKey!, { + state: "active", + server: streamEvent.server, + operation: streamEvent.operation, + }) } else if (streamEvent.type === "mcp.completed" || streamEvent.type === "mcp.failed") { - if (mcpStates.get(mcpKey!) !== "active") { + const operation = mcpStates.get(mcpKey!) + if ( + operation?.state !== "active" || + operation.server !== streamEvent.server || + operation.operation !== streamEvent.operation + ) { return { ok: false, code: "task_failed", message: "MCP result requires an active operation" } } - mcpStates.set(mcpKey!, "terminal") + mcpStates.set(mcpKey!, { ...operation, state: "terminal" }) } } if (pendingAsks.size > 0 && resultEvent.result.outcome !== "needs_input") { return { ok: false, code: "task_failed", message: "Terminal stream contains unresolved asks" } } + if ([...abandonedAsks.values()].some((reason) => reason !== resultEvent.result.outcome)) { + return { ok: false, code: "task_failed", message: "Ask abandonment contradicts task.result" } + } const expectedState = { completed: "completed", needs_input: "waiting", @@ -383,8 +422,19 @@ export function validateStreamLifecycle( timed_out: "interrupted", failed: "failed", } as const - if (!createdTasks.has(rootTaskId) || [...taskParents.keys()].some((taskId) => !createdTasks.has(taskId))) { - return { ok: false, code: "task_failed", message: "Every task in the authoritative tree must be created" } + if ( + !createdTasks.has(rootTaskId) || + [...taskParents.keys()].some((taskId) => !createdTasks.has(taskId)) || + [...createdTasks].some((taskId) => taskId !== rootTaskId && !delegatedTasks.has(taskId)) + ) { + return { + ok: false, + code: "task_failed", + message: "Every task in the authoritative tree must be created and every descendant delegated", + } + } + if (resultEvent.result.currentTaskId !== undefined && !createdTasks.has(resultEvent.result.currentTaskId)) { + return { ok: false, code: "task_failed", message: "currentTaskId must identify a task in the authoritative tree" } } const rootState = taskStates.get(rootTaskId) if (rootState !== expectedState[resultEvent.result.outcome]) { @@ -408,7 +458,10 @@ export function validateStreamLifecycle( message: "Every descendant task must reach a compatible settled state", } } - if ([...toolStates.values(), ...terminalOperationStates.values(), ...mcpStates.values()].includes("active")) { + if ( + [...toolStates.values(), ...mcpStates.values()].some((operation) => operation.state === "active") || + [...terminalOperationStates.values()].includes("active") + ) { return { ok: false, code: "task_failed", message: "Terminal stream contains active operations" } } if (resultEvent.result.outcome === "cancelled") { diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index ad7340ed53..617f80cd85 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -1,6 +1,6 @@ const REDACTED = "[REDACTED]" as const -const sensitiveKey = /(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)/i -const sensitiveKeyName = String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|token)[A-Za-z0-9_-]*` +const sensitiveKey = /(?:api[-_ ]?key|authorization|cookie|credential|password|private[-_ ]?key|secret|token)/i +const sensitiveKeyName = String.raw`[A-Za-z0-9_-]*(?:api[-_ ]?key|authorization|cookie|credential|password|private[-_ ]?key|secret|token)[A-Za-z0-9_-]*` const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)` const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") const singleQuotedSecret = new RegExp(`('${sensitiveKeyName}'\\s*:\\s*)'(?:\\\\.|[^'\\\\])*'`, "gi") diff --git a/packages/zoo-protocol/src/version.ts b/packages/zoo-protocol/src/version.ts index 27db93ae0b..914726ce7c 100644 --- a/packages/zoo-protocol/src/version.ts +++ b/packages/zoo-protocol/src/version.ts @@ -66,6 +66,7 @@ export function negotiateProtocol( .sort((left, right) => right - left) .find( (candidate) => + candidate === ZOO_HOST_PROTOCOL_VERSION && host.supportedVersions.includes(candidate) && requiredCapabilities.every((capability) => host.capabilities[String(candidate)]?.includes(capability)), ) @@ -81,6 +82,9 @@ export function negotiateProtocol( } export function validateParentHello(host: HostHello, parent: ParentHello): NegotiationResult { + if (parent.version !== ZOO_HOST_PROTOCOL_VERSION) { + return { ok: false, code: "protocol_incompatible", message: "Selected protocol version has no installed codec" } + } if (!host.supportedVersions.includes(parent.version)) { return { ok: false, code: "protocol_incompatible", message: "Parent selected an unadvertised protocol version" } } From 5ba349b2837aa7ea48a29b978fe974440529c5fc Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 03:17:27 -0400 Subject: [PATCH 08/24] no-mistakes(review): Tighten Zoo protocol lifecycle and redaction contracts --- .../src/__tests__/contracts.test.ts | 123 ++++++++++++++++++ packages/zoo-protocol/src/host-commands.ts | 5 +- packages/zoo-protocol/src/host-events.ts | 10 +- packages/zoo-protocol/src/parity.ts | 16 ++- packages/zoo-protocol/src/public-events.ts | 109 ++++++++++++++-- packages/zoo-protocol/src/redaction.ts | 6 +- 6 files changed, 250 insertions(+), 19 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 47bcaf2cd4..c582dd53b9 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -92,6 +92,9 @@ describe("strict host contracts", () => { it("enforces input and approval payload invariants", () => { expect(hostCommandSchema.safeParse({ v: 1, id: "1", type: "task.input", taskId: "task" }).success).toBe(false) + expect( + hostCommandSchema.safeParse({ v: 1, id: "1", type: "task.input", taskId: "task", text: " \n" }).success, + ).toBe(false) expect( hostCommandSchema.safeParse({ v: 1, @@ -102,6 +105,17 @@ describe("strict host contracts", () => { response: "message", }).success, ).toBe(false) + expect( + hostCommandSchema.safeParse({ + v: 1, + id: "1", + type: "ask.respond", + taskId: "task", + askId: "ask", + response: "message", + text: " \t", + }).success, + ).toBe(false) }) it("negotiates versions and required capabilities", () => { @@ -247,6 +261,26 @@ describe("strict host contracts", () => { data: { commandType: "task.start", task: { rootTaskId: "root" } }, }).success, ).toBe(false) + const start = hostCommandSchema.parse({ + v: 1, + id: "cmd", + type: "task.start", + workspace: "/workspace", + prompt: "start", + }) + const acknowledgement = hostEventSchema.parse({ + v: 1, + seq: 1, + hostId: "host", + type: "command.ack", + commandId: "cmd", + }) + const childCompletion = hostEventSchema.parse({ + ...done, + seq: 2, + data: { commandType: "task.start", task: { rootTaskId: "root", taskId: "child" } }, + }) + expect(validateCommandLifecycle([start], [acknowledgement, childCompletion])).toMatchObject({ ok: false }) }) it("binds history completion data to its requested workspace", () => { @@ -605,6 +639,57 @@ describe("public automation contracts", () => { ).toMatchObject({ ok: false }) }) + it("reconstructs resume streams from a matching command", () => { + const command = hostCommandSchema.parse({ + v: 1, + id: "resume", + type: "task.resume", + rootTaskId: "root", + taskId: "root", + }) + const resumed = taskEvent(3, "task.resumed", { requestId: "resume", previousState: "interrupted" }) + const completed = taskEvent(4, "task.lifecycle", { state: "completed" }) + const stream = [initEvent, taskEvent(2, "task.created"), resumed, completed, resultEvent(5)] + expect(validateStreamLifecycle(stream, [command])).toEqual({ ok: true }) + expect(validateStreamLifecycle(stream)).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [...stream.slice(0, 3), { ...resumed, seq: 4 }, { ...completed, seq: 5 }, resultEvent(6)], + [command], + ), + ).toMatchObject({ ok: false }) + expect(zooStreamEventSchema.safeParse({ ...resumed, previousState: "completed" }).success).toBe(false) + }) + + it("keeps pending asks on waiting tasks", () => { + const childCreated = taskEvent(3, "task.created", { taskId: "child", parentTaskId: "root" }) + const delegated = taskEvent(4, "task.delegated", { + taskId: "child", + parentTaskId: "root", + childTaskId: "child", + }) + const required = taskEvent(5, "ask.required", { + taskId: "child", + askId: "ask", + category: "tool", + subject: "Run", + }) + const childCompleted = taskEvent(6, "task.lifecycle", { taskId: "child", state: "completed" }) + const rootWaiting = taskEvent(7, "task.lifecycle", { state: "waiting" }) + expect( + validateStreamLifecycle([ + initEvent, + taskEvent(2, "task.created"), + childCreated, + delegated, + required, + childCompleted, + rootWaiting, + resultEvent(8, { outcome: "needs_input" }), + ]), + ).toMatchObject({ ok: false }) + }) + it("maps every terminal outcome deterministically", () => { expect(exitCodeFor({ outcome: "completed" })).toBe(EXIT_CODES.completed) expect(exitCodeFor({ outcome: "needs_input" })).toBe(EXIT_CODES.needsInput) @@ -644,6 +729,8 @@ describe("redaction contracts", () => { expect(redactText("--api-key abc123 run")).toBe("[REDACTED] run") expect(redactText("API Key: abc123")).toBe("[REDACTED]") expect(redactText("Private Key: abc123")).toBe("[REDACTED]") + expect(redactText('{"auth.token":"secret"}')).toBe('{"auth.token":"[REDACTED]"}') + expect(redactText("https://alice:hunter2@example.com/path")).toBe("https://[REDACTED]@example.com/path") expect(redactText('API_TOKEN="abc def" run')).toBe("[REDACTED] run") }) @@ -667,6 +754,16 @@ describe("redaction contracts", () => { elapsedMs: 1, }) expect(result.content).toBe("[REDACTED]") + expect(zooStreamEventSchema.safeParse({ ...message, taskId: Symbol("secret") }).success).toBe(false) + const structuralIdentity = taskEvent(2, "message.upsert", { + taskId: "password=hunter2", + messageId: "token=identity", + role: "assistant", + content: "safe", + complete: true, + }) + expect(structuralIdentity.taskId).toBe("password=hunter2") + expect(structuralIdentity.type === "message.upsert" && structuralIdentity.messageId).toBe("token=identity") }) it("handles cycles without throwing", () => { @@ -722,6 +819,32 @@ describe("deterministic parity oracle", () => { expect(result).toMatchObject({ ok: false }) }) + it("models timeout separately and rejects trailing terminal turns", () => { + const timeout = runDeterministicFakeProvider({ + id: "timeout", + prompt: "Timeout", + providerTurns: ["timeout:task_timed_out"], + expected: [], + }) + expect(timeout.at(-1)).toMatchObject({ outcome: "timed_out", errorCode: "task_timed_out" }) + expect(() => + runDeterministicFakeProvider({ + id: "invalid-failure", + prompt: "Fail", + providerTurns: ["fail:task_timed_out"], + expected: [], + }), + ).toThrow() + expect(() => + runDeterministicFakeProvider({ + id: "trailing", + prompt: "Cancel", + providerTurns: ["cancel:cancel-1:user", "trailing"], + expected: [], + }), + ).toThrow() + }) + it("ignores object property insertion order without ignoring event order", () => { const expected = [{ type: "message.upsert", taskId: "root", content: "hello" }] const reordered = [{ content: "hello", taskId: "root", type: "message.upsert" }] diff --git a/packages/zoo-protocol/src/host-commands.ts b/packages/zoo-protocol/src/host-commands.ts index d5c0dfaabf..3cbd4e7680 100644 --- a/packages/zoo-protocol/src/host-commands.ts +++ b/packages/zoo-protocol/src/host-commands.ts @@ -52,6 +52,7 @@ const taskResumeCommandSchema = commandBaseSchema .extend({ type: z.literal("task.resume"), taskId: z.string().min(1), + rootTaskId: z.string().min(1), overrides: runOverridesSchema.optional(), }) .strict() @@ -60,7 +61,7 @@ const taskInputCommandSchema = commandBaseSchema .extend({ type: z.literal("task.input"), taskId: z.string().min(1), - text: z.string().min(1).optional(), + text: z.string().refine((text) => text.trim().length > 0, "Input cannot be blank").optional(), images: z.array(z.string().min(1)).min(1).optional(), }) .strict() @@ -71,7 +72,7 @@ const askRespondCommandSchema = commandBaseSchema taskId: z.string().min(1), askId: z.string().min(1), response: z.enum(["approve", "reject", "message"]), - text: z.string().min(1).optional(), + text: z.string().refine((text) => text.trim().length > 0, "Response cannot be blank").optional(), }) .strict() diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index 8336b2e802..d2299162d9 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -155,10 +155,14 @@ export function validateCommandLifecycle( const data = terminal.data const matches = (() => { switch (command.type) { - case "task.start": - return data.commandType === command.type + case "task.start": + return data.commandType === command.type && data.task.taskId === data.task.rootTaskId case "task.resume": - return data.commandType === command.type && data.task.taskId === command.taskId + return ( + data.commandType === command.type && + data.task.taskId === command.taskId && + data.task.rootTaskId === command.rootTaskId + ) case "task.input": return data.commandType === command.type && data.taskId === command.taskId case "ask.respond": diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index f382779af5..3309b06bcb 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -163,7 +163,9 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly { type: "task.created", rootTaskId: "root", taskId: "root", prompt: scenario.prompt }, ] let result: SemanticTraceEntry = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" } + let terminalReached = false for (const turn of scenario.providerTurns) { + if (terminalReached) throw new Error("Fake-provider terminal directives must be the final turn") if (turn.startsWith("tool:")) { const [, operation, toolCallId, argument] = turn.split(":") if (operation !== "read_file" || !toolCallId || !argument) throw new Error(`Invalid tool fixture: ${turn}`) @@ -215,12 +217,24 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly requestId, cancellationReason: cancellationReason as "user" | "signal" | "timeout", } + terminalReached = true continue } if (turn.startsWith("fail:")) { - const errorCode = zooErrorCodeSchema.parse(turn.slice(5)) + const errorCode = failedErrorCodeSchema.parse(turn.slice(5)) trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "failed" }) result = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "failed", errorCode } + terminalReached = true + continue + } + if (turn.startsWith("timeout:")) { + const errorCode = zooErrorCodeSchema.parse(turn.slice(8)) + if (errorCode !== "task_timed_out" && errorCode !== "cleanup_timed_out") { + throw new Error(`Invalid timeout fixture: ${turn}`) + } + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted" }) + result = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "timed_out", errorCode } + terminalReached = true continue } trace.push({ type: "message.upsert", rootTaskId: "root", taskId: "root", content: turn }) diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 1231e9402e..3f87b6e605 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -2,16 +2,16 @@ import { z } from "zod" import type { HostCommand } from "./host-commands.js" import { failedErrorCodeSchema, zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" -import { redactValue } from "./redaction.js" +import { redactValue, type RedactedValue } from "./redaction.js" import { ZOO_PUBLIC_SCHEMA_VERSION, zooCapabilitySchema } from "./version.js" const strictObject = (shape: T) => z.object(shape).strict() export const usageSchema = strictObject({ - inputTokens: z.number().int().nonnegative().optional(), - outputTokens: z.number().int().nonnegative().optional(), - cacheReads: z.number().int().nonnegative().optional(), - cacheWrites: z.number().int().nonnegative().optional(), + inputTokens: z.number().int().safe().nonnegative().optional(), + outputTokens: z.number().int().safe().nonnegative().optional(), + cacheReads: z.number().int().safe().nonnegative().optional(), + cacheWrites: z.number().int().safe().nonnegative().optional(), }) export const changedFileSchema = strictObject({ path: z.string().min(1), status: z.string().min(1) }) @@ -28,8 +28,8 @@ const rawZooRunResultSchema = strictObject({ content: z.string().optional(), error: zooErrorSchema.optional(), usage: usageSchema.optional(), - cost: z.number().nonnegative().optional(), - elapsedMs: z.number().int().nonnegative(), + cost: z.number().finite().nonnegative().optional(), + elapsedMs: z.number().int().safe().nonnegative(), changedFiles: z.array(changedFileSchema).optional(), cancellationReason: z.enum(["user", "signal", "timeout"]).optional(), }).superRefine((result, context) => { @@ -61,7 +61,15 @@ const rawZooRunResultSchema = strictObject({ } }) -export const zooRunResultSchema = z.preprocess((value) => redactValue(value), rawZooRunResultSchema) +const redactError = (error: T): T => ({ ...error, message: String(redactValue(error.message)) }) +const redactRecord = (value: Record): Record => + redactValue(value) as Record + +export const zooRunResultSchema = rawZooRunResultSchema.transform((result) => ({ + ...result, + content: result.content === undefined ? undefined : String(redactValue(result.content)), + error: result.error === undefined ? undefined : redactError(result.error), +})) export type ZooRunResult = z.infer @@ -97,7 +105,9 @@ const taskStartedEventSchema = taskEvent("task.started", {}) const taskLifecycleEventSchema = taskEvent("task.lifecycle", { state: z.enum(["running", "waiting", "interrupted", "completed", "failed"]), }) -const taskResumedEventSchema = taskEvent("task.resumed", {}) +const taskResumedEventSchema = taskEvent("task.resumed", { + previousState: z.enum(["waiting", "interrupted"]), +}) const taskDelegatedEventSchema = taskEvent("task.delegated", { parentTaskId: z.string().min(1), childTaskId: z.string().min(1), @@ -153,7 +163,7 @@ const mcpCompletedEventSchema = taskEvent("mcp.completed", mcpEventState) const mcpFailedEventSchema = taskEvent("mcp.failed", { ...mcpEventState, error: zooErrorSchema }) const usageUpdatedEventSchema = taskEvent("usage.updated", { usage: usageSchema, - cost: z.number().nonnegative().optional(), + cost: z.number().finite().nonnegative().optional(), }) const taskResultEventSchema = taskEvent("task.result", { result: zooRunResultSchema }) @@ -191,7 +201,47 @@ const rawZooStreamEventSchema = z.discriminatedUnion("type", [ } }) -export const zooStreamEventSchema = z.preprocess((value) => redactValue(value), rawZooStreamEventSchema) +export const zooStreamEventSchema = rawZooStreamEventSchema.transform((streamEvent) => { + switch (streamEvent.type) { + case "system.warning": + return { ...streamEvent, message: String(redactValue(streamEvent.message)) } + case "message.upsert": + return { ...streamEvent, content: String(redactValue(streamEvent.content)) } + case "ask.required": + return { ...streamEvent, subject: String(redactValue(streamEvent.subject)) } + case "tool.started": + case "tool.updated": + case "tool.completed": + return { + ...streamEvent, + arguments: streamEvent.arguments === undefined ? undefined : redactRecord(streamEvent.arguments), + output: streamEvent.output === undefined ? undefined : String(redactValue(streamEvent.output)), + } + case "tool.failed": + return { + ...streamEvent, + arguments: streamEvent.arguments === undefined ? undefined : redactRecord(streamEvent.arguments), + output: streamEvent.output === undefined ? undefined : String(redactValue(streamEvent.output)), + error: redactError(streamEvent.error), + } + case "terminal.output": + return { ...streamEvent, delta: String(redactValue(streamEvent.delta)) } + case "mcp.started": + case "mcp.completed": + return { + ...streamEvent, + output: streamEvent.output === undefined ? undefined : String(redactValue(streamEvent.output)), + } + case "mcp.failed": + return { + ...streamEvent, + output: streamEvent.output === undefined ? undefined : String(redactValue(streamEvent.output)), + error: redactError(streamEvent.error), + } + default: + return streamEvent + } +}) export type ZooStreamEvent = z.infer @@ -232,6 +282,7 @@ export function validateStreamLifecycle( const taskParents = new Map() const createdTasks = new Set() const delegatedTasks = new Set() + const resumedTasks = new Set() const toolStates = new Map() const terminalOperationStates = new Map() const mcpStates = new Map() @@ -295,6 +346,14 @@ export function validateStreamLifecycle( } else if (!taskParents.has(streamEvent.taskId)) { return { ok: false, code: "task_failed", message: `Event references unknown task ${streamEvent.taskId}` } } + if ( + streamEvent.taskId !== rootTaskId && + streamEvent.type !== "task.created" && + streamEvent.type !== "task.delegated" && + !delegatedTasks.has(streamEvent.taskId) + ) { + return { ok: false, code: "task_failed", message: `Task ${streamEvent.taskId} emitted an event before delegation` } + } const previousState = taskStates.get(streamEvent.taskId) if (previousState !== undefined && terminalStates.has(previousState)) { @@ -305,8 +364,26 @@ export function validateStreamLifecycle( } } if (streamEvent.type === "task.lifecycle") { + const hasPendingAsk = [...pendingAsks].some((askKey) => askKey.startsWith(`${streamEvent.taskId}\u0000`)) + if (hasPendingAsk && terminalStates.has(streamEvent.state)) { + return { ok: false, code: "task_failed", message: "A task with a pending ask cannot terminate" } + } taskStates.set(streamEvent.taskId, streamEvent.state) } + if (streamEvent.type === "task.resumed") { + const resume = commands.find( + (command) => + command.type === "task.resume" && + command.id === streamEvent.requestId && + command.taskId === streamEvent.taskId && + command.rootTaskId === streamEvent.rootTaskId, + ) + if (resume === undefined || resumedTasks.size > 0 || streamEvent.taskId !== rootTaskId) { + return { ok: false, code: "task_failed", message: "task.resumed must uniquely match the root resume command" } + } + resumedTasks.add(streamEvent.taskId) + taskStates.set(streamEvent.taskId, "running") + } if (streamEvent.type === "ask.required") { const askKey = `${streamEvent.taskId}\u0000${streamEvent.askId}` if (pendingAsks.has(askKey)) { @@ -412,6 +489,16 @@ export function validateStreamLifecycle( if (pendingAsks.size > 0 && resultEvent.result.outcome !== "needs_input") { return { ok: false, code: "task_failed", message: "Terminal stream contains unresolved asks" } } + if ( + resultEvent.result.outcome === "needs_input" && + [...pendingAsks].some((askKey) => taskStates.get(askKey.slice(0, askKey.indexOf("\u0000"))) !== "waiting") + ) { + return { ok: false, code: "task_failed", message: "Pending asks must belong to waiting tasks" } + } + const resumeCommands = commands.filter((command) => command.type === "task.resume") + if (resumeCommands.length !== resumedTasks.size) { + return { ok: false, code: "task_failed", message: "Every resume command must reconstruct one resumed root" } + } if ([...abandonedAsks.values()].some((reason) => reason !== resultEvent.result.outcome)) { return { ok: false, code: "task_failed", message: "Ask abandonment contradicts task.result" } } diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 617f80cd85..771c38bc18 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -1,6 +1,6 @@ const REDACTED = "[REDACTED]" as const const sensitiveKey = /(?:api[-_ ]?key|authorization|cookie|credential|password|private[-_ ]?key|secret|token)/i -const sensitiveKeyName = String.raw`[A-Za-z0-9_-]*(?:api[-_ ]?key|authorization|cookie|credential|password|private[-_ ]?key|secret|token)[A-Za-z0-9_-]*` +const sensitiveKeyName = String.raw`[A-Za-z0-9_.-]*(?:api[-_ ]?key|authorization|cookie|credential|password|private[-_ ]?key|secret|token)[A-Za-z0-9_.-]*` const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)` const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") const singleQuotedSecret = new RegExp(`('${sensitiveKeyName}'\\s*:\\s*)'(?:\\\\.|[^'\\\\])*'`, "gi") @@ -9,6 +9,7 @@ const quotedUnquotedSecret = new RegExp( "gi", ) const secretPatterns: ReadonlyArray = [ + /\bhttps?:\/\/[^\s/@:]+:[^\s/@]+@/gi, /\b(?:Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, new RegExp(`--${sensitiveKeyName}(?:\\s*=\\s*|\\s+)${secretValue}`, "gi"), new RegExp(`(?()): Redac if (value === null || typeof value === "boolean" || typeof value === "number") return value if (value === undefined) return undefined if (typeof value === "string") return redactText(value) - if (typeof value !== "object") return String(value) + if (typeof value !== "object") return undefined if (seen.has(value)) return "[CIRCULAR]" seen.add(value) From 9f5cbac7333eee8682d9b9d3bf4e998c7a6d29d9 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 03:39:32 -0400 Subject: [PATCH 09/24] no-mistakes(review): Tighten Zoo protocol lifecycle and redaction contracts --- .../src/__tests__/contracts.test.ts | 252 +++++++++++++++--- packages/zoo-protocol/src/host-events.ts | 31 ++- packages/zoo-protocol/src/parity.ts | 57 +++- packages/zoo-protocol/src/public-events.ts | 206 ++++++++++---- packages/zoo-protocol/src/redaction.ts | 11 +- 5 files changed, 458 insertions(+), 99 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index c582dd53b9..09c8ee38aa 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -23,6 +23,13 @@ import { } from "../index.js" const timestamp = "2026-08-05T12:00:00.000Z" +const startCommand = hostCommandSchema.parse({ + v: 1, + id: "start", + type: "task.start", + workspace: "/workspace", + prompt: "Start", +}) const initEvent = zooStreamEventSchema.parse({ v: 1, @@ -45,6 +52,7 @@ function taskEvent(seq: number, type: string, fields: Record = type, rootTaskId: "root", taskId: "root", + ...(type === "task.created" ? { requestId: "start" } : {}), ...fields, }) } @@ -52,6 +60,7 @@ function taskEvent(seq: number, type: string, fields: Record = function resultEvent(seq: number, result: Record = {}, event: Record = {}) { const outcome = result.outcome ?? "completed" const parsed = taskEvent(seq, "task.result", { + requestId: "start", result: { schemaVersion: 1, protocol: "zoo-run-result", @@ -283,6 +292,43 @@ describe("strict host contracts", () => { expect(validateCommandLifecycle([start], [acknowledgement, childCompletion])).toMatchObject({ ok: false }) }) + it("does not reuse root identities across successful starts", () => { + const commands = ["first", "second"].map((id) => + hostCommandSchema.parse({ v: 1, id, type: "task.start", workspace: "/workspace", prompt: id }), + ) + const events = commands.flatMap((command, index) => [ + hostEventSchema.parse({ + v: 1, + seq: index * 2 + 1, + hostId: "host", + type: "command.ack", + commandId: command.id, + }), + hostEventSchema.parse({ + v: 1, + seq: index * 2 + 2, + hostId: "host", + type: "command.done", + commandId: command.id, + data: { commandType: "task.start", task: { rootTaskId: "root", taskId: "root" } }, + }), + ]) + expect(validateCommandLifecycle(commands, events)).toMatchObject({ ok: false }) + }) + + it("redacts command errors before they cross the host boundary", () => { + const parsed = hostEventSchema.parse({ + v: 1, + seq: 1, + hostId: "host", + type: "command.error", + commandId: "command", + error: { code: "provider_failed", message: "password=hunter2", phase: "token=secret" }, + }) + expect(parsed.type === "command.error" && parsed.error.message).toBe("[REDACTED]") + expect(parsed.type === "command.error" && parsed.error.phase).toBe("[REDACTED]") + }) + it("binds history completion data to its requested workspace", () => { const command = hostCommandSchema.parse({ v: 1, @@ -382,9 +428,16 @@ describe("public automation contracts", () => { it("requires init, contiguous sequence, and a settled authoritative root", () => { const created = taskEvent(2, "task.created") - const completed = taskEvent(3, "task.lifecycle", { state: "completed" }) - const result = resultEvent(4) - expect(validateStreamLifecycle([initEvent, created, completed, result])).toEqual({ ok: true }) + const started = taskEvent(3, "task.started") + const completed = taskEvent(4, "task.lifecycle", { state: "completed" }) + const result = resultEvent(5) + expect(validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand])).toEqual({ ok: true }) + expect( + validateStreamLifecycle( + [initEvent, created, started, completed, resultEvent(5, { workspace: "/other" })], + [startCommand], + ), + ).toMatchObject({ ok: false }) expect(validateStreamLifecycle([initEvent, resultEvent(2)])).toMatchObject({ ok: false }) expect(validateStreamLifecycle([initEvent, created, resultEvent(3)])).toMatchObject({ ok: false }) expect( @@ -409,25 +462,45 @@ describe("public automation contracts", () => { it("validates task-tree settlement and approval command causation", () => { const rootCreated = taskEvent(2, "task.created") - const childCreated = taskEvent(3, "task.created", { taskId: "child", parentTaskId: "root" }) - const delegated = taskEvent(4, "task.delegated", { + const rootStarted = taskEvent(3, "task.started") + const childCreated = taskEvent(4, "task.created", { taskId: "child", parentTaskId: "root" }) + const delegated = taskEvent(5, "task.delegated", { taskId: "child", parentTaskId: "root", childTaskId: "child", }) - const childCompleted = taskEvent(5, "task.lifecycle", { taskId: "child", state: "completed" }) - const rootCompleted = taskEvent(6, "task.lifecycle", { state: "completed" }) + const childStarted = taskEvent(6, "task.started", { taskId: "child" }) + const childCompleted = taskEvent(7, "task.lifecycle", { taskId: "child", state: "completed" }) + const rootCompleted = taskEvent(8, "task.lifecycle", { state: "completed" }) expect( validateStreamLifecycle([ initEvent, rootCreated, + rootStarted, childCreated, delegated, + childStarted, childCompleted, rootCompleted, - resultEvent(7), - ]), + resultEvent(9), + ], [startCommand]), ).toEqual({ ok: true }) + expect( + validateStreamLifecycle( + [ + initEvent, + rootCreated, + rootStarted, + childCreated, + delegated, + childStarted, + { ...rootCompleted, seq: 7 }, + { ...childCompleted, seq: 8 }, + resultEvent(9), + ], + [startCommand], + ), + ).toMatchObject({ ok: false }) expect( validateStreamLifecycle([initEvent, rootCreated, childCreated, delegated, rootCompleted, resultEvent(6)]), ).toMatchObject({ ok: false }) @@ -457,12 +530,12 @@ describe("public automation contracts", () => { ]), ).toMatchObject({ ok: false }) - const required = taskEvent(3, "ask.required", { + const required = taskEvent(4, "ask.required", { askId: "ask", category: "tool", subject: "Run command", }) - const resolved = taskEvent(4, "ask.resolved", { + const resolved = taskEvent(5, "ask.resolved", { requestId: "respond", askId: "ask", decision: "approve", @@ -476,11 +549,11 @@ describe("public automation contracts", () => { askId: "ask", response: "approve", }) - const completed = taskEvent(5, "task.lifecycle", { state: "completed" }) + const completed = taskEvent(6, "task.lifecycle", { state: "completed" }) expect( validateStreamLifecycle( - [initEvent, rootCreated, required, resolved, completed, resultEvent(6)], - [response], + [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [startCommand, response], ), ).toEqual({ ok: true, @@ -500,14 +573,15 @@ describe("public automation contracts", () => { it("correlates cancellation and settles operation lifecycles", () => { const created = taskEvent(2, "task.created") - const toolStarted = taskEvent(3, "tool.started", { toolCallId: "tool", name: "read" }) - const toolCompleted = taskEvent(4, "tool.completed", { toolCallId: "tool", name: "read" }) - const terminalStarted = taskEvent(5, "terminal.status", { toolCallId: "terminal", state: "running" }) - const terminalExited = taskEvent(6, "terminal.status", { toolCallId: "terminal", state: "exited", exitCode: 0 }) - const mcpStarted = taskEvent(7, "mcp.started", { operationId: "mcp", server: "test", operation: "read" }) - const mcpCompleted = taskEvent(8, "mcp.completed", { operationId: "mcp", server: "test", operation: "read" }) - const interrupted = taskEvent(9, "task.lifecycle", { state: "interrupted" }) - const cancelled = resultEvent(10, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) + const started = taskEvent(3, "task.started") + const toolStarted = taskEvent(4, "tool.started", { toolCallId: "tool", name: "read" }) + const toolCompleted = taskEvent(5, "tool.completed", { toolCallId: "tool", name: "read" }) + const terminalStarted = taskEvent(6, "terminal.status", { toolCallId: "terminal", state: "running" }) + const terminalExited = taskEvent(7, "terminal.status", { toolCallId: "terminal", state: "exited", exitCode: 0 }) + const mcpStarted = taskEvent(8, "mcp.started", { operationId: "mcp", server: "test", operation: "read" }) + const mcpCompleted = taskEvent(9, "mcp.completed", { operationId: "mcp", server: "test", operation: "read" }) + const interrupted = taskEvent(10, "task.lifecycle", { state: "interrupted" }) + const cancelled = resultEvent(11, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) const command = hostCommandSchema.parse({ v: 1, id: "cancel", @@ -519,6 +593,7 @@ describe("public automation contracts", () => { const stream = [ initEvent, created, + started, toolStarted, toolCompleted, terminalStarted, @@ -528,7 +603,7 @@ describe("public automation contracts", () => { interrupted, cancelled, ] - expect(validateStreamLifecycle(stream, [command])).toEqual({ ok: true }) + expect(validateStreamLifecycle(stream, [startCommand, command])).toEqual({ ok: true }) expect(validateStreamLifecycle(stream)).toMatchObject({ ok: false }) expect(validateStreamLifecycle(stream, [{ ...command, reason: "signal" }])).toMatchObject({ ok: false }) expect( @@ -598,11 +673,12 @@ describe("public automation contracts", () => { it("abandons pending asks only for cancellation or timeout", () => { const created = taskEvent(2, "task.created") - const required = taskEvent(3, "ask.required", { askId: "ask", category: "tool", subject: "Run" }) - const abandoned = taskEvent(4, "ask.abandoned", { askId: "ask", reason: "cancelled" }) + const started = taskEvent(3, "task.started") + const required = taskEvent(4, "ask.required", { askId: "ask", category: "tool", subject: "Run" }) + const abandoned = taskEvent(5, "ask.abandoned", { askId: "ask", reason: "cancelled" }) if (abandoned.type !== "ask.abandoned") throw new Error("Expected ask.abandoned fixture") - const interrupted = taskEvent(5, "task.lifecycle", { state: "interrupted" }) - const cancelled = resultEvent(6, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) + const interrupted = taskEvent(6, "task.lifecycle", { state: "interrupted" }) + const cancelled = resultEvent(7, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) const command = hostCommandSchema.parse({ v: 1, id: "cancel", @@ -611,7 +687,7 @@ describe("public automation contracts", () => { reason: "user", }) expect( - validateStreamLifecycle([initEvent, created, required, abandoned, interrupted, cancelled], [command]), + validateStreamLifecycle([initEvent, created, started, required, abandoned, interrupted, cancelled], [startCommand, command]), ).toEqual({ ok: true }) expect( validateStreamLifecycle( @@ -647,9 +723,19 @@ describe("public automation contracts", () => { rootTaskId: "root", taskId: "root", }) - const resumed = taskEvent(3, "task.resumed", { requestId: "resume", previousState: "interrupted" }) - const completed = taskEvent(4, "task.lifecycle", { state: "completed" }) - const stream = [initEvent, taskEvent(2, "task.created"), resumed, completed, resultEvent(5)] + const predecessor = taskEvent(3, "task.lifecycle", { state: "interrupted" }) + const resumed = taskEvent(4, "task.resumed", { requestId: "resume", previousState: "interrupted" }) + const started = taskEvent(5, "task.started") + const completed = taskEvent(6, "task.lifecycle", { state: "completed" }) + const stream = [ + initEvent, + taskEvent(2, "task.created"), + predecessor, + resumed, + started, + completed, + resultEvent(7, {}, { requestId: "resume" }), + ] expect(validateStreamLifecycle(stream, [command])).toEqual({ ok: true }) expect(validateStreamLifecycle(stream)).toMatchObject({ ok: false }) expect( @@ -661,6 +747,65 @@ describe("public automation contracts", () => { expect(zooStreamEventSchema.safeParse({ ...resumed, previousState: "completed" }).success).toBe(false) }) + it("resumes a correlated descendant from its reconstructed predecessor", () => { + const command = hostCommandSchema.parse({ + v: 1, + id: "resume-child", + type: "task.resume", + rootTaskId: "root", + taskId: "child", + }) + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.created", { taskId: "child", parentTaskId: "root" }), + taskEvent(4, "task.delegated", { taskId: "child", parentTaskId: "root", childTaskId: "child" }), + taskEvent(5, "task.lifecycle", { taskId: "child", state: "waiting" }), + taskEvent(6, "task.resumed", { + taskId: "child", + requestId: "resume-child", + previousState: "waiting", + }), + taskEvent(7, "task.started", { taskId: "child" }), + taskEvent(8, "task.lifecycle", { taskId: "child", state: "completed" }), + taskEvent(9, "task.started"), + taskEvent(10, "task.lifecycle", { state: "completed" }), + resultEvent(11, {}, { requestId: "resume-child" }), + ] + expect(validateStreamLifecycle(stream, [command])).toEqual({ ok: true }) + expect( + validateStreamLifecycle( + stream.map((event) => + event.type === "task.resumed" ? { ...event, previousState: "interrupted" as const } : event, + ), + [command], + ), + ).toMatchObject({ ok: false }) + }) + + it("keeps operation identities separate for delimiter-bearing IDs", () => { + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "task.created", { taskId: "root\u0000x", parentTaskId: "root" }), + taskEvent(5, "task.delegated", { + taskId: "root\u0000x", + parentTaskId: "root", + childTaskId: "root\u0000x", + }), + taskEvent(6, "task.started", { taskId: "root\u0000x" }), + taskEvent(7, "tool.started", { toolCallId: "x\u0000y", name: "read" }), + taskEvent(8, "tool.started", { taskId: "root\u0000x", toolCallId: "y", name: "read" }), + taskEvent(9, "tool.completed", { toolCallId: "x\u0000y", name: "read" }), + taskEvent(10, "tool.completed", { taskId: "root\u0000x", toolCallId: "y", name: "read" }), + taskEvent(11, "task.lifecycle", { taskId: "root\u0000x", state: "completed" }), + taskEvent(12, "task.lifecycle", { state: "completed" }), + resultEvent(13), + ] + expect(validateStreamLifecycle(stream, [startCommand])).toEqual({ ok: true }) + }) + it("keeps pending asks on waiting tasks", () => { const childCreated = taskEvent(3, "task.created", { taskId: "child", parentTaskId: "root" }) const delegated = taskEvent(4, "task.delegated", { @@ -731,6 +876,8 @@ describe("redaction contracts", () => { expect(redactText("Private Key: abc123")).toBe("[REDACTED]") expect(redactText('{"auth.token":"secret"}')).toBe('{"auth.token":"[REDACTED]"}') expect(redactText("https://alice:hunter2@example.com/path")).toBe("https://[REDACTED]@example.com/path") + expect(redactText("https://alice:p@ss@example.com/path")).toBe("https://[REDACTED]@example.com/path") + expect(redactText("--password abc,def run")).toBe("[REDACTED] run") expect(redactText('API_TOKEN="abc def" run')).toBe("[REDACTED] run") }) @@ -762,8 +909,21 @@ describe("redaction contracts", () => { content: "safe", complete: true, }) + if (structuralIdentity.type !== "message.upsert") throw new Error("Expected message.upsert fixture") expect(structuralIdentity.taskId).toBe("password=hunter2") - expect(structuralIdentity.type === "message.upsert" && structuralIdentity.messageId).toBe("token=identity") + expect(structuralIdentity.messageId).toBe("token=identity") + const terminalOutput = taskEvent(3, "terminal.output", { + toolCallId: "terminal", + stream: "stdout", + delta: "abcdefgh", + }) + expect(terminalOutput.type === "terminal.output" && terminalOutput.delta).toBe("[REDACTED]") + const terminalPrefix = taskEvent(4, "terminal.output", { + toolCallId: "terminal", + stream: "stdout", + delta: "API_TOKEN=", + }) + expect(terminalPrefix.type === "terminal.output" && terminalPrefix.delta).toBe("[REDACTED]") }) it("handles cycles without throwing", () => { @@ -795,6 +955,14 @@ describe("deterministic parity oracle", () => { }) }) + it("includes tool identity and arguments in fake-provider semantics", () => { + const trace = runDeterministicFakeProvider(parityScenarios[1]!) + expect(trace.find((entry) => entry.type === "tool.started")).toMatchObject({ + toolName: "read_file", + toolArguments: { path: "README.md" }, + }) + }) + it("detects child completion incorrectly settling the root", () => { const trace = [ { type: "task.created", taskId: "root" }, @@ -811,6 +979,26 @@ describe("deterministic parity oracle", () => { "root", ), ).toBe(false) + expect( + assertAuthoritativeRootResult( + [ + { + type: "task.result", + taskId: "root", + rootTaskId: "root", + outcome: "cancelled", + cancellationReason: "invalid" as "user", + }, + ], + "root", + ), + ).toBe(false) + expect( + assertAuthoritativeRootResult( + [{ type: "task.result", taskId: "root", rootTaskId: "root", outcome: "timed_out" }], + "root", + ), + ).toBe(false) }) it("reports semantic drift without timestamps", () => { diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index d2299162d9..5f3ee9bcb0 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -3,6 +3,7 @@ import { z } from "zod" import type { HostCommand } from "./host-commands.js" import { zooErrorSchema } from "./outcomes.js" import { zooStreamEventSchema } from "./public-events.js" +import { redactText } from "./redaction.js" import { ZOO_HOST_PROTOCOL_VERSION } from "./version.js" const base = { @@ -79,11 +80,24 @@ const hostEventDiscriminatedSchema = z.discriminatedUnion("type", [ normalizedEventSchema, ]) -export const hostEventSchema = hostEventDiscriminatedSchema.superRefine((event, context) => { - if (event.type === "event" && event.event.hostId !== event.hostId) { - context.addIssue({ code: z.ZodIssueCode.custom, message: "Normalized event hostId must match its host envelope" }) - } -}) +export const hostEventSchema = hostEventDiscriminatedSchema + .superRefine((event, context) => { + if (event.type === "event" && event.event.hostId !== event.hostId) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "Normalized event hostId must match its host envelope" }) + } + }) + .transform((event) => + event.type === "command.error" + ? { + ...event, + error: { + ...event.error, + message: redactText(event.error.message), + phase: event.error.phase === undefined ? undefined : redactText(event.error.phase), + }, + } + : event, + ) export type HostEvent = z.infer @@ -102,6 +116,7 @@ export function validateCommandLifecycle( events: readonly HostEvent[], ): { ok: true } | { ok: false; commandId: string; message: string } { const commandById = new Map() + const startedRoots = new Set() for (const command of commands) { if (commandById.has(command.id)) { return { ok: false, commandId: command.id, message: "Command IDs must be unique" } @@ -183,6 +198,12 @@ export function validateCommandLifecycle( if (!matches) { return { ok: false, commandId, message: "DONE payload does not match the originating command" } } + if (command.type === "task.start" && data.commandType === "task.start") { + if (startedRoots.has(data.task.rootTaskId)) { + return { ok: false, commandId, message: "Successful task starts must return unique root task IDs" } + } + startedRoots.add(data.task.rootTaskId) + } } } return { ok: true } diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index 3309b06bcb..d2da100c1a 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -12,6 +12,8 @@ export type SemanticTraceEntry = { rootTaskId?: string parentTaskId?: string toolCallId?: string + toolName?: string + toolArguments?: Record state?: "running" | "waiting" | "interrupted" | "completed" | "failed" askId?: string decision?: "approve" | "reject" | "needs_input" @@ -38,6 +40,7 @@ export const parityScenarios: readonly ParityScenario[] = [ providerTurns: ["Hello from Zoo."], expected: [ { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Reply with the fixture greeting." }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, { type: "message.upsert", rootTaskId: "root", taskId: "root", content: "Hello from Zoo." }, { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" }, @@ -54,8 +57,23 @@ export const parityScenarios: readonly ParityScenario[] = [ taskId: "root", prompt: "Read README.md and report its title.", }, - { type: "tool.started", rootTaskId: "root", taskId: "root", toolCallId: "call-1" }, - { type: "tool.completed", rootTaskId: "root", taskId: "root", toolCallId: "call-1" }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, + { + type: "tool.started", + rootTaskId: "root", + taskId: "root", + toolCallId: "call-1", + toolName: "read_file", + toolArguments: { path: "README.md" }, + }, + { + type: "tool.completed", + rootTaskId: "root", + taskId: "root", + toolCallId: "call-1", + toolName: "read_file", + toolArguments: { path: "README.md" }, + }, { type: "message.upsert", rootTaskId: "root", taskId: "root", content: "Zoo Code" }, { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" }, @@ -72,8 +90,10 @@ export const parityScenarios: readonly ParityScenario[] = [ taskId: "root", prompt: "Delegate once, then finish the root task.", }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, { type: "task.created", rootTaskId: "root", taskId: "child", parentTaskId: "root" }, { type: "task.delegated", rootTaskId: "root", taskId: "child", parentTaskId: "root" }, + { type: "task.started", rootTaskId: "root", taskId: "child" }, { type: "task.lifecycle", rootTaskId: "root", taskId: "child", state: "completed" }, { type: "message.upsert", rootTaskId: "root", taskId: "root", content: "root:accepted" }, { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, @@ -86,6 +106,7 @@ export const parityScenarios: readonly ParityScenario[] = [ providerTurns: ["ask:ask-1", "approve:ask-1:user:respond-1"], expected: [ { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Request approval." }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, { type: "ask.required", rootTaskId: "root", taskId: "root", askId: "ask-1" }, { type: "ask.resolved", @@ -106,6 +127,7 @@ export const parityScenarios: readonly ParityScenario[] = [ providerTurns: ["cancel:cancel-1:user"], expected: [ { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Cancel deterministically." }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted" }, { type: "task.result", @@ -123,6 +145,7 @@ export const parityScenarios: readonly ParityScenario[] = [ providerTurns: ["fail:provider_failed"], expected: [ { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Fail deterministically." }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "failed" }, { type: "task.result", @@ -161,6 +184,7 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly const trace: SemanticTraceEntry[] = [ { type: "task.created", rootTaskId: "root", taskId: "root", prompt: scenario.prompt }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, ] let result: SemanticTraceEntry = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" } let terminalReached = false @@ -169,8 +193,15 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly if (turn.startsWith("tool:")) { const [, operation, toolCallId, argument] = turn.split(":") if (operation !== "read_file" || !toolCallId || !argument) throw new Error(`Invalid tool fixture: ${turn}`) - trace.push({ type: "tool.started", rootTaskId: "root", taskId: "root", toolCallId }) - trace.push({ type: "tool.completed", rootTaskId: "root", taskId: "root", toolCallId }) + const tool = { + rootTaskId: "root", + taskId: "root", + toolCallId, + toolName: operation, + toolArguments: { path: argument }, + } + trace.push({ type: "tool.started", ...tool }) + trace.push({ type: "tool.completed", ...tool }) continue } if (turn.startsWith("delegate:")) { @@ -178,6 +209,7 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly if (!taskId) throw new Error(`Invalid delegation fixture: ${turn}`) trace.push({ type: "task.created", rootTaskId: "root", taskId, parentTaskId: "root" }) trace.push({ type: "task.delegated", rootTaskId: "root", taskId, parentTaskId: "root" }) + trace.push({ type: "task.started", rootTaskId: "root", taskId }) continue } if (turn.endsWith(":done")) { @@ -257,10 +289,21 @@ export function assertAuthoritativeRootResult(trace: readonly SemanticTraceEntry ) { return false } - if (result.outcome === "failed") return failedErrorCodeSchema.safeParse(result.errorCode).success + if (result.outcome === "failed") { + return failedErrorCodeSchema.safeParse(result.errorCode).success && result.cancellationReason === undefined + } if (result.outcome === "timed_out") { - return result.errorCode === undefined || ["task_timed_out", "cleanup_timed_out"].includes(result.errorCode) + return ( + ["task_timed_out", "cleanup_timed_out"].includes(result.errorCode ?? "") && + result.cancellationReason === undefined + ) + } + if (result.outcome === "cancelled") { + return ( + result.errorCode === undefined && + result.cancellationReason !== undefined && + ["user", "signal", "timeout"].includes(result.cancellationReason) + ) } - if (result.outcome === "cancelled") return result.cancellationReason !== undefined && result.errorCode === undefined return result.errorCode === undefined && result.cancellationReason === undefined } diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 3f87b6e605..bab7c07c79 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -2,7 +2,7 @@ import { z } from "zod" import type { HostCommand } from "./host-commands.js" import { failedErrorCodeSchema, zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" -import { redactValue, type RedactedValue } from "./redaction.js" +import { REDACTED, redactValue, type RedactedValue } from "./redaction.js" import { ZOO_PUBLIC_SCHEMA_VERSION, zooCapabilitySchema } from "./version.js" const strictObject = (shape: T) => z.object(shape).strict() @@ -225,7 +225,7 @@ export const zooStreamEventSchema = rawZooStreamEventSchema.transform((streamEve error: redactError(streamEvent.error), } case "terminal.output": - return { ...streamEvent, delta: String(redactValue(streamEvent.delta)) } + return { ...streamEvent, delta: streamEvent.delta.length === 0 ? "" : REDACTED } case "mcp.started": case "mcp.completed": return { @@ -274,18 +274,61 @@ export function validateStreamLifecycle( if (resultEvent.rootTaskId !== rootTaskId || resultEvent.taskId !== rootTaskId) { return { ok: false, code: "task_failed", message: "task.result must identify the authoritative root task" } } + const resumedEvents = events.filter((streamEvent) => streamEvent.type === "task.resumed") + const startCommands = commands.filter((command) => command.type === "task.start") + const resumeCommands = commands.filter((command) => command.type === "task.resume") + if (resumedEvents.length === 0) { + const start = startCommands[0] + if ( + startCommands.length !== 1 || + resumeCommands.length !== 0 || + start === undefined || + resultEvent.result.workspace !== start.workspace + ) { + return { ok: false, code: "task_failed", message: "Fresh streams must match exactly one task.start command" } + } + } else if (resumedEvents.length !== 1 || startCommands.length !== 0 || resumeCommands.length !== 1) { + return { ok: false, code: "task_failed", message: "Resume streams must match exactly one task.resume command" } + } - const pendingAsks = new Set() - const abandonedAsks = new Map() + const pendingAsks = new Map>() + const abandonedAsks = new Map>() const taskStates = new Map() - const terminalStates = new Set(["interrupted", "completed", "failed"]) + const endedStates = new Set(["completed", "failed"]) + const settledStates = new Set(["interrupted", "completed", "failed"]) const taskParents = new Map() const createdTasks = new Set() const delegatedTasks = new Set() const resumedTasks = new Set() - const toolStates = new Map() - const terminalOperationStates = new Map() - const mcpStates = new Map() + const startedTasks = new Set() + type ToolState = { state: "active" | "terminal"; name: string } + type McpState = { state: "active" | "terminal"; server: string; operation: string } + const toolStates = new Map>() + const terminalOperationStates = new Map>() + const mcpStates = new Map>() + const scope = (map: Map>, taskId: string): Map => { + const existing = map.get(taskId) + if (existing !== undefined) return existing + const created = new Map() + map.set(taskId, created) + return created + } + const askScope = (taskId: string): Set => { + const existing = pendingAsks.get(taskId) + if (existing !== undefined) return existing + const created = new Set() + pendingAsks.set(taskId, created) + return created + } + const values = (map: Map>): T[] => [...map.values()].flatMap((entries) => [...entries.values()]) + const isDescendantOf = (taskId: string, parentTaskId: string): boolean => { + let current = taskParents.get(taskId) + while (current !== undefined && current !== null) { + if (current === parentTaskId) return true + current = taskParents.get(current) + } + return false + } for (const streamEvent of events) { if ("rootTaskId" in streamEvent && streamEvent.rootTaskId !== rootTaskId) { return { @@ -301,7 +344,7 @@ export function validateStreamLifecycle( if ( (streamEvent.taskId === rootTaskId) !== (parentTaskId === null) || (parentTaskId !== null && !taskParents.has(parentTaskId)) || - (parentTaskId !== null && terminalStates.has(taskStates.get(parentTaskId) ?? "")) || + (parentTaskId !== null && settledStates.has(taskStates.get(parentTaskId) ?? "")) || createdTasks.has(streamEvent.taskId) ) { return { @@ -315,6 +358,9 @@ export function validateStreamLifecycle( } taskParents.set(streamEvent.taskId, parentTaskId) createdTasks.add(streamEvent.taskId) + if (streamEvent.taskId === rootTaskId && resumedEvents.length === 0 && streamEvent.requestId !== startCommands[0]?.id) { + return { ok: false, code: "task_failed", message: "Root creation must match its task.start request" } + } } else if (streamEvent.type === "task.delegated") { if ( streamEvent.taskId !== streamEvent.childTaskId || @@ -322,7 +368,7 @@ export function validateStreamLifecycle( streamEvent.childTaskId === streamEvent.parentTaskId || !createdTasks.has(streamEvent.parentTaskId) || !createdTasks.has(streamEvent.childTaskId) || - terminalStates.has(taskStates.get(streamEvent.parentTaskId) ?? "") || + settledStates.has(taskStates.get(streamEvent.parentTaskId) ?? "") || delegatedTasks.has(streamEvent.childTaskId) ) { return { @@ -356,44 +402,96 @@ export function validateStreamLifecycle( } const previousState = taskStates.get(streamEvent.taskId) - if (previousState !== undefined && terminalStates.has(previousState)) { + if ( + (previousState !== undefined && endedStates.has(previousState)) || + (previousState === "interrupted" && streamEvent.type !== "task.resumed") + ) { return { ok: false, code: "task_failed", message: `Task ${streamEvent.taskId} emitted an event after termination`, } } + if (streamEvent.type === "task.started") { + if ( + startedTasks.has(streamEvent.taskId) || + (previousState !== undefined && previousState !== "running") || + (resumeCommands[0]?.taskId === streamEvent.taskId && !resumedTasks.has(streamEvent.taskId)) + ) { + return { ok: false, code: "task_failed", message: `Invalid start transition for task ${streamEvent.taskId}` } + } + startedTasks.add(streamEvent.taskId) + taskStates.set(streamEvent.taskId, "running") + } if (streamEvent.type === "task.lifecycle") { - const hasPendingAsk = [...pendingAsks].some((askKey) => askKey.startsWith(`${streamEvent.taskId}\u0000`)) - if (hasPendingAsk && terminalStates.has(streamEvent.state)) { + const resume = resumeCommands[0] + const reconstructingPredecessor = + !startedTasks.has(streamEvent.taskId) && + resume?.taskId === streamEvent.taskId && + !resumedTasks.has(streamEvent.taskId) && + (streamEvent.state === "waiting" || streamEvent.state === "interrupted") + if (!startedTasks.has(streamEvent.taskId) && !reconstructingPredecessor) { + return { ok: false, code: "task_failed", message: "Task lifecycle requires an ordered task.started event" } + } + if ((pendingAsks.get(streamEvent.taskId)?.size ?? 0) > 0 && settledStates.has(streamEvent.state)) { return { ok: false, code: "task_failed", message: "A task with a pending ask cannot terminate" } } + if ( + settledStates.has(streamEvent.state) && + [...createdTasks].some( + (taskId) => isDescendantOf(taskId, streamEvent.taskId) && !settledStates.has(taskStates.get(taskId) ?? ""), + ) + ) { + return { ok: false, code: "task_failed", message: "A task cannot terminate before its descendants" } + } taskStates.set(streamEvent.taskId, streamEvent.state) } if (streamEvent.type === "task.resumed") { - const resume = commands.find( - (command) => - command.type === "task.resume" && - command.id === streamEvent.requestId && - command.taskId === streamEvent.taskId && - command.rootTaskId === streamEvent.rootTaskId, - ) - if (resume === undefined || resumedTasks.size > 0 || streamEvent.taskId !== rootTaskId) { - return { ok: false, code: "task_failed", message: "task.resumed must uniquely match the root resume command" } + const resume = resumeCommands[0] + if ( + resume === undefined || + resume.id !== streamEvent.requestId || + resume.taskId !== streamEvent.taskId || + resume.rootTaskId !== streamEvent.rootTaskId || + resultEvent.requestId !== resume.id || + resumedTasks.size > 0 || + previousState !== streamEvent.previousState + ) { + return { ok: false, code: "task_failed", message: "task.resumed must match reconstructed persisted state" } } resumedTasks.add(streamEvent.taskId) taskStates.set(streamEvent.taskId, "running") } + if ( + [ + "message.upsert", + "ask.required", + "ask.resolved", + "ask.abandoned", + "tool.started", + "tool.updated", + "tool.completed", + "tool.failed", + "terminal.output", + "terminal.status", + "mcp.started", + "mcp.completed", + "mcp.failed", + "usage.updated", + ].includes(streamEvent.type) && + !startedTasks.has(streamEvent.taskId) + ) { + return { ok: false, code: "task_failed", message: "Task operation requires an ordered task.started event" } + } if (streamEvent.type === "ask.required") { - const askKey = `${streamEvent.taskId}\u0000${streamEvent.askId}` - if (pendingAsks.has(askKey)) { + const asks = askScope(streamEvent.taskId) + if (asks.has(streamEvent.askId)) { return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} is already pending` } } - pendingAsks.add(askKey) + asks.add(streamEvent.askId) } if (streamEvent.type === "ask.resolved") { - const askKey = `${streamEvent.taskId}\u0000${streamEvent.askId}` - if (!pendingAsks.delete(askKey)) { + if (!pendingAsks.get(streamEvent.taskId)?.delete(streamEvent.askId)) { return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was not pending` } } if ( @@ -424,58 +522,62 @@ export function validateStreamLifecycle( } } if (streamEvent.type === "ask.abandoned") { - const askKey = `${streamEvent.taskId}\u0000${streamEvent.askId}` - if (!pendingAsks.delete(askKey)) { + if (!pendingAsks.get(streamEvent.taskId)?.delete(streamEvent.askId)) { return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was not pending` } } - abandonedAsks.set(askKey, streamEvent.reason) + scope(abandonedAsks, streamEvent.taskId).set(streamEvent.askId, streamEvent.reason) } - const toolKey = "toolCallId" in streamEvent ? `${streamEvent.taskId}\u0000${streamEvent.toolCallId}` : undefined if (streamEvent.type === "tool.started") { - if (toolStates.has(toolKey!)) + const tools = scope(toolStates, streamEvent.taskId) + if (tools.has(streamEvent.toolCallId)) return { ok: false, code: "task_failed", message: "Tool operation started twice" } - toolStates.set(toolKey!, { state: "active", name: streamEvent.name }) + tools.set(streamEvent.toolCallId, { state: "active", name: streamEvent.name }) } else if ( streamEvent.type === "tool.updated" || streamEvent.type === "tool.completed" || streamEvent.type === "tool.failed" ) { - const tool = toolStates.get(toolKey!) + const tools = scope(toolStates, streamEvent.taskId) + const tool = tools.get(streamEvent.toolCallId) if (tool?.state !== "active" || tool.name !== streamEvent.name) { return { ok: false, code: "task_failed", message: "Tool event requires an active operation" } } if (streamEvent.type === "tool.completed" || streamEvent.type === "tool.failed") - toolStates.set(toolKey!, { ...tool, state: "terminal" }) + tools.set(streamEvent.toolCallId, { ...tool, state: "terminal" }) } if (streamEvent.type === "terminal.status") { - const state = terminalOperationStates.get(toolKey!) + const operations = scope(terminalOperationStates, streamEvent.taskId) + const state = operations.get(streamEvent.toolCallId) if (streamEvent.state === "running") { if (state !== undefined) return { ok: false, code: "task_failed", message: "Terminal operation started twice" } - terminalOperationStates.set(toolKey!, "active") + operations.set(streamEvent.toolCallId, "active") } else if (state !== "active") { return { ok: false, code: "task_failed", message: "Terminal status requires an active operation" } } else if (streamEvent.state === "exited" || streamEvent.state === "killed") { - terminalOperationStates.set(toolKey!, "terminal") + operations.set(streamEvent.toolCallId, "terminal") } - } else if (streamEvent.type === "terminal.output" && terminalOperationStates.get(toolKey!) !== "active") { + } else if ( + streamEvent.type === "terminal.output" && + terminalOperationStates.get(streamEvent.taskId)?.get(streamEvent.toolCallId) !== "active" + ) { return { ok: false, code: "task_failed", message: "Terminal output requires an active operation" } } - const mcpKey = - "operationId" in streamEvent ? `${streamEvent.taskId}\u0000${streamEvent.operationId}` : undefined if (streamEvent.type === "mcp.started") { - if (mcpStates.has(mcpKey!)) + const operations = scope(mcpStates, streamEvent.taskId) + if (operations.has(streamEvent.operationId)) return { ok: false, code: "task_failed", message: "MCP operation started twice" } - mcpStates.set(mcpKey!, { + operations.set(streamEvent.operationId, { state: "active", server: streamEvent.server, operation: streamEvent.operation, }) } else if (streamEvent.type === "mcp.completed" || streamEvent.type === "mcp.failed") { - const operation = mcpStates.get(mcpKey!) + const operations = scope(mcpStates, streamEvent.taskId) + const operation = operations.get(streamEvent.operationId) if ( operation?.state !== "active" || operation.server !== streamEvent.server || @@ -483,23 +585,23 @@ export function validateStreamLifecycle( ) { return { ok: false, code: "task_failed", message: "MCP result requires an active operation" } } - mcpStates.set(mcpKey!, { ...operation, state: "terminal" }) + operations.set(streamEvent.operationId, { ...operation, state: "terminal" }) } } - if (pendingAsks.size > 0 && resultEvent.result.outcome !== "needs_input") { + const pendingAskCount = [...pendingAsks.values()].reduce((count, asks) => count + asks.size, 0) + if (pendingAskCount > 0 && resultEvent.result.outcome !== "needs_input") { return { ok: false, code: "task_failed", message: "Terminal stream contains unresolved asks" } } if ( resultEvent.result.outcome === "needs_input" && - [...pendingAsks].some((askKey) => taskStates.get(askKey.slice(0, askKey.indexOf("\u0000"))) !== "waiting") + [...pendingAsks].some(([taskId, asks]) => asks.size > 0 && taskStates.get(taskId) !== "waiting") ) { return { ok: false, code: "task_failed", message: "Pending asks must belong to waiting tasks" } } - const resumeCommands = commands.filter((command) => command.type === "task.resume") if (resumeCommands.length !== resumedTasks.size) { - return { ok: false, code: "task_failed", message: "Every resume command must reconstruct one resumed root" } + return { ok: false, code: "task_failed", message: "Every resume command must reconstruct one resumed task" } } - if ([...abandonedAsks.values()].some((reason) => reason !== resultEvent.result.outcome)) { + if (values(abandonedAsks).some((reason) => reason !== resultEvent.result.outcome)) { return { ok: false, code: "task_failed", message: "Ask abandonment contradicts task.result" } } const expectedState = { @@ -546,8 +648,8 @@ export function validateStreamLifecycle( } } if ( - [...toolStates.values(), ...mcpStates.values()].some((operation) => operation.state === "active") || - [...terminalOperationStates.values()].includes("active") + [...values(toolStates), ...values(mcpStates)].some((operation) => operation.state === "active") || + values(terminalOperationStates).includes("active") ) { return { ok: false, code: "task_failed", message: "Terminal stream contains active operations" } } diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 771c38bc18..2df8dcb08f 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -2,6 +2,7 @@ const REDACTED = "[REDACTED]" as const const sensitiveKey = /(?:api[-_ ]?key|authorization|cookie|credential|password|private[-_ ]?key|secret|token)/i const sensitiveKeyName = String.raw`[A-Za-z0-9_.-]*(?:api[-_ ]?key|authorization|cookie|credential|password|private[-_ ]?key|secret|token)[A-Za-z0-9_.-]*` const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)` +const cliSecretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}]+)` const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") const singleQuotedSecret = new RegExp(`('${sensitiveKeyName}'\\s*:\\s*)'(?:\\\\.|[^'\\\\])*'`, "gi") const quotedUnquotedSecret = new RegExp( @@ -9,9 +10,8 @@ const quotedUnquotedSecret = new RegExp( "gi", ) const secretPatterns: ReadonlyArray = [ - /\bhttps?:\/\/[^\s/@:]+:[^\s/@]+@/gi, /\b(?:Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, - new RegExp(`--${sensitiveKeyName}(?:\\s*=\\s*|\\s+)${secretValue}`, "gi"), + new RegExp(`--${sensitiveKeyName}(?:\\s*=\\s*|\\s+)${cliSecretValue}`, "gi"), new RegExp(`(? { + const schemeEnd = authority.indexOf("//") + 2 + const credentialsEnd = authority.lastIndexOf("@") + if (credentialsEnd < schemeEnd || !authority.slice(schemeEnd, credentialsEnd).includes(":")) return authority + return `${authority.slice(0, schemeEnd)}${REDACTED}@${authority.slice(credentialsEnd + 1)}` + }) .replace(doubleQuotedSecret, `$1"${REDACTED}"`) .replace(singleQuotedSecret, `$1'${REDACTED}'`) .replace(quotedUnquotedSecret, `$1${REDACTED}`) From aa8e0bf3b6f67bb0e69730206e10c3844d2a6ac5 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 03:58:32 -0400 Subject: [PATCH 10/24] no-mistakes(review): Tighten Zoo protocol redaction and lifecycle contracts --- .../src/__tests__/contracts.test.ts | 162 +++++++++++++++++- packages/zoo-protocol/src/parity.ts | 9 +- packages/zoo-protocol/src/public-events.ts | 131 +++++++++++++- packages/zoo-protocol/src/redaction.ts | 17 +- 4 files changed, 306 insertions(+), 13 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 09c8ee38aa..9568d9e739 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -20,6 +20,7 @@ import { validateStreamLifecycle, zooRunResultSchema, zooStreamEventSchema, + zooStreamSchema, } from "../index.js" const timestamp = "2026-08-05T12:00:00.000Z" @@ -424,6 +425,9 @@ describe("public automation contracts", () => { expect(zooStreamEventSchema.safeParse({ ...event, seq: Number.MAX_SAFE_INTEGER + 1 }).success).toBe(false) expect(zooStreamEventSchema.safeParse({ ...event, rawSecret: "no" }).success).toBe(false) expect(zooStreamEventSchema.safeParse({ ...event, taskId: undefined }).success).toBe(false) + expect(zooStreamEventSchema.safeParse({ ...initEvent, capabilities: ["task:start", "future:additive"] }).success).toBe( + true, + ) }) it("requires init, contiguous sequence, and a settled authoritative root", () => { @@ -438,6 +442,12 @@ describe("public automation contracts", () => { [startCommand], ), ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, created, started, completed, resultEvent(5, {}, { requestId: "other" })], + [startCommand], + ), + ).toMatchObject({ ok: false }) expect(validateStreamLifecycle([initEvent, resultEvent(2)])).toMatchObject({ ok: false }) expect(validateStreamLifecycle([initEvent, created, resultEvent(3)])).toMatchObject({ ok: false }) expect( @@ -835,6 +845,99 @@ describe("public automation contracts", () => { ).toMatchObject({ ok: false }) }) + it("enforces operation, ask, message, and parent execution state", () => { + const created = taskEvent(2, "task.created") + const started = taskEvent(3, "task.started") + const required = taskEvent(4, "ask.required", { askId: "ask", category: "tool", subject: "Run" }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + taskEvent(5, "tool.started", { toolCallId: "tool", name: "shell" }), + taskEvent(6, "task.lifecycle", { state: "completed" }), + resultEvent(7), + ], + [startCommand], + ), + ).toMatchObject({ ok: false }) + + const response = hostCommandSchema.parse({ + v: 1, + id: "respond", + type: "ask.respond", + taskId: "root", + askId: "ask", + response: "approve", + }) + const resolved = taskEvent(5, "ask.resolved", { + requestId: "respond", + askId: "ask", + decision: "approve", + source: "user", + }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + resolved, + taskEvent(6, "ask.required", { askId: "ask", category: "tool", subject: "Again" }), + taskEvent(7, "task.lifecycle", { state: "completed" }), + resultEvent(8), + ], + [startCommand, response], + ), + ).toMatchObject({ ok: false }) + + const message = taskEvent(4, "message.upsert", { + messageId: "message", + role: "assistant", + content: "done", + complete: true, + }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + message, + taskEvent(5, "message.upsert", { + messageId: "message", + role: "user", + content: "changed", + complete: false, + }), + taskEvent(6, "task.lifecycle", { state: "completed" }), + resultEvent(7), + ], + [startCommand], + ), + ).toMatchObject({ ok: false }) + + expect( + validateStreamLifecycle( + [ + initEvent, + created, + taskEvent(3, "task.created", { taskId: "child", parentTaskId: "root" }), + taskEvent(4, "task.delegated", { taskId: "child", parentTaskId: "root", childTaskId: "child" }), + taskEvent(5, "task.started", { taskId: "child" }), + taskEvent(6, "task.lifecycle", { taskId: "child", state: "completed" }), + taskEvent(7, "task.started"), + taskEvent(8, "task.lifecycle", { state: "completed" }), + resultEvent(9), + ], + [startCommand], + ), + ).toMatchObject({ ok: false }) + }) + it("maps every terminal outcome deterministically", () => { expect(exitCodeFor({ outcome: "completed" })).toBe(EXIT_CODES.completed) expect(exitCodeFor({ outcome: "needs_input" })).toBe(EXIT_CODES.needsInput) @@ -879,6 +982,15 @@ describe("redaction contracts", () => { expect(redactText("https://alice:p@ss@example.com/path")).toBe("https://[REDACTED]@example.com/path") expect(redactText("--password abc,def run")).toBe("[REDACTED] run") expect(redactText('API_TOKEN="abc def" run')).toBe("[REDACTED] run") + expect(redactText('{"access token":"hunter2","client secret":"secret-value"}')).toBe( + '{"access token":"[REDACTED]","client secret":"[REDACTED]"}', + ) + expect(redactValue({ max_tokens: 4096, tokenCount: 12, tokenizer: "bpe", accessToken: "secret" })).toEqual({ + max_tokens: 4096, + tokenCount: 12, + tokenizer: "bpe", + accessToken: "[REDACTED]", + }) }) it("redacts public event and result payloads during parsing", () => { @@ -924,6 +1036,39 @@ describe("redaction contracts", () => { delta: "API_TOKEN=", }) expect(terminalPrefix.type === "terminal.output" && terminalPrefix.delta).toBe("[REDACTED]") + const failed = zooRunResultSchema.parse({ + schemaVersion: 1, + protocol: "zoo-run-result", + success: false, + outcome: "failed", + rootTaskId: "root", + workspace: "/workspace", + resumable: false, + error: { code: "provider_failed", message: "safe", phase: "password=hunter2" }, + elapsedMs: 1, + }) + expect(failed.error?.phase).toBe("[REDACTED]") + }) + + it("buffers terminal output across delta boundaries without destroying harmless output", () => { + const terminal = { + v: 1, + timestamp, + hostId: "host", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + } as const + const output = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "Build succeeded\n" }, + { ...terminal, seq: 2, delta: "API_TOKEN=" }, + { ...terminal, seq: 3, delta: "abcdefgh" }, + ]) + expect(output.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "Build succeeded\n[REDACTED]", + ) }) it("handles cycles without throwing", () => { @@ -998,7 +1143,7 @@ describe("deterministic parity oracle", () => { [{ type: "task.result", taskId: "root", rootTaskId: "root", outcome: "timed_out" }], "root", ), - ).toBe(false) + ).toBe(true) }) it("reports semantic drift without timestamps", () => { @@ -1015,6 +1160,21 @@ describe("deterministic parity oracle", () => { expected: [], }) expect(timeout.at(-1)).toMatchObject({ outcome: "timed_out", errorCode: "task_timed_out" }) + expect( + assertAuthoritativeRootResult( + [{ type: "task.result", taskId: "root", rootTaskId: "root", outcome: "timed_out" }], + "root", + ), + ).toBe(true) + const colonArgument = runDeterministicFakeProvider({ + id: "colon-argument", + prompt: "Read URL", + providerTurns: ["tool:read_file:call:https://example.com/a:b"], + expected: [], + }) + expect(colonArgument.find((entry) => entry.type === "tool.started")?.toolArguments).toEqual({ + path: "https://example.com/a:b", + }) expect(() => runDeterministicFakeProvider({ id: "invalid-failure", diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index d2da100c1a..3992a244c0 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -191,7 +191,12 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly for (const turn of scenario.providerTurns) { if (terminalReached) throw new Error("Fake-provider terminal directives must be the final turn") if (turn.startsWith("tool:")) { - const [, operation, toolCallId, argument] = turn.split(":") + const separator1 = turn.indexOf(":") + const separator2 = turn.indexOf(":", separator1 + 1) + const separator3 = turn.indexOf(":", separator2 + 1) + const operation = turn.slice(separator1 + 1, separator2) + const toolCallId = turn.slice(separator2 + 1, separator3) + const argument = turn.slice(separator3 + 1) if (operation !== "read_file" || !toolCallId || !argument) throw new Error(`Invalid tool fixture: ${turn}`) const tool = { rootTaskId: "root", @@ -294,7 +299,7 @@ export function assertAuthoritativeRootResult(trace: readonly SemanticTraceEntry } if (result.outcome === "timed_out") { return ( - ["task_timed_out", "cleanup_timed_out"].includes(result.errorCode ?? "") && + (result.errorCode === undefined || ["task_timed_out", "cleanup_timed_out"].includes(result.errorCode)) && result.cancellationReason === undefined ) } diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index bab7c07c79..d8e2228439 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -3,7 +3,7 @@ import { z } from "zod" import type { HostCommand } from "./host-commands.js" import { failedErrorCodeSchema, zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" import { REDACTED, redactValue, type RedactedValue } from "./redaction.js" -import { ZOO_PUBLIC_SCHEMA_VERSION, zooCapabilitySchema } from "./version.js" +import { ZOO_PUBLIC_SCHEMA_VERSION } from "./version.js" const strictObject = (shape: T) => z.object(shape).strict() @@ -61,7 +61,11 @@ const rawZooRunResultSchema = strictObject({ } }) -const redactError = (error: T): T => ({ ...error, message: String(redactValue(error.message)) }) +const redactError = (error: T): T => ({ + ...error, + message: String(redactValue(error.message)), + ...(error.phase === undefined ? {} : { phase: String(redactValue(error.phase)) }), +}) const redactRecord = (value: Record): Record => redactValue(value) as Record @@ -95,7 +99,7 @@ const taskEvent = (type: Typ const systemInitEventSchema = event("system.init", { protocol: z.literal("zoo-stream"), - capabilities: z.array(zooCapabilitySchema), + capabilities: z.array(z.string().min(1)), clientVersion: z.string().min(1), hostVersion: z.string().min(1), }) @@ -245,6 +249,64 @@ export const zooStreamEventSchema = rawZooStreamEventSchema.transform((streamEve export type ZooStreamEvent = z.infer +export const zooStreamSchema = z.array(rawZooStreamEventSchema).transform((events): ZooStreamEvent[] => { + type BufferedOutput = { pending: string; outputIndex: number } + const buffers = new Map>>() + const output = events.map((streamEvent) => + streamEvent.type === "terminal.output" + ? ({ ...streamEvent, delta: "" } as ZooStreamEvent) + : zooStreamEventSchema.parse(streamEvent), + ) + const bufferFor = (streamEvent: z.infer, outputIndex: number): BufferedOutput => { + let taskBuffers = buffers.get(streamEvent.taskId) + if (taskBuffers === undefined) { + taskBuffers = new Map() + buffers.set(streamEvent.taskId, taskBuffers) + } + let operationBuffers = taskBuffers.get(streamEvent.toolCallId) + if (operationBuffers === undefined) { + operationBuffers = new Map() + taskBuffers.set(streamEvent.toolCallId, operationBuffers) + } + const existing = operationBuffers.get(streamEvent.stream) + if (existing !== undefined) return existing + const created = { pending: "", outputIndex } + operationBuffers.set(streamEvent.stream, created) + return created + } + const flush = (buffer: BufferedOutput) => { + if (buffer.pending.length === 0) return + const event = output[buffer.outputIndex] + if (event?.type === "terminal.output") event.delta += String(redactValue(buffer.pending)) + buffer.pending = "" + } + + events.forEach((streamEvent, index) => { + if (streamEvent.type === "terminal.output") { + const buffer = bufferFor(streamEvent, index) + buffer.pending += streamEvent.delta + buffer.outputIndex = index + const boundary = buffer.pending.lastIndexOf("\n") + if (boundary >= 0) { + const event = output[index] + if (event?.type === "terminal.output") event.delta = String(redactValue(buffer.pending.slice(0, boundary + 1))) + buffer.pending = buffer.pending.slice(boundary + 1) + } + } else if ( + streamEvent.type === "terminal.status" && + (streamEvent.state === "exited" || streamEvent.state === "killed") + ) { + for (const buffer of buffers.get(streamEvent.taskId)?.get(streamEvent.toolCallId)?.values() ?? []) flush(buffer) + } + }) + for (const taskBuffers of buffers.values()) { + for (const operationBuffers of taskBuffers.values()) { + for (const buffer of operationBuffers.values()) flush(buffer) + } + } + return output +}) + export function validateStreamLifecycle( events: readonly ZooStreamEvent[], commands: readonly HostCommand[] = [], @@ -283,7 +345,8 @@ export function validateStreamLifecycle( startCommands.length !== 1 || resumeCommands.length !== 0 || start === undefined || - resultEvent.result.workspace !== start.workspace + resultEvent.result.workspace !== start.workspace || + (resultEvent.result.outcome !== "cancelled" && resultEvent.requestId !== start.id) ) { return { ok: false, code: "task_failed", message: "Fresh streams must match exactly one task.start command" } } @@ -292,6 +355,8 @@ export function validateStreamLifecycle( } const pendingAsks = new Map>() + const settledAsks = new Map>() + const consumedResponseCommands = new Set() const abandonedAsks = new Map>() const taskStates = new Map() const endedStates = new Set(["completed", "failed"]) @@ -301,11 +366,13 @@ export function validateStreamLifecycle( const delegatedTasks = new Set() const resumedTasks = new Set() const startedTasks = new Set() + type MessageState = { role: "assistant" | "user" | "reasoning"; complete: boolean } type ToolState = { state: "active" | "terminal"; name: string } type McpState = { state: "active" | "terminal"; server: string; operation: string } const toolStates = new Map>() const terminalOperationStates = new Map>() const mcpStates = new Map>() + const messageStates = new Map>() const scope = (map: Map>, taskId: string): Map => { const existing = map.get(taskId) if (existing !== undefined) return existing @@ -320,6 +387,13 @@ export function validateStreamLifecycle( pendingAsks.set(taskId, created) return created } + const setScope = (map: Map>, taskId: string): Set => { + const existing = map.get(taskId) + if (existing !== undefined) return existing + const created = new Set() + map.set(taskId, created) + return created + } const values = (map: Map>): T[] => [...map.values()].flatMap((entries) => [...entries.values()]) const isDescendantOf = (taskId: string, parentTaskId: string): boolean => { let current = taskParents.get(taskId) @@ -329,6 +403,14 @@ export function validateStreamLifecycle( } return false } + const hasPendingAskInAncestry = (taskId: string): boolean => { + let current: string | null | undefined = taskId + while (current !== undefined && current !== null) { + if ((pendingAsks.get(current)?.size ?? 0) > 0) return true + current = taskParents.get(current) + } + return false + } for (const streamEvent of events) { if ("rootTaskId" in streamEvent && streamEvent.rootTaskId !== rootTaskId) { return { @@ -413,10 +495,17 @@ export function validateStreamLifecycle( } } if (streamEvent.type === "task.started") { + const parentTaskId = taskParents.get(streamEvent.taskId) + const reconstructingResumedDescendant = + resumedEvents.length === 1 && resumeCommands[0]?.taskId === streamEvent.taskId && resumedTasks.has(streamEvent.taskId) if ( startedTasks.has(streamEvent.taskId) || (previousState !== undefined && previousState !== "running") || - (resumeCommands[0]?.taskId === streamEvent.taskId && !resumedTasks.has(streamEvent.taskId)) + (resumeCommands[0]?.taskId === streamEvent.taskId && !resumedTasks.has(streamEvent.taskId)) || + (parentTaskId !== null && + parentTaskId !== undefined && + taskStates.get(parentTaskId) !== "running" && + !reconstructingResumedDescendant) ) { return { ok: false, code: "task_failed", message: `Invalid start transition for task ${streamEvent.taskId}` } } @@ -483,10 +572,34 @@ export function validateStreamLifecycle( ) { return { ok: false, code: "task_failed", message: "Task operation requires an ordered task.started event" } } + if ( + [ + "tool.started", + "tool.updated", + "tool.completed", + "tool.failed", + "terminal.output", + "terminal.status", + "mcp.started", + "mcp.completed", + "mcp.failed", + ].includes(streamEvent.type) && + (taskStates.get(streamEvent.taskId) !== "running" || hasPendingAskInAncestry(streamEvent.taskId)) + ) { + return { ok: false, code: "task_failed", message: "Task operations require an unblocked running task" } + } + if (streamEvent.type === "message.upsert") { + const messages = scope(messageStates, streamEvent.taskId) + const previous = messages.get(streamEvent.messageId) + if (previous?.complete === true || (previous !== undefined && previous.role !== streamEvent.role)) { + return { ok: false, code: "task_failed", message: `Invalid update for message ${streamEvent.messageId}` } + } + messages.set(streamEvent.messageId, { role: streamEvent.role, complete: streamEvent.complete }) + } if (streamEvent.type === "ask.required") { const asks = askScope(streamEvent.taskId) - if (asks.has(streamEvent.askId)) { - return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} is already pending` } + if (asks.has(streamEvent.askId) || settledAsks.get(streamEvent.taskId)?.has(streamEvent.askId) === true) { + return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was already used` } } asks.add(streamEvent.askId) } @@ -504,6 +617,7 @@ export function validateStreamLifecycle( const response = commands.find( (command) => command.type === "ask.respond" && + !consumedResponseCommands.has(command.id) && command.id === streamEvent.requestId && command.taskId === streamEvent.taskId && command.askId === streamEvent.askId, @@ -519,13 +633,16 @@ export function validateStreamLifecycle( message: "User ask resolution does not match its response command", } } + if (response !== undefined) consumedResponseCommands.add(response.id) } + setScope(settledAsks, streamEvent.taskId).add(streamEvent.askId) } if (streamEvent.type === "ask.abandoned") { if (!pendingAsks.get(streamEvent.taskId)?.delete(streamEvent.askId)) { return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was not pending` } } scope(abandonedAsks, streamEvent.taskId).set(streamEvent.askId, streamEvent.reason) + setScope(settledAsks, streamEvent.taskId).add(streamEvent.askId) } if (streamEvent.type === "tool.started") { diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 2df8dcb08f..f1d9dc1cf9 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -1,6 +1,5 @@ const REDACTED = "[REDACTED]" as const -const sensitiveKey = /(?:api[-_ ]?key|authorization|cookie|credential|password|private[-_ ]?key|secret|token)/i -const sensitiveKeyName = String.raw`[A-Za-z0-9_.-]*(?:api[-_ ]?key|authorization|cookie|credential|password|private[-_ ]?key|secret|token)[A-Za-z0-9_.-]*` +const sensitiveKeyName = String.raw`[A-Za-z0-9_.-]*(?:api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|authorization|bearer[-_ ]?token|client[-_ ]?secret|cookie|credential|id[-_ ]?token|password|private[-_ ]?key|refresh[-_ ]?token|secret|session[-_ ]?token|token)[A-Za-z0-9_.-]*` const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)` const cliSecretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}]+)` const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") @@ -21,6 +20,18 @@ const secretPatterns: ReadonlyArray = [ export type RedactedValue = null | undefined | boolean | number | string | RedactedValue[] | { [key: string]: RedactedValue } +function isSensitiveKey(key: string): boolean { + const words = key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[^A-Za-z0-9]+/g, " ") + .trim() + .toLowerCase() + if (/^(?:authorization|cookie|credential|password|secret)$/.test(words)) return true + return /^(?:.* )?(?:api key|access token|auth token|bearer token|client secret|id token|private key|refresh token|session token|token)$/.test( + words, + ) +} + export function redactText(value: string): string { const structured = value .replace(/\bhttps?:\/\/[^\s/?#]+/gi, (authority) => { @@ -49,7 +60,7 @@ export function redactValue(value: unknown, seen = new WeakSet()): Redac } else { const entries: Record = {} for (const [key, entry] of Object.entries(value)) { - entries[key] = sensitiveKey.test(key) ? REDACTED : redactValue(entry, seen) + entries[key] = isSensitiveKey(key) ? REDACTED : redactValue(entry, seen) } result = entries } From c81508aef938e01226e599e7fedb3a399455809b Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 04:13:10 -0400 Subject: [PATCH 11/24] no-mistakes(review): Tighten Zoo protocol stream redaction and lifecycle contracts --- .../src/__tests__/contracts.test.ts | 140 ++++++++++++++++++ packages/zoo-protocol/src/host-events.ts | 59 +++++++- packages/zoo-protocol/src/parity.ts | 14 +- packages/zoo-protocol/src/public-events.ts | 133 ++++++++++------- packages/zoo-protocol/src/redaction.ts | 8 +- 5 files changed, 292 insertions(+), 62 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 9568d9e739..bebcaff2e6 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -2,6 +2,7 @@ import { EXIT_CODES, ZOO_HOST_PROTOCOL_VERSION, assertAuthoritativeRootResult, + createHostEventStreamParser, compareSemanticTraces, exitContextSchema, exitCodeFor, @@ -330,6 +331,36 @@ describe("strict host contracts", () => { expect(parsed.type === "command.error" && parsed.error.phase).toBe("[REDACTED]") }) + it("statefully redacts normalized terminal output at the host boundary", () => { + const parser = createHostEventStreamParser() + const envelope = (seq: number, delta: string) => ({ + v: 1, + seq, + hostId: "host", + type: "event", + event: { + v: 1, + seq, + timestamp, + hostId: "host", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + delta, + }, + }) + const events = [ + ...parser.push(envelope(1, "Build succeeded\n")), + ...parser.push(envelope(2, "API_TOKEN=")), + ...parser.push(envelope(3, "abcdefgh")), + ...parser.flush(), + ] + expect(events.map((event) => (event.type === "event" && event.event.type === "terminal.output" ? event.event.delta : "")).join("")) + .toBe("Build succeeded\n[REDACTED]") + }) + it("binds history completion data to its requested workspace", () => { const command = hostCommandSchema.parse({ v: 1, @@ -757,6 +788,27 @@ describe("public automation contracts", () => { expect(zooStreamEventSchema.safeParse({ ...resumed, previousState: "completed" }).success).toBe(false) }) + it("allows a resumed run to be cancelled by a distinct command", () => { + const resume = hostCommandSchema.parse({ v: 1, id: "resume", type: "task.resume", rootTaskId: "root", taskId: "root" }) + const cancel = hostCommandSchema.parse({ + v: 1, + id: "cancel", + type: "task.cancel", + rootTaskId: "root", + reason: "user", + }) + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.lifecycle", { state: "interrupted" }), + taskEvent(4, "task.resumed", { requestId: "resume", previousState: "interrupted" }), + taskEvent(5, "task.started"), + taskEvent(6, "task.lifecycle", { state: "interrupted" }), + resultEvent(7, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }), + ] + expect(validateStreamLifecycle(stream, [resume, cancel])).toEqual({ ok: true }) + }) + it("resumes a correlated descendant from its reconstructed predecessor", () => { const command = hostCommandSchema.parse({ v: 1, @@ -938,6 +990,37 @@ describe("public automation contracts", () => { ).toMatchObject({ ok: false }) }) + it("requires completed streams to settle partial messages", () => { + const partial = taskEvent(4, "message.upsert", { + messageId: "message", + role: "assistant", + content: "partial", + complete: false, + }) + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + partial, + taskEvent(5, "task.lifecycle", { state: "completed" }), + resultEvent(6), + ] + expect(validateStreamLifecycle(stream, [startCommand])).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [ + ...stream.slice(0, -2), + taskEvent(5, "task.lifecycle", { state: "failed" }), + resultEvent(6, { + outcome: "failed", + error: { code: "task_failed", message: "failed" }, + }), + ], + [startCommand], + ), + ).toEqual({ ok: true }) + }) + it("maps every terminal outcome deterministically", () => { expect(exitCodeFor({ outcome: "completed" })).toBe(EXIT_CODES.completed) expect(exitCodeFor({ outcome: "needs_input" })).toBe(EXIT_CODES.needsInput) @@ -991,6 +1074,15 @@ describe("redaction contracts", () => { tokenizer: "bpe", accessToken: "[REDACTED]", }) + expect(redactValue({ databasePassword: "pw", signingSecret: "sig", secretAccessKey: "key" })).toEqual({ + databasePassword: "[REDACTED]", + signingSecret: "[REDACTED]", + secretAccessKey: "[REDACTED]", + }) + expect(redactText("https://opaque-token@example.com/path")).toBe("https://[REDACTED]@example.com/path") + expect(redactText('{"max_tokens":4096} tokenizer=bpe --max-tokens 4096')).toBe( + '{"max_tokens":4096} tokenizer=bpe --max-tokens 4096', + ) }) it("redacts public event and result payloads during parsing", () => { @@ -1071,6 +1163,37 @@ describe("redaction contracts", () => { ) }) + it("buffers multiline secrets and fails closed when bounded memory is exceeded", () => { + const terminal = { + v: 1, + timestamp, + hostId: "host", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + } as const + const output = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "-----BEGIN PRIVATE KEY-----\n" }, + { ...terminal, seq: 2, delta: "super-secret-body\n" }, + { ...terminal, seq: 3, delta: "-----END PRIVATE KEY-----\n" }, + ]) + expect(output.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) + .toBe("[REDACTED]\n") + + const parser = createHostEventStreamParser({ maxPendingBytes: 4 }) + const overflow = parser.push({ + v: 1, + seq: 1, + hostId: "host", + type: "event", + event: { ...terminal, seq: 1, delta: "secret" }, + }) + expect(overflow[0]?.type === "event" && overflow[0].event.type === "terminal.output" && overflow[0].event.delta) + .toBe("[REDACTED]") + }) + it("handles cycles without throwing", () => { const input: Record = {} input.self = input @@ -1193,6 +1316,23 @@ describe("deterministic parity oracle", () => { ).toThrow() }) + it("rejects unresolved fake-provider state and events after the authoritative result", () => { + for (const providerTurns of [["delegate:child"], ["ask:ask-1"]]) { + expect(() => + runDeterministicFakeProvider({ id: "unresolved", prompt: "Unresolved", providerTurns, expected: [] }), + ).toThrow() + } + expect( + assertAuthoritativeRootResult( + [ + { type: "task.result", taskId: "root", rootTaskId: "root", outcome: "completed" }, + { type: "message.upsert", taskId: "root", content: "late" }, + ], + "root", + ), + ).toBe(false) + }) + it("ignores object property insertion order without ignoring event order", () => { const expected = [{ type: "message.upsert", taskId: "root", content: "hello" }] const reordered = [{ content: "hello", taskId: "root", type: "message.upsert" }] diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index 5f3ee9bcb0..fc755c9919 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -2,7 +2,12 @@ import { z } from "zod" import type { HostCommand } from "./host-commands.js" import { zooErrorSchema } from "./outcomes.js" -import { zooStreamEventSchema } from "./public-events.js" +import { + createZooStreamRedactor, + rawZooStreamEventSchema, + zooStreamEventSchema, + type RawZooStreamEvent, +} from "./public-events.js" import { redactText } from "./redaction.js" import { ZOO_HOST_PROTOCOL_VERSION } from "./version.js" @@ -70,6 +75,7 @@ const snapshotSchema = strictObject({ activeRootTaskId: z.string().min(1).optional(), }) const normalizedEventSchema = strictObject({ ...base, type: z.literal("event"), event: zooStreamEventSchema }) +const rawNormalizedEventSchema = strictObject({ ...base, type: z.literal("event"), event: rawZooStreamEventSchema }) const hostEventDiscriminatedSchema = z.discriminatedUnion("type", [ commandAckSchema, @@ -80,6 +86,15 @@ const hostEventDiscriminatedSchema = z.discriminatedUnion("type", [ normalizedEventSchema, ]) +const rawHostEventDiscriminatedSchema = z.discriminatedUnion("type", [ + commandAckSchema, + commandDoneSchema, + commandErrorSchema, + heartbeatSchema, + snapshotSchema, + rawNormalizedEventSchema, +]) + export const hostEventSchema = hostEventDiscriminatedSchema .superRefine((event, context) => { if (event.type === "event" && event.event.hostId !== event.hostId) { @@ -101,6 +116,48 @@ export const hostEventSchema = hostEventDiscriminatedSchema export type HostEvent = z.infer +export type HostEventStreamParser = { + push: (event: unknown) => HostEvent[] + flush: () => HostEvent[] +} + +export function createHostEventStreamParser( + options: { maxPendingBytes?: number; maxPendingEvents?: number } = {}, +): HostEventStreamParser { + const redactor = createZooStreamRedactor(options) + const envelopes = new Map>() + const eventKey = (event: RawZooStreamEvent) => JSON.stringify([event.hostId, event.seq]) + const wrap = (events: ReturnType): HostEvent[] => + events.map((event) => { + const envelope = envelopes.get(eventKey(event)) + if (envelope === undefined) throw new Error("Missing host envelope for buffered Zoo stream event") + envelopes.delete(eventKey(event)) + return { ...envelope, event } + }) + const sanitizeNonEvent = (event: z.infer): HostEvent => + event.type === "command.error" + ? { + ...event, + error: { + ...event.error, + message: redactText(event.error.message), + phase: event.error.phase === undefined ? undefined : redactText(event.error.phase), + }, + } + : event as HostEvent + + return { + push(input) { + const event = rawHostEventDiscriminatedSchema.parse(input) + if (event.type !== "event") return [...wrap(redactor.flush()), sanitizeNonEvent(event)] + if (event.event.hostId !== event.hostId) throw new Error("Normalized event hostId must match its host envelope") + envelopes.set(eventKey(event.event), event) + return wrap(redactor.push(event.event)) + }, + flush: () => wrap(redactor.flush()), + } +} + export function validateMonotonicSequence( previous: number, next: number, diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index 3992a244c0..2a5d4f4178 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -188,6 +188,8 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly ] let result: SemanticTraceEntry = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" } let terminalReached = false + const activeChildren = new Set() + const pendingAsks = new Set() for (const turn of scenario.providerTurns) { if (terminalReached) throw new Error("Fake-provider terminal directives must be the final turn") if (turn.startsWith("tool:")) { @@ -215,21 +217,27 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly trace.push({ type: "task.created", rootTaskId: "root", taskId, parentTaskId: "root" }) trace.push({ type: "task.delegated", rootTaskId: "root", taskId, parentTaskId: "root" }) trace.push({ type: "task.started", rootTaskId: "root", taskId }) + activeChildren.add(taskId) continue } if (turn.endsWith(":done")) { const taskId = turn.slice(0, -":done".length) if (!taskId) throw new Error(`Invalid completion fixture: ${turn}`) trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId, state: "completed" }) + activeChildren.delete(taskId) continue } if (turn.startsWith("ask:")) { - trace.push({ type: "ask.required", rootTaskId: "root", taskId: "root", askId: turn.slice(4) }) + const askId = turn.slice(4) + if (!askId || pendingAsks.has(askId)) throw new Error(`Invalid ask fixture: ${turn}`) + pendingAsks.add(askId) + trace.push({ type: "ask.required", rootTaskId: "root", taskId: "root", askId }) continue } if (turn.startsWith("approve:")) { const [, askId, source, requestId] = turn.split(":") if (!askId || source !== "user" || !requestId) throw new Error(`Invalid approval fixture: ${turn}`) + if (!pendingAsks.delete(askId)) throw new Error(`Approval references unknown ask: ${turn}`) trace.push({ type: "ask.resolved", rootTaskId: "root", @@ -277,6 +285,9 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly trace.push({ type: "message.upsert", rootTaskId: "root", taskId: "root", content: turn }) } if (result.outcome === "completed") { + if (activeChildren.size > 0 || pendingAsks.size > 0) { + throw new Error("Fake-provider scenarios cannot complete with unresolved descendants or asks") + } trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }) } trace.push(result) @@ -287,6 +298,7 @@ export function assertAuthoritativeRootResult(trace: readonly SemanticTraceEntry const results = trace.filter((entry) => entry.type === "task.result") if (results.length !== 1) return false const result = results[0]! + if (trace.at(-1) !== result) return false if ( result.taskId !== rootTaskId || result.rootTaskId !== rootTaskId || diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index d8e2228439..2b8fbf4c91 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -171,7 +171,7 @@ const usageUpdatedEventSchema = taskEvent("usage.updated", { }) const taskResultEventSchema = taskEvent("task.result", { result: zooRunResultSchema }) -const rawZooStreamEventSchema = z.discriminatedUnion("type", [ +export const rawZooStreamEventSchema = z.discriminatedUnion("type", [ systemInitEventSchema, systemWarningEventSchema, taskCreatedEventSchema, @@ -205,7 +205,7 @@ const rawZooStreamEventSchema = z.discriminatedUnion("type", [ } }) -export const zooStreamEventSchema = rawZooStreamEventSchema.transform((streamEvent) => { +const redactStreamEvent = (streamEvent: z.infer) => { switch (streamEvent.type) { case "system.warning": return { ...streamEvent, message: String(redactValue(streamEvent.message)) } @@ -245,66 +245,84 @@ export const zooStreamEventSchema = rawZooStreamEventSchema.transform((streamEve default: return streamEvent } -}) +} + +export const zooStreamEventSchema = rawZooStreamEventSchema.transform(redactStreamEvent) export type ZooStreamEvent = z.infer +export type RawZooStreamEvent = z.infer -export const zooStreamSchema = z.array(rawZooStreamEventSchema).transform((events): ZooStreamEvent[] => { - type BufferedOutput = { pending: string; outputIndex: number } - const buffers = new Map>>() - const output = events.map((streamEvent) => - streamEvent.type === "terminal.output" - ? ({ ...streamEvent, delta: "" } as ZooStreamEvent) - : zooStreamEventSchema.parse(streamEvent), - ) - const bufferFor = (streamEvent: z.infer, outputIndex: number): BufferedOutput => { - let taskBuffers = buffers.get(streamEvent.taskId) - if (taskBuffers === undefined) { - taskBuffers = new Map() - buffers.set(streamEvent.taskId, taskBuffers) - } - let operationBuffers = taskBuffers.get(streamEvent.toolCallId) - if (operationBuffers === undefined) { - operationBuffers = new Map() - taskBuffers.set(streamEvent.toolCallId, operationBuffers) - } - const existing = operationBuffers.get(streamEvent.stream) - if (existing !== undefined) return existing - const created = { pending: "", outputIndex } - operationBuffers.set(streamEvent.stream, created) - return created - } - const flush = (buffer: BufferedOutput) => { - if (buffer.pending.length === 0) return - const event = output[buffer.outputIndex] - if (event?.type === "terminal.output") event.delta += String(redactValue(buffer.pending)) - buffer.pending = "" +export type ZooStreamRedactor = { + push: (event: RawZooStreamEvent) => ZooStreamEvent[] + flush: () => ZooStreamEvent[] +} + +export function createZooStreamRedactor(options: { maxPendingBytes?: number; maxPendingEvents?: number } = {}): ZooStreamRedactor { + const maxPendingBytes = options.maxPendingBytes ?? 64 * 1024 + const maxPendingEvents = options.maxPendingEvents ?? 256 + type PendingOutput = { events: Array>; text: string; pem: boolean } + let pending: PendingOutput | undefined + const overflowedKeys = new Set() + const outputKey = (event: z.infer) => + JSON.stringify([event.hostId, event.rootTaskId, event.taskId, event.toolCallId, event.stream]) + let pendingKey: string | undefined + + const emit = (replacement?: string): ZooStreamEvent[] => { + if (pending === undefined) return [] + const [first, ...rest] = pending.events + const delta = replacement ?? String(redactValue(pending.text)) + pending = undefined + pendingKey = undefined + return first === undefined + ? [] + : [{ ...first, delta }, ...rest.map((event) => ({ ...event, delta: "" }))] } - events.forEach((streamEvent, index) => { - if (streamEvent.type === "terminal.output") { - const buffer = bufferFor(streamEvent, index) - buffer.pending += streamEvent.delta - buffer.outputIndex = index - const boundary = buffer.pending.lastIndexOf("\n") - if (boundary >= 0) { - const event = output[index] - if (event?.type === "terminal.output") event.delta = String(redactValue(buffer.pending.slice(0, boundary + 1))) - buffer.pending = buffer.pending.slice(boundary + 1) + return { + push(event) { + if (event.type !== "terminal.output") { + if (event.type === "terminal.status" && (event.state === "exited" || event.state === "killed")) { + for (const key of overflowedKeys) { + const [hostId, rootTaskId, taskId, toolCallId] = JSON.parse(key) as string[] + if ( + hostId === event.hostId && + rootTaskId === event.rootTaskId && + taskId === event.taskId && + toolCallId === event.toolCallId + ) { + overflowedKeys.delete(key) + } + } + } + return [...emit(), redactStreamEvent(event)] } - } else if ( - streamEvent.type === "terminal.status" && - (streamEvent.state === "exited" || streamEvent.state === "killed") - ) { - for (const buffer of buffers.get(streamEvent.taskId)?.get(streamEvent.toolCallId)?.values() ?? []) flush(buffer) - } - }) - for (const taskBuffers of buffers.values()) { - for (const operationBuffers of taskBuffers.values()) { - for (const buffer of operationBuffers.values()) flush(buffer) - } + const key = outputKey(event) + const preceding = pendingKey !== undefined && pendingKey !== key ? emit() : [] + if (overflowedKeys.has(key)) return [...preceding, { ...event, delta: event.delta.length === 0 ? "" : REDACTED }] + if (pending === undefined) { + pending = { events: [], text: "", pem: false } + pendingKey = key + } + pending.events.push(event) + pending.text += event.delta + pending.pem ||= /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(pending.text) + if (pending.text.length > maxPendingBytes || pending.events.length > maxPendingEvents) { + overflowedKeys.add(key) + return [...preceding, ...emit(REDACTED)] + } + if (pending.pem && !/-----END [A-Z ]*PRIVATE KEY-----/.test(pending.text)) return preceding + const boundary = pending.text.lastIndexOf("\n") + return boundary >= 0 || (pending.pem && /-----END [A-Z ]*PRIVATE KEY-----/.test(pending.text)) + ? [...preceding, ...emit()] + : preceding + }, + flush: () => emit(), } - return output +} + +export const zooStreamSchema = z.array(rawZooStreamEventSchema).transform((events): ZooStreamEvent[] => { + const redactor = createZooStreamRedactor() + return [...events.flatMap((streamEvent) => redactor.push(streamEvent)), ...redactor.flush()] }) export function validateStreamLifecycle( @@ -542,7 +560,7 @@ export function validateStreamLifecycle( resume.id !== streamEvent.requestId || resume.taskId !== streamEvent.taskId || resume.rootTaskId !== streamEvent.rootTaskId || - resultEvent.requestId !== resume.id || + (resultEvent.result.outcome !== "cancelled" && resultEvent.requestId !== resume.id) || resumedTasks.size > 0 || previousState !== streamEvent.previousState ) { @@ -770,6 +788,9 @@ export function validateStreamLifecycle( ) { return { ok: false, code: "task_failed", message: "Terminal stream contains active operations" } } + if (resultEvent.result.outcome === "completed" && values(messageStates).some((message) => !message.complete)) { + return { ok: false, code: "task_failed", message: "Completed streams cannot contain partial messages" } + } if (resultEvent.result.outcome === "cancelled") { const cancellation = commands.find( (command) => diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index f1d9dc1cf9..96368c3021 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -1,5 +1,5 @@ const REDACTED = "[REDACTED]" as const -const sensitiveKeyName = String.raw`[A-Za-z0-9_.-]*(?:api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|authorization|bearer[-_ ]?token|client[-_ ]?secret|cookie|credential|id[-_ ]?token|password|private[-_ ]?key|refresh[-_ ]?token|secret|session[-_ ]?token|token)[A-Za-z0-9_.-]*` +const sensitiveKeyName = String.raw`(?:[A-Za-z0-9_.-]*(?:password|secret)[A-Za-z0-9_.-]*|api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|authorization|bearer[-_. ]?token|client[-_. ]?secret|cookie|credential|id[-_. ]?token|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token|token)` const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)` const cliSecretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}]+)` const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") @@ -26,8 +26,8 @@ function isSensitiveKey(key: string): boolean { .replace(/[^A-Za-z0-9]+/g, " ") .trim() .toLowerCase() - if (/^(?:authorization|cookie|credential|password|secret)$/.test(words)) return true - return /^(?:.* )?(?:api key|access token|auth token|bearer token|client secret|id token|private key|refresh token|session token|token)$/.test( + if (/\b(?:password|secret)\b/.test(words) || /^(?:authorization|cookie|credential)$/.test(words)) return true + return /^(?:.* )?(?:api key|api token|access token|auth token|bearer token|id token|private key|refresh token|session token|token)$/.test( words, ) } @@ -37,7 +37,7 @@ export function redactText(value: string): string { .replace(/\bhttps?:\/\/[^\s/?#]+/gi, (authority) => { const schemeEnd = authority.indexOf("//") + 2 const credentialsEnd = authority.lastIndexOf("@") - if (credentialsEnd < schemeEnd || !authority.slice(schemeEnd, credentialsEnd).includes(":")) return authority + if (credentialsEnd < schemeEnd) return authority return `${authority.slice(0, schemeEnd)}${REDACTED}@${authority.slice(credentialsEnd + 1)}` }) .replace(doubleQuotedSecret, `$1"${REDACTED}"`) From 480811d543adc02a69229804bb1f4780089b0f70 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 04:31:39 -0400 Subject: [PATCH 12/24] no-mistakes(review): Tighten protocol redaction and lifecycle invariants --- .../src/__tests__/contracts.test.ts | 215 +++++++++++++++++- packages/zoo-protocol/src/host-events.ts | 71 ++++-- packages/zoo-protocol/src/parity.ts | 18 +- packages/zoo-protocol/src/public-events.ts | 179 +++++++++++---- packages/zoo-protocol/src/redaction.ts | 20 +- 5 files changed, 433 insertions(+), 70 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index bebcaff2e6..24c08e234a 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -80,6 +80,28 @@ function resultEvent(seq: number, result: Record = {}, event: R return parsed } +function cancellationDone(commandId = "cancel") { + return hostEventSchema.parse({ + v: 1, + seq: 1, + hostId: "host", + type: "command.done", + commandId, + data: { commandType: "task.cancel", rootTaskId: "root" }, + }) +} + +function cancellationError(commandId = "cancel") { + return hostEventSchema.parse({ + v: 1, + seq: 1, + hostId: "host", + type: "command.error", + commandId, + error: { code: "cancel_failed", message: "Task already completed" }, + }) +} + describe("strict host contracts", () => { it("accepts a valid start and rejects unknown fields", () => { const command = { @@ -359,6 +381,57 @@ describe("strict host contracts", () => { ] expect(events.map((event) => (event.type === "event" && event.event.type === "terminal.output" ? event.event.delta : "")).join("")) .toBe("Build succeeded\n[REDACTED]") + + const interleavedParser = createHostEventStreamParser() + const interleaved = [ + ...interleavedParser.push(envelope(1, "API_TOKEN=")), + ...interleavedParser.push({ v: 1, seq: 2, hostId: "host", type: "host.heartbeat", monotonicMs: 1 }), + ...interleavedParser.push(envelope(3, "abcdefgh")), + ...interleavedParser.flush(), + ] + expect(interleaved.map((event) => event.seq)).toEqual([1, 2, 3]) + expect( + interleaved + .filter((event) => event.type === "event" && event.event.type === "terminal.output") + .map((event) => (event.type === "event" && event.event.type === "terminal.output" ? event.event.delta : "")) + .join(""), + ).toBe("[REDACTED]") + }) + + it("preserves host envelopes across concurrent root streams", () => { + const parser = createHostEventStreamParser() + const envelope = (hostSeq: number, rootTaskId: string, delta: string) => ({ + v: 1, + seq: hostSeq, + hostId: "host", + type: "event", + event: { + v: 1, + seq: 1, + timestamp, + hostId: "host", + rootTaskId, + taskId: rootTaskId, + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + delta, + }, + }) + const events = [ + ...parser.push(envelope(1, "root-a", "first\n")), + ...parser.push(envelope(2, "root-b", "second\n")), + ...parser.flush(), + ] + expect( + events.map((event) => [ + event.seq, + event.type === "event" && "rootTaskId" in event.event ? event.event.rootTaskId : undefined, + ]), + ).toEqual([ + [1, "root-a"], + [2, "root-b"], + ]) }) it("binds history completion data to its requested workspace", () => { @@ -419,6 +492,15 @@ describe("public automation contracts", () => { error: { code: "task_failed", message: "contradiction" }, }).success, ).toBe(false) + expect(zooRunResultSchema.safeParse({ ...result, resumable: true }).success).toBe(false) + expect( + zooRunResultSchema.safeParse({ + ...result, + success: false, + outcome: "needs_input", + resumable: true, + }).success, + ).toBe(true) expect( zooRunResultSchema.safeParse({ ...result, @@ -610,6 +692,20 @@ describe("public automation contracts", () => { expect( validateStreamLifecycle([initEvent, rootCreated, required, deniedApproval, completed, resultEvent(6)]), ).toMatchObject({ ok: false }) + const policyOverride = zooStreamEventSchema.parse({ ...resolved, source: "policy", decision: "reject" }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, policyOverride, completed, resultEvent(7)], + [startCommand, response], + ), + ).toMatchObject({ ok: false }) + const policyReportedResponse = zooStreamEventSchema.parse({ ...resolved, source: "policy" }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, policyReportedResponse, completed, resultEvent(7)], + [startCommand, response], + ), + ).toEqual({ ok: true }) }) it("correlates cancellation and settles operation lifecycles", () => { @@ -644,7 +740,7 @@ describe("public automation contracts", () => { interrupted, cancelled, ] - expect(validateStreamLifecycle(stream, [startCommand, command])).toEqual({ ok: true }) + expect(validateStreamLifecycle(stream, [startCommand, command], [cancellationDone()])).toEqual({ ok: true }) expect(validateStreamLifecycle(stream)).toMatchObject({ ok: false }) expect(validateStreamLifecycle(stream, [{ ...command, reason: "signal" }])).toMatchObject({ ok: false }) expect( @@ -728,7 +824,11 @@ describe("public automation contracts", () => { reason: "user", }) expect( - validateStreamLifecycle([initEvent, created, started, required, abandoned, interrupted, cancelled], [startCommand, command]), + validateStreamLifecycle( + [initEvent, created, started, required, abandoned, interrupted, cancelled], + [startCommand, command], + [cancellationDone()], + ), ).toEqual({ ok: true }) expect( validateStreamLifecycle( @@ -806,7 +906,29 @@ describe("public automation contracts", () => { taskEvent(6, "task.lifecycle", { state: "interrupted" }), resultEvent(7, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }), ] - expect(validateStreamLifecycle(stream, [resume, cancel])).toEqual({ ok: true }) + expect(validateStreamLifecycle(stream, [resume, cancel], [cancellationDone()])).toEqual({ ok: true }) + }) + + it("requires accepted cancellations to own cancelled results", () => { + const cancel = hostCommandSchema.parse({ + v: 1, + id: "cancel", + type: "task.cancel", + rootTaskId: "root", + reason: "user", + }) + const completed = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ] + expect(validateStreamLifecycle(completed, [startCommand, cancel], [cancellationDone()])).toMatchObject({ + ok: false, + }) + expect(validateStreamLifecycle(completed, [startCommand, cancel], [cancellationError()])).toEqual({ ok: true }) + expect(validateStreamLifecycle(completed, [startCommand, cancel])).toMatchObject({ ok: false }) }) it("resumes a correlated descendant from its reconstructed predecessor", () => { @@ -1080,6 +1202,15 @@ describe("redaction contracts", () => { secretAccessKey: "[REDACTED]", }) expect(redactText("https://opaque-token@example.com/path")).toBe("https://[REDACTED]@example.com/path") + expect(redactText("postgres://alice:hunter2@db/prod")).toBe("postgres://[REDACTED]@db/prod") + expect(redactText("redis://:secret@cache/0")).toBe("redis://[REDACTED]@cache/0") + expect(redactText("API_TOKEN=abc,def")).toBe("[REDACTED]") + expect(redactValue({ credentials: "value", passphrase: "value", passwd: "value", pwd: "value" })).toEqual({ + credentials: "[REDACTED]", + passphrase: "[REDACTED]", + passwd: "[REDACTED]", + pwd: "[REDACTED]", + }) expect(redactText('{"max_tokens":4096} tokenizer=bpe --max-tokens 4096')).toBe( '{"max_tokens":4096} tokenizer=bpe --max-tokens 4096', ) @@ -1161,6 +1292,13 @@ describe("redaction contracts", () => { expect(output.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( "Build succeeded\n[REDACTED]", ) + const mixed = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "Build succeeded\nAPI_TOKEN=" }, + { ...terminal, seq: 2, delta: "abcdefgh\n" }, + ]) + expect(mixed.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "Build succeeded\n[REDACTED]\n", + ) }) it("buffers multiline secrets and fails closed when bounded memory is exceeded", () => { @@ -1192,6 +1330,53 @@ describe("redaction contracts", () => { }) expect(overflow[0]?.type === "event" && overflow[0].event.type === "terminal.output" && overflow[0].event.delta) .toBe("[REDACTED]") + const cappedParser = createHostEventStreamParser({ maxPendingStreams: 1 }) + const pending = (seq: number, toolCallId: string) => ({ + v: 1, + seq, + hostId: "host", + type: "event", + event: { ...terminal, seq, toolCallId, delta: "unterminated" }, + }) + const capped = [ + ...cappedParser.push(pending(1, "first")), + ...cappedParser.push(pending(2, "second")), + ...cappedParser.push(pending(3, "third")), + ...cappedParser.flush(), + ] + expect( + capped.every( + (event) => event.type === "event" && event.event.type === "terminal.output" && event.event.delta === "[REDACTED]", + ), + ).toBe(true) + + const unterminated = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "-----BEGIN PRIVATE KEY-----\n" }, + { ...terminal, seq: 2, delta: "super-secret-body" }, + ]) + expect(unterminated.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED]", + ) + + const interleaved = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "API_TOKEN=" }, + { ...terminal, seq: 2, stream: "stderr", delta: "harmless\n" }, + { ...terminal, seq: 3, delta: "abcdefgh\n" }, + ]) + expect( + interleaved + .filter((event) => event.type === "terminal.output" && event.stream === "stdout") + .map((event) => (event.type === "terminal.output" ? event.delta : "")) + .join(""), + ).toBe("[REDACTED]\n") + }) + + it("preserves prototype-like keys as redacted record data", () => { + const input = JSON.parse('{"__proto__":{"polluted":true},"safe":"value"}') as Record + const output = redactValue(input) + expect(output).toEqual(input) + expect(Object.prototype.hasOwnProperty.call(output, "__proto__")).toBe(true) + expect(({} as Record).polluted).toBeUndefined() }) it("handles cycles without throwing", () => { @@ -1317,7 +1502,13 @@ describe("deterministic parity oracle", () => { }) it("rejects unresolved fake-provider state and events after the authoritative result", () => { - for (const providerTurns of [["delegate:child"], ["ask:ask-1"]]) { + for (const providerTurns of [ + ["delegate:child"], + ["ask:ask-1"], + ["delegate:child", "fail:provider_failed"], + ["ask:ask-1", "cancel:cancel-1:user"], + ["delegate:child", "timeout:task_timed_out"], + ]) { expect(() => runDeterministicFakeProvider({ id: "unresolved", prompt: "Unresolved", providerTurns, expected: [] }), ).toThrow() @@ -1331,6 +1522,22 @@ describe("deterministic parity oracle", () => { "root", ), ).toBe(false) + expect(() => + runDeterministicFakeProvider({ + id: "ghost", + prompt: "Ghost", + providerTurns: ["ghost:done"], + expected: [], + }), + ).toThrow() + expect(() => + runDeterministicFakeProvider({ + id: "duplicate", + prompt: "Duplicate", + providerTurns: ["delegate:child", "delegate:child"], + expected: [], + }), + ).toThrow() }) it("ignores object property insertion order without ignoring event order", () => { diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index fc755c9919..37592c76b9 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -122,18 +122,46 @@ export type HostEventStreamParser = { } export function createHostEventStreamParser( - options: { maxPendingBytes?: number; maxPendingEvents?: number } = {}, + options: { maxPendingBytes?: number; maxPendingEvents?: number; maxPendingStreams?: number } = {}, ): HostEventStreamParser { const redactor = createZooStreamRedactor(options) - const envelopes = new Map>() - const eventKey = (event: RawZooStreamEvent) => JSON.stringify([event.hostId, event.seq]) - const wrap = (events: ReturnType): HostEvent[] => - events.map((event) => { - const envelope = envelopes.get(eventKey(event)) - if (envelope === undefined) throw new Error("Missing host envelope for buffered Zoo stream event") - envelopes.delete(eventKey(event)) - return { ...envelope, event } - }) + type QueueEntry = { envelope?: z.infer; output?: HostEvent } + const queue: QueueEntry[] = [] + const envelopes = new Map() + const eventKey = (event: RawZooStreamEvent) => + JSON.stringify([ + event.hostId, + event.seq, + event.timestamp, + event.type, + event.requestId, + "rootTaskId" in event ? event.rootTaskId : undefined, + "taskId" in event ? event.taskId : undefined, + "messageId" in event ? event.messageId : undefined, + "askId" in event ? event.askId : undefined, + "toolCallId" in event ? event.toolCallId : undefined, + "operationId" in event ? event.operationId : undefined, + "stream" in event ? event.stream : undefined, + ]) + const assign = (events: ReturnType) => { + for (const event of events) { + const key = eventKey(event) + const entries = envelopes.get(key) + const entry = entries?.shift() + if (entry === undefined) throw new Error("Missing host envelope for buffered Zoo stream event") + if (entries?.length === 0) envelopes.delete(key) + const envelope = entry.envelope + if (envelope === undefined) { + throw new Error("Missing host envelope for buffered Zoo stream event") + } + entry.output = { ...envelope, event } + } + } + const drain = (): HostEvent[] => { + const ready: HostEvent[] = [] + while (queue[0]?.output !== undefined) ready.push(queue.shift()!.output!) + return ready + } const sanitizeNonEvent = (event: z.infer): HostEvent => event.type === "command.error" ? { @@ -149,12 +177,27 @@ export function createHostEventStreamParser( return { push(input) { const event = rawHostEventDiscriminatedSchema.parse(input) - if (event.type !== "event") return [...wrap(redactor.flush()), sanitizeNonEvent(event)] + const entry: QueueEntry = {} + queue.push(entry) + if (event.type !== "event") { + entry.output = sanitizeNonEvent(event) + return drain() + } if (event.event.hostId !== event.hostId) throw new Error("Normalized event hostId must match its host envelope") - envelopes.set(eventKey(event.event), event) - return wrap(redactor.push(event.event)) + entry.envelope = event + const key = eventKey(event.event) + const entries = envelopes.get(key) ?? [] + entries.push(entry) + envelopes.set(key, entries) + assign(redactor.push(event.event)) + return drain() + }, + flush() { + assign(redactor.flush()) + const output = drain() + if (queue.length > 0 || envelopes.size > 0) throw new Error("Host event stream contains unflushed events") + return output }, - flush: () => wrap(redactor.flush()), } } diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index 2a5d4f4178..f10c3a76fb 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -189,7 +189,13 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly let result: SemanticTraceEntry = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" } let terminalReached = false const activeChildren = new Set() + const knownChildren = new Set() const pendingAsks = new Set() + const requireSettledState = () => { + if (activeChildren.size > 0 || pendingAsks.size > 0) { + throw new Error("Fake-provider terminal outcomes require settled descendants and asks") + } + } for (const turn of scenario.providerTurns) { if (terminalReached) throw new Error("Fake-provider terminal directives must be the final turn") if (turn.startsWith("tool:")) { @@ -213,16 +219,17 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly } if (turn.startsWith("delegate:")) { const taskId = turn.slice("delegate:".length) - if (!taskId) throw new Error(`Invalid delegation fixture: ${turn}`) + if (!taskId || knownChildren.has(taskId)) throw new Error(`Invalid delegation fixture: ${turn}`) trace.push({ type: "task.created", rootTaskId: "root", taskId, parentTaskId: "root" }) trace.push({ type: "task.delegated", rootTaskId: "root", taskId, parentTaskId: "root" }) trace.push({ type: "task.started", rootTaskId: "root", taskId }) activeChildren.add(taskId) + knownChildren.add(taskId) continue } if (turn.endsWith(":done")) { const taskId = turn.slice(0, -":done".length) - if (!taskId) throw new Error(`Invalid completion fixture: ${turn}`) + if (!taskId || !activeChildren.has(taskId)) throw new Error(`Invalid completion fixture: ${turn}`) trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId, state: "completed" }) activeChildren.delete(taskId) continue @@ -250,6 +257,7 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly continue } if (turn.startsWith("cancel:")) { + requireSettledState() const [, requestId, cancellationReason] = turn.split(":") if (!requestId || !["user", "signal", "timeout"].includes(cancellationReason ?? "")) throw new Error(`Invalid cancellation fixture: ${turn}`) @@ -266,6 +274,7 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly continue } if (turn.startsWith("fail:")) { + requireSettledState() const errorCode = failedErrorCodeSchema.parse(turn.slice(5)) trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "failed" }) result = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "failed", errorCode } @@ -273,6 +282,7 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly continue } if (turn.startsWith("timeout:")) { + requireSettledState() const errorCode = zooErrorCodeSchema.parse(turn.slice(8)) if (errorCode !== "task_timed_out" && errorCode !== "cleanup_timed_out") { throw new Error(`Invalid timeout fixture: ${turn}`) @@ -285,9 +295,7 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly trace.push({ type: "message.upsert", rootTaskId: "root", taskId: "root", content: turn }) } if (result.outcome === "completed") { - if (activeChildren.size > 0 || pendingAsks.size > 0) { - throw new Error("Fake-provider scenarios cannot complete with unresolved descendants or asks") - } + requireSettledState() trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }) } trace.push(result) diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 2b8fbf4c91..aa1d052c87 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -1,6 +1,7 @@ import { z } from "zod" import type { HostCommand } from "./host-commands.js" +import type { HostEvent } from "./host-events.js" import { failedErrorCodeSchema, zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" import { REDACTED, redactValue, type RedactedValue } from "./redaction.js" import { ZOO_PUBLIC_SCHEMA_VERSION } from "./version.js" @@ -59,6 +60,9 @@ const rawZooRunResultSchema = strictObject({ if ((result.outcome === "cancelled") !== (result.cancellationReason !== undefined)) { context.addIssue({ code: z.ZodIssueCode.custom, message: "cancelled results require a cancellation reason" }) } + if (result.resumable && !["needs_input", "cancelled", "timed_out"].includes(result.outcome)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: `${result.outcome} results cannot be resumed` }) + } }) const redactError = (error: T): T => ({ @@ -252,82 +256,138 @@ export const zooStreamEventSchema = rawZooStreamEventSchema.transform(redactStre export type ZooStreamEvent = z.infer export type RawZooStreamEvent = z.infer +const streamEventKey = (event: RawZooStreamEvent) => + JSON.stringify([ + event.hostId, + event.seq, + event.timestamp, + event.type, + event.requestId, + "rootTaskId" in event ? event.rootTaskId : undefined, + "taskId" in event ? event.taskId : undefined, + "messageId" in event ? event.messageId : undefined, + "askId" in event ? event.askId : undefined, + "toolCallId" in event ? event.toolCallId : undefined, + "operationId" in event ? event.operationId : undefined, + "stream" in event ? event.stream : undefined, + ]) + export type ZooStreamRedactor = { push: (event: RawZooStreamEvent) => ZooStreamEvent[] flush: () => ZooStreamEvent[] } -export function createZooStreamRedactor(options: { maxPendingBytes?: number; maxPendingEvents?: number } = {}): ZooStreamRedactor { +export function createZooStreamRedactor( + options: { maxPendingBytes?: number; maxPendingEvents?: number; maxPendingStreams?: number } = {}, +): ZooStreamRedactor { const maxPendingBytes = options.maxPendingBytes ?? 64 * 1024 const maxPendingEvents = options.maxPendingEvents ?? 256 - type PendingOutput = { events: Array>; text: string; pem: boolean } - let pending: PendingOutput | undefined - const overflowedKeys = new Set() + const maxPendingStreams = options.maxPendingStreams ?? 256 + type PendingOutput = { + events: Array> + text: string + pem: boolean + overflowed: boolean + } + const pendingOutputs = new Map() + let failClosed = false const outputKey = (event: z.infer) => JSON.stringify([event.hostId, event.rootTaskId, event.taskId, event.toolCallId, event.stream]) - let pendingKey: string | undefined - const emit = (replacement?: string): ZooStreamEvent[] => { + const emit = (key: string, replacement?: string): ZooStreamEvent[] => { + const pending = pendingOutputs.get(key) if (pending === undefined) return [] + if (pending.events.length === 0) { + pendingOutputs.delete(key) + return [] + } const [first, ...rest] = pending.events - const delta = replacement ?? String(redactValue(pending.text)) - pending = undefined - pendingKey = undefined + const unterminatedSecret = + pending.pem || + /(?:password|secret|passphrase|passwd|pwd|credentials?|api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|authorization|bearer[-_. ]?token|client[-_. ]?secret|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token)\s*[:=]\s*$/i.test( + pending.text, + ) + const delta = replacement ?? (unterminatedSecret ? REDACTED : String(redactValue(pending.text))) + pendingOutputs.delete(key) return first === undefined ? [] : [{ ...first, delta }, ...rest.map((event) => ({ ...event, delta: "" }))] } + const emitOperation = (event: z.infer): ZooStreamEvent[] => + [...pendingOutputs.keys()] + .filter((key) => { + const [hostId, rootTaskId, taskId, toolCallId] = JSON.parse(key) as string[] + return ( + hostId === event.hostId && + rootTaskId === event.rootTaskId && + taskId === event.taskId && + toolCallId === event.toolCallId + ) + }) + .flatMap((key) => emit(key)) return { push(event) { if (event.type !== "terminal.output") { - if (event.type === "terminal.status" && (event.state === "exited" || event.state === "killed")) { - for (const key of overflowedKeys) { - const [hostId, rootTaskId, taskId, toolCallId] = JSON.parse(key) as string[] - if ( - hostId === event.hostId && - rootTaskId === event.rootTaskId && - taskId === event.taskId && - toolCallId === event.toolCallId - ) { - overflowedKeys.delete(key) - } - } - } - return [...emit(), redactStreamEvent(event)] + const finalized = + event.type === "terminal.status" && (event.state === "exited" || event.state === "killed") + ? emitOperation(event) + : [] + return [...finalized, redactStreamEvent(event)] } const key = outputKey(event) - const preceding = pendingKey !== undefined && pendingKey !== key ? emit() : [] - if (overflowedKeys.has(key)) return [...preceding, { ...event, delta: event.delta.length === 0 ? "" : REDACTED }] + if (failClosed) return [{ ...event, delta: event.delta.length === 0 ? "" : REDACTED }] + let pending = pendingOutputs.get(key) if (pending === undefined) { - pending = { events: [], text: "", pem: false } - pendingKey = key + if (pendingOutputs.size >= maxPendingStreams) { + failClosed = true + const buffered = [...pendingOutputs.keys()].flatMap((pendingKey) => emit(pendingKey, REDACTED)) + return [...buffered, { ...event, delta: event.delta.length === 0 ? "" : REDACTED }] + } + pending = { events: [], text: "", pem: false, overflowed: false } + pendingOutputs.set(key, pending) } + if (pending.overflowed) return [{ ...event, delta: event.delta.length === 0 ? "" : REDACTED }] pending.events.push(event) pending.text += event.delta - pending.pem ||= /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(pending.text) + if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(pending.text)) pending.pem = true + if (/-----END [A-Z ]*PRIVATE KEY-----/.test(pending.text)) pending.pem = false if (pending.text.length > maxPendingBytes || pending.events.length > maxPendingEvents) { - overflowedKeys.add(key) - return [...preceding, ...emit(REDACTED)] + pending.overflowed = true + const redacted = emit(key, REDACTED) + pendingOutputs.set(key, { events: [], text: "", pem: false, overflowed: true }) + return redacted } - if (pending.pem && !/-----END [A-Z ]*PRIVATE KEY-----/.test(pending.text)) return preceding + if (pending.pem && !/-----END [A-Z ]*PRIVATE KEY-----/.test(pending.text)) return [] const boundary = pending.text.lastIndexOf("\n") - return boundary >= 0 || (pending.pem && /-----END [A-Z ]*PRIVATE KEY-----/.test(pending.text)) - ? [...preceding, ...emit()] - : preceding + const allEventsEndAtBoundary = boundary === pending.text.length - 1 + return allEventsEndAtBoundary || (pending.pem && /-----END [A-Z ]*PRIVATE KEY-----/.test(pending.text)) + ? emit(key) + : [] }, - flush: () => emit(), + flush: () => [...pendingOutputs.keys()].flatMap((key) => emit(key)), } } export const zooStreamSchema = z.array(rawZooStreamEventSchema).transform((events): ZooStreamEvent[] => { const redactor = createZooStreamRedactor() + const positions = new Map() + for (const [index, event] of events.entries()) { + const key = streamEventKey(event) + const indices = positions.get(key) ?? [] + indices.push(index) + positions.set(key, indices) + } return [...events.flatMap((streamEvent) => redactor.push(streamEvent)), ...redactor.flush()] + .map((event) => ({ event, index: positions.get(streamEventKey(event))?.shift() ?? Number.MAX_SAFE_INTEGER })) + .sort((left, right) => left.index - right.index) + .map(({ event }) => event) }) export function validateStreamLifecycle( events: readonly ZooStreamEvent[], commands: readonly HostCommand[] = [], + commandEvents: readonly HostEvent[] = [], ): { ok: true } | { ok: false; code: "protocol_gap" | "task_failed"; message: string } { if (events[0]?.type !== "system.init") { return { ok: false, code: "task_failed", message: "Stream must start with system.init" } @@ -631,14 +691,17 @@ export function validateStreamLifecycle( ) { return { ok: false, code: "task_failed", message: "Ask decision contradicts its resolution source" } } - if (streamEvent.source === "user") { - const response = commands.find( + const responseCommands = commands.filter( + (command) => + command.type === "ask.respond" && + command.taskId === streamEvent.taskId && + command.askId === streamEvent.askId, + ) + if (streamEvent.source === "user" || responseCommands.length > 0) { + const response = responseCommands.find( (command) => - command.type === "ask.respond" && !consumedResponseCommands.has(command.id) && - command.id === streamEvent.requestId && - command.taskId === streamEvent.taskId && - command.askId === streamEvent.askId, + command.id === streamEvent.requestId, ) const expectedDecision = response?.type === "ask.respond" @@ -791,8 +854,40 @@ export function validateStreamLifecycle( if (resultEvent.result.outcome === "completed" && values(messageStates).some((message) => !message.complete)) { return { ok: false, code: "task_failed", message: "Completed streams cannot contain partial messages" } } + const unconsumedResponses = commands.some( + (command) => + command.type === "ask.respond" && + settledAsks.get(command.taskId)?.has(command.askId) === true && + !consumedResponseCommands.has(command.id), + ) + if (unconsumedResponses) { + return { ok: false, code: "task_failed", message: "Every ask response command must settle its matching ask" } + } + const cancelCommands = commands.filter((command) => command.type === "task.cancel" && command.rootTaskId === rootTaskId) + const cancellationTerminals = cancelCommands.map((command) => + commandEvents.filter( + (event) => + ((event.type === "command.error" && event.commandId === command.id) || + (event.type === "command.done" && + event.commandId === command.id && + event.data.commandType === "task.cancel" && + event.data.rootTaskId === rootTaskId)), + ), + ) + if (cancellationTerminals.some((terminals) => terminals.length !== 1)) { + return { ok: false, code: "task_failed", message: "Every cancellation command requires a terminal response" } + } + const acceptedCancellations = cancelCommands.filter( + (_command, index) => cancellationTerminals[index]?.[0]?.type === "command.done", + ) + if (acceptedCancellations.length > 0 && resultEvent.result.outcome !== "cancelled") { + return { ok: false, code: "task_failed", message: "An accepted cancellation must interrupt the result" } + } + if (acceptedCancellations.length > 1) { + return { ok: false, code: "task_failed", message: "A task can accept only one cancellation command" } + } if (resultEvent.result.outcome === "cancelled") { - const cancellation = commands.find( + const cancellation = acceptedCancellations.find( (command) => command.type === "task.cancel" && command.id === resultEvent.requestId && diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 96368c3021..a8e107907d 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -1,5 +1,5 @@ const REDACTED = "[REDACTED]" as const -const sensitiveKeyName = String.raw`(?:[A-Za-z0-9_.-]*(?:password|secret)[A-Za-z0-9_.-]*|api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|authorization|bearer[-_. ]?token|client[-_. ]?secret|cookie|credential|id[-_. ]?token|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token|token)` +const sensitiveKeyName = String.raw`(?:[A-Za-z0-9_.-]*(?:password|secret|passphrase|passwd|pwd)[A-Za-z0-9_.-]*|api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|authorization|bearer[-_. ]?token|client[-_. ]?secret|cookie|credentials?|id[-_. ]?token|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token|token)` const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)` const cliSecretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}]+)` const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") @@ -11,7 +11,8 @@ const quotedUnquotedSecret = new RegExp( const secretPatterns: ReadonlyArray = [ /\b(?:Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, new RegExp(`--${sensitiveKeyName}(?:\\s*=\\s*|\\s+)${cliSecretValue}`, "gi"), - new RegExp(`(? { + .replace(/\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s/?#]+/g, (authority) => { const schemeEnd = authority.indexOf("//") + 2 const credentialsEnd = authority.lastIndexOf("@") if (credentialsEnd < schemeEnd) return authority @@ -60,7 +65,12 @@ export function redactValue(value: unknown, seen = new WeakSet()): Redac } else { const entries: Record = {} for (const [key, entry] of Object.entries(value)) { - entries[key] = isSensitiveKey(key) ? REDACTED : redactValue(entry, seen) + Object.defineProperty(entries, key, { + value: isSensitiveKey(key) ? REDACTED : redactValue(entry, seen), + enumerable: true, + configurable: true, + writable: true, + }) } result = entries } From 066bc9be2d39e8a0baa7e1667f792a07ea297ea7 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 04:43:07 -0400 Subject: [PATCH 13/24] no-mistakes(review): Tighten Zoo protocol session and redaction contracts --- .../src/__tests__/contracts.test.ts | 116 +++++++++++++++++- packages/zoo-protocol/src/host-events.ts | 44 ++++++- packages/zoo-protocol/src/index.ts | 12 +- packages/zoo-protocol/src/parity.ts | 1 + packages/zoo-protocol/src/public-events.ts | 81 ++++++++++-- packages/zoo-protocol/src/redaction.ts | 2 +- 6 files changed, 237 insertions(+), 19 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 24c08e234a..5089c9cb27 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -17,6 +17,7 @@ import { runDeterministicFakeProvider, validateCommandLifecycle, validateMonotonicSequence, + validateNegotiatedStreamSession, validateParentHello, validateStreamLifecycle, zooRunResultSchema, @@ -40,6 +41,7 @@ const initEvent = zooStreamEventSchema.parse({ hostId: "host", type: "system.init", protocol: "zoo-stream", + hostProtocolVersion: 1, capabilities: ["task:start"], clientVersion: "1.0.0", hostVersion: "1.0.0", @@ -80,11 +82,11 @@ function resultEvent(seq: number, result: Record = {}, event: R return parsed } -function cancellationDone(commandId = "cancel") { +function cancellationDone(commandId = "cancel", hostId = "host") { return hostEventSchema.parse({ v: 1, seq: 1, - hostId: "host", + hostId, type: "command.done", commandId, data: { commandType: "task.cancel", rootTaskId: "root" }, @@ -102,6 +104,17 @@ function cancellationError(commandId = "cancel") { }) } +function askResponseDone(commandId = "respond", hostId = "host") { + return hostEventSchema.parse({ + v: 1, + seq: 1, + hostId, + type: "command.done", + commandId, + data: { commandType: "ask.respond", taskId: "root", askId: "ask" }, + }) +} + describe("strict host contracts", () => { it("accepts a valid start and rejects unknown fields", () => { const command = { @@ -214,6 +227,50 @@ describe("strict host contracts", () => { ).toBe(false) }) + it("binds stream initialization to the negotiated session", () => { + const host = hostHelloSchema.parse({ + type: "hello", + hostId: "host", + supportedVersions: [1], + capabilities: { 1: ["task:start", "future:additive-capability"] }, + buildVersion: "1.0.0", + }) + const parent = parentHelloSchema.parse({ + type: "hello.select", + version: 1, + clientVersion: "1.0.0", + requiredCapabilities: ["task:start"], + }) + expect( + validateNegotiatedStreamSession(host, parent, [ + { ...initEvent, capabilities: ["task:start", "future:additive-capability"] }, + ]), + ).toEqual({ ok: true }) + expect(validateNegotiatedStreamSession(host, parent, [{ ...initEvent, hostId: "other" }])).toMatchObject({ + ok: false, + }) + expect(validateNegotiatedStreamSession(host, parent, [{ ...initEvent, capabilities: [] }])).toMatchObject({ + ok: false, + }) + }) + + it("pins host identity and sequence in the streaming parser", () => { + const parser = createHostEventStreamParser() + parser.push({ v: 1, seq: 4, hostId: "host", type: "host.heartbeat", monotonicMs: 1 }) + expect(() => + parser.push({ v: 1, seq: 6, hostId: "host", type: "host.heartbeat", monotonicMs: 2 }), + ).toThrow("Expected host sequence 5") + const otherHost = createHostEventStreamParser() + otherHost.push({ v: 1, seq: 1, hostId: "host", type: "host.heartbeat", monotonicMs: 1 }) + expect(() => + otherHost.push({ v: 1, seq: 2, hostId: "other", type: "host.heartbeat", monotonicMs: 2 }), + ).toThrow("cannot span multiple hosts") + expect( + hostEventSchema.safeParse({ v: 1, seq: 1, hostId: "host", type: "host.heartbeat", monotonicMs: Infinity }) + .success, + ).toBe(false) + }) + it("models one ACK and terminal command response independently", () => { const command = hostCommandSchema.parse({ v: 1, id: "cmd", type: "host.shutdown" }) const events = [ @@ -677,6 +734,7 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], [startCommand, response], + [askResponseDone()], ), ).toEqual({ ok: true, @@ -697,6 +755,7 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, rootCreated, rootStarted, required, policyOverride, completed, resultEvent(7)], [startCommand, response], + [askResponseDone()], ), ).toMatchObject({ ok: false }) const policyReportedResponse = zooStreamEventSchema.parse({ ...resolved, source: "policy" }) @@ -704,8 +763,22 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, rootCreated, rootStarted, required, policyReportedResponse, completed, resultEvent(7)], [startCommand, response], + [askResponseDone()], ), ).toEqual({ ok: true }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [startCommand, response], + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [startCommand, response], + [askResponseDone("respond", "other")], + ), + ).toMatchObject({ ok: false }) }) it("correlates cancellation and settles operation lifecycles", () => { @@ -741,6 +814,9 @@ describe("public automation contracts", () => { cancelled, ] expect(validateStreamLifecycle(stream, [startCommand, command], [cancellationDone()])).toEqual({ ok: true }) + expect( + validateStreamLifecycle(stream, [startCommand, command], [cancellationDone("cancel", "other")]), + ).toMatchObject({ ok: false }) expect(validateStreamLifecycle(stream)).toMatchObject({ ok: false }) expect(validateStreamLifecycle(stream, [{ ...command, reason: "signal" }])).toMatchObject({ ok: false }) expect( @@ -1065,6 +1141,7 @@ describe("public automation contracts", () => { resultEvent(8), ], [startCommand, response], + [askResponseDone()], ), ).toMatchObject({ ok: false }) @@ -1205,6 +1282,9 @@ describe("redaction contracts", () => { expect(redactText("postgres://alice:hunter2@db/prod")).toBe("postgres://[REDACTED]@db/prod") expect(redactText("redis://:secret@cache/0")).toBe("redis://[REDACTED]@cache/0") expect(redactText("API_TOKEN=abc,def")).toBe("[REDACTED]") + expect(redactText("OPENAI_API_KEY: value")).toBe("[REDACTED]") + expect(redactText("SENTRY_AUTH_TOKEN: value")).toBe("[REDACTED]") + expect(redactText("openaiApiKey: value")).toBe("[REDACTED]") expect(redactValue({ credentials: "value", passphrase: "value", passwd: "value", pwd: "value" })).toEqual({ credentials: "[REDACTED]", passphrase: "[REDACTED]", @@ -1319,6 +1399,16 @@ describe("redaction contracts", () => { ]) expect(output.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) .toBe("[REDACTED]\n") + const repeated = zooStreamSchema.parse([ + { + ...terminal, + seq: 1, + delta: + "-----BEGIN PRIVATE KEY-----\nfirst\n-----END PRIVATE KEY-----\n-----BEGIN PRIVATE KEY-----\nsecond", + }, + ]) + expect(repeated.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) + .toBe("[REDACTED]") const parser = createHostEventStreamParser({ maxPendingBytes: 4 }) const overflow = parser.push({ @@ -1349,6 +1439,20 @@ describe("redaction contracts", () => { (event) => event.type === "event" && event.event.type === "terminal.output" && event.event.delta === "[REDACTED]", ), ).toBe(true) + let now = 0 + const deadlineParser = createHostEventStreamParser({ maxPendingMs: 10, now: () => now }) + expect(deadlineParser.push(pending(1, "deadline"))).toEqual([]) + now = 10 + const released = deadlineParser.push({ + v: 1, + seq: 2, + hostId: "host", + type: "host.heartbeat", + monotonicMs: 1, + }) + expect(released.map((event) => event.seq)).toEqual([1, 2]) + expect(released[0]?.type === "event" && released[0].event.type === "terminal.output" && released[0].event.delta) + .toBe("[REDACTED]") const unterminated = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: "-----BEGIN PRIVATE KEY-----\n" }, @@ -1483,6 +1587,14 @@ describe("deterministic parity oracle", () => { expect(colonArgument.find((entry) => entry.type === "tool.started")?.toolArguments).toEqual({ path: "https://example.com/a:b", }) + expect(() => + runDeterministicFakeProvider({ + id: "malformed-tool", + prompt: "Read", + providerTurns: ["tool:read_file:call"], + expected: [], + }), + ).toThrow("Invalid tool fixture") expect(() => runDeterministicFakeProvider({ id: "invalid-failure", diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index 37592c76b9..57a9ae6e28 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -66,7 +66,7 @@ const commandErrorSchema = strictObject({ const heartbeatSchema = strictObject({ ...base, type: z.literal("host.heartbeat"), - monotonicMs: z.number().nonnegative(), + monotonicMs: z.number().finite().nonnegative(), }) const snapshotSchema = strictObject({ ...base, @@ -122,12 +122,24 @@ export type HostEventStreamParser = { } export function createHostEventStreamParser( - options: { maxPendingBytes?: number; maxPendingEvents?: number; maxPendingStreams?: number } = {}, + options: { + maxPendingBytes?: number + maxPendingEvents?: number + maxPendingStreams?: number + maxQueuedEvents?: number + maxPendingMs?: number + now?: () => number + } = {}, ): HostEventStreamParser { const redactor = createZooStreamRedactor(options) - type QueueEntry = { envelope?: z.infer; output?: HostEvent } + const maxQueuedEvents = options.maxQueuedEvents ?? 512 + const maxPendingMs = options.maxPendingMs ?? 1_000 + const now = options.now ?? Date.now + type QueueEntry = { envelope?: z.infer; output?: HostEvent; enqueuedAt: number } const queue: QueueEntry[] = [] const envelopes = new Map() + let pinnedHostId: string | undefined + let lastSeq: number | undefined const eventKey = (event: RawZooStreamEvent) => JSON.stringify([ event.hostId, @@ -162,6 +174,17 @@ export function createHostEventStreamParser( while (queue[0]?.output !== undefined) ready.push(queue.shift()!.output!) return ready } + const releaseBlockedQueue = (): HostEvent[] => { + const oldest = queue[0] + if ( + oldest !== undefined && + (oldest.output === undefined && + (queue.length >= maxQueuedEvents || now() - oldest.enqueuedAt >= maxPendingMs)) + ) { + assign(redactor.failClosed()) + } + return drain() + } const sanitizeNonEvent = (event: z.infer): HostEvent => event.type === "command.error" ? { @@ -177,11 +200,20 @@ export function createHostEventStreamParser( return { push(input) { const event = rawHostEventDiscriminatedSchema.parse(input) - const entry: QueueEntry = {} + if (pinnedHostId !== undefined && event.hostId !== pinnedHostId) { + throw new Error("Host event stream cannot span multiple hosts") + } + if (lastSeq !== undefined && !validateMonotonicSequence(lastSeq, event.seq).ok) { + throw new Error(`Expected host sequence ${lastSeq + 1}`) + } + pinnedHostId ??= event.hostId + lastSeq = event.seq + const released = releaseBlockedQueue() + const entry: QueueEntry = { enqueuedAt: now() } queue.push(entry) if (event.type !== "event") { entry.output = sanitizeNonEvent(event) - return drain() + return [...released, ...releaseBlockedQueue()] } if (event.event.hostId !== event.hostId) throw new Error("Normalized event hostId must match its host envelope") entry.envelope = event @@ -190,7 +222,7 @@ export function createHostEventStreamParser( entries.push(entry) envelopes.set(key, entries) assign(redactor.push(event.event)) - return drain() + return [...released, ...releaseBlockedQueue()] }, flush() { assign(redactor.flush()) diff --git a/packages/zoo-protocol/src/index.ts b/packages/zoo-protocol/src/index.ts index c73f4b1cd2..ca3718595f 100644 --- a/packages/zoo-protocol/src/index.ts +++ b/packages/zoo-protocol/src/index.ts @@ -2,6 +2,16 @@ export * from "./host-commands.js" export * from "./host-events.js" export * from "./outcomes.js" export * from "./parity.js" -export * from "./public-events.js" +export { + changedFileSchema, + usageSchema, + validateNegotiatedStreamSession, + validateStreamLifecycle, + zooRunResultSchema, + zooStreamEventSchema, + zooStreamSchema, + type ZooRunResult, + type ZooStreamEvent, +} from "./public-events.js" export * from "./redaction.js" export * from "./version.js" diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index f10c3a76fb..74721cdca3 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -202,6 +202,7 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly const separator1 = turn.indexOf(":") const separator2 = turn.indexOf(":", separator1 + 1) const separator3 = turn.indexOf(":", separator2 + 1) + if (separator2 < 0 || separator3 < 0) throw new Error(`Invalid tool fixture: ${turn}`) const operation = turn.slice(separator1 + 1, separator2) const toolCallId = turn.slice(separator2 + 1, separator3) const argument = turn.slice(separator3 + 1) diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index aa1d052c87..5776801cc1 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -4,7 +4,13 @@ import type { HostCommand } from "./host-commands.js" import type { HostEvent } from "./host-events.js" import { failedErrorCodeSchema, zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" import { REDACTED, redactValue, type RedactedValue } from "./redaction.js" -import { ZOO_PUBLIC_SCHEMA_VERSION } from "./version.js" +import { + ZOO_HOST_PROTOCOL_VERSION, + ZOO_PUBLIC_SCHEMA_VERSION, + validateParentHello, + type HostHello, + type ParentHello, +} from "./version.js" const strictObject = (shape: T) => z.object(shape).strict() @@ -103,6 +109,7 @@ const taskEvent = (type: Typ const systemInitEventSchema = event("system.init", { protocol: z.literal("zoo-stream"), + hostProtocolVersion: z.literal(ZOO_HOST_PROTOCOL_VERSION), capabilities: z.array(z.string().min(1)), clientVersion: z.string().min(1), hostVersion: z.string().min(1), @@ -275,6 +282,7 @@ const streamEventKey = (event: RawZooStreamEvent) => export type ZooStreamRedactor = { push: (event: RawZooStreamEvent) => ZooStreamEvent[] flush: () => ZooStreamEvent[] + failClosed: () => ZooStreamEvent[] } export function createZooStreamRedactor( @@ -293,6 +301,15 @@ export function createZooStreamRedactor( let failClosed = false const outputKey = (event: z.infer) => JSON.stringify([event.hostId, event.rootTaskId, event.taskId, event.toolCallId, event.stream]) + const hasUnmatchedPem = (text: string): boolean => { + const openLabels: string[] = [] + for (const match of text.matchAll(/-----(BEGIN|END) ([A-Z ]*PRIVATE KEY)-----/g)) { + const [, boundary, label] = match + if (boundary === "BEGIN" && label !== undefined) openLabels.push(label) + else if (boundary === "END" && label !== undefined && openLabels.at(-1) === label) openLabels.pop() + } + return openLabels.length > 0 + } const emit = (key: string, replacement?: string): ZooStreamEvent[] => { const pending = pendingOutputs.get(key) @@ -350,22 +367,23 @@ export function createZooStreamRedactor( if (pending.overflowed) return [{ ...event, delta: event.delta.length === 0 ? "" : REDACTED }] pending.events.push(event) pending.text += event.delta - if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(pending.text)) pending.pem = true - if (/-----END [A-Z ]*PRIVATE KEY-----/.test(pending.text)) pending.pem = false + pending.pem = hasUnmatchedPem(pending.text) if (pending.text.length > maxPendingBytes || pending.events.length > maxPendingEvents) { pending.overflowed = true const redacted = emit(key, REDACTED) pendingOutputs.set(key, { events: [], text: "", pem: false, overflowed: true }) return redacted } - if (pending.pem && !/-----END [A-Z ]*PRIVATE KEY-----/.test(pending.text)) return [] + if (pending.pem) return [] const boundary = pending.text.lastIndexOf("\n") const allEventsEndAtBoundary = boundary === pending.text.length - 1 - return allEventsEndAtBoundary || (pending.pem && /-----END [A-Z ]*PRIVATE KEY-----/.test(pending.text)) - ? emit(key) - : [] + return allEventsEndAtBoundary ? emit(key) : [] }, flush: () => [...pendingOutputs.keys()].flatMap((key) => emit(key)), + failClosed: () => { + failClosed = true + return [...pendingOutputs.keys()].flatMap((key) => emit(key, REDACTED)) + }, } } @@ -384,6 +402,33 @@ export const zooStreamSchema = z.array(rawZooStreamEventSchema).transform((event .map(({ event }) => event) }) +export function validateNegotiatedStreamSession( + host: HostHello, + parent: ParentHello, + events: readonly ZooStreamEvent[], +): { ok: true } | { ok: false; code: "protocol_incompatible"; message: string } { + const negotiation = validateParentHello(host, parent) + if (!negotiation.ok) return negotiation + const init = events[0] + const advertised = host.capabilities[String(parent.version)] ?? [] + if ( + init?.type !== "system.init" || + init.hostId !== host.hostId || + init.hostProtocolVersion !== parent.version || + init.clientVersion !== parent.clientVersion || + init.hostVersion !== host.buildVersion || + parent.requiredCapabilities.some((capability) => !init.capabilities.includes(capability)) || + init.capabilities.some((capability) => !advertised.includes(capability)) + ) { + return { + ok: false, + code: "protocol_incompatible", + message: "system.init does not match the negotiated host session", + } + } + return { ok: true } +} + export function validateStreamLifecycle( events: readonly ZooStreamEvent[], commands: readonly HostCommand[] = [], @@ -707,11 +752,28 @@ export function validateStreamLifecycle( response?.type === "ask.respond" ? { approve: "approve", reject: "reject", message: "needs_input" }[response.response] : undefined - if (expectedDecision !== streamEvent.decision) { + const terminals = + response === undefined + ? [] + : commandEvents.filter( + (event) => + (event.type === "command.done" || event.type === "command.error") && + event.hostId === hostId && + event.commandId === response.id, + ) + const completion = terminals[0] + if ( + expectedDecision !== streamEvent.decision || + terminals.length !== 1 || + completion?.type !== "command.done" || + completion.data.commandType !== "ask.respond" || + completion.data.taskId !== streamEvent.taskId || + completion.data.askId !== streamEvent.askId + ) { return { ok: false, code: "task_failed", - message: "User ask resolution does not match its response command", + message: "Ask resolution requires its successful response command", } } if (response !== undefined) consumedResponseCommands.add(response.id) @@ -867,6 +929,7 @@ export function validateStreamLifecycle( const cancellationTerminals = cancelCommands.map((command) => commandEvents.filter( (event) => + event.hostId === hostId && ((event.type === "command.error" && event.commandId === command.id) || (event.type === "command.done" && event.commandId === command.id && diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index a8e107907d..389cbfe632 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -1,5 +1,5 @@ const REDACTED = "[REDACTED]" as const -const sensitiveKeyName = String.raw`(?:[A-Za-z0-9_.-]*(?:password|secret|passphrase|passwd|pwd)[A-Za-z0-9_.-]*|api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|authorization|bearer[-_. ]?token|client[-_. ]?secret|cookie|credentials?|id[-_. ]?token|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token|token)` +const sensitiveKeyName = String.raw`(?:[A-Za-z0-9_.-]*(?:password|secret|passphrase|passwd|pwd)[A-Za-z0-9_.-]*|[A-Za-z0-9_.-]*(?:api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|bearer[-_. ]?token|client[-_. ]?secret|id[-_. ]?token|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token)|authorization|cookie|credentials?|token)` const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)` const cliSecretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}]+)` const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") From e49f16897c811d9cae85120906ab277a9e178905 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 04:55:21 -0400 Subject: [PATCH 14/24] no-mistakes(review): Tighten protocol causality, redaction, and parity contracts --- .../src/__tests__/contracts.test.ts | 138 +++++++++++++----- packages/zoo-protocol/src/host-events.ts | 31 +++- packages/zoo-protocol/src/parity.ts | 57 +++++++- packages/zoo-protocol/src/public-events.ts | 66 +++++---- packages/zoo-protocol/src/redaction.ts | 10 +- 5 files changed, 228 insertions(+), 74 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 5089c9cb27..95f2a0229d 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -46,6 +46,7 @@ const initEvent = zooStreamEventSchema.parse({ clientVersion: "1.0.0", hostVersion: "1.0.0", }) +if (initEvent.type !== "system.init") throw new Error("Expected system.init fixture") function taskEvent(seq: number, type: string, fields: Record = {}) { return zooStreamEventSchema.parse({ @@ -83,36 +84,45 @@ function resultEvent(seq: number, result: Record = {}, event: R } function cancellationDone(commandId = "cancel", hostId = "host") { - return hostEventSchema.parse({ - v: 1, - seq: 1, - hostId, - type: "command.done", - commandId, - data: { commandType: "task.cancel", rootTaskId: "root" }, - }) + return [ + hostEventSchema.parse({ v: 1, seq: 1, hostId, type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: 2, + hostId, + type: "command.done", + commandId, + data: { commandType: "task.cancel", rootTaskId: "root" }, + }), + ] } function cancellationError(commandId = "cancel") { - return hostEventSchema.parse({ - v: 1, - seq: 1, - hostId: "host", - type: "command.error", - commandId, - error: { code: "cancel_failed", message: "Task already completed" }, - }) + return [ + hostEventSchema.parse({ v: 1, seq: 1, hostId: "host", type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host", + type: "command.error", + commandId, + error: { code: "cancel_failed", message: "Task already completed" }, + }), + ] } function askResponseDone(commandId = "respond", hostId = "host") { - return hostEventSchema.parse({ - v: 1, - seq: 1, - hostId, - type: "command.done", - commandId, - data: { commandType: "ask.respond", taskId: "root", askId: "ask" }, - }) + return [ + hostEventSchema.parse({ v: 1, seq: 1, hostId, type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: 2, + hostId, + type: "command.done", + commandId, + data: { commandType: "ask.respond", taskId: "root", askId: "ask" }, + }), + ] } describe("strict host contracts", () => { @@ -455,6 +465,37 @@ describe("strict host contracts", () => { ).toBe("[REDACTED]") }) + it("releases blocked envelopes on deadline and byte pressure", () => { + let now = 0 + const terminalEnvelope = (seq: number, delta: string) => ({ + v: 1, + seq, + hostId: "host", + type: "event", + event: { + v: 1, + seq, + timestamp, + hostId: "host", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + delta, + }, + }) + const deadlineParser = createHostEventStreamParser({ maxPendingMs: 10, now: () => now }) + expect(deadlineParser.push(terminalEnvelope(1, "unterminated"))).toEqual([]) + now = 10 + expect(deadlineParser.tick()).toMatchObject([{ seq: 1, event: { delta: "[REDACTED]" } }]) + + const byteParser = createHostEventStreamParser({ maxQueuedBytes: 1 }) + expect(byteParser.push(terminalEnvelope(1, "unterminated"))).toMatchObject([ + { seq: 1, event: { delta: "[REDACTED]" } }, + ]) + }) + it("preserves host envelopes across concurrent root streams", () => { const parser = createHostEventStreamParser() const envelope = (hostSeq: number, rootTaskId: string, delta: string) => ({ @@ -734,7 +775,7 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], [startCommand, response], - [askResponseDone()], + askResponseDone(), ), ).toEqual({ ok: true, @@ -755,7 +796,7 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, rootCreated, rootStarted, required, policyOverride, completed, resultEvent(7)], [startCommand, response], - [askResponseDone()], + askResponseDone(), ), ).toMatchObject({ ok: false }) const policyReportedResponse = zooStreamEventSchema.parse({ ...resolved, source: "policy" }) @@ -763,7 +804,7 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, rootCreated, rootStarted, required, policyReportedResponse, completed, resultEvent(7)], [startCommand, response], - [askResponseDone()], + askResponseDone(), ), ).toEqual({ ok: true }) expect( @@ -776,7 +817,14 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], [startCommand, response], - [askResponseDone("respond", "other")], + askResponseDone().slice(1), + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [startCommand, response], + askResponseDone("respond", "other"), ), ).toMatchObject({ ok: false }) }) @@ -813,9 +861,9 @@ describe("public automation contracts", () => { interrupted, cancelled, ] - expect(validateStreamLifecycle(stream, [startCommand, command], [cancellationDone()])).toEqual({ ok: true }) + expect(validateStreamLifecycle(stream, [startCommand, command], cancellationDone())).toEqual({ ok: true }) expect( - validateStreamLifecycle(stream, [startCommand, command], [cancellationDone("cancel", "other")]), + validateStreamLifecycle(stream, [startCommand, command], cancellationDone("cancel", "other")), ).toMatchObject({ ok: false }) expect(validateStreamLifecycle(stream)).toMatchObject({ ok: false }) expect(validateStreamLifecycle(stream, [{ ...command, reason: "signal" }])).toMatchObject({ ok: false }) @@ -903,7 +951,7 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, created, started, required, abandoned, interrupted, cancelled], [startCommand, command], - [cancellationDone()], + cancellationDone(), ), ).toEqual({ ok: true }) expect( @@ -982,7 +1030,7 @@ describe("public automation contracts", () => { taskEvent(6, "task.lifecycle", { state: "interrupted" }), resultEvent(7, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }), ] - expect(validateStreamLifecycle(stream, [resume, cancel], [cancellationDone()])).toEqual({ ok: true }) + expect(validateStreamLifecycle(stream, [resume, cancel], cancellationDone())).toEqual({ ok: true }) }) it("requires accepted cancellations to own cancelled results", () => { @@ -1000,10 +1048,10 @@ describe("public automation contracts", () => { taskEvent(4, "task.lifecycle", { state: "completed" }), resultEvent(5), ] - expect(validateStreamLifecycle(completed, [startCommand, cancel], [cancellationDone()])).toMatchObject({ + expect(validateStreamLifecycle(completed, [startCommand, cancel], cancellationDone())).toMatchObject({ ok: false, }) - expect(validateStreamLifecycle(completed, [startCommand, cancel], [cancellationError()])).toEqual({ ok: true }) + expect(validateStreamLifecycle(completed, [startCommand, cancel], cancellationError())).toEqual({ ok: true }) expect(validateStreamLifecycle(completed, [startCommand, cancel])).toMatchObject({ ok: false }) }) @@ -1141,7 +1189,7 @@ describe("public automation contracts", () => { resultEvent(8), ], [startCommand, response], - [askResponseDone()], + askResponseDone(), ), ).toMatchObject({ ok: false }) @@ -1294,6 +1342,14 @@ describe("redaction contracts", () => { expect(redactText('{"max_tokens":4096} tokenizer=bpe --max-tokens 4096')).toBe( '{"max_tokens":4096} tokenizer=bpe --max-tokens 4096', ) + expect(redactValue({ "set-cookie": "session=abc", setCookie: "session=def", proxyAuthorization: "Basic abc" })).toEqual({ + "set-cookie": "[REDACTED]", + setCookie: "[REDACTED]", + proxyAuthorization: "[REDACTED]", + }) + expect(redactText("password: abc,def")).toBe("[REDACTED]") + expect(redactText('{"api\\u005fkey":"hunter2"}')).toBe('{"api_key":"[REDACTED]"}') + expect(redactText("API_\u001b[31mTOKEN=abcdefgh")).toBe("[REDACTED]") }) it("redacts public event and result payloads during parsing", () => { @@ -1379,6 +1435,8 @@ describe("redaction contracts", () => { expect(mixed.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( "Build succeeded\n[REDACTED]\n", ) + const empty = zooStreamSchema.parse([{ ...terminal, seq: 1, delta: "" }]) + expect(empty).toMatchObject([{ type: "terminal.output", delta: "" }]) }) it("buffers multiline secrets and fails closed when bounded memory is exceeded", () => { @@ -1650,6 +1708,16 @@ describe("deterministic parity oracle", () => { expected: [], }), ).toThrow() + for (const providerTurns of [ + ["delegate:root"], + ["tool:read_file:call-1:a", "tool:read_file:call-1:b"], + ["ask:ask-1", "approve:ask-1:user:request-1", "ask:ask-1"], + ["ask:ask-1", "approve:ask-1:user:request-1", "ask:ask-2", "approve:ask-2:user:request-1"], + ]) { + expect(() => + runDeterministicFakeProvider({ id: "reused", prompt: "Reuse", providerTurns, expected: [] }), + ).toThrow() + } }) it("ignores object property insertion order without ignoring event order", () => { diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index 57a9ae6e28..3a9aac2383 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -118,6 +118,7 @@ export type HostEvent = z.infer export type HostEventStreamParser = { push: (event: unknown) => HostEvent[] + tick: () => HostEvent[] flush: () => HostEvent[] } @@ -127,16 +128,24 @@ export function createHostEventStreamParser( maxPendingEvents?: number maxPendingStreams?: number maxQueuedEvents?: number + maxQueuedBytes?: number maxPendingMs?: number now?: () => number } = {}, ): HostEventStreamParser { const redactor = createZooStreamRedactor(options) const maxQueuedEvents = options.maxQueuedEvents ?? 512 + const maxQueuedBytes = options.maxQueuedBytes ?? 1024 * 1024 const maxPendingMs = options.maxPendingMs ?? 1_000 const now = options.now ?? Date.now - type QueueEntry = { envelope?: z.infer; output?: HostEvent; enqueuedAt: number } + type QueueEntry = { + envelope?: z.infer + output?: HostEvent + enqueuedAt: number + bytes: number + } const queue: QueueEntry[] = [] + let queuedBytes = 0 const envelopes = new Map() let pinnedHostId: string | undefined let lastSeq: number | undefined @@ -171,7 +180,11 @@ export function createHostEventStreamParser( } const drain = (): HostEvent[] => { const ready: HostEvent[] = [] - while (queue[0]?.output !== undefined) ready.push(queue.shift()!.output!) + while (queue[0]?.output !== undefined) { + const entry = queue.shift()! + queuedBytes -= entry.bytes + ready.push(entry.output!) + } return ready } const releaseBlockedQueue = (): HostEvent[] => { @@ -179,7 +192,9 @@ export function createHostEventStreamParser( if ( oldest !== undefined && (oldest.output === undefined && - (queue.length >= maxQueuedEvents || now() - oldest.enqueuedAt >= maxPendingMs)) + (queue.length >= maxQueuedEvents || + queuedBytes >= maxQueuedBytes || + now() - oldest.enqueuedAt >= maxPendingMs)) ) { assign(redactor.failClosed()) } @@ -209,8 +224,15 @@ export function createHostEventStreamParser( pinnedHostId ??= event.hostId lastSeq = event.seq const released = releaseBlockedQueue() - const entry: QueueEntry = { enqueuedAt: now() } + let bytes: number + try { + bytes = new TextEncoder().encode(JSON.stringify(event)).byteLength + } catch { + bytes = maxQueuedBytes + } + const entry: QueueEntry = { enqueuedAt: now(), bytes } queue.push(entry) + queuedBytes += bytes if (event.type !== "event") { entry.output = sanitizeNonEvent(event) return [...released, ...releaseBlockedQueue()] @@ -224,6 +246,7 @@ export function createHostEventStreamParser( assign(redactor.push(event.event)) return [...released, ...releaseBlockedQueue()] }, + tick: releaseBlockedQueue, flush() { assign(redactor.flush()) const output = drain() diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index 74721cdca3..c5b2a49943 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -24,6 +24,7 @@ export type SemanticTraceEntry = { prompt?: string outcome?: ZooOutcome errorCode?: ZooErrorCode + resumable?: boolean } export type ParityScenario = { @@ -156,6 +157,18 @@ export const parityScenarios: readonly ParityScenario[] = [ }, ], }, + { + id: "needs-input", + prompt: "Wait for deterministic input.", + providerTurns: ["ask:ask-1", "needs_input"], + expected: [ + { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Wait for deterministic input." }, + { type: "task.started", rootTaskId: "root", taskId: "root" }, + { type: "ask.required", rootTaskId: "root", taskId: "root", askId: "ask-1" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "waiting" }, + { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "needs_input", resumable: true }, + ], + }, ] export function compareSemanticTraces( @@ -189,8 +202,11 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly let result: SemanticTraceEntry = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" } let terminalReached = false const activeChildren = new Set() - const knownChildren = new Set() + const usedTaskIds = new Set(["root"]) const pendingAsks = new Set() + const usedAskIds = new Set() + const usedToolCallIds = new Set() + const usedRequestIds = new Set() const requireSettledState = () => { if (activeChildren.size > 0 || pendingAsks.size > 0) { throw new Error("Fake-provider terminal outcomes require settled descendants and asks") @@ -206,7 +222,10 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly const operation = turn.slice(separator1 + 1, separator2) const toolCallId = turn.slice(separator2 + 1, separator3) const argument = turn.slice(separator3 + 1) - if (operation !== "read_file" || !toolCallId || !argument) throw new Error(`Invalid tool fixture: ${turn}`) + if (operation !== "read_file" || !toolCallId || !argument || usedToolCallIds.has(toolCallId)) { + throw new Error(`Invalid tool fixture: ${turn}`) + } + usedToolCallIds.add(toolCallId) const tool = { rootTaskId: "root", taskId: "root", @@ -220,12 +239,12 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly } if (turn.startsWith("delegate:")) { const taskId = turn.slice("delegate:".length) - if (!taskId || knownChildren.has(taskId)) throw new Error(`Invalid delegation fixture: ${turn}`) + if (!taskId || usedTaskIds.has(taskId)) throw new Error(`Invalid delegation fixture: ${turn}`) trace.push({ type: "task.created", rootTaskId: "root", taskId, parentTaskId: "root" }) trace.push({ type: "task.delegated", rootTaskId: "root", taskId, parentTaskId: "root" }) trace.push({ type: "task.started", rootTaskId: "root", taskId }) activeChildren.add(taskId) - knownChildren.add(taskId) + usedTaskIds.add(taskId) continue } if (turn.endsWith(":done")) { @@ -237,15 +256,19 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly } if (turn.startsWith("ask:")) { const askId = turn.slice(4) - if (!askId || pendingAsks.has(askId)) throw new Error(`Invalid ask fixture: ${turn}`) + if (!askId || usedAskIds.has(askId)) throw new Error(`Invalid ask fixture: ${turn}`) pendingAsks.add(askId) + usedAskIds.add(askId) trace.push({ type: "ask.required", rootTaskId: "root", taskId: "root", askId }) continue } if (turn.startsWith("approve:")) { const [, askId, source, requestId] = turn.split(":") - if (!askId || source !== "user" || !requestId) throw new Error(`Invalid approval fixture: ${turn}`) + if (!askId || source !== "user" || !requestId || usedRequestIds.has(requestId)) { + throw new Error(`Invalid approval fixture: ${turn}`) + } if (!pendingAsks.delete(askId)) throw new Error(`Approval references unknown ask: ${turn}`) + usedRequestIds.add(requestId) trace.push({ type: "ask.resolved", rootTaskId: "root", @@ -260,8 +283,13 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly if (turn.startsWith("cancel:")) { requireSettledState() const [, requestId, cancellationReason] = turn.split(":") - if (!requestId || !["user", "signal", "timeout"].includes(cancellationReason ?? "")) + if ( + !requestId || + usedRequestIds.has(requestId) || + !["user", "signal", "timeout"].includes(cancellationReason ?? "") + ) throw new Error(`Invalid cancellation fixture: ${turn}`) + usedRequestIds.add(requestId) trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted" }) result = { type: "task.result", @@ -293,6 +321,21 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly terminalReached = true continue } + if (turn === "needs_input") { + if (activeChildren.size > 0 || pendingAsks.size === 0) { + throw new Error("needs_input requires a pending ask and settled descendants") + } + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "waiting" }) + result = { + type: "task.result", + rootTaskId: "root", + taskId: "root", + outcome: "needs_input", + resumable: true, + } + terminalReached = true + continue + } trace.push({ type: "message.upsert", rootTaskId: "root", taskId: "root", content: turn }) } if (result.outcome === "completed") { diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 5776801cc1..42c8ff72af 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -352,6 +352,7 @@ export function createZooStreamRedactor( : [] return [...finalized, redactStreamEvent(event)] } + if (event.delta.length === 0) return [{ ...event, delta: "" }] const key = outputKey(event) if (failClosed) return [{ ...event, delta: event.delta.length === 0 ? "" : REDACTED }] let pending = pendingOutputs.get(key) @@ -444,6 +445,15 @@ export function validateStreamLifecycle( if (events.some((streamEvent) => streamEvent.hostId !== hostId)) { return { ok: false, code: "protocol_gap", message: "Stream cannot span multiple hosts" } } + if (commandEvents.some((event) => event.hostId !== hostId)) { + return { ok: false, code: "protocol_gap", message: "Command lifecycle cannot span multiple hosts" } + } + for (let index = 1; index < commandEvents.length; index += 1) { + const expected = commandEvents[index - 1]!.seq + 1 + if (commandEvents[index]!.seq !== expected) { + return { ok: false, code: "protocol_gap", message: `Expected host sequence ${expected}` } + } + } for (let index = 1; index < events.length; index += 1) { const expected = events[index - 1]!.seq + 1 if (events[index]!.seq !== expected) { @@ -534,6 +544,23 @@ export function validateStreamLifecycle( } return false } + const causalTerminal = (commandId: string): HostEvent | undefined => { + const lifecycle = commandEvents.filter( + (event) => + (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && + event.commandId === commandId, + ) + const acknowledgements = lifecycle.filter((event) => event.type === "command.ack") + const terminals = lifecycle.filter((event) => event.type === "command.done" || event.type === "command.error") + if ( + acknowledgements.length !== 1 || + terminals.length !== 1 || + acknowledgements[0]!.seq >= terminals[0]!.seq + ) { + return undefined + } + return terminals[0] + } for (const streamEvent of events) { if ("rootTaskId" in streamEvent && streamEvent.rootTaskId !== rootTaskId) { return { @@ -752,19 +779,9 @@ export function validateStreamLifecycle( response?.type === "ask.respond" ? { approve: "approve", reject: "reject", message: "needs_input" }[response.response] : undefined - const terminals = - response === undefined - ? [] - : commandEvents.filter( - (event) => - (event.type === "command.done" || event.type === "command.error") && - event.hostId === hostId && - event.commandId === response.id, - ) - const completion = terminals[0] + const completion = response === undefined ? undefined : causalTerminal(response.id) if ( expectedDecision !== streamEvent.decision || - terminals.length !== 1 || completion?.type !== "command.done" || completion.data.commandType !== "ask.respond" || completion.data.taskId !== streamEvent.taskId || @@ -926,22 +943,21 @@ export function validateStreamLifecycle( return { ok: false, code: "task_failed", message: "Every ask response command must settle its matching ask" } } const cancelCommands = commands.filter((command) => command.type === "task.cancel" && command.rootTaskId === rootTaskId) - const cancellationTerminals = cancelCommands.map((command) => - commandEvents.filter( - (event) => - event.hostId === hostId && - ((event.type === "command.error" && event.commandId === command.id) || - (event.type === "command.done" && - event.commandId === command.id && - event.data.commandType === "task.cancel" && - event.data.rootTaskId === rootTaskId)), - ), - ) - if (cancellationTerminals.some((terminals) => terminals.length !== 1)) { - return { ok: false, code: "task_failed", message: "Every cancellation command requires a terminal response" } + const cancellationTerminals = cancelCommands.map((command) => causalTerminal(command.id)) + if (cancellationTerminals.some((terminal) => terminal === undefined)) { + return { ok: false, code: "task_failed", message: "Every cancellation command requires ACK and one terminal response" } + } + if ( + cancellationTerminals.some( + (terminal) => + terminal?.type === "command.done" && + (terminal.data.commandType !== "task.cancel" || terminal.data.rootTaskId !== rootTaskId), + ) + ) { + return { ok: false, code: "task_failed", message: "Cancellation completion does not match its command" } } const acceptedCancellations = cancelCommands.filter( - (_command, index) => cancellationTerminals[index]?.[0]?.type === "command.done", + (_command, index) => cancellationTerminals[index]?.type === "command.done", ) if (acceptedCancellations.length > 0 && resultEvent.result.outcome !== "cancelled") { return { ok: false, code: "task_failed", message: "An accepted cancellation must interrupt the result" } diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 389cbfe632..d6386a4349 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -1,6 +1,6 @@ const REDACTED = "[REDACTED]" as const const sensitiveKeyName = String.raw`(?:[A-Za-z0-9_.-]*(?:password|secret|passphrase|passwd|pwd)[A-Za-z0-9_.-]*|[A-Za-z0-9_.-]*(?:api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|bearer[-_. ]?token|client[-_. ]?secret|id[-_. ]?token|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token)|authorization|cookie|credentials?|token)` -const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)` +const secretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}]+)` const cliSecretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}]+)` const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") const singleQuotedSecret = new RegExp(`('${sensitiveKeyName}'\\s*:\\s*)'(?:\\\\.|[^'\\\\])*'`, "gi") @@ -8,6 +8,7 @@ const quotedUnquotedSecret = new RegExp( `((?:"${sensitiveKeyName}"|'${sensitiveKeyName}')\\s*:\\s*)(?!["'])[^\\s,;}]+`, "gi", ) +const ansiEscape = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g") const secretPatterns: ReadonlyArray = [ /\b(?:Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, new RegExp(`--${sensitiveKeyName}(?:\\s*=\\s*|\\s+)${cliSecretValue}`, "gi"), @@ -29,7 +30,7 @@ function isSensitiveKey(key: string): boolean { .toLowerCase() if ( /\b(?:password|secret|passphrase|passwd|pwd)\b/.test(words) || - /^(?:authorization|cookie|credentials?)$/.test(words) + /^(?:(?:proxy )?authorization|(?:set )?cookie|credentials?)$/.test(words) ) return true return /^(?:.* )?(?:api key|api token|access token|auth token|bearer token|id token|private key|refresh token|session token|token)$/.test( @@ -38,7 +39,10 @@ function isSensitiveKey(key: string): boolean { } export function redactText(value: string): string { - const structured = value + const canonical = value + .replace(ansiEscape, "") + .replace(/\\u([0-9a-f]{4})/gi, (_match, code: string) => String.fromCharCode(Number.parseInt(code, 16))) + const structured = canonical .replace(/\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s/?#]+/g, (authority) => { const schemeEnd = authority.indexOf("//") + 2 const credentialsEnd = authority.lastIndexOf("@") From 850c121eaceb94ae6731006fe7c1ddc76a248941 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 05:09:42 -0400 Subject: [PATCH 15/24] no-mistakes(review): Tighten Zoo protocol lifecycle and redaction contracts --- .../src/__tests__/contracts.test.ts | 129 +++++++++++++++++- packages/zoo-protocol/src/parity.ts | 3 +- packages/zoo-protocol/src/public-events.ts | 71 ++++++++-- packages/zoo-protocol/src/redaction.ts | 2 +- 4 files changed, 192 insertions(+), 13 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 95f2a0229d..58e8fbe508 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -125,6 +125,20 @@ function askResponseDone(commandId = "respond", hostId = "host") { ] } +function askResponseError(commandId = "respond") { + return [ + hostEventSchema.parse({ v: 1, seq: 1, hostId: "host", type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: 2, + hostId: "host", + type: "command.error", + commandId, + error: { code: "task_failed", message: "Response was not accepted" }, + }), + ] +} + describe("strict host contracts", () => { it("accepts a valid start and rejects unknown fields", () => { const command = { @@ -827,6 +841,28 @@ describe("public automation contracts", () => { askResponseDone("respond", "other"), ), ).toMatchObject({ ok: false }) + const waiting = taskEvent(5, "task.lifecycle", { state: "waiting" }) + const needsInput = resultEvent(6, { outcome: "needs_input", resumable: true }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, waiting, needsInput], + [startCommand, response], + askResponseError(), + ), + ).toEqual({ ok: true }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, waiting, needsInput], + [startCommand, response], + ), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [startCommand, { ...response, id: startCommand.id }], + askResponseDone(startCommand.id), + ), + ).toMatchObject({ ok: false }) }) it("correlates cancellation and settles operation lifecycles", () => { @@ -932,7 +968,7 @@ describe("public automation contracts", () => { ).toMatchObject({ ok: false }) }) - it("abandons pending asks only for cancellation or timeout", () => { + it("abandons pending asks for terminal interruption or failure", () => { const created = taskEvent(2, "task.created") const started = taskEvent(3, "task.started") const required = taskEvent(4, "ask.required", { askId: "ask", category: "tool", subject: "Run" }) @@ -967,6 +1003,24 @@ describe("public automation contracts", () => { [command], ), ).toMatchObject({ ok: false }) + const failedAbandonment = taskEvent(5, "ask.abandoned", { askId: "ask", reason: "failed" }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + failedAbandonment, + taskEvent(6, "task.lifecycle", { state: "failed" }), + resultEvent(7, { + outcome: "failed", + error: { code: "provider_failed", message: "failed" }, + }), + ], + [startCommand], + ), + ).toEqual({ ok: true }) }) it("requires currentTaskId to belong to the authoritative tree", () => { @@ -980,6 +1034,53 @@ describe("public automation contracts", () => { ).toMatchObject({ ok: false }) }) + it("requires a cause for waiting tasks to return to running", () => { + const created = taskEvent(2, "task.created") + const started = taskEvent(3, "task.started") + const required = taskEvent(4, "ask.required", { askId: "ask", category: "tool", subject: "Run" }) + const waiting = taskEvent(5, "task.lifecycle", { state: "waiting" }) + const running = taskEvent(6, "task.lifecycle", { state: "running" }) + const completed = taskEvent(7, "task.lifecycle", { state: "completed" }) + expect( + validateStreamLifecycle( + [initEvent, created, started, required, waiting, running, completed, resultEvent(8)], + [startCommand], + ), + ).toMatchObject({ ok: false }) + + const response = hostCommandSchema.parse({ + v: 1, + id: "respond", + type: "ask.respond", + taskId: "root", + askId: "ask", + response: "approve", + }) + const resolved = taskEvent(6, "ask.resolved", { + requestId: "respond", + askId: "ask", + decision: "approve", + source: "user", + }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + waiting, + resolved, + { ...running, seq: 7, requestId: "respond" }, + { ...completed, seq: 8 }, + resultEvent(9), + ], + [startCommand, response], + askResponseDone(), + ), + ).toEqual({ ok: true }) + }) + it("reconstructs resume streams from a matching command", () => { const command = hostCommandSchema.parse({ v: 1, @@ -1467,6 +1568,14 @@ describe("redaction contracts", () => { ]) expect(repeated.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) .toBe("[REDACTED]") + const pgp = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "-----BEGIN PGP PRIVATE KEY BLOCK-----\n" }, + { ...terminal, seq: 2, delta: "private-body\n" }, + { ...terminal, seq: 3, delta: "-----END PGP PRIVATE KEY BLOCK-----\n" }, + ]) + expect(pgp.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED]\n", + ) const parser = createHostEventStreamParser({ maxPendingBytes: 4 }) const overflow = parser.push({ @@ -1519,6 +1628,10 @@ describe("redaction contracts", () => { expect(unterminated.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( "[REDACTED]", ) + const unterminatedQuoted = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: '{"api_key":"hunter2' }, + ]) + expect(unterminatedQuoted[0]?.type === "terminal.output" && unterminatedQuoted[0].delta).toBe("[REDACTED]") const interleaved = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: "API_TOKEN=" }, @@ -1614,6 +1727,20 @@ describe("deterministic parity oracle", () => { "root", ), ).toBe(true) + expect( + assertAuthoritativeRootResult( + [ + { + type: "task.result", + taskId: "root", + rootTaskId: "root", + outcome: "completed", + resumable: true, + }, + ], + "root", + ), + ).toBe(false) }) it("reports semantic drift without timestamps", () => { diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index c5b2a49943..660db04588 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -354,7 +354,8 @@ export function assertAuthoritativeRootResult(trace: readonly SemanticTraceEntry if ( result.taskId !== rootTaskId || result.rootTaskId !== rootTaskId || - !zooOutcomeSchema.safeParse(result.outcome).success + !zooOutcomeSchema.safeParse(result.outcome).success || + (result.resumable === true && !["needs_input", "cancelled", "timed_out"].includes(result.outcome ?? "")) ) { return false } diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 42c8ff72af..639f6f7369 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -145,7 +145,7 @@ const askResolvedEventSchema = taskEvent("ask.resolved", { }) const askAbandonedEventSchema = taskEvent("ask.abandoned", { askId: z.string().min(1), - reason: z.enum(["cancelled", "timed_out"]), + reason: z.enum(["cancelled", "timed_out", "failed"]), }) const toolEventState = { toolCallId: z.string().min(1), @@ -303,7 +303,7 @@ export function createZooStreamRedactor( JSON.stringify([event.hostId, event.rootTaskId, event.taskId, event.toolCallId, event.stream]) const hasUnmatchedPem = (text: string): boolean => { const openLabels: string[] = [] - for (const match of text.matchAll(/-----(BEGIN|END) ([A-Z ]*PRIVATE KEY)-----/g)) { + for (const match of text.matchAll(/-----(BEGIN|END) ((?:[A-Z ]*PRIVATE KEY|PGP PRIVATE KEY BLOCK))-----/g)) { const [, boundary, label] = match if (boundary === "BEGIN" && label !== undefined) openLabels.push(label) else if (boundary === "END" && label !== undefined && openLabels.at(-1) === label) openLabels.pop() @@ -321,7 +321,7 @@ export function createZooStreamRedactor( const [first, ...rest] = pending.events const unterminatedSecret = pending.pem || - /(?:password|secret|passphrase|passwd|pwd|credentials?|api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|authorization|bearer[-_. ]?token|client[-_. ]?secret|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token)\s*[:=]\s*$/i.test( + /(?:password|secret|passphrase|passwd|pwd|credentials?|api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|authorization|bearer[-_. ]?token|client[-_. ]?secret|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token)["']?\s*[:=]\s*(?:"(?:\\.|[^"\\])*|'(?:\\.|[^'\\])*)?$/i.test( pending.text, ) const delta = replacement ?? (unterminatedSecret ? REDACTED : String(redactValue(pending.text))) @@ -472,6 +472,9 @@ export function validateStreamLifecycle( const resumedEvents = events.filter((streamEvent) => streamEvent.type === "task.resumed") const startCommands = commands.filter((command) => command.type === "task.start") const resumeCommands = commands.filter((command) => command.type === "task.resume") + if (new Set(commands.map((command) => command.id)).size !== commands.length) { + return { ok: false, code: "protocol_gap", message: "Command IDs must be globally unique" } + } if (resumedEvents.length === 0) { const start = startCommands[0] if ( @@ -490,7 +493,7 @@ export function validateStreamLifecycle( const pendingAsks = new Map>() const settledAsks = new Map>() const consumedResponseCommands = new Set() - const abandonedAsks = new Map>() + const abandonedAsks = new Map>() const taskStates = new Map() const endedStates = new Set(["completed", "failed"]) const settledStates = new Set(["interrupted", "completed", "failed"]) @@ -506,6 +509,7 @@ export function validateStreamLifecycle( const terminalOperationStates = new Map>() const mcpStates = new Map>() const messageStates = new Map>() + const approvalResumeCauses = new Map() const scope = (map: Map>, taskId: string): Map => { const existing = map.get(taskId) if (existing !== undefined) return existing @@ -561,6 +565,29 @@ export function validateStreamLifecycle( } return terminals[0] } + const inputResumeCause = (taskId: string, requestId: string | undefined): boolean => { + if (requestId === undefined) return false + const input = commands.find( + (command) => command.type === "task.input" && command.id === requestId && command.taskId === taskId, + ) + const terminal = causalTerminal(requestId) + return ( + input !== undefined && + terminal?.type === "command.done" && + terminal.data.commandType === "task.input" && + terminal.data.taskId === taskId + ) + } + const approvalResumeCause = (taskId: string, requestId: string | undefined): boolean => { + if (!approvalResumeCauses.has(taskId) || approvalResumeCauses.get(taskId) !== requestId) return false + approvalResumeCauses.delete(taskId) + return true + } + for (const response of commands.filter((command) => command.type === "ask.respond")) { + if (causalTerminal(response.id) === undefined) { + return { ok: false, code: "protocol_gap", message: "Every ask response requires ACK and one terminal response" } + } + } for (const streamEvent of events) { if ("rootTaskId" in streamEvent && streamEvent.rootTaskId !== rootTaskId) { return { @@ -594,6 +621,7 @@ export function validateStreamLifecycle( return { ok: false, code: "task_failed", message: "Root creation must match its task.start request" } } } else if (streamEvent.type === "task.delegated") { + const reconstructingResumeTree = resumedEvents.length === 1 && resumedTasks.size === 0 if ( streamEvent.taskId !== streamEvent.childTaskId || streamEvent.childTaskId === rootTaskId || @@ -601,6 +629,7 @@ export function validateStreamLifecycle( !createdTasks.has(streamEvent.parentTaskId) || !createdTasks.has(streamEvent.childTaskId) || settledStates.has(taskStates.get(streamEvent.parentTaskId) ?? "") || + (taskStates.get(streamEvent.parentTaskId) !== "running" && !reconstructingResumeTree) || delegatedTasks.has(streamEvent.childTaskId) ) { return { @@ -634,6 +663,12 @@ export function validateStreamLifecycle( } const previousState = taskStates.get(streamEvent.taskId) + if ( + approvalResumeCauses.has(streamEvent.taskId) && + !(streamEvent.type === "task.lifecycle" && streamEvent.state === "running") + ) { + approvalResumeCauses.delete(streamEvent.taskId) + } if ( (previousState !== undefined && endedStates.has(previousState)) || (previousState === "interrupted" && streamEvent.type !== "task.resumed") @@ -672,6 +707,21 @@ export function validateStreamLifecycle( if (!startedTasks.has(streamEvent.taskId) && !reconstructingPredecessor) { return { ok: false, code: "task_failed", message: "Task lifecycle requires an ordered task.started event" } } + const transitionAllowed = + reconstructingPredecessor || + (previousState === "running" && ["waiting", "interrupted", "completed", "failed"].includes(streamEvent.state)) || + (previousState === "waiting" && + (["interrupted", "failed"].includes(streamEvent.state) || + (streamEvent.state === "running" && + (approvalResumeCause(streamEvent.taskId, streamEvent.requestId) || + inputResumeCause(streamEvent.taskId, streamEvent.requestId))))) + if (!transitionAllowed) { + return { + ok: false, + code: "task_failed", + message: `Invalid lifecycle transition for task ${streamEvent.taskId}`, + } + } if ((pendingAsks.get(streamEvent.taskId)?.size ?? 0) > 0 && settledStates.has(streamEvent.state)) { return { ok: false, code: "task_failed", message: "A task with a pending ask cannot terminate" } } @@ -796,6 +846,9 @@ export function validateStreamLifecycle( if (response !== undefined) consumedResponseCommands.add(response.id) } setScope(settledAsks, streamEvent.taskId).add(streamEvent.askId) + if (streamEvent.decision === "approve" || streamEvent.decision === "needs_input") { + approvalResumeCauses.set(streamEvent.taskId, streamEvent.requestId) + } } if (streamEvent.type === "ask.abandoned") { if (!pendingAsks.get(streamEvent.taskId)?.delete(streamEvent.askId)) { @@ -933,12 +986,10 @@ export function validateStreamLifecycle( if (resultEvent.result.outcome === "completed" && values(messageStates).some((message) => !message.complete)) { return { ok: false, code: "task_failed", message: "Completed streams cannot contain partial messages" } } - const unconsumedResponses = commands.some( - (command) => - command.type === "ask.respond" && - settledAsks.get(command.taskId)?.has(command.askId) === true && - !consumedResponseCommands.has(command.id), - ) + const unconsumedResponses = commands.some((command) => { + if (command.type !== "ask.respond" || consumedResponseCommands.has(command.id)) return false + return causalTerminal(command.id)?.type === "command.done" + }) if (unconsumedResponses) { return { ok: false, code: "task_failed", message: "Every ask response command must settle its matching ask" } } diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index d6386a4349..3da568e838 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -17,7 +17,7 @@ const secretPatterns: ReadonlyArray = [ /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, /\b(?:sk|xox[baprs]|gh[opusr])[-_][A-Za-z0-9_-]{8,}\b/g, /\b[A-Za-z][A-Za-z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD)\s*=\s*[^\s]+/gi, - /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + /-----BEGIN (?:[A-Z ]*PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----[\s\S]*?-----END (?:[A-Z ]*PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----/g, ] export type RedactedValue = null | undefined | boolean | number | string | RedactedValue[] | { [key: string]: RedactedValue } From 6c0a3d028c373896817b51fe89653e3a47bd89d1 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 05:25:35 -0400 Subject: [PATCH 16/24] no-mistakes(review): Tighten Zoo protocol causality and redaction boundaries --- .../src/__tests__/contracts.test.ts | 215 +++++++++++++++--- packages/zoo-protocol/src/host-events.ts | 2 +- packages/zoo-protocol/src/public-events.ts | 79 +++++-- packages/zoo-protocol/src/redaction.ts | 13 +- 4 files changed, 260 insertions(+), 49 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 58e8fbe508..bf3f941b87 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -83,12 +83,40 @@ function resultEvent(seq: number, result: Record = {}, event: R return parsed } -function cancellationDone(commandId = "cancel", hostId = "host") { +function startDone(commandId = "start", rootTaskId = "root", startSeq = 1) { return [ - hostEventSchema.parse({ v: 1, seq: 1, hostId, type: "command.ack", commandId }), + hostEventSchema.parse({ v: 1, seq: startSeq, hostId: "host", type: "command.ack", commandId }), hostEventSchema.parse({ v: 1, - seq: 2, + seq: startSeq + 1, + hostId: "host", + type: "command.done", + commandId, + data: { commandType: "task.start", task: { rootTaskId, taskId: rootTaskId } }, + }), + ] +} + +function resumeDone(commandId = "resume", taskId = "root", startSeq = 1) { + return [ + hostEventSchema.parse({ v: 1, seq: startSeq, hostId: "host", type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: startSeq + 1, + hostId: "host", + type: "command.done", + commandId, + data: { commandType: "task.resume", task: { rootTaskId: "root", taskId } }, + }), + ] +} + +function cancellationDone(commandId = "cancel", hostId = "host", startSeq = 1) { + return [ + hostEventSchema.parse({ v: 1, seq: startSeq, hostId, type: "command.ack", commandId }), + hostEventSchema.parse({ + v: 1, + seq: startSeq + 1, hostId, type: "command.done", commandId, @@ -97,12 +125,12 @@ function cancellationDone(commandId = "cancel", hostId = "host") { ] } -function cancellationError(commandId = "cancel") { +function cancellationError(commandId = "cancel", startSeq = 1) { return [ - hostEventSchema.parse({ v: 1, seq: 1, hostId: "host", type: "command.ack", commandId }), + hostEventSchema.parse({ v: 1, seq: startSeq, hostId: "host", type: "command.ack", commandId }), hostEventSchema.parse({ v: 1, - seq: 2, + seq: startSeq + 1, hostId: "host", type: "command.error", commandId, @@ -111,12 +139,12 @@ function cancellationError(commandId = "cancel") { ] } -function askResponseDone(commandId = "respond", hostId = "host") { +function askResponseDone(commandId = "respond", hostId = "host", startSeq = 1) { return [ - hostEventSchema.parse({ v: 1, seq: 1, hostId, type: "command.ack", commandId }), + hostEventSchema.parse({ v: 1, seq: startSeq, hostId, type: "command.ack", commandId }), hostEventSchema.parse({ v: 1, - seq: 2, + seq: startSeq + 1, hostId, type: "command.done", commandId, @@ -125,12 +153,12 @@ function askResponseDone(commandId = "respond", hostId = "host") { ] } -function askResponseError(commandId = "respond") { +function askResponseError(commandId = "respond", startSeq = 1) { return [ - hostEventSchema.parse({ v: 1, seq: 1, hostId: "host", type: "command.ack", commandId }), + hostEventSchema.parse({ v: 1, seq: startSeq, hostId: "host", type: "command.ack", commandId }), hostEventSchema.parse({ v: 1, - seq: 2, + seq: startSeq + 1, hostId: "host", type: "command.error", commandId, @@ -508,6 +536,18 @@ describe("strict host contracts", () => { expect(byteParser.push(terminalEnvelope(1, "unterminated"))).toMatchObject([ { seq: 1, event: { delta: "[REDACTED]" } }, ]) + + const scopedParser = createHostEventStreamParser({ maxPendingMs: 10, now: () => now }) + now = 0 + expect(scopedParser.push(terminalEnvelope(1, "unterminated"))).toEqual([]) + now = 10 + expect(scopedParser.tick()).toMatchObject([{ seq: 1, event: { delta: "[REDACTED]" } }]) + expect( + scopedParser.push({ + ...terminalEnvelope(2, "harmless\n"), + event: { ...terminalEnvelope(2, "harmless\n").event, toolCallId: "other-terminal" }, + }), + ).toMatchObject([{ seq: 2, event: { delta: "harmless\n" } }]) }) it("preserves host envelopes across concurrent root streams", () => { @@ -660,7 +700,9 @@ describe("public automation contracts", () => { const started = taskEvent(3, "task.started") const completed = taskEvent(4, "task.lifecycle", { state: "completed" }) const result = resultEvent(5) - expect(validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand])).toEqual({ ok: true }) + expect(validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand], startDone())).toEqual({ + ok: true, + }) expect( validateStreamLifecycle( [initEvent, created, started, completed, resultEvent(5, { workspace: "/other" })], @@ -675,6 +717,16 @@ describe("public automation contracts", () => { ).toMatchObject({ ok: false }) expect(validateStreamLifecycle([initEvent, resultEvent(2)])).toMatchObject({ ok: false }) expect(validateStreamLifecycle([initEvent, created, resultEvent(3)])).toMatchObject({ ok: false }) + expect(validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand])).toMatchObject({ + ok: false, + }) + expect( + validateStreamLifecycle( + [initEvent, created, started, completed, result], + [startCommand], + startDone("start", "other-root"), + ), + ).toMatchObject({ ok: false }) expect( validateStreamLifecycle([initEvent, created, completed, { ...result, hostId: "other-host" }]), ).toMatchObject({ @@ -718,7 +770,7 @@ describe("public automation contracts", () => { childCompleted, rootCompleted, resultEvent(9), - ], [startCommand]), + ], [startCommand], startDone()), ).toEqual({ ok: true }) expect( validateStreamLifecycle( @@ -734,6 +786,7 @@ describe("public automation contracts", () => { resultEvent(9), ], [startCommand], + startDone(), ), ).toMatchObject({ ok: false }) expect( @@ -789,7 +842,7 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], [startCommand, response], - askResponseDone(), + [...startDone(), ...askResponseDone("respond", "host", 3)], ), ).toEqual({ ok: true, @@ -810,7 +863,7 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, rootCreated, rootStarted, required, policyOverride, completed, resultEvent(7)], [startCommand, response], - askResponseDone(), + [...startDone(), ...askResponseDone("respond", "host", 3)], ), ).toMatchObject({ ok: false }) const policyReportedResponse = zooStreamEventSchema.parse({ ...resolved, source: "policy" }) @@ -818,7 +871,7 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, rootCreated, rootStarted, required, policyReportedResponse, completed, resultEvent(7)], [startCommand, response], - askResponseDone(), + [...startDone(), ...askResponseDone("respond", "host", 3)], ), ).toEqual({ ok: true }) expect( @@ -847,7 +900,7 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, rootCreated, rootStarted, required, waiting, needsInput], [startCommand, response], - askResponseError(), + [...startDone(), ...askResponseError("respond", 3)], ), ).toEqual({ ok: true }) expect( @@ -874,7 +927,7 @@ describe("public automation contracts", () => { const terminalExited = taskEvent(7, "terminal.status", { toolCallId: "terminal", state: "exited", exitCode: 0 }) const mcpStarted = taskEvent(8, "mcp.started", { operationId: "mcp", server: "test", operation: "read" }) const mcpCompleted = taskEvent(9, "mcp.completed", { operationId: "mcp", server: "test", operation: "read" }) - const interrupted = taskEvent(10, "task.lifecycle", { state: "interrupted" }) + const interrupted = taskEvent(10, "task.lifecycle", { state: "interrupted", cause: "cancelled" }) const cancelled = resultEvent(11, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) const command = hostCommandSchema.parse({ v: 1, @@ -897,7 +950,9 @@ describe("public automation contracts", () => { interrupted, cancelled, ] - expect(validateStreamLifecycle(stream, [startCommand, command], cancellationDone())).toEqual({ ok: true }) + expect( + validateStreamLifecycle(stream, [startCommand, command], [...startDone(), ...cancellationDone("cancel", "host", 3)]), + ).toEqual({ ok: true }) expect( validateStreamLifecycle(stream, [startCommand, command], cancellationDone("cancel", "other")), ).toMatchObject({ ok: false }) @@ -974,7 +1029,7 @@ describe("public automation contracts", () => { const required = taskEvent(4, "ask.required", { askId: "ask", category: "tool", subject: "Run" }) const abandoned = taskEvent(5, "ask.abandoned", { askId: "ask", reason: "cancelled" }) if (abandoned.type !== "ask.abandoned") throw new Error("Expected ask.abandoned fixture") - const interrupted = taskEvent(6, "task.lifecycle", { state: "interrupted" }) + const interrupted = taskEvent(6, "task.lifecycle", { state: "interrupted", cause: "cancelled" }) const cancelled = resultEvent(7, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) const command = hostCommandSchema.parse({ v: 1, @@ -987,7 +1042,7 @@ describe("public automation contracts", () => { validateStreamLifecycle( [initEvent, created, started, required, abandoned, interrupted, cancelled], [startCommand, command], - cancellationDone(), + [...startDone(), ...cancellationDone("cancel", "host", 3)], ), ).toEqual({ ok: true }) expect( @@ -1012,13 +1067,40 @@ describe("public automation contracts", () => { started, required, failedAbandonment, - taskEvent(6, "task.lifecycle", { state: "failed" }), + taskEvent(6, "task.lifecycle", { state: "failed", cause: "failed" }), resultEvent(7, { outcome: "failed", error: { code: "provider_failed", message: "failed" }, }), ], [startCommand], + startDone(), + ), + ).toEqual({ ok: true }) + + const childFailureThenCancellation = [ + initEvent, + created, + started, + taskEvent(4, "task.created", { taskId: "child", parentTaskId: "root" }), + taskEvent(5, "task.delegated", { taskId: "child", parentTaskId: "root", childTaskId: "child" }), + taskEvent(6, "task.started", { taskId: "child" }), + taskEvent(7, "ask.required", { + taskId: "child", + askId: "child-ask", + category: "tool", + subject: "Run", + }), + taskEvent(8, "ask.abandoned", { taskId: "child", askId: "child-ask", reason: "failed" }), + taskEvent(9, "task.lifecycle", { taskId: "child", state: "failed", cause: "failed" }), + taskEvent(10, "task.lifecycle", { state: "interrupted", cause: "cancelled" }), + resultEvent(11, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }), + ] + expect( + validateStreamLifecycle( + childFailureThenCancellation, + [startCommand, command], + [...startDone(), ...cancellationDone("cancel", "host", 3)], ), ).toEqual({ ok: true }) }) @@ -1076,9 +1158,57 @@ describe("public automation contracts", () => { resultEvent(9), ], [startCommand, response], - askResponseDone(), + [...startDone(), ...askResponseDone("respond", "host", 3)], ), ).toEqual({ ok: true }) + + const secondRequired = taskEvent(5, "ask.required", { askId: "other", category: "tool", subject: "Other" }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + secondRequired, + { ...waiting, seq: 6 }, + { ...resolved, seq: 7 }, + { ...running, seq: 8, requestId: "respond" }, + resultEvent(9, { outcome: "needs_input", resumable: true }), + ], + [startCommand, response], + [...startDone(), ...askResponseDone("respond", "host", 3)], + ), + ).toMatchObject({ ok: false }) + }) + + it("consumes each task input resume cause once", () => { + const input = hostCommandSchema.parse({ v: 1, id: "input", type: "task.input", taskId: "root", text: "continue" }) + const inputEvents = [ + hostEventSchema.parse({ v: 1, seq: 3, hostId: "host", type: "command.ack", commandId: "input" }), + hostEventSchema.parse({ + v: 1, + seq: 4, + hostId: "host", + type: "command.done", + commandId: "input", + data: { commandType: "task.input", taskId: "root" }, + }), + ] + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "task.lifecycle", { state: "waiting" }), + taskEvent(5, "task.lifecycle", { state: "running", requestId: "input" }), + taskEvent(6, "task.lifecycle", { state: "waiting" }), + taskEvent(7, "task.lifecycle", { state: "running", requestId: "input" }), + taskEvent(8, "task.lifecycle", { state: "completed" }), + resultEvent(9), + ] + expect(validateStreamLifecycle(stream, [startCommand, input], [...startDone(), ...inputEvents])).toMatchObject({ + ok: false, + }) }) it("reconstructs resume streams from a matching command", () => { @@ -1102,7 +1232,7 @@ describe("public automation contracts", () => { completed, resultEvent(7, {}, { requestId: "resume" }), ] - expect(validateStreamLifecycle(stream, [command])).toEqual({ ok: true }) + expect(validateStreamLifecycle(stream, [command], resumeDone())).toEqual({ ok: true }) expect(validateStreamLifecycle(stream)).toMatchObject({ ok: false }) expect( validateStreamLifecycle( @@ -1128,10 +1258,12 @@ describe("public automation contracts", () => { taskEvent(3, "task.lifecycle", { state: "interrupted" }), taskEvent(4, "task.resumed", { requestId: "resume", previousState: "interrupted" }), taskEvent(5, "task.started"), - taskEvent(6, "task.lifecycle", { state: "interrupted" }), + taskEvent(6, "task.lifecycle", { state: "interrupted", cause: "cancelled" }), resultEvent(7, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }), ] - expect(validateStreamLifecycle(stream, [resume, cancel], cancellationDone())).toEqual({ ok: true }) + expect( + validateStreamLifecycle(stream, [resume, cancel], [...resumeDone(), ...cancellationDone("cancel", "host", 3)]), + ).toEqual({ ok: true }) }) it("requires accepted cancellations to own cancelled results", () => { @@ -1152,7 +1284,9 @@ describe("public automation contracts", () => { expect(validateStreamLifecycle(completed, [startCommand, cancel], cancellationDone())).toMatchObject({ ok: false, }) - expect(validateStreamLifecycle(completed, [startCommand, cancel], cancellationError())).toEqual({ ok: true }) + expect( + validateStreamLifecycle(completed, [startCommand, cancel], [...startDone(), ...cancellationError("cancel", 3)]), + ).toEqual({ ok: true }) expect(validateStreamLifecycle(completed, [startCommand, cancel])).toMatchObject({ ok: false }) }) @@ -1181,7 +1315,7 @@ describe("public automation contracts", () => { taskEvent(10, "task.lifecycle", { state: "completed" }), resultEvent(11, {}, { requestId: "resume-child" }), ] - expect(validateStreamLifecycle(stream, [command])).toEqual({ ok: true }) + expect(validateStreamLifecycle(stream, [command], resumeDone("resume-child", "child"))).toEqual({ ok: true }) expect( validateStreamLifecycle( stream.map((event) => @@ -1212,7 +1346,7 @@ describe("public automation contracts", () => { taskEvent(12, "task.lifecycle", { state: "completed" }), resultEvent(13), ] - expect(validateStreamLifecycle(stream, [startCommand])).toEqual({ ok: true }) + expect(validateStreamLifecycle(stream, [startCommand], startDone())).toEqual({ ok: true }) }) it("keeps pending asks on waiting tasks", () => { @@ -1260,6 +1394,7 @@ describe("public automation contracts", () => { resultEvent(7), ], [startCommand], + startDone(), ), ).toMatchObject({ ok: false }) @@ -1365,6 +1500,7 @@ describe("public automation contracts", () => { }), ], [startCommand], + startDone(), ), ).toEqual({ ok: true }) }) @@ -1451,6 +1587,12 @@ describe("redaction contracts", () => { expect(redactText("password: abc,def")).toBe("[REDACTED]") expect(redactText('{"api\\u005fkey":"hunter2"}')).toBe('{"api_key":"[REDACTED]"}') expect(redactText("API_\u001b[31mTOKEN=abcdefgh")).toBe("[REDACTED]") + expect(redactText("github_pat_1234567890abcdef")).toBe("[REDACTED]") + expect(redactValue({ sessionCookie: "abc", cookieJar: "def", privateKeyPem: "ghi" })).toEqual({ + sessionCookie: "[REDACTED]", + cookieJar: "[REDACTED]", + privateKeyPem: "[REDACTED]", + }) }) it("redacts public event and result payloads during parsing", () => { @@ -1632,6 +1774,19 @@ describe("redaction contracts", () => { { ...terminal, seq: 1, delta: '{"api_key":"hunter2' }, ]) expect(unterminatedQuoted[0]?.type === "terminal.output" && unterminatedQuoted[0].delta).toBe("[REDACTED]") + const escapedUnterminatedQuoted = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: '{"api\\u005fkey":"hunter2' }, + ]) + expect( + escapedUnterminatedQuoted[0]?.type === "terminal.output" && escapedUnterminatedQuoted[0].delta, + ).toBe("[REDACTED]") + const ansiPem = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "-----BEGIN\u001b[31m PRIVATE KEY-----\n" }, + { ...terminal, seq: 2, delta: "private-body\n" }, + ]) + expect(ansiPem.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED]", + ) const interleaved = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: "API_TOKEN=" }, diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index 3a9aac2383..c38e4b27d3 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -196,7 +196,7 @@ export function createHostEventStreamParser( queuedBytes >= maxQueuedBytes || now() - oldest.enqueuedAt >= maxPendingMs)) ) { - assign(redactor.failClosed()) + assign(redactor.failClosed(oldest.envelope?.event)) } return drain() } diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 639f6f7369..0ed2800166 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -3,7 +3,7 @@ import { z } from "zod" import type { HostCommand } from "./host-commands.js" import type { HostEvent } from "./host-events.js" import { failedErrorCodeSchema, zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" -import { REDACTED, redactValue, type RedactedValue } from "./redaction.js" +import { canonicalizeRedactionText, REDACTED, redactValue, type RedactedValue } from "./redaction.js" import { ZOO_HOST_PROTOCOL_VERSION, ZOO_PUBLIC_SCHEMA_VERSION, @@ -119,6 +119,7 @@ const taskCreatedEventSchema = taskEvent("task.created", { parentTaskId: z.strin const taskStartedEventSchema = taskEvent("task.started", {}) const taskLifecycleEventSchema = taskEvent("task.lifecycle", { state: z.enum(["running", "waiting", "interrupted", "completed", "failed"]), + cause: z.enum(["cancelled", "timed_out", "failed"]).optional(), }) const taskResumedEventSchema = taskEvent("task.resumed", { previousState: z.enum(["waiting", "interrupted"]), @@ -282,7 +283,7 @@ const streamEventKey = (event: RawZooStreamEvent) => export type ZooStreamRedactor = { push: (event: RawZooStreamEvent) => ZooStreamEvent[] flush: () => ZooStreamEvent[] - failClosed: () => ZooStreamEvent[] + failClosed: (event?: RawZooStreamEvent) => ZooStreamEvent[] } export function createZooStreamRedactor( @@ -298,7 +299,7 @@ export function createZooStreamRedactor( overflowed: boolean } const pendingOutputs = new Map() - let failClosed = false + let failClosedAll = false const outputKey = (event: z.infer) => JSON.stringify([event.hostId, event.rootTaskId, event.taskId, event.toolCallId, event.stream]) const hasUnmatchedPem = (text: string): boolean => { @@ -319,10 +320,11 @@ export function createZooStreamRedactor( return [] } const [first, ...rest] = pending.events + const detectionText = canonicalizeRedactionText(pending.text) const unterminatedSecret = pending.pem || /(?:password|secret|passphrase|passwd|pwd|credentials?|api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|authorization|bearer[-_. ]?token|client[-_. ]?secret|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token)["']?\s*[:=]\s*(?:"(?:\\.|[^"\\])*|'(?:\\.|[^'\\])*)?$/i.test( - pending.text, + detectionText, ) const delta = replacement ?? (unterminatedSecret ? REDACTED : String(redactValue(pending.text))) pendingOutputs.delete(key) @@ -354,11 +356,11 @@ export function createZooStreamRedactor( } if (event.delta.length === 0) return [{ ...event, delta: "" }] const key = outputKey(event) - if (failClosed) return [{ ...event, delta: event.delta.length === 0 ? "" : REDACTED }] + if (failClosedAll) return [{ ...event, delta: REDACTED }] let pending = pendingOutputs.get(key) if (pending === undefined) { if (pendingOutputs.size >= maxPendingStreams) { - failClosed = true + failClosedAll = true const buffered = [...pendingOutputs.keys()].flatMap((pendingKey) => emit(pendingKey, REDACTED)) return [...buffered, { ...event, delta: event.delta.length === 0 ? "" : REDACTED }] } @@ -368,7 +370,7 @@ export function createZooStreamRedactor( if (pending.overflowed) return [{ ...event, delta: event.delta.length === 0 ? "" : REDACTED }] pending.events.push(event) pending.text += event.delta - pending.pem = hasUnmatchedPem(pending.text) + pending.pem = hasUnmatchedPem(canonicalizeRedactionText(pending.text)) if (pending.text.length > maxPendingBytes || pending.events.length > maxPendingEvents) { pending.overflowed = true const redacted = emit(key, REDACTED) @@ -381,9 +383,14 @@ export function createZooStreamRedactor( return allEventsEndAtBoundary ? emit(key) : [] }, flush: () => [...pendingOutputs.keys()].flatMap((key) => emit(key)), - failClosed: () => { - failClosed = true - return [...pendingOutputs.keys()].flatMap((key) => emit(key, REDACTED)) + failClosed: (event) => { + if (event === undefined) failClosedAll = true + const keys = event?.type === "terminal.output" ? [outputKey(event)] : [...pendingOutputs.keys()] + return keys.flatMap((key) => { + const redacted = emit(key, REDACTED) + pendingOutputs.set(key, { events: [], text: "", pem: false, overflowed: true }) + return redacted + }) }, } } @@ -493,7 +500,9 @@ export function validateStreamLifecycle( const pendingAsks = new Map>() const settledAsks = new Map>() const consumedResponseCommands = new Set() + const consumedInputCommands = new Set() const abandonedAsks = new Map>() + const taskTerminalCauses = new Map() const taskStates = new Map() const endedStates = new Set(["completed", "failed"]) const settledStates = new Set(["interrupted", "completed", "failed"]) @@ -566,17 +575,18 @@ export function validateStreamLifecycle( return terminals[0] } const inputResumeCause = (taskId: string, requestId: string | undefined): boolean => { - if (requestId === undefined) return false + if (requestId === undefined || consumedInputCommands.has(requestId)) return false const input = commands.find( (command) => command.type === "task.input" && command.id === requestId && command.taskId === taskId, ) const terminal = causalTerminal(requestId) - return ( + const valid = input !== undefined && terminal?.type === "command.done" && terminal.data.commandType === "task.input" && terminal.data.taskId === taskId - ) + if (valid) consumedInputCommands.add(requestId) + return valid } const approvalResumeCause = (taskId: string, requestId: string | undefined): boolean => { if (!approvalResumeCauses.has(taskId) || approvalResumeCauses.get(taskId) !== requestId) return false @@ -588,6 +598,27 @@ export function validateStreamLifecycle( return { ok: false, code: "protocol_gap", message: "Every ask response requires ACK and one terminal response" } } } + const initiatingCommand = resumedEvents.length === 0 ? startCommands[0] : resumeCommands[0] + const initiatingTerminal = initiatingCommand === undefined ? undefined : causalTerminal(initiatingCommand.id) + if (initiatingTerminal?.type !== "command.done") { + return { ok: false, code: "protocol_gap", message: "Task stream requires a successful initiating command" } + } + if ( + initiatingCommand?.type === "task.start" && + (initiatingTerminal.data.commandType !== "task.start" || + initiatingTerminal.data.task.rootTaskId !== rootTaskId || + initiatingTerminal.data.task.taskId !== rootTaskId) + ) { + return { ok: false, code: "task_failed", message: "task.start completion does not match the stream root" } + } + if ( + initiatingCommand?.type === "task.resume" && + (initiatingTerminal.data.commandType !== "task.resume" || + initiatingTerminal.data.task.rootTaskId !== rootTaskId || + initiatingTerminal.data.task.taskId !== initiatingCommand.taskId) + ) { + return { ok: false, code: "task_failed", message: "task.resume completion does not match the resumed task" } + } for (const streamEvent of events) { if ("rootTaskId" in streamEvent && streamEvent.rootTaskId !== rootTaskId) { return { @@ -713,6 +744,7 @@ export function validateStreamLifecycle( (previousState === "waiting" && (["interrupted", "failed"].includes(streamEvent.state) || (streamEvent.state === "running" && + !hasPendingAskInAncestry(streamEvent.taskId) && (approvalResumeCause(streamEvent.taskId, streamEvent.requestId) || inputResumeCause(streamEvent.taskId, streamEvent.requestId))))) if (!transitionAllowed) { @@ -722,6 +754,18 @@ export function validateStreamLifecycle( message: `Invalid lifecycle transition for task ${streamEvent.taskId}`, } } + if ( + (streamEvent.state === "running" || streamEvent.state === "waiting" || streamEvent.state === "completed") && + streamEvent.cause !== undefined + ) { + return { ok: false, code: "task_failed", message: "Lifecycle cause contradicts task state" } + } + if (streamEvent.state === "failed" && streamEvent.cause !== undefined && streamEvent.cause !== "failed") { + return { ok: false, code: "task_failed", message: "Lifecycle cause contradicts task state" } + } + if (streamEvent.state === "interrupted" && streamEvent.cause === "failed") { + return { ok: false, code: "task_failed", message: "Lifecycle cause contradicts task state" } + } if ((pendingAsks.get(streamEvent.taskId)?.size ?? 0) > 0 && settledStates.has(streamEvent.state)) { return { ok: false, code: "task_failed", message: "A task with a pending ask cannot terminate" } } @@ -734,6 +778,7 @@ export function validateStreamLifecycle( return { ok: false, code: "task_failed", message: "A task cannot terminate before its descendants" } } taskStates.set(streamEvent.taskId, streamEvent.state) + if (streamEvent.cause !== undefined) taskTerminalCauses.set(streamEvent.taskId, streamEvent.cause) } if (streamEvent.type === "task.resumed") { const resume = resumeCommands[0] @@ -931,8 +976,12 @@ export function validateStreamLifecycle( if (resumeCommands.length !== resumedTasks.size) { return { ok: false, code: "task_failed", message: "Every resume command must reconstruct one resumed task" } } - if (values(abandonedAsks).some((reason) => reason !== resultEvent.result.outcome)) { - return { ok: false, code: "task_failed", message: "Ask abandonment contradicts task.result" } + if ( + [...abandonedAsks].some(([taskId, asks]) => + [...asks.values()].some((reason) => taskTerminalCauses.get(taskId) !== reason), + ) + ) { + return { ok: false, code: "task_failed", message: "Ask abandonment contradicts its task lifecycle" } } const expectedState = { completed: "completed", diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 3da568e838..0c8655f10f 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -16,6 +16,7 @@ const secretPatterns: ReadonlyArray = [ new RegExp(`(? String.fromCharCode(Number.parseInt(code, 16))) +} + +export function redactText(value: string): string { + const canonical = canonicalizeRedactionText(value) const structured = canonical .replace(/\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s/?#]+/g, (authority) => { const schemeEnd = authority.indexOf("//") + 2 From 4839ba94f0d69ce575a3328c3e0af63e6520cea6 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 05:40:07 -0400 Subject: [PATCH 17/24] no-mistakes(review): Tighten Zoo protocol lifecycle and redaction contracts --- .../src/__tests__/contracts.test.ts | 118 +++++++++++++++++- packages/zoo-protocol/src/host-events.ts | 10 +- packages/zoo-protocol/src/parity.ts | 8 +- packages/zoo-protocol/src/public-events.ts | 56 +++++++-- packages/zoo-protocol/src/redaction.ts | 19 ++- 5 files changed, 189 insertions(+), 22 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index bf3f941b87..9fc60a69d0 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -323,6 +323,33 @@ describe("strict host contracts", () => { ).toBe(false) }) + it("does not mutate parser state for an invalid nested event", () => { + const parser = createHostEventStreamParser() + const invalid = { + v: 1, + seq: 1, + hostId: "host", + type: "event", + event: { + v: 1, + seq: 1, + timestamp, + hostId: "other", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + delta: "safe\n", + }, + } + expect(() => parser.push(invalid)).toThrow("hostId must match") + expect(parser.push({ ...invalid, event: { ...invalid.event, hostId: "host" } })).toMatchObject([ + { seq: 1, event: { delta: "safe\n" } }, + ]) + expect(parser.flush()).toEqual([]) + }) + it("models one ACK and terminal command response independently", () => { const command = hostCommandSchema.parse({ v: 1, id: "cmd", type: "host.shutdown" }) const events = [ @@ -653,6 +680,14 @@ describe("public automation contracts", () => { resumable: true, }).success, ).toBe(true) + expect( + zooRunResultSchema.safeParse({ + ...result, + success: false, + outcome: "needs_input", + resumable: false, + }).success, + ).toBe(false) expect( zooRunResultSchema.safeParse({ ...result, @@ -690,6 +725,22 @@ describe("public automation contracts", () => { expect(zooStreamEventSchema.safeParse({ ...event, seq: Number.MAX_SAFE_INTEGER + 1 }).success).toBe(false) expect(zooStreamEventSchema.safeParse({ ...event, rawSecret: "no" }).success).toBe(false) expect(zooStreamEventSchema.safeParse({ ...event, taskId: undefined }).success).toBe(false) + const tool = { + v: 1, + seq: 1, + timestamp, + hostId: "host", + type: "tool.started", + rootTaskId: "root", + taskId: "root", + toolCallId: "tool", + name: "read", + arguments: { nested: [null, true, 1, "value"] }, + } + expect(zooStreamEventSchema.safeParse(tool).success).toBe(true) + for (const invalid of [Infinity, BigInt(1), undefined, () => undefined]) { + expect(zooStreamEventSchema.safeParse({ ...tool, arguments: { invalid } }).success).toBe(false) + } expect(zooStreamEventSchema.safeParse({ ...initEvent, capabilities: ["task:start", "future:additive"] }).success).toBe( true, ) @@ -772,6 +823,7 @@ describe("public automation contracts", () => { resultEvent(9), ], [startCommand], startDone()), ).toEqual({ ok: true }) + expect( validateStreamLifecycle( [ @@ -953,6 +1005,17 @@ describe("public automation contracts", () => { expect( validateStreamLifecycle(stream, [startCommand, command], [...startDone(), ...cancellationDone("cancel", "host", 3)]), ).toEqual({ ok: true }) + expect( + validateStreamLifecycle( + stream.map((event) => + event.type === "task.lifecycle" && event.state === "interrupted" + ? { ...event, cause: "timed_out" as const } + : event, + ), + [startCommand, command], + [...startDone(), ...cancellationDone("cancel", "host", 3)], + ), + ).toMatchObject({ ok: false }) expect( validateStreamLifecycle(stream, [startCommand, command], cancellationDone("cancel", "other")), ).toMatchObject({ ok: false }) @@ -1162,6 +1225,31 @@ describe("public automation contracts", () => { ), ).toEqual({ ok: true }) + const rejection = hostCommandSchema.parse({ ...response, id: "reject", response: "reject" }) + const rejected = taskEvent(6, "ask.resolved", { + requestId: "reject", + askId: "ask", + decision: "reject", + source: "user", + }) + expect( + validateStreamLifecycle( + [ + initEvent, + created, + started, + required, + waiting, + rejected, + { ...running, seq: 7, requestId: "reject" }, + { ...completed, seq: 8 }, + resultEvent(9), + ], + [startCommand, rejection], + [...startDone(), ...askResponseDone("reject", "host", 3)], + ), + ).toEqual({ ok: true }) + const secondRequired = taskEvent(5, "ask.required", { askId: "other", category: "tool", subject: "Other" }) expect( validateStreamLifecycle( @@ -1373,7 +1461,7 @@ describe("public automation contracts", () => { required, childCompleted, rootWaiting, - resultEvent(8, { outcome: "needs_input" }), + resultEvent(8, { outcome: "needs_input", resumable: true }), ]), ).toMatchObject({ ok: false }) }) @@ -1780,6 +1868,13 @@ describe("redaction contracts", () => { expect( escapedUnterminatedQuoted[0]?.type === "terminal.output" && escapedUnterminatedQuoted[0].delta, ).toBe("[REDACTED]") + const multilineQuoted = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: 'password="first\n' }, + { ...terminal, seq: 2, delta: 'second"\n' }, + { ...terminal, seq: 3, delta: "harmless\n" }, + ]) + expect(multilineQuoted.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) + .toBe("[REDACTED][REDACTED]harmless\n") const ansiPem = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: "-----BEGIN\u001b[31m PRIVATE KEY-----\n" }, { ...terminal, seq: 2, delta: "private-body\n" }, @@ -1822,6 +1917,16 @@ describe("redaction contracts", () => { right: { value: "safe" }, }) }) + + it("canonicalizes terminal controls and credential value suffixes", () => { + expect(redactText(`API_\u001b]0;title\u0007TOKEN=hunter2`)).toBe("[REDACTED]") + expect(redactText(`API_\u009dtitle\u009cTOKEN=hunter2`)).toBe("[REDACTED]") + expect(redactValue({ accessTokenValue: "hunter2", apiKeyValue: "secret", maxTokenValue: 10 })).toEqual({ + accessTokenValue: "[REDACTED]", + apiKeyValue: "[REDACTED]", + maxTokenValue: 10, + }) + }) }) describe("deterministic parity oracle", () => { @@ -2002,6 +2107,17 @@ describe("deterministic parity oracle", () => { } }) + it("rejects extra approval and cancellation fixture fields", () => { + for (const providerTurns of [ + ["ask:ask-1", "approve:ask-1:user:request-1:extra"], + ["cancel:request-1:user:extra"], + ]) { + expect(() => + runDeterministicFakeProvider({ id: "extra-fields", prompt: "Reject extras", providerTurns, expected: [] }), + ).toThrow() + } + }) + it("ignores object property insertion order without ignoring event order", () => { const expected = [{ type: "message.upsert", taskId: "root", content: "hello" }] const reordered = [{ content: "hello", taskId: "root", type: "message.upsert" }] diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index c38e4b27d3..97493cc87d 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -221,15 +221,18 @@ export function createHostEventStreamParser( if (lastSeq !== undefined && !validateMonotonicSequence(lastSeq, event.seq).ok) { throw new Error(`Expected host sequence ${lastSeq + 1}`) } - pinnedHostId ??= event.hostId - lastSeq = event.seq - const released = releaseBlockedQueue() + if (event.type === "event" && event.event.hostId !== event.hostId) { + throw new Error("Normalized event hostId must match its host envelope") + } let bytes: number try { bytes = new TextEncoder().encode(JSON.stringify(event)).byteLength } catch { bytes = maxQueuedBytes } + pinnedHostId ??= event.hostId + lastSeq = event.seq + const released = releaseBlockedQueue() const entry: QueueEntry = { enqueuedAt: now(), bytes } queue.push(entry) queuedBytes += bytes @@ -237,7 +240,6 @@ export function createHostEventStreamParser( entry.output = sanitizeNonEvent(event) return [...released, ...releaseBlockedQueue()] } - if (event.event.hostId !== event.hostId) throw new Error("Normalized event hostId must match its host envelope") entry.envelope = event const key = eventKey(event.event) const entries = envelopes.get(key) ?? [] diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index 660db04588..59e97f9b41 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -263,10 +263,12 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly continue } if (turn.startsWith("approve:")) { - const [, askId, source, requestId] = turn.split(":") + const fields = turn.split(":") + const [, askId, source, requestId] = fields if (!askId || source !== "user" || !requestId || usedRequestIds.has(requestId)) { throw new Error(`Invalid approval fixture: ${turn}`) } + if (fields.length !== 4) throw new Error(`Invalid approval fixture: ${turn}`) if (!pendingAsks.delete(askId)) throw new Error(`Approval references unknown ask: ${turn}`) usedRequestIds.add(requestId) trace.push({ @@ -282,8 +284,10 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly } if (turn.startsWith("cancel:")) { requireSettledState() - const [, requestId, cancellationReason] = turn.split(":") + const fields = turn.split(":") + const [, requestId, cancellationReason] = fields if ( + fields.length !== 3 || !requestId || usedRequestIds.has(requestId) || !["user", "signal", "timeout"].includes(cancellationReason ?? "") diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 0ed2800166..98d6afca73 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -3,7 +3,7 @@ import { z } from "zod" import type { HostCommand } from "./host-commands.js" import type { HostEvent } from "./host-events.js" import { failedErrorCodeSchema, zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" -import { canonicalizeRedactionText, REDACTED, redactValue, type RedactedValue } from "./redaction.js" +import { canonicalizeRedactionText, REDACTED, redactValue, type JsonValue } from "./redaction.js" import { ZOO_HOST_PROTOCOL_VERSION, ZOO_PUBLIC_SCHEMA_VERSION, @@ -69,6 +69,9 @@ const rawZooRunResultSchema = strictObject({ if (result.resumable && !["needs_input", "cancelled", "timed_out"].includes(result.outcome)) { context.addIssue({ code: z.ZodIssueCode.custom, message: `${result.outcome} results cannot be resumed` }) } + if (result.outcome === "needs_input" && !result.resumable) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "needs_input results must be resumable" }) + } }) const redactError = (error: T): T => ({ @@ -76,8 +79,7 @@ const redactError = (error: T): T message: String(redactValue(error.message)), ...(error.phase === undefined ? {} : { phase: String(redactValue(error.phase)) }), }) -const redactRecord = (value: Record): Record => - redactValue(value) as Record +const redactRecord = (value: Record): Record => redactValue(value) export const zooRunResultSchema = rawZooRunResultSchema.transform((result) => ({ ...result, @@ -148,10 +150,13 @@ const askAbandonedEventSchema = taskEvent("ask.abandoned", { askId: z.string().min(1), reason: z.enum(["cancelled", "timed_out", "failed"]), }) +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([z.null(), z.boolean(), z.number().finite(), z.string(), z.array(jsonValueSchema), z.record(jsonValueSchema)]), +) const toolEventState = { toolCallId: z.string().min(1), name: z.string().min(1), - arguments: z.record(z.unknown()).optional(), + arguments: z.record(jsonValueSchema).optional(), output: z.string().optional(), } const toolStartedEventSchema = taskEvent("tool.started", toolEventState) @@ -297,6 +302,7 @@ export function createZooStreamRedactor( text: string pem: boolean overflowed: boolean + secretQuote?: '"' | "'" } const pendingOutputs = new Map() let failClosedAll = false @@ -311,6 +317,18 @@ export function createZooStreamRedactor( } return openLabels.length > 0 } + const incompleteSecretQuote = (text: string): '"' | "'" | undefined => { + const match = text.match( + /(?:password|secret|passphrase|passwd|pwd|credentials?|api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|authorization|bearer[-_. ]?token|client[-_. ]?secret|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token)["']?\s*[:=]\s*(["'])(?:\\.|[^\\])*$/i, + ) + if (match?.[1] !== '"' && match?.[1] !== "'") return undefined + const opening = /[:=]\s*(["'])/.exec(match[0]) + if (opening === null) return undefined + const value = match[0].slice(opening.index + opening[0].length) + return new RegExp(`(?:^|[^\\\\])${match[1]}`).test(value) ? undefined : match[1] + } + const closesSecretQuote = (text: string, quote: '"' | "'"): boolean => + new RegExp(`(?:^|[^\\\\])${quote}`).test(text) const emit = (key: string, replacement?: string): ZooStreamEvent[] => { const pending = pendingOutputs.get(key) @@ -321,16 +339,21 @@ export function createZooStreamRedactor( } const [first, ...rest] = pending.events const detectionText = canonicalizeRedactionText(pending.text) - const unterminatedSecret = - pending.pem || - /(?:password|secret|passphrase|passwd|pwd|credentials?|api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|authorization|bearer[-_. ]?token|client[-_. ]?secret|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token)["']?\s*[:=]\s*(?:"(?:\\.|[^"\\])*|'(?:\\.|[^'\\])*)?$/i.test( - detectionText, - ) + const continuedSecret = pending.secretQuote !== undefined + const secretQuote = continuedSecret + ? closesSecretQuote(detectionText, pending.secretQuote!) + ? undefined + : pending.secretQuote + : incompleteSecretQuote(detectionText) + const unterminatedSecret = pending.pem || secretQuote !== undefined const delta = replacement ?? (unterminatedSecret ? REDACTED : String(redactValue(pending.text))) pendingOutputs.delete(key) + if (secretQuote !== undefined) { + pendingOutputs.set(key, { events: [], text: "", pem: false, overflowed: false, secretQuote }) + } return first === undefined ? [] - : [{ ...first, delta }, ...rest.map((event) => ({ ...event, delta: "" }))] + : [{ ...first, delta: continuedSecret ? REDACTED : delta }, ...rest.map((event) => ({ ...event, delta: "" }))] } const emitOperation = (event: z.infer): ZooStreamEvent[] => [...pendingOutputs.keys()] @@ -891,9 +914,7 @@ export function validateStreamLifecycle( if (response !== undefined) consumedResponseCommands.add(response.id) } setScope(settledAsks, streamEvent.taskId).add(streamEvent.askId) - if (streamEvent.decision === "approve" || streamEvent.decision === "needs_input") { - approvalResumeCauses.set(streamEvent.taskId, streamEvent.requestId) - } + approvalResumeCauses.set(streamEvent.taskId, streamEvent.requestId) } if (streamEvent.type === "ask.abandoned") { if (!pendingAsks.get(streamEvent.taskId)?.delete(streamEvent.askId)) { @@ -1008,6 +1029,15 @@ export function validateStreamLifecycle( if (rootState !== expectedState[resultEvent.result.outcome]) { return { ok: false, code: "task_failed", message: "Root lifecycle state contradicts task.result" } } + const expectedInterruptedCause = + resultEvent.result.outcome === "cancelled" + ? "cancelled" + : resultEvent.result.outcome === "timed_out" + ? "timed_out" + : undefined + if (expectedInterruptedCause !== undefined && taskTerminalCauses.get(rootTaskId) !== expectedInterruptedCause) { + return { ok: false, code: "task_failed", message: "Root lifecycle cause contradicts task.result" } + } const allowedDescendantStates = { completed: new Set(["completed"]), needs_input: new Set(["waiting", "interrupted", "completed", "failed"]), diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 0c8655f10f..b1b311b3b6 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -8,7 +8,10 @@ const quotedUnquotedSecret = new RegExp( `((?:"${sensitiveKeyName}"|'${sensitiveKeyName}')\\s*:\\s*)(?!["'])[^\\s,;}]+`, "gi", ) -const ansiEscape = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g") +const terminalControl = new RegExp( + `(?:${String.fromCharCode(27)}\\][^${String.fromCharCode(7)}${String.fromCharCode(27)}]*(?:${String.fromCharCode(7)}|${String.fromCharCode(27)}\\\\)|${String.fromCharCode(27)}[PX^_][\\s\\S]*?${String.fromCharCode(27)}\\\\|${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]|[\\u0090\\u0098\\u009d\\u009e\\u009f][\\s\\S]*?\\u009c|\\u009b[0-?]*[ -/]*[@-~]|${String.fromCharCode(27)}[@-_])`, + "g", +) const secretPatterns: ReadonlyArray = [ /\b(?:Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, new RegExp(`--${sensitiveKeyName}(?:\\s*=\\s*|\\s+)${cliSecretValue}`, "gi"), @@ -22,6 +25,7 @@ const secretPatterns: ReadonlyArray = [ ] export type RedactedValue = null | undefined | boolean | number | string | RedactedValue[] | { [key: string]: RedactedValue } +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } function isSensitiveKey(key: string): boolean { const words = key @@ -36,6 +40,13 @@ function isSensitiveKey(key: string): boolean { /\bprivate key\b/.test(words) ) return true + if ( + /\b(?:api key|api token|access token|auth token|bearer token|id token|private key|refresh token|session token) value$/.test( + words, + ) + ) { + return true + } return /^(?:.* )?(?:api key|api token|access token|auth token|bearer token|id token|private key|refresh token|session token|token)$/.test( words, ) @@ -43,7 +54,7 @@ function isSensitiveKey(key: string): boolean { export function canonicalizeRedactionText(value: string): string { return value - .replace(ansiEscape, "") + .replace(terminalControl, "") .replace(/\\u([0-9a-f]{4})/gi, (_match, code: string) => String.fromCharCode(Number.parseInt(code, 16))) } @@ -62,6 +73,10 @@ export function redactText(value: string): string { return secretPatterns.reduce((redacted, pattern) => redacted.replace(pattern, REDACTED), structured) } +export function redactValue(value: Record): Record +export function redactValue(value: JsonValue[]): JsonValue[] +export function redactValue(value: JsonValue): JsonValue +export function redactValue(value: unknown, seen?: WeakSet): RedactedValue export function redactValue(value: unknown, seen = new WeakSet()): RedactedValue { if (value === null || typeof value === "boolean" || typeof value === "number") return value if (value === undefined) return undefined From 23b811bdc542363cf031abde590add69abe786b2 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 05:49:09 -0400 Subject: [PATCH 18/24] no-mistakes(review): Enforce actionable needs-input and cursor-safe redaction --- .../src/__tests__/contracts.test.ts | 20 +++++++++++++++++++ packages/zoo-protocol/src/parity.ts | 1 + packages/zoo-protocol/src/public-events.ts | 3 +++ packages/zoo-protocol/src/redaction.ts | 11 +++++++++- 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 9fc60a69d0..3db841ae8e 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -1464,6 +1464,19 @@ describe("public automation contracts", () => { resultEvent(8, { outcome: "needs_input", resumable: true }), ]), ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "task.lifecycle", { state: "waiting" }), + resultEvent(5, { outcome: "needs_input", resumable: true }), + ], + [startCommand], + startDone(), + ), + ).toMatchObject({ ok: false }) }) it("enforces operation, ask, message, and parent execution state", () => { @@ -1921,6 +1934,7 @@ describe("redaction contracts", () => { it("canonicalizes terminal controls and credential value suffixes", () => { expect(redactText(`API_\u001b]0;title\u0007TOKEN=hunter2`)).toBe("[REDACTED]") expect(redactText(`API_\u009dtitle\u009cTOKEN=hunter2`)).toBe("[REDACTED]") + expect(redactText("passX\bword=hunter2")).toBe("[REDACTED]") expect(redactValue({ accessTokenValue: "hunter2", apiKeyValue: "secret", maxTokenValue: 10 })).toEqual({ accessTokenValue: "[REDACTED]", apiKeyValue: "[REDACTED]", @@ -2001,6 +2015,12 @@ describe("deterministic parity oracle", () => { "root", ), ).toBe(false) + expect( + assertAuthoritativeRootResult( + [{ type: "task.result", taskId: "root", rootTaskId: "root", outcome: "needs_input" }], + "root", + ), + ).toBe(false) }) it("reports semantic drift without timestamps", () => { diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index 59e97f9b41..735b0dfcb6 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -359,6 +359,7 @@ export function assertAuthoritativeRootResult(trace: readonly SemanticTraceEntry result.taskId !== rootTaskId || result.rootTaskId !== rootTaskId || !zooOutcomeSchema.safeParse(result.outcome).success || + (result.outcome === "needs_input" && result.resumable !== true) || (result.resumable === true && !["needs_input", "cancelled", "timed_out"].includes(result.outcome ?? "")) ) { return false diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 98d6afca73..e9c2c0f3c5 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -988,6 +988,9 @@ export function validateStreamLifecycle( if (pendingAskCount > 0 && resultEvent.result.outcome !== "needs_input") { return { ok: false, code: "task_failed", message: "Terminal stream contains unresolved asks" } } + if (resultEvent.result.outcome === "needs_input" && pendingAskCount === 0) { + return { ok: false, code: "task_failed", message: "needs_input requires an unresolved actionable ask" } + } if ( resultEvent.result.outcome === "needs_input" && [...pendingAsks].some(([taskId, asks]) => asks.size > 0 && taskStates.get(taskId) !== "waiting") diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index b1b311b3b6..263c0f53c5 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -53,9 +53,18 @@ function isSensitiveKey(key: string): boolean { } export function canonicalizeRedactionText(value: string): string { - return value + const withoutTerminalControls = value .replace(terminalControl, "") .replace(/\\u([0-9a-f]{4})/gi, (_match, code: string) => String.fromCharCode(Number.parseInt(code, 16))) + const rendered: string[] = [] + for (const character of withoutTerminalControls) { + if (character === "\b") { + if (rendered.at(-1) !== "\n") rendered.pop() + } else { + rendered.push(character) + } + } + return rendered.join("") } export function redactText(value: string): string { From 995abbb1262d37cf0771a4eea30e49db24aea74f Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 06:00:50 -0400 Subject: [PATCH 19/24] no-mistakes(review): Tighten Zoo protocol validation and redaction contracts --- .../src/__tests__/contracts.test.ts | 106 ++++++++++---- .../zoo-protocol/src/command-lifecycle.ts | 98 +++++++++++++ packages/zoo-protocol/src/host-events.ts | 129 +++--------------- packages/zoo-protocol/src/parity.ts | 7 +- packages/zoo-protocol/src/public-events.ts | 17 ++- packages/zoo-protocol/src/redaction.ts | 6 +- 6 files changed, 215 insertions(+), 148 deletions(-) create mode 100644 packages/zoo-protocol/src/command-lifecycle.ts diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 3db841ae8e..d387c0a0e8 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -307,12 +307,21 @@ describe("strict host contracts", () => { }) it("pins host identity and sequence in the streaming parser", () => { - const parser = createHostEventStreamParser() + const parser = createHostEventStreamParser({ hostId: "host" }) + expect(() => + createHostEventStreamParser({ hostId: "negotiated-host" }).push({ + v: 1, + seq: 1, + hostId: "other", + type: "host.heartbeat", + monotonicMs: 1, + }), + ).toThrow("cannot span multiple hosts") parser.push({ v: 1, seq: 4, hostId: "host", type: "host.heartbeat", monotonicMs: 1 }) expect(() => parser.push({ v: 1, seq: 6, hostId: "host", type: "host.heartbeat", monotonicMs: 2 }), ).toThrow("Expected host sequence 5") - const otherHost = createHostEventStreamParser() + const otherHost = createHostEventStreamParser({ hostId: "host" }) otherHost.push({ v: 1, seq: 1, hostId: "host", type: "host.heartbeat", monotonicMs: 1 }) expect(() => otherHost.push({ v: 1, seq: 2, hostId: "other", type: "host.heartbeat", monotonicMs: 2 }), @@ -324,7 +333,7 @@ describe("strict host contracts", () => { }) it("does not mutate parser state for an invalid nested event", () => { - const parser = createHostEventStreamParser() + const parser = createHostEventStreamParser({ hostId: "host" }) const invalid = { v: 1, seq: 1, @@ -363,10 +372,10 @@ describe("strict host contracts", () => { data: { commandType: "host.shutdown" }, }), ] - expect(validateCommandLifecycle([command], events)).toEqual({ ok: true }) - expect(validateCommandLifecycle([command], [...events, events[1]!])).toMatchObject({ ok: false }) - expect(validateCommandLifecycle([command], [events[1]!, events[0]!])).toMatchObject({ ok: false }) - expect(validateCommandLifecycle([command], [events[0]!, { ...events[1]!, seq: 3 }])).toMatchObject({ + expect(validateCommandLifecycle([command], events, "host")).toEqual({ ok: true }) + expect(validateCommandLifecycle([command], [...events, events[1]!], "host")).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [events[1]!, events[0]!], "host")).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [events[0]!, { ...events[1]!, seq: 3 }], "host")).toMatchObject({ ok: false, }) }) @@ -411,11 +420,11 @@ describe("strict host contracts", () => { commandId: "cmd", data: { commandType: "host.shutdown" }, }) - expect(validateCommandLifecycle([command], [acknowledgement, completion])).toEqual({ ok: true }) - expect(validateCommandLifecycle([command], [acknowledgement, mismatchedIdentity])).toMatchObject({ ok: false }) - expect(validateCommandLifecycle([command], [acknowledgement, mismatchedType])).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [acknowledgement, completion], "host-a")).toEqual({ ok: true }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedIdentity], "host-a")).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedType], "host-a")).toMatchObject({ ok: false }) expect( - validateCommandLifecycle([command], [acknowledgement, { ...completion, hostId: "host-b" }]), + validateCommandLifecycle([command], [acknowledgement, { ...completion, hostId: "host-b" }], "host-a"), ).toMatchObject({ ok: false, }) @@ -449,7 +458,7 @@ describe("strict host contracts", () => { seq: 2, data: { commandType: "task.start", task: { rootTaskId: "root", taskId: "child" } }, }) - expect(validateCommandLifecycle([start], [acknowledgement, childCompletion])).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([start], [acknowledgement, childCompletion], "host")).toMatchObject({ ok: false }) }) it("does not reuse root identities across successful starts", () => { @@ -473,7 +482,7 @@ describe("strict host contracts", () => { data: { commandType: "task.start", task: { rootTaskId: "root", taskId: "root" } }, }), ]) - expect(validateCommandLifecycle(commands, events)).toMatchObject({ ok: false }) + expect(validateCommandLifecycle(commands, events, "host")).toMatchObject({ ok: false }) }) it("redacts command errors before they cross the host boundary", () => { @@ -490,7 +499,7 @@ describe("strict host contracts", () => { }) it("statefully redacts normalized terminal output at the host boundary", () => { - const parser = createHostEventStreamParser() + const parser = createHostEventStreamParser({ hostId: "host" }) const envelope = (seq: number, delta: string) => ({ v: 1, seq, @@ -518,7 +527,7 @@ describe("strict host contracts", () => { expect(events.map((event) => (event.type === "event" && event.event.type === "terminal.output" ? event.event.delta : "")).join("")) .toBe("Build succeeded\n[REDACTED]") - const interleavedParser = createHostEventStreamParser() + const interleavedParser = createHostEventStreamParser({ hostId: "host" }) const interleaved = [ ...interleavedParser.push(envelope(1, "API_TOKEN=")), ...interleavedParser.push({ v: 1, seq: 2, hostId: "host", type: "host.heartbeat", monotonicMs: 1 }), @@ -554,17 +563,17 @@ describe("strict host contracts", () => { delta, }, }) - const deadlineParser = createHostEventStreamParser({ maxPendingMs: 10, now: () => now }) + const deadlineParser = createHostEventStreamParser({ hostId: "host", maxPendingMs: 10, now: () => now }) expect(deadlineParser.push(terminalEnvelope(1, "unterminated"))).toEqual([]) now = 10 expect(deadlineParser.tick()).toMatchObject([{ seq: 1, event: { delta: "[REDACTED]" } }]) - const byteParser = createHostEventStreamParser({ maxQueuedBytes: 1 }) + const byteParser = createHostEventStreamParser({ hostId: "host", maxQueuedBytes: 1 }) expect(byteParser.push(terminalEnvelope(1, "unterminated"))).toMatchObject([ { seq: 1, event: { delta: "[REDACTED]" } }, ]) - const scopedParser = createHostEventStreamParser({ maxPendingMs: 10, now: () => now }) + const scopedParser = createHostEventStreamParser({ hostId: "host", maxPendingMs: 10, now: () => now }) now = 0 expect(scopedParser.push(terminalEnvelope(1, "unterminated"))).toEqual([]) now = 10 @@ -575,10 +584,25 @@ describe("strict host contracts", () => { event: { ...terminalEnvelope(2, "harmless\n").event, toolCallId: "other-terminal" }, }), ).toMatchObject([{ seq: 2, event: { delta: "harmless\n" } }]) + + now = 0 + const multipleParser = createHostEventStreamParser({ hostId: "host", maxPendingMs: 10, now: () => now }) + expect(multipleParser.push(terminalEnvelope(1, "first"))).toEqual([]) + expect( + multipleParser.push({ + ...terminalEnvelope(2, "second"), + event: { ...terminalEnvelope(2, "second").event, toolCallId: "other-terminal" }, + }), + ).toEqual([]) + now = 10 + expect(multipleParser.tick()).toMatchObject([ + { seq: 1, event: { delta: "[REDACTED]" } }, + { seq: 2, event: { delta: "[REDACTED]" } }, + ]) }) it("preserves host envelopes across concurrent root streams", () => { - const parser = createHostEventStreamParser() + const parser = createHostEventStreamParser({ hostId: "host" }) const envelope = (hostSeq: number, rootTaskId: string, delta: string) => ({ v: 1, seq: hostSeq, @@ -643,8 +667,8 @@ describe("strict host contracts", () => { commandId: "history", data: { commandType: "history.list", workspace: "/other", tasks: [] }, }) - expect(validateCommandLifecycle([command], [acknowledgement, completion])).toEqual({ ok: true }) - expect(validateCommandLifecycle([command], [acknowledgement, mismatchedCompletion])).toMatchObject({ + expect(validateCommandLifecycle([command], [acknowledgement, completion], "host")).toEqual({ ok: true }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedCompletion], "host")).toMatchObject({ ok: false, }) }) @@ -754,6 +778,25 @@ describe("public automation contracts", () => { expect(validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand], startDone())).toEqual({ ok: true, }) + const history = hostCommandSchema.parse({ + v: 1, + id: "history", + type: "history.list", + workspace: "/workspace", + }) + expect( + validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand, history], startDone()), + ).toMatchObject({ ok: false }) + expect( + validateStreamLifecycle( + [initEvent, created, started, completed, result], + [startCommand], + [ + ...startDone(), + hostEventSchema.parse({ v: 1, seq: 3, hostId: "host", type: "command.ack", commandId: "unknown" }), + ], + ), + ).toMatchObject({ ok: false }) expect( validateStreamLifecycle( [initEvent, created, started, completed, resultEvent(5, { workspace: "/other" })], @@ -1686,8 +1729,10 @@ describe("redaction contracts", () => { proxyAuthorization: "[REDACTED]", }) expect(redactText("password: abc,def")).toBe("[REDACTED]") - expect(redactText('{"api\\u005fkey":"hunter2"}')).toBe('{"api_key":"[REDACTED]"}') + expect(redactText('{"api\\u005fkey":"hunter2"}')).toBe("[REDACTED]") expect(redactText("API_\u001b[31mTOKEN=abcdefgh")).toBe("[REDACTED]") + expect(redactText('{"literal":"\\u0061"}')).toBe('{"literal":"\\u0061"}') + expect(redactText("safe\u001b[31m text")).toBe("safe\u001b[31m text") expect(redactText("github_pat_1234567890abcdef")).toBe("[REDACTED]") expect(redactValue({ sessionCookie: "abc", cookieJar: "def", privateKeyPem: "ghi" })).toEqual({ sessionCookie: "[REDACTED]", @@ -1820,7 +1865,7 @@ describe("redaction contracts", () => { "[REDACTED]\n", ) - const parser = createHostEventStreamParser({ maxPendingBytes: 4 }) + const parser = createHostEventStreamParser({ hostId: "host", maxPendingBytes: 4 }) const overflow = parser.push({ v: 1, seq: 1, @@ -1830,7 +1875,7 @@ describe("redaction contracts", () => { }) expect(overflow[0]?.type === "event" && overflow[0].event.type === "terminal.output" && overflow[0].event.delta) .toBe("[REDACTED]") - const cappedParser = createHostEventStreamParser({ maxPendingStreams: 1 }) + const cappedParser = createHostEventStreamParser({ hostId: "host", maxPendingStreams: 1 }) const pending = (seq: number, toolCallId: string) => ({ v: 1, seq, @@ -1850,7 +1895,7 @@ describe("redaction contracts", () => { ), ).toBe(true) let now = 0 - const deadlineParser = createHostEventStreamParser({ maxPendingMs: 10, now: () => now }) + const deadlineParser = createHostEventStreamParser({ hostId: "host", maxPendingMs: 10, now: () => now }) expect(deadlineParser.push(pending(1, "deadline"))).toEqual([]) now = 10 const released = deadlineParser.push({ @@ -1888,6 +1933,13 @@ describe("redaction contracts", () => { ]) expect(multilineQuoted.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) .toBe("[REDACTED][REDACTED]harmless\n") + const multilineCookie = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: 'cookie="first\n' }, + { ...terminal, seq: 2, delta: 'second"\n' }, + { ...terminal, seq: 3, delta: "harmless\n" }, + ]) + expect(multilineCookie.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) + .toBe("[REDACTED][REDACTED]harmless\n") const ansiPem = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: "-----BEGIN\u001b[31m PRIVATE KEY-----\n" }, { ...terminal, seq: 2, delta: "private-body\n" }, @@ -2037,6 +2089,10 @@ describe("deterministic parity oracle", () => { expected: [], }) expect(timeout.at(-1)).toMatchObject({ outcome: "timed_out", errorCode: "task_timed_out" }) + expect(timeout.find((entry) => entry.type === "task.lifecycle")).toMatchObject({ + state: "interrupted", + cause: "timed_out", + }) expect( assertAuthoritativeRootResult( [{ type: "task.result", taskId: "root", rootTaskId: "root", outcome: "timed_out" }], diff --git a/packages/zoo-protocol/src/command-lifecycle.ts b/packages/zoo-protocol/src/command-lifecycle.ts new file mode 100644 index 0000000000..e3c81abf3f --- /dev/null +++ b/packages/zoo-protocol/src/command-lifecycle.ts @@ -0,0 +1,98 @@ +import type { HostCommand } from "./host-commands.js" +import type { HostEvent } from "./host-events.js" + +export function validateCommandLifecycle( + commands: readonly HostCommand[], + events: readonly HostEvent[], + hostId: string, +): { ok: true } | { ok: false; commandId: string; message: string } { + const commandById = new Map() + const startedRoots = new Set() + for (const command of commands) { + if (commandById.has(command.id)) { + return { ok: false, commandId: command.id, message: "Command IDs must be unique" } + } + commandById.set(command.id, command) + } + + for (const [index, event] of events.entries()) { + if (event.hostId !== hostId) { + const commandId = "commandId" in event ? event.commandId : commands[0]?.id ?? "unknown" + return { ok: false, commandId, message: "Command lifecycle cannot span multiple hosts" } + } + if ( + (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && + !commandById.has(event.commandId) + ) { + return { ok: false, commandId: event.commandId, message: "Response references an unknown command" } + } + if (index > 0) { + const expected = events[index - 1]!.seq + 1 + if (event.seq !== expected) { + const commandId = "commandId" in event ? event.commandId : commands[0]?.id ?? "unknown" + return { ok: false, commandId, message: `Expected host sequence ${expected}` } + } + } + } + + for (const command of commands) { + const commandId = command.id + const commandEvents = events.filter( + (event) => + (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && + event.commandId === commandId, + ) + const acknowledgements = commandEvents.filter((event) => event.type === "command.ack") + const terminals = commandEvents.filter((event) => event.type === "command.done" || event.type === "command.error") + if (acknowledgements.length !== 1) { + return { ok: false, commandId, message: `Expected one ACK, received ${acknowledgements.length}` } + } + if (terminals.length !== 1) { + return { ok: false, commandId, message: `Expected one DONE or ERROR, received ${terminals.length}` } + } + if (acknowledgements[0]!.seq >= terminals[0]!.seq) { + return { ok: false, commandId, message: "ACK must precede DONE or ERROR" } + } + const terminal = terminals[0]! + if (terminal.type === "command.done") { + const data = terminal.data + const matches = (() => { + switch (command.type) { + case "task.start": + return data.commandType === command.type && data.task.taskId === data.task.rootTaskId + case "task.resume": + return ( + data.commandType === command.type && + data.task.taskId === command.taskId && + data.task.rootTaskId === command.rootTaskId + ) + case "task.input": + return data.commandType === command.type && data.taskId === command.taskId + case "ask.respond": + return data.commandType === command.type && data.taskId === command.taskId && data.askId === command.askId + case "task.cancel": + return data.commandType === command.type && data.rootTaskId === command.rootTaskId + case "history.list": + return ( + data.commandType === command.type && + data.workspace === command.workspace && + data.tasks.every((task) => task.workspace === command.workspace) + ) + case "host.snapshot": + case "host.shutdown": + return data.commandType === command.type + } + })() + if (!matches) { + return { ok: false, commandId, message: "DONE payload does not match the originating command" } + } + if (command.type === "task.start" && data.commandType === "task.start") { + if (startedRoots.has(data.task.rootTaskId)) { + return { ok: false, commandId, message: "Successful task starts must return unique root task IDs" } + } + startedRoots.add(data.task.rootTaskId) + } + } + } + return { ok: true } +} diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index 97493cc87d..d3f06806c7 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -1,6 +1,5 @@ import { z } from "zod" -import type { HostCommand } from "./host-commands.js" import { zooErrorSchema } from "./outcomes.js" import { createZooStreamRedactor, @@ -124,6 +123,7 @@ export type HostEventStreamParser = { export function createHostEventStreamParser( options: { + hostId: string maxPendingBytes?: number maxPendingEvents?: number maxPendingStreams?: number @@ -131,7 +131,7 @@ export function createHostEventStreamParser( maxQueuedBytes?: number maxPendingMs?: number now?: () => number - } = {}, + }, ): HostEventStreamParser { const redactor = createZooStreamRedactor(options) const maxQueuedEvents = options.maxQueuedEvents ?? 512 @@ -147,7 +147,7 @@ export function createHostEventStreamParser( const queue: QueueEntry[] = [] let queuedBytes = 0 const envelopes = new Map() - let pinnedHostId: string | undefined + const pinnedHostId = options.hostId let lastSeq: number | undefined const eventKey = (event: RawZooStreamEvent) => JSON.stringify([ @@ -188,17 +188,21 @@ export function createHostEventStreamParser( return ready } const releaseBlockedQueue = (): HostEvent[] => { - const oldest = queue[0] - if ( - oldest !== undefined && - (oldest.output === undefined && - (queue.length >= maxQueuedEvents || - queuedBytes >= maxQueuedBytes || - now() - oldest.enqueuedAt >= maxPendingMs)) - ) { + const ready: HostEvent[] = [] + while (true) { + ready.push(...drain()) + const oldest = queue[0] + if ( + oldest === undefined || + oldest.output !== undefined || + (queue.length < maxQueuedEvents && + queuedBytes < maxQueuedBytes && + now() - oldest.enqueuedAt < maxPendingMs) + ) { + return ready + } assign(redactor.failClosed(oldest.envelope?.event)) } - return drain() } const sanitizeNonEvent = (event: z.infer): HostEvent => event.type === "command.error" @@ -215,7 +219,7 @@ export function createHostEventStreamParser( return { push(input) { const event = rawHostEventDiscriminatedSchema.parse(input) - if (pinnedHostId !== undefined && event.hostId !== pinnedHostId) { + if (event.hostId !== pinnedHostId) { throw new Error("Host event stream cannot span multiple hosts") } if (lastSeq !== undefined && !validateMonotonicSequence(lastSeq, event.seq).ok) { @@ -230,7 +234,6 @@ export function createHostEventStreamParser( } catch { bytes = maxQueuedBytes } - pinnedHostId ??= event.hostId lastSeq = event.seq const released = releaseBlockedQueue() const entry: QueueEntry = { enqueuedAt: now(), bytes } @@ -268,100 +271,4 @@ export function validateMonotonicSequence( : { ok: false, expected } } -export function validateCommandLifecycle( - commands: readonly HostCommand[], - events: readonly HostEvent[], -): { ok: true } | { ok: false; commandId: string; message: string } { - const commandById = new Map() - const startedRoots = new Set() - for (const command of commands) { - if (commandById.has(command.id)) { - return { ok: false, commandId: command.id, message: "Command IDs must be unique" } - } - commandById.set(command.id, command) - } - - const firstHostId = events[0]?.hostId - for (const [index, event] of events.entries()) { - if (event.hostId !== firstHostId) { - const commandId = "commandId" in event ? event.commandId : commands[0]?.id ?? "unknown" - return { ok: false, commandId, message: "Command lifecycle cannot span multiple hosts" } - } - if ( - (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && - !commandById.has(event.commandId) - ) { - return { ok: false, commandId: event.commandId, message: "Response references an unknown command" } - } - if (index > 0) { - const expected = events[index - 1]!.seq + 1 - if (event.seq !== expected) { - const commandId = "commandId" in event ? event.commandId : commands[0]?.id ?? "unknown" - return { ok: false, commandId, message: `Expected host sequence ${expected}` } - } - } - } - - for (const command of commands) { - const commandId = command.id - const commandEvents = events.filter( - (event) => - (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && - event.commandId === commandId, - ) - const acknowledgements = commandEvents.filter((event) => event.type === "command.ack") - const terminals = commandEvents.filter( - (event) => event.type === "command.done" || event.type === "command.error", - ) - if (acknowledgements.length !== 1) { - return { ok: false, commandId, message: `Expected one ACK, received ${acknowledgements.length}` } - } - if (terminals.length !== 1) { - return { ok: false, commandId, message: `Expected one DONE or ERROR, received ${terminals.length}` } - } - if (acknowledgements[0]!.seq >= terminals[0]!.seq) { - return { ok: false, commandId, message: "ACK must precede DONE or ERROR" } - } - const terminal = terminals[0]! - if (terminal.type === "command.done") { - const data = terminal.data - const matches = (() => { - switch (command.type) { - case "task.start": - return data.commandType === command.type && data.task.taskId === data.task.rootTaskId - case "task.resume": - return ( - data.commandType === command.type && - data.task.taskId === command.taskId && - data.task.rootTaskId === command.rootTaskId - ) - case "task.input": - return data.commandType === command.type && data.taskId === command.taskId - case "ask.respond": - return data.commandType === command.type && data.taskId === command.taskId && data.askId === command.askId - case "task.cancel": - return data.commandType === command.type && data.rootTaskId === command.rootTaskId - case "history.list": - return ( - data.commandType === command.type && - data.workspace === command.workspace && - data.tasks.every((task) => task.workspace === command.workspace) - ) - case "host.snapshot": - case "host.shutdown": - return data.commandType === command.type - } - })() - if (!matches) { - return { ok: false, commandId, message: "DONE payload does not match the originating command" } - } - if (command.type === "task.start" && data.commandType === "task.start") { - if (startedRoots.has(data.task.rootTaskId)) { - return { ok: false, commandId, message: "Successful task starts must return unique root task IDs" } - } - startedRoots.add(data.task.rootTaskId) - } - } - } - return { ok: true } -} +export { validateCommandLifecycle } from "./command-lifecycle.js" diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index 735b0dfcb6..a3e9332db9 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -15,6 +15,7 @@ export type SemanticTraceEntry = { toolName?: string toolArguments?: Record state?: "running" | "waiting" | "interrupted" | "completed" | "failed" + cause?: "cancelled" | "timed_out" | "failed" askId?: string decision?: "approve" | "reject" | "needs_input" source?: "policy" | "user" | "auto" | "deny" @@ -129,7 +130,7 @@ export const parityScenarios: readonly ParityScenario[] = [ expected: [ { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Cancel deterministically." }, { type: "task.started", rootTaskId: "root", taskId: "root" }, - { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted", cause: "cancelled" }, { type: "task.result", rootTaskId: "root", @@ -294,7 +295,7 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly ) throw new Error(`Invalid cancellation fixture: ${turn}`) usedRequestIds.add(requestId) - trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted" }) + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted", cause: "cancelled" }) result = { type: "task.result", rootTaskId: "root", @@ -320,7 +321,7 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly if (errorCode !== "task_timed_out" && errorCode !== "cleanup_timed_out") { throw new Error(`Invalid timeout fixture: ${turn}`) } - trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted" }) + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "interrupted", cause: "timed_out" }) result = { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "timed_out", errorCode } terminalReached = true continue diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index e9c2c0f3c5..d66bbfa93b 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -1,9 +1,10 @@ import { z } from "zod" +import { validateCommandLifecycle } from "./command-lifecycle.js" import type { HostCommand } from "./host-commands.js" import type { HostEvent } from "./host-events.js" import { failedErrorCodeSchema, zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" -import { canonicalizeRedactionText, REDACTED, redactValue, type JsonValue } from "./redaction.js" +import { canonicalizeRedactionText, isSensitiveKey, REDACTED, redactValue, type JsonValue } from "./redaction.js" import { ZOO_HOST_PROTOCOL_VERSION, ZOO_PUBLIC_SCHEMA_VERSION, @@ -318,14 +319,12 @@ export function createZooStreamRedactor( return openLabels.length > 0 } const incompleteSecretQuote = (text: string): '"' | "'" | undefined => { - const match = text.match( - /(?:password|secret|passphrase|passwd|pwd|credentials?|api[-_. ]?(?:key|token)|access[-_. ]?token|auth[-_. ]?token|authorization|bearer[-_. ]?token|client[-_. ]?secret|private[-_. ]?key|refresh[-_. ]?token|session[-_. ]?token)["']?\s*[:=]\s*(["'])(?:\\.|[^\\])*$/i, - ) - if (match?.[1] !== '"' && match?.[1] !== "'") return undefined + const match = text.match(/(?:^|[,{;\s])["']?([A-Za-z0-9_. -]+)["']?\s*[:=]\s*(["'])(?:\\.|[^\\])*$/i) + if (match === null || !isSensitiveKey(match[1] ?? "") || (match[2] !== '"' && match[2] !== "'")) return undefined const opening = /[:=]\s*(["'])/.exec(match[0]) if (opening === null) return undefined const value = match[0].slice(opening.index + opening[0].length) - return new RegExp(`(?:^|[^\\\\])${match[1]}`).test(value) ? undefined : match[1] + return new RegExp(`(?:^|[^\\\\])${match[2]}`).test(value) ? undefined : match[2] } const closesSecretQuote = (text: string, quote: '"' | "'"): boolean => new RegExp(`(?:^|[^\\\\])${quote}`).test(text) @@ -394,7 +393,7 @@ export function createZooStreamRedactor( pending.events.push(event) pending.text += event.delta pending.pem = hasUnmatchedPem(canonicalizeRedactionText(pending.text)) - if (pending.text.length > maxPendingBytes || pending.events.length > maxPendingEvents) { + if (new TextEncoder().encode(pending.text).byteLength > maxPendingBytes || pending.events.length > maxPendingEvents) { pending.overflowed = true const redacted = emit(key, REDACTED) pendingOutputs.set(key, { events: [], text: "", pem: false, overflowed: true }) @@ -490,6 +489,10 @@ export function validateStreamLifecycle( return { ok: false, code: "protocol_gap", message: `Expected sequence ${expected}` } } } + const commandLifecycle = validateCommandLifecycle(commands, commandEvents, hostId) + if (!commandLifecycle.ok) { + return { ok: false, code: "protocol_gap", message: commandLifecycle.message } + } const results = events.filter((event) => event.type === "task.result") if (results.length !== 1 || events.at(-1)?.type !== "task.result") { return { ok: false, code: "task_failed", message: "Accepted stream must end with exactly one task.result" } diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 263c0f53c5..8d05fe354e 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -27,7 +27,7 @@ const secretPatterns: ReadonlyArray = [ export type RedactedValue = null | undefined | boolean | number | string | RedactedValue[] | { [key: string]: RedactedValue } export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } -function isSensitiveKey(key: string): boolean { +export function isSensitiveKey(key: string): boolean { const words = key .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .replace(/[^A-Za-z0-9]+/g, " ") @@ -79,7 +79,9 @@ export function redactText(value: string): string { .replace(doubleQuotedSecret, `$1"${REDACTED}"`) .replace(singleQuotedSecret, `$1'${REDACTED}'`) .replace(quotedUnquotedSecret, `$1${REDACTED}`) - return secretPatterns.reduce((redacted, pattern) => redacted.replace(pattern, REDACTED), structured) + const redacted = secretPatterns.reduce((text, pattern) => text.replace(pattern, REDACTED), structured) + if (canonical !== value) return redacted === canonical ? value : REDACTED + return redacted } export function redactValue(value: Record): Record From dce472e04ba6637803ab16d8f93198e28d216170 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 06:12:45 -0400 Subject: [PATCH 20/24] no-mistakes(review): Tighten Zoo protocol redaction and session causality --- .../src/__tests__/contracts.test.ts | 113 +++++++++++++++++- packages/zoo-protocol/src/host-events.ts | 17 +++ packages/zoo-protocol/src/public-events.ts | 90 +++++++++++--- packages/zoo-protocol/src/redaction.ts | 13 ++ 4 files changed, 215 insertions(+), 18 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index d387c0a0e8..9c83809f66 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -19,7 +19,7 @@ import { validateMonotonicSequence, validateNegotiatedStreamSession, validateParentHello, - validateStreamLifecycle, + validateStreamLifecycle as validateStreamLifecycleContract, zooRunResultSchema, zooStreamEventSchema, zooStreamSchema, @@ -34,6 +34,25 @@ const startCommand = hostCommandSchema.parse({ prompt: "Start", }) +function validateStreamLifecycle( + events: Parameters[0], + commands: Parameters[1] = [], + commandEvents: Parameters[2] = [], + scope?: Parameters[3], +) { + const lastHostSeq = commandEvents.reduce((maximum, event) => Math.max(maximum, event.seq), 0) + const eventEnvelopes = events.map((event, index) => + hostEventSchema.parse({ + v: 1, + seq: lastHostSeq + index + 1, + hostId: event.hostId, + type: "event", + event, + }), + ) + return validateStreamLifecycleContract(events, commands, [...commandEvents, ...eventEnvelopes], scope) +} + const initEvent = zooStreamEventSchema.parse({ v: 1, seq: 1, @@ -332,6 +351,30 @@ describe("strict host contracts", () => { ).toBe(false) }) + it("rejects oversized terminal output before stream parsing", () => { + const parser = createHostEventStreamParser({ hostId: "host", maxInputBytes: 4 }) + expect(() => + parser.push({ + v: 1, + seq: 1, + hostId: "host", + type: "event", + event: { + v: 1, + seq: 1, + timestamp, + hostId: "host", + rootTaskId: "root", + taskId: "root", + type: "terminal.output", + toolCallId: "terminal", + stream: "stdout", + delta: "12345", + }, + }), + ).toThrow("input limit") + }) + it("does not mutate parser state for an invalid nested event", () => { const parser = createHostEventStreamParser({ hostId: "host" }) const invalid = { @@ -841,6 +884,60 @@ describe("public automation contracts", () => { }) }) + it("scopes interleaved host commands and requires ACK before public effects", () => { + const stream = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ] + const otherStart = hostCommandSchema.parse({ + v: 1, + id: "other-start", + type: "task.start", + workspace: "/other", + prompt: "Other", + }) + const interleaved = [ + hostEventSchema.parse({ v: 1, seq: 1, hostId: "host", type: "command.ack", commandId: "start" }), + hostEventSchema.parse({ v: 1, seq: 2, hostId: "host", type: "command.ack", commandId: "other-start" }), + hostEventSchema.parse({ + v: 1, + seq: 3, + hostId: "host", + type: "command.done", + commandId: "other-start", + data: { commandType: "task.start", task: { rootTaskId: "other-root", taskId: "other-root" } }, + }), + hostEventSchema.parse({ + v: 1, + seq: 4, + hostId: "host", + type: "command.done", + commandId: "start", + data: { commandType: "task.start", task: { rootTaskId: "root", taskId: "root" } }, + }), + ] + expect( + validateStreamLifecycle(stream, [startCommand, otherStart], interleaved, { + initiatingCommandId: "start", + commandIds: ["start"], + }), + ).toEqual({ ok: true }) + + const eventEnvelopes = stream.map((event, index) => + hostEventSchema.parse({ v: 1, seq: index + 1, hostId: "host", type: "event", event }), + ) + const lateAckWindow = [ + eventEnvelopes[0]!, + eventEnvelopes[1]!, + ...startDone("start", "root", 3), + ...eventEnvelopes.slice(2).map((event, index) => ({ ...event, seq: index + 5 })), + ] + expect(validateStreamLifecycleContract(stream, [startCommand], lateAckWindow)).toMatchObject({ ok: false }) + }) + it("validates task-tree settlement and approval command causation", () => { const rootCreated = taskEvent(2, "task.created") const rootStarted = taskEvent(3, "task.started") @@ -1940,6 +2037,13 @@ describe("redaction contracts", () => { ]) expect(multilineCookie.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) .toBe("[REDACTED][REDACTED]harmless\n") + const multilineAssignment = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "API_TOKEN=\n" }, + { ...terminal, seq: 2, delta: "hunter2\n" }, + { ...terminal, seq: 3, delta: "harmless\n" }, + ]) + expect(multilineAssignment.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) + .toBe("[REDACTED][REDACTED]harmless\n") const ansiPem = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: "-----BEGIN\u001b[31m PRIVATE KEY-----\n" }, { ...terminal, seq: 2, delta: "private-body\n" }, @@ -1987,6 +2091,13 @@ describe("redaction contracts", () => { expect(redactText(`API_\u001b]0;title\u0007TOKEN=hunter2`)).toBe("[REDACTED]") expect(redactText(`API_\u009dtitle\u009cTOKEN=hunter2`)).toBe("[REDACTED]") expect(redactText("passX\bword=hunter2")).toBe("[REDACTED]") + expect(redactText("passX\u001b[1Dword=hunter2")).toBe("[REDACTED]") + expect(redactValue({ apikey: "one", apitoken: "two", authtoken: "three", accesstoken: "four" })).toEqual({ + apikey: "[REDACTED]", + apitoken: "[REDACTED]", + authtoken: "[REDACTED]", + accesstoken: "[REDACTED]", + }) expect(redactValue({ accessTokenValue: "hunter2", apiKeyValue: "secret", maxTokenValue: 10 })).toEqual({ accessTokenValue: "[REDACTED]", apiKeyValue: "[REDACTED]", diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index d3f06806c7..f3f75063ee 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -129,6 +129,7 @@ export function createHostEventStreamParser( maxPendingStreams?: number maxQueuedEvents?: number maxQueuedBytes?: number + maxInputBytes?: number maxPendingMs?: number now?: () => number }, @@ -136,6 +137,7 @@ export function createHostEventStreamParser( const redactor = createZooStreamRedactor(options) const maxQueuedEvents = options.maxQueuedEvents ?? 512 const maxQueuedBytes = options.maxQueuedBytes ?? 1024 * 1024 + const maxInputBytes = options.maxInputBytes ?? 1024 * 1024 const maxPendingMs = options.maxPendingMs ?? 1_000 const now = options.now ?? Date.now type QueueEntry = { @@ -149,6 +151,20 @@ export function createHostEventStreamParser( const envelopes = new Map() const pinnedHostId = options.hostId let lastSeq: number | undefined + const inputSize = (value: unknown, seen = new WeakSet()): number => { + if (value === null || value === undefined) return 4 + if (typeof value === "string") return new TextEncoder().encode(value).byteLength + if (typeof value !== "object") return 8 + if (seen.has(value)) return maxInputBytes + 1 + seen.add(value) + let bytes = 2 + for (const [key, entry] of Object.entries(value)) { + bytes += new TextEncoder().encode(key).byteLength + inputSize(entry, seen) + if (bytes > maxInputBytes) return bytes + } + seen.delete(value) + return bytes + } const eventKey = (event: RawZooStreamEvent) => JSON.stringify([ event.hostId, @@ -218,6 +234,7 @@ export function createHostEventStreamParser( return { push(input) { + if (inputSize(input) > maxInputBytes) throw new Error("Host event exceeds the input limit") const event = rawHostEventDiscriminatedSchema.parse(input) if (event.hostId !== pinnedHostId) { throw new Error("Host event stream cannot span multiple hosts") diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index d66bbfa93b..3a40e51446 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -4,7 +4,14 @@ import { validateCommandLifecycle } from "./command-lifecycle.js" import type { HostCommand } from "./host-commands.js" import type { HostEvent } from "./host-events.js" import { failedErrorCodeSchema, zooErrorSchema, zooOutcomeSchema } from "./outcomes.js" -import { canonicalizeRedactionText, isSensitiveKey, REDACTED, redactValue, type JsonValue } from "./redaction.js" +import { + canonicalizeRedactionText, + isSensitiveKey, + requiresFailClosedRedaction, + REDACTED, + redactValue, + type JsonValue, +} from "./redaction.js" import { ZOO_HOST_PROTOCOL_VERSION, ZOO_PUBLIC_SCHEMA_VERSION, @@ -304,6 +311,7 @@ export function createZooStreamRedactor( pem: boolean overflowed: boolean secretQuote?: '"' | "'" + secretValueContinuation?: boolean } const pendingOutputs = new Map() let failClosedAll = false @@ -328,6 +336,11 @@ export function createZooStreamRedactor( } const closesSecretQuote = (text: string, quote: '"' | "'"): boolean => new RegExp(`(?:^|[^\\\\])${quote}`).test(text) + const incompleteSecretValue = (text: string): boolean => { + const line = text.replace(/\r?\n$/, "").split(/\r?\n/).at(-1) ?? "" + const match = line.match(/(?:^|[,{;\s])["']?([A-Za-z0-9_. -]+)["']?\s*[:=]\s*$/i) + return match !== null && isSensitiveKey(match[1] ?? "") + } const emit = (key: string, replacement?: string): ZooStreamEvent[] => { const pending = pendingOutputs.get(key) @@ -339,20 +352,28 @@ export function createZooStreamRedactor( const [first, ...rest] = pending.events const detectionText = canonicalizeRedactionText(pending.text) const continuedSecret = pending.secretQuote !== undefined + const continuedValue = pending.secretValueContinuation === true const secretQuote = continuedSecret ? closesSecretQuote(detectionText, pending.secretQuote!) ? undefined : pending.secretQuote : incompleteSecretQuote(detectionText) - const unterminatedSecret = pending.pem || secretQuote !== undefined + const secretValueContinuation = !continuedValue && incompleteSecretValue(detectionText) + const unterminatedSecret = + pending.pem || secretQuote !== undefined || secretValueContinuation || requiresFailClosedRedaction(pending.text) const delta = replacement ?? (unterminatedSecret ? REDACTED : String(redactValue(pending.text))) pendingOutputs.delete(key) if (secretQuote !== undefined) { pendingOutputs.set(key, { events: [], text: "", pem: false, overflowed: false, secretQuote }) + } else if (secretValueContinuation) { + pendingOutputs.set(key, { events: [], text: "", pem: false, overflowed: false, secretValueContinuation: true }) } return first === undefined ? [] - : [{ ...first, delta: continuedSecret ? REDACTED : delta }, ...rest.map((event) => ({ ...event, delta: "" }))] + : [ + { ...first, delta: continuedSecret || continuedValue ? REDACTED : delta }, + ...rest.map((event) => ({ ...event, delta: "" })), + ] } const emitOperation = (event: z.infer): ZooStreamEvent[] => [...pendingOutputs.keys()] @@ -463,6 +484,7 @@ export function validateStreamLifecycle( events: readonly ZooStreamEvent[], commands: readonly HostCommand[] = [], commandEvents: readonly HostEvent[] = [], + lifecycleScope?: { initiatingCommandId: string; commandIds?: readonly string[] }, ): { ok: true } | { ok: false; code: "protocol_gap" | "task_failed"; message: string } { if (events[0]?.type !== "system.init") { return { ok: false, code: "task_failed", message: "Stream must start with system.init" } @@ -503,8 +525,20 @@ export function validateStreamLifecycle( return { ok: false, code: "task_failed", message: "task.result must identify the authoritative root task" } } const resumedEvents = events.filter((streamEvent) => streamEvent.type === "task.resumed") - const startCommands = commands.filter((command) => command.type === "task.start") - const resumeCommands = commands.filter((command) => command.type === "task.resume") + const scopedCommandIds = + lifecycleScope?.commandIds === undefined ? undefined : new Set(lifecycleScope.commandIds) + const runCommands = scopedCommandIds === undefined ? commands : commands.filter((command) => scopedCommandIds.has(command.id)) + const initiatingCandidates = runCommands.filter((command) => command.type === "task.start" || command.type === "task.resume") + const initiatingCommand = lifecycleScope + ? initiatingCandidates.find((command) => command.id === lifecycleScope.initiatingCommandId) + : initiatingCandidates.length === 1 + ? initiatingCandidates[0] + : undefined + const startCommands = initiatingCommand?.type === "task.start" ? [initiatingCommand] : [] + const resumeCommands = initiatingCommand?.type === "task.resume" ? [initiatingCommand] : [] + if (initiatingCommand === undefined) { + return { ok: false, code: "protocol_gap", message: "Run scope must identify exactly one initiating command" } + } if (new Set(commands.map((command) => command.id)).size !== commands.length) { return { ok: false, code: "protocol_gap", message: "Command IDs must be globally unique" } } @@ -583,7 +617,19 @@ export function validateStreamLifecycle( } return false } - const causalTerminal = (commandId: string): HostEvent | undefined => { + const hostSequenceFor = (streamEvent: ZooStreamEvent): number | undefined => { + const matches = commandEvents.filter( + (event) => + event.type === "event" && + event.event.hostId === streamEvent.hostId && + event.event.seq === streamEvent.seq && + event.event.type === streamEvent.type && + ("rootTaskId" in event.event ? event.event.rootTaskId : undefined) === + ("rootTaskId" in streamEvent ? streamEvent.rootTaskId : undefined), + ) + return matches.length === 1 ? matches[0]!.seq : undefined + } + const causalTerminal = (commandId: string, effect?: ZooStreamEvent): HostEvent | undefined => { const lifecycle = commandEvents.filter( (event) => (event.type === "command.ack" || event.type === "command.done" || event.type === "command.error") && @@ -594,18 +640,20 @@ export function validateStreamLifecycle( if ( acknowledgements.length !== 1 || terminals.length !== 1 || - acknowledgements[0]!.seq >= terminals[0]!.seq + acknowledgements[0]!.seq >= terminals[0]!.seq || + (effect !== undefined && (hostSequenceFor(effect) ?? 0) <= acknowledgements[0]!.seq) ) { return undefined } return terminals[0] } - const inputResumeCause = (taskId: string, requestId: string | undefined): boolean => { + const inputResumeCause = (streamEvent: ZooStreamEvent & { taskId: string }): boolean => { + const { taskId, requestId } = streamEvent if (requestId === undefined || consumedInputCommands.has(requestId)) return false - const input = commands.find( + const input = runCommands.find( (command) => command.type === "task.input" && command.id === requestId && command.taskId === taskId, ) - const terminal = causalTerminal(requestId) + const terminal = causalTerminal(requestId, streamEvent) const valid = input !== undefined && terminal?.type === "command.done" && @@ -619,12 +667,11 @@ export function validateStreamLifecycle( approvalResumeCauses.delete(taskId) return true } - for (const response of commands.filter((command) => command.type === "ask.respond")) { + for (const response of runCommands.filter((command) => command.type === "ask.respond")) { if (causalTerminal(response.id) === undefined) { return { ok: false, code: "protocol_gap", message: "Every ask response requires ACK and one terminal response" } } } - const initiatingCommand = resumedEvents.length === 0 ? startCommands[0] : resumeCommands[0] const initiatingTerminal = initiatingCommand === undefined ? undefined : causalTerminal(initiatingCommand.id) if (initiatingTerminal?.type !== "command.done") { return { ok: false, code: "protocol_gap", message: "Task stream requires a successful initiating command" } @@ -677,6 +724,9 @@ export function validateStreamLifecycle( if (streamEvent.taskId === rootTaskId && resumedEvents.length === 0 && streamEvent.requestId !== startCommands[0]?.id) { return { ok: false, code: "task_failed", message: "Root creation must match its task.start request" } } + if (streamEvent.taskId === rootTaskId && causalTerminal(initiatingCommand.id, streamEvent) === undefined) { + return { ok: false, code: "protocol_gap", message: "Task creation must follow its command ACK" } + } } else if (streamEvent.type === "task.delegated") { const reconstructingResumeTree = resumedEvents.length === 1 && resumedTasks.size === 0 if ( @@ -772,7 +822,7 @@ export function validateStreamLifecycle( (streamEvent.state === "running" && !hasPendingAskInAncestry(streamEvent.taskId) && (approvalResumeCause(streamEvent.taskId, streamEvent.requestId) || - inputResumeCause(streamEvent.taskId, streamEvent.requestId))))) + inputResumeCause(streamEvent))))) if (!transitionAllowed) { return { ok: false, @@ -819,6 +869,9 @@ export function validateStreamLifecycle( ) { return { ok: false, code: "task_failed", message: "task.resumed must match reconstructed persisted state" } } + if (causalTerminal(resume!.id, streamEvent) === undefined) { + return { ok: false, code: "protocol_gap", message: "Task resume must follow its command ACK" } + } resumedTasks.add(streamEvent.taskId) taskStates.set(streamEvent.taskId, "running") } @@ -884,7 +937,7 @@ export function validateStreamLifecycle( ) { return { ok: false, code: "task_failed", message: "Ask decision contradicts its resolution source" } } - const responseCommands = commands.filter( + const responseCommands = runCommands.filter( (command) => command.type === "ask.respond" && command.taskId === streamEvent.taskId && @@ -900,7 +953,7 @@ export function validateStreamLifecycle( response?.type === "ask.respond" ? { approve: "approve", reject: "reject", message: "needs_input" }[response.response] : undefined - const completion = response === undefined ? undefined : causalTerminal(response.id) + const completion = response === undefined ? undefined : causalTerminal(response.id, streamEvent) if ( expectedDecision !== streamEvent.decision || completion?.type !== "command.done" || @@ -1071,14 +1124,14 @@ export function validateStreamLifecycle( if (resultEvent.result.outcome === "completed" && values(messageStates).some((message) => !message.complete)) { return { ok: false, code: "task_failed", message: "Completed streams cannot contain partial messages" } } - const unconsumedResponses = commands.some((command) => { + const unconsumedResponses = runCommands.some((command) => { if (command.type !== "ask.respond" || consumedResponseCommands.has(command.id)) return false return causalTerminal(command.id)?.type === "command.done" }) if (unconsumedResponses) { return { ok: false, code: "task_failed", message: "Every ask response command must settle its matching ask" } } - const cancelCommands = commands.filter((command) => command.type === "task.cancel" && command.rootTaskId === rootTaskId) + const cancelCommands = runCommands.filter((command) => command.type === "task.cancel" && command.rootTaskId === rootTaskId) const cancellationTerminals = cancelCommands.map((command) => causalTerminal(command.id)) if (cancellationTerminals.some((terminal) => terminal === undefined)) { return { ok: false, code: "task_failed", message: "Every cancellation command requires ACK and one terminal response" } @@ -1116,6 +1169,9 @@ export function validateStreamLifecycle( message: "Cancelled result does not match its cancellation command", } } + if (causalTerminal(cancellation.id, resultEvent) === undefined) { + return { ok: false, code: "protocol_gap", message: "Cancelled result must follow its command ACK" } + } } return { ok: true } } diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 8d05fe354e..be91479148 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -12,6 +12,10 @@ const terminalControl = new RegExp( `(?:${String.fromCharCode(27)}\\][^${String.fromCharCode(7)}${String.fromCharCode(27)}]*(?:${String.fromCharCode(7)}|${String.fromCharCode(27)}\\\\)|${String.fromCharCode(27)}[PX^_][\\s\\S]*?${String.fromCharCode(27)}\\\\|${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]|[\\u0090\\u0098\\u009d\\u009e\\u009f][\\s\\S]*?\\u009c|\\u009b[0-?]*[ -/]*[@-~]|${String.fromCharCode(27)}[@-_])`, "g", ) +const unsafeTerminalEditing = new RegExp( + `(?:${String.fromCharCode(27)}\\[[0-?]*[ -/]*[A-HJKSTfsu]|${String.fromCharCode(155)}[0-?]*[ -/]*[A-HJKSTfsu])`, + "i", +) const secretPatterns: ReadonlyArray = [ /\b(?:Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, new RegExp(`--${sensitiveKeyName}(?:\\s*=\\s*|\\s+)${cliSecretValue}`, "gi"), @@ -33,6 +37,7 @@ export function isSensitiveKey(key: string): boolean { .replace(/[^A-Za-z0-9]+/g, " ") .trim() .toLowerCase() + const compact = words.replace(/ /g, "") if ( /\b(?:password|secret|passphrase|passwd|pwd)\b/.test(words) || /^(?:(?:proxy )?authorization|credentials?)$/.test(words) || @@ -40,6 +45,9 @@ export function isSensitiveKey(key: string): boolean { /\bprivate key\b/.test(words) ) return true + if (/^(?:.*)?(?:apikey|apitoken|accesstoken|authtoken|bearertoken|idtoken|privatekey|refreshtoken|sessiontoken)$/.test(compact)) { + return true + } if ( /\b(?:api key|api token|access token|auth token|bearer token|id token|private key|refresh token|session token) value$/.test( words, @@ -52,6 +60,10 @@ export function isSensitiveKey(key: string): boolean { ) } +export function requiresFailClosedRedaction(value: string): boolean { + return unsafeTerminalEditing.test(value) +} + export function canonicalizeRedactionText(value: string): string { const withoutTerminalControls = value .replace(terminalControl, "") @@ -68,6 +80,7 @@ export function canonicalizeRedactionText(value: string): string { } export function redactText(value: string): string { + if (requiresFailClosedRedaction(value)) return REDACTED const canonical = canonicalizeRedactionText(value) const structured = canonical .replace(/\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s/?#]+/g, (authority) => { From 438ebc4ae27a27678a611e1cccef0d51fc90fb77 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 06:26:44 -0400 Subject: [PATCH 21/24] no-mistakes(review): Tighten protocol causality, redaction, and lifecycle invariants --- .../src/__tests__/contracts.test.ts | 112 +++++++++++++++--- packages/zoo-protocol/src/host-events.ts | 47 ++++++-- packages/zoo-protocol/src/public-events.ts | 43 ++++--- packages/zoo-protocol/src/redaction.ts | 29 ++++- 4 files changed, 186 insertions(+), 45 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 9c83809f66..39097f0c22 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -373,6 +373,17 @@ describe("strict host contracts", () => { }, }), ).toThrow("input limit") + const escaped = createHostEventStreamParser({ hostId: "host", maxInputBytes: 100 }) + expect(() => + escaped.push({ + v: 1, + seq: 1, + hostId: "host", + type: "host.heartbeat", + monotonicMs: 1, + padding: "\u0000".repeat(30), + }), + ).toThrow("input limit") }) it("does not mutate parser state for an invalid nested event", () => { @@ -936,6 +947,14 @@ describe("public automation contracts", () => { ...eventEnvelopes.slice(2).map((event, index) => ({ ...event, seq: index + 5 })), ] expect(validateStreamLifecycleContract(stream, [startCommand], lateAckWindow)).toMatchObject({ ok: false }) + const mismatchedEnvelope = eventEnvelopes.map((event, index) => + index === 1 && event.type === "event" + ? { ...event, seq: event.seq + 2, event: { ...event.event, requestId: "different-request" } } + : { ...event, seq: event.seq + 2 }, + ) + expect(validateStreamLifecycleContract(stream, [startCommand], [...startDone(), ...mismatchedEnvelope])).toMatchObject({ + ok: false, + }) }) it("validates task-tree settlement and approval command causation", () => { @@ -1015,7 +1034,8 @@ describe("public automation contracts", () => { category: "tool", subject: "Run command", }) - const resolved = taskEvent(5, "ask.resolved", { + const waitingForApproval = taskEvent(5, "task.lifecycle", { state: "waiting" }) + const resolved = taskEvent(6, "ask.resolved", { requestId: "respond", askId: "ask", decision: "approve", @@ -1029,16 +1049,34 @@ describe("public automation contracts", () => { askId: "ask", response: "approve", }) - const completed = taskEvent(6, "task.lifecycle", { state: "completed" }) + const runningAfterApproval = taskEvent(7, "task.lifecycle", { state: "running", requestId: "respond" }) + const completed = taskEvent(8, "task.lifecycle", { state: "completed" }) expect( validateStreamLifecycle( - [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [ + initEvent, + rootCreated, + rootStarted, + required, + waitingForApproval, + resolved, + runningAfterApproval, + completed, + resultEvent(9), + ], [startCommand, response], [...startDone(), ...askResponseDone("respond", "host", 3)], ), ).toEqual({ ok: true, }) + expect( + validateStreamLifecycle( + [initEvent, rootCreated, rootStarted, required, resolved, completed, resultEvent(7)], + [startCommand, response], + [...startDone(), ...askResponseDone("respond", "host", 3)], + ), + ).toMatchObject({ ok: false }) const mismatchedResolution = zooStreamEventSchema.parse({ ...resolved, decision: "reject" }) expect( validateStreamLifecycle( @@ -1061,7 +1099,17 @@ describe("public automation contracts", () => { const policyReportedResponse = zooStreamEventSchema.parse({ ...resolved, source: "policy" }) expect( validateStreamLifecycle( - [initEvent, rootCreated, rootStarted, required, policyReportedResponse, completed, resultEvent(7)], + [ + initEvent, + rootCreated, + rootStarted, + required, + waitingForApproval, + policyReportedResponse, + runningAfterApproval, + completed, + resultEvent(9), + ], [startCommand, response], [...startDone(), ...askResponseDone("respond", "host", 3)], ), @@ -1145,6 +1193,21 @@ describe("public automation contracts", () => { expect( validateStreamLifecycle(stream, [startCommand, command], [...startDone(), ...cancellationDone("cancel", "host", 3)]), ).toEqual({ ok: true }) + const completedDespiteCancellation = [ + initEvent, + created, + started, + taskEvent(4, "task.lifecycle", { state: "completed" }), + resultEvent(5), + ] + expect( + validateStreamLifecycle( + completedDespiteCancellation, + [startCommand, command], + [...startDone(), ...cancellationDone("cancel", "host", 3)], + { initiatingCommandId: "start", commandIds: ["start"] }, + ), + ).toMatchObject({ ok: false }) expect( validateStreamLifecycle( stream.map((event) => @@ -1230,10 +1293,11 @@ describe("public automation contracts", () => { const created = taskEvent(2, "task.created") const started = taskEvent(3, "task.started") const required = taskEvent(4, "ask.required", { askId: "ask", category: "tool", subject: "Run" }) - const abandoned = taskEvent(5, "ask.abandoned", { askId: "ask", reason: "cancelled" }) + const waiting = taskEvent(5, "task.lifecycle", { state: "waiting" }) + const abandoned = taskEvent(6, "ask.abandoned", { askId: "ask", reason: "cancelled" }) if (abandoned.type !== "ask.abandoned") throw new Error("Expected ask.abandoned fixture") - const interrupted = taskEvent(6, "task.lifecycle", { state: "interrupted", cause: "cancelled" }) - const cancelled = resultEvent(7, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) + const interrupted = taskEvent(7, "task.lifecycle", { state: "interrupted", cause: "cancelled" }) + const cancelled = resultEvent(8, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }) const command = hostCommandSchema.parse({ v: 1, id: "cancel", @@ -1243,7 +1307,7 @@ describe("public automation contracts", () => { }) expect( validateStreamLifecycle( - [initEvent, created, started, required, abandoned, interrupted, cancelled], + [initEvent, created, started, required, waiting, abandoned, interrupted, cancelled], [startCommand, command], [...startDone(), ...cancellationDone("cancel", "host", 3)], ), @@ -1261,7 +1325,7 @@ describe("public automation contracts", () => { [command], ), ).toMatchObject({ ok: false }) - const failedAbandonment = taskEvent(5, "ask.abandoned", { askId: "ask", reason: "failed" }) + const failedAbandonment = taskEvent(6, "ask.abandoned", { askId: "ask", reason: "failed" }) expect( validateStreamLifecycle( [ @@ -1269,9 +1333,10 @@ describe("public automation contracts", () => { created, started, required, + waiting, failedAbandonment, - taskEvent(6, "task.lifecycle", { state: "failed", cause: "failed" }), - resultEvent(7, { + taskEvent(7, "task.lifecycle", { state: "failed", cause: "failed" }), + resultEvent(8, { outcome: "failed", error: { code: "provider_failed", message: "failed" }, }), @@ -1294,10 +1359,11 @@ describe("public automation contracts", () => { category: "tool", subject: "Run", }), - taskEvent(8, "ask.abandoned", { taskId: "child", askId: "child-ask", reason: "failed" }), - taskEvent(9, "task.lifecycle", { taskId: "child", state: "failed", cause: "failed" }), - taskEvent(10, "task.lifecycle", { state: "interrupted", cause: "cancelled" }), - resultEvent(11, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }), + taskEvent(8, "task.lifecycle", { taskId: "child", state: "waiting" }), + taskEvent(9, "ask.abandoned", { taskId: "child", askId: "child-ask", reason: "failed" }), + taskEvent(10, "task.lifecycle", { taskId: "child", state: "failed", cause: "failed" }), + taskEvent(11, "task.lifecycle", { state: "interrupted", cause: "cancelled" }), + resultEvent(12, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }), ] expect( validateStreamLifecycle( @@ -1826,6 +1892,9 @@ describe("redaction contracts", () => { proxyAuthorization: "[REDACTED]", }) expect(redactText("password: abc,def")).toBe("[REDACTED]") + expect(redactText('{"password": abc,def}')).toBe('{"password": [REDACTED]}') + expect(redactText("accessTokenValue=hunter2 cookieJar=session123")).not.toMatch(/hunter2|session123/) + expect(redactText(`\u001b]0;password=hunter2\u0007safe`)).toBe("[REDACTED]") expect(redactText('{"api\\u005fkey":"hunter2"}')).toBe("[REDACTED]") expect(redactText("API_\u001b[31mTOKEN=abcdefgh")).toBe("[REDACTED]") expect(redactText('{"literal":"\\u0061"}')).toBe('{"literal":"\\u0061"}') @@ -2062,7 +2131,18 @@ describe("redaction contracts", () => { .filter((event) => event.type === "terminal.output" && event.stream === "stdout") .map((event) => (event.type === "terminal.output" ? event.delta : "")) .join(""), - ).toBe("[REDACTED]\n") + ).toBe("[REDACTED]\nabcdefgh\n") + const crossStream = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: "API_TOKEN=" }, + { ...terminal, seq: 2, stream: "stderr", delta: "hunter2\n" }, + ]) + expect(crossStream.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED]\n", + ) + expect(crossStream.map((event) => (event.type === "terminal.output" ? event.stream : ""))).toEqual([ + "stdout", + "stderr", + ]) }) it("preserves prototype-like keys as redacted record data", () => { diff --git a/packages/zoo-protocol/src/host-events.ts b/packages/zoo-protocol/src/host-events.ts index f3f75063ee..0c54faf4d5 100644 --- a/packages/zoo-protocol/src/host-events.ts +++ b/packages/zoo-protocol/src/host-events.ts @@ -151,18 +151,43 @@ export function createHostEventStreamParser( const envelopes = new Map() const pinnedHostId = options.hostId let lastSeq: number | undefined - const inputSize = (value: unknown, seen = new WeakSet()): number => { - if (value === null || value === undefined) return 4 - if (typeof value === "string") return new TextEncoder().encode(value).byteLength - if (typeof value !== "object") return 8 - if (seen.has(value)) return maxInputBytes + 1 - seen.add(value) - let bytes = 2 - for (const [key, entry] of Object.entries(value)) { - bytes += new TextEncoder().encode(key).byteLength + inputSize(entry, seen) + const encoder = new TextEncoder() + const inputSize = (value: unknown): number => { + const seen = new WeakSet() + const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }] + let bytes = 0 + while (stack.length > 0) { + const current = stack.pop()! + if (current.depth > 64) return maxInputBytes + 1 + if (current.value === null || typeof current.value !== "object") { + let serialized: string | undefined + try { + serialized = JSON.stringify(current.value) + } catch { + return maxInputBytes + 1 + } + if (serialized === undefined) return maxInputBytes + 1 + bytes += encoder.encode(serialized).byteLength + } else { + if (seen.has(current.value)) return maxInputBytes + 1 + seen.add(current.value) + if (Array.isArray(current.value)) { + bytes += 2 + Math.max(0, current.value.length - 1) + for (let index = current.value.length - 1; index >= 0; index -= 1) { + stack.push({ value: current.value[index], depth: current.depth + 1 }) + } + } else { + const entries = Object.entries(current.value) + bytes += 2 + Math.max(0, entries.length - 1) + for (let index = entries.length - 1; index >= 0; index -= 1) { + const [key, entry] = entries[index]! + bytes += encoder.encode(JSON.stringify(key)).byteLength + 1 + stack.push({ value: entry, depth: current.depth + 1 }) + } + } + } if (bytes > maxInputBytes) return bytes } - seen.delete(value) return bytes } const eventKey = (event: RawZooStreamEvent) => @@ -247,7 +272,7 @@ export function createHostEventStreamParser( } let bytes: number try { - bytes = new TextEncoder().encode(JSON.stringify(event)).byteLength + bytes = encoder.encode(JSON.stringify(event)).byteLength } catch { bytes = maxQueuedBytes } diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 3a40e51446..4d10130413 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -1,3 +1,5 @@ +import { isDeepStrictEqual } from "node:util" + import { z } from "zod" import { validateCommandLifecycle } from "./command-lifecycle.js" @@ -316,7 +318,7 @@ export function createZooStreamRedactor( const pendingOutputs = new Map() let failClosedAll = false const outputKey = (event: z.infer) => - JSON.stringify([event.hostId, event.rootTaskId, event.taskId, event.toolCallId, event.stream]) + JSON.stringify([event.hostId, event.rootTaskId, event.taskId, event.toolCallId]) const hasUnmatchedPem = (text: string): boolean => { const openLabels: string[] = [] for (const match of text.matchAll(/-----(BEGIN|END) ((?:[A-Z ]*PRIVATE KEY|PGP PRIVATE KEY BLOCK))-----/g)) { @@ -515,6 +517,13 @@ export function validateStreamLifecycle( if (!commandLifecycle.ok) { return { ok: false, code: "protocol_gap", message: commandLifecycle.message } } + const eventEnvelopes = commandEvents.filter((event) => event.type === "event") + if ( + eventEnvelopes.length !== events.length || + events.some((streamEvent, index) => !isDeepStrictEqual(eventEnvelopes[index]?.event, streamEvent)) + ) { + return { ok: false, code: "protocol_gap", message: "Public events must exactly match their ordered host envelopes" } + } const results = events.filter((event) => event.type === "task.result") if (results.length !== 1 || events.at(-1)?.type !== "task.result") { return { ok: false, code: "task_failed", message: "Accepted stream must end with exactly one task.result" } @@ -525,9 +534,7 @@ export function validateStreamLifecycle( return { ok: false, code: "task_failed", message: "task.result must identify the authoritative root task" } } const resumedEvents = events.filter((streamEvent) => streamEvent.type === "task.resumed") - const scopedCommandIds = - lifecycleScope?.commandIds === undefined ? undefined : new Set(lifecycleScope.commandIds) - const runCommands = scopedCommandIds === undefined ? commands : commands.filter((command) => scopedCommandIds.has(command.id)) + const runCommands = commands const initiatingCandidates = runCommands.filter((command) => command.type === "task.start" || command.type === "task.resume") const initiatingCommand = lifecycleScope ? initiatingCandidates.find((command) => command.id === lifecycleScope.initiatingCommandId) @@ -618,16 +625,8 @@ export function validateStreamLifecycle( return false } const hostSequenceFor = (streamEvent: ZooStreamEvent): number | undefined => { - const matches = commandEvents.filter( - (event) => - event.type === "event" && - event.event.hostId === streamEvent.hostId && - event.event.seq === streamEvent.seq && - event.event.type === streamEvent.type && - ("rootTaskId" in event.event ? event.event.rootTaskId : undefined) === - ("rootTaskId" in streamEvent ? streamEvent.rootTaskId : undefined), - ) - return matches.length === 1 ? matches[0]!.seq : undefined + const index = events.indexOf(streamEvent) + return index < 0 ? undefined : eventEnvelopes[index]?.seq } const causalTerminal = (commandId: string, effect?: ZooStreamEvent): HostEvent | undefined => { const lifecycle = commandEvents.filter( @@ -770,6 +769,13 @@ export function validateStreamLifecycle( } const previousState = taskStates.get(streamEvent.taskId) + if ( + previousState === "running" && + (pendingAsks.get(streamEvent.taskId)?.size ?? 0) > 0 && + !(streamEvent.type === "task.lifecycle" && streamEvent.state === "waiting") + ) { + return { ok: false, code: "task_failed", message: "A pending approval must transition its task to waiting" } + } if ( approvalResumeCauses.has(streamEvent.taskId) && !(streamEvent.type === "task.lifecycle" && streamEvent.state === "running") @@ -922,12 +928,19 @@ export function validateStreamLifecycle( } if (streamEvent.type === "ask.required") { const asks = askScope(streamEvent.taskId) - if (asks.has(streamEvent.askId) || settledAsks.get(streamEvent.taskId)?.has(streamEvent.askId) === true) { + if ( + taskStates.get(streamEvent.taskId) !== "running" || + asks.has(streamEvent.askId) || + settledAsks.get(streamEvent.taskId)?.has(streamEvent.askId) === true + ) { return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was already used` } } asks.add(streamEvent.askId) } if (streamEvent.type === "ask.resolved") { + if (taskStates.get(streamEvent.taskId) !== "waiting") { + return { ok: false, code: "task_failed", message: "Ask resolution requires a waiting task" } + } if (!pendingAsks.get(streamEvent.taskId)?.delete(streamEvent.askId)) { return { ok: false, code: "task_failed", message: `Ask ${streamEvent.askId} was not pending` } } diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index be91479148..1aa5d4a895 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -5,9 +5,12 @@ const cliSecretValue = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}] const doubleQuotedSecret = new RegExp(`("${sensitiveKeyName}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi") const singleQuotedSecret = new RegExp(`('${sensitiveKeyName}'\\s*:\\s*)'(?:\\\\.|[^'\\\\])*'`, "gi") const quotedUnquotedSecret = new RegExp( - `((?:"${sensitiveKeyName}"|'${sensitiveKeyName}')\\s*:\\s*)(?!["'])[^\\s,;}]+`, + `((?:"${sensitiveKeyName}"|'${sensitiveKeyName}')\\s*:\\s*)(?!["'])[^\\r\\n;}]+`, "gi", ) +const quotedAssignment = /((["'])([^"']+)\2\s*[:=]\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\r\n;}]+)/g +const bareColonAssignment = /(\b([A-Za-z][A-Za-z0-9_.-]*)\s*:\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\r\n;}]+)/g +const bareEqualsAssignment = /(\b([A-Za-z][A-Za-z0-9_.-]*)\s*=\s*)("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s;}]+)/g const terminalControl = new RegExp( `(?:${String.fromCharCode(27)}\\][^${String.fromCharCode(7)}${String.fromCharCode(27)}]*(?:${String.fromCharCode(7)}|${String.fromCharCode(27)}\\\\)|${String.fromCharCode(27)}[PX^_][\\s\\S]*?${String.fromCharCode(27)}\\\\|${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]|[\\u0090\\u0098\\u009d\\u009e\\u009f][\\s\\S]*?\\u009c|\\u009b[0-?]*[ -/]*[@-~]|${String.fromCharCode(27)}[@-_])`, "g", @@ -61,7 +64,26 @@ export function isSensitiveKey(key: string): boolean { } export function requiresFailClosedRedaction(value: string): boolean { - return unsafeTerminalEditing.test(value) + if (unsafeTerminalEditing.test(value)) return true + return [...value.matchAll(new RegExp(terminalControl.source, "g"))].some(([control]) => containsSensitiveAssignment(control)) +} + +function containsSensitiveAssignment(value: string): boolean { + const candidates = value.matchAll(/([A-Za-z][A-Za-z0-9_. -]*)\s*[:=]/g) + return [...candidates].some((match) => isSensitiveKey(match[1] ?? "")) +} + +function redactAssignments(value: string): string { + return value + .replace(quotedAssignment, (match, prefix: string, _quote: string, key: string, value: string) => + isSensitiveKey(key) && !value.includes(REDACTED) ? `${prefix}${REDACTED}` : match, + ) + .replace(bareColonAssignment, (match, prefix: string, key: string, value: string) => + isSensitiveKey(key) && !value.includes(REDACTED) ? `${prefix}${REDACTED}` : match, + ) + .replace(bareEqualsAssignment, (match, prefix: string, key: string, value: string) => + isSensitiveKey(key) && !value.includes(REDACTED) ? `${prefix}${REDACTED}` : match, + ) } export function canonicalizeRedactionText(value: string): string { @@ -92,7 +114,8 @@ export function redactText(value: string): string { .replace(doubleQuotedSecret, `$1"${REDACTED}"`) .replace(singleQuotedSecret, `$1'${REDACTED}'`) .replace(quotedUnquotedSecret, `$1${REDACTED}`) - const redacted = secretPatterns.reduce((text, pattern) => text.replace(pattern, REDACTED), structured) + const assignments = redactAssignments(structured) + const redacted = secretPatterns.reduce((text, pattern) => text.replace(pattern, REDACTED), assignments) if (canonical !== value) return redacted === canonical ? value : REDACTED return redacted } From 10ef0a6041c1eb4c420fbfda5930f0d11c15bcdd Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 06:36:12 -0400 Subject: [PATCH 22/24] no-mistakes(review): Fix protocol redaction and approval parity invariants --- .../src/__tests__/contracts.test.ts | 13 ++++ packages/zoo-protocol/src/parity.ts | 8 ++- packages/zoo-protocol/src/public-events.ts | 63 ++++++++++++++----- packages/zoo-protocol/src/redaction.ts | 20 ++++-- 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index 39097f0c22..ae53e0e6f1 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -1905,6 +1905,12 @@ describe("redaction contracts", () => { cookieJar: "[REDACTED]", privateKeyPem: "[REDACTED]", }) + expect(redactValue({ dbpassword: "pw", appsecret: "secret" })).toEqual({ + dbpassword: "[REDACTED]", + appsecret: "[REDACTED]", + }) + expect(redactText('{"my token":"[REDACTED]hunter2"}')).toBe('{"my token":"[REDACTED]"}') + expect(redactText("pass\\u001b[31mword=hunter2")).toBe("[REDACTED]") }) it("redacts public event and result payloads during parsing", () => { @@ -2143,6 +2149,13 @@ describe("redaction contracts", () => { "stdout", "stderr", ]) + const crossStreamQuote = zooStreamSchema.parse([ + { ...terminal, seq: 1, delta: 'password="first\n' }, + { ...terminal, seq: 2, stream: "stderr", delta: 'diagnostic "\n' }, + { ...terminal, seq: 3, delta: "hunter2\n" }, + ]) + expect(crossStreamQuote.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) + .toBe("[REDACTED][REDACTED][REDACTED]") }) it("preserves prototype-like keys as redacted record data", () => { diff --git a/packages/zoo-protocol/src/parity.ts b/packages/zoo-protocol/src/parity.ts index a3e9332db9..c40054cfb3 100644 --- a/packages/zoo-protocol/src/parity.ts +++ b/packages/zoo-protocol/src/parity.ts @@ -110,6 +110,7 @@ export const parityScenarios: readonly ParityScenario[] = [ { type: "task.created", rootTaskId: "root", taskId: "root", prompt: "Request approval." }, { type: "task.started", rootTaskId: "root", taskId: "root" }, { type: "ask.required", rootTaskId: "root", taskId: "root", askId: "ask-1" }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "waiting" }, { type: "ask.resolved", rootTaskId: "root", @@ -119,6 +120,7 @@ export const parityScenarios: readonly ParityScenario[] = [ source: "user", requestId: "respond-1", }, + { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "running" }, { type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "completed" }, { type: "task.result", rootTaskId: "root", taskId: "root", outcome: "completed" }, ], @@ -258,9 +260,11 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly if (turn.startsWith("ask:")) { const askId = turn.slice(4) if (!askId || usedAskIds.has(askId)) throw new Error(`Invalid ask fixture: ${turn}`) + const wasUnblocked = pendingAsks.size === 0 pendingAsks.add(askId) usedAskIds.add(askId) trace.push({ type: "ask.required", rootTaskId: "root", taskId: "root", askId }) + if (wasUnblocked) trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "waiting" }) continue } if (turn.startsWith("approve:")) { @@ -281,6 +285,9 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly source, requestId, }) + if (pendingAsks.size === 0) { + trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "running" }) + } continue } if (turn.startsWith("cancel:")) { @@ -330,7 +337,6 @@ export function runDeterministicFakeProvider(scenario: ParityScenario): readonly if (activeChildren.size > 0 || pendingAsks.size === 0) { throw new Error("needs_input requires a pending ask and settled descendants") } - trace.push({ type: "task.lifecycle", rootTaskId: "root", taskId: "root", state: "waiting" }) result = { type: "task.result", rootTaskId: "root", diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index 4d10130413..b96ed38b01 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -310,9 +310,10 @@ export function createZooStreamRedactor( type PendingOutput = { events: Array> text: string + textByStream: Partial["stream"], string>> pem: boolean overflowed: boolean - secretQuote?: '"' | "'" + secretQuotes: Partial["stream"], '"' | "'">> secretValueContinuation?: boolean } const pendingOutputs = new Map() @@ -353,22 +354,39 @@ export function createZooStreamRedactor( } const [first, ...rest] = pending.events const detectionText = canonicalizeRedactionText(pending.text) - const continuedSecret = pending.secretQuote !== undefined + const continuedSecret = Object.keys(pending.secretQuotes).length > 0 const continuedValue = pending.secretValueContinuation === true - const secretQuote = continuedSecret - ? closesSecretQuote(detectionText, pending.secretQuote!) - ? undefined - : pending.secretQuote - : incompleteSecretQuote(detectionText) + const secretQuotes = { ...pending.secretQuotes } + for (const [stream, text] of Object.entries(pending.textByStream) as Array< + [z.infer["stream"], string] + >) { + const streamText = canonicalizeRedactionText(text) + const activeQuote = secretQuotes[stream] + if (activeQuote !== undefined) { + if (closesSecretQuote(streamText, activeQuote)) delete secretQuotes[stream] + } else { + const quote = incompleteSecretQuote(streamText) + if (quote !== undefined) secretQuotes[stream] = quote + } + } const secretValueContinuation = !continuedValue && incompleteSecretValue(detectionText) const unterminatedSecret = - pending.pem || secretQuote !== undefined || secretValueContinuation || requiresFailClosedRedaction(pending.text) + pending.pem || + Object.keys(secretQuotes).length > 0 || + secretValueContinuation || + requiresFailClosedRedaction(pending.text) const delta = replacement ?? (unterminatedSecret ? REDACTED : String(redactValue(pending.text))) pendingOutputs.delete(key) - if (secretQuote !== undefined) { - pendingOutputs.set(key, { events: [], text: "", pem: false, overflowed: false, secretQuote }) - } else if (secretValueContinuation) { - pendingOutputs.set(key, { events: [], text: "", pem: false, overflowed: false, secretValueContinuation: true }) + if (Object.keys(secretQuotes).length > 0 || secretValueContinuation) { + pendingOutputs.set(key, { + events: [], + text: "", + textByStream: {}, + pem: false, + overflowed: false, + secretQuotes, + secretValueContinuation: secretValueContinuation || undefined, + }) } return first === undefined ? [] @@ -409,17 +427,25 @@ export function createZooStreamRedactor( const buffered = [...pendingOutputs.keys()].flatMap((pendingKey) => emit(pendingKey, REDACTED)) return [...buffered, { ...event, delta: event.delta.length === 0 ? "" : REDACTED }] } - pending = { events: [], text: "", pem: false, overflowed: false } + pending = { events: [], text: "", textByStream: {}, pem: false, overflowed: false, secretQuotes: {} } pendingOutputs.set(key, pending) } if (pending.overflowed) return [{ ...event, delta: event.delta.length === 0 ? "" : REDACTED }] pending.events.push(event) pending.text += event.delta + pending.textByStream[event.stream] = (pending.textByStream[event.stream] ?? "") + event.delta pending.pem = hasUnmatchedPem(canonicalizeRedactionText(pending.text)) if (new TextEncoder().encode(pending.text).byteLength > maxPendingBytes || pending.events.length > maxPendingEvents) { pending.overflowed = true const redacted = emit(key, REDACTED) - pendingOutputs.set(key, { events: [], text: "", pem: false, overflowed: true }) + pendingOutputs.set(key, { + events: [], + text: "", + textByStream: {}, + pem: false, + overflowed: true, + secretQuotes: {}, + }) return redacted } if (pending.pem) return [] @@ -433,7 +459,14 @@ export function createZooStreamRedactor( const keys = event?.type === "terminal.output" ? [outputKey(event)] : [...pendingOutputs.keys()] return keys.flatMap((key) => { const redacted = emit(key, REDACTED) - pendingOutputs.set(key, { events: [], text: "", pem: false, overflowed: true }) + pendingOutputs.set(key, { + events: [], + text: "", + textByStream: {}, + pem: false, + overflowed: true, + secretQuotes: {}, + }) return redacted }) }, diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 1aa5d4a895..477f4d08c3 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -48,6 +48,7 @@ export function isSensitiveKey(key: string): boolean { /\bprivate key\b/.test(words) ) return true + if (/^[a-z0-9]+(?:password|secret|passphrase|passwd|pwd)$/.test(compact)) return true if (/^(?:.*)?(?:apikey|apitoken|accesstoken|authtoken|bearertoken|idtoken|privatekey|refreshtoken|sessiontoken)$/.test(compact)) { return true } @@ -74,22 +75,33 @@ function containsSensitiveAssignment(value: string): boolean { } function redactAssignments(value: string): string { + const isRedactedValue = (entry: string) => { + const unquoted = + (entry.startsWith('"') && entry.endsWith('"')) || (entry.startsWith("'") && entry.endsWith("'")) + ? entry.slice(1, -1) + : entry + return unquoted === REDACTED + } + const redactValueText = (prefix: string, entry: string) => { + const quote = entry.startsWith('"') && entry.endsWith('"') ? '"' : entry.startsWith("'") && entry.endsWith("'") ? "'" : "" + return `${prefix}${quote}${REDACTED}${quote}` + } return value .replace(quotedAssignment, (match, prefix: string, _quote: string, key: string, value: string) => - isSensitiveKey(key) && !value.includes(REDACTED) ? `${prefix}${REDACTED}` : match, + isSensitiveKey(key) && !isRedactedValue(value) ? redactValueText(prefix, value) : match, ) .replace(bareColonAssignment, (match, prefix: string, key: string, value: string) => - isSensitiveKey(key) && !value.includes(REDACTED) ? `${prefix}${REDACTED}` : match, + isSensitiveKey(key) && !isRedactedValue(value) ? redactValueText(prefix, value) : match, ) .replace(bareEqualsAssignment, (match, prefix: string, key: string, value: string) => - isSensitiveKey(key) && !value.includes(REDACTED) ? `${prefix}${REDACTED}` : match, + isSensitiveKey(key) && !isRedactedValue(value) ? redactValueText(prefix, value) : match, ) } export function canonicalizeRedactionText(value: string): string { const withoutTerminalControls = value - .replace(terminalControl, "") .replace(/\\u([0-9a-f]{4})/gi, (_match, code: string) => String.fromCharCode(Number.parseInt(code, 16))) + .replace(terminalControl, "") const rendered: string[] = [] for (const character of withoutTerminalControls) { if (character === "\b") { From 978e2d8e3d25f5de49cef2349e3deaf3d63d45f9 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 07:04:52 -0400 Subject: [PATCH 23/24] no-mistakes(review): Fix stream redaction and lifecycle command scoping --- .../src/__tests__/contracts.test.ts | 312 +++++++++++++----- packages/zoo-protocol/src/public-events.ts | 264 +++++++++++---- packages/zoo-protocol/src/redaction.ts | 24 +- 3 files changed, 440 insertions(+), 160 deletions(-) diff --git a/packages/zoo-protocol/src/__tests__/contracts.test.ts b/packages/zoo-protocol/src/__tests__/contracts.test.ts index ae53e0e6f1..c8d1da1bae 100644 --- a/packages/zoo-protocol/src/__tests__/contracts.test.ts +++ b/packages/zoo-protocol/src/__tests__/contracts.test.ts @@ -278,9 +278,10 @@ describe("strict host contracts", () => { expect(validateParentHello(host, selected)).toMatchObject({ ok: false }) expect(validateParentHello(host, { ...selected, version: 3 })).toMatchObject({ ok: false }) expect(validateParentHello(host, { ...selected, version: 1 })).toMatchObject({ ok: false }) - expect( - validateParentHello(host, { ...selected, version: 1, requiredCapabilities: ["task:start"] }), - ).toEqual({ ok: true, version: 1 }) + expect(validateParentHello(host, { ...selected, version: 1, requiredCapabilities: ["task:start"] })).toEqual({ + ok: true, + version: 1, + }) }) it("requires contiguous host sequence numbers", () => { @@ -337,14 +338,14 @@ describe("strict host contracts", () => { }), ).toThrow("cannot span multiple hosts") parser.push({ v: 1, seq: 4, hostId: "host", type: "host.heartbeat", monotonicMs: 1 }) - expect(() => - parser.push({ v: 1, seq: 6, hostId: "host", type: "host.heartbeat", monotonicMs: 2 }), - ).toThrow("Expected host sequence 5") + expect(() => parser.push({ v: 1, seq: 6, hostId: "host", type: "host.heartbeat", monotonicMs: 2 })).toThrow( + "Expected host sequence 5", + ) const otherHost = createHostEventStreamParser({ hostId: "host" }) otherHost.push({ v: 1, seq: 1, hostId: "host", type: "host.heartbeat", monotonicMs: 1 }) - expect(() => - otherHost.push({ v: 1, seq: 2, hostId: "other", type: "host.heartbeat", monotonicMs: 2 }), - ).toThrow("cannot span multiple hosts") + expect(() => otherHost.push({ v: 1, seq: 2, hostId: "other", type: "host.heartbeat", monotonicMs: 2 })).toThrow( + "cannot span multiple hosts", + ) expect( hostEventSchema.safeParse({ v: 1, seq: 1, hostId: "host", type: "host.heartbeat", monotonicMs: Infinity }) .success, @@ -475,8 +476,12 @@ describe("strict host contracts", () => { data: { commandType: "host.shutdown" }, }) expect(validateCommandLifecycle([command], [acknowledgement, completion], "host-a")).toEqual({ ok: true }) - expect(validateCommandLifecycle([command], [acknowledgement, mismatchedIdentity], "host-a")).toMatchObject({ ok: false }) - expect(validateCommandLifecycle([command], [acknowledgement, mismatchedType], "host-a")).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedIdentity], "host-a")).toMatchObject({ + ok: false, + }) + expect(validateCommandLifecycle([command], [acknowledgement, mismatchedType], "host-a")).toMatchObject({ + ok: false, + }) expect( validateCommandLifecycle([command], [acknowledgement, { ...completion, hostId: "host-b" }], "host-a"), ).toMatchObject({ @@ -512,7 +517,9 @@ describe("strict host contracts", () => { seq: 2, data: { commandType: "task.start", task: { rootTaskId: "root", taskId: "child" } }, }) - expect(validateCommandLifecycle([start], [acknowledgement, childCompletion], "host")).toMatchObject({ ok: false }) + expect(validateCommandLifecycle([start], [acknowledgement, childCompletion], "host")).toMatchObject({ + ok: false, + }) }) it("does not reuse root identities across successful starts", () => { @@ -578,8 +585,13 @@ describe("strict host contracts", () => { ...parser.push(envelope(3, "abcdefgh")), ...parser.flush(), ] - expect(events.map((event) => (event.type === "event" && event.event.type === "terminal.output" ? event.event.delta : "")).join("")) - .toBe("Build succeeded\n[REDACTED]") + expect( + events + .map((event) => + event.type === "event" && event.event.type === "terminal.output" ? event.event.delta : "", + ) + .join(""), + ).toBe("Build succeeded\n[REDACTED]") const interleavedParser = createHostEventStreamParser({ hostId: "host" }) const interleaved = [ @@ -592,7 +604,9 @@ describe("strict host contracts", () => { expect( interleaved .filter((event) => event.type === "event" && event.event.type === "terminal.output") - .map((event) => (event.type === "event" && event.event.type === "terminal.output" ? event.event.delta : "")) + .map((event) => + event.type === "event" && event.event.type === "terminal.output" ? event.event.delta : "", + ) .join(""), ).toBe("[REDACTED]") }) @@ -819,9 +833,9 @@ describe("public automation contracts", () => { for (const invalid of [Infinity, BigInt(1), undefined, () => undefined]) { expect(zooStreamEventSchema.safeParse({ ...tool, arguments: { invalid } }).success).toBe(false) } - expect(zooStreamEventSchema.safeParse({ ...initEvent, capabilities: ["task:start", "future:additive"] }).success).toBe( - true, - ) + expect( + zooStreamEventSchema.safeParse({ ...initEvent, capabilities: ["task:start", "future:additive"] }).success, + ).toBe(true) }) it("requires init, contiguous sequence, and a settled authoritative root", () => { @@ -829,7 +843,9 @@ describe("public automation contracts", () => { const started = taskEvent(3, "task.started") const completed = taskEvent(4, "task.lifecycle", { state: "completed" }) const result = resultEvent(5) - expect(validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand], startDone())).toEqual({ + expect( + validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand], startDone()), + ).toEqual({ ok: true, }) const history = hostCommandSchema.parse({ @@ -839,7 +855,11 @@ describe("public automation contracts", () => { workspace: "/workspace", }) expect( - validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand, history], startDone()), + validateStreamLifecycle( + [initEvent, created, started, completed, result], + [startCommand, history], + startDone(), + ), ).toMatchObject({ ok: false }) expect( validateStreamLifecycle( @@ -865,9 +885,11 @@ describe("public automation contracts", () => { ).toMatchObject({ ok: false }) expect(validateStreamLifecycle([initEvent, resultEvent(2)])).toMatchObject({ ok: false }) expect(validateStreamLifecycle([initEvent, created, resultEvent(3)])).toMatchObject({ ok: false }) - expect(validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand])).toMatchObject({ - ok: false, - }) + expect(validateStreamLifecycle([initEvent, created, started, completed, result], [startCommand])).toMatchObject( + { + ok: false, + }, + ) expect( validateStreamLifecycle( [initEvent, created, started, completed, result], @@ -936,6 +958,36 @@ describe("public automation contracts", () => { commandIds: ["start"], }), ).toEqual({ ok: true }) + const unrelatedResponse = hostCommandSchema.parse({ + v: 1, + id: "other-response", + type: "ask.respond", + taskId: "other-root", + askId: "other", + response: "approve", + }) + const unrelatedResponseEvents = [ + hostEventSchema.parse({ v: 1, seq: 5, hostId: "host", type: "command.ack", commandId: "other-response" }), + hostEventSchema.parse({ + v: 1, + seq: 6, + hostId: "host", + type: "command.done", + commandId: "other-response", + data: { commandType: "ask.respond", taskId: "other-root", askId: "other" }, + }), + ] + expect( + validateStreamLifecycle( + stream, + [startCommand, otherStart, unrelatedResponse], + [...interleaved, ...unrelatedResponseEvents], + { + initiatingCommandId: "start", + commandIds: ["start"], + }, + ), + ).toEqual({ ok: true }) const eventEnvelopes = stream.map((event, index) => hostEventSchema.parse({ v: 1, seq: index + 1, hostId: "host", type: "event", event }), @@ -952,7 +1004,9 @@ describe("public automation contracts", () => { ? { ...event, seq: event.seq + 2, event: { ...event.event, requestId: "different-request" } } : { ...event, seq: event.seq + 2 }, ) - expect(validateStreamLifecycleContract(stream, [startCommand], [...startDone(), ...mismatchedEnvelope])).toMatchObject({ + expect( + validateStreamLifecycleContract(stream, [startCommand], [...startDone(), ...mismatchedEnvelope]), + ).toMatchObject({ ok: false, }) }) @@ -970,17 +1024,21 @@ describe("public automation contracts", () => { const childCompleted = taskEvent(7, "task.lifecycle", { taskId: "child", state: "completed" }) const rootCompleted = taskEvent(8, "task.lifecycle", { state: "completed" }) expect( - validateStreamLifecycle([ - initEvent, - rootCreated, - rootStarted, - childCreated, - delegated, - childStarted, - childCompleted, - rootCompleted, - resultEvent(9), - ], [startCommand], startDone()), + validateStreamLifecycle( + [ + initEvent, + rootCreated, + rootStarted, + childCreated, + delegated, + childStarted, + childCompleted, + rootCompleted, + resultEvent(9), + ], + [startCommand], + startDone(), + ), ).toEqual({ ok: true }) expect( @@ -1191,7 +1249,11 @@ describe("public automation contracts", () => { cancelled, ] expect( - validateStreamLifecycle(stream, [startCommand, command], [...startDone(), ...cancellationDone("cancel", "host", 3)]), + validateStreamLifecycle( + stream, + [startCommand, command], + [...startDone(), ...cancellationDone("cancel", "host", 3)], + ), ).toEqual({ ok: true }) const completedDespiteCancellation = [ initEvent, @@ -1314,14 +1376,7 @@ describe("public automation contracts", () => { ).toEqual({ ok: true }) expect( validateStreamLifecycle( - [ - initEvent, - created, - required, - { ...abandoned, reason: "timed_out" }, - interrupted, - cancelled, - ], + [initEvent, created, required, { ...abandoned, reason: "timed_out" }, interrupted, cancelled], [command], ), ).toMatchObject({ ok: false }) @@ -1477,7 +1532,13 @@ describe("public automation contracts", () => { }) it("consumes each task input resume cause once", () => { - const input = hostCommandSchema.parse({ v: 1, id: "input", type: "task.input", taskId: "root", text: "continue" }) + const input = hostCommandSchema.parse({ + v: 1, + id: "input", + type: "task.input", + taskId: "root", + text: "continue", + }) const inputEvents = [ hostEventSchema.parse({ v: 1, seq: 3, hostId: "host", type: "command.ack", commandId: "input" }), hostEventSchema.parse({ @@ -1503,6 +1564,56 @@ describe("public automation contracts", () => { expect(validateStreamLifecycle(stream, [startCommand, input], [...startDone(), ...inputEvents])).toMatchObject({ ok: false, }) + const messageEffect = [ + initEvent, + taskEvent(2, "task.created"), + taskEvent(3, "task.started"), + taskEvent(4, "message.upsert", { + requestId: "input", + messageId: "input-message", + role: "user", + content: "continue", + complete: true, + }), + taskEvent(5, "task.lifecycle", { state: "completed" }), + resultEvent(6), + ] + expect(validateStreamLifecycle(messageEffect, [startCommand, input], [...startDone(), ...inputEvents])).toEqual( + { + ok: true, + }, + ) + const ghostInput = hostCommandSchema.parse({ + v: 1, + id: "ghost-input", + type: "task.input", + taskId: "ghost", + text: "continue", + }) + const ghostInputEvents = [ + hostEventSchema.parse({ v: 1, seq: 3, hostId: "host", type: "command.ack", commandId: "ghost-input" }), + hostEventSchema.parse({ + v: 1, + seq: 4, + hostId: "host", + type: "command.done", + commandId: "ghost-input", + data: { commandType: "task.input", taskId: "ghost" }, + }), + ] + expect( + validateStreamLifecycle( + messageEffect + .filter((event) => event.type !== "message.upsert") + .map((event, index) => ({ ...event, seq: index + 1 })), + [startCommand, ghostInput], + [...startDone(), ...ghostInputEvents], + { + initiatingCommandId: "start", + commandIds: ["start", "ghost-input"], + }, + ), + ).toMatchObject({ ok: false }) }) it("reconstructs resume streams from a matching command", () => { @@ -1538,7 +1649,13 @@ describe("public automation contracts", () => { }) it("allows a resumed run to be cancelled by a distinct command", () => { - const resume = hostCommandSchema.parse({ v: 1, id: "resume", type: "task.resume", rootTaskId: "root", taskId: "root" }) + const resume = hostCommandSchema.parse({ + v: 1, + id: "resume", + type: "task.resume", + rootTaskId: "root", + taskId: "root", + }) const cancel = hostCommandSchema.parse({ v: 1, id: "cancel", @@ -1556,7 +1673,11 @@ describe("public automation contracts", () => { resultEvent(7, { outcome: "cancelled", cancellationReason: "user" }, { requestId: "cancel" }), ] expect( - validateStreamLifecycle(stream, [resume, cancel], [...resumeDone(), ...cancellationDone("cancel", "host", 3)]), + validateStreamLifecycle( + stream, + [resume, cancel], + [...resumeDone(), ...cancellationDone("cancel", "host", 3)], + ), ).toEqual({ ok: true }) }) @@ -1579,7 +1700,11 @@ describe("public automation contracts", () => { ok: false, }) expect( - validateStreamLifecycle(completed, [startCommand, cancel], [...startDone(), ...cancellationError("cancel", 3)]), + validateStreamLifecycle( + completed, + [startCommand, cancel], + [...startDone(), ...cancellationError("cancel", 3)], + ), ).toEqual({ ok: true }) expect(validateStreamLifecycle(completed, [startCommand, cancel])).toMatchObject({ ok: false }) }) @@ -1886,7 +2011,9 @@ describe("redaction contracts", () => { expect(redactText('{"max_tokens":4096} tokenizer=bpe --max-tokens 4096')).toBe( '{"max_tokens":4096} tokenizer=bpe --max-tokens 4096', ) - expect(redactValue({ "set-cookie": "session=abc", setCookie: "session=def", proxyAuthorization: "Basic abc" })).toEqual({ + expect( + redactValue({ "set-cookie": "session=abc", setCookie: "session=def", proxyAuthorization: "Basic abc" }), + ).toEqual({ "set-cookie": "[REDACTED]", setCookie: "[REDACTED]", proxyAuthorization: "[REDACTED]", @@ -2016,26 +2143,25 @@ describe("redaction contracts", () => { { ...terminal, seq: 2, delta: "super-secret-body\n" }, { ...terminal, seq: 3, delta: "-----END PRIVATE KEY-----\n" }, ]) - expect(output.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) - .toBe("[REDACTED]\n") + expect(output.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED]\n", + ) const repeated = zooStreamSchema.parse([ { ...terminal, seq: 1, - delta: - "-----BEGIN PRIVATE KEY-----\nfirst\n-----END PRIVATE KEY-----\n-----BEGIN PRIVATE KEY-----\nsecond", + delta: "-----BEGIN PRIVATE KEY-----\nfirst\n-----END PRIVATE KEY-----\n-----BEGIN PRIVATE KEY-----\nsecond", }, ]) - expect(repeated.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) - .toBe("[REDACTED]") + expect(repeated.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED]", + ) const pgp = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: "-----BEGIN PGP PRIVATE KEY BLOCK-----\n" }, { ...terminal, seq: 2, delta: "private-body\n" }, { ...terminal, seq: 3, delta: "-----END PGP PRIVATE KEY BLOCK-----\n" }, ]) - expect(pgp.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( - "[REDACTED]\n", - ) + expect(pgp.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe("[REDACTED]\n") const parser = createHostEventStreamParser({ hostId: "host", maxPendingBytes: 4 }) const overflow = parser.push({ @@ -2045,8 +2171,9 @@ describe("redaction contracts", () => { type: "event", event: { ...terminal, seq: 1, delta: "secret" }, }) - expect(overflow[0]?.type === "event" && overflow[0].event.type === "terminal.output" && overflow[0].event.delta) - .toBe("[REDACTED]") + expect( + overflow[0]?.type === "event" && overflow[0].event.type === "terminal.output" && overflow[0].event.delta, + ).toBe("[REDACTED]") const cappedParser = createHostEventStreamParser({ hostId: "host", maxPendingStreams: 1 }) const pending = (seq: number, toolCallId: string) => ({ v: 1, @@ -2063,7 +2190,10 @@ describe("redaction contracts", () => { ] expect( capped.every( - (event) => event.type === "event" && event.event.type === "terminal.output" && event.event.delta === "[REDACTED]", + (event) => + event.type === "event" && + event.event.type === "terminal.output" && + event.event.delta === "[REDACTED]", ), ).toBe(true) let now = 0 @@ -2078,8 +2208,9 @@ describe("redaction contracts", () => { monotonicMs: 1, }) expect(released.map((event) => event.seq)).toEqual([1, 2]) - expect(released[0]?.type === "event" && released[0].event.type === "terminal.output" && released[0].event.delta) - .toBe("[REDACTED]") + expect( + released[0]?.type === "event" && released[0].event.type === "terminal.output" && released[0].event.delta, + ).toBe("[REDACTED]") const unterminated = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: "-----BEGIN PRIVATE KEY-----\n" }, @@ -2088,37 +2219,38 @@ describe("redaction contracts", () => { expect(unterminated.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( "[REDACTED]", ) - const unterminatedQuoted = zooStreamSchema.parse([ - { ...terminal, seq: 1, delta: '{"api_key":"hunter2' }, - ]) + const unterminatedQuoted = zooStreamSchema.parse([{ ...terminal, seq: 1, delta: '{"api_key":"hunter2' }]) expect(unterminatedQuoted[0]?.type === "terminal.output" && unterminatedQuoted[0].delta).toBe("[REDACTED]") const escapedUnterminatedQuoted = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: '{"api\\u005fkey":"hunter2' }, ]) - expect( - escapedUnterminatedQuoted[0]?.type === "terminal.output" && escapedUnterminatedQuoted[0].delta, - ).toBe("[REDACTED]") + expect(escapedUnterminatedQuoted[0]?.type === "terminal.output" && escapedUnterminatedQuoted[0].delta).toBe( + "[REDACTED]", + ) const multilineQuoted = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: 'password="first\n' }, { ...terminal, seq: 2, delta: 'second"\n' }, { ...terminal, seq: 3, delta: "harmless\n" }, ]) - expect(multilineQuoted.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) - .toBe("[REDACTED][REDACTED]harmless\n") + expect(multilineQuoted.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED][REDACTED]harmless\n", + ) const multilineCookie = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: 'cookie="first\n' }, { ...terminal, seq: 2, delta: 'second"\n' }, { ...terminal, seq: 3, delta: "harmless\n" }, ]) - expect(multilineCookie.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) - .toBe("[REDACTED][REDACTED]harmless\n") + expect(multilineCookie.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED][REDACTED]harmless\n", + ) const multilineAssignment = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: "API_TOKEN=\n" }, { ...terminal, seq: 2, delta: "hunter2\n" }, { ...terminal, seq: 3, delta: "harmless\n" }, ]) - expect(multilineAssignment.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) - .toBe("[REDACTED][REDACTED]harmless\n") + expect(multilineAssignment.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED][REDACTED]harmless\n", + ) const ansiPem = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: "-----BEGIN\u001b[31m PRIVATE KEY-----\n" }, { ...terminal, seq: 2, delta: "private-body\n" }, @@ -2132,18 +2264,15 @@ describe("redaction contracts", () => { { ...terminal, seq: 2, stream: "stderr", delta: "harmless\n" }, { ...terminal, seq: 3, delta: "abcdefgh\n" }, ]) - expect( - interleaved - .filter((event) => event.type === "terminal.output" && event.stream === "stdout") - .map((event) => (event.type === "terminal.output" ? event.delta : "")) - .join(""), - ).toBe("[REDACTED]\nabcdefgh\n") + expect(interleaved.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED][REDACTED]", + ) const crossStream = zooStreamSchema.parse([ { ...terminal, seq: 1, delta: "API_TOKEN=" }, { ...terminal, seq: 2, stream: "stderr", delta: "hunter2\n" }, ]) expect(crossStream.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( - "[REDACTED]\n", + "[REDACTED]", ) expect(crossStream.map((event) => (event.type === "terminal.output" ? event.stream : ""))).toEqual([ "stdout", @@ -2154,8 +2283,9 @@ describe("redaction contracts", () => { { ...terminal, seq: 2, stream: "stderr", delta: 'diagnostic "\n' }, { ...terminal, seq: 3, delta: "hunter2\n" }, ]) - expect(crossStreamQuote.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")) - .toBe("[REDACTED][REDACTED][REDACTED]") + expect(crossStreamQuote.map((event) => (event.type === "terminal.output" ? event.delta : "")).join("")).toBe( + "[REDACTED][REDACTED][REDACTED]", + ) }) it("preserves prototype-like keys as redacted record data", () => { @@ -2185,6 +2315,7 @@ describe("redaction contracts", () => { expect(redactText(`API_\u009dtitle\u009cTOKEN=hunter2`)).toBe("[REDACTED]") expect(redactText("passX\bword=hunter2")).toBe("[REDACTED]") expect(redactText("passX\u001b[1Dword=hunter2")).toBe("[REDACTED]") + expect(redactText("word=hunter2\rpass")).toBe("[REDACTED]") expect(redactValue({ apikey: "one", apitoken: "two", authtoken: "three", accesstoken: "four" })).toEqual({ apikey: "[REDACTED]", apitoken: "[REDACTED]", @@ -2228,9 +2359,9 @@ describe("deterministic parity oracle", () => { ] expect(assertAuthoritativeRootResult(trace, "root")).toBe(false) expect(assertAuthoritativeRootResult(parityScenarios[2]!.expected, "root")).toBe(true) - expect(assertAuthoritativeRootResult([{ type: "task.result", taskId: "root", rootTaskId: "root" }], "root")).toBe( - false, - ) + expect( + assertAuthoritativeRootResult([{ type: "task.result", taskId: "root", rootTaskId: "root" }], "root"), + ).toBe(false) expect( assertAuthoritativeRootResult( [{ type: "task.result", taskId: "root", rootTaskId: "root", outcome: "failed" }], @@ -2393,7 +2524,12 @@ describe("deterministic parity oracle", () => { ["cancel:request-1:user:extra"], ]) { expect(() => - runDeterministicFakeProvider({ id: "extra-fields", prompt: "Reject extras", providerTurns, expected: [] }), + runDeterministicFakeProvider({ + id: "extra-fields", + prompt: "Reject extras", + providerTurns, + expected: [], + }), ).toThrow() } }) diff --git a/packages/zoo-protocol/src/public-events.ts b/packages/zoo-protocol/src/public-events.ts index b96ed38b01..9e98123ceb 100644 --- a/packages/zoo-protocol/src/public-events.ts +++ b/packages/zoo-protocol/src/public-events.ts @@ -161,7 +161,14 @@ const askAbandonedEventSchema = taskEvent("ask.abandoned", { reason: z.enum(["cancelled", "timed_out", "failed"]), }) const jsonValueSchema: z.ZodType = z.lazy(() => - z.union([z.null(), z.boolean(), z.number().finite(), z.string(), z.array(jsonValueSchema), z.record(jsonValueSchema)]), + z.union([ + z.null(), + z.boolean(), + z.number().finite(), + z.string(), + z.array(jsonValueSchema), + z.record(jsonValueSchema), + ]), ) const toolEventState = { toolCallId: z.string().min(1), @@ -198,39 +205,43 @@ const usageUpdatedEventSchema = taskEvent("usage.updated", { }) const taskResultEventSchema = taskEvent("task.result", { result: zooRunResultSchema }) -export const rawZooStreamEventSchema = z.discriminatedUnion("type", [ - systemInitEventSchema, - systemWarningEventSchema, - taskCreatedEventSchema, - taskStartedEventSchema, - taskLifecycleEventSchema, - taskResumedEventSchema, - taskDelegatedEventSchema, - messageUpsertEventSchema, - askRequiredEventSchema, - askResolvedEventSchema, - askAbandonedEventSchema, - toolStartedEventSchema, - toolUpdatedEventSchema, - toolCompletedEventSchema, - toolFailedEventSchema, - terminalOutputEventSchema, - terminalStatusEventSchema, - mcpStartedEventSchema, - mcpCompletedEventSchema, - mcpFailedEventSchema, - usageUpdatedEventSchema, - taskResultEventSchema, -]).superRefine((streamEvent, context) => { - if (streamEvent.type !== "terminal.status") return - const terminal = streamEvent.state === "exited" || streamEvent.state === "killed" - if (terminal === (streamEvent.exitCode === undefined)) { - context.addIssue({ - code: z.ZodIssueCode.custom, - message: terminal ? "Terminal states require an exit code or null" : "Nonterminal states cannot include an exit code", - }) - } -}) +export const rawZooStreamEventSchema = z + .discriminatedUnion("type", [ + systemInitEventSchema, + systemWarningEventSchema, + taskCreatedEventSchema, + taskStartedEventSchema, + taskLifecycleEventSchema, + taskResumedEventSchema, + taskDelegatedEventSchema, + messageUpsertEventSchema, + askRequiredEventSchema, + askResolvedEventSchema, + askAbandonedEventSchema, + toolStartedEventSchema, + toolUpdatedEventSchema, + toolCompletedEventSchema, + toolFailedEventSchema, + terminalOutputEventSchema, + terminalStatusEventSchema, + mcpStartedEventSchema, + mcpCompletedEventSchema, + mcpFailedEventSchema, + usageUpdatedEventSchema, + taskResultEventSchema, + ]) + .superRefine((streamEvent, context) => { + if (streamEvent.type !== "terminal.status") return + const terminal = streamEvent.state === "exited" || streamEvent.state === "killed" + if (terminal === (streamEvent.exitCode === undefined)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: terminal + ? "Terminal states require an exit code or null" + : "Nonterminal states cannot include an exit code", + }) + } + }) const redactStreamEvent = (streamEvent: z.infer) => { switch (streamEvent.type) { @@ -314,7 +325,7 @@ export function createZooStreamRedactor( pem: boolean overflowed: boolean secretQuotes: Partial["stream"], '"' | "'">> - secretValueContinuation?: boolean + secretValueContinuations: Partial["stream"], true>> } const pendingOutputs = new Map() let failClosedAll = false @@ -331,7 +342,8 @@ export function createZooStreamRedactor( } const incompleteSecretQuote = (text: string): '"' | "'" | undefined => { const match = text.match(/(?:^|[,{;\s])["']?([A-Za-z0-9_. -]+)["']?\s*[:=]\s*(["'])(?:\\.|[^\\])*$/i) - if (match === null || !isSensitiveKey(match[1] ?? "") || (match[2] !== '"' && match[2] !== "'")) return undefined + if (match === null || !isSensitiveKey(match[1] ?? "") || (match[2] !== '"' && match[2] !== "'")) + return undefined const opening = /[:=]\s*(["'])/.exec(match[0]) if (opening === null) return undefined const value = match[0].slice(opening.index + opening[0].length) @@ -340,7 +352,11 @@ export function createZooStreamRedactor( const closesSecretQuote = (text: string, quote: '"' | "'"): boolean => new RegExp(`(?:^|[^\\\\])${quote}`).test(text) const incompleteSecretValue = (text: string): boolean => { - const line = text.replace(/\r?\n$/, "").split(/\r?\n/).at(-1) ?? "" + const line = + text + .replace(/\r?\n$/, "") + .split(/\r?\n/) + .at(-1) ?? "" const match = line.match(/(?:^|[,{;\s])["']?([A-Za-z0-9_. -]+)["']?\s*[:=]\s*$/i) return match !== null && isSensitiveKey(match[1] ?? "") } @@ -353,10 +369,10 @@ export function createZooStreamRedactor( return [] } const [first, ...rest] = pending.events - const detectionText = canonicalizeRedactionText(pending.text) const continuedSecret = Object.keys(pending.secretQuotes).length > 0 - const continuedValue = pending.secretValueContinuation === true + const continuedValue = Object.keys(pending.secretValueContinuations).length > 0 const secretQuotes = { ...pending.secretQuotes } + const secretValueContinuations = { ...pending.secretValueContinuations } for (const [stream, text] of Object.entries(pending.textByStream) as Array< [z.infer["stream"], string] >) { @@ -368,16 +384,20 @@ export function createZooStreamRedactor( const quote = incompleteSecretQuote(streamText) if (quote !== undefined) secretQuotes[stream] = quote } + if (secretValueContinuations[stream] === true) { + if (!incompleteSecretValue(streamText)) delete secretValueContinuations[stream] + } else if (incompleteSecretValue(streamText)) { + secretValueContinuations[stream] = true + } } - const secretValueContinuation = !continuedValue && incompleteSecretValue(detectionText) const unterminatedSecret = pending.pem || Object.keys(secretQuotes).length > 0 || - secretValueContinuation || + Object.keys(secretValueContinuations).length > 0 || requiresFailClosedRedaction(pending.text) const delta = replacement ?? (unterminatedSecret ? REDACTED : String(redactValue(pending.text))) pendingOutputs.delete(key) - if (Object.keys(secretQuotes).length > 0 || secretValueContinuation) { + if (Object.keys(secretQuotes).length > 0 || Object.keys(secretValueContinuations).length > 0) { pendingOutputs.set(key, { events: [], text: "", @@ -385,7 +405,7 @@ export function createZooStreamRedactor( pem: false, overflowed: false, secretQuotes, - secretValueContinuation: secretValueContinuation || undefined, + secretValueContinuations, }) } return first === undefined @@ -427,7 +447,15 @@ export function createZooStreamRedactor( const buffered = [...pendingOutputs.keys()].flatMap((pendingKey) => emit(pendingKey, REDACTED)) return [...buffered, { ...event, delta: event.delta.length === 0 ? "" : REDACTED }] } - pending = { events: [], text: "", textByStream: {}, pem: false, overflowed: false, secretQuotes: {} } + pending = { + events: [], + text: "", + textByStream: {}, + pem: false, + overflowed: false, + secretQuotes: {}, + secretValueContinuations: {}, + } pendingOutputs.set(key, pending) } if (pending.overflowed) return [{ ...event, delta: event.delta.length === 0 ? "" : REDACTED }] @@ -435,7 +463,10 @@ export function createZooStreamRedactor( pending.text += event.delta pending.textByStream[event.stream] = (pending.textByStream[event.stream] ?? "") + event.delta pending.pem = hasUnmatchedPem(canonicalizeRedactionText(pending.text)) - if (new TextEncoder().encode(pending.text).byteLength > maxPendingBytes || pending.events.length > maxPendingEvents) { + if ( + new TextEncoder().encode(pending.text).byteLength > maxPendingBytes || + pending.events.length > maxPendingEvents + ) { pending.overflowed = true const redacted = emit(key, REDACTED) pendingOutputs.set(key, { @@ -445,6 +476,7 @@ export function createZooStreamRedactor( pem: false, overflowed: true, secretQuotes: {}, + secretValueContinuations: {}, }) return redacted } @@ -466,6 +498,7 @@ export function createZooStreamRedactor( pem: false, overflowed: true, secretQuotes: {}, + secretValueContinuations: {}, }) return redacted }) @@ -555,7 +588,11 @@ export function validateStreamLifecycle( eventEnvelopes.length !== events.length || events.some((streamEvent, index) => !isDeepStrictEqual(eventEnvelopes[index]?.event, streamEvent)) ) { - return { ok: false, code: "protocol_gap", message: "Public events must exactly match their ordered host envelopes" } + return { + ok: false, + code: "protocol_gap", + message: "Public events must exactly match their ordered host envelopes", + } } const results = events.filter((event) => event.type === "task.result") if (results.length !== 1 || events.at(-1)?.type !== "task.result") { @@ -567,13 +604,31 @@ export function validateStreamLifecycle( return { ok: false, code: "task_failed", message: "task.result must identify the authoritative root task" } } const resumedEvents = events.filter((streamEvent) => streamEvent.type === "task.resumed") - const runCommands = commands - const initiatingCandidates = runCommands.filter((command) => command.type === "task.start" || command.type === "task.resume") + const initiatingCandidates = commands.filter( + (command) => command.type === "task.start" || command.type === "task.resume", + ) const initiatingCommand = lifecycleScope ? initiatingCandidates.find((command) => command.id === lifecycleScope.initiatingCommandId) : initiatingCandidates.length === 1 ? initiatingCandidates[0] : undefined + const treeTaskIds = new Set( + events + .filter((event): event is ZooStreamEvent & { type: "task.created" } => event.type === "task.created") + .map((event) => event.taskId), + ) + const explicitlyScopedCommandIds = + lifecycleScope?.commandIds === undefined ? undefined : new Set(lifecycleScope.commandIds) + const targetsRunTree = (command: HostCommand): boolean => { + if (command.id === initiatingCommand?.id) return true + if (command.type === "task.resume" || command.type === "task.cancel") return command.rootTaskId === rootTaskId + if (command.type === "task.input" || command.type === "ask.respond") return treeTaskIds.has(command.taskId) + return false + } + const runCommands = + explicitlyScopedCommandIds === undefined + ? commands + : commands.filter((command) => explicitlyScopedCommandIds.has(command.id) || targetsRunTree(command)) const startCommands = initiatingCommand?.type === "task.start" ? [initiatingCommand] : [] const resumeCommands = initiatingCommand?.type === "task.resume" ? [initiatingCommand] : [] if (initiatingCommand === undefined) { @@ -591,7 +646,11 @@ export function validateStreamLifecycle( resultEvent.result.workspace !== start.workspace || (resultEvent.result.outcome !== "cancelled" && resultEvent.requestId !== start.id) ) { - return { ok: false, code: "task_failed", message: "Fresh streams must match exactly one task.start command" } + return { + ok: false, + code: "task_failed", + message: "Fresh streams must match exactly one task.start command", + } } } else if (resumedEvents.length !== 1 || startCommands.length !== 0 || resumeCommands.length !== 1) { return { ok: false, code: "task_failed", message: "Resume streams must match exactly one task.resume command" } @@ -640,7 +699,8 @@ export function validateStreamLifecycle( map.set(taskId, created) return created } - const values = (map: Map>): T[] => [...map.values()].flatMap((entries) => [...entries.values()]) + const values = (map: Map>): T[] => + [...map.values()].flatMap((entries) => [...entries.values()]) const isDescendantOf = (taskId: string, parentTaskId: string): boolean => { let current = taskParents.get(taskId) while (current !== undefined && current !== null) { @@ -701,7 +761,11 @@ export function validateStreamLifecycle( } for (const response of runCommands.filter((command) => command.type === "ask.respond")) { if (causalTerminal(response.id) === undefined) { - return { ok: false, code: "protocol_gap", message: "Every ask response requires ACK and one terminal response" } + return { + ok: false, + code: "protocol_gap", + message: "Every ask response requires ACK and one terminal response", + } } } const initiatingTerminal = initiatingCommand === undefined ? undefined : causalTerminal(initiatingCommand.id) @@ -753,7 +817,11 @@ export function validateStreamLifecycle( } taskParents.set(streamEvent.taskId, parentTaskId) createdTasks.add(streamEvent.taskId) - if (streamEvent.taskId === rootTaskId && resumedEvents.length === 0 && streamEvent.requestId !== startCommands[0]?.id) { + if ( + streamEvent.taskId === rootTaskId && + resumedEvents.length === 0 && + streamEvent.requestId !== startCommands[0]?.id + ) { return { ok: false, code: "task_failed", message: "Root creation must match its task.start request" } } if (streamEvent.taskId === rootTaskId && causalTerminal(initiatingCommand.id, streamEvent) === undefined) { @@ -798,7 +866,11 @@ export function validateStreamLifecycle( streamEvent.type !== "task.delegated" && !delegatedTasks.has(streamEvent.taskId) ) { - return { ok: false, code: "task_failed", message: `Task ${streamEvent.taskId} emitted an event before delegation` } + return { + ok: false, + code: "task_failed", + message: `Task ${streamEvent.taskId} emitted an event before delegation`, + } } const previousState = taskStates.get(streamEvent.taskId) @@ -828,7 +900,9 @@ export function validateStreamLifecycle( if (streamEvent.type === "task.started") { const parentTaskId = taskParents.get(streamEvent.taskId) const reconstructingResumedDescendant = - resumedEvents.length === 1 && resumeCommands[0]?.taskId === streamEvent.taskId && resumedTasks.has(streamEvent.taskId) + resumedEvents.length === 1 && + resumeCommands[0]?.taskId === streamEvent.taskId && + resumedTasks.has(streamEvent.taskId) if ( startedTasks.has(streamEvent.taskId) || (previousState !== undefined && previousState !== "running") || @@ -838,7 +912,11 @@ export function validateStreamLifecycle( taskStates.get(parentTaskId) !== "running" && !reconstructingResumedDescendant) ) { - return { ok: false, code: "task_failed", message: `Invalid start transition for task ${streamEvent.taskId}` } + return { + ok: false, + code: "task_failed", + message: `Invalid start transition for task ${streamEvent.taskId}`, + } } startedTasks.add(streamEvent.taskId) taskStates.set(streamEvent.taskId, "running") @@ -851,11 +929,16 @@ export function validateStreamLifecycle( !resumedTasks.has(streamEvent.taskId) && (streamEvent.state === "waiting" || streamEvent.state === "interrupted") if (!startedTasks.has(streamEvent.taskId) && !reconstructingPredecessor) { - return { ok: false, code: "task_failed", message: "Task lifecycle requires an ordered task.started event" } + return { + ok: false, + code: "task_failed", + message: "Task lifecycle requires an ordered task.started event", + } } const transitionAllowed = reconstructingPredecessor || - (previousState === "running" && ["waiting", "interrupted", "completed", "failed"].includes(streamEvent.state)) || + (previousState === "running" && + ["waiting", "interrupted", "completed", "failed"].includes(streamEvent.state)) || (previousState === "waiting" && (["interrupted", "failed"].includes(streamEvent.state) || (streamEvent.state === "running" && @@ -870,7 +953,9 @@ export function validateStreamLifecycle( } } if ( - (streamEvent.state === "running" || streamEvent.state === "waiting" || streamEvent.state === "completed") && + (streamEvent.state === "running" || + streamEvent.state === "waiting" || + streamEvent.state === "completed") && streamEvent.cause !== undefined ) { return { ok: false, code: "task_failed", message: "Lifecycle cause contradicts task state" } @@ -887,7 +972,8 @@ export function validateStreamLifecycle( if ( settledStates.has(streamEvent.state) && [...createdTasks].some( - (taskId) => isDescendantOf(taskId, streamEvent.taskId) && !settledStates.has(taskStates.get(taskId) ?? ""), + (taskId) => + isDescendantOf(taskId, streamEvent.taskId) && !settledStates.has(taskStates.get(taskId) ?? ""), ) ) { return { ok: false, code: "task_failed", message: "A task cannot terminate before its descendants" } @@ -906,7 +992,11 @@ export function validateStreamLifecycle( resumedTasks.size > 0 || previousState !== streamEvent.previousState ) { - return { ok: false, code: "task_failed", message: "task.resumed must match reconstructed persisted state" } + return { + ok: false, + code: "task_failed", + message: "task.resumed must match reconstructed persisted state", + } } if (causalTerminal(resume!.id, streamEvent) === undefined) { return { ok: false, code: "protocol_gap", message: "Task resume must follow its command ACK" } @@ -952,10 +1042,24 @@ export function validateStreamLifecycle( return { ok: false, code: "task_failed", message: "Task operations require an unblocked running task" } } if (streamEvent.type === "message.upsert") { + const matchingInput = runCommands.find( + (command) => command.type === "task.input" && command.id === streamEvent.requestId, + ) + if (streamEvent.role === "user" && matchingInput !== undefined && !inputResumeCause(streamEvent)) { + return { + ok: false, + code: "task_failed", + message: "Task input must cause exactly one matching user message", + } + } const messages = scope(messageStates, streamEvent.taskId) const previous = messages.get(streamEvent.messageId) if (previous?.complete === true || (previous !== undefined && previous.role !== streamEvent.role)) { - return { ok: false, code: "task_failed", message: `Invalid update for message ${streamEvent.messageId}` } + return { + ok: false, + code: "task_failed", + message: `Invalid update for message ${streamEvent.messageId}`, + } } messages.set(streamEvent.messageId, { role: streamEvent.role, complete: streamEvent.complete }) } @@ -991,9 +1095,7 @@ export function validateStreamLifecycle( ) if (streamEvent.source === "user" || responseCommands.length > 0) { const response = responseCommands.find( - (command) => - !consumedResponseCommands.has(command.id) && - command.id === streamEvent.requestId, + (command) => !consumedResponseCommands.has(command.id) && command.id === streamEvent.requestId, ) const expectedDecision = response?.type === "ask.respond" @@ -1128,7 +1230,11 @@ export function validateStreamLifecycle( } } if (resultEvent.result.currentTaskId !== undefined && !createdTasks.has(resultEvent.result.currentTaskId)) { - return { ok: false, code: "task_failed", message: "currentTaskId must identify a task in the authoritative tree" } + return { + ok: false, + code: "task_failed", + message: "currentTaskId must identify a task in the authoritative tree", + } } const rootState = taskStates.get(rootTaskId) if (rootState !== expectedState[resultEvent.result.outcome]) { @@ -1177,10 +1283,34 @@ export function validateStreamLifecycle( if (unconsumedResponses) { return { ok: false, code: "task_failed", message: "Every ask response command must settle its matching ask" } } - const cancelCommands = runCommands.filter((command) => command.type === "task.cancel" && command.rootTaskId === rootTaskId) + const invalidInputs = runCommands.some((command) => { + if (command.type !== "task.input") return false + const terminal = causalTerminal(command.id) + if (terminal?.type !== "command.done") return false + return ( + !createdTasks.has(command.taskId) || + terminal.data.commandType !== "task.input" || + terminal.data.taskId !== command.taskId || + !consumedInputCommands.has(command.id) + ) + }) + if (invalidInputs) { + return { + ok: false, + code: "task_failed", + message: "Every successful task input must have one matching tree effect", + } + } + const cancelCommands = runCommands.filter( + (command) => command.type === "task.cancel" && command.rootTaskId === rootTaskId, + ) const cancellationTerminals = cancelCommands.map((command) => causalTerminal(command.id)) if (cancellationTerminals.some((terminal) => terminal === undefined)) { - return { ok: false, code: "task_failed", message: "Every cancellation command requires ACK and one terminal response" } + return { + ok: false, + code: "task_failed", + message: "Every cancellation command requires ACK and one terminal response", + } } if ( cancellationTerminals.some( diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index 477f4d08c3..fe79d3afa5 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -31,7 +31,14 @@ const secretPatterns: ReadonlyArray = [ /-----BEGIN (?:[A-Z ]*PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----[\s\S]*?-----END (?:[A-Z ]*PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----/g, ] -export type RedactedValue = null | undefined | boolean | number | string | RedactedValue[] | { [key: string]: RedactedValue } +export type RedactedValue = + | null + | undefined + | boolean + | number + | string + | RedactedValue[] + | { [key: string]: RedactedValue } export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } export function isSensitiveKey(key: string): boolean { @@ -49,7 +56,11 @@ export function isSensitiveKey(key: string): boolean { ) return true if (/^[a-z0-9]+(?:password|secret|passphrase|passwd|pwd)$/.test(compact)) return true - if (/^(?:.*)?(?:apikey|apitoken|accesstoken|authtoken|bearertoken|idtoken|privatekey|refreshtoken|sessiontoken)$/.test(compact)) { + if ( + /^(?:.*)?(?:apikey|apitoken|accesstoken|authtoken|bearertoken|idtoken|privatekey|refreshtoken|sessiontoken)$/.test( + compact, + ) + ) { return true } if ( @@ -65,8 +76,10 @@ export function isSensitiveKey(key: string): boolean { } export function requiresFailClosedRedaction(value: string): boolean { - if (unsafeTerminalEditing.test(value)) return true - return [...value.matchAll(new RegExp(terminalControl.source, "g"))].some(([control]) => containsSensitiveAssignment(control)) + if (unsafeTerminalEditing.test(value) || /(?:\r(?!\n)|[\v\f])/.test(value)) return true + return [...value.matchAll(new RegExp(terminalControl.source, "g"))].some(([control]) => + containsSensitiveAssignment(control), + ) } function containsSensitiveAssignment(value: string): boolean { @@ -83,7 +96,8 @@ function redactAssignments(value: string): string { return unquoted === REDACTED } const redactValueText = (prefix: string, entry: string) => { - const quote = entry.startsWith('"') && entry.endsWith('"') ? '"' : entry.startsWith("'") && entry.endsWith("'") ? "'" : "" + const quote = + entry.startsWith('"') && entry.endsWith('"') ? '"' : entry.startsWith("'") && entry.endsWith("'") ? "'" : "" return `${prefix}${quote}${REDACTED}${quote}` } return value From 4a80564a740f971d45c46970a984af5f796e3dc8 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 14:19:58 -0400 Subject: [PATCH 24/24] fix(cli): make assignment redaction linear --- packages/zoo-protocol/src/redaction.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/zoo-protocol/src/redaction.ts b/packages/zoo-protocol/src/redaction.ts index fe79d3afa5..f225db8c3d 100644 --- a/packages/zoo-protocol/src/redaction.ts +++ b/packages/zoo-protocol/src/redaction.ts @@ -83,8 +83,27 @@ export function requiresFailClosedRedaction(value: string): boolean { } function containsSensitiveAssignment(value: string): boolean { - const candidates = value.matchAll(/([A-Za-z][A-Za-z0-9_. -]*)\s*[:=]/g) - return [...candidates].some((match) => isSensitiveKey(match[1] ?? "")) + let candidateStart = 0 + for (let index = 0; index < value.length; index += 1) { + const character = value[index]! + if (character === ":" || character === "=") { + const candidate = value.slice(candidateStart, index).trim() + if (candidate.length > 0 && isSensitiveKey(candidate)) return true + candidateStart = index + 1 + continue + } + const code = character.charCodeAt(0) + const allowed = + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + character === "_" || + character === "." || + character === "-" || + character === " " + if (!allowed) candidateStart = index + 1 + } + return false } function redactAssignments(value: string): string {