feat(FN-4879): merge fusion/fn-4879

This commit is contained in:
gsxdsm
2026-05-17 06:23:00 -07:00
parent 7b48387a74
commit 83ed893382
8 changed files with 396 additions and 18 deletions

View File

@@ -0,0 +1,92 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const resolveSecretAccessPolicyMock = vi.hoisted(() => vi.fn());
const revealSecretMock = vi.hoisted(() => vi.fn());
const listSecretsMock = vi.hoisted(() => vi.fn());
const approvalCreateMock = vi.hoisted(() => vi.fn());
const approvalFindLatestByDedupeKeyMock = vi.hoisted(() => vi.fn());
const recordRunAuditEventMock = vi.hoisted(() => vi.fn());
vi.mock("@fusion/dashboard", () => ({ registerGithubTrackingHook: vi.fn() }));
vi.mock("@fusion/engine", () => ({ createFnAgent: vi.fn(), fetchWebContent: vi.fn() }));
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
class MockTaskStore {
async init() {}
async getSecretsStore() {
return { listSecrets: listSecretsMock, revealSecret: revealSecretMock };
}
getGlobalSettingsStore() {
return { getSettings: async () => ({ secretsAccessPolicy: "prompt" }) };
}
recordRunAuditEvent = recordRunAuditEventMock;
getDatabase() {
return {} as any;
}
}
class MockApprovalRequestStore {
constructor(_db: unknown) {}
findLatestByDedupeKey = approvalFindLatestByDedupeKeyMock;
create = approvalCreateMock;
}
return {
...actual,
TaskStore: MockTaskStore,
ApprovalRequestStore: MockApprovalRequestStore,
resolveSecretAccessPolicy: resolveSecretAccessPolicyMock,
};
});
import kbExtension from "../extension.js";
describe("extension fn_secret_get", () => {
beforeEach(() => {
vi.clearAllMocks();
listSecretsMock.mockImplementation((scope?: "project" | "global") => {
if (scope === "project") return [{ id: "s1", key: "API_KEY", accessPolicy: "auto" }];
return [];
});
revealSecretMock.mockResolvedValue({ key: "API_KEY", plaintextValue: "secret-value" });
resolveSecretAccessPolicyMock.mockReturnValue({ policy: "auto", source: "secret" });
approvalFindLatestByDedupeKeyMock.mockReturnValue(null);
approvalCreateMock.mockReturnValue({ id: "apr-1", status: "pending" });
});
it("returns value for auto policy", async () => {
const tools = new Map<string, any>();
kbExtension({ registerTool: (d: any) => tools.set(d.name, d), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any);
const tool = tools.get("fn_secret_get");
const result = await tool.execute("id", { key: "API_KEY" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1", runId: "run-1" });
expect(result.details.value).toBe("secret-value");
expect(approvalCreateMock).not.toHaveBeenCalled();
expect(recordRunAuditEventMock.mock.calls[0][0].mutationType).toBe("secret:read");
expect(JSON.stringify(recordRunAuditEventMock.mock.calls[0][0])).not.toContain("secret-value");
});
it("returns pending_approval for prompt policy", async () => {
resolveSecretAccessPolicyMock.mockReturnValue({ policy: "prompt", source: "secret" });
const tools = new Map<string, any>();
kbExtension({ registerTool: (d: any) => tools.set(d.name, d), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any);
const tool = tools.get("fn_secret_get");
const result = await tool.execute("id", { key: "API_KEY" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1" });
expect(result.details.outcome).toBe("pending_approval");
expect(approvalCreateMock).toHaveBeenCalled();
});
it("returns denied for deny policy and not found when missing", async () => {
const tools = new Map<string, any>();
kbExtension({ registerTool: (d: any) => tools.set(d.name, d), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any);
const tool = tools.get("fn_secret_get");
resolveSecretAccessPolicyMock.mockReturnValue({ policy: "deny", source: "secret" });
const denied = await tool.execute("id", { key: "API_KEY" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1", runId: "run-1" });
expect(denied.details.error).toBe("denied");
expect(revealSecretMock).not.toHaveBeenCalled();
listSecretsMock.mockReturnValue([]);
const missing = await tool.execute("id", { key: "NOPE" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1" });
expect(missing.details.error).toBe("not-found");
});
});

View File

@@ -21,6 +21,7 @@ import {
formatRoleMismatchReason,
resolveAgentProvisioningPolicy,
TASK_PRIORITIES,
resolveSecretAccessPolicy,
} from "@fusion/core";
import {
getGhErrorMessage,
@@ -97,6 +98,25 @@ function getFusionDir(cwd: string): string {
return join(resolveProjectRoot(cwd), ".fusion");
}
function emitSecretAudit(
store: TaskStore,
ctx: { runId?: string; agentId?: string; taskId?: string },
mutationType: string,
target: string,
metadata?: Record<string, unknown>,
): void {
if (!ctx.runId || !ctx.agentId) return;
store.recordRunAuditEvent({
runId: ctx.runId,
agentId: ctx.agentId,
taskId: ctx.taskId,
domain: "filesystem",
mutationType,
target,
metadata,
});
}
/**
* Validate an agent id supplied to task create/update tools.
* Returns null on success, or an error message describing why the id was rejected.
@@ -1490,6 +1510,82 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
pi.registerTool({
name: "fn_secret_get",
label: "fn: Secret Get",
description: "Read a secret by key using per-secret access policy.",
parameters: Type.Object({
key: Type.String({ description: "Secret key" }),
scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("global")], { description: "Optional scope" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const secretsStore = await store.getSecretsStore();
const scopes = params.scope ? [params.scope] : ["project", "global"];
let record: import("@fusion/core").SecretRecord | null = null;
let resolvedScope: "project" | "global" | null = null;
for (const scope of scopes) {
const match = secretsStore.listSecrets(scope).find((candidate) => candidate.key === params.key);
if (match) {
record = match;
resolvedScope = scope;
break;
}
}
if (!record || !resolvedScope) {
return { content: [{ type: "text", text: `Secret '${params.key}' not found.` }], details: { error: "not-found", key: params.key, scope: params.scope ?? null } };
}
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const decision = resolveSecretAccessPolicy({
secretPolicy: record.accessPolicy,
settings: { secretsAccessPolicy: globalSettings.secretsAccessPolicy },
});
if (decision.policy === "deny") {
emitSecretAudit(store, ctx as { runId?: string; agentId?: string; taskId?: string }, "secret:approval-denied", `${resolvedScope}:${params.key}`);
return { content: [{ type: "text", text: "Secret access denied by policy." }], details: { error: "denied", key: params.key, scope: resolvedScope, policySource: decision.source } };
}
if (decision.policy === "prompt") {
const { ApprovalRequestStore } = await import("@fusion/core");
const approvalStore = new ApprovalRequestStore(store.getDatabase());
const dedupeKey = `secret-read:${resolvedScope}:${params.key}:${ctx.agentId ?? "unknown"}`;
const existing = approvalStore.findLatestByDedupeKey({ requesterActorId: ctx.agentId ?? "user", taskId: (ctx as { taskId?: string }).taskId, dedupeKey });
const request = existing && existing.status === "pending"
? existing
: approvalStore.create({
requester: { actorId: ctx.agentId ?? "user", actorType: "agent", actorName: ctx.agentName ?? ctx.agentId ?? "Agent" },
targetAction: {
category: "secrets_access",
action: "read",
summary: `Read secret ${params.key}`,
resourceType: "secret",
resourceId: record.id,
context: { approvalDedupeKey: dedupeKey, key: params.key, scope: resolvedScope },
},
...(ctx.runId ? { runId: ctx.runId } : {}),
...((ctx as { taskId?: string }).taskId ? { taskId: (ctx as { taskId?: string }).taskId } : {}),
});
emitSecretAudit(store, ctx as { runId?: string; agentId?: string; taskId?: string }, "secret:approval-requested", `${resolvedScope}:${params.key}`);
return {
content: [{ type: "text", text: `Secret access requires approval. Request ${request.id} is pending. Approve via POST /api/approvals/:id/decision.` }],
details: { outcome: "pending_approval", approvalRequestId: request.id, key: params.key, scope: resolvedScope },
};
}
const revealed = await secretsStore.revealSecret(record.id, resolvedScope, { agentId: ctx.agentId ?? null });
emitSecretAudit(store, ctx as { runId?: string; agentId?: string; taskId?: string }, "secret:read", `${resolvedScope}:${params.key}`, { key: params.key, scope: resolvedScope });
return {
content: [{ type: "text", text: `Loaded secret '${params.key}' from ${resolvedScope} scope.` }],
details: { key: params.key, value: revealed.plaintextValue, scope: resolvedScope },
};
},
});
// ── Research Tools ──────────────────────────────────────────────
pi.registerTool({