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

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

## Linked Issues or Issue Description

No pre-existing public issue. Related work:

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

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

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

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

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

## What Changed

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

### UI Screenshots

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

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

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

## Verification

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

## Risks

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

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

## Model Used

- OpenAI Codex, `gpt-5.3-codex`, with reasoning, repository tool use,
terminal execution, Paperclip API access, and GitHub CLI capabilities.
Context-window size is not exposed by the runtime.
- Anthropic Claude Opus 4.8 with 1M context and tool use assisted with
the UI implementation commit.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 08:04:39 -05:00

164 lines
5.3 KiB
TypeScript

// @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" } });
});
});