Files
PaperClipAI/ui/src/components/AgentSecretAccessEditor.tsx
T
0b496c9c03 feat(secrets): add run-bound agent secret access (#9921)
## Thinking Path

> - Paperclip is the open source control plane people use to manage
AI-agent companies.
> - Agents already receive selected company secrets through `env.*`
bindings at run launch, but environment injection is ambient,
long-lived, and not suitable for every secret consumer.
> - The existing binding and secret-access-event models already provide
company-scoped authorization and per-resolution audit seams.
> - Agents need an explicit way to discover only the secrets granted to
them and fetch a value on demand without exposing the wider company
catalog.
> - That capability must remain run-bound, preserve low-trust token
carve-outs, and make every value read visible in both security and
operator audit trails.
> - This pull request adds an `access.*` delivery namespace, two
run-bound agent routes, dual audit logging, documentation, and an
operator grants editor.
> - The benefit is least-privilege, revocable, auditable secret access
while preserving existing env injection behavior.

## Linked Issues or Issue Description

No pre-existing public issue. Related work:

- Refs #9797 — existing in-sheet agent access UI that this PR extends to
distinguish env and API delivery.
- Refs #9918 — complementary searchable-agent picker improvement for the
same secrets sheet.
- Refs #9530 — related company-wide metadata catalog proposal; this PR
intentionally exposes only the authenticated run's granted aliases and
values.

**Problem / motivation:** Agents can currently consume secrets only
through process environment injection. This keeps values resident for
the run, does not support on-demand consumers, and cannot provide a
discrete operator-visible activity event for each agent-initiated read.

**Proposed solution:** Treat `company_secret_bindings` as the source of
truth for agent secret grants. Keep `env.KEY` as env delivery and add
`access.ALIAS` for API-only delivery; an env binding also implies read
access because the value is already present in the agent process. Add
run-bound list/fetch endpoints that derive scope from the authenticated
heartbeat run and never accept caller-selected overlays.

**Alternatives considered:** A company-wide agent-readable catalog was
rejected for this value path because it increases reconnaissance and
does not prove a per-secret grant. Reusing the ephemeral
environment-probe resolver was rejected because it lacks binding
enforcement. Approval-gated reads and user-scoped secrets remain
deferred beyond v1.

**Roadmap alignment:** This extends the completed **Secrets Manager with
per-agent access** roadmap capability from launch-time env injection to
explicit run-bound API delivery without duplicating a separate planned
initiative.

## What Changed

- Added `access.*` agent binding validation and a dedicated run-bound
resolver that combines `secrets:read` authorization with binding-context
enforcement.
- Added `GET /api/agents/me/secrets` for minimal granted metadata and
`POST /api/agents/me/secrets/:key/value` for on-demand value fetches
with `Cache-Control: no-store`.
- Preserved the existing denials for low-trust review agents,
task-bridge credentials, and skill-test tokens; standard long-lived
agent API keys cannot call the run-bound routes.
- Added dual audit behavior: value attempts write `secret_access_events`
and `activity_log` (`secret.value.read`), while metadata listing writes
the lighter `secret.access.listed` activity event.
- Kept env compatibility: `env.*` remains injected at launch and also
implies API read for the same bound agent; `access.*` never becomes an
environment variable.
- Added the agent-settings **Secret access** editor plus
delivery-mode/alias surfacing on the Secrets page, with focused UI tests
and tokenized layout styles.
- Updated OpenAPI, shared types, agent-facing skill documentation, and
API reference documentation.

### UI Screenshots

P3 produced and reviewed three screenshots using mock data; images are
intentionally not committed to the repository:

- `secret-access-editor.png` — agent settings grant editor.
- `secret-access-light.png` — Secrets-page delivery surfacing in light
mode.
- `secret-access-dark.png` — Secrets-page delivery surfacing in dark
mode.

The source attachments are retained with the implementation task and
linked in the internal handoff; the public page publisher was
unavailable in the PR-prep runtime.

## Verification

- `pnpm exec vitest run
server/src/__tests__/agent-secrets-routes.test.ts
server/src/__tests__/secrets-service.test.ts
server/src/__tests__/secrets-routes.test.ts
ui/src/lib/secret-delivery.test.ts
ui/src/components/AgentSecretAccessEditor.test.tsx` — 5 files, 122 tests
passed.
- Security follow-up: `pnpm exec vitest run
server/src/__tests__/agent-secrets-routes.test.ts
server/src/__tests__/secrets-service.test.ts` — 2 files, 73 tests passed
after active-run and version-consistency fixes.
- Final-head CI: all feature, typecheck, build, e2e, security, and
review gates pass; `General tests (server (1/3))` remains red after one
rerun because unrelated `heartbeat-retry-scheduling.test.ts` cleanup
deletes `heartbeat_runs` before referenced `activity_log` rows.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm --filter @paperclipai/shared typecheck` — passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm check:token-gates` — feature-local arbitrary-value violations
fixed; command still reports five unchanged `#9627` literals outside
this PR.
- End-to-end QA passed all eight acceptance criteria: grant/list, fetch,
dual audit, env-implies-read, denial matrix, revocation, UI rendering,
and env-injection regression. Evidence:
https://github.com/paperclipai/paperclip/pull/9921#issuecomment-5027455492
- Security review returned PASS-with-required-changes; the
implementation uses the required dedicated binding-enforcing resolver,
run-bound JWT restriction, run-derived overlays, minimal metadata, and a
resolver redaction-registration hook. Evidence:
https://github.com/paperclipai/paperclip/pull/9921#issuecomment-5027455382

## Risks

- A compromised agent can exfiltrate any secret explicitly granted to
it; explicit company-scoped/run-scoped grants, revocation, and audit
reduce but cannot remove that inherent capability risk.
- The resolver invokes a redaction-registration hook before returning
values, but the current route has no persistent cross-request per-run
redaction registry. Paperclip-owned later comments/events therefore
cannot yet guarantee automatic scrubbing of a deliberately copied
fetched value; QA classified this as non-blocking residual hardening.
- Audit-event insertion currently fails open if the security-event
insert itself fails; the operator activity event provides partial
redundancy, but a future hardening change should define fail-closed
behavior for value delivery.
- This PR overlaps `ui/src/pages/Secrets.tsx` with #9918 and may require
a straightforward rebase after that PR moves.

> 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.3-codex`, with reasoning, repository tool use,
terminal execution, Paperclip API access, and GitHub CLI capabilities.
Context-window size is not exposed by the runtime.
- Anthropic Claude Opus 4.8 with 1M context and tool use assisted with
the UI implementation commit.

## 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
- [x] 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>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:04:39 -05:00

349 lines
14 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { KeyRound, Plus, ServerCog, Trash2, Variable } from "lucide-react";
import type { CompanySecret, EnvSecretRefBinding, SecretVersionSelector } from "@paperclipai/shared";
import { cn } from "../lib/utils";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { SecretBindingPicker, type SecretBindingValue } from "./SecretBindingPicker";
import {
AGENT_ACCESS_CONFIG_PATH_PREFIX,
ENV_CONFIG_PATH_PREFIX,
SECRET_ALIAS_RE,
deliveryModeDescription,
} from "../lib/secret-delivery";
import { envKeyFromSecretName } from "./environment-variables-editor/model";
/* -------------------------------------------------------------------------- */
/* Pure model (exported for tests) */
/* -------------------------------------------------------------------------- */
export interface AgentSecretRefEntry {
/** env KEY (env delivery) or access ALIAS (API-access delivery). */
name: string;
secretId: string;
version: SecretVersionSelector;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function readSecretRef(raw: unknown): { secretId: string; version: SecretVersionSelector } | null {
const binding = asRecord(raw);
if (!binding || binding.type !== "secret_ref") return null;
const secretId = typeof binding.secretId === "string" ? binding.secretId : "";
if (!secretId) return null;
const version: SecretVersionSelector = typeof binding.version === "number" ? binding.version : "latest";
return { secretId, version };
}
/** Secret-ref bindings delivered as environment variables (`config.env.<KEY>`). */
export function parseEnvSecretRefs(config: Record<string, unknown> | null | undefined): AgentSecretRefEntry[] {
const env = asRecord(config?.env);
if (!env) return [];
const entries: AgentSecretRefEntry[] = [];
for (const [key, raw] of Object.entries(env)) {
const ref = readSecretRef(raw);
if (ref) entries.push({ name: key, ...ref });
}
return entries;
}
/** Secret-ref bindings delivered via the agent API (top-level `access.<ALIAS>`). */
export function parseAccessGrants(config: Record<string, unknown> | null | undefined): AgentSecretRefEntry[] {
if (!config) return [];
const entries: AgentSecretRefEntry[] = [];
for (const [key, raw] of Object.entries(config)) {
if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) continue;
const ref = readSecretRef(raw);
if (ref) entries.push({ name: key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length), ...ref });
}
return entries;
}
export interface AgentSecretBindingSummary {
secretId: string;
envKeys: string[];
apiAliases: string[];
}
/** Group env + API bindings by secret so the overview can show delivery mode per secret. */
export function summarizeAgentBindings(
envBindings: readonly AgentSecretRefEntry[],
apiBindings: readonly AgentSecretRefEntry[],
): AgentSecretBindingSummary[] {
const bySecret = new Map<string, AgentSecretBindingSummary>();
const ensure = (secretId: string) => {
let summary = bySecret.get(secretId);
if (!summary) {
summary = { secretId, envKeys: [], apiAliases: [] };
bySecret.set(secretId, summary);
}
return summary;
};
for (const entry of envBindings) ensure(entry.secretId).envKeys.push(entry.name);
for (const entry of apiBindings) ensure(entry.secretId).apiAliases.push(entry.name);
return [...bySecret.values()];
}
let accessRowCounter = 0;
function nextAccessRowId(): string {
accessRowCounter += 1;
return `access-row-${accessRowCounter}`;
}
interface AccessRow {
id: string;
alias: string;
secretId: string;
version: SecretVersionSelector;
}
function entriesToRows(entries: readonly AgentSecretRefEntry[]): AccessRow[] {
return entries.map((entry) => ({
id: nextAccessRowId(),
alias: entry.name,
secretId: entry.secretId,
version: entry.version,
}));
}
/** Complete, valid API-access grants keyed by alias. Incomplete/invalid/duplicate rows are dropped. */
export function rowsToAccessMap(rows: readonly AccessRow[]): Record<string, EnvSecretRefBinding> {
const map: Record<string, EnvSecretRefBinding> = {};
for (const row of rows) {
const alias = row.alias.trim();
if (!alias || !SECRET_ALIAS_RE.test(alias) || !row.secretId) continue;
map[alias] = { type: "secret_ref", secretId: row.secretId, version: row.version };
}
return map;
}
/** Stable key for change-detection between the controlled value and the local draft. */
export function normalizeAccessMapKey(map: Record<string, EnvSecretRefBinding>): string {
return JSON.stringify(
Object.keys(map)
.sort()
.map((alias) => {
const binding = map[alias]!;
return [alias, binding.secretId, binding.version ?? "latest"];
}),
);
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export interface AgentSecretAccessEditorProps {
/** Effective adapter config (env + top-level `access.*`), reflecting unsaved edits. */
config: Record<string, unknown>;
secrets: readonly CompanySecret[];
/**
* Emit the complete desired set of API-access grants (alias → secret_ref). The
* parent diffs this against the current `access.*` keys to add/remove them.
*/
onChange: (next: Record<string, EnvSecretRefBinding>) => void;
disabled?: boolean;
}
function DeliveryBadge({ mode }: { mode: "env" | "api" }) {
if (mode === "env") {
return (
<Badge
variant="outline"
className="h-5 gap-1 px-1.5 text-(length:--text-nano) font-normal border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"
>
<Variable className="size-3" /> Env var
</Badge>
);
}
return (
<Badge
variant="outline"
className="h-5 gap-1 px-1.5 text-(length:--text-nano) font-normal border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300"
>
<ServerCog className="size-3" /> API access
</Badge>
);
}
export function AgentSecretAccessEditor({ config, secrets, onChange, disabled }: AgentSecretAccessEditorProps) {
const envBindings = useMemo(() => parseEnvSecretRefs(config), [config]);
const apiBindings = useMemo(() => parseAccessGrants(config), [config]);
const summaries = useMemo(() => summarizeAgentBindings(envBindings, apiBindings), [envBindings, apiBindings]);
const incomingMap = useMemo(() => rowsToAccessMap(entriesToRows(apiBindings)), [apiBindings]);
const incomingKey = useMemo(() => normalizeAccessMapKey(incomingMap), [incomingMap]);
const [rows, setRows] = useState<AccessRow[]>(() => entriesToRows(apiBindings));
const lastEmittedKeyRef = useRef(incomingKey);
const lastIncomingKeyRef = useRef(incomingKey);
// Controlled sync (mirrors the env editor): adopt genuine external changes
// (Cancel / agent refetch) but never clobber a local draft that produced the
// incoming value (the echo of our own emit) or an in-progress incomplete row.
useEffect(() => {
if (incomingKey === lastIncomingKeyRef.current) return;
lastIncomingKeyRef.current = incomingKey;
if (incomingKey === lastEmittedKeyRef.current) return;
setRows(entriesToRows(apiBindings));
}, [incomingKey, apiBindings]);
const secretName = (secretId: string): string =>
secrets.find((secret) => secret.id === secretId)?.name ?? `${secretId.slice(0, 8)}…`;
function emit(nextRows: AccessRow[]) {
setRows(nextRows);
const map = rowsToAccessMap(nextRows);
lastEmittedKeyRef.current = normalizeAccessMapKey(map);
onChange(map);
}
function patchRow(id: string, patch: Partial<AccessRow>) {
emit(rows.map((row) => (row.id === id ? { ...row, ...patch } : row)));
}
function removeRow(id: string) {
emit(rows.filter((row) => row.id !== id));
}
function addRow() {
setRows((prev) => [...prev, { id: nextAccessRowId(), alias: "", secretId: "", version: "latest" }]);
}
const aliasCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const row of rows) {
const alias = row.alias.trim();
if (alias) counts.set(alias, (counts.get(alias) ?? 0) + 1);
}
return counts;
}, [rows]);
const hasBindings = summaries.length > 0;
return (
<div className="space-y-3">
{/* Overview: every secret bound to this agent + how it is delivered. */}
{hasBindings ? (
<div className="space-y-1.5">
{summaries.map((summary) => (
<div
key={summary.secretId}
className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs"
>
<KeyRound className="size-3.5 shrink-0 text-muted-foreground" />
<span className="font-medium">{secretName(summary.secretId)}</span>
{summary.envKeys.length > 0 ? <DeliveryBadge mode="env" /> : null}
{summary.apiAliases.length > 0 ? <DeliveryBadge mode="api" /> : null}
<span className="min-w-0 truncate font-mono text-(length:--text-micro) text-muted-foreground">
{[
...summary.envKeys.map((key) => `${ENV_CONFIG_PATH_PREFIX}${key}`),
...summary.apiAliases.map((alias) => `${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`),
].join(" · ")}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No secrets are bound to this agent yet.</p>
)}
{/* Editable API-access grants (access.<ALIAS>). */}
<div className="space-y-2">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
API access (no env var)
</div>
{rows.length > 0 ? (
<div className="space-y-2">
{rows.map((row) => {
const trimmedAlias = row.alias.trim();
const aliasInvalid = Boolean(trimmedAlias) && !SECRET_ALIAS_RE.test(trimmedAlias);
const aliasDuplicate = Boolean(trimmedAlias) && (aliasCounts.get(trimmedAlias) ?? 0) > 1;
const bindingValue: SecretBindingValue | null = row.secretId
? { secretId: row.secretId, version: row.version }
: null;
return (
<div key={row.id} className="space-y-1">
<div className="grid grid-cols-(--gtc-65) items-start gap-1.5">
<div>
<Input
value={row.alias}
onChange={(event) => patchRow(row.id, { alias: event.target.value })}
onBlur={(event) => {
const next = event.target.value.trim();
if (next && !SECRET_ALIAS_RE.test(next)) {
const suggested = envKeyFromSecretName(next);
if (suggested && suggested !== next) patchRow(row.id, { alias: suggested });
}
}}
placeholder="ALIAS"
aria-label="Access alias"
disabled={disabled}
className={cn(
"h-9 font-mono text-sm",
(aliasInvalid || aliasDuplicate) && "border-destructive text-destructive",
)}
/>
</div>
<div>
<SecretBindingPicker
value={bindingValue}
onChange={(next) =>
patchRow(row.id, {
secretId: next?.secretId ?? "",
version: next?.version ?? "latest",
alias:
!row.alias.trim() && next?.secretId
? envKeyFromSecretName(secretName(next.secretId))
: row.alias,
})
}
label=""
placeholder="Select secret"
disabled={disabled}
/>
</div>
<button
type="button"
onClick={() => removeRow(row.id)}
disabled={disabled}
aria-label="Remove API access"
className="mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
<Trash2 className="size-3.5" />
</button>
</div>
{aliasInvalid ? (
<p className="pl-0.5 text-(length:--text-micro) text-destructive">
Invalid alias — use letters, digits and _
</p>
) : aliasDuplicate ? (
<p className="pl-0.5 text-(length:--text-micro) text-destructive">Duplicate alias</p>
) : null}
</div>
);
})}
</div>
) : null}
<button
type="button"
onClick={addRow}
disabled={disabled}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
<Plus className="size-3.5" />
Add API access
</button>
</div>
<p className="text-(length:--text-micro) text-muted-foreground/70">
{deliveryModeDescription("api")} The agent reads them by alias through <code>GET /agents/me/secrets</code>.
</p>
</div>
);
}