499 lines · 18.9 KB · TypeScript
RawDownload
import { useState } from "react";import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import * as Exit from "effect/Exit";import { trackEvent } from "../api/analytics";import { generateKeyBetween } from "fractional-indexing";import { ChevronDownIcon } from "lucide-react";import {  PolicyId,  matchPattern,  isValidPattern,  type Owner,  type ToolPolicyAction,} from "@executor-js/sdk/shared"; import {  createPolicyOptimistic,  policiesOptimisticAtom,  removePolicyOptimistic,  updatePolicyOptimistic,} from "../api/atoms";import { policyWriteKeys } from "../api/reactivity-keys";import { ownerLabel, useOwnerDisplay } from "../api/owner-display";import { badgeVariants } from "../components/badge";import { cn } from "../lib/utils";import {  POLICY_ACTION_LABEL,  POLICY_ACTIONS_IN_ORDER,  POLICY_BADGE_VARIANT,} from "../lib/policy-display";import { Button } from "../components/button";import { PageContainer, PageHeader } from "../components/page";import {  CardStack,  CardStackContent,  CardStackEntry,  CardStackEntryActions,  CardStackEntryContent,  CardStackEntryDescription,  CardStackEntryTitle,  CardStackHeader,} from "../components/card-stack";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuItem,  DropdownMenuTrigger,} from "../components/dropdown-menu";import { Input } from "../components/input";import {  Select,  SelectContent,  SelectItem,  SelectPrimitiveTrigger,  SelectTrigger,  SelectValue,} from "../components/select";import { Label } from "../components/label";import { useExecutorDocumentTitle } from "../lib/document-title";import { ErrorState } from "../components/error-state";import { isAsyncResultLoading } from "../lib/async-result"; // Owner guardrail ordering: org rules are the outer guardrail (rank 0), user// rules are inner (rank 1). Mirrors server-side resolution where the most// restrictive matched action across owners wins.const ownerRank = (owner: Owner): number => (owner === "org" ? 0 : 1); // The two owners a policy can target.const POLICY_OWNERS: readonly { readonly owner: Owner; readonly label: string }[] = [  { owner: "org", label: "Workspace" },  { owner: "user", label: "Personal" },]; // ---------------------------------------------------------------------------// Sort comparator — owner rank, then fractional-indexing key, then id as a// stable tiebreak. Identical positions can briefly happen across racing// inserts; without the tiebreak the rendered order flips between refetches, and// `generateKeyBetween` would also throw if asked to insert "between" two equal// keys.// --------------------------------------------------------------------------- const comparePolicy = (posA: string, idA: string, posB: string, idB: string): number => {  if (posA < posB) return -1;  if (posA > posB) return 1;  if (idA < idB) return -1;  if (idA > idB) return 1;  return 0;}; // Pattern matching + validation come from the SDK so the UI's "matches N tools"// preview and the add-policy validation use the EXACT same grammar the executor// enforces — including mid-segment wildcards (`integration.*.*.tool`), which the// connection-aware policy model now relies on. (Re-exported below for callers.)const matchesPattern = matchPattern; // ---------------------------------------------------------------------------// Add-policy form// --------------------------------------------------------------------------- function AddPolicyForm(props: {  onSubmit: (input: { owner: Owner; pattern: string; action: ToolPolicyAction }) => void;  owner: Owner;  onOwnerChange: (owner: Owner) => void;  busy: boolean;}) {  const [pattern, setPattern] = useState("");  const [action, setAction] = useState<ToolPolicyAction>("require_approval");  const valid = isValidPattern(pattern);  // Non-org hosts (local/desktop) have one local workspace. New local policies  // are org-owned internally to match the v1->v2 migration.  const ownerDisplay = useOwnerDisplay();  const ownerChoices = ownerDisplay.isSinglePlayerHost    ? POLICY_OWNERS.filter((option) => option.owner === "org").map((option) => ({        ...option,        label: "Local",      }))    : POLICY_OWNERS;   return (    <form      className="flex flex-col gap-3 rounded-xl border border-border bg-card px-5 py-4"      onSubmit={(e) => {        e.preventDefault();        if (!valid) return;        props.onSubmit({ owner: props.owner, pattern, action });        setPattern("");        setAction("require_approval");      }}    >      <div className="flex flex-col gap-1">        <Label htmlFor="policy-pattern" className="text-xs font-medium text-foreground/80">          Pattern        </Label>        <Input          id="policy-pattern"          placeholder="vercel.dns.* or *"          value={pattern}          onChange={(e) => setPattern(e.target.value)}          className="font-mono text-sm"        />        <p className="text-xs text-muted-foreground">          Exact tool id, trailing wildcard, or <code className="font-mono">*</code> for every tool.          Examples: <code className="font-mono">*</code>,{" "}          <code className="font-mono">vercel.*</code>,{" "}          <code className="font-mono">vercel.dns.*</code>,{" "}          <code className="font-mono">vercel.dns.create</code>.        </p>      </div>      <div className="flex flex-col gap-1">        <Label className="text-xs font-medium text-foreground/80">Action</Label>        <Select value={action} onValueChange={(v) => setAction(v as ToolPolicyAction)}>          <SelectTrigger className="w-full">            <SelectValue />          </SelectTrigger>          <SelectContent>            {POLICY_ACTIONS_IN_ORDER.map((a) => (              <SelectItem key={a} value={a}>                {POLICY_ACTION_LABEL[a]}              </SelectItem>            ))}          </SelectContent>        </Select>      </div>      {ownerChoices.length > 1 ? (        <div className="flex flex-col gap-1">          <Label className="text-xs font-medium text-foreground/80">Applies to</Label>          <Select            value={props.owner}            onValueChange={(value) => props.onOwnerChange(value as Owner)}          >            <SelectTrigger className="w-full">              <SelectValue />            </SelectTrigger>            <SelectContent>              {ownerChoices.map((option) => (                <SelectItem key={option.owner} value={option.owner}>                  {option.label}                </SelectItem>              ))}            </SelectContent>          </Select>        </div>      ) : null}      <div className="flex items-center justify-end">        <Button type="submit" disabled={!valid || props.busy} size="sm">          Add policy        </Button>      </div>    </form>  );} // ---------------------------------------------------------------------------// Policy row// --------------------------------------------------------------------------- function PolicyRow(props: {  policy: {    id: string;    owner: Owner;    pattern: string;    action: ToolPolicyAction;  };  isFirst: boolean;  isLast: boolean;  onRemove: () => void;  onChangeAction: (action: ToolPolicyAction) => void;  onMoveUp: () => void;  onMoveDown: () => void;  showOwnerLabel: boolean;}) {  return (    <CardStackEntry>      <CardStackEntryContent>        <CardStackEntryTitle className="flex items-center gap-2 font-mono text-sm">          <span className="truncate">{props.policy.pattern}</span>          {props.showOwnerLabel ? (            <span className="shrink-0 rounded border border-border px-1.5 py-0.5 font-sans text-[10px] leading-none text-muted-foreground">              {ownerLabel(props.policy.owner)}            </span>          ) : null}        </CardStackEntryTitle>      </CardStackEntryContent>      <CardStackEntryActions>        <Select          value={props.policy.action}          onValueChange={(v) => props.onChangeAction(v as ToolPolicyAction)}        >          <SelectPrimitiveTrigger            className={cn(              badgeVariants({                variant: POLICY_BADGE_VARIANT[props.policy.action],              }),              "cursor-pointer pr-1.5 gap-1 transition-[opacity,box-shadow] hover:opacity-80 focus-visible:outline-none data-[state=open]:ring-2 data-[state=open]:ring-ring/50",            )}          >            {POLICY_ACTION_LABEL[props.policy.action]}            <ChevronDownIcon className="size-3 opacity-70" />          </SelectPrimitiveTrigger>          <SelectContent position="popper" align="end">            {POLICY_ACTIONS_IN_ORDER.map((a) => (              <SelectItem key={a} value={a}>                {POLICY_ACTION_LABEL[a]}              </SelectItem>            ))}          </SelectContent>        </Select>        <DropdownMenu>          <DropdownMenuTrigger asChild>            <Button              variant="ghost"              size="icon"              className="size-7 opacity-0 transition-opacity group-hover/card-stack-entry:opacity-100 group-focus-within/card-stack-entry:opacity-100 data-[state=open]:opacity-100"            >              <svg viewBox="0 0 16 16" className="size-3">                <circle cx="8" cy="3" r="1.2" fill="currentColor" />                <circle cx="8" cy="8" r="1.2" fill="currentColor" />                <circle cx="8" cy="13" r="1.2" fill="currentColor" />              </svg>            </Button>          </DropdownMenuTrigger>          <DropdownMenuContent align="end" className="w-40">            <DropdownMenuItem disabled={props.isFirst} onClick={props.onMoveUp}>              Move up            </DropdownMenuItem>            <DropdownMenuItem disabled={props.isLast} onClick={props.onMoveDown}>              Move down            </DropdownMenuItem>            <DropdownMenuItem              className="text-destructive focus:text-destructive text-sm"              onClick={props.onRemove}            >              Remove            </DropdownMenuItem>          </DropdownMenuContent>        </DropdownMenu>      </CardStackEntryActions>    </CardStackEntry>  );} // ---------------------------------------------------------------------------// Page// --------------------------------------------------------------------------- export function PoliciesPage() {  useExecutorDocumentTitle("Policies");  const policies = useAtomValue(policiesOptimisticAtom);  const refreshPolicies = useAtomRefresh(policiesOptimisticAtom);  const doCreate = useAtomSet(createPolicyOptimistic, { mode: "promiseExit" });  const doUpdate = useAtomSet(updatePolicyOptimistic, { mode: "promiseExit" });  const doRemove = useAtomSet(removePolicyOptimistic, { mode: "promiseExit" });  const [busy, setBusy] = useState(false);  const ownerDisplay = useOwnerDisplay();  // Policies default to org/workspace. On local this is the hidden Local owner  // that v1 local data migrates into.  const [targetOwner, setTargetOwner] = useState<Owner>("org");   const handleCreate = async (input: {    owner: Owner;    pattern: string;    action: ToolPolicyAction;  }) => {    setBusy(true);    const exit = await doCreate({      payload: {        owner: input.owner,        pattern: input.pattern,        action: input.action,      },      reactivityKeys: policyWriteKeys,    });    trackEvent("policy_created", {      action: input.action,      owner: input.owner,      success: Exit.isSuccess(exit),    });    if (Exit.isFailure(exit)) {      setBusy(false);      return;    }    setBusy(false);  };   const handleUpdate = async (policy: { id: string; owner: Owner }, action: ToolPolicyAction) => {    const exit = await doUpdate({      params: { policyId: PolicyId.make(policy.id) },      payload: { owner: policy.owner, action },      reactivityKeys: policyWriteKeys,    });    trackEvent("policy_action_changed", {      action,      owner: policy.owner,      success: Exit.isSuccess(exit),    });  };   const handleRemove = async (policy: { id: string; owner: Owner }) => {    const exit = await doRemove({      params: { policyId: PolicyId.make(policy.id) },      payload: { owner: policy.owner },      reactivityKeys: policyWriteKeys,    });    trackEvent("policy_removed", { owner: policy.owner, success: Exit.isSuccess(exit) });  };   const handleMove = async (    policy: { id: string; owner: Owner },    position: string,    direction: "up" | "down",  ) => {    const exit = await doUpdate({      params: { policyId: PolicyId.make(policy.id) },      payload: { owner: policy.owner, position },      reactivityKeys: policyWriteKeys,    });    trackEvent("policy_reordered", {      owner: policy.owner,      direction,      success: Exit.isSuccess(exit),    });  };   return (    <PageContainer>      <PageHeader        title="Policies"        description="Decide which tools run automatically, which ask for approval, and which are blocked. The most restrictive matching rule wins; blocked tools are hidden from agents."      />       <div className="mb-8">        <AddPolicyForm          onSubmit={handleCreate}          owner={targetOwner}          onOwnerChange={setTargetOwner}          busy={busy}        />      </div>       {isAsyncResultLoading(policies) ? (        <div className="flex items-center gap-2 py-8">          <div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" />          <p className="text-sm text-muted-foreground">Loading policies…</p>        </div>      ) : (        AsyncResult.match(policies, {          onInitial: () => (            <div className="flex items-center gap-2 py-8">              <div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" />              <p className="text-sm text-muted-foreground">Loading policies…</p>            </div>          ),          onFailure: () => (            <ErrorState message="Failed to load policies" onRetry={refreshPolicies} />          ),          onSuccess: ({ value }) => {            // Sort by owner rank (org outer, user inner), then position (lex            // order on fractional-indexing keys), tiebreaking on id so            // identical positions don't swap on refetch and            // `generateKeyBetween` never sees duplicate neighbor keys (which            // would throw). Optimistic placeholders carry `position: ""` so            // they sort to the top of their owner group.            const sorted = [...value].sort((a, b) => {              const ownerOrder = ownerRank(a.owner) - ownerRank(b.owner);              return ownerOrder === 0                ? comparePolicy(a.position, a.id, b.position, b.id)                : ownerOrder;            });            // Reorder math runs against committed rows only — placeholder rows            // (empty `position`) aren't valid keys for `generateKeyBetween` and            // aren't reorderable until the server confirms.            const committedForOwner = (owner: Owner) =>              sorted.filter((p) => p.owner === owner && p.position !== "");            const committedIndex = (id: string, owner: Owner): number =>              committedForOwner(owner).findIndex((p) => p.id === id);            const positionAbove = (id: string, owner: Owner): string => {              const committed = committedForOwner(owner);              const j = committedIndex(id, owner);              if (j <= 0) return generateKeyBetween(null, committed[0]!.position);              return j === 1                ? generateKeyBetween(null, committed[0]!.position)                : generateKeyBetween(committed[j - 2]!.position, committed[j - 1]!.position);            };            const positionBelow = (id: string, owner: Owner): string => {              const committed = committedForOwner(owner);              const j = committedIndex(id, owner);              if (j === -1 || j >= committed.length - 1)                return generateKeyBetween(committed[committed.length - 1]!.position, null);              return j === committed.length - 2                ? generateKeyBetween(committed[committed.length - 1]!.position, null)                : generateKeyBetween(committed[j + 1]!.position, committed[j + 2]!.position);            };            return (              <CardStack>                <CardStackHeader>Active policies</CardStackHeader>                <CardStackContent>                  {sorted.length === 0 ? (                    <CardStackEntry>                      <CardStackEntryContent>                        <CardStackEntryDescription>                          No policies yet. Tools fall back to their plugin's default approval                          behavior.                        </CardStackEntryDescription>                      </CardStackEntryContent>                    </CardStackEntry>                  ) : (                    sorted.map((p) => {                      const committed = committedForOwner(p.owner);                      const j = committedIndex(p.id, p.owner);                      // Pending placeholder or only one committed row → no                      // reorder affordance.                      const reorderable = j !== -1 && committed.length > 1;                      return (                        <PolicyRow                          key={p.id}                          policy={{                            id: p.id,                            owner: p.owner,                            pattern: p.pattern,                            action: p.action,                          }}                          isFirst={!reorderable || j === 0}                          isLast={!reorderable || j === committed.length - 1}                          showOwnerLabel={ownerDisplay.showOwnerLabels}                          onRemove={() => handleRemove({ id: p.id, owner: p.owner })}                          onChangeAction={(action) =>                            handleUpdate({ id: p.id, owner: p.owner }, action)                          }                          onMoveUp={() =>                            handleMove(                              { id: p.id, owner: p.owner },                              positionAbove(p.id, p.owner),                              "up",                            )                          }                          onMoveDown={() =>                            handleMove(                              { id: p.id, owner: p.owner },                              positionBelow(p.id, p.owner),                              "down",                            )                          }                        />                      );                    })                  )}                </CardStackContent>              </CardStack>            );          },        })      )}    </PageContainer>  );} // Exported for tests / direct consumers that don't want the matcher// duplicated in two places. Cloud's UI uses these for live preview.export { matchesPattern, isValidPattern };