mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
fix(secrets): expose secure human entry in native discovery (#18927)
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Some tasks need a company credential or the current person's credential. > - Paperclip already has secure human entry forms for these values. > - Native connection guidance did not distinguish storing a credential from connecting an external service. > - Native API search also omitted the existing secure entry route from secret operation help. > - This change makes that route discoverable while preserving human and owner authority. ## Linked Issues or Issue Description Refs #18780. Related #18921 corrects a separate observer false positive. Neither PR owns this production guidance change. **What happened?** A request to store an existing company or personal credential could lead to an unnecessary external provider question. Native API search did not explain the existing human entry surface. **Expected behavior** An agent can find the existing secure form, direct the human to the matching entry, and verify the result through its authorized interfaces. The agent must not ask for values in chat or act as the human. **Steps to reproduce** Search the native API for a company credential or personal publishing credential. The result should name the supported Secrets settings route and its human authority boundary. Open a known personal definition link and confirm that the existing Set my value action appears. ## What Changed - Distinguish Paperclip credential storage from external connection discovery in shared native guidance. - Describe the existing Secrets settings route, secure entry actions and current-person boundary in OpenAPI help. Qualify definition detail links as admin-only; other members use My secrets and Set my value. - Exercise discovery through the actual OpenCode MCP bridge and native API catalog. - Retain agent denials for personal definition and value routes. Exercise the real Secrets UI definition link. ## Verification - Focused local checks: four suites, 78 tests passed. These cover native discovery, secret-route permissions, shared guidance and the Secrets UI. - The new discovery test fails against unchanged master OpenAPI help. It passes with this change. - Token gates and diff checks passed. - Full workspace typecheck passed. The first attempt found missing SDK links after script-free installation; the existing root postinstall helper restored them. An unprivileged retry also hit the local tsx IPC sandbox restriction. The corrected owned setup passed without source changes. - Full local workspace build passed. The full local test run stopped in chat shard 2: one Discord receiver-lease test failed during initial configuration. That shard passed 263 tests; the first shard passed 310. The test and owning lease/configuration sources are unchanged from master. One focused isolation passed without code changes. Its cause remains unknown; this is not a complete local test pass. No unrelated Discord code was changed. [Normal hosted CI](https://github.com/paperclipai/paperclip/actions/runs/41773266687) passed on exact head `0abb8b722779368109b9639f68122d7df02a8ab1`. - Fresh Greptile review reports 5/5 on the exact current head; all review threads are resolved. Paperclip Review remains in progress. The optional Superagent scan is neutral/inconclusive: Security Graph context retrieval returned HTTP 503 and assessment did not start. It supplies no security PASS credit. - A retained representative native attempt with this guidance produced the correct definition-scoped secure-entry link. Its observer then rejected an explicit warning against chat entry. That failed result remains unchanged. Human entry, saving the value and worker delivery were not exercised. This PR claims no complete product-eval pass. ## Risks This changes agent guidance and search ranking. It grants no new permission and changes no secret resolution or owner checks. Broader service-connection discovery still applies when a task needs a connection. The secure human forms are unchanged. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with repository tools, code execution and test analysis. The host does not expose the context-window limit. ## 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 — the 78 owning controls pass; the broad local failure remains disclosed above - [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 — normal CI passed; optional review states are disclosed above - [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>
This commit is contained in:
1 parent
5b50c6b32c
commit
e54f9fbf82
5 files changed
+62
-9
No files matched your search
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
export const CONNECTION_INTENT_AGENT_GUIDANCE = [
|
||||
"Connection tools:",
|
||||
"- Storing a company or personal credential in Paperclip Secrets is distinct from connecting an external service. Use `search_api` to discover secret metadata and secure human-entry guidance; do not require an external provider choice merely to store an already-requested credential. Values stay in the human's secure entry surface, never in comments; personal values belong to the signed-in person.",
|
||||
"- When the user asks to connect a service, call `connections_search` before any service tool, even if already installed. Also search when a task needs a service and usable access is uncertain. Search by its name or capability and follow the returned `instruction`.",
|
||||
"- Distinguish tool, chat/channel, and AI account purposes. Use `search_api` and `call_api` for authorized connection configuration requested by the user. Standard agents can manage connections under their responsible user's authority; inspect the returned capabilities and respect explicit restrictions. Reuse saved accounts and setup drafts.",
|
||||
"- Suggest a relevant connection when it would help the task and usable access is missing. Respect recorded declines and provider choices. Ask only for missing decisions, consent, credentials, or authority; never ask for credentials in comments. Hand off the exact required human action at the saved setup link, then inspect status and continue through the API.",
|
||||
@@ -16,7 +17,7 @@ export const CONNECTION_INTENT_AGENT_GUIDANCE = [
|
||||
"- Do not use connection tools for arbitrary MCP URLs or unrelated work.",
|
||||
].join("\n");
|
||||
|
||||
export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search connections across tool, channel/email, and AI purposes with a service name or natural-language description (up to 4000 characters). Choose the relevant method and inspect its management capabilities. For user-requested configuration, discover and call the existing REST operation with search_api/call_api; reuse saved drafts and hand off only required human authentication or consent. Use connection_request for missing access or required user input. Also discovers verified external aggregator apps. Use first when asked to connect a service or usable access is uncertain; respect the returned instruction, recorded declines, and exact providerQuestion. Do not use for arbitrary MCP URLs. Search is read-only.";
|
||||
export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search connections across tool, channel/email, and AI purposes with a service name or natural-language description (up to 4000 characters). Storing a company or personal credential in Paperclip Secrets is a separate operation: use search_api for secret metadata and secure human-entry guidance, not external provider discovery. Choose the relevant method and inspect its management capabilities. For user-requested configuration, discover and call the existing REST operation with search_api/call_api; reuse saved drafts and hand off only required human authentication or consent. Use connection_request for missing access or required user input. Also discovers verified external aggregator apps. Use first when asked to connect a service or usable access is uncertain; respect the returned instruction, recorded declines, and exact providerQuestion. Do not use for arbitrary MCP URLs. Search is read-only.";
|
||||
|
||||
export const CONNECTION_REQUEST_TOOL_DESCRIPTION = "Request the service identifier returned by connections_search. For an existing connection with missing agent access, pass connectionId and the exact required toolNames to create an embedded Grant access card; only the human can approve it. Follow the search instruction; aggregator routes require the saved provider-selection interaction ID. Only this tool creates the real setup card. If user action is needed, finish independent work, then yield without retrying or asking for credentials in comments.";
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { searchRunnerApi } from "../services/native-runtime/runner-api-catalog.js";
|
||||
import { startOpenCodeMcpBridge } from "../../../packages/paperclip-runner/src/drivers/opencode/mcp-bridge.js";
|
||||
import { searchApiAction } from "../../../packages/paperclip-runner/src/protocol-actions/search-api.js";
|
||||
|
||||
it("discovers the existing secure human entry surface through native API search", async () => {
|
||||
const descriptor = searchApiAction.live.descriptor;
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
tools: [{ name: descriptor.operationId, description: descriptor.description, inputSchema: descriptor.inputSchema }],
|
||||
handler: async call => ({ type: "inputText", text: JSON.stringify(searchRunnerApi(call.arguments)) }),
|
||||
});
|
||||
try {
|
||||
for (const query of ["company credential secret", "personal publishing credential"]) {
|
||||
const response = await fetch(bridge.url, { method: "POST", headers: {
|
||||
Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream",
|
||||
}, body: JSON.stringify({ jsonrpc: "2.0", id: query, method: "tools/call",
|
||||
params: { name: "paperclip_search_api", arguments: { query } } }) });
|
||||
expect(response.status).toBe(200);
|
||||
const wire = await response.json() as { result: { isError?: boolean; content: Array<{ text: string }> } };
|
||||
expect(wire.result.isError).not.toBe(true);
|
||||
const controller = JSON.parse(wire.result.content[0]!.text) as { text: string };
|
||||
const discovered = JSON.parse(controller.text) as ReturnType<typeof searchRunnerApi>;
|
||||
const operations = discovered.results.filter(operation => operation.description.includes("/company/settings/secrets"));
|
||||
expect(operations.length, query).toBeGreaterThan(0);
|
||||
expect(operations.some(operation => /human|signed-in person/.test(operation.description))).toBe(true);
|
||||
expect(operations.some(operation => /never.*chat/i.test(operation.description))).toBe(true);
|
||||
if (query.startsWith("personal")) {
|
||||
const definitions = operations.find(operation => operation.path.endsWith("user-secret-definitions"));
|
||||
expect(definitions?.description).toContain("Set my value");
|
||||
expect(definitions?.description).toContain("current person");
|
||||
expect(definitions?.description).toContain("Company admins can also open definition details");
|
||||
expect(definitions?.description).toContain("other members use My secrets and Set my value");
|
||||
expect(definitions?.authorization.actor).toBe("board");
|
||||
}
|
||||
}
|
||||
} finally { await bridge.close(); }
|
||||
});
|
||||
@@ -303,6 +303,17 @@ describe("secret routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not turn agent discovery of personal entry into human identity authority", async () => {
|
||||
const app = createApp({ type: "agent", agentId: "agent-1", companyId: "company-1" });
|
||||
expect((await request(app).get("/api/companies/company-1/user-secret-definitions")).status).toBe(403);
|
||||
expect((await request(app).get("/api/companies/company-1/me/user-secrets")).status).toBe(403);
|
||||
expect((await request(app).post("/api/companies/company-1/me/user-secrets").send({
|
||||
definitionKey: "github_token", value: "synthetic-value",
|
||||
})).status).toBe(403);
|
||||
expect(mockSecretService.listUserSecretDefinitions).not.toHaveBeenCalled();
|
||||
expect(mockSecretService.createCurrentUserSecretValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates current-user secret values for the authenticated user only", async () => {
|
||||
mockSecretService.createCurrentUserSecretValue.mockResolvedValue({
|
||||
id: "secret-1",
|
||||
|
||||
@@ -5419,6 +5419,7 @@ registry.registerPath({
|
||||
tags: ["secrets"],
|
||||
summary:
|
||||
"List secret metadata (id, name, key, status) — accessible to agents",
|
||||
description: "Discover stored company credential metadata, never values. To add a company credential, direct the human to the company-prefixed /company/settings/secrets page and New secret, then bind only the intended agent's environment reference in its configuration. Secure storage of an already-requested key does not require connecting a new external service. Never request the value in chat or claim it is saved or bound before observing the authorized changes.",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
@@ -5436,7 +5437,8 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/secrets",
|
||||
tags: ["secrets"],
|
||||
summary: "Create a secret",
|
||||
summary: "Create a company secret credential",
|
||||
description: "Board-only secure credential entry. An agent cannot create the value on behalf of the human. Direct the human to New secret on the company-prefixed /company/settings/secrets page; never request the value in chat. After entry, use only the intended consumer's secret reference, not plaintext or a grant to every agent. A saved credential is not proof of runtime delivery.",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
body: jsonBody(createSecretSchema),
|
||||
@@ -5537,7 +5539,8 @@ registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/user-secret-definitions",
|
||||
tags: ["secrets"],
|
||||
summary: "List user secret definitions",
|
||||
summary: "List personal credential definitions (user secrets)",
|
||||
description: "Board-only company-admin metadata for personal credential definitions, not their values. Agents cannot read this list using the human's identity. For an existing required personal credential, direct the current person to the company-prefixed /company/settings/secrets page, My secrets, then Set my value for the matching definition. Company admins can also open definition details with ?definition=<definitionId> when its ID is already known; other members use My secrets and Set my value. Only that signed-in person's value is set; another person's value cannot satisfy it. Never request values in chat. Storing a credential for an existing definition does not require choosing or connecting a new external provider.",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
@@ -5613,7 +5616,8 @@ registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/me/user-secrets",
|
||||
tags: ["secrets"],
|
||||
summary: "List my user secret values",
|
||||
summary: "List my personal credential definitions and value metadata",
|
||||
description: "Board-only current-person metadata. An agent cannot impersonate the person or list another owner's values. The signed-in person uses My secrets on the company-prefixed /company/settings/secrets page and Set my value for an existing definition; never send the value in chat. This secure entry is separate from connecting an external service. Confirm availability only after authorized entry and the intended task's required delivery have been observed.",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
|
||||
});
|
||||
|
||||
@@ -814,18 +814,18 @@ describe("Secrets page layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the each-user detail sheet with coverage and set-my-value actions", async () => {
|
||||
it.each([false, true])("opens the admin-visible each-user secure-entry surface (definition link: %s)", async (fromLink) => {
|
||||
const definition = makeUserSecretDefinition();
|
||||
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([definition]);
|
||||
mockSecretsApi.listMyUserSecrets.mockResolvedValue([{ definition, secret: null }]);
|
||||
const root = await renderSecrets(container);
|
||||
const root = await renderSecrets(container, {
|
||||
path: fromLink ? `/company/settings/secrets?definition=${definition.id}` : "/company/settings/secrets",
|
||||
});
|
||||
|
||||
const definitionRow = Array.from(container.querySelectorAll("[role='row']")).find(
|
||||
(row) => row.textContent?.includes("Personal GitHub token"),
|
||||
) as HTMLElement | undefined;
|
||||
await act(async () => {
|
||||
definitionRow?.click();
|
||||
});
|
||||
if (!fromLink) await act(async () => { definitionRow?.click(); });
|
||||
await waitForReact(() => document.body.textContent?.includes("Details") ?? false);
|
||||
|
||||
expect(document.body.textContent).toContain("Personal GitHub token");
|
||||
|
||||
Reference in new issue
Block a user