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>
This commit is contained in:
Dotta 2026-07-22 08:04:39 -05:00 committed by GitHub
parent 5ed0b74b34
commit 0b496c9c03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1870 additions and 53 deletions

View File

@ -3,7 +3,62 @@ title: Secrets
summary: Secrets CRUD
---
Manage encrypted secrets that agents reference in their environment configuration.
Manage encrypted secrets that agents receive through environment bindings or fetch on demand.
## Agent List and Fetch
These routes require the current run-bound agent JWT. They are not available to
long-lived agent keys, low-trust review agents, task-bridge keys, or skill-test
tokens.
List the secrets accessible to the current run without materializing values:
```
GET /api/agents/me/secrets
```
```json
{
"secrets": [
{
"key": "github_token",
"name": "GitHub token",
"description": null,
"delivery": "env",
"projectionClass": "unclassified",
"latestVersion": 2,
"versionSelector": "latest",
"resolvedVersion": 2
}
]
}
```
`delivery` is `env`, `api`, or `both`. The list never returns values, secret
IDs, binding IDs, or config paths. An `env.*` binding implies read access through
this API; an `access.*` binding grants API access without environment injection.
Fetch a value only when it is needed. The request has no body and the response
uses `Cache-Control: no-store`:
```
POST /api/agents/me/secrets/github_token/value
```
```json
{
"key": "github_token",
"value": "decrypted-secret-value",
"version": 2
}
```
Prefer env injection when the adapter or its child processes need the value on
every run. Prefer on-demand fetch for values used only on some runs, large or
structured values, or skills and tools that do not inherit adapter env. Every
successful or failed value fetch is audited in both `secret_access_events` and
`activity_log`; agents must not log or paste fetched values into issues,
comments, or documents.
## List Secrets

View File

@ -290,7 +290,7 @@ export interface SecretAccessEvent {
credentialSubjectId: string | null;
actorType: "agent" | "user" | "system" | "plugin";
actorId: string | null;
consumerType: SecretBindingTargetType | "plugin_worker";
consumerType: SecretBindingTargetType | "agent_api" | "plugin_worker";
consumerId: string;
configPath: string | null;
issueId: string | null;

View File

@ -0,0 +1,245 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
companies,
companySecretBindings,
companySecretProviderConfigs,
companySecretVersions,
companySecrets,
createDb,
heartbeatRuns,
secretAccessEvents,
} from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET, type AgentApiKeyScope } from "@paperclipai/shared";
import { errorHandler } from "../middleware/error-handler.js";
import { secretRoutes } from "../routes/secrets.js";
import { secretService } from "../services/secrets.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("agent secret routes", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-agent-secret-routes-${randomUUID()}`);
beforeAll(async () => {
mkdirSync(secretsTmpDir, { recursive: true });
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
const started = await startEmbeddedPostgresTestDatabase("agent-secret-routes");
stopDb = started.cleanup;
db = createDb(started.connectionString);
});
afterEach(async () => {
await db.delete(activityLog);
await db.delete(secretAccessEvents);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(companySecretProviderConfigs);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await stopDb?.();
if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
rmSync(secretsTmpDir, { recursive: true, force: true });
});
async function seedAgentRun(permissions: Record<string, unknown> = {}) {
const companyId = randomUUID();
const agentId = randomUUID();
const heartbeatRunId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Agent secret routes",
issuePrefix: `S${companyId.slice(0, 7)}`.toUpperCase(),
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Secret reader",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {},
permissions,
status: "idle",
});
await db.insert(heartbeatRuns).values({
id: heartbeatRunId,
companyId,
agentId,
status: "running",
contextSnapshot: {},
});
return { companyId, agentId, heartbeatRunId };
}
function createApp(
fixture: Awaited<ReturnType<typeof seedAgentRun>>,
keyScope: AgentApiKeyScope = { kind: "standard" },
source: "agent_jwt" | "agent_key" = "agent_jwt",
) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = {
type: "agent",
agentId: fixture.agentId,
companyId: fixture.companyId,
runId: fixture.heartbeatRunId,
keyScope,
keyId: source === "agent_key" ? randomUUID() : undefined,
source,
};
next();
});
app.use("/api", secretRoutes(db));
app.use(errorHandler);
return app;
}
it("lists metadata only, reads env and access grants, and audits success and failure", async () => {
const fixture = await seedAgentRun();
const svc = secretService(db);
const envSecret = await svc.create(fixture.companyId, {
key: "ENV_ONLY_KEY",
name: "Env only",
description: "Injected and API-readable",
provider: "local_encrypted",
value: "env-secret-value",
});
const apiSecret = await svc.create(fixture.companyId, {
key: "API_ONLY_KEY",
name: "API only",
provider: "local_encrypted",
value: "api-secret-value",
});
const unboundSecret = await svc.create(fixture.companyId, {
key: "UNBOUND_KEY",
name: "Unbound",
provider: "local_encrypted",
value: "unbound-secret-value",
});
const projectSecret = await svc.create(fixture.companyId, {
key: "PROJECT_KEY",
name: "Project layer",
provider: "local_encrypted",
value: "project-secret-value",
});
await svc.createBinding({
companyId: fixture.companyId,
secretId: envSecret.id,
targetType: "agent",
targetId: fixture.agentId,
configPath: "env.ENV_ONLY_KEY",
});
await svc.createBinding({
companyId: fixture.companyId,
secretId: apiSecret.id,
targetType: "agent",
targetId: fixture.agentId,
configPath: "access.API_ONLY_KEY",
projectionClass: "class_2_runtime_only",
});
const projectBinding = await svc.createBinding({
companyId: fixture.companyId,
secretId: projectSecret.id,
targetType: "project",
targetId: randomUUID(),
configPath: "env.PROJECT_KEY",
});
await db.update(heartbeatRuns).set({
contextSnapshot: {
paperclipSecrets: {
manifest: [{
bindingId: projectBinding.id,
secretId: projectSecret.id,
configPath: projectBinding.configPath,
}],
},
},
}).where(eq(heartbeatRuns.id, fixture.heartbeatRunId));
const list = await request(createApp(fixture)).get("/api/agents/me/secrets");
expect(list.status).toBe(200);
expect(list.body.secrets).toEqual([
expect.objectContaining({ key: "api_only_key", delivery: "api", projectionClass: "class_2_runtime_only" }),
expect.objectContaining({ key: "env_only_key", delivery: "env" }),
expect.objectContaining({ key: "project_key", delivery: "env" }),
]);
expect(JSON.stringify(list.body)).not.toContain("secret-value");
expect(await db.select().from(secretAccessEvents)).toEqual([]);
expect(await db.select().from(activityLog)).toEqual([
expect.objectContaining({ action: "secret.access.listed", runId: fixture.heartbeatRunId }),
]);
const fetched = await request(createApp(fixture)).post("/api/agents/me/secrets/env_only_key/value");
expect(fetched.status).toBe(200);
expect(fetched.headers["cache-control"]).toBe("no-store");
expect(fetched.body).toEqual({ key: "env_only_key", value: "env-secret-value", version: 1 });
expect(await db.select().from(secretAccessEvents)).toEqual([
expect.objectContaining({ secretId: envSecret.id, outcome: "success", consumerType: "agent_api" }),
]);
expect(await db.select().from(activityLog)).toEqual(expect.arrayContaining([
expect.objectContaining({ action: "secret.value.read", entityId: envSecret.id }),
]));
const projectFetched = await request(createApp(fixture)).post("/api/agents/me/secrets/project_key/value");
expect(projectFetched.status).toBe(200);
expect(projectFetched.body).toEqual({ key: "project_key", value: "project-secret-value", version: 1 });
expect(await db.select().from(secretAccessEvents)).toEqual(expect.arrayContaining([
expect.objectContaining({ secretId: projectSecret.id, outcome: "success", consumerType: "agent_api" }),
]));
const denied = await request(createApp(fixture)).post("/api/agents/me/secrets/unbound_key/value");
expect(denied.status).toBe(403);
expect(await db.select().from(secretAccessEvents)).toEqual(expect.arrayContaining([
expect.objectContaining({ secretId: unboundSecret.id, outcome: "failure", errorCode: "binding_missing" }),
]));
expect(await db.select().from(activityLog)).toEqual(expect.arrayContaining([
expect.objectContaining({ action: "secret.value.read", entityId: unboundSecret.id }),
]));
});
it("denies low-trust, task-bridge, and skill-test callers on both routes", async () => {
const lowTrust = await seedAgentRun({
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, projectIds: [randomUUID()] } },
});
const standard = await seedAgentRun();
const cases = [
{ name: "low trust", fixture: lowTrust, scope: { kind: "standard" } as const, source: "agent_jwt" as const },
{ name: "task bridge", fixture: standard, scope: { kind: "task_bridge", parentIssueId: randomUUID() } as const, source: "agent_key" as const },
{ name: "skill test", fixture: standard, scope: { kind: "skill_test", issueId: randomUUID() } as const, source: "agent_jwt" as const },
];
for (const testCase of cases) {
expect(
(await request(createApp(testCase.fixture, testCase.scope, testCase.source)).get("/api/agents/me/secrets")).status,
`${testCase.name} list`,
).toBe(403);
expect(
(await request(createApp(testCase.fixture, testCase.scope, testCase.source)).post("/api/agents/me/secrets/ANY/value")).status,
`${testCase.name} fetch`,
).toBe(403);
}
});
});

View File

@ -100,13 +100,33 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
});
async function cleanupRetryFixture() {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await cleanupRetryFixtureOnce();
return;
} catch (error) {
if (attempt === 4) throw error;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
}
async function cleanupHeartbeatRunDependents() {
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
await new Promise((resolve) => setTimeout(resolve, 25));
await db.delete(heartbeatRunEvents);
await db.delete(activityLog);
}
async function cleanupRetryFixtureOnce() {
await db.delete(activityLog);
await db.delete(environmentLeases);
await db.delete(issueRelations);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projects);
await db.delete(heartbeatRunEvents);
await cleanupHeartbeatRunDependents();
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);

View File

@ -18,6 +18,7 @@ const mockSecretService = vi.hoisted(() => ({
setDefaultProviderConfig: vi.fn(),
checkProviderConfigHealth: vi.fn(),
getById: vi.fn(),
getByKey: vi.fn(),
create: vi.fn(),
rotate: vi.fn(),
update: vi.fn(),
@ -36,6 +37,8 @@ const mockSecretService = vi.hoisted(() => ({
importRemoteSecrets: vi.fn(),
listBindingReferences: vi.fn(),
listAccessEvents: vi.fn(),
listAgentSecretAccess: vi.fn(),
resolveSecretValueForAgentAccess: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());

View File

@ -5,6 +5,7 @@ import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import {
activityLog,
agents,
companies,
companyMemberships,
@ -13,10 +14,12 @@ import {
companySecretVersions,
companySecrets,
createDb,
heartbeatRuns,
secretAccessEvents,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
@ -48,6 +51,7 @@ describeEmbeddedPostgres("secretService", () => {
afterEach(async () => {
vi.restoreAllMocks();
await db.delete(activityLog);
await db.delete(secretAccessEvents);
await db.delete(userSecretDeclarations);
await db.delete(companySecretBindings);
@ -56,6 +60,7 @@ describeEmbeddedPostgres("secretService", () => {
await db.delete(userSecretDefinitions);
await db.delete(companySecretProviderConfigs);
await db.delete(companyMemberships);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
@ -99,6 +104,33 @@ describeEmbeddedPostgres("secretService", () => {
});
}
async function seedAgentRun(companyId: string, permissions: Record<string, unknown> = {}) {
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "Secret reader",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {},
permissions,
status: "idle",
createdAt: new Date(),
updatedAt: new Date(),
});
const heartbeatRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: heartbeatRunId,
companyId,
agentId,
status: "running",
startedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
return { agentId, heartbeatRunId };
}
it("rejects cross-company secret references during env normalization", async () => {
const companyA = await seedCompany("A");
const companyB = await seedCompany("B");
@ -149,6 +181,211 @@ describeEmbeddedPostgres("secretService", () => {
).rejects.toThrow(/already exists/i);
});
it("validates the access namespace as agent-only with env-style aliases", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `access-validation-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await expect(svc.createBinding({
companyId,
secretId: secret.id,
targetType: "project",
targetId: randomUUID(),
configPath: "access.API_KEY",
})).rejects.toThrow(/must target an agent/i);
await expect(svc.createBinding({
companyId,
secretId: secret.id,
targetType: "agent",
targetId: randomUUID(),
configPath: "access.invalid-alias",
})).rejects.toThrow(/invalid agent secret access alias/i);
});
it("resolves env and access bindings through the run-bound agent resolver with dual audit", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const { agentId, heartbeatRunId } = await seedAgentRun(companyId);
const secret = await svc.create(companyId, {
name: `agent-read-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await svc.createBinding({
companyId,
secretId: secret.id,
targetType: "agent",
targetId: agentId,
configPath: "access.API_KEY",
});
await svc.createBinding({
companyId,
secretId: secret.id,
targetType: "agent",
targetId: agentId,
configPath: "env.API_KEY",
});
const redactedValues: string[] = [];
for (const configPath of ["access.API_KEY", "env.API_KEY"]) {
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath,
actorSource: "agent_jwt",
heartbeatRunId,
registerForRedaction: (value) => redactedValues.push(value),
})).resolves.toEqual({ value: "runtime-secret", version: 1 });
}
expect(redactedValues).toEqual(["runtime-secret", "runtime-secret"]);
const events = await svc.listAccessEvents(companyId, secret.id);
expect(events).toHaveLength(2);
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({
consumerType: "agent_api",
consumerId: agentId,
configPath: "access.API_KEY",
actorType: "agent",
actorId: agentId,
heartbeatRunId,
outcome: "success",
}),
expect.objectContaining({
consumerType: "agent_api",
consumerId: agentId,
configPath: "env.API_KEY",
outcome: "success",
}),
]));
const activities = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, secret.id));
expect(activities).toHaveLength(2);
expect(activities.every((entry) => entry.action === "secret.value.read")).toBe(true);
expect(activities.every((entry) => entry.runId === heartbeatRunId)).toBe(true);
expect(JSON.stringify([...events, ...activities])).not.toContain("runtime-secret");
});
it("rejects long-lived, mismatched-run, and unbound agent secret reads", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const { agentId, heartbeatRunId } = await seedAgentRun(companyId);
const secret = await svc.create(companyId, {
name: `agent-read-denied-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await svc.createBinding({
companyId,
secretId: secret.id,
targetType: "agent",
targetId: agentId,
configPath: "access.GRANTED",
});
const registerForRedaction = vi.fn();
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.API_KEY",
actorSource: "agent_key",
heartbeatRunId,
registerForRedaction,
})).rejects.toThrow(/run-bound agent token/i);
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.GRANTED",
actorSource: "agent_jwt",
keyScope: { kind: "skill_test", issueId: randomUUID() },
heartbeatRunId,
registerForRedaction,
})).rejects.toThrow(/skill-test.*secret/i);
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.API_KEY",
actorSource: "agent_jwt",
heartbeatRunId: randomUUID(),
registerForRedaction,
})).rejects.toThrow(/verified heartbeat run/i);
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.API_KEY",
actorSource: "agent_jwt",
heartbeatRunId,
registerForRedaction,
})).rejects.toThrow(/not granted/i);
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, heartbeatRunId));
await expect(svc.listAgentSecretAccess(companyId, {
agentId,
actorSource: "agent_jwt",
heartbeatRunId,
})).rejects.toThrow(/verified heartbeat run/i);
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.GRANTED",
actorSource: "agent_jwt",
heartbeatRunId,
registerForRedaction,
})).rejects.toThrow(/verified heartbeat run/i);
expect(registerForRedaction).not.toHaveBeenCalled();
const events = await svc.listAccessEvents(companyId, secret.id);
expect(events).toEqual([
expect.objectContaining({
consumerType: "agent_api",
consumerId: agentId,
configPath: "access.API_KEY",
outcome: "failure",
errorCode: "binding_missing",
}),
]);
});
it("preserves low-trust authorization denial for agent secret reads", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const { agentId, heartbeatRunId } = await seedAgentRun(companyId, {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
projectIds: [randomUUID()],
},
},
});
const secret = await svc.create(companyId, {
name: `low-trust-agent-read-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await svc.createBinding({
companyId,
secretId: secret.id,
targetType: "agent",
targetId: agentId,
configPath: "access.API_KEY",
});
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.API_KEY",
actorSource: "agent_jwt",
heartbeatRunId,
registerForRedaction: vi.fn(),
})).rejects.toThrow(/low[_-]trust.*secrets:read/i);
expect(await svc.listAccessEvents(companyId, secret.id)).toEqual([]);
});
it("syncs top-level secret refs idempotently", async () => {
const companyId = await seedCompany();
const svc = secretService(db);

View File

@ -1503,6 +1503,47 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized },
});
const AgentSecretListResponseSchema = z.object({
secrets: z.array(z.object({
key: z.string(),
name: z.string(),
description: z.string().nullable(),
delivery: z.enum(["env", "api", "both"]),
projectionClass: z.string(),
latestVersion: z.number().int().nonnegative(),
versionSelector: z.union([z.literal("latest"), z.number().int().positive()]),
resolvedVersion: z.number().int().positive(),
})),
});
registry.registerPath({
method: "get",
path: "/api/agents/me/secrets",
tags: ["secrets"],
summary: "List secrets accessible to the current agent run",
responses: {
200: { description: "Accessible secret metadata", content: { "application/json": { schema: AgentSecretListResponseSchema } } },
401: r.unauthorized,
403: r.forbidden,
},
});
registry.registerPath({
method: "post",
path: "/api/agents/me/secrets/{key}/value",
tags: ["secrets"],
summary: "Fetch one secret value for the current agent run",
request: { params: z.object({ key: z.string() }) },
responses: {
200: {
description: "Decrypted secret value",
content: { "application/json": { schema: z.object({ key: z.string(), value: z.string(), version: z.number().int().positive() }) } },
},
401: r.unauthorized,
403: r.forbidden,
},
});
registry.registerPath({
method: "post",
path: "/api/agents/me/connections/{connectionId}/token",

View File

@ -60,6 +60,66 @@ export function secretRoutes(db: Db) {
const svc = secretService(db);
const defaultProvider = getConfiguredSecretProvider();
function agentSecretContext(req: Parameters<typeof assertBoard>[0]) {
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId || !req.actor.runId) {
throw forbidden("Run-bound agent authentication required");
}
return {
companyId: req.actor.companyId,
agentId: req.actor.agentId,
actorSource: req.actor.source === "agent_jwt" ? "agent_jwt" as const : "agent_key" as const,
keyId: req.actor.keyId ?? null,
keyScope: req.actor.keyScope ?? null,
heartbeatRunId: req.actor.runId,
responsibleUserId: req.actor.onBehalfOfUserId ?? null,
};
}
router.get("/agents/me/secrets", async (req, res) => {
const context = agentSecretContext(req);
const secrets = await svc.listAgentSecretAccess(context.companyId, context);
await logActivity(db, {
companyId: context.companyId,
actorType: "agent",
actorId: context.agentId,
action: "secret.access.listed",
entityType: "agent",
entityId: context.agentId,
agentId: context.agentId,
runId: context.heartbeatRunId,
details: { count: secrets.length },
});
res.json({
secrets: secrets.map(({ secretId: _secretId, bindingId: _bindingId, configPath: _configPath, ...secret }) => secret),
});
});
router.post("/agents/me/secrets/:key/value", async (req, res) => {
const context = agentSecretContext(req);
const available = await svc.listAgentSecretAccess(context.companyId, context);
const secret = available.find((entry) => entry.key === req.params.key);
const unresolvedSecret = secret ? null : await svc.getByKey(context.companyId, req.params.key);
if (!secret && !unresolvedSecret) throw forbidden("Secret access is not granted for this agent");
const resolution = await svc.resolveSecretValueForAgentAccess(
context.companyId,
secret?.secretId ?? unresolvedSecret!.id,
secret?.versionSelector ?? "latest",
{
...context,
configPath: secret?.configPath ?? `access.${req.params.key}`,
bindingId: secret?.bindingId ?? null,
issueId: null,
registerForRedaction: () => undefined,
},
);
res.set("Cache-Control", "no-store");
res.json({
key: secret?.key ?? unresolvedSecret!.key,
value: resolution.value,
version: resolution.version,
});
});
router.get("/companies/:companyId/secret-providers", (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;

View File

@ -18,6 +18,7 @@ import {
userSecretDefinitions,
} from "@paperclipai/db";
import type {
AgentApiKeyScope,
AgentEnvConfig,
CompanySecretBindingTarget,
EnvBinding,
@ -62,8 +63,10 @@ import type {
import { isSecretProviderClientError } from "../secrets/types.js";
import { authorizationDeniedDetails, authorizationService } from "./authorization.js";
import { findActiveServerAdapter } from "../adapters/index.js";
import { logActivity } from "./activity-log.js";
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const AGENT_ACCESS_CONFIG_PATH_PREFIX = "access.";
const SENSITIVE_ENV_KEY_RE =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
const REDACTED_SENTINEL = "***REDACTED***";
@ -394,7 +397,7 @@ type CanonicalEnvBinding =
allowMissingOverride: boolean;
};
type SecretAccessConsumerType = SecretBindingTargetType | "plugin_worker";
type SecretAccessConsumerType = SecretBindingTargetType | "agent_api" | "plugin_worker";
type SecretConsumerContext = {
consumerType: SecretAccessConsumerType;
@ -420,6 +423,33 @@ type SecretResolutionOptions = {
allowUserSecretScope?: boolean;
};
export type AgentSecretReadContext = {
agentId: string;
configPath: string;
bindingId?: string | null;
actorSource: "agent_jwt" | "agent_key";
keyId?: string | null;
keyScope?: AgentApiKeyScope | null;
heartbeatRunId: string;
issueId?: string | null;
responsibleUserId?: string | null;
registerForRedaction: (value: string) => void | Promise<void>;
};
export type AgentSecretAccessEntry = {
secretId: string;
bindingId: string;
configPath: string;
key: string;
name: string;
description: string | null;
delivery: "env" | "api" | "both";
projectionClass: SecretProjectionClass;
latestVersion: number;
versionSelector: SecretVersionSelector;
resolvedVersion: number;
};
type ResolveAdapterConfigForRuntimeOptions = {
adapterType?: string | null;
skipUserSecrets?: boolean;
@ -454,7 +484,9 @@ export type MissingRuntimeBinding = {
};
function missingRuntimeConsumerType(consumerType: SecretAccessConsumerType): SecretBindingTargetType {
return consumerType === "plugin_worker" ? "plugin" : consumerType;
if (consumerType === "plugin_worker") return "plugin";
if (consumerType === "agent_api") return "agent";
return consumerType;
}
type RuntimeSecretResolution = {
@ -595,6 +627,20 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode {
return "provider_error";
}
function assertSecretBindingConfigPath(input: {
targetType: SecretBindingTargetType;
configPath: string;
}) {
if (!input.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return;
if (input.targetType !== "agent") {
throw unprocessable("API-only secret access bindings must target an agent");
}
const alias = input.configPath.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length);
if (!ENV_KEY_RE.test(alias)) {
throw unprocessable(`Invalid agent secret access alias: ${alias || "(empty)"}`);
}
}
function missingUserSecretDefinitionRuntimeBinding(
entry: {
key: string;
@ -670,6 +716,19 @@ export function secretService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function getByKey(companyId: string, key: string) {
return db
.select()
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.key, key),
eq(companySecrets.scope, "company"),
ne(companySecrets.status, "deleted"),
))
.then((rows) => rows[0] ?? null);
}
async function getUserSecretDefinitionById(
companyId: string,
definitionId: string,
@ -1174,6 +1233,278 @@ export function secretService(db: Db) {
})).value;
}
async function resolveSecretValueForAgentAccess(
companyId: string,
secretId: string,
version: number | "latest",
context: AgentSecretReadContext,
): Promise<{ value: string; version: number }> {
if (context.actorSource !== "agent_jwt") {
throw forbidden("Agent secret access requires a run-bound agent token");
}
if (!isUuidLike(context.heartbeatRunId)) {
throw forbidden("Agent secret access requires a verified heartbeat run");
}
if (!context.configPath.startsWith("env.") && !context.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) {
throw forbidden("Secret access is not granted for this binding path");
}
assertSecretBindingConfigPath({ targetType: "agent", configPath: context.configPath });
const run = await db
.select({ id: heartbeatRuns.id, contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.id, context.heartbeatRunId),
eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.agentId, context.agentId),
eq(heartbeatRuns.status, "running"),
))
.then((rows) => rows[0] ?? null);
if (!run) {
throw forbidden("Agent secret access requires a verified heartbeat run");
}
const decision = await authorization.decide({
actor: {
type: "agent",
agentId: context.agentId,
companyId,
source: "agent_jwt",
keyId: context.keyId ?? null,
keyScope: context.keyScope ?? null,
runId: context.heartbeatRunId,
},
action: "secrets:read",
resource: { type: "company", companyId },
});
if (!decision.allowed) {
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
let bindingContext: SecretBindingContext = {
consumerType: "agent",
consumerId: context.agentId,
configPath: context.configPath,
responsibleUserId: context.responsibleUserId ?? null,
actorType: "agent",
actorId: context.agentId,
actorSource: context.actorSource,
issueId: context.issueId ?? null,
heartbeatRunId: context.heartbeatRunId,
};
if (context.bindingId) {
const binding = await db
.select()
.from(companySecretBindings)
.where(and(
eq(companySecretBindings.id, context.bindingId),
eq(companySecretBindings.companyId, companyId),
eq(companySecretBindings.secretId, secretId),
eq(companySecretBindings.configPath, context.configPath),
))
.then((rows) => rows[0] ?? null);
if (!binding) throw forbidden("Secret access is not granted for this agent");
const runContext = asRecord(run.contextSnapshot) ?? {};
const manifest = (asRecord(runContext.paperclipSecrets) ?? {}).manifest;
const manifestBindingIds = new Set(
Array.isArray(manifest)
? manifest.flatMap((entry) => {
const record = asRecord(entry) ?? {};
return typeof record.bindingId === "string" ? [record.bindingId] : [];
})
: [],
);
const isDirectAgentBinding = binding.targetType === "agent" && binding.targetId === context.agentId;
if (!isDirectAgentBinding && !manifestBindingIds.has(binding.id)) {
throw forbidden("Secret access is not granted for this agent run");
}
bindingContext = {
...bindingContext,
consumerType: binding.targetType as SecretBindingTargetType,
consumerId: binding.targetId,
};
}
const runContext = asRecord(run.contextSnapshot) ?? {};
const effectiveIssueId = context.issueId ?? (
typeof runContext.issueId === "string"
? runContext.issueId
: typeof (asRecord(runContext.paperclipIssue) ?? {}).id === "string"
? String((asRecord(runContext.paperclipIssue) ?? {}).id)
: null
);
bindingContext.issueId = effectiveIssueId;
const accessContext: SecretConsumerContext = {
consumerType: "agent_api",
consumerId: context.agentId,
configPath: context.configPath,
responsibleUserId: context.responsibleUserId ?? null,
actorType: "agent",
actorId: context.agentId,
actorSource: context.actorSource,
issueId: effectiveIssueId,
heartbeatRunId: context.heartbeatRunId,
};
try {
const resolution = await resolveSecretValueInternal(companyId, secretId, version, {
bindingContext,
accessContext,
});
await context.registerForRedaction(resolution.value);
await logActivity(db, {
companyId,
actorType: "agent",
actorId: context.agentId,
action: "secret.value.read",
entityType: "secret",
entityId: secretId,
agentId: context.agentId,
runId: context.heartbeatRunId,
issueId: effectiveIssueId,
details: {
configPath: context.configPath,
outcome: "success",
version: resolution.manifestEntry.version,
},
});
return {
value: resolution.value,
version: resolution.manifestEntry.version,
};
} catch (error) {
const errorCode = secretResolutionErrorCode(error);
await logActivity(db, {
companyId,
actorType: "agent",
actorId: context.agentId,
action: "secret.value.read",
entityType: "secret",
entityId: secretId,
agentId: context.agentId,
runId: context.heartbeatRunId,
issueId: effectiveIssueId,
details: {
configPath: context.configPath,
outcome: "failure",
errorCode,
},
}).catch(() => undefined);
if (errorCode === "binding_missing" || errorCode === "secret_scope_invalid") {
throw forbidden("Secret access is not granted for this agent");
}
throw error;
}
}
async function listAgentSecretAccess(
companyId: string,
context: Omit<AgentSecretReadContext, "configPath" | "bindingId" | "registerForRedaction">,
): Promise<AgentSecretAccessEntry[]> {
if (context.actorSource !== "agent_jwt" || !isUuidLike(context.heartbeatRunId)) {
throw forbidden("Agent secret access requires a run-bound agent token");
}
const run = await db
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.id, context.heartbeatRunId),
eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.agentId, context.agentId),
eq(heartbeatRuns.status, "running"),
))
.then((rows) => rows[0] ?? null);
if (!run) throw forbidden("Agent secret access requires a verified heartbeat run");
const decision = await authorization.decide({
actor: {
type: "agent",
agentId: context.agentId,
companyId,
source: "agent_jwt",
keyId: context.keyId ?? null,
keyScope: context.keyScope ?? null,
runId: context.heartbeatRunId,
},
action: "secrets:read",
resource: { type: "company", companyId },
});
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
const runContext = asRecord(run.contextSnapshot) ?? {};
const manifest = (asRecord(runContext.paperclipSecrets) ?? {}).manifest;
const manifestBindingIds = Array.isArray(manifest)
? manifest.flatMap((entry) => {
const bindingId = (asRecord(entry) ?? {}).bindingId;
return typeof bindingId === "string" ? [bindingId] : [];
})
: [];
const [directBindings, runtimeBindings] = await Promise.all([
db.select().from(companySecretBindings).where(and(
eq(companySecretBindings.companyId, companyId),
eq(companySecretBindings.targetType, "agent"),
eq(companySecretBindings.targetId, context.agentId),
or(
like(companySecretBindings.configPath, "env.%"),
like(companySecretBindings.configPath, `${AGENT_ACCESS_CONFIG_PATH_PREFIX}%`),
),
)),
manifestBindingIds.length > 0
? db.select().from(companySecretBindings).where(and(
eq(companySecretBindings.companyId, companyId),
inArray(companySecretBindings.id, manifestBindingIds),
))
: Promise.resolve([]),
]);
const bindings = [...new Map([...directBindings, ...runtimeBindings].map((binding) => [binding.id, binding])).values()];
if (bindings.length === 0) return [];
const secrets = await db
.select()
.from(companySecrets)
.where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.scope, "company"),
eq(companySecrets.status, "active"),
inArray(companySecrets.id, [...new Set(bindings.map((binding) => binding.secretId))]),
));
const secretsById = new Map(secrets.map((secret) => [secret.id, secret]));
const bindingsBySecret = new Map<string, typeof bindings>();
for (const binding of bindings) {
const current = bindingsBySecret.get(binding.secretId) ?? [];
current.push(binding);
bindingsBySecret.set(binding.secretId, current);
}
return [...bindingsBySecret.entries()].flatMap(([secretId, secretBindings]) => {
const secret = secretsById.get(secretId);
if (!secret) return [];
const accessBinding = secretBindings.find((binding) => binding.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX));
const selectedBinding = accessBinding ?? secretBindings[0];
const hasEnv = secretBindings.some((binding) => binding.configPath.startsWith("env."));
const hasApi = Boolean(accessBinding);
const versionSelector: SecretVersionSelector = selectedBinding.versionSelector === "latest"
? "latest"
: Number(selectedBinding.versionSelector);
const delivery: AgentSecretAccessEntry["delivery"] = hasEnv && hasApi ? "both" : hasEnv ? "env" : "api";
return [{
secretId,
bindingId: selectedBinding.id,
configPath: selectedBinding.configPath,
key: secret.key,
name: secret.name,
description: secret.description ?? null,
delivery,
projectionClass: (selectedBinding.projectionClass ?? "unclassified") as SecretProjectionClass,
latestVersion: secret.latestVersion,
versionSelector,
resolvedVersion: versionSelector === "latest" ? secret.latestVersion : versionSelector,
}];
}).sort((left, right) => left.key.localeCompare(right.key));
}
async function resolveSecretVersion(
companyId: string,
secretId: string,
@ -3018,8 +3349,11 @@ export function secretService(db: Db) {
getById,
getByName,
getByKey,
resolveSecretValue,
resolveSecretVersion,
resolveSecretValueForAgentAccess,
listAgentSecretAccess,
resolveSecretValueForEphemeralAccess,
create: async (
@ -3498,6 +3832,7 @@ export function secretService(db: Db) {
projectionAllowlistKey?: string | null;
}) => {
await assertSecretInCompany(input.companyId, input.secretId);
assertSecretBindingConfigPath(input);
assertClass3StaticLeaseAllowed({
targetType: input.targetType,
configPath: input.configPath,
@ -3560,6 +3895,7 @@ export function secretService(db: Db) {
}> = [];
for (const ref of refs) {
await assertSecretInCompany(companyId, ref.secretId);
assertSecretBindingConfigPath({ targetType: target.targetType, configPath: ref.configPath });
const projectionClass = ref.projectionClass ?? "unclassified";
const projectionAllowlistKey = ref.projectionAllowlistKey ?? null;
assertClass3StaticLeaseAllowed({

View File

@ -352,6 +352,32 @@ When an issue needs browser/manual QA or a preview server, inspect its current e
For commands, response fields, and MCP tools, read:
`skills/paperclip/references/issue-workspaces.md`
## Reading Granted Secrets
When authenticated with the current run's agent JWT, list the secrets available to that run before fetching a value:
```bash
PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"
PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/agents/me/secrets"
```
The list is metadata-only. Fetch a specific value only when needed; the request has no body:
```bash
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
"$PAPERCLIP_API_BASE/api/agents/me/secrets/github_token/value"
```
- An `env.*` secret binding also grants API read access; `access.*` bindings grant API access without env injection.
- Prefer env injection for values needed on every run by the adapter or its child processes.
- Prefer on-demand fetch for values used only on some runs, large or structured values, or skills/tools that do not inherit adapter env.
- Every value fetch, including failures, is audited in `secret_access_events` and `activity_log`; never print, persist, or paste fetched values into task comments.
- These endpoints require the current run-bound agent JWT. Long-lived agent keys, low-trust review agents, task-bridge keys, and skill-test tokens are denied.
Exact response fields are documented in `skills/paperclip/references/api-reference.md`.
## Critical Rules
- **Never retry a 409.** The task belongs to someone else.

View File

@ -1293,6 +1293,43 @@ Terminal states: `done`, `cancelled`
| GET | `/api/companies/:companyId/secrets` | List secrets (metadata only) |
| POST | `/api/companies/:companyId/secrets` | Create secret |
| PATCH | `/api/secrets/:secretId` | Update secret value (creates new version) |
| GET | `/api/agents/me/secrets` | List secrets accessible to the current run (metadata only) |
| POST | `/api/agents/me/secrets/:key/value` | Fetch one granted secret value; request body is empty |
Agent secret access requires the current run-bound agent JWT. An `env.*` binding implies API read access; an `access.*` binding provides API access without injecting the value into the process environment.
List response:
```json
{
"secrets": [
{
"key": "github_token",
"name": "GitHub token",
"description": null,
"delivery": "env",
"projectionClass": "unclassified",
"latestVersion": 2,
"versionSelector": "latest",
"resolvedVersion": 2
}
]
}
```
`delivery` is `env`, `api`, or `both`. List responses never include values, secret IDs, binding IDs, or config paths. Successful lists write `activity_log.action = secret.access.listed` but do not create `secret_access_events` rows.
Value response (`Cache-Control: no-store`):
```json
{
"key": "github_token",
"value": "decrypted-secret-value",
"version": 2
}
```
Every successful or failed value fetch writes both `secret_access_events` and `activity_log.action = secret.value.read`. Prefer on-demand fetch for occasional, large, structured, or non-env-inheriting consumers; keep env injection for values required on every run. Never log or paste fetched values into issues, comments, or documents.
---

View File

@ -5,6 +5,7 @@ import type {
AdapterEnvironmentTestResult,
CompanySecret,
EnvBinding,
EnvSecretRefBinding,
Environment,
} from "@paperclipai/shared";
import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter } from "@paperclipai/shared";
@ -51,6 +52,8 @@ import {
EnvironmentVariablesEditor,
type EnvironmentVariablesEditorHandle,
} from "./environment-variables-editor";
import { AgentSecretAccessEditor } from "./AgentSecretAccessEditor";
import { AGENT_ACCESS_CONFIG_PATH_PREFIX } from "../lib/secret-delivery";
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-registry";
@ -324,6 +327,29 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
return environmentVariablesEditorRef.current?.flushPendingDraft() ?? null;
}
/**
* Replace the agent's API-access grants (top-level `access.<ALIAS>` keys) with
* the complete set emitted by the Secret access editor. Added/changed aliases
* are marked into the overlay; aliases dropped from the set are marked
* `undefined` so `buildAgentUpdatePatch` strips them.
*/
const applyAccessGrants = useCallback((next: Record<string, EnvSecretRefBinding>) => {
if (isCreate) return;
setOverlay((prev) => {
const effective = { ...(props.agent.adapterConfig ?? {}), ...prev.adapterConfig } as Record<string, unknown>;
const nextAdapterConfig: Record<string, unknown> = { ...prev.adapterConfig };
for (const [alias, binding] of Object.entries(next)) {
nextAdapterConfig[`${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`] = binding;
}
for (const key of Object.keys(effective)) {
if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) continue;
const alias = key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length);
if (!(alias in next)) nextAdapterConfig[key] = undefined;
}
return { ...prev, adapterConfig: nextAdapterConfig };
});
}, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps
/** Build accumulated patch and send to parent */
const handleCancel = useCallback(() => {
setOverlay({ ...emptyOverlay });
@ -1381,6 +1407,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
/>
</Field>
{!isCreate && (
<Field label="Secret access" hint={help.secretAccess}>
<AgentSecretAccessEditor
config={{ ...config, ...overlay.adapterConfig }}
secrets={availableSecrets}
onChange={applyAccessGrants}
/>
</Field>
)}
{/* Edit-only: timeout + grace period */}
{!isCreate && (
<>

View File

@ -0,0 +1,163 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanySecret, EnvSecretRefBinding } from "@paperclipai/shared";
// Stub SecretBindingPicker so the editor renders without CompanyContext /
// react-query. The stub exposes a button that binds a fixed secret.
vi.mock("./SecretBindingPicker", () => ({
SecretBindingPicker: ({
onChange,
}: {
onChange: (next: { secretId: string; version?: number | "latest" } | null) => void;
}) => (
<button type="button" data-testid="pick-secret" onClick={() => onChange({ secretId: "s1", version: "latest" })}>
pick
</button>
),
}));
import {
AgentSecretAccessEditor,
parseAccessGrants,
parseEnvSecretRefs,
rowsToAccessMap,
summarizeAgentBindings,
} from "./AgentSecretAccessEditor";
function makeSecret(id: string, name: string): CompanySecret {
return {
id,
companyId: "co",
scope: "company",
ownerUserId: null,
userSecretDefinitionId: null,
key: id,
name,
provider: "local_encrypted",
status: "active",
managedMode: "paperclip_managed",
externalRef: null,
providerConfigId: null,
providerMetadata: null,
latestVersion: 1,
description: null,
lastResolvedAt: null,
lastRotatedAt: null,
deletedAt: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date(0),
updatedAt: new Date(0),
};
}
describe("AgentSecretAccessEditor model", () => {
const config = {
env: {
GH_TOKEN: { type: "secret_ref", secretId: "s1", version: 2 },
PLAIN: { type: "plain", value: "hi" },
},
"access.STRIPE": { type: "secret_ref", secretId: "s1" },
"access.BROKEN": { type: "plain", value: "nope" },
model: "claude",
};
it("parses env secret refs, ignoring plain values", () => {
expect(parseEnvSecretRefs(config)).toEqual([{ name: "GH_TOKEN", secretId: "s1", version: 2 }]);
});
it("parses only well-formed top-level access.* secret refs", () => {
expect(parseAccessGrants(config)).toEqual([{ name: "STRIPE", secretId: "s1", version: "latest" }]);
});
it("summarizes bindings per secret with both delivery modes", () => {
const summary = summarizeAgentBindings(parseEnvSecretRefs(config), parseAccessGrants(config));
expect(summary).toEqual([{ secretId: "s1", envKeys: ["GH_TOKEN"], apiAliases: ["STRIPE"] }]);
});
it("drops incomplete, invalid-alias, and unselected rows from the emitted access map", () => {
expect(
rowsToAccessMap([
{ id: "1", alias: "OK", secretId: "s1", version: "latest" },
{ id: "2", alias: "", secretId: "s1", version: "latest" }, // no alias
{ id: "3", alias: "1BAD", secretId: "s1", version: "latest" }, // invalid alias
{ id: "4", alias: "NOSECRET", secretId: "", version: "latest" }, // no secret
]),
).toEqual({ OK: { type: "secret_ref", secretId: "s1", version: "latest" } });
});
});
describe("AgentSecretAccessEditor component", () => {
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
});
function render(node: React.ReactNode) {
root = createRoot(container);
flushSync(() => root!.render(node));
}
const secrets = [makeSecret("s1", "STRIPE_KEY")];
it("shows the delivery-mode overview for existing bindings", () => {
render(
<AgentSecretAccessEditor
config={{
env: { GH_TOKEN: { type: "secret_ref", secretId: "s1" } },
"access.STRIPE": { type: "secret_ref", secretId: "s1" },
}}
secrets={secrets}
onChange={() => {}}
/>,
);
expect(container.textContent).toContain("STRIPE_KEY");
expect(container.textContent).toContain("Env var");
expect(container.textContent).toContain("API access");
expect(container.textContent).toContain("env.GH_TOKEN");
expect(container.textContent).toContain("access.STRIPE");
});
it("adds an API-access grant, emitting an access.<ALIAS> secret_ref", () => {
const emitted: Array<Record<string, EnvSecretRefBinding>> = [];
render(
<AgentSecretAccessEditor
config={{}}
secrets={secrets}
onChange={(next) => emitted.push(next)}
/>,
);
// "Add API access" appends an editable row.
const addButton = [...container.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Add API access"),
)!;
flushSync(() => addButton.click());
// Type an alias.
const aliasInput = container.querySelector<HTMLInputElement>('input[aria-label="Access alias"]')!;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
setter.call(aliasInput, "STRIPE");
flushSync(() => aliasInput.dispatchEvent(new Event("input", { bubbles: true })));
// Bind a secret via the stubbed picker.
const pick = container.querySelector<HTMLButtonElement>('[data-testid="pick-secret"]')!;
flushSync(() => pick.click());
const last = emitted.at(-1)!;
expect(last).toEqual({ STRIPE: { type: "secret_ref", secretId: "s1", version: "latest" } });
});
});

View File

@ -0,0 +1,348 @@
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>
);
}

View File

@ -46,6 +46,8 @@ export const help: Record<string, string> = {
args: "Command-line arguments, comma-separated.",
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
secretAccess:
"Secrets this agent can reach. Env-var bindings are injected at run start; API-access bindings are fetched on demand via the run-bound agent API and never written to the environment.",
bootstrapPrompt: "Only sent when Paperclip starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.",
payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.",
webhookUrl: "The URL that receives POST requests when the agent is invoked.",

View File

@ -1835,6 +1835,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--gtc-62: 1fr minmax(0,18rem);
--gtc-63: 280px 1fr;
--gtc-64: minmax(0,20rem) 1fr;
--gtc-65: minmax(8rem,0.38fr) minmax(0,0.62fr) auto;
--gtr-1: auto auto; /* Extracted from ui/src/components/ui/card.tsx (grid-rows-[auto_auto]). */
--gtr-2: 1fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[1fr]). */
--gtr-3: 0fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[0fr]). */

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
aliasFromConfigPath,
consumerTypeLabel,
deliveryModeForConfigPath,
deliveryModeLabel,
} from "./secret-delivery";
describe("deliveryModeForConfigPath", () => {
it("classifies env config paths", () => {
expect(deliveryModeForConfigPath("env.OPENAI_API_KEY")).toBe("env");
});
it("classifies API-access config paths", () => {
expect(deliveryModeForConfigPath("access.STRIPE")).toBe("api");
});
it("falls back to config for other paths and empties", () => {
expect(deliveryModeForConfigPath("headers.authorization")).toBe("config");
expect(deliveryModeForConfigPath(null)).toBe("config");
expect(deliveryModeForConfigPath("")).toBe("config");
});
});
describe("deliveryModeLabel", () => {
it("maps each mode to a human label", () => {
expect(deliveryModeLabel("env")).toBe("Env var");
expect(deliveryModeLabel("api")).toBe("API access");
expect(deliveryModeLabel("config")).toBe("Config");
});
});
describe("aliasFromConfigPath", () => {
it("strips the delivery prefix", () => {
expect(aliasFromConfigPath("env.GH_TOKEN")).toBe("GH_TOKEN");
expect(aliasFromConfigPath("access.STRIPE")).toBe("STRIPE");
});
it("returns the raw path when no known prefix applies", () => {
expect(aliasFromConfigPath("headers.authorization")).toBe("headers.authorization");
expect(aliasFromConfigPath(null)).toBe("");
});
});
describe("consumerTypeLabel", () => {
it("renders agent_api and other multiword consumers sensibly", () => {
expect(consumerTypeLabel("agent_api")).toBe("Agent API");
expect(consumerTypeLabel("plugin_worker")).toBe("Plugin worker");
expect(consumerTypeLabel("tool_connection")).toBe("Tool connection");
});
it("capitalizes single-word consumer types", () => {
expect(consumerTypeLabel("agent")).toBe("Agent");
expect(consumerTypeLabel("project")).toBe("Project");
});
});

View File

@ -0,0 +1,83 @@
import type { SecretAccessEvent } from "@paperclipai/shared";
/**
* Delivery mode for an agent secret binding, derived from its `configPath`.
*
* The server (see `AGENT_ACCESS_CONFIG_PATH_PREFIX` in
* `server/src/services/secrets.ts`) treats a binding's config path as the
* source of truth for how a secret reaches the runtime:
* - `env.<KEY>` — injected as an environment variable at run start.
* - `access.<ALIAS>` — fetched on demand via the run-bound agent API
* (`GET /agents/me/secrets`), never written to the env.
* - anything else — a generic adapter config path (rendered as "Config").
*/
export type SecretDeliveryMode = "env" | "api" | "config";
/** Prefix for env-var delivery config paths. Mirrors the server convention. */
export const ENV_CONFIG_PATH_PREFIX = "env.";
/** Prefix for API-access (no env var) delivery config paths. Mirrors the server's `AGENT_ACCESS_CONFIG_PATH_PREFIX`. */
export const AGENT_ACCESS_CONFIG_PATH_PREFIX = "access.";
/** Valid env-var name / access alias (matches the server's `ENV_KEY_RE`). */
export const SECRET_ALIAS_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
export function deliveryModeForConfigPath(configPath: string | null | undefined): SecretDeliveryMode {
if (!configPath) return "config";
if (configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return "api";
if (configPath.startsWith(ENV_CONFIG_PATH_PREFIX)) return "env";
return "config";
}
/** Short human label for a delivery mode. */
export function deliveryModeLabel(mode: SecretDeliveryMode): string {
switch (mode) {
case "env":
return "Env var";
case "api":
return "API access";
default:
return "Config";
}
}
/** One-line explanation of a delivery mode, for tooltips/hints. */
export function deliveryModeDescription(mode: SecretDeliveryMode): string {
switch (mode) {
case "env":
return "Injected as an environment variable at run start.";
case "api":
return "Fetched on demand via the run-bound agent API. Never written to the environment.";
default:
return "Provided through adapter configuration.";
}
}
/** The env KEY / access ALIAS carried by a config path (the part after the prefix). */
export function aliasFromConfigPath(configPath: string | null | undefined): string {
if (!configPath) return "";
if (configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) {
return configPath.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length);
}
if (configPath.startsWith(ENV_CONFIG_PATH_PREFIX)) {
return configPath.slice(ENV_CONFIG_PATH_PREFIX.length);
}
return configPath;
}
/**
* Human label for a secret access-event `consumerType`. Runtime consumers are
* emitted as raw enum values (e.g. `agent_api`, `plugin_worker`) which read
* poorly when merely capitalized; map the ones that need help explicitly.
*/
export function consumerTypeLabel(consumerType: SecretAccessEvent["consumerType"]): string {
switch (consumerType) {
case "agent_api":
return "Agent API";
case "plugin_worker":
return "Plugin worker";
case "tool_connection":
return "Tool connection";
default:
return consumerType.charAt(0).toUpperCase() + consumerType.slice(1);
}
}

View File

@ -65,6 +65,13 @@ import { ApiError } from "../api/client";
import { accessApi, type CompanyUserDirectoryEntry } from "../api/access";
import { agentsApi } from "../api/agents";
import { envKeyFromSecretName } from "../components/environment-variables-editor/model";
import {
AGENT_ACCESS_CONFIG_PATH_PREFIX,
aliasFromConfigPath,
consumerTypeLabel,
deliveryModeForConfigPath,
deliveryModeLabel,
} from "../lib/secret-delivery";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
import { Button } from "@/components/ui/button";
@ -4224,6 +4231,25 @@ function envKeysReferencingSecret(env: unknown, reference: AgentAccessReference)
.sort();
}
/**
* Top-level `access.<ALIAS>` keys in an agent's adapter config that resolve to
* this secret (API-access delivery). Only company secrets support API access;
* user secrets remain env-only.
*/
function apiAliasesReferencingSecret(adapterConfig: unknown, reference: AgentAccessReference): string[] {
if (reference.kind !== "company") return [];
if (typeof adapterConfig !== "object" || adapterConfig === null || Array.isArray(adapterConfig)) return [];
return Object.entries(adapterConfig as Record<string, unknown>)
.filter(([key, binding]) => {
if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return false;
if (typeof binding !== "object" || binding === null) return false;
const record = binding as Record<string, unknown>;
return record.type === "secret_ref" && record.secretId === reference.secret.id;
})
.map(([key]) => key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length))
.sort();
}
function AgentAccessSection({
companyId,
reference,
@ -4253,14 +4279,15 @@ function AgentAccessSection({
const agentAccess = useMemo(
() =>
agents
.map((agent) => ({
agent,
envKeys: envKeysReferencingSecret(
(agent.adapterConfig as Record<string, unknown> | null)?.env,
reference,
),
}))
.filter((entry) => entry.envKeys.length > 0),
.map((agent) => {
const adapterConfig = (agent.adapterConfig as Record<string, unknown> | null) ?? null;
return {
agent,
envKeys: envKeysReferencingSecret(adapterConfig?.env, reference),
apiAliases: apiAliasesReferencingSecret(adapterConfig, reference),
};
})
.filter((entry) => entry.envKeys.length > 0 || entry.apiAliases.length > 0),
[agents, reference],
);
const grantableAgents = useMemo(
@ -4325,8 +4352,10 @@ function AgentAccessSection({
const adapterConfig = { ...((detail.adapterConfig ?? {}) as Record<string, unknown>) };
const env = { ...((adapterConfig.env ?? {}) as Record<string, unknown>) };
const keys = envKeysReferencingSecret(env, reference);
if (keys.length === 0) return detail;
const aliases = apiAliasesReferencingSecret(adapterConfig, reference);
if (keys.length === 0 && aliases.length === 0) return detail;
for (const key of keys) delete env[key];
for (const alias of aliases) delete adapterConfig[`${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`];
return agentsApi.update(
agentId,
{ adapterConfig: { ...adapterConfig, env }, replaceAdapterConfig: true },
@ -4352,7 +4381,7 @@ function AgentAccessSection({
</div>
<p className="mt-0.5 text-(length:--text-micro) text-muted-foreground">
{reference.kind === "company"
? "These agents receive this secret as an environment variable at run start."
? "Add here to inject this secret as an environment variable at run start. API-access grants (fetched on demand, no env var) are managed from each agent's Secret access settings and shown below."
: "These agents resolve the responsible user's value as an environment variable at run start."}
</p>
{agentsQuery.isPending ? (
@ -4365,15 +4394,30 @@ function AgentAccessSection({
<>
{agentAccess.length > 0 ? (
<ul className="mt-2 space-y-1">
{agentAccess.map(({ agent, envKeys }) => (
{agentAccess.map(({ agent, envKeys, apiAliases }) => (
<li
key={agent.id}
className="flex items-center gap-2 rounded border border-border/60 bg-background px-2 py-1"
>
<span className="min-w-0 flex-1 truncate text-xs font-medium">{agent.name}</span>
<code className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
{envKeys.join(", ")}
</code>
<span className="flex shrink-0 flex-wrap items-center justify-end gap-1">
{envKeys.length > 0 ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-(length:--text-nano) font-normal border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"
>
Env · {envKeys.join(", ")}
</Badge>
) : null}
{apiAliases.length > 0 ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-(length:--text-nano) font-normal border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300"
>
API · {apiAliases.join(", ")}
</Badge>
) : null}
</span>
<Button
type="button"
variant="ghost"
@ -4517,7 +4561,7 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
);
}
function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: CompanySecretUsageBinding[] }) {
export function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: CompanySecretUsageBinding[] }) {
if (loading) {
return <div className="py-6 text-center text-xs text-muted-foreground">Loading…</div>;
}
@ -4530,42 +4574,65 @@ function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: Com
}
return (
<div className="space-y-2">
{bindings.map((binding) => (
<div
key={binding.id}
className="rounded-md border border-border bg-muted/30 p-2 text-xs"
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium capitalize">{binding.target.type}</span>
<span className="font-mono text-muted-foreground">v{binding.versionSelector}</span>
{bindings.map((binding) => {
const deliveryMode = deliveryModeForConfigPath(binding.configPath);
return (
<div
key={binding.id}
className="rounded-md border border-border bg-muted/30 p-2 text-xs"
>
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5">
<span className="font-medium capitalize">{binding.target.type}</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-(length:--text-nano) font-normal",
deliveryMode === "api"
? "border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300"
: deliveryMode === "env"
? "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"
: null,
)}
>
{deliveryModeLabel(deliveryMode)}
</Badge>
</span>
<span className="font-mono text-muted-foreground">v{binding.versionSelector}</span>
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-2">
{binding.target.href ? (
<Link to={binding.target.href} className="truncate font-medium text-primary hover:underline">
{binding.target.label}
</Link>
) : (
<span className="truncate font-medium">{binding.target.label}</span>
)}
{binding.target.status ? (
<Badge variant="outline" className="h-5 px-1.5 text-(length:--text-nano) font-normal">
{binding.target.status.replaceAll("_", " ")}
</Badge>
) : null}
</div>
<div className="font-mono text-(length:--text-micro) text-muted-foreground break-all">
{binding.targetId}
</div>
<div className="text-(length:--text-micro) text-muted-foreground">
{deliveryMode === "api" ? (
<>API alias <span className="font-mono">{aliasFromConfigPath(binding.configPath)}</span></>
) : (
<span className="font-mono">{binding.configPath}</span>
)}{" "}
{binding.required ? "· required" : "· optional"}
</div>
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-2">
{binding.target.href ? (
<Link to={binding.target.href} className="truncate font-medium text-primary hover:underline">
{binding.target.label}
</Link>
) : (
<span className="truncate font-medium">{binding.target.label}</span>
)}
{binding.target.status ? (
<Badge variant="outline" className="h-5 px-1.5 text-(length:--text-nano) font-normal">
{binding.target.status.replaceAll("_", " ")}
</Badge>
) : null}
</div>
<div className="font-mono text-(length:--text-micro) text-muted-foreground break-all">
{binding.targetId}
</div>
<div className="text-(length:--text-micro) text-muted-foreground">
{binding.configPath} {binding.required ? "· required" : "· optional"}
</div>
</div>
))}
);
})}
</div>
);
}
function SecretEventsTab({
export function SecretEventsTab({
loading,
events,
companyId,
@ -4608,8 +4675,9 @@ function SecretEventsTab({
{events.map((event) => (
<div key={event.id} className="rounded border border-border px-2 py-1.5 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 capitalize">
{event.consumerType} · {event.outcome}
<span className="flex items-center gap-1.5">
<span>{consumerTypeLabel(event.consumerType)}</span>
<span className="capitalize">· {event.outcome}</span>
{event.secretScope === "user" ? (
<Badge
variant="outline"