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:
7
.changeset/fn-9214-secret-get-value-delivery.md
Normal file
7
.changeset/fn-9214-secret-get-value-delivery.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user