Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hidden-mcp-apps-core-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": none
---

Carry the shared `isOutsideAgentCall` helper from #8005, whose changeset supplies the release note.
76 changes: 76 additions & 0 deletions packages/core/src/action-caller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";

import { isOutsideAgentCall } from "./action-caller.js";
import type { ActionCaller, ActionRunContext } from "./action.js";

const callers = {
frontend: { outside: false, reason: "the app's own UI" },
tool: { outside: false, reason: "the app's own agent without delegation" },
http: {
outside: true,
reason:
"HTTP alone does not prove a human UI click; action routes verify UI capability separately",
},
cli: {
outside: true,
reason:
"scripts and the development CLI do not prove an in-app human interaction",
},
automation: {
outside: true,
reason: "scheduled or triggered execution is not an interactive human call",
},
mcp: { outside: true, reason: "a direct MCP tool call" },
a2a: { outside: true, reason: "a direct A2A action call" },
webmcp: { outside: true, reason: "a browser agent's WebMCP call" },
"mcp-widget": { outside: true, reason: "a card's scoped read capability" },
"mcp-widget-write": {
outside: true,
reason: "a card's scoped write capability",
},
} satisfies Record<ActionCaller, { outside: boolean; reason: string }>;

const protocols = {
a2a: "the default A2A chat handoff retains tool as the caller",
mcp: "ask_app runs the app agent as tool on an outside agent's behalf",
"provider-api":
"external provider provenance does not establish an in-app human call",
} satisfies Record<NonNullable<ActionRunContext["networkProtocol"]>, string>;

describe("isOutsideAgentCall", () => {
for (const [caller, { outside, reason }] of Object.entries(callers)) {
const ctx = { caller: caller as ActionCaller };

it(`classifies ${caller}: ${reason}`, () => {
expect(isOutsideAgentCall(ctx)).toBe(outside);
});

it(`treats ${caller} with automation context as outside`, () => {
expect(
isOutsideAgentCall({
...ctx,
automation: { triggerId: "trigger-1", triggerName: "Scheduled run" },
}),
).toBe(true);
});

for (const [protocol, reason] of Object.entries(protocols)) {
it(`treats ${caller} over ${protocol} as outside: ${reason}`, () => {
expect(
isOutsideAgentCall({
...ctx,
networkProtocol: protocol as ActionRunContext["networkProtocol"],
}),
).toBe(true);
});
}
}

it("fails closed without context or a recognized caller", () => {
expect(isOutsideAgentCall()).toBe(true);
expect(isOutsideAgentCall({} as ActionRunContext)).toBe(true);
expect(
isOutsideAgentCall({ caller: "future-caller" } as ActionRunContext),
).toBe(true);
});
});
14 changes: 14 additions & 0 deletions packages/core/src/action-caller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ActionRunContext } from "./action.js";

/** Classify provenance for approval policy; this does not authenticate a caller. */
export function isOutsideAgentCall(
ctx?: Pick<ActionRunContext, "caller" | "networkProtocol" | "automation">,
): boolean {
// CLI defaults to outside because coding agents drive it; Alice's decision is pending.
// Automation has no trusted origin in ctx yet; #8070's mcpManaged is job frontmatter.
return (
ctx?.networkProtocol !== undefined ||
ctx?.automation !== undefined ||
(ctx?.caller !== "frontend" && ctx?.caller !== "tool")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Unproven frontend claims bypass approval

The HTTP route can set caller: "frontend" from the client-controlled frontend header even when provenUiRequest is false; it then passes that caller into the approval predicate, which treats it as trusted and skips approval. Only classify frontend calls as trusted after the route verifies the UI capability, and add a regression test for the header without that proof.

Fix in Builder

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. The route can still treat an arbitrary caller-supplied x-agent-native-frontend: 1 header as trusted even when the UI capability proof fails. Please make frontend trusted only after the route verifies that proof and add a regression for the forged header; otherwise outside callers can skip the approval gate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this PR waits for #8071. Once #8071 merges, I'll rebase onto main and add a regression at that head: a forged x-agent-native-frontend: 1 request for Calendar's manage-event-draft delete-all reaches needsApproval as http and needs approval.

  • Claude Code AI

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. This still depends on #8071 and the follow-up regression, so I’m keeping approval held until both land and are reviewed at the resulting head.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 CLI actions bypass the new approval policy

This helper classifies caller: "cli" as outside, but the CLI dispatcher invokes handler.run(...) directly without evaluating needsApproval. Calls to the newly gated sharing, handoff, or bulk-delete actions through the CLI therefore bypass the intended approval; enforce the policy before dispatch or block approval-gated actions on that path.

Fix in Builder

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. The CLI dispatcher still calls handlers directly without evaluating needsApproval, so the new gate is bypassed on CLI requests too. Please enforce the approval policy before dispatch or fail closed for approval-gated actions, with a CLI regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. scripts/runner.ts and server/dev-action-bridge.ts run handlers without calling preparedCallNeedsApproval, and that's true on main for every action with a gate. The CLI should evaluate the gate before it dispatches and refuse when it can't get approval. That's a core change that affects every gated action, so it's being scoped separately; this PR won't merge without it. I'm leaving this thread open until it's pushed.

  • Claude Code AI

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming the CLI gap. I’m holding approval until CLI dispatch applies preparedCallNeedsApproval and refuses gated calls when it cannot obtain approval. Please send the current head once that fix is ready.

);
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export {
type ActionMcpAppResourceConfig,
type ActionMcpAppResourceMeta,
} from "./action.js";
export { isOutsideAgentCall } from "./action-caller.js";
export * from "./user-profile/index.js";
export {
embedApp,
Expand Down
275 changes: 275 additions & 0 deletions packages/core/src/mcp/__snapshots__/hidden-inline-apps.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`explicit connector inline apps > advertises each inline tool and its UI resource on the default production catalog 1`] = `
{
"analytics": [
{
"_meta": {
"openai/outputTemplate": "ui://analytics/open-traffic-dashboard/shell-v65",
"openai/toolInvocation/invoked": "Traffic dashboard ready",
"openai/toolInvocation/invoking": "Opening Traffic dashboard",
"openai/widgetAccessible": true,
"openai/widgetCSP": {
"connect_domains": [
"https://esm.sh",
"$requestOrigin",
],
"frame_domains": [
"$requestOrigin",
],
"resource_domains": [
"https://esm.sh",
"$requestOrigin",
],
},
"ui": {
"resourceUri": "ui://analytics/open-traffic-dashboard/shell-v65",
"visibility": [
"model",
"app",
],
},
"ui/resourceUri": "ui://analytics/open-traffic-dashboard/shell-v65",
},
"annotations": {
"destructiveHint": false,
"openWorldHint": false,
"readOnlyHint": true,
"title": "Open traffic dashboard",
},
"name": "open-traffic-dashboard",
},
],
"calendar": [
{
"_meta": {
"openai/outputTemplate": "ui://calendar/manage-event-draft/shell-v65",
"openai/toolInvocation/invoked": "Review calendar invite ready",
"openai/toolInvocation/invoking": "Opening Review calendar invite",
"openai/widgetAccessible": true,
"openai/widgetCSP": {
"connect_domains": [
"https://esm.sh",
"$requestOrigin",
],
"frame_domains": [
"$requestOrigin",
],
"resource_domains": [
"https://esm.sh",
"$requestOrigin",
],
},
"ui": {
"resourceUri": "ui://calendar/manage-event-draft/shell-v65",
"visibility": [
"model",
"app",
],
},
"ui/resourceUri": "ui://calendar/manage-event-draft/shell-v65",
},
"annotations": {
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": false,
"readOnlyHint": false,
"title": "Manage event draft",
},
"name": "manage-event-draft",
},
],
"content": [
{
"_meta": {
"openai/outputTemplate": "ui://content/share-local-file-document/shell-v65",
"openai/toolInvocation/invoked": "Share document ready",
"openai/toolInvocation/invoking": "Opening Share document",
"openai/widgetAccessible": true,
"openai/widgetCSP": {
"connect_domains": [
"https://esm.sh",
"$requestOrigin",
],
"frame_domains": [
"$requestOrigin",
],
"resource_domains": [
"https://esm.sh",
"$requestOrigin",
],
},
"ui": {
"resourceUri": "ui://content/share-local-file-document/shell-v65",
"visibility": [
"model",
"app",
],
},
"ui/resourceUri": "ui://content/share-local-file-document/shell-v65",
},
"annotations": {
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": true,
"readOnlyHint": false,
"title": "Share local file document",
},
"name": "share-local-file-document",
},
],
"design": [
{
"_meta": {
"openai/outputTemplate": "ui://design/generate-screens/shell-v65",
"openai/toolInvocation/invoked": "Design generation session ready",
"openai/toolInvocation/invoking": "Opening Design generation session",
"openai/widgetAccessible": true,
"openai/widgetCSP": {
"connect_domains": [
"https://esm.sh",
"$requestOrigin",
],
"frame_domains": [
"$requestOrigin",
],
"resource_domains": [
"https://esm.sh",
"$requestOrigin",
],
},
"ui": {
"resourceUri": "ui://design/generate-screens/shell-v65",
"visibility": [
"model",
"app",
],
},
"ui/resourceUri": "ui://design/generate-screens/shell-v65",
},
"annotations": {
"destructiveHint": true,
"idempotentHint": false,
"openWorldHint": false,
"readOnlyHint": false,
"title": "Generate screens",
},
"name": "generate-screens",
},
{
"_meta": {
"openai/outputTemplate": "ui://design/export-coding-handoff/shell-v65",
"openai/toolInvocation/invoked": "Design agent handoff ready",
"openai/toolInvocation/invoking": "Opening Design agent handoff",
"openai/widgetAccessible": true,
"openai/widgetCSP": {
"connect_domains": [
"https://esm.sh",
"$requestOrigin",
],
"frame_domains": [
"$requestOrigin",
],
"resource_domains": [
"https://esm.sh",
"$requestOrigin",
],
},
"ui": {
"resourceUri": "ui://design/export-coding-handoff/shell-v65",
"visibility": [
"model",
"app",
],
},
"ui/resourceUri": "ui://design/export-coding-handoff/shell-v65",
},
"annotations": {
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": true,
"readOnlyHint": false,
"title": "Export coding handoff",
},
"name": "export-coding-handoff",
},
],
"slides": [
{
"_meta": {
"openai/outputTemplate": "ui://slides/clone-context-slide/shell-v65",
"openai/toolInvocation/invoked": "Cloned native slide ready",
"openai/toolInvocation/invoking": "Opening Cloned native slide",
"openai/widgetAccessible": true,
"openai/widgetCSP": {
"connect_domains": [
"https://esm.sh",
"$requestOrigin",
],
"frame_domains": [
"$requestOrigin",
],
"resource_domains": [
"https://esm.sh",
"$requestOrigin",
],
},
"ui": {
"resourceUri": "ui://slides/clone-context-slide/shell-v65",
"visibility": [
"model",
"app",
],
},
"ui/resourceUri": "ui://slides/clone-context-slide/shell-v65",
},
"annotations": {
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": false,
"readOnlyHint": false,
"title": "Clone context slide",
},
"name": "clone-context-slide",
},
],
"slides-2": [
{
"_meta": {
"openai/outputTemplate": "ui://slides-2/clone-context-slide/shell-v65",
"openai/toolInvocation/invoked": "Cloned native slide ready",
"openai/toolInvocation/invoking": "Opening Cloned native slide",
"openai/widgetAccessible": true,
"openai/widgetCSP": {
"connect_domains": [
"https://esm.sh",
"$requestOrigin",
],
"frame_domains": [
"$requestOrigin",
],
"resource_domains": [
"https://esm.sh",
"$requestOrigin",
],
},
"ui": {
"resourceUri": "ui://slides-2/clone-context-slide/shell-v65",
"visibility": [
"model",
"app",
],
},
"ui/resourceUri": "ui://slides-2/clone-context-slide/shell-v65",
},
"annotations": {
"destructiveHint": false,
"openWorldHint": false,
"readOnlyHint": false,
"title": "Clone context slide",
},
"name": "clone-context-slide",
},
],
}
`;
Loading
Loading