mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies. > - Agents already receive selected company secrets through `env.*` bindings at run launch, but environment injection is ambient, long-lived, and not suitable for every secret consumer. > - The existing binding and secret-access-event models already provide company-scoped authorization and per-resolution audit seams. > - Agents need an explicit way to discover only the secrets granted to them and fetch a value on demand without exposing the wider company catalog. > - That capability must remain run-bound, preserve low-trust token carve-outs, and make every value read visible in both security and operator audit trails. > - This pull request adds an `access.*` delivery namespace, two run-bound agent routes, dual audit logging, documentation, and an operator grants editor. > - The benefit is least-privilege, revocable, auditable secret access while preserving existing env injection behavior. ## Linked Issues or Issue Description No pre-existing public issue. Related work: - Refs #9797 — existing in-sheet agent access UI that this PR extends to distinguish env and API delivery. - Refs #9918 — complementary searchable-agent picker improvement for the same secrets sheet. - Refs #9530 — related company-wide metadata catalog proposal; this PR intentionally exposes only the authenticated run's granted aliases and values. **Problem / motivation:** Agents can currently consume secrets only through process environment injection. This keeps values resident for the run, does not support on-demand consumers, and cannot provide a discrete operator-visible activity event for each agent-initiated read. **Proposed solution:** Treat `company_secret_bindings` as the source of truth for agent secret grants. Keep `env.KEY` as env delivery and add `access.ALIAS` for API-only delivery; an env binding also implies read access because the value is already present in the agent process. Add run-bound list/fetch endpoints that derive scope from the authenticated heartbeat run and never accept caller-selected overlays. **Alternatives considered:** A company-wide agent-readable catalog was rejected for this value path because it increases reconnaissance and does not prove a per-secret grant. Reusing the ephemeral environment-probe resolver was rejected because it lacks binding enforcement. Approval-gated reads and user-scoped secrets remain deferred beyond v1. **Roadmap alignment:** This extends the completed **Secrets Manager with per-agent access** roadmap capability from launch-time env injection to explicit run-bound API delivery without duplicating a separate planned initiative. ## What Changed - Added `access.*` agent binding validation and a dedicated run-bound resolver that combines `secrets:read` authorization with binding-context enforcement. - Added `GET /api/agents/me/secrets` for minimal granted metadata and `POST /api/agents/me/secrets/:key/value` for on-demand value fetches with `Cache-Control: no-store`. - Preserved the existing denials for low-trust review agents, task-bridge credentials, and skill-test tokens; standard long-lived agent API keys cannot call the run-bound routes. - Added dual audit behavior: value attempts write `secret_access_events` and `activity_log` (`secret.value.read`), while metadata listing writes the lighter `secret.access.listed` activity event. - Kept env compatibility: `env.*` remains injected at launch and also implies API read for the same bound agent; `access.*` never becomes an environment variable. - Added the agent-settings **Secret access** editor plus delivery-mode/alias surfacing on the Secrets page, with focused UI tests and tokenized layout styles. - Updated OpenAPI, shared types, agent-facing skill documentation, and API reference documentation. ### UI Screenshots P3 produced and reviewed three screenshots using mock data; images are intentionally not committed to the repository: - `secret-access-editor.png` — agent settings grant editor. - `secret-access-light.png` — Secrets-page delivery surfacing in light mode. - `secret-access-dark.png` — Secrets-page delivery surfacing in dark mode. The source attachments are retained with the implementation task and linked in the internal handoff; the public page publisher was unavailable in the PR-prep runtime. ## Verification - `pnpm exec vitest run server/src/__tests__/agent-secrets-routes.test.ts server/src/__tests__/secrets-service.test.ts server/src/__tests__/secrets-routes.test.ts ui/src/lib/secret-delivery.test.ts ui/src/components/AgentSecretAccessEditor.test.tsx` — 5 files, 122 tests passed. - Security follow-up: `pnpm exec vitest run server/src/__tests__/agent-secrets-routes.test.ts server/src/__tests__/secrets-service.test.ts` — 2 files, 73 tests passed after active-run and version-consistency fixes. - Final-head CI: all feature, typecheck, build, e2e, security, and review gates pass; `General tests (server (1/3))` remains red after one rerun because unrelated `heartbeat-retry-scheduling.test.ts` cleanup deletes `heartbeat_runs` before referenced `activity_log` rows. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — feature-local arbitrary-value violations fixed; command still reports five unchanged `#9627` literals outside this PR. - End-to-end QA passed all eight acceptance criteria: grant/list, fetch, dual audit, env-implies-read, denial matrix, revocation, UI rendering, and env-injection regression. Evidence: https://github.com/paperclipai/paperclip/pull/9921#issuecomment-5027455492 - Security review returned PASS-with-required-changes; the implementation uses the required dedicated binding-enforcing resolver, run-bound JWT restriction, run-derived overlays, minimal metadata, and a resolver redaction-registration hook. Evidence: https://github.com/paperclipai/paperclip/pull/9921#issuecomment-5027455382 ## Risks - A compromised agent can exfiltrate any secret explicitly granted to it; explicit company-scoped/run-scoped grants, revocation, and audit reduce but cannot remove that inherent capability risk. - The resolver invokes a redaction-registration hook before returning values, but the current route has no persistent cross-request per-run redaction registry. Paperclip-owned later comments/events therefore cannot yet guarantee automatic scrubbing of a deliberately copied fetched value; QA classified this as non-blocking residual hardening. - Audit-event insertion currently fails open if the security-event insert itself fails; the operator activity event provides partial redundancy, but a future hardening change should define fail-closed behavior for value delivery. - This PR overlaps `ui/src/pages/Secrets.tsx` with #9918 and may require a straightforward rebase after that PR moves. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, `gpt-5.3-codex`, with reasoning, repository tool use, terminal execution, Paperclip API access, and GitHub CLI capabilities. Context-window size is not exposed by the runtime. - Anthropic Claude Opus 4.8 with 1M context and tool use assisted with the UI implementation commit. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
164 lines
5.3 KiB
TypeScript
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" } });
|
|
});
|
|
});
|