From 241d79379c2b152a6c4d9cc8878c67953161bf1a Mon Sep 17 00:00:00 2001 From: krandder Date: Tue, 17 Mar 2026 18:38:24 +0000 Subject: [PATCH] Safety fixes: parent-done guard, verification nullsafe, metadata inheritance, role leak 1. Parent-done guard (verifyCompletion): - Layer 1: block marking parent done if any children are non-terminal - Layer 2: block if completionRule=and and not all terminal children are done - Layer 3: block if metadata.repo set but stateRef missing/zeroed 2. Role_mismatch identity leak: remove agent ID and assignee/reviewer names from error messages to prevent agents from learning who to impersonate 3. Metadata inheritance in decompose handlers: children now inherit all parent metadata (minus bookkeeping fields like claimedAt, claimedBy, etc.) instead of only priority/assignee/reviewer 4. applyLeaseGranted ts fallback: use Date.now() when event.ts is missing to prevent leaseExpiresAt = NaN from external tool events 5. Type fixes (pre-existing compilation errors): - Add "file" to ArtifactKind union + path/sizeBytes to ArtifactEvidence - Add optional source to TaskCompleted and TaskFailed interfaces --- core/reducer.ts | 5 ++- core/types.ts | 6 ++- middle/http.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/core/reducer.ts b/core/reducer.ts index 766d128..179165c 100644 --- a/core/reducer.ts +++ b/core/reducer.ts @@ -262,10 +262,11 @@ function applyTaskCreated(state: SystemState, event: Extract, task: Task): void { + const ts = event.ts ?? Date.now(); const t = deepCloneTask(task); t.currentFenceToken = event.fenceToken; t.leasedTo = event.agentId; - t.leaseExpiresAt = event.ts + event.leaseTimeout; + t.leaseExpiresAt = ts + event.leaseTimeout; t.phase = event.phase; t.condition = "active"; t.retryAfter = null; @@ -273,7 +274,7 @@ function applyLeaseGranted(state: SystemState, event: Extract 0) { + const children = core.getChildren(task.id); + const pending = children.filter((c) => c.terminal === null); + if (pending.length > 0) { + return { + status: 422, + body: { + error: "children_pending", + message: + `Cannot mark done: ${pending.length} child(ren) still pending: ` + + pending.map((c) => c.id).join(", ") + + ". Cancel or complete them first.", + }, + }; + } + + // (c) Layer 2: completionRule="and" requires all terminal children to be "done" + if (task.completionRule === "and") { + const notDone = children.filter((c) => c.terminal !== "done"); + if (notDone.length > 0) { + return { + status: 422, + body: { + error: "children_not_done", + message: + `Task has completionRule='and' but ${notDone.length} child(ren) are not done: ` + + notDone.map((c) => c.id).join(", "), + }, + }; + } + } + } + + return null; +} + function applyDoneTransition( core: Core, config: Config, @@ -1732,6 +1800,10 @@ function applyDoneTransition( evidence?: string, stateRef?: StateRef, ): RouteResult { + // Pre-flight completion checks (children guard, stateRef guard) + const completionErr = verifyCompletion(core, task, stateRef); + if (completionErr) return completionErr; + if (task.phase === "execution" && task.condition === "active" && task.reviewConfig === null) { // Verify artifacts before allowing completion const verification = verifyArtifacts(task, config); @@ -2535,10 +2607,17 @@ function handleDecomposeCommit( } } - const parentPriority = (task.metadata["priority"] as string | undefined) ?? "medium"; - const metadata: Record = {}; + // Inherit all parent metadata, then override with child-specific values + const metadata: Record = { ...task.metadata }; + // Remove parent-specific bookkeeping that shouldn't propagate + delete metadata["createdBy"]; delete metadata["createdAt"]; + delete metadata["claimedAt"]; delete metadata["claimedBy"]; + delete metadata["claimSessionId"]; delete metadata["claimSource"]; + delete metadata["last_update"]; delete metadata["last_update_at"]; + // Apply child overrides if (child.assignee) metadata["assignee"] = child.assignee; if (child.reviewer) metadata["reviewer"] = child.reviewer; + const parentPriority = (task.metadata["priority"] as string | undefined) ?? "medium"; const childPriority = ("priority" in child ? (child as { priority?: string }).priority : undefined) ?? "medium"; metadata["priority"] = maxPriority(parentPriority, childPriority); @@ -2779,10 +2858,17 @@ function handleDecompose( } } - const parentPriority = (task.metadata["priority"] as string | undefined) ?? "medium"; - const metadata: Record = {}; + // Inherit all parent metadata, then override with child-specific values + const metadata: Record = { ...task.metadata }; + // Remove parent-specific bookkeeping that shouldn't propagate + delete metadata["createdBy"]; delete metadata["createdAt"]; + delete metadata["claimedAt"]; delete metadata["claimedBy"]; + delete metadata["claimSessionId"]; delete metadata["claimSource"]; + delete metadata["last_update"]; delete metadata["last_update_at"]; + // Apply child overrides if (child.assignee) metadata["assignee"] = child.assignee; if (child.reviewer) metadata["reviewer"] = child.reviewer; + const parentPriority = (task.metadata["priority"] as string | undefined) ?? "medium"; const childPriority = ("priority" in child ? (child as { priority?: string }).priority : undefined) ?? "medium"; metadata["priority"] = maxPriority(parentPriority, childPriority);