FN-9214: deliver secret values in model-visible tool content

Make policy-approved fn_secret_get reads visible to calling agents without weakening refusal paths.

- Include revealed plaintext in tool content for automatic and redeemed approval reads while retaining details.value.
- Keep denied, pending, missing, and ambiguous outcomes plaintext-free and audit metadata sanitized.
- Add PostgreSQL-backed regression coverage, shared secret-store test setup, documentation, and a patch changeset.

Files changed:
 .changeset/fn-9214-secret-get-value-delivery.md    |   7 ++
 docs/architecture.md                               |   2 +-
 docs/secrets.md                                    |  11 ++-
 .../__tests__/extension-permission-gates.test.ts   |  43 ++-------
 .../extension-secret-get-value-delivery.test.ts    | 101 +++++++++++++++++++++
 packages/cli/src/__tests__/pg-extension-harness.ts |  18 +++-
 packages/cli/src/extension.ts                      |  17 +++-
 7 files changed, 156 insertions(+), 43 deletions(-)

Fusion-Task-Id: FN-9214

Fusion-Task-Lineage: 2738aa00-9e67-4282-b950-01ed33431cfc

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-26 21:03:37 -07:00
parent 25f24c7439
commit 0fd247b589
7 changed files with 156 additions and 43 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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<AgentStore> {
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" });

View File

@@ -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");
});
});

View File

@@ -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);

View File

@@ -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 },
};
},