Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/lib/tools/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,8 @@ const localToolOptions: RuntimeToolOption[] = [
{
id: "tool:patchLoopTool",
label: "Patch loop",
description: "Start a passive patch plan from a validated finding or explicit user request.",
description:
"Plan a passive patch proposal for a persisted validated finding; direct user-request admission requires an app-owned deliberate action.",
kind: "tool",
risk: "passive",
},
Expand Down
2 changes: 1 addition & 1 deletion src/mastra/config/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export const securityResearchEditorToolConfig: Record<string, StorageToolConfig>
},
patchLoopTool: {
description:
"Start a passive patch loop from a validated finding or explicit user request, including branch plan, planned tests, and optional diff artifact evidence.",
"Plan a passive patch proposal for a persisted validated finding. Direct user-request admission is not exposed until the app provides a server-owned deliberate action.",
},
graphQlImportTool: {
description:
Expand Down
11 changes: 5 additions & 6 deletions src/mastra/tools/patch-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,10 @@ type FindingRow = {
export const patchLoopTool = createTool({
id: "security-patch-loop",
description:
"Plans a patch loop from a validated finding or explicit user request. Coding-agent execution remains unavailable unless the app provides an isolated clone and confined ACP launcher; it never falls back to a host process.",
"Plans a patch loop from a server-resolved validated finding. Explicit user-request admission requires an app-owned action and cannot be asserted by the model. Coding-agent execution remains unavailable unless the app provides an isolated clone and confined ACP launcher; it never falls back to a host process.",
inputSchema: z.object({
findingId: z.string().optional(),
explicitRequest: z.string().optional(),
objective: z.string().optional(),
requestedBy: z.string().optional(),
targetId: z.string().optional(),
projectScoped: z.boolean().optional(),
baseBranch: z.string().optional(),
Expand Down Expand Up @@ -142,9 +140,7 @@ export const patchLoopTool = createTool({
projectId,
...(threadId ? { threadId } : {}),
...(input.findingId ? { findingId: input.findingId } : {}),
...(input.explicitRequest ? { explicitRequest: input.explicitRequest } : {}),
...(input.objective ? { objective: input.objective } : {}),
...(input.requestedBy ? { requestedBy: input.requestedBy } : {}),
...(input.targetId ? { targetId: input.targetId } : {}),
...(typeof input.projectScoped === "boolean" ? { projectScoped: input.projectScoped } : {}),
...(input.baseBranch ? { baseBranch: input.baseBranch } : {}),
Expand Down Expand Up @@ -190,7 +186,10 @@ export const patchLoopTool = createTool({
status: "refused" as const,
projectId,
reason: result.reason,
message: result.message,
message:
result.reason === "validated-finding-or-explicit-request-required"
? "This tool requires a persisted validated finding. Direct user-request admission requires an app-owned deliberate action."
: result.message,
};
}

Expand Down
127 changes: 84 additions & 43 deletions tests/integration/patch-loop-tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,120 @@
import { describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

import { patchLoopTool } from "../../src/mastra/tools/patch-loop";
import { withDatabase } from "../../src/server/db/client";

const requestContext = (values: Record<string, unknown>) => ({
get: (key: string) => values[key],
});

describe("patch loop Mastra tool", () => {
const suffix = `${process.pid}-${Date.now()}`;
const projectId = `patch-tool-project-${suffix}`;
const threadId = `patch-tool-thread-${suffix}`;
const findingId = `patch-tool-finding-${suffix}`;

describe.sequential("patch loop Mastra tool", () => {
beforeAll(async () => {
await withDatabase(async (db) => {
await db.query("INSERT INTO projects (id, name, slug) VALUES ($1, $2, $3)", [
projectId,
"Patch tool test",
`patch-tool-${suffix}`,
]);
await db.query("INSERT INTO chat_threads (id, project_id, title) VALUES ($1, $2, $3)", [
threadId,
projectId,
"Patch tool test",
]);
await db.query(
`INSERT INTO findings (id, project_id, thread_id, title, severity, status, summary)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
findingId,
projectId,
threadId,
"Validated CSRF regression",
"high",
"validated",
"The persisted finding has deterministic reproduction evidence.",
],
);
});
});

afterAll(async () => {
await withDatabase((db) => db.query("DELETE FROM projects WHERE id = $1", [projectId]));
});

it("requires project context before starting patch planning", async () => {
await expect(
patchLoopTool.execute?.(
{
explicitRequest: "Please patch the CSRF guard regression.",
findingId: "finding-1",
projectScoped: true,
},
{ requestContext: requestContext({}) } as never,
),
).rejects.toThrow(/projectId/);
});

it("starts a passive patch loop from an explicit project-scoped request", async () => {
it("plans from a persisted validated finding while keeping ACP unavailable", async () => {
const result = await patchLoopTool.execute?.(
{
findingId,
projectScoped: true,
executionMode: "coding-agent",
},
{ requestContext: requestContext({ projectId, threadId }) } as never,
);

expect(result).toMatchObject({
status: "ready",
projectId,
patch: {
projectId,
threadId,
findingId,
source: "validated-finding",
gate: "validated-finding",
codingAgentRun: {
status: "unavailable",
reason: "coding-agent-runner-unavailable",
},
},
});
});

it("does not treat legacy model-authored request fields as user authority", async () => {
const result = await patchLoopTool.execute?.(
{
explicitRequest: "Please patch the CSRF guard regression.",
objective: "Restore CSRF token validation.",
requestedBy: "user",
objective: "Restore CSRF token validation.",
projectScoped: true,
executionMode: "coding-agent",
codingAgent: {
harnessId: "codex-acp",
modelRef: "llm://openai/gpt-5.4",
effectiveModelId: "gpt-5.4",
},
baseBranch: "main",
repositoryPath: "/workspace/app",
testCommands: [
{
command: "pnpm test -- csrf",
cwd: "/workspace/app",
reason: "Regression coverage for CSRF guard.",
},
],
},
{ requestContext: requestContext({ projectId: "project-1", threadId: "thread-1" }) } as never,
} as never,
{ requestContext: requestContext({ projectId, threadId }) } as never,
);

expect(result).toMatchObject({
status: "ready",
projectId: "project-1",
patch: {
projectId: "project-1",
threadId: "thread-1",
source: "explicit-request",
objective: "Restore CSRF token validation.",
requestedBy: "user",
baseBranch: "main",
repositoryPath: "/workspace/app",
gate: "explicit-user-request",
targetScope: "project",
},
expect(result).toEqual({
status: "refused",
projectId,
reason: "validated-finding-or-explicit-request-required",
message:
"This tool requires a persisted validated finding. Direct user-request admission requires an app-owned deliberate action.",
});
const ready = result as {
status: "ready";
patch?: {
branchName: string;
testCommands: unknown[];
};
};
expect(ready.status).toBe("ready");
expect(ready.patch?.branchName).toMatch(
/^patch\/project-1\/request-restore-csrf-token-validation-/,
);
expect(ready.patch?.testCommands).toEqual([
{
id: "test-1",
order: 1,
command: "pnpm test -- csrf",
cwd: "/workspace/app",
reason: "Regression coverage for CSRF guard.",
status: "planned",
},
]);
});
});
Loading