feat(FN-4879): merge fusion/fn-4879
This commit is contained in:
92
packages/cli/src/__tests__/extension-fn-secret-get.test.ts
Normal file
92
packages/cli/src/__tests__/extension-fn-secret-get.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user