mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 21:03:51 +02:00
## Thinking Path > - Paperclip is the control plane people use to manage AI-agent companies. > - Agents can encounter credentials during work. > - Directly creating live secrets or bindings would bypass human governance. > - Proposal records must remain inert and separate from live secret resolution until an authorized human approves them. > - Approval must reuse the existing secret-create and protected agent-config write paths. > - This pull request adds the propose, review, approve, and reject lifecycle. > - The benefit is that agents can safely hand credentials into Paperclip without exposing plaintext or gaining authority to activate them. ## Linked Issues or Issue Description Follow-on to #9921, which established run-bound agent secret access. **Problem / motivation:** Agents can receive credentials during work. There is no governed way for them to propose a credential or binding without exposing plaintext in work artifacts or immediately creating live access. **Proposed solution:** Store agent-authored proposals outside live secret tables. Encrypt each proposed value and register exact-value redaction when Paperclip receives it. Require an authorized human to approve or reject each proposal. Approval executes through the normal write paths as the human approver. Binding proposals can target only the proposer or its downward reporting chain under the restrictive V1 policy. **Alternatives considered:** We rejected live secrets with a `proposed` status. That design would put untrusted rows in resolver, list, and sync paths. It would also allow uniqueness squatting. We rejected direct agent binding writes because a binding is an agent-config write and must keep the existing human permission gate. **Roadmap alignment:** This change extends the run-bound agent secret-access foundation in #9921 with a governed proposal workflow. ## Security Verdict Q0 SecEng verdict: **PASS-with-required-changes**. The review accepted the separate proposal-table design and required the implementation to: - fail closed unless both encryption and exact-value run redaction registration succeed; - scrub ciphertext idempotently on reject, withdraw, and expiry, with audit-visible state; - treat agent justification as hostile input and foreground action, target, provenance, and approver permissions; - snapshot and re-check the target agent plus reports-to chain at approval to prevent org-chart laundering; - make cascade approval atomic and fail closed if either secret creation or binding authorization fails; - deny low-trust, `skill_test`, `task_bridge`, and non-run-bound sources consistently; and - execute approval through the normal human secret/config write paths, including protected-change gates. Those requirements are implemented and covered by focused service, route, and UI tests. Residual V1 risk remains the accepted 14-day encrypted retention window. Proposal-time redaction also cannot clean a value that leaked before the propose call. ## What Changed - Added `company_secret_proposals`, migration `0207`, shared proposal contracts, and a state-machine service for create, approve, reject, withdraw, cascade, expiry, and ciphertext scrubbing. - Added run-bound agent proposal routes and board review routes. The routes derive provenance from authentication and enforce source restrictions, company isolation, chain-of-command checks, approval-as-approver, wake-on-resolution, and dual audit trails. - Added durable per-run exact-value redaction registration so proposal values remain redacted on later read surfaces. - Added the Secrets **Proposals** tab and agent configuration **Proposed access** rows. The UI shows fingerprint and length only. It also frames agent justification as untrusted input, runs permission preflight, supports approve and reject actions, and confirms cascades. - Updated OpenAPI, agent skill guidance, API reference documentation, and focused server and UI regression coverage. - Rebased the branch onto current `master` and renumbered the proposal migration after `0206`. ## QA Acceptance Results Q5 QA verdict: **PASS — 9/9 acceptance criteria met**, with one Minor non-blocking follow-up. - **AC1:** proposed values never echo, never appear in live lists/resolvers, and expose only fingerprint + length to board reviewers. - **AC2:** restrictive `self_and_reports` matrix passes: self/downward allowed; upward/lateral denied. - **AC3:** secret approval uses the normal create path, honors rename overrides, records proposer/approver provenance, and scrubs ciphertext. - **AC4:** approved bindings materialize and resolve through the target agent's runtime list/fetch routes. - **AC5:** pending-secret bindings require cascade; cascade succeeds atomically and permission failures leave nothing applied. - **AC6:** reject, withdraw, dependent rejection, and expiry paths scrub ciphertext and preserve reasons/audit state. - **AC7:** token/source and approver denial matrix passes through live checks plus focused route tests. - **AC8:** proposal lifecycle events and reused `secret.created`/config-write events form the required dual audit trail; origin-issue notification and wake are queued. - **AC9:** both review surfaces render and execute correctly; UI approval materializes the binding. QA also confirmed zero plaintext occurrences for all exercised proposal values in server logs. The single finding is that the company-level `bindingTargetPolicy` toggle is not wired yet. V1 is hardcoded to the restrictive `self_and_reports` policy. The matrix is correct and the follow-up is tracked separately, so QA classified it as non-blocking. ## Verification - Focused server proposal and redaction suite: 83 tests pass. - Focused proposal review UI suite: 54 tests pass. - Embedded-Postgres migration reapply test: 1 test passes with the documented 30-second timeout. - `pnpm --filter @paperclipai/db typecheck` passes, including migration numbering and safety checks. - `pnpm --filter @paperclipai/shared typecheck` passes. - `pnpm --filter @paperclipai/ui typecheck` passes. - `pnpm check:token-gates` passes with all gates clean. - Q5 exercised the complete propose, review, approve, bind, and runtime-resolve flow over real HTTP, JWT, and database paths. It verified 9/9 acceptance criteria. ## Risks - Proposal ciphertext is retained encrypted for up to 14 days while pending. Terminal-state and expiry scrub paths reduce but do not remove server-compromise risk during that window. - The V1 target policy is restrictive but not yet company-configurable. A separate follow-up owns that change. - A new migration can require another renumber if another migration lands before maintainers merge this pull request. > 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. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent. The exact runtime model ID and context-window size are not exposed. The agent used reasoning, repository editing, terminal execution, Paperclip API, and GitHub CLI capabilities. Q3 UI work also records Claude Opus 4.8 assistance in its commit trailers. ## 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 or instance-local Paperclip issues or links - [x] My branch name describes the change 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 Fable 5 <noreply@anthropic.com>
256 lines
9.6 KiB
TypeScript
256 lines
9.6 KiB
TypeScript
import type {
|
|
CompanySecret,
|
|
CompanySecretUsageBinding,
|
|
CompanySecretProviderConfig,
|
|
SecretProviderConfigDiscoveryPreviewResult,
|
|
RemoteSecretImportPreviewResult,
|
|
RemoteSecretImportResult,
|
|
SecretAccessEvent,
|
|
SecretManagedMode,
|
|
SecretProvider,
|
|
SecretProviderConfigStatus,
|
|
SecretProviderConfigHealthResponse,
|
|
SecretProviderDescriptor,
|
|
SecretStatus,
|
|
SecretProposalView,
|
|
SecretProposalStatus,
|
|
ApproveSecretProposalInput,
|
|
RejectSecretProposalInput,
|
|
UserSecretCoverageSummary,
|
|
UserSecretDefinition,
|
|
} from "@paperclipai/shared";
|
|
import { api } from "./client";
|
|
|
|
export interface SecretUsageResponse {
|
|
secretId: string;
|
|
bindings: CompanySecretUsageBinding[];
|
|
}
|
|
|
|
/** One "My secrets" row: a company definition paired with the current user's own value (if set). */
|
|
export interface MyUserSecretEntry {
|
|
definition: UserSecretDefinition;
|
|
secret: CompanySecret | null;
|
|
}
|
|
|
|
export interface CreateUserSecretDefinitionInput {
|
|
key: string;
|
|
name: string;
|
|
description?: string | null;
|
|
status?: Exclude<SecretStatus, "deleted">;
|
|
provider?: SecretProvider;
|
|
managedMode?: SecretManagedMode;
|
|
providerConfigId?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
usageGuidance?: string | null;
|
|
}
|
|
|
|
export interface UpdateUserSecretDefinitionInput {
|
|
name?: string;
|
|
description?: string | null;
|
|
status?: SecretStatus;
|
|
providerConfigId?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
usageGuidance?: string | null;
|
|
}
|
|
|
|
/** Owner-supplied value for a user secret. Either `value` (managed) or `externalRef`. */
|
|
export interface UpsertMyUserSecretInput {
|
|
definitionId?: string;
|
|
definitionKey?: string;
|
|
value?: string | null;
|
|
externalRef?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerConfigId?: string | null;
|
|
}
|
|
|
|
export interface CreateSecretInput {
|
|
name: string;
|
|
key?: string;
|
|
provider?: SecretProvider;
|
|
managedMode?: SecretManagedMode;
|
|
value?: string | null;
|
|
description?: string | null;
|
|
externalRef?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerConfigId?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface SecretProviderHealthResponse {
|
|
providers: Array<{
|
|
provider: SecretProvider;
|
|
status: "ok" | "warn" | "error";
|
|
message: string;
|
|
warnings?: string[];
|
|
backupGuidance?: string[];
|
|
details?: Record<string, unknown>;
|
|
}>;
|
|
}
|
|
|
|
export interface UpdateSecretInput {
|
|
name?: string;
|
|
key?: string;
|
|
status?: SecretStatus;
|
|
description?: string | null;
|
|
externalRef?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface RotateSecretInput {
|
|
value?: string | null;
|
|
externalRef?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerConfigId?: string | null;
|
|
}
|
|
|
|
export interface CreateSecretProviderConfigInput {
|
|
provider: SecretProvider;
|
|
displayName: string;
|
|
status?: SecretProviderConfigStatus;
|
|
isDefault?: boolean;
|
|
config?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface UpdateSecretProviderConfigInput {
|
|
displayName?: string;
|
|
status?: SecretProviderConfigStatus;
|
|
isDefault?: boolean;
|
|
config?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface RemoteImportPreviewInput {
|
|
providerConfigId: string;
|
|
query?: string | null;
|
|
nextToken?: string | null;
|
|
pageSize?: number;
|
|
}
|
|
|
|
export interface RemoteImportSelectionInput {
|
|
externalRef: string;
|
|
name?: string | null;
|
|
key?: string | null;
|
|
description?: string | null;
|
|
providerVersionRef?: string | null;
|
|
providerMetadata?: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface RemoteImportInput {
|
|
providerConfigId: string;
|
|
secrets: RemoteImportSelectionInput[];
|
|
}
|
|
|
|
export interface SecretProviderConfigDiscoveryPreviewInput {
|
|
provider: SecretProvider;
|
|
config?: Record<string, unknown>;
|
|
query?: string | null;
|
|
nextToken?: string | null;
|
|
pageSize?: number;
|
|
}
|
|
|
|
export const secretsApi = {
|
|
list: (companyId: string) => api.get<CompanySecret[]>(`/companies/${companyId}/secrets`),
|
|
providers: (companyId: string) =>
|
|
api.get<SecretProviderDescriptor[]>(`/companies/${companyId}/secret-providers`),
|
|
providerHealth: (companyId: string) =>
|
|
api.get<SecretProviderHealthResponse>(`/companies/${companyId}/secret-providers/health`),
|
|
providerConfigs: (companyId: string) =>
|
|
api.get<CompanySecretProviderConfig[]>(`/companies/${companyId}/secret-provider-configs`),
|
|
providerConfigDiscoveryPreview: (
|
|
companyId: string,
|
|
data: SecretProviderConfigDiscoveryPreviewInput,
|
|
) =>
|
|
api.post<SecretProviderConfigDiscoveryPreviewResult>(
|
|
`/companies/${companyId}/secret-provider-configs/discovery/preview`,
|
|
data,
|
|
),
|
|
createProviderConfig: (companyId: string, data: CreateSecretProviderConfigInput) =>
|
|
api.post<CompanySecretProviderConfig>(`/companies/${companyId}/secret-provider-configs`, data),
|
|
updateProviderConfig: (id: string, data: UpdateSecretProviderConfigInput) =>
|
|
api.patch<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`, data),
|
|
disableProviderConfig: (id: string) =>
|
|
api.patch<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`, { status: "disabled" }),
|
|
removeProviderConfig: (id: string) =>
|
|
api.delete<CompanySecretProviderConfig>(`/secret-provider-configs/${id}`),
|
|
setDefaultProviderConfig: (id: string) =>
|
|
api.post<CompanySecretProviderConfig>(`/secret-provider-configs/${id}/default`, {}),
|
|
checkProviderConfigHealth: (id: string) =>
|
|
api.post<SecretProviderConfigHealthResponse>(`/secret-provider-configs/${id}/health`, {}),
|
|
create: (companyId: string, data: CreateSecretInput) =>
|
|
api.post<CompanySecret>(`/companies/${companyId}/secrets`, data),
|
|
update: (id: string, data: UpdateSecretInput) =>
|
|
api.patch<CompanySecret>(`/secrets/${id}`, data),
|
|
rotate: (id: string, data: RotateSecretInput) =>
|
|
api.post<CompanySecret>(`/secrets/${id}/rotate`, data),
|
|
disable: (id: string) =>
|
|
api.patch<CompanySecret>(`/secrets/${id}`, { status: "disabled" satisfies SecretStatus }),
|
|
enable: (id: string) =>
|
|
api.patch<CompanySecret>(`/secrets/${id}`, { status: "active" satisfies SecretStatus }),
|
|
archive: (id: string) =>
|
|
api.patch<CompanySecret>(`/secrets/${id}`, { status: "archived" satisfies SecretStatus }),
|
|
remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`),
|
|
usage: (id: string) => api.get<SecretUsageResponse>(`/secrets/${id}/usage`),
|
|
accessEvents: (id: string) => api.get<SecretAccessEvent[]>(`/secrets/${id}/access-events`),
|
|
|
|
// --- User-specific secrets ---------------------------------------------
|
|
// Admin: shared definitions each member fills in with their own value.
|
|
listUserSecretDefinitions: (companyId: string) =>
|
|
api.get<UserSecretDefinition[]>(`/companies/${companyId}/user-secret-definitions`),
|
|
createUserSecretDefinition: (companyId: string, data: CreateUserSecretDefinitionInput) =>
|
|
api.post<UserSecretDefinition>(`/companies/${companyId}/user-secret-definitions`, data),
|
|
updateUserSecretDefinition: (
|
|
companyId: string,
|
|
definitionId: string,
|
|
data: UpdateUserSecretDefinitionInput,
|
|
) =>
|
|
api.patch<UserSecretDefinition>(
|
|
`/companies/${companyId}/user-secret-definitions/${definitionId}`,
|
|
data,
|
|
),
|
|
removeUserSecretDefinition: (companyId: string, definitionId: string) =>
|
|
api.delete<{ ok: true }>(`/companies/${companyId}/user-secret-definitions/${definitionId}`),
|
|
userSecretDefinitionCoverage: (companyId: string, definitionId: string) =>
|
|
api.get<UserSecretCoverageSummary>(
|
|
`/companies/${companyId}/user-secret-definitions/${definitionId}/coverage`,
|
|
),
|
|
|
|
// Current user ("My secrets"): each definition paired with my own value.
|
|
listMyUserSecrets: (companyId: string) =>
|
|
api.get<MyUserSecretEntry[]>(`/companies/${companyId}/me/user-secrets`),
|
|
createMyUserSecret: (companyId: string, data: UpsertMyUserSecretInput) =>
|
|
api.post<CompanySecret>(`/companies/${companyId}/me/user-secrets`, data),
|
|
updateMyUserSecret: (
|
|
companyId: string,
|
|
secretId: string,
|
|
data: Partial<UpsertMyUserSecretInput> & { status?: SecretStatus },
|
|
) => api.patch<CompanySecret>(`/companies/${companyId}/me/user-secrets/${secretId}`, data),
|
|
rotateMyUserSecret: (companyId: string, secretId: string, data: UpsertMyUserSecretInput) =>
|
|
api.post<CompanySecret>(`/companies/${companyId}/me/user-secrets/${secretId}/rotate`, data),
|
|
removeMyUserSecret: (companyId: string, secretId: string) =>
|
|
api.delete<{ ok: true }>(`/companies/${companyId}/me/user-secrets/${secretId}`),
|
|
remoteImportPreview: (companyId: string, data: RemoteImportPreviewInput) =>
|
|
api.post<RemoteSecretImportPreviewResult>(
|
|
`/companies/${companyId}/secrets/remote-import/preview`,
|
|
data,
|
|
),
|
|
remoteImport: (companyId: string, data: RemoteImportInput) =>
|
|
api.post<RemoteSecretImportResult>(`/companies/${companyId}/secrets/remote-import`, data),
|
|
|
|
// --- Secret & binding proposals (PAP-14731) -----------------------------
|
|
// Board-facing review surface. Agents propose credentials/bindings; humans
|
|
// approve or reject them here. Values are never returned by these routes.
|
|
listProposals: (companyId: string, status: SecretProposalStatus = "pending") =>
|
|
api.get<SecretProposalView[]>(
|
|
`/companies/${companyId}/secret-proposals?status=${encodeURIComponent(status)}`,
|
|
),
|
|
approveProposal: (companyId: string, proposalId: string, data: ApproveSecretProposalInput = {}) =>
|
|
api.post<SecretProposalView>(
|
|
`/companies/${companyId}/secret-proposals/${proposalId}/approve`,
|
|
data,
|
|
),
|
|
rejectProposal: (companyId: string, proposalId: string, data: RejectSecretProposalInput) =>
|
|
api.post<SecretProposalView>(
|
|
`/companies/${companyId}/secret-proposals/${proposalId}/reject`,
|
|
data,
|
|
),
|
|
};
|