mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## 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>
349 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|