diff --git a/.changeset/fn-9214-secret-get-value-delivery.md b/.changeset/fn-9214-secret-get-value-delivery.md new file mode 100644 index 0000000000..98d276384c --- /dev/null +++ b/.changeset/fn-9214-secret-get-value-delivery.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Let agents receive secrets read through fn_secret_get. +category: fix +dev: The value now ships in tool result content; details.value and non-delivery returns are unchanged. diff --git a/docs/architecture.md b/docs/architecture.md index 1082a8a7eb..0f873658c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -302,7 +302,7 @@ Public API surface: Settings boundary: - Global default policy: `GlobalSettings.secretsAccessPolicy` (used by `resolveSecretAccessPolicy`). - Project-level secrets settings: `ProjectSettings.secretsEnv`. Cross-node sync passphrase state surfaces read-only via `GlobalSettings.secretsSyncPassphraseConfigured` (derived from `hasSyncPassphraseConfigured(secretsStore)` against the reserved `__sync_passphrase__` row in `secrets_global`). -- Agent secret reads are exposed via `fn_secret_get` (`packages/cli/src/extension.ts:1542-1629`). +- Agent secret reads are exposed via `fn_secret_get` (`packages/cli/src/extension.ts`). Policy-allowed reads deliver plaintext in model-visible tool content while retaining `details.value` for host consumers; refusal and pending outcomes remain plaintext-free. - Cross-node sync routes ship at `/api/nodes/:id/secrets/push`, `/api/nodes/:id/secrets/pull`, `/api/secrets/sync-receive`, `/api/secrets/sync-export` with inbound Bearer apiKey validation (`packages/dashboard/src/routes/register-secrets-sync-inbound-routes.ts:99-114`, `:181-196`). ### Mesh state read path for dashboard topology diff --git a/docs/secrets.md b/docs/secrets.md index 237b20d723..fd7a3ac961 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -37,7 +37,7 @@ Threat-model baseline: - Secret plaintext is **not** stored in PostgreSQL. - Ciphertext + nonce are persisted; plaintext exists only in process memory during create/reveal. -- Secret values must never be logged. +- Secret values must never be logged in audit metadata, structured logs, or API responses. A policy-allowed `fn_secret_get` read intentionally delivers plaintext into the agent's model-visible transcript; that session/agent log is persisted and executor memory capture can record its `tool_result`, so operators should use `prompt` or `deny` when that exposure is unsuitable. - MCP server settings store only secret references for sensitive env/header/token fields; imports surface plaintext as secret-creation descriptors instead of persisting it in settings. - MCP server secret references are materialized only at session/probe creation time for MCP-capable AI lanes and `POST /api/mcp/validate`; responses and structured logs include status/count metadata only, never resolved env/header values. @@ -121,15 +121,16 @@ Older dashboard writes made without an explicit project id may be stranded in a ## Agent Access (`fn_secret_get`) -`fn_secret_get` is shipped in `packages/cli/src/extension.ts:1542-1629`. +`fn_secret_get` is shipped in `packages/cli/src/extension.ts`. Tool contract: - Params: `key` (required), `scope?: "project" | "global"`. - Resolution: when `scope` is omitted, lookup is project → global; when provided, only that scope is queried. Missing key returns `{ error: "not-found" }`. +- An `auto` read, or a one-time approved `prompt` read, returns plaintext in model-visible tool `content` with an immediate-use instruction. `details.value` remains available for host consumers. +- Ambiguous identity, missing, deny, pending approval, and denied approval outcomes contain no plaintext in either `content` or `details`. - Policy outcomes: - - `auto` → reveals and returns plaintext value (`secret:read` audit at `extension.ts:1615`). - - `prompt` → creates `ApprovalRequestStore` request (`secret-read:{scope}:{key}:{agentId}` dedupe key) and returns `{ outcome: "pending_approval", approvalRequestId }` (`extension.ts:1607-1611`). - - `deny` → immediate refusal and `secret:approval-denied` audit (`extension.ts:1581-1583`). + - `prompt` creates an `ApprovalRequestStore` request (`secret-read:{scope}:{key}:{agentId}` dedupe key) and returns `{ outcome: "pending_approval", approvalRequestId }` until approval is redeemed once. + - `deny` returns an immediate refusal and emits `secret:approval-denied`. ## `.env` Auto-write into Worktrees diff --git a/packages/cli/src/__tests__/extension-permission-gates.test.ts b/packages/cli/src/__tests__/extension-permission-gates.test.ts index 194ee0bd2e..1efbfb90ac 100644 --- a/packages/cli/src/__tests__/extension-permission-gates.test.ts +++ b/packages/cli/src/__tests__/extension-permission-gates.test.ts @@ -23,7 +23,6 @@ import { join } from "node:path"; import { AgentStore, ApprovalRequestStore, - SecretsStore, registerFusionSessionIdentity, runWithFusionSessionIdentity, __clearFusionSessionIdentityRegistryForTests, @@ -31,6 +30,7 @@ import { } from "@fusion/core"; import { createPgExtensionHarness, + injectSecretsStore, createMockApi, registerExtension, requireTool, @@ -85,32 +85,6 @@ async function buildAgentStore(): Promise { return agentStore; } -/** - * FNXC:ToolPermissionGates 2026-07-26-14:20: - * TaskStore.getSecretsStore constructs a MasterKeyManager against the real global dir, - * which resolveGlobalDir hard-refuses under vitest. Pre-seed the store's public - * `secretsStore` cache with a backend-mode SecretsStore using a fixed in-memory test key - * so fn_secret_get exercises the real encrypt/reveal + approval paths without touching - * ~/.fusion. - */ -function injectSecretsStore(): SecretsStore { - const layer = h.store().getAsyncLayer(); - if (!layer) throw new Error("harness store has no async layer"); - const noopDb = { - prepare: () => { - throw new Error("sync DB not available in backend-mode test"); - }, - bumpLastModified: () => {}, - }; - const secretsStore = new SecretsStore( - noopDb as never, - noopDb as never, - async () => Buffer.alloc(32, 7), - { asyncLayer: layer }, - ); - h.store().secretsStore = secretsStore; - return secretsStore; -} /** Hardcoded full-rules policy literals (never derived from core preset constants). */ const LOCKED_DOWN_POLICY: AgentPermissionPolicy = { @@ -468,7 +442,7 @@ pgDescribe("extension tool permission gates", () => { const cwd = h.rootDir(); const api = freshApi(); const tool = requireTool(api, "fn_secret_get"); - const secretsStore = injectSecretsStore(); + const secretsStore = injectSecretsStore(h); await secretsStore.createSecret({ scope: "project", key: "API_TOKEN", @@ -499,6 +473,7 @@ pgDescribe("extension tool permission gates", () => { const redeemed = await tool.execute("c3", { key: "API_TOKEN" }, undefined, undefined, agentCtx); expect(redeemed.isError).toBeUndefined(); expect(redeemed.details?.value).toBe("s3cret-value"); + expect(redeemed.content.map((part) => part.text).join("\n")).toContain("s3cret-value"); expect(redeemed.details?.approvalRequestId).toBe(requestId); expect((await approvals.get(requestId))?.status).toBe("completed"); @@ -512,7 +487,7 @@ pgDescribe("extension tool permission gates", () => { const cwd = h.rootDir(); const api = freshApi(); const tool = requireTool(api, "fn_secret_get"); - const secretsStore = injectSecretsStore(); + const secretsStore = injectSecretsStore(h); await secretsStore.createSecret({ scope: "project", key: "DENIED_TOKEN", @@ -539,7 +514,7 @@ pgDescribe("extension tool permission gates", () => { it("fn_secret_get: a registered durable chat agent is persisted when pi omits immediate agentId", async () => { const cwd = h.rootDir(); const tool = requireTool(freshApi(), "fn_secret_get"); - const secretsStore = injectSecretsStore(); + const secretsStore = injectSecretsStore(h); await secretsStore.createSecret({ scope: "project", key: "CHAT_TOKEN", plaintextValue: "not-in-approval", accessPolicy: "prompt" }); const dispose = registerFusionSessionIdentity(cwd, { agentId: "agent-1a009724", agentName: "Dashboard Chat Agent", purpose: "chat" }); try { @@ -558,7 +533,7 @@ pgDescribe("extension tool permission gates", () => { it("fn_secret_get: dashboard-chat pi invocation reaches real operator approve and deny routes", async () => { const cwd = h.rootDir(); const tool = requireTool(freshApi(), "fn_secret_get"); - const secretsStore = injectSecretsStore(); + const secretsStore = injectSecretsStore(h); const app = createApprovalDecisionApp(); /* @@ -604,7 +579,7 @@ pgDescribe("extension tool permission gates", () => { it("fn_secret_get: production dashboard chat keeps its durable principal through a host secret call", async () => { const cwd = h.rootDir(); const tool = requireTool(freshApi(), "fn_secret_get"); - const secretsStore = injectSecretsStore(); + const secretsStore = injectSecretsStore(h); const app = createApprovalDecisionApp(); const chatStore = { /* @@ -700,7 +675,7 @@ pgDescribe("extension tool permission gates", () => { it("fn_secret_get: direct human CLI remains a user requester after a session disposes", async () => { const cwd = h.rootDir(); const tool = requireTool(freshApi(), "fn_secret_get"); - const secretsStore = injectSecretsStore(); + const secretsStore = injectSecretsStore(h); await secretsStore.createSecret({ scope: "project", key: "CLI_TOKEN", plaintextValue: "not-in-approval", accessPolicy: "prompt" }); const dispose = registerFusionSessionIdentity(cwd, { agentId: "agent-disposed" }); dispose(); @@ -713,7 +688,7 @@ pgDescribe("extension tool permission gates", () => { it("fn_secret_get: concurrent same-root registrations fail closed without minting a shared approval", async () => { const cwd = h.rootDir(); const tool = requireTool(freshApi(), "fn_secret_get"); - const secretsStore = injectSecretsStore(); + const secretsStore = injectSecretsStore(h); await secretsStore.createSecret({ scope: "project", key: "AMBIGUOUS_TOKEN", plaintextValue: "not-in-approval", accessPolicy: "prompt" }); const disposeA = registerFusionSessionIdentity(cwd, { agentId: "agent-a" }); const disposeB = registerFusionSessionIdentity(cwd, { agentId: "agent-b" }); diff --git a/packages/cli/src/__tests__/extension-secret-get-value-delivery.test.ts b/packages/cli/src/__tests__/extension-secret-get-value-delivery.test.ts new file mode 100644 index 0000000000..de007cae8c --- /dev/null +++ b/packages/cli/src/__tests__/extension-secret-get-value-delivery.test.ts @@ -0,0 +1,101 @@ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { ApprovalRequestStore, registerFusionSessionIdentity, __clearFusionSessionIdentityRegistryForTests } from "@fusion/core"; +import { + createMockApi, + createPgExtensionHarness, + injectSecretsStore, + pgDescribe, + registerExtension, + requireTool, + type ToolResult, +} from "./pg-extension-harness.js"; + +const h = createPgExtensionHarness("fn-secret-delivery"); + +function resultText(result: ToolResult): string { + return result.content.map((part) => part.text).join("\n"); +} + +function expectNoPlaintext(result: ToolResult, plaintext: string): void { + expect(resultText(result)).not.toContain(plaintext); + expect(JSON.stringify(result.details ?? {})).not.toContain(plaintext); +} + +function freshSecretTool() { + const api = createMockApi(); + registerExtension(api); + return requireTool(api, "fn_secret_get"); +} + +pgDescribe("fn_secret_get value delivery", () => { + beforeAll(h.beforeAll); + beforeEach(async () => { + await h.beforeEach(); + __clearFusionSessionIdentityRegistryForTests(); + }); + afterEach(async () => { + __clearFusionSessionIdentityRegistryForTests(); + await h.afterEach(); + }); + afterAll(h.afterAll); + + it("delivers auto-policy values in model-visible content for agent and operator callers", async () => { + const store = injectSecretsStore(h); + const tool = freshSecretTool(); + const agentCtx = { cwd: h.rootDir(), agentId: "secret-agent", agentName: "Secret Agent", runId: "secret-run" }; + const values = ["normal-value", "line-one\nline-two", "quotes ' ` and $dollar"]; + for (const [index, plaintextValue] of values.entries()) { + const key = `AUTO_${index}`; + await store.createSecret({ scope: "project", key, plaintextValue, accessPolicy: "auto" }); + const result = await tool.execute(`auto-${index}`, { key }, undefined, undefined, agentCtx); + expect(resultText(result)).toContain(plaintextValue); + expect(result.details?.value).toBe(plaintextValue); + } + await store.createSecret({ scope: "global", key: "GLOBAL_ONLY", plaintextValue: "global-value", accessPolicy: "auto" }); + expect(resultText(await tool.execute("global", { key: "GLOBAL_ONLY" }, undefined, undefined, { cwd: h.rootDir() }))).toContain("global-value"); + await store.createSecret({ scope: "global", key: "PREFERRED", plaintextValue: "global-fallback", accessPolicy: "auto" }); + await store.createSecret({ scope: "project", key: "PREFERRED", plaintextValue: "project-wins", accessPolicy: "auto" }); + const preferred = await tool.execute("preferred", { key: "PREFERRED" }, undefined, undefined, agentCtx); + expect(resultText(preferred)).toContain("project-wins"); + expect(resultText(preferred)).not.toContain("global-fallback"); + await store.createSecret({ scope: "project", key: "EMPTY", plaintextValue: "", accessPolicy: "auto" }); + expect(resultText(await tool.execute("empty", { key: "EMPTY", scope: "project" }, undefined, undefined, agentCtx))).toContain("stored secret value is empty"); + const audit = await h.store().getRunAuditEventsAsync({ runId: "secret-run", mutationType: "secret:read" }); + expect(JSON.stringify(audit.map((event) => event.metadata))).not.toContain("normal-value"); + }); + + it("keeps plaintext out of non-delivery results", async () => { + const store = injectSecretsStore(h); + const tool = freshSecretTool(); + const cwd = h.rootDir(); + await store.createSecret({ scope: "project", key: "DENY", plaintextValue: "never-deliver", accessPolicy: "deny" }); + expectNoPlaintext(await tool.execute("deny", { key: "DENY" }, undefined, undefined, { cwd, agentId: "agent" }), "never-deliver"); + expectNoPlaintext(await tool.execute("missing", { key: "MISSING" }, undefined, undefined, { cwd, agentId: "agent" }), "never-deliver"); + const disposeA = registerFusionSessionIdentity(cwd, { agentId: "agent-a" }); + const disposeB = registerFusionSessionIdentity(cwd, { agentId: "agent-b" }); + try { + const ambiguous = await tool.execute("ambiguous", { key: "DENY" }, undefined, undefined, { cwd }); + expect(ambiguous.details?.error).toBe("ambiguous-caller-identity"); + expectNoPlaintext(ambiguous, "never-deliver"); + } finally { disposeA(); disposeB(); } + }); + + it("keeps pending and denied prompt requests plaintext-free", async () => { + const store = injectSecretsStore(h); + const tool = freshSecretTool(); + const cwd = h.rootDir(); + await store.createSecret({ scope: "project", key: "PROMPT", plaintextValue: "prompt-plaintext", accessPolicy: "prompt" }); + const ctx = { cwd, agentId: "prompt-agent", runId: "prompt-run" }; + const minted = await tool.execute("mint", { key: "PROMPT" }, undefined, undefined, ctx); + expectNoPlaintext(minted, "prompt-plaintext"); + const pending = await tool.execute("pending", { key: "PROMPT" }, undefined, undefined, ctx); + expectNoPlaintext(pending, "prompt-plaintext"); + const layer = h.store().getAsyncLayer(); + if (!layer) throw new Error("harness store has no async layer"); + await new ApprovalRequestStore(null, { asyncLayer: layer }).decide(minted.details?.approvalRequestId as string, "denied", { actor: { actorId: "operator", actorType: "user", actorName: "Operator" } }); + const denied = await tool.execute("denied", { key: "PROMPT" }, undefined, undefined, ctx); + expectNoPlaintext(denied, "prompt-plaintext"); + const audit = await h.store().getRunAuditEventsAsync({ runId: "prompt-run" }); + expect(JSON.stringify(audit.map((event) => event.metadata))).not.toContain("prompt-plaintext"); + }); +}); diff --git a/packages/cli/src/__tests__/pg-extension-harness.ts b/packages/cli/src/__tests__/pg-extension-harness.ts index 4093584bdb..7c387dfee5 100644 --- a/packages/cli/src/__tests__/pg-extension-harness.ts +++ b/packages/cli/src/__tests__/pg-extension-harness.ts @@ -30,7 +30,7 @@ import kbExtension, { __setCachedStoreForTesting, closeCachedStores, } from "../extension.js"; -import type { TaskStore } from "@fusion/core"; +import { SecretsStore, type TaskStore } from "@fusion/core"; export { pgDescribe }; @@ -149,6 +149,22 @@ export function createPgExtensionHarness(prefix: string): PgExtensionHarness { }; } +/** + * Install an in-memory-key SecretsStore so extension tests exercise real encryption without + * resolving the developer's global Fusion key directory. + */ +export function injectSecretsStore(harness: PgExtensionHarness): SecretsStore { + const layer = harness.store().getAsyncLayer(); + if (!layer) throw new Error("harness store has no async layer"); + const noopDb = { + prepare: () => { throw new Error("sync DB not available in backend-mode test"); }, + bumpLastModified: () => {}, + }; + const secretsStore = new SecretsStore(noopDb as never, noopDb as never, async () => Buffer.alloc(32, 7), { asyncLayer: layer }); + harness.store().secretsStore = secretsStore; + return secretsStore; +} + /** Look up a registered tool, failing the test loudly if it was never registered. */ export function requireTool(api: MockApi, name: string): RegisteredTool { const tool = api.tools.get(name); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 0348014c01..b4f639cedb 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -821,6 +821,19 @@ function emitSecretAudit( } } +/** + * FNXC:Secrets 2026-08-27-03:47: + * Pi forwards tool `content` to the model but treats `details` as host-render metadata. + * A policy-allowed read must therefore include its value here or it is audited but undelivered. + * Keep details.value for host consumers; refusal and approval-pending returns remain plaintext-free. + */ +function formatRevealedSecretContent(confirmation: string, plaintextValue: string): string { + const delivery = plaintextValue === "" + ? "The stored secret value is empty." + : `Secret value:\n${plaintextValue}`; + return `${confirmation}\n${delivery}\nUse this value only for the immediate operation. Never write it to files, commits, logs, PR descriptions, or task documents.`; +} + /** * Validate an agent id supplied to task create/update tools. * Returns null on success, or an error message describing why the id was rejected. @@ -3673,7 +3686,7 @@ export default function kbExtension(pi: ExtensionAPI) { }); emitSecretAudit(store, effectiveCtx, "secret:read", `${resolvedScope}:${params.key}`, { key: params.key, scope: resolvedScope, approvalRequestId: existing.id }); return { - content: [{ type: "text", text: `Loaded secret '${params.key}' from ${resolvedScope} scope (approval ${existing.id} consumed).` }], + content: [{ type: "text", text: formatRevealedSecretContent(`Loaded secret '${params.key}' from ${resolvedScope} scope (approval ${existing.id} consumed).`, revealedAfterApproval.plaintextValue) }], details: { key: params.key, value: revealedAfterApproval.plaintextValue, scope: resolvedScope, approvalRequestId: existing.id }, }; } @@ -3703,7 +3716,7 @@ export default function kbExtension(pi: ExtensionAPI) { const revealed = await secretsStore.revealSecret(record.id, resolvedScope, { agentId: secretPrincipal.agentId }); emitSecretAudit(store, effectiveCtx, "secret:read", `${resolvedScope}:${params.key}`, { key: params.key, scope: resolvedScope }); return { - content: [{ type: "text", text: `Loaded secret '${params.key}' from ${resolvedScope} scope.` }], + content: [{ type: "text", text: formatRevealedSecretContent(`Loaded secret '${params.key}' from ${resolvedScope} scope.`, revealed.plaintextValue) }], details: { key: params.key, value: revealed.plaintextValue, scope: resolvedScope }, }; },