Skip to content
Merged
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
10 changes: 9 additions & 1 deletion packages/core/api/src/handlers/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,22 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler
.handle("list", () =>
capture(Effect.gen(function* () {
const executor = yield* ExecutorService;
const tools = yield* executor.tools.list();
// Tools page is a management view — include policy-blocked tools
// (so users can unblock them) and load annotations so the row can
// show the plugin's default approval state when no user rule
// matches. Mirrors the per-source `sources.tools` handler.
const tools = yield* executor.tools.list({
includeAnnotations: true,
includeBlocked: true,
});
return tools.map((t) => ({
id: ToolId.make(t.id),
pluginId: t.pluginId,
sourceId: t.sourceId,
name: t.name,
description: t.description,
mayElicit: t.annotations?.mayElicit,
requiresApproval: t.annotations?.requiresApproval,
}));
})),
)
Expand Down
1 change: 1 addition & 0 deletions packages/core/api/src/tools/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const ToolMetadataResponse = Schema.Struct({
name: Schema.String,
description: Schema.optional(Schema.String),
mayElicit: Schema.optional(Schema.Boolean),
requiresApproval: Schema.optional(Schema.Boolean),
});

const ToolSchemaResponse = Schema.Struct({
Expand Down
153 changes: 123 additions & 30 deletions packages/react/src/components/tool-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,48 @@ import { toolSchemaAtom } from "../api/atoms";
import { ScopeId, ToolId, type EffectivePolicy, type ToolPolicyAction } from "@executor-js/sdk";
import { Badge } from "./badge";
import { Button } from "./button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./dropdown-menu";
import { Markdown } from "./markdown";
import { SchemaExplorer } from "./schema-explorer";
import { ExpandableCodeBlock } from "./expandable-code-block";
import { CardStack, CardStackHeader, CardStackContent } from "./card-stack";
import { CopyButton } from "./copy-button";
import { ChevronRight } from "lucide-react";

const POLICY_LABEL: Record<ToolPolicyAction, string> = {
approve: "Auto-approve",
require_approval: "Require approval",
block: "Blocked",
};

const POLICY_VARIANT: Record<
ToolPolicyAction,
"default" | "secondary" | "outline" | "destructive"
> = {
approve: "secondary",
require_approval: "outline",
block: "destructive",
};
import { ChevronRight, ChevronDownIcon } from "lucide-react";
import { cn } from "../lib/utils";
import {
POLICY_ACTION_LABEL,
POLICY_ACTIONS_IN_ORDER,
POLICY_BADGE_VARIANT,
POLICY_STATE_LABEL,
} from "../lib/policy-display";

// Render the effective policy as a badge. User policies show the
// matched pattern; plugin defaults read "Default: <action>". Silent for
// the auto-approve plugin default — that's the safe state and the
// the always-run plugin default — that's the safe state and the
// header would just be noise.
const policyBadgeFor = (policy: EffectivePolicy) => {
if (policy.source === "plugin-default" && policy.action === "approve") {
return null;
}
if (policy.source === "user") {
return {
variant: POLICY_VARIANT[policy.action],
variant: POLICY_BADGE_VARIANT[policy.action],
title: `Matched policy: ${policy.pattern}`,
text: `${POLICY_LABEL[policy.action]} · ${policy.pattern}`,
text: `${POLICY_STATE_LABEL[policy.action]} · ${policy.pattern}`,
className: "font-mono text-[10px]",
};
}
return {
variant: "outline" as const,
title: "No matching policy — plugin default applies",
text: `Default: ${POLICY_LABEL[policy.action]}`,
text: `Default: ${POLICY_STATE_LABEL[policy.action]}`,
className: "text-[10px] text-muted-foreground",
};
};
Expand Down Expand Up @@ -94,6 +94,10 @@ export function ToolDetail(props: {
/** Resolved effective policy — user-authored or plugin-default,
* unified into one shape. Surfaces in the header. */
policy?: EffectivePolicy;
/** When provided, the policy badge becomes a dropdown trigger that
* applies a user rule to this tool's exact id. */
onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
onClearPolicy?: (pattern: string) => void;
}) {
const toolContract = useAtomValue(toolSchemaAtom(props.scopeId, props.toolId as ToolId));
const [tab, setTab] = useState<"schema" | "typescript">("schema");
Expand Down Expand Up @@ -136,16 +140,12 @@ export function ToolDetail(props: {
<div className="mt-1 flex items-center gap-2">
<h3 className="text-base font-semibold text-foreground truncate">{displayName}</h3>
<CopyButton value={props.toolId} label="Copy tool ID" />
{(() => {
if (!props.policy) return null;
const badge = policyBadgeFor(props.policy);
if (!badge) return null;
return (
<Badge variant={badge.variant} title={badge.title} className={badge.className}>
{badge.text}
</Badge>
);
})()}
<PolicyBadgeMenu
toolName={props.toolName}
policy={props.policy}
onSetPolicy={props.onSetPolicy}
onClearPolicy={props.onClearPolicy}
/>
</div>
{props.toolDescription && (
<div className="mt-1.5 max-w-lg text-sm text-muted-foreground line-clamp-2">
Expand Down Expand Up @@ -244,6 +244,99 @@ export function ToolDetail(props: {
);
}

// ---------------------------------------------------------------------------
// PolicyBadgeMenu — clickable header badge that opens the same
// Always run / Require approval / Block / Clear menu the tree row uses.
// Falls back to a plain Badge when no actions are provided.
// ---------------------------------------------------------------------------

function PolicyBadgeMenu(props: {
toolName: string;
policy?: EffectivePolicy;
onSetPolicy?: (pattern: string, action: ToolPolicyAction) => void;
onClearPolicy?: (pattern: string) => void;
}) {
const interactive = !!props.onSetPolicy;
// The "Clear" affordance only makes sense when there's a user rule
// pinned to this exact tool id — clearing a wildcard rule from a
// single tool's detail header would silently affect siblings.
const hasExactUserRule =
props.policy?.source === "user" && props.policy.pattern === props.toolName;
const currentAction = hasExactUserRule ? props.policy?.action : undefined;

if (!interactive) {
if (!props.policy) return null;
const badge = policyBadgeFor(props.policy);
if (!badge) return null;
return (
<Badge variant={badge.variant} title={badge.title} className={badge.className}>
{badge.text}
</Badge>
);
}

// Interactive trigger always renders, even when the effective policy
// would otherwise be "silent" (auto-approve plugin-default), so the
// user can click it to override.
const badge = props.policy ? policyBadgeFor(props.policy) : null;
const triggerLabel = badge?.text ?? "Set policy";
const triggerVariant = badge?.variant ?? "outline";
const triggerTitle = badge?.title ?? "Set policy";
const triggerClassName = badge?.className ?? "text-[10px] text-muted-foreground";

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
aria-label={triggerTitle}
className="h-auto rounded-none p-0 hover:bg-transparent"
>
<Badge
variant={triggerVariant}
title={triggerTitle}
className={cn(
triggerClassName,
"cursor-pointer gap-1 pr-1.5 transition-opacity hover:opacity-80",
)}
>
{triggerLabel}
<ChevronDownIcon aria-hidden className="size-3 opacity-70" />
</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel className="font-mono text-xs">{props.toolName}</DropdownMenuLabel>
<DropdownMenuSeparator />
{POLICY_ACTIONS_IN_ORDER.map((action) => (
<DropdownMenuItem
key={action}
onSelect={() => props.onSetPolicy?.(props.toolName, action)}
>
<span className="flex-1">{POLICY_ACTION_LABEL[action]}</span>
{currentAction === action && (
<span aria-hidden className="text-muted-foreground">
</span>
)}
</DropdownMenuItem>
))}
{hasExactUserRule && props.onClearPolicy && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => props.onClearPolicy?.(props.toolName)}
className="text-muted-foreground"
>
Clear
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading