FN-7411: prevent task-bound self deletion
Prevent task-bound callers from soft-deleting the task they are currently executing. - Add a TaskSelfDeleteError guard in TaskStore.deleteTask before mutation or audit emission. - Pass the current task id through the fn_task_delete audit context so CLI tool calls inherit the store invariant. - Cover self-delete rejection and cross-task deletion allowance in core and CLI tests. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-7411-self-delete-guard.md | 7 +++ .../task-delete-allow-resurrection.test.ts | 48 ++++++++++++++++- packages/cli/src/extension.ts | 2 + .../src/__tests__/store-self-delete-guard.test.ts | 60 ++++++++++++++++++++++ packages/core/src/store.ts | 22 +++++++- 5 files changed, 136 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7411 Fusion-Task-Lineage: 50028769-5396-4435-84fb-2ae182315e81 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7411-self-delete-guard.md
Normal file
7
.changeset/fn-7411-self-delete-guard.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Prevent task-bound agents from deleting the task they are currently executing.
|
||||||
|
category: fix
|
||||||
|
dev: TaskStore.deleteTask now rejects audit contexts whose caller task matches the delete target.
|
||||||
@@ -4,11 +4,11 @@ import { join } from "node:path";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
import { TaskStore } from "@fusion/core";
|
import { TaskStore } from "@fusion/core";
|
||||||
import kbExtension from "../extension.js";
|
import kbExtension, { closeCachedStores } from "../extension.js";
|
||||||
|
|
||||||
type RegisteredTool = {
|
type RegisteredTool = {
|
||||||
name: string;
|
name: string;
|
||||||
execute: (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: { cwd: string }) => Promise<any>;
|
execute: (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: { cwd: string; taskId?: string; agentId?: string; runId?: string }) => Promise<any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMockAPI() {
|
function createMockAPI() {
|
||||||
@@ -36,6 +36,7 @@ describe("task delete allowResurrection plumbing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
await closeCachedStores();
|
||||||
await rm(rootDir, { recursive: true, force: true });
|
await rm(rootDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,4 +69,47 @@ describe("task delete allowResurrection plumbing", () => {
|
|||||||
expect(deleted.deletedAt).toBeTruthy();
|
expect(deleted.deletedAt).toBeTruthy();
|
||||||
expect(deleted.allowResurrection).toBeUndefined();
|
expect(deleted.allowResurrection).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fn_task_delete rejects deleting the caller task and leaves it live", async () => {
|
||||||
|
const store = new TaskStore(rootDir);
|
||||||
|
await store.init();
|
||||||
|
const task = await store.createTask({ title: "self", description: "current task", column: "in-progress" });
|
||||||
|
|
||||||
|
const api = createMockAPI();
|
||||||
|
kbExtension(api);
|
||||||
|
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tool.execute("call-self", { id: task.id }, undefined, undefined, {
|
||||||
|
cwd: rootDir,
|
||||||
|
taskId: task.id,
|
||||||
|
agentId: "agent-test",
|
||||||
|
runId: "run-test",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(`Task ${task.id} cannot delete itself`);
|
||||||
|
|
||||||
|
const row = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||||
|
expect(row.deletedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fn_task_delete lets a task-bound caller delete a different task", async () => {
|
||||||
|
const store = new TaskStore(rootDir);
|
||||||
|
await store.init();
|
||||||
|
const caller = await store.createTask({ title: "caller", description: "current task", column: "in-progress" });
|
||||||
|
const target = await store.createTask({ title: "target", description: "cleanup target", column: "todo" });
|
||||||
|
|
||||||
|
const api = createMockAPI();
|
||||||
|
kbExtension(api);
|
||||||
|
const tool = api.tools.get("fn_task_delete") as RegisteredTool;
|
||||||
|
const result = await tool.execute("call-other", { id: target.id }, undefined, undefined, {
|
||||||
|
cwd: rootDir,
|
||||||
|
taskId: caller.id,
|
||||||
|
agentId: "agent-test",
|
||||||
|
runId: "run-test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.content[0]?.text).toBe(`Deleted ${target.id}`);
|
||||||
|
const deleted = (store as any).readTaskFromDb(target.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||||
|
expect(deleted.deletedAt).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1561,11 +1561,13 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
const store = await getStore(ctx.cwd);
|
const store = await getStore(ctx.cwd);
|
||||||
|
const callerTaskId = (ctx as { taskId?: string }).taskId;
|
||||||
const task = await store.deleteTask(params.id, {
|
const task = await store.deleteTask(params.id, {
|
||||||
allowResurrection: params.allowResurrection === true,
|
allowResurrection: params.allowResurrection === true,
|
||||||
auditContext: {
|
auditContext: {
|
||||||
agentId: "pi-extension",
|
agentId: "pi-extension",
|
||||||
runId: `synthetic-pi-delete-${params.id}-${Date.now()}`,
|
runId: `synthetic-pi-delete-${params.id}-${Date.now()}`,
|
||||||
|
taskId: callerTaskId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
60
packages/core/src/__tests__/store-self-delete-guard.test.ts
Normal file
60
packages/core/src/__tests__/store-self-delete-guard.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { TaskSelfDeleteError } from "../store.js";
|
||||||
|
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("TaskStore.deleteTask self-delete guard (FN-7411)", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.afterEach();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the audit context task is the deletion target before mutation or audit", async () => {
|
||||||
|
const store = harness.store();
|
||||||
|
const task = await store.createTask({ title: "self", description: "do not delete self", column: "in-progress" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.deleteTask(task.id, {
|
||||||
|
auditContext: {
|
||||||
|
agentId: "agent-test",
|
||||||
|
runId: "run-test",
|
||||||
|
taskId: task.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
name: "TaskSelfDeleteError",
|
||||||
|
code: "TASK_SELF_DELETE",
|
||||||
|
taskId: task.id,
|
||||||
|
message: `Task ${task.id} cannot delete itself`,
|
||||||
|
} satisfies Partial<TaskSelfDeleteError>);
|
||||||
|
|
||||||
|
const row = (store as any).readTaskFromDb(task.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||||
|
expect(row.deletedAt).toBeUndefined();
|
||||||
|
expect(store.getRunAuditEvents({ taskId: task.id, mutationType: "task:deleted" })).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a task-bound caller to delete a different task", async () => {
|
||||||
|
const store = harness.store();
|
||||||
|
const caller = await store.createTask({ title: "caller", description: "current task", column: "in-progress" });
|
||||||
|
const target = await store.createTask({ title: "target", description: "cleanup target", column: "todo" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.deleteTask(target.id, {
|
||||||
|
auditContext: {
|
||||||
|
agentId: "agent-test",
|
||||||
|
runId: "run-test",
|
||||||
|
taskId: caller.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ id: target.id });
|
||||||
|
|
||||||
|
const deleted = (store as any).readTaskFromDb(target.id, { includeDeleted: true }) as { deletedAt?: string };
|
||||||
|
expect(deleted.deletedAt).toBeTruthy();
|
||||||
|
expect(store.getRunAuditEvents({ taskId: target.id, mutationType: "task:deleted" })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1085,6 +1085,17 @@ export class TaskHasDependentsError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TaskSelfDeleteError extends Error {
|
||||||
|
readonly taskId: string;
|
||||||
|
readonly code = "TASK_SELF_DELETE";
|
||||||
|
|
||||||
|
constructor(taskId: string) {
|
||||||
|
super(`Task ${taskId} cannot delete itself`);
|
||||||
|
this.name = "TaskSelfDeleteError";
|
||||||
|
this.taskId = taskId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class TaskDeletedError extends Error {
|
export class TaskDeletedError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly taskId: string,
|
public readonly taskId: string,
|
||||||
@@ -11364,10 +11375,19 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
|
|||||||
removeLineageReferences?: boolean;
|
removeLineageReferences?: boolean;
|
||||||
allowResurrection?: boolean;
|
allowResurrection?: boolean;
|
||||||
githubIssueAction?: GithubIssueAction;
|
githubIssueAction?: GithubIssueAction;
|
||||||
auditContext?: { agentId: string; runId: string; sessionId?: string };
|
auditContext?: { agentId: string; runId: string; sessionId?: string; taskId?: string };
|
||||||
},
|
},
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
const deletedTask = await this.withTaskLock(id, async () => {
|
const deletedTask = await this.withTaskLock(id, async () => {
|
||||||
|
/*
|
||||||
|
FNXC:TaskDeletion 2026-07-01-00:00:
|
||||||
|
Task-bound runtime callers may clean up other tasks, but the executing task must never soft-delete itself because that hides active work before the executor can finish or report failure.
|
||||||
|
Enforce this at the store boundary so future task-delete bridges inherit the same invariant before any mutation, branch cleanup, or task:deleted audit emission.
|
||||||
|
*/
|
||||||
|
if (options?.auditContext?.taskId === id) {
|
||||||
|
throw new TaskSelfDeleteError(id);
|
||||||
|
}
|
||||||
|
|
||||||
// Flush buffered agent logs inside the lock so no new appends for this
|
// Flush buffered agent logs inside the lock so no new appends for this
|
||||||
// task can sneak in between flush and soft-delete mutation.
|
// task can sneak in between flush and soft-delete mutation.
|
||||||
this.flushAgentLogBuffer();
|
this.flushAgentLogBuffer();
|
||||||
|
|||||||
Reference in New Issue
Block a user