Files
PaperClipAI/ui/src/pages/AgentToolsTab.tsx
T
DottaandPaperclip 0ffc091473 feat(connections): add durable GitHub identities and webhooks (#12843)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents need source control access for repository work
> - A shared token cannot preserve the responsible person's identity or
an agent's dedicated identity
> - GitHub App tokens also need durable refresh, repository access
checks, and webhook delivery
> - Paperclip already has managed connections, encrypted grants, run
secret leases, and merge-confirmation behavior
> - This pull request extends those systems with GitHub identities
instead of adding a parallel credential system
> - The benefit is durable GitHub access with explicit identity,
repository, runtime, and webhook boundaries

## Linked Issues or Issue Description

No public GitHub issue describes this connection change. This
description follows the feature request template.

**Subsystem affected**

Connected Apps, connection grants, secret resolution, native Git runtime
setup, webhook processing, and the Apps UI.

**Problem or motivation**

Users need to connect GitHub once and let agents use the correct GitHub
identity. A run should use a dedicated agent account when one exists.
Otherwise, it should use the responsible person's account. The
connection must survive token expiry, repository access changes, and
temporary instance downtime.

**Proposed solution**

Add user-owned and agent-owned GitHub grants to the existing connection
model. Resolve one identity for MCP, Git, `gh`, health checks, and
webhook bindings. Store provider tokens in the existing encrypted secret
system. Refresh expiring token pairs under the existing lease and
compare-and-swap path. Register signed Cloud webhook bindings and
process normalized pull request and installation events through a
durable local inbox.

**Alternatives considered**

An organization-wide GitHub token would lose person and agent
attribution. Environment variables alone would bypass the managed
connection and grant model. A new GitHub-only credential store would
duplicate the existing secret and access systems. GitHub App
installation tokens and private-key custody remain outside this first
version.

**Roadmap alignment**

This change implements the Connected Apps direction. It also extends the
shipped MCP Tool Gateway, per-agent secret access, and
action-attribution systems. It does not add a repository catalog. The
open repository catalog work in
[#11234](https://github.com/paperclipai/paperclip/pull/11234) is related
and complementary.

## What Changed

- Added agent-owned connection grants and a per-agent credential policy
with company and subject constraints.
- Added a managed GitHub App method while keeping the personal access
token method as an advanced fallback.
- Added durable access-token and refresh-token handling with proactive
rotation and one automatic recovery after a provider `401`.
- Added GitHub identity and installation summaries without storing
repository-name lists.
- Added signed Cloud webhook binding, event lease, acknowledgement,
local idempotency, pull request merge processing, and installation
access handling.
- Added one identity resolver for MCP, native Git, `gh`, checkout,
health checks, and webhook bindings.
- Added a class-3 run projection for `GH_TOKEN`, `GITHUB_TOKEN`, a
`github.com`-only credential helper, SSH-to-HTTPS rewrite, and GitHub
noreply commit attribution.
- Added personal and dedicated-agent setup choices plus identity,
repository, continuity, and webhook status in the Apps UI.
- Added schema migrations, tests, and connection documentation.

## Verification

- The current head is fully green in GitHub CI, including build,
typecheck, all serialized/general server shards, all browser shards,
policy, canary dry run, review, and security checks.
- Live staging proof completed with a non-expiring GitHub App user
token, selected-repository installation, repository add/remove refresh,
managed MCP, native `gh`, HTTPS clone/push/delete, GitHub noreply commit
attribution, signed merged-PR webhook acceptance, durable
Cloud-to-instance delivery, and installation-access event processing.
Temporary branches and temporary repository access were removed
afterward.
- `pnpm check:token-gates` passed.
- `pnpm -r typecheck` passed before and after the rebase onto
`origin/master`.
- `pnpm build` passed.
- The focused connector suite passed 285 tests after the rebase.
- The full stable suite passed 5,790 tests and failed 22 tests across 8
general server files. The failures reproduced as shared-runner
environment issues. They included `/tmp` versus `/private/tmp`, closed
database connections, and invalid high ephemeral ports. The focused
connection tests pass in isolation.

## Risks

- Migrations add agent grant subjects and a durable connection-event
inbox. Migration numbering and safety checks pass.
- A raw GitHub user token enters the agent process for Git and `gh`.
Per-tool Ask-first controls cannot limit those shell operations. The UI
warns users about this boundary.
- GitHub App user tokens can be non-expiring. Paperclip performs a
continuity check every 30 days, but provider revocation still requires a
reconnect.
- The webhook path accepts only signed and bounded payloads. It stores a
minimal normalized record and no raw provider payload.
- GitHub repository permissions remain authoritative. Removed access can
make a cached repository count temporarily stale, but runtime access
fails immediately.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, `gpt-5.6-sol`, extended reasoning, tool use, code
execution, browser control, and multi-file repository editing. The
context window size was not provided.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-04 18:02:52 -05:00

786 lines
32 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { HelpCircle, PackageCheck } from "lucide-react";
import type {
AgentDetail as AgentDetailRecord,
ConnectionGrant,
ToolCatalogEntry,
ToolConnection,
ToolPolicy,
} from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { queryKeys } from "../lib/queryKeys";
import { toolsApi } from "../api/tools";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { GithubIcon } from "@/components/icons/github-icon";
import { InlineBanner } from "@/components/InlineBanner";
import { EnforcementBanner } from "../components/EnforcementBanner";
import {
RiskBadge,
CapabilityBadges,
LoadingState as ToolsLoadingState,
ErrorState as ToolsErrorState,
} from "./tools/shared";
import { cn } from "../lib/utils";
import { brandChipBadge } from "../lib/status-colors";
import { installPayload, installStateFrom, isAgentInstalled, INSTALLED_HINT } from "../lib/tool-installs";
function isGitHubConnection(connection: ToolConnection): boolean {
return (connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey) === "github";
}
function isEligibleGitHubIdentityConnection(connection: ToolConnection, agentId: string): boolean {
return isGitHubConnection(connection)
&& connection.enabled
&& connection.status === "active"
&& isAgentInstalled(installStateFrom(connection.installs), agentId);
}
function GitHubIdentitySection({
agentName,
dedicatedIdentity,
personalIdentity,
loading,
loadError,
onRetry,
}: {
agentName: string;
dedicatedIdentity: { connection: ToolConnection; grant: ConnectionGrant } | null;
personalIdentity: { connection: ToolConnection; grant: ConnectionGrant } | null;
loading: boolean;
loadError: boolean;
onRetry: () => void;
}) {
const dedicatedLogin = dedicatedIdentity?.grant.providerTenant?.github?.login;
const personalLogin = personalIdentity?.grant.providerTenant?.github?.login;
return (
<section className="rounded-lg border border-border bg-card">
<div className="flex flex-wrap items-start justify-between gap-3 px-3 py-3">
<div className="flex min-w-0 items-start gap-3">
<div className="rounded-md border border-border bg-muted/40 p-2 text-foreground">
<GithubIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">GitHub identity</h3>
{loading ? (
<p className="mt-0.5 text-xs text-muted-foreground">Checking GitHub identity…</p>
) : loadError ? (
<>
<p className="mt-0.5 text-sm font-medium text-foreground">Could not load GitHub identity</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Paperclip could not verify this agent&apos;s current connection. Your existing setup was not changed.
</p>
</>
) : dedicatedIdentity ? (
<>
<p className="mt-0.5 text-sm font-medium text-foreground">
{dedicatedLogin ? `@${dedicatedLogin}` : "Dedicated GitHub account"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Used only by {agentName}. This dedicated connection takes precedence over the responsible person&apos;s GitHub.
</p>
</>
) : (
<>
<p className="mt-0.5 text-sm font-medium text-foreground">Use responsible person&apos;s GitHub</p>
<p className="mt-0.5 text-xs text-muted-foreground">
At run start, Paperclip uses the personal GitHub connection of the person responsible for the task.
</p>
{personalIdentity ? (
<p className="mt-1 text-xs text-muted-foreground">
Your account is connected{personalLogin ? ` as @${personalLogin}` : ""}.
</p>
) : null}
</>
)}
</div>
</div>
{!loading ? (
<div className="flex flex-wrap items-center gap-2">
{loadError ? (
<Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>
) : dedicatedIdentity ? (
<Button variant="outline" size="sm" asChild>
<Link to={`/apps/${dedicatedIdentity.connection.id}/permissions`}>Manage GitHub identity</Link>
</Button>
) : (
<>
{personalIdentity ? (
<Button variant="outline" size="sm" asChild>
<Link to={`/apps/${personalIdentity.connection.id}/permissions`}>Manage my GitHub</Link>
</Button>
) : (
<Button size="sm" asChild>
<Link to="/apps/connect?source=github">Connect my GitHub</Link>
</Button>
)}
<Button variant="outline" size="sm" asChild>
<Link to="/apps/connect?source=github">Use a dedicated account</Link>
</Button>
</>
)}
</div>
) : null}
</div>
</section>
);
}
/** Normalize a selector value (string or string[]) into a flat string list. */
function selectorStringList(value: unknown): string[] {
if (typeof value === "string") return value.trim() ? [value.trim()] : [];
if (Array.isArray(value)) {
return value.filter((v): v is string => typeof v === "string" && v.trim().length > 0);
}
return [];
}
/**
* A policy governs this agent's allow list when it either explicitly names the
* agent, or carries no agent/actor restriction that would exclude agents. This
* mirrors how the tool gateway evaluates selectors server-side.
*/
export function policyGovernsAgent(policy: ToolPolicy, agentId: string): boolean {
const selectors = (policy.selectors ?? {}) as Record<string, unknown>;
const agentIds = [
...selectorStringList(selectors.agentId),
...selectorStringList(selectors.agentIds),
];
if (agentIds.length > 0) return agentIds.includes(agentId);
const actorTypes = [
...selectorStringList(selectors.actorType),
...selectorStringList(selectors.actorTypes),
];
if (actorTypes.length > 0 && !actorTypes.includes("agent")) return false;
return true;
}
export function mergeInstallDraft(
serverDraft: Record<string, boolean>,
currentDraft: Record<string, boolean>,
lastSavedDraft: Record<string, boolean>,
): { draft: Record<string, boolean>; hasPendingChanges: boolean } {
const draft = { ...serverDraft };
let hasPendingChanges = false;
for (const [connectionId, installed] of Object.entries(currentDraft)) {
if (installed === (lastSavedDraft[connectionId] ?? false)) continue;
draft[connectionId] = installed;
hasPendingChanges = true;
}
return { draft, hasPendingChanges };
}
function InstalledAppsSection({
agentId,
agentName,
connections,
draft,
permittedConnectionIds,
pendingConnectionId,
saving,
unsaved,
error,
onChange,
}: {
agentId: string;
agentName: string;
connections: ToolConnection[];
draft: Record<string, boolean>;
permittedConnectionIds: Set<string>;
pendingConnectionId: string | null;
saving: boolean;
unsaved: boolean;
error: boolean;
onChange: (connectionId: string, installed: boolean) => void;
}) {
return (
<section className="rounded-lg border border-border bg-card">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-3 py-2.5">
<div>
<h3 className="text-sm font-semibold text-foreground">Installed apps</h3>
<p className="mt-0.5 text-xs text-muted-foreground">
Installed apps load tools into {agentName}'s context on every run. Permitted-only apps do not add context cost.
</p>
</div>
<InstallSaveStatusChip pending={saving} unsaved={unsaved} error={error} />
</div>
<div className="space-y-3 p-3">
<InlineBanner tone="info" compact>
Has access means the app is permitted. Installed means its tools are added to this agent's runtime context.
</InlineBanner>
{connections.length === 0 ? (
<p className="rounded-md border border-border bg-muted/30 px-3 py-4 text-sm text-muted-foreground">
No permitted apps yet. Bind an access profile to make apps available here.
</p>
) : (
<div className="divide-y divide-border rounded-md border border-border">
{connections.map((connection) => {
const installState = installStateFrom(connection.installs);
const installedForAll = installState.onAll;
const checked = installedForAll || (draft[connection.id] ?? installState.agentIds.has(agentId));
const permitted = permittedConnectionIds.has(connection.id);
const rowPending = pendingConnectionId === connection.id;
return (
<div key={connection.id} className="space-y-2 px-3 py-3">
<label className="flex items-start gap-3">
<Checkbox
checked={checked}
disabled={installedForAll || rowPending}
aria-label={`Install ${connection.name} on ${agentName}`}
onCheckedChange={(next) => onChange(connection.id, Boolean(next))}
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">{connection.name}</span>
<InstallBadge installed={checked} installedForAll={installedForAll} permitted={permitted} />
{rowPending ? <span className="text-xs text-muted-foreground">Saving...</span> : null}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{installedForAll
? "Installed from the app page for every agent. Remove the all-agents install there."
: checked
? "Loaded into this agent's runtime context."
: INSTALLED_HINT}
</span>
</span>
</label>
{permitted && !checked ? (
<InlineBanner
tone="warning"
compact
className="ml-7"
actions={(
<Link
to={`/apps/${connection.id}/permissions`}
className="text-xs font-medium text-primary hover:underline"
>
Open permissions
</Link>
)}
>
Permitted but not installed — tools will not appear in runs.
</InlineBanner>
) : null}
</div>
);
})}
</div>
)}
</div>
</section>
);
}
function InstallBadge({
installed,
installedForAll,
permitted,
}: {
installed: boolean;
installedForAll: boolean;
permitted: boolean;
}) {
const label = installed ? (installedForAll ? "Installed for all" : "Installed") : permitted ? "Permitted only" : "Not permitted";
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
installed ? brandChipBadge.green : brandChipBadge.gray,
)}
>
{installed ? <PackageCheck className="h-3 w-3" /> : null}
{label}
</span>
);
}
function InstallSaveStatusChip({
pending,
unsaved,
error,
}: {
pending: boolean;
unsaved: boolean;
error: boolean;
}) {
if (pending) return <span className="text-xs text-muted-foreground">Saving...</span>;
if (error) return <span className="text-xs text-destructive">Could not save</span>;
if (unsaved) return <span className="text-xs text-muted-foreground">Unsaved changes</span>;
return <span className="text-xs text-muted-foreground">Saved</span>;
}
const POLICY_EFFECT_LABEL: Record<string, string> = {
allow: "allow",
block: "block",
deny: "deny",
require_approval: "require approval",
redact: "redact",
rate_limit: "rate limit",
};
const DENIED_TOOLS_DISPLAY_LIMIT = 30;
/**
* Agent detail · Tools tab (PAP-10788, surface 09 of the PAP-10771 v2 spec).
*
* Communicates the agent's resolved tool access: the banner makes clear the
* prompt can narrow the list but never expand it, and the side panel explains
* which access profiles and rules shape the final list.
*/
export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord; companyId: string }) {
const queryClient = useQueryClient();
const [installDraft, setInstallDraft] = useState<Record<string, boolean>>({});
const lastSavedInstallRef = useRef<Record<string, boolean>>({});
const skipNextInstallAutosaveRef = useRef(true);
const failedInstallDraftRef = useRef<{ connectionId: string; installed: boolean } | null>(null);
const effective = useQuery({
queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id),
queryFn: () => toolsApi.getEffectiveProfilesForAgent(companyId, agent.id),
});
const connectionsQuery = useQuery({
queryKey: queryKeys.tools.connections(companyId),
queryFn: () => toolsApi.listConnections(companyId),
});
const policiesQuery = useQuery({
queryKey: queryKeys.tools.policies(companyId),
queryFn: () => toolsApi.listPolicies(companyId),
});
const connectionList = connectionsQuery.data?.connections ?? [];
const eligibleGitHubConnections = useMemo(
() => connectionList.filter((connection) => isEligibleGitHubIdentityConnection(connection, agent.id)),
[agent.id, connectionList],
);
const githubGrantQueries = useQueries({
queries: eligibleGitHubConnections.map((connection) => ({
queryKey: queryKeys.tools.connectionGrants(connection.id),
queryFn: () => toolsApi.listConnectionGrants(connection.id),
staleTime: 30_000,
})),
});
const githubIdentityRows = eligibleGitHubConnections.flatMap((connection, index) =>
(githubGrantQueries[index]?.data?.grants ?? []).map((grant) => ({
connection,
grant,
currentUserId: githubGrantQueries[index]?.data?.currentUserId,
})),
);
const dedicatedGitHubIdentity = githubIdentityRows.find(({ grant }) => (
grant.kind === "agent"
&& grant.subjectAgentId === agent.id
&& grant.status === "active"
)) ?? null;
const personalGitHubIdentity = githubIdentityRows.find(({ grant, currentUserId }) => (
grant.kind === "user"
&& grant.subjectUserId === currentUserId
&& grant.status === "active"
)) ?? null;
const connectionInstallSignature = useMemo(
() =>
connectionList
.map((connection) => {
const installKey = (connection.installs ?? [])
.map((install) => `${install.targetType}:${install.targetId}`)
.sort()
.join(",");
return `${connection.id}=${installKey}`;
})
.join("|"),
[connectionList],
);
const syncInstall = useMutation({
mutationFn: ({ connection, installed }: { connection: ToolConnection; installed: boolean }) => {
const nextState = installStateFrom(connection.installs);
if (!nextState.onAll) {
if (installed) nextState.agentIds.add(agent.id);
else nextState.agentIds.delete(agent.id);
}
return toolsApi.putConnectionInstalls(
connection.id,
installPayload(connection.companyId ?? companyId, nextState),
);
},
onSuccess: async (_snapshot, variables) => {
failedInstallDraftRef.current = null;
lastSavedInstallRef.current = {
...lastSavedInstallRef.current,
[variables.connection.id]: variables.installed,
};
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id) }),
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(variables.connection.id) }),
]);
},
onError: (_error, variables) => {
failedInstallDraftRef.current = {
connectionId: variables.connection.id,
installed: variables.installed,
};
},
});
useEffect(() => {
setInstallDraft({});
lastSavedInstallRef.current = {};
skipNextInstallAutosaveRef.current = true;
failedInstallDraftRef.current = null;
}, [agent.id, companyId]);
useEffect(() => {
const next = Object.fromEntries(
connectionList.map((connection) => [
connection.id,
isAgentInstalled(installStateFrom(connection.installs), agent.id),
]),
);
failedInstallDraftRef.current = null;
setInstallDraft((current) => {
const merged = mergeInstallDraft(next, current, lastSavedInstallRef.current);
lastSavedInstallRef.current = next;
skipNextInstallAutosaveRef.current = !merged.hasPendingChanges;
return merged.draft;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent.id, connectionInstallSignature]);
useEffect(() => {
if (skipNextInstallAutosaveRef.current) {
skipNextInstallAutosaveRef.current = false;
return;
}
if (syncInstall.isPending) return;
const changedConnection = connectionList.find((connection) => {
const saved = lastSavedInstallRef.current[connection.id] ?? false;
const draft = installDraft[connection.id] ?? false;
const failed = failedInstallDraftRef.current;
return saved !== draft && !(failed?.connectionId === connection.id && failed.installed === draft);
});
if (!changedConnection) return;
const timeout = window.setTimeout(() => {
const installed = installDraft[changedConnection.id] ?? false;
const saved = lastSavedInstallRef.current[changedConnection.id] ?? false;
const failed = failedInstallDraftRef.current;
if (saved !== installed && !(failed?.connectionId === changedConnection.id && failed.installed === installed)) {
syncInstall.mutate({ connection: changedConnection, installed });
}
}, 250);
return () => window.clearTimeout(timeout);
}, [connectionList, installDraft, syncInstall.isPending, syncInstall.mutate]);
// The effective endpoint returns the *allowed* slice of the catalog. To show
// "Denied tools (suppressed)" we need the full company catalog, which is only
// exposed per-connection — same aggregation the Applications tab uses.
const catalogQueries = useQueries({
queries: connectionList.map((connection) => ({
queryKey: queryKeys.tools.catalog(connection.id),
queryFn: () => toolsApi.listCatalog(connection.id),
staleTime: 60_000,
})),
});
const connectionNameById = useMemo(() => {
const map = new Map<string, string>();
for (const connection of connectionList) map.set(connection.id, connection.name);
return map;
}, [connectionList]);
const allowedTools = useMemo(
() =>
[...(effective.data?.allowedTools ?? [])].sort((a, b) =>
a.toolName.localeCompare(b.toolName),
),
[effective.data?.allowedTools],
);
const permittedConnectionIds = useMemo(
() => new Set([
...(effective.data?.entries ?? [])
.filter((entry) => entry.effect === "include" && entry.connectionId)
.map((entry) => entry.connectionId!),
...allowedTools.map((tool) => tool.connectionId),
]),
[allowedTools, effective.data?.entries],
);
const installedAppConnections = useMemo(
() =>
connectionList
.filter((connection) => permittedConnectionIds.has(connection.id) || (installDraft[connection.id] ?? false))
.sort((a, b) => a.name.localeCompare(b.name)),
[connectionList, installDraft, permittedConnectionIds],
);
const hasInstallUnsavedChanges = installedAppConnections.some(
(connection) => (installDraft[connection.id] ?? false) !== (lastSavedInstallRef.current[connection.id] ?? false),
);
const catalogStamp = catalogQueries.map((q) => q.dataUpdatedAt).join(",");
const deniedTools = useMemo(() => {
const allowedIds = new Set((effective.data?.allowedTools ?? []).map((tool) => tool.id));
const seen = new Set<string>();
const denied: ToolCatalogEntry[] = [];
for (const result of catalogQueries) {
for (const entry of result.data?.catalog ?? []) {
if (entry.status !== "active") continue;
if (allowedIds.has(entry.id) || seen.has(entry.id)) continue;
seen.add(entry.id);
denied.push(entry);
}
}
return denied.sort((a, b) => a.toolName.localeCompare(b.toolName));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effective.data?.allowedTools, catalogStamp]);
const governingPolicies = useMemo(() => {
const policies = policiesQuery.data?.policies ?? [];
return policies
.map((policy, index) => ({ policy, order: index + 1 }))
.filter(({ policy }) => policy.enabled && policyGovernsAgent(policy, agent.id));
}, [policiesQuery.data?.policies, agent.id]);
const profiles = effective.data?.profiles ?? [];
const catalogLoading = catalogQueries.some((q) => q.isLoading);
if (effective.isLoading) return <ToolsLoadingState label="Resolving effective access…" />;
if (effective.error) {
return <ToolsErrorState error={effective.error} onRetry={() => effective.refetch()} />;
}
const profilesHref = "/apps/advanced/profiles";
return (
<div className="space-y-4">
<EnforcementBanner
tone="info"
title="Effective access"
body={
<>
This is exactly the tool set Paperclip will accept for{" "}
<span className="font-medium">{agent.name}</span>. Profile and policy edits are
reflected within ~5 seconds. The agent's prompt can narrow this list but{" "}
<span className="font-medium">cannot expand it</span> — everything else is blocked by
default.
</>
}
/>
<GitHubIdentitySection
agentName={agent.name}
dedicatedIdentity={dedicatedGitHubIdentity}
personalIdentity={personalGitHubIdentity}
loading={connectionsQuery.isLoading || githubGrantQueries.some((query) => query.isLoading)}
loadError={connectionsQuery.isError || githubGrantQueries.some((query) => query.isError)}
onRetry={() => {
if (connectionsQuery.isError) void connectionsQuery.refetch();
for (const query of githubGrantQueries) {
if (query.isError) void query.refetch();
}
}}
/>
<InstalledAppsSection
agentId={agent.id}
agentName={agent.name}
connections={installedAppConnections}
draft={installDraft}
permittedConnectionIds={permittedConnectionIds}
pendingConnectionId={syncInstall.isPending ? syncInstall.variables?.connection.id ?? null : null}
saving={syncInstall.isPending}
unsaved={hasInstallUnsavedChanges}
error={syncInstall.isError && hasInstallUnsavedChanges}
onChange={(connectionId, installed) => {
failedInstallDraftRef.current = null;
setInstallDraft((current) => ({ ...current, [connectionId]: installed }));
}}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* Allowed tools table */}
<div className="lg:col-span-2">
<div className="rounded-lg border border-border">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
<h3 className="text-sm font-semibold text-foreground">Allowed tools</h3>
<span className="text-xs text-muted-foreground tabular-nums">
{allowedTools.length} {allowedTools.length === 1 ? "tool" : "tools"}
</span>
</div>
{allowedTools.length === 0 ? (
<p className="px-3 py-6 text-sm text-muted-foreground">
No tools are allowed for this agent. Bind a tool profile to grant access.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="px-3 py-2 font-medium">Tool</th>
<th className="px-3 py-2 font-medium">Capability</th>
<th className="px-3 py-2 font-medium">Risk</th>
<th className="px-3 py-2 font-medium">Source</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{allowedTools.map((tool) => (
<tr key={tool.id} className="align-top">
<td className="px-3 py-2">
<div className="font-mono text-xs text-foreground">{tool.toolName}</div>
{tool.title ? (
<div className="text-(length:--text-micro) text-muted-foreground">{tool.title}</div>
) : null}
</td>
<td className="px-3 py-2">
<CapabilityBadges
isReadOnly={tool.isReadOnly}
isWrite={tool.isWrite}
isDestructive={tool.isDestructive}
/>
</td>
<td className="px-3 py-2">
<RiskBadge risk={tool.riskLevel} />
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">
{connectionNameById.get(tool.connectionId) ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Why these tools? side panel */}
<aside className="space-y-4">
<div className="rounded-lg border border-border bg-background/60 p-3">
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
Why these tools?
</h3>
{/* Access profiles */}
<div className="mt-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Access profiles
</div>
<Link
to={`${profilesHref}?check=1`}
className="text-(length:--text-micro) font-medium text-primary hover:underline"
>
Check access
</Link>
</div>
{profiles.length === 0 ? (
<p className="text-xs text-muted-foreground">
No active profile applies to this agent, so it has no allowed tools.
</p>
) : (
profiles.map((profile) => {
return (
<div key={profile.id} className="rounded-md border border-border/70 px-2.5 py-2">
<Link
to={`${profilesHref}/${profile.id}`}
className="text-xs font-medium text-primary hover:underline"
>
{profile.name}
</Link>
{profile.summary.isCompanyDefault ? (
<div className="mt-1">
<span className="rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
Organization default
</span>
</div>
) : null}
</div>
);
})
)}
</div>
{/* Policies mutating the allow list */}
<div className="mt-3 space-y-1.5">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Active policies
</div>
{policiesQuery.isLoading ? (
<p className="text-xs text-muted-foreground">Loading policies…</p>
) : governingPolicies.length === 0 ? (
<p className="text-xs text-muted-foreground">
No enabled policy currently mutates this agent's allow list.
</p>
) : (
governingPolicies.map(({ policy, order }) => (
<div key={policy.id} className="rounded-md border border-border/70 px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<span
className="truncate text-xs font-medium text-foreground"
title={`Policy #${order}: ${policy.name}`}
>
#{order} {policy.name}
</span>
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
{POLICY_EFFECT_LABEL[policy.policyType] ?? policy.policyType}
</span>
</div>
{policy.description ? (
<div className="mt-0.5 truncate text-(length:--text-micro) text-muted-foreground">
{policy.description}
</div>
) : null}
</div>
))
)}
</div>
{/* Unavailable tools */}
<div className="mt-3 space-y-1.5">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Unavailable tools
</div>
{catalogLoading ? (
<p className="text-xs text-muted-foreground">Checking tools…</p>
) : deniedTools.length === 0 ? (
<p className="text-xs text-muted-foreground">
Every known tool this agent could name is allowed.
</p>
) : (
<>
<p className="text-(length:--text-micro) text-muted-foreground">
Tools the agent could name but Paperclip would block:
</p>
<div className="flex flex-wrap gap-1">
{deniedTools.slice(0, DENIED_TOOLS_DISPLAY_LIMIT).map((tool) => (
<span
key={tool.id}
className="inline-flex items-center gap-1 rounded border border-border/70 bg-muted/40 px-1.5 py-0.5 font-mono text-(length:--text-micro) text-muted-foreground"
title={connectionNameById.get(tool.connectionId) ?? undefined}
>
{tool.toolName}
</span>
))}
</div>
{deniedTools.length > DENIED_TOOLS_DISPLAY_LIMIT ? (
<p className="text-(length:--text-micro) text-muted-foreground">
+{deniedTools.length - DENIED_TOOLS_DISPLAY_LIMIT} more
</p>
) : null}
</>
)}
</div>
</div>
</aside>
</div>
</div>
);
}