FN-7609: show gated action payload details on approval requests

Approval cards previously showed only a generic gating message with no visibility into the underlying command/arguments being approved, and repeated pending requests for the same action could pile up as duplicates.

- Add GatedActionApprovalDetails component to render the gated command/arguments payload on agent-gating approval cards in MailboxView
- Persist approvalDedupeKey in targetAction.context and a payload-bearing summary via buildAgentGatedActionSummary in permanent-agent-gating
- Wire agent-heartbeat, executor, and pi to pass through the richer gated-action context/summary
- Add changeset (patch) documenting the fix
- Update docs/dashboard-guide.md
- Add/extend tests: GatedActionApprovalDetails, MailboxView, permanent-agent-gating, pi-create-fn-agent

Files changed:
 .changeset/FN-7609-gated-action-approval-payload.md            |  7 ++
 docs/dashboard-guide.md                                        |  1 +
 packages/core/src/types.ts                                     |  8 +++
 .../app/components/GatedActionApprovalDetails.css              | 50 ++++++++++++++
 .../app/components/GatedActionApprovalDetails.tsx              | 72 +++++++++++++++++++
 packages/dashboard/app/components/MailboxView.tsx               | 12 ++++
 .../__tests__/GatedActionApprovalDetails.test.tsx               | 66 ++++++++++++++++++
 .../app/components/__tests__/MailboxView.test.tsx                | 41 +++++++++++
 .../src/__tests__/permanent-agent-gating.test.ts                 | 31 +++++++++
 .../src/__tests__/pi-create-fn-agent.test.ts                     | 80 ++++++++++++++++++++++
 packages/engine/src/agent-heartbeat.ts                           | 19 ++++-
 packages/engine/src/executor.ts                                  | 19 ++++-
 packages/engine/src/permanent-agent-gating.ts                    | 53 ++++++++++++++
 packages/engine/src/pi.ts                                        |  6 ++
 14 files changed, 461 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-7609

Fusion-Task-Lineage: 80a6bb5b-79f7-4b78-9204-402c2dea6171

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 21:08:11 -07:00
parent 60081fb1f4
commit 44442622c5
14 changed files with 461 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Approval cards now show the gated command/arguments and dedupe repeated pending requests.
category: fix
dev: Permanent-agent gate persists approvalDedupeKey in targetAction.context and a payload-bearing summary (buildAgentGatedActionSummary); MailboxView renders GatedActionApprovalDetails for source="agent-gating".

View File

@@ -572,6 +572,7 @@ Mailbox view shows inbox/outbox communication threads and unread state.
- reply rows in the mailbox modal can expand inline to show the replied-to message context for easier thread reading
- when an agent or dashboard chat session registers an artifact with `fn_artifact_register`, Fusion sends a best-effort `system` → user inbox message announcing the new artifact (for example, `New image artifact registered: <title>`) with metadata for `artifactId`, `artifactType`, `title`, `authorId`, and optional `taskId`; notification delivery is informational and never blocks or rolls back the artifact registration
- mailbox now includes an **Approvals** tab with pending and history filters (`approved` / `denied` / `completed`), approval detail context, and inline approve/deny actions for pending requests
- for approvals gated by an agent's permission policy (permanent agents and task-worker heartbeats), the Approvals detail pane renders the gated action's real payload — tool name, shell command line or structured arguments, and working directory when present — instead of only a generic "Agent gated action for `<tool>`" summary; a stateless heartbeat retrying the same gated command reuses the existing pending approval instead of creating a duplicate (FN-7609)
- in the **Agents** tab, the agent selector now includes **All agents**, which shows one combined agent-to-agent stream (with sender + recipient labels); selecting a specific agent still shows Inbox/Outbox subtabs
- mailbox entry points now show unread/pending indicators: the desktop/tablet Header mailbox toggle shows a pending-approval dot first or an unread dot when unread mail exists without pending approvals, the mobile bottom-nav Mailbox tab carries the mobile badges/dots, and the compact Header actions overflow keeps a Mailbox entry only when the mobile bottom nav is disabled
- approval lifecycle SSE events (`approval:requested`, `approval:updated`, `approval:decided`) trigger mailbox approvals refresh without manual reload

View File

@@ -6725,6 +6725,14 @@ export interface PermanentAgentGatingContext {
category: AgentPermissionPolicyActionCategory;
toolName: string;
args: Record<string, unknown>;
/**
* FNXC:AgentGating 2026-07-05-00:00:
* FN-7609: the dedupe key must be persisted into the created request's
* targetAction.context so a retrying heartbeat's findPendingApprovalRequest
* lookup (which matches on context.approvalDedupeKey) can actually find and
* reuse the pending request instead of minting a new blank one every tick.
*/
approvalDedupeKey?: string;
}) => Promise<ApprovalRequest | null>;
findPendingApprovalRequest?: (dedupeKey: string) => Promise<ApprovalRequest | null>;
}

View File

@@ -0,0 +1,50 @@
.gated-action-approval-details {
margin-bottom: var(--space-md);
padding: var(--space-md);
}
.gated-action-approval-details__title {
margin: 0 0 var(--space-sm) 0;
color: var(--text);
font-size: 0.875rem;
}
.gated-action-approval-details__list {
margin: 0;
display: grid;
gap: var(--space-xs);
}
.gated-action-approval-details__row {
display: grid;
grid-template-columns: minmax(0, auto) minmax(0, 1fr);
gap: var(--space-sm);
}
.gated-action-approval-details__row dt {
color: var(--text-muted);
font-weight: 600;
}
.gated-action-approval-details__row dd {
margin: 0;
color: var(--text);
overflow-wrap: anywhere;
}
.gated-action-approval-details__code {
margin: 0;
padding: var(--space-xs) var(--space-sm);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.8125rem;
}
@media (max-width: 768px) {
.gated-action-approval-details__row {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -0,0 +1,72 @@
import "./GatedActionApprovalDetails.css";
import { useTranslation } from "react-i18next";
import type { ApprovalRequestDetail } from "../api";
interface GatedActionApprovalDetailsProps {
targetAction: ApprovalRequestDetail["targetAction"];
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function readToolArgs(context: Record<string, unknown> | undefined): Record<string, unknown> | null {
const toolArgs = context?.toolArgs;
if (!toolArgs || typeof toolArgs !== "object") return null;
return toolArgs as Record<string, unknown>;
}
/*
FNXC:Approvals 2026-07-05-00:00:
FN-7609: operators approving an agent-gated action (bash command, fn_* tool
call, etc.) must be able to see the real gated payload — tool name, the
shell command line or structured arguments, and working directory — instead
of just the tool name. This component renders `targetAction.context` (which
the engine's permanent-agent gating closures now populate with `toolName`,
`toolArgs`, `command`, and `cwd`) generically for any request whose
context.source === "agent-gating", so it covers both the executor and
heartbeat gating paths without duplicating rendering logic.
*/
export function GatedActionApprovalDetails({ targetAction }: GatedActionApprovalDetailsProps) {
const { t } = useTranslation("app");
const context = targetAction.context as Record<string, unknown> | undefined;
const toolName = readString(context?.toolName) ?? targetAction.resourceId;
const command = readString(context?.command);
const cwd = readString(context?.cwd);
const toolArgs = readToolArgs(context);
const argsJson = toolArgs && Object.keys(toolArgs).length > 0 ? JSON.stringify(toolArgs, null, 2) : null;
return (
<section className="card gated-action-approval-details" data-testid="gated-action-approval-details">
<h4 className="gated-action-approval-details__title">{t("approvals.gatedActionTitle", "Gated action payload")}</h4>
<dl className="gated-action-approval-details__list">
<div className="gated-action-approval-details__row">
<dt>{t("approvals.gatedActionTool", "Tool")}</dt>
<dd>{toolName}</dd>
</div>
{command && (
<div className="gated-action-approval-details__row">
<dt>{t("approvals.gatedActionCommand", "Command")}</dt>
<dd>
<pre className="gated-action-approval-details__code"><code>{command}</code></pre>
</dd>
</div>
)}
{argsJson && (
<div className="gated-action-approval-details__row">
<dt>{t("approvals.gatedActionArgs", "Arguments")}</dt>
<dd>
<pre className="gated-action-approval-details__code"><code>{argsJson}</code></pre>
</dd>
</div>
)}
{cwd && (
<div className="gated-action-approval-details__row">
<dt>{t("approvals.gatedActionCwd", "Working directory")}</dt>
<dd>{cwd}</dd>
</div>
)}
</dl>
</section>
);
}

View File

@@ -41,6 +41,7 @@ import { MailboxMessageContent } from "./MailboxMessageContent";
import { MessageComposer } from "./MessageComposer";
import { ViewHeader } from "./ViewHeader";
import { WorktrunkInstallApprovalDetails } from "./WorktrunkInstallApprovalDetails";
import { GatedActionApprovalDetails } from "./GatedActionApprovalDetails";
import { subscribeSse } from "../sse-bus";
import { useViewportMode } from "../hooks/useViewportMode";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
@@ -1208,6 +1209,17 @@ export function MailboxView({
{selectedApproval.targetAction.category === "network_api" && selectedApproval.targetAction.action === "worktrunk_install" && (
<WorktrunkInstallApprovalDetails targetAction={selectedApproval.targetAction} />
)}
{/*
FNXC:Approvals 2026-07-05-00:00:
FN-7609: render the generic gated-action payload (command/args/cwd)
whenever the request came from the agent-gating path, on both
desktop and mobile layouts. Mutually exclusive with the dedicated
worktrunk_install branch above, which must keep rendering unchanged.
*/}
{selectedApproval.targetAction.action !== "worktrunk_install"
&& (selectedApproval.targetAction.context as Record<string, unknown> | undefined)?.source === "agent-gating" && (
<GatedActionApprovalDetails targetAction={selectedApproval.targetAction} />
)}
<div className="mailbox-conversation" data-testid="mailbox-approval-history">
{selectedApproval.history.map((event) => (
<div key={event.id} className="mailbox-conversation-msg">

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { GatedActionApprovalDetails } from "../GatedActionApprovalDetails";
import type { ApprovalRequestDetail } from "../../api";
describe("GatedActionApprovalDetails", () => {
it("renders the tool name, command, and working directory for a bash payload", () => {
const targetAction: ApprovalRequestDetail["targetAction"] = {
category: "command_execution",
action: "bash",
summary: "Run: pnpm test",
resourceType: "tool",
resourceId: "bash",
context: {
toolName: "bash",
toolArgs: { command: "pnpm test", cwd: "/project/.worktrees/fn-001" },
source: "agent-gating",
command: "pnpm test",
cwd: "/project/.worktrees/fn-001",
},
};
render(<GatedActionApprovalDetails targetAction={targetAction} />);
expect(screen.getByTestId("gated-action-approval-details")).toBeInTheDocument();
expect(screen.getByText("bash")).toBeInTheDocument();
expect(screen.getByText("pnpm test")).toBeInTheDocument();
expect(screen.getByText("/project/.worktrees/fn-001")).toBeInTheDocument();
});
it("renders structured arguments for an fn_* tool payload", () => {
const targetAction: ApprovalRequestDetail["targetAction"] = {
category: "task_agent_mutation",
action: "fn_task_update",
summary: "fn_task_update {id: FN-1, status: done}",
resourceType: "tool",
resourceId: "fn_task_update",
context: {
toolName: "fn_task_update",
toolArgs: { id: "FN-1", status: "done" },
source: "agent-gating",
},
};
render(<GatedActionApprovalDetails targetAction={targetAction} />);
expect(screen.getByText("fn_task_update")).toBeInTheDocument();
expect(screen.getByText(/"id": "FN-1"/)).toBeInTheDocument();
expect(screen.getByText(/"status": "done"/)).toBeInTheDocument();
});
it("gracefully handles missing context", () => {
const targetAction: ApprovalRequestDetail["targetAction"] = {
category: "command_execution",
action: "bash",
summary: "Agent gated action for bash",
resourceType: "tool",
resourceId: "bash",
};
render(<GatedActionApprovalDetails targetAction={targetAction} />);
expect(screen.getByTestId("gated-action-approval-details")).toBeInTheDocument();
expect(screen.getByText("bash")).toBeInTheDocument();
});
});

View File

@@ -379,6 +379,47 @@ describe("MailboxView", () => {
});
});
// FN-7609: an agent-gating approval must render the real gated payload
// (command visible), and this must be mutually exclusive with the
// dedicated worktrunk_install branch (covered by the test above).
it("renders gated action payload details for agent-gating approvals", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchApprovals.mockResolvedValue({
requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run: pnpm test", agentId: "agent-001", createdAt: now, updatedAt: now }],
total: 1,
pendingCount: 1,
});
mockFetchApprovalDetail.mockResolvedValue({
id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run: pnpm test", agentId: "agent-001", createdAt: now, updatedAt: now,
requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now,
targetAction: {
category: "command_execution",
action: "bash",
summary: "Run: pnpm test",
resourceType: "tool",
resourceId: "bash",
context: {
toolName: "bash",
toolArgs: { command: "pnpm test" },
source: "agent-gating",
command: "pnpm test",
},
},
history: [{ id: "evt-1", eventType: "created", actor: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, createdAt: now }],
});
render(<MailboxView {...defaultProps} />);
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
await waitFor(() => {
expect(screen.getByTestId("gated-action-approval-details")).toBeInTheDocument();
expect(screen.getByText("pnpm test")).toBeInTheDocument();
expect(screen.queryByTestId("worktrunk-install-approval-details")).not.toBeInTheDocument();
});
});
it("allows approving a pending approval request", async () => {
const now = new Date().toISOString();
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });

View File

@@ -1,6 +1,7 @@
import type { AgentPermissionPolicy } from "@fusion/core";
import { describe, expect, it } from "vitest";
import {
buildAgentGatedActionSummary,
classifyPermanentAgentToolCall,
resolvePermanentAgentToolDecision,
} from "../permanent-agent-gating.js";
@@ -251,3 +252,33 @@ describe("permanent-agent-gating", () => {
expect(approvalDecision.disposition).toBe("require-approval");
});
});
// FN-7609: approval cards must show the real gated payload instead of a
// generic "Agent gated action for <tool>" placeholder.
describe("buildAgentGatedActionSummary", () => {
it("renders the trimmed shell command for bash calls", () => {
expect(buildAgentGatedActionSummary("bash", { command: " pnpm test --run " })).toBe("Run: pnpm test --run");
});
it("truncates very long shell commands to a sane cap", () => {
const longCommand = `echo ${"x".repeat(300)}`;
const summary = buildAgentGatedActionSummary("bash", { command: longCommand });
expect(summary.startsWith("Run: echo ")).toBe(true);
expect(summary.length).toBeLessThanOrEqual(210);
expect(summary.endsWith("\u2026")).toBe(true);
});
it("renders a compact args summary for fn_* tools", () => {
const summary = buildAgentGatedActionSummary("fn_task_update", { id: "FN-1", status: "done" });
expect(summary).toBe("fn_task_update {id: FN-1, status: done}");
});
it("falls back to a generic summary when no args are meaningful", () => {
expect(buildAgentGatedActionSummary("fn_task_list", {})).toBe("Agent gated action for fn_task_list");
expect(buildAgentGatedActionSummary("fn_task_list", undefined)).toBe("Agent gated action for fn_task_list");
});
it("falls back to a generic summary for bash calls with no command", () => {
expect(buildAgentGatedActionSummary("bash", {})).toBe("Agent gated action for bash");
});
});

View File

@@ -663,6 +663,86 @@ describe("wrapToolsWithPermanentAgentGating", () => {
expect(tool.execute).not.toHaveBeenCalled();
});
// FN-7609: the permanent-agent gate must pass its computed dedupe key
// through to createApprovalRequest so the closure can persist it into
// targetAction.context.approvalDedupeKey — without this, a stateless
// heartbeat retrying the same gated command mints a brand-new blank
// approval every tick instead of reusing the pending one.
it("passes the computed approvalDedupeKey through to createApprovalRequest", async () => {
const tool = { name: "bash", label: "Bash", description: "", parameters: {}, execute: vi.fn() };
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-dedupe-1" });
const findPendingApprovalRequest = vi.fn().mockResolvedValue(null);
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
requester: { actorId: "agent-1", actorType: "agent", actorName: "Perm" },
taskId: "FN-1",
permissionPolicy: {
presetId: "approval-required",
rules: {
git_write: "require-approval",
file_write_delete: "require-approval",
command_execution: "require-approval",
network_api: "require-approval",
task_agent_mutation: "require-approval",
},
},
createApprovalRequest,
findPendingApprovalRequest,
});
await (wrapped[0] as any).execute("t1", { command: "pnpm test" });
expect(findPendingApprovalRequest).toHaveBeenCalledWith("agent-1|FN-1|bash|command_execution");
expect(createApprovalRequest).toHaveBeenCalledWith(expect.objectContaining({
toolName: "bash",
approvalDedupeKey: "agent-1|FN-1|bash|command_execution",
}));
});
it("reuses a pending approval instead of creating a duplicate on a repeated gated tick", async () => {
const tool = { name: "bash", label: "Bash", description: "", parameters: {}, execute: vi.fn() };
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-dedupe-2" });
// Simulate a real findPendingApprovalRequest backed by a store that
// persists context.approvalDedupeKey (as executor.ts/agent-heartbeat.ts
// now do): first tick finds nothing and creates a request; the store then
// "remembers" it, so the second identical tick finds it and reuses it.
let stored: { id: string; targetAction: { context: Record<string, unknown> } } | null = null;
const findPendingApprovalRequest = vi.fn(async (dedupeKey: string) => {
if (stored && stored.targetAction.context.approvalDedupeKey === dedupeKey) {
return stored as any;
}
return null;
});
const gating = {
requester: { actorId: "agent-1", actorType: "agent" as const, actorName: "Perm" },
taskId: "FN-1",
permissionPolicy: {
presetId: "approval-required" as const,
rules: {
git_write: "require-approval" as const,
file_write_delete: "require-approval" as const,
command_execution: "require-approval" as const,
network_api: "require-approval" as const,
task_agent_mutation: "require-approval" as const,
},
},
createApprovalRequest: vi.fn(async (input: { toolName: string; approvalDedupeKey?: string }) => {
const created = await createApprovalRequest(input);
stored = { id: created.id, targetAction: { context: { approvalDedupeKey: input.approvalDedupeKey } } };
return created;
}),
findPendingApprovalRequest,
};
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], gating as any);
await (wrapped[0] as any).execute("t1", { command: "pnpm test" });
await (wrapped[0] as any).execute("t2", { command: "pnpm test" });
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
expect(findPendingApprovalRequest).toHaveBeenCalledTimes(2);
});
it("requires approval for governed internal task-mutation fn_* tools", async () => {
const tool = { name: "fn_task_create", label: "Task Create", description: "", parameters: {}, execute: vi.fn().mockResolvedValue({ ok: true }) };
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-fn-1" });

View File

@@ -37,6 +37,7 @@ import { createLogger, heartbeatLog, formatError } from "./logger.js";
import { acquireTaskWorktree } from "./worktree-acquisition.js";
import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type EngineRunContext } from "./run-audit.js";
import { promptWithFallback } from "./pi.js";
import { buildAgentGatedActionSummary } from "./permanent-agent-gating.js";
import { createResolvedAgentSession, extractRuntimeHint, resolveHeartbeatSessionModels } from "./agent-session-helpers.js";
import { resolveMcpServersForStore } from "./mcp-resolution.js";
import type { AgentActionGateContext } from "./agent-action-gate.js";
@@ -1225,20 +1226,34 @@ export class HeartbeatMonitor {
requester: { actorId: agent.id, actorType: "agent", actorName: agent.name },
taskId,
runId,
createApprovalRequest: async ({ category, toolName, args }) => this.getApprovalRequestStore().create({
// FNXC:AgentGating 2026-07-05-00:00:
// FN-7609: operators approving a gated action need the real command/args,
// and a stateless heartbeat retrying the same command must reuse a single
// pending approval instead of minting duplicates. `summary` is now
// payload-bearing (shared helper) and `approvalDedupeKey`/`command`/`cwd`
// are persisted into targetAction.context so findPendingApprovalRequest
// can match and the UI can render the payload without re-parsing.
createApprovalRequest: async ({ category, toolName, args, approvalDedupeKey }) => this.getApprovalRequestStore().create({
requester: { actorId: agent.id, actorType: "agent", actorName: agent.name },
taskId,
runId,
targetAction: {
category,
action: toolName,
summary: `Agent gated action for ${toolName}`,
summary: buildAgentGatedActionSummary(toolName, args),
resourceType: "tool",
resourceId: toolName,
context: {
toolName,
toolArgs: args,
source: "agent-gating",
...(approvalDedupeKey ? { approvalDedupeKey } : {}),
...(typeof (args as Record<string, unknown> | undefined)?.command === "string"
? { command: (args as Record<string, unknown>).command }
: {}),
...(typeof (args as Record<string, unknown> | undefined)?.cwd === "string"
? { cwd: (args as Record<string, unknown>).cwd }
: {}),
},
},
}),

View File

@@ -77,6 +77,7 @@ import { canonicalFusionBranchName, canonicalStepInstanceBranchName, generateWor
import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js";
import { Type, type Static } from "@earendil-works/pi-ai";
import { describeModel, formatModelMarkerDetails, promptWithFallback, compactSessionContext } from "./pi.js";
import { buildAgentGatedActionSummary } from "./permanent-agent-gating.js";
import { accumulateSessionTokenUsage, mergeTokenUsagePerModel } from "./session-token-usage.js";
import {
createResolvedAgentSession,
@@ -2302,7 +2303,14 @@ export class TaskExecutor {
},
taskId,
runId: taskId ? this.getRunContextFor(taskId)?.runId : undefined,
createApprovalRequest: async ({ category, toolName, args }) => this.approvalRequestStore.create({
// FNXC:AgentGating 2026-07-05-00:00:
// FN-7609: operators approving a gated action need the real command/args,
// and a stateless heartbeat retrying the same command must reuse a single
// pending approval instead of minting duplicates. `summary` is now
// payload-bearing (shared helper) and `approvalDedupeKey`/`command`/`cwd`
// are persisted into targetAction.context so findPendingApprovalRequest
// can match and the UI can render the payload without re-parsing.
createApprovalRequest: async ({ category, toolName, args, approvalDedupeKey }) => this.approvalRequestStore.create({
requester: {
actorId,
actorType: "agent",
@@ -2313,13 +2321,20 @@ export class TaskExecutor {
targetAction: {
category,
action: toolName,
summary: `Agent gated action for ${toolName}`,
summary: buildAgentGatedActionSummary(toolName, args),
resourceType: "tool",
resourceId: toolName,
context: {
toolName,
toolArgs: args,
source: "agent-gating",
...(approvalDedupeKey ? { approvalDedupeKey } : {}),
...(typeof (args as Record<string, unknown> | undefined)?.command === "string"
? { command: (args as Record<string, unknown>).command }
: {}),
...(typeof (args as Record<string, unknown> | undefined)?.cwd === "string"
? { cwd: (args as Record<string, unknown>).cwd }
: {}),
},
},
}),

View File

@@ -43,6 +43,59 @@ function extractShellCommand(args: Record<string, unknown>): string {
return typeof command === "string" ? command.trim() : "";
}
const GATED_SUMMARY_COMMAND_MAX_LENGTH = 200;
function truncateForSummary(value: string, maxLength: number): string {
const singleLine = value.replace(/\s+/g, " ").trim();
if (singleLine.length <= maxLength) {
return singleLine;
}
return `${singleLine.slice(0, maxLength - 1)}\u2026`;
}
function renderCompactArgs(args: Record<string, unknown>): string {
const entries = Object.entries(args).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
return "";
}
const rendered = entries
.slice(0, 4)
.map(([key, value]) => {
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
return `${key}: ${truncateForSummary(String(stringValue ?? ""), 60)}`;
})
.join(", ");
const suffix = entries.length > 4 ? ", \u2026" : "";
return `{${rendered}${suffix}}`;
}
/**
* FNXC:AgentGating 2026-07-05-00:00:
* FN-7609: operators approving a gated agent action need to see the real
* payload (shell command line, or tool arguments), not just a generic
* "Agent gated action for <tool>" placeholder. This pure helper builds a
* payload-bearing, human-readable summary shared by both permanent-agent
* gating context builders (executor.ts and agent-heartbeat.ts) so approval
* cards are actionable instead of blank.
*/
export function buildAgentGatedActionSummary(toolName: string, args: unknown): string {
const normalizedArgs = normalizeArgs(args);
if (toolName === "bash") {
const command = extractShellCommand(normalizedArgs);
if (command) {
return `Run: ${truncateForSummary(command, GATED_SUMMARY_COMMAND_MAX_LENGTH)}`;
}
}
const compactArgs = renderCompactArgs(normalizedArgs);
if (compactArgs) {
return `${toolName} ${compactArgs}`;
}
return `Agent gated action for ${toolName}`;
}
export function classifyPermanentAgentToolCall(
toolName: string,

View File

@@ -1861,10 +1861,16 @@ export function wrapToolsWithPermanentAgentGating(
let approvalRequest = await gating.findPendingApprovalRequest?.(dedupeKey);
if (!approvalRequest && gating.createApprovalRequest) {
// FNXC:AgentGating 2026-07-05-00:00:
// FN-7609: pass the dedupe key through so the gating closure can persist
// it into targetAction.context.approvalDedupeKey — without this, the
// findPendingApprovalRequest lookup above can never match and every
// retrying heartbeat tick mints a fresh duplicate approval request.
approvalRequest = await gating.createApprovalRequest({
category: normalizeApprovalRequestCategory(decision.category),
toolName: decision.toolName,
args: params,
approvalDedupeKey: dedupeKey,
});
}