Files
PaperClipAI/docs/api/secrets.md
T
DottaandPaperclip 6b8e42168e Add governed secret alias confirmation cards (#11486)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents need scoped secret bindings to use external services safely.
> - Agents could not request an existing secret under a new config name
without an internal secret identifier.
> - Existing binding proposals were only visible in Settings and did not
create an issue-thread approval path.
> - A confirmation card could record acceptance without proving that the
binding was created.
> - This pull request extends the existing secret proposal system with
safe source references and governed issue-thread confirmation cards.
> - The benefit is a one-click flow that creates the binding or shows a
clear failure without exposing secret material.

## Linked Issues or Issue Description

Related prerequisite: #11482.

**Subsystem affected**

Cross-cutting: server REST APIs, shared interaction contracts, database
proposal schema, and issue-thread UI.

**Problem or motivation**

An agent can need an existing bound secret under a second config name.
The agent cannot safely discover the internal secret identifier. The
existing proposal is also easy for the operator to miss because it only
appears in Settings. A generic confirmation can record acceptance
without executing the binding.

**Proposed solution**

Let an agent create a binding proposal from one of its existing config
paths. Mint a server-owned, human-only confirmation card on the
checked-out issue. Recheck the operator's target-agent permission under
the proposal row lock. Execute the existing proposal transaction after
card acceptance. Store an `executed` or `failed` result on the card.
Render the complete lifecycle in the issue thread and attention
resolver.

**Alternatives considered**

A new alias subsystem would duplicate proposal quotas, expiry,
authorization, and binding synchronization. A text-only issue comment
would not provide a governed action or an execution result. An
agent-supplied card payload would permit metadata smuggling. This change
uses the existing proposal transaction and a server-owned payload
instead.

**Roadmap alignment**

This change extends the completed "Secrets Manager with per-agent
access" roadmap item. It preserves scoped bindings and audited
resolution. The required GitHub search found no other open duplicate
issue or pull request.

## What Changed

- Added safe source-config-path binding proposals and preserved
user-secret ownership checks.
- Added a proposal-to-interaction link and an idempotent database
migration.
- Minted human-only `request_confirmation` cards with server-owned
`secretProposal` metadata.
- Rejected agent-supplied governed metadata and agent addressees.
- Rechecked `agent_config:update` authority under the proposal lock
before execution.
- Recorded `executed` or `failed` results and posted a failure comment
when no binding was created.
- Settled failed accepted proposals atomically and mirrored rejection,
withdrawal, and expiry in both directions.
- Emitted `secret.binding.created` for new agent binding writes.
- Added a dedicated issue-thread card for pending, executed, failed,
rejected, withdrawn, and expired states.
- Showed only the source label, target agent, config path, skeptical
justification, expiry, and safe failure code.
- Replaced resolved attention-query entries immediately with the
stitched server result.
- Added focused server, database, UI, and state-transition tests.
- Added Storybook fixtures for every review state and documented the API
and agent behavior.

## Verification

- `pnpm exec vitest run
ui/src/components/IssueThreadInteractionCard.test.tsx
ui/src/components/AttentionInteractionResolver.test.ts` — 58 passed.
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`
- `pnpm build-storybook`
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/db typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/db check:migrations`
- `NODE_ENV=test pnpm exec vitest run
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/secret-proposals-routes.test.ts
server/src/__tests__/secrets-routes.test.ts
server/src/__tests__/agents-service-secret-bindings.test.ts` — 142
passed.
- `NODE_ENV=test pnpm --filter @paperclipai/db exec vitest run
src/company-secret-proposals-migration.test.ts --silent` — 1 passed.
- `pnpm -r typecheck`
- `pnpm test:run` — server 4,175 passed, UI 4,109 passed; the CLI
AWS-doctor case passes 8/8 with runtime-injected static AWS credential
variables unset.
- `pnpm build`
- `git diff --check origin/master...HEAD`

## Risks

- Migration `0221` adds one nullable foreign key and one index. It uses
idempotent guards.
- The accept route performs a governed write after it records card
acceptance. A failed write is visible and settles the proposal as
rejected.
- Concurrent proposal and card resolution must use
proposal-before-interaction lock order. A race test covers direct
approval against card rejection.
- The new audit event increases activity rows for newly added agent
bindings. It does not include secret values or fingerprints.
- The card includes only safe proposal metadata. It does not include
secret value, fingerprint, version, or internal secret identifiers.
- The UI uses the stitched resolution result. Focused tests cover
immediate cache replacement and every terminal state.

> This work extends an existing completed roadmap capability. The GitHub
duplicate search returned no other open related work.

## Model Used

- OpenAI Codex with model ID `gpt-5`. The runtime did not expose its
context-window size. Reasoning, repository tools, code execution,
database integration tests, UI rendering, and GitHub tools were enabled.

## 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>
2026-08-18 09:44:24 -05:00

18 KiB

title, summary
title summary
Secrets Secrets CRUD

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
{
  "secrets": [
    {
      "key": "github_token",
      "secretRef": "11111111-1111-4111-8111-111111111111",
      "name": "GitHub token",
      "description": null,
      "delivery": "env",
      "projectionClass": "unclassified",
      "latestVersion": 2,
      "versionSelector": "latest",
      "resolvedVersion": 2
    }
  ]
}

delivery is env, api, or both. secretRef is a stable opaque handle, not secret material or a capability; every consuming route re-authorizes it. The list never returns values, the internal secretId field, 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
{
  "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.

Agent Secret Proposals

These routes use the same current run-bound agent JWT as the list and fetch routes:

POST /api/agents/me/secret-proposals
GET /api/agents/me/secret-proposals
DELETE /api/agents/me/secret-proposals/{proposalId}

An agent can ask Paperclip to bind an existing secret under a new path without knowing a secret ID. Set kind to binding and identify the source by the agent's own existing env.* or access.* config path:

POST /api/agents/me/secret-proposals
{
  "kind": "binding",
  "sourceConfigPath": "access.openai_api_key",
  "configPath": "access.evals_openai_api_key",
  "justification": "Use the existing OpenAI credential under the eval-specific alias"
}

sourceConfigPath must resolve from the proposing agent's own binding. An unknown path or another agent's path returns 404. A binding request must provide exactly one of sourceConfigPath, secretId, or secretProposalId. Omit targetAgentId to target the proposing agent; under the default policy a manager may instead target one of its reports. configPath accepts env.<KEY> for environment injection or access.<ALIAS> for API-only access.

For a run with a checked-out origin issue, a successful proposal automatically creates a human-only Confirm secret binding card in that issue. API clients must not create a second interaction. The card contains only non-secret metadata: the source label, target agent, new config path, justification, and expiry.

Selecting Create binding accepts the card and then triggers a separate, freshly authorized binding write. Card acceptance is not execution. Read result.secretProposal.status for the actual outcome:

  • executed means the binding write completed.
  • failed means the card was accepted but execution failed. The card renders FAILED, exposes a non-secret errorCode, and Paperclip posts a Secret binding execution failed comment with Binding created: no.
  • rejected, withdrawn, or expired means no binding was created.

The card wakes the issue assignee after resolution. The wake payload includes secretProposal.configPath, decision, executionStatus, and instructions. After any secret card, call GET /api/agents/me/secrets again and confirm the expected secret metadata and delivery before using the new binding. Acceptance is not execution; a failed wake or missing metadata means the alias must be treated as unavailable until a fresh proposal executes successfully.

List Secrets

GET /api/companies/{companyId}/secrets

Returns secret metadata (not decrypted values).

Create Secret

POST /api/companies/{companyId}/secrets
{
  "name": "anthropic-api-key",
  "value": "sk-ant-..."
}

The value is encrypted at rest. Only the secret ID and metadata are returned.

To link a provider-owned secret without copying the value into Paperclip, create an external-reference secret:

{
  "name": "prod-stripe-key",
  "provider": "aws_secrets_manager",
  "managedMode": "external_reference",
  "externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/stripe",
  "providerVersionRef": "version-id-or-label"
}

Paperclip stores the provider reference and a non-sensitive fingerprint only. The value is resolved, when the provider is configured, through the server runtime path that enforces binding context and records access events.

Provider Health

GET /api/companies/{companyId}/secret-providers/health

Returns provider setup diagnostics, warnings, and local backup guidance. Health responses must not include secret values or provider credentials.

For aws_secrets_manager, an unready health response names the missing non-secret provider environment variables, the AWS SDK default credential source expected by the server runtime, and the custody rule that AWS bootstrap credentials must not be stored in Paperclip company_secrets.

The equivalent CLI check is:

npx paperclipai secrets doctor --company-id {companyId}

Provider Vaults

Provider vaults are named, company-scoped configurations that route secret material to one of the supported provider backends. See the secrets deploy guide for the operator model and custody rules.

All routes below require board auth and company access. Mutating routes emit secret_provider_config.* activity-log entries. No route in this surface returns provider credential values; submitting credential-shaped fields in config is rejected at validation time.

List Vaults

GET /api/companies/{companyId}/secret-provider-configs

Returns every vault for the company (including disabled rows for audit), each with id, provider, displayName, status, isDefault, non-sensitive config, latest health snapshot (healthStatus, healthCheckedAt, healthMessage, healthDetails), disabledAt, and audit columns.

Create Vault

POST /api/companies/{companyId}/secret-provider-configs
{
  "provider": "aws_secrets_manager",
  "displayName": "Prod US-East",
  "isDefault": true,
  "config": {
    "region": "us-east-1",
    "namespace": "paperclip",
    "secretNamePrefix": "paperclip",
    "kmsKeyId": "arn:aws:kms:us-east-1:123456789012:key/abcd-...",
    "environmentTag": "production"
  }
}

Per-provider config shapes:

  • local_encrypted: optional backupReminderAcknowledged: boolean.
  • aws_secrets_manager: required region; optional namespace, secretNamePrefix, kmsKeyId, ownerTag, environmentTag.
  • gcp_secret_manager (coming soon): optional projectId, location, namespace, secretNamePrefix.
  • vault (coming soon): optional origin-only HTTPS address, namespace, mountPath, secretPathPrefix. address values with embedded credentials, paths, query strings, or fragments are rejected.

status defaults to ready for local_encrypted and aws_secrets_manager, and to coming_soon for gcp_secret_manager and vault. Coming-soon and disabled vaults cannot be marked isDefault. Setting isDefault: true clears the previous default for the same provider in the same transaction.

Get Vault

GET /api/secret-provider-configs/{id}

Update Vault

PATCH /api/secret-provider-configs/{id}
{
  "displayName": "Prod US-East-2",
  "config": {
    "region": "us-east-2",
    "kmsKeyId": "arn:aws:kms:us-east-2:123456789012:key/abcd-..."
  }
}

config is replaced wholesale on update — pass the full provider config payload, not a partial diff. Status transitions for gcp_secret_manager and vault are constrained to coming_soon and disabled until their runtime modules ship.

Disable Vault

DELETE /api/secret-provider-configs/{id}

Soft-deletes the vault: status flips to disabled, isDefault clears, and disabledAt is stamped. Disabled vaults remain in GET results for audit purposes but are no longer offered in the secret create/rotate flow.

Set Default

POST /api/secret-provider-configs/{id}/default

Marks the target vault as the default for its provider family and clears the previous default. Returns 422 when the target is coming_soon or disabled.

Run Health Check

POST /api/secret-provider-configs/{id}/health

Runs a provider-specific health probe and persists the result on the vault. Response shape:

{
  "configId": "<uuid>",
  "provider": "aws_secrets_manager",
  "status": "ready" | "warning" | "error" | "coming_soon" | "disabled",
  "message": "Provider vault is ready to handle managed writes",
  "details": {
    "code": "provider_ready",
    "message": "...",
    "guidance": ["..."]
  },
  "checkedAt": "2026-05-06T14:00:00.000Z"
}

Health responses never include provider credentials or secret values. For AWS vaults, details.guidance may include missing non-secret env names and the expected AWS SDK credential source; coming-soon vaults always return status: "coming_soon" with code: "runtime_locked" and never call into provider modules.

Selecting A Vault When Creating Or Rotating Secrets

POST /api/companies/{companyId}/secrets and POST /api/secrets/{secretId}/rotate both accept an optional providerConfigId field that pins the secret to a specific vault. When omitted (or null), the operation runs through the deployment-level provider configuration — the same path existing installs already use. The board UI preselects the company's default vault for the chosen provider before submitting, so callers should usually send an explicit providerConfigId. Coming-soon and disabled vaults are rejected with a 422; a vault that does not match the secret's provider is rejected the same way.

POST /api/companies/{companyId}/secrets
{
  "name": "prod-stripe-key",
  "provider": "aws_secrets_manager",
  "providerConfigId": "<vault-uuid>",
  "managedMode": "external_reference",
  "externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/stripe"
}

Response Redaction Rules

Every route in this surface enforces the same redaction contract:

  • Secret values are never returned. The board UI never has a "reveal value" affordance; resolution happens server-side at runtime under a binding.
  • Provider credential values are never accepted, stored, returned, logged, or echoed in error messages. Submitting credential-shaped fields fails validation with a non-leaking error.
  • Activity log entries record vault id, provider, displayName, status, and isDefault transitions — never config payloads or health detail bodies.

Remote Import From AWS Secrets Manager

Remote import links existing AWS Secrets Manager entries into Paperclip as external_reference secrets. Import stores provider reference metadata only; it does not copy the remote secret plaintext into Paperclip.

The routes are board-only and company-scoped. providerConfigId must point to a same-company AWS provider vault with status ready or warning. Disabled, coming-soon, non-AWS, and cross-company vaults are rejected. Imported secrets resolve later through the selected vault, so runtime reads still need secretsmanager:GetSecretValue and any required KMS decrypt permission on the selected external secret.

Preview Remote Import Candidates

POST /api/companies/{companyId}/secrets/remote-import/preview
{
  "providerConfigId": "<aws-vault-uuid>",
  "query": "stripe",
  "nextToken": "opaque-provider-token",
  "pageSize": 50
}

query is optional and is passed to AWS Secrets Manager inventory filtering. Treat it as non-secret metadata because AWS may record list request parameters in CloudTrail. nextToken is an opaque AWS cursor; callers must pass it back unchanged and must not synthesize offsets. pageSize is optional, defaults to 50 in the UI, and is capped at 100.

Preview uses AWS ListSecrets only. It must not call GetSecretValue or BatchGetSecretValue, must not request SecretString, and must not require KMS decrypt. The response contains sanitized metadata for display and conflict decisions:

{
  "providerConfigId": "<aws-vault-uuid>",
  "provider": "aws_secrets_manager",
  "nextToken": null,
  "candidates": [
    {
      "externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
      "remoteName": "prod/stripe",
      "name": "prod/stripe",
      "key": "prod-stripe",
      "providerVersionRef": null,
      "providerMetadata": {
        "createdDate": "2026-05-06T00:00:00.000Z",
        "lastChangedDate": "2026-05-06T00:00:00.000Z",
        "hasDescription": true,
        "hasKmsKey": true,
        "tagCount": 3
      },
      "status": "ready",
      "importable": true,
      "conflicts": []
    }
  ]
}

Candidate statuses:

  • ready: the row can be selected for import.
  • duplicate: a Paperclip secret already links the same canonical provider reference for the same provider vault.
  • conflict: the row has a name/key collision or provider guardrail failure.

Conflict types are exact_reference, name, key, and provider_guardrail. AWS refs under Paperclip's own managed namespace are blocked as external references; use the Paperclip-managed secret flow for those resources instead.

Import Selected Remote References

POST /api/companies/{companyId}/secrets/remote-import
{
  "providerConfigId": "<aws-vault-uuid>",
  "secrets": [
    {
      "externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
      "name": "Stripe production key",
      "key": "stripe-production-key",
      "description": "Stripe key used by production checkout",
      "providerVersionRef": null,
      "providerMetadata": {
        "createdDate": "2026-05-06T00:00:00.000Z"
      }
    }
  ]
}

The secrets array accepts 1-100 rows. Each row may override the suggested Paperclip name, key, optional Paperclip description, providerVersionRef, and sanitized providerMetadata. Blank descriptions are stored as null; AWS provider descriptions are not copied into Paperclip descriptions. The backend re-checks duplicate refs and name/key conflicts at submit time; a stale preview does not bypass those checks.

The import response is row-level:

{
  "providerConfigId": "<aws-vault-uuid>",
  "provider": "aws_secrets_manager",
  "importedCount": 1,
  "skippedCount": 1,
  "errorCount": 0,
  "results": [
    {
      "externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
      "name": "Stripe production key",
      "key": "stripe-production-key",
      "status": "imported",
      "reason": null,
      "secretId": "<paperclip-secret-id>",
      "conflicts": []
    }
  ]
}

Row statuses:

  • imported: Paperclip created an active external_reference secret and one metadata-only version row.
  • skipped: the row had an exact-reference duplicate or name/key conflict.
  • error: the provider rejected the reference or the row failed validation.

Activity logs for preview/import store aggregate counts, provider id, and vault id only. They must not store remote secret names, ARNs, descriptions, tags, plaintext values, provider credentials, or raw AWS error blobs.

Rotate Secret

POST /api/secrets/{secretId}/rotate
{
  "value": "sk-ant-new-value..."
}

Creates a new version of the secret. Agents referencing "version": "latest" automatically get the new value on next heartbeat. Pin to a specific version when a bad latest rollout would affect many agents at once.

Using Secrets in Agent Config

Reference secrets in agent adapter config instead of inline values:

{
  "env": {
    "ANTHROPIC_API_KEY": {
      "type": "secret_ref",
      "secretId": "{secretId}",
      "version": "latest"
    }
  }
}

The server resolves and decrypts secret references at runtime, injecting the real value into the agent process environment. Paperclip's custody guarantees end at injection: the agent process can read, log, or forward the value, so treat any secret bound to an agent as exposed to that agent. See the custody boundaries note in the secrets deploy guide.

User-specific env bindings use a definition key instead of a concrete secretId. The concrete value is resolved for the run's responsible user:

{
  "env": {
    "GITHUB_TOKEN": {
      "type": "user_secret_ref",
      "key": "github_api_token",
      "version": "latest",
      "required": true,
      "allowMissingOverride": false
    }
  }
}

required defaults to true and allowMissingOverride defaults to false. Missing required user-secret values must fail closed before adapter dispatch. Optional missing values omit the environment variable; they must not inject an empty string or another user's value. Paperclip records value-free access events with secretScope, responsibleUserId, credentialOwnerUserId, and userSecretDefinitionId.

Portability

Company export/import APIs represent agent and project environment requirements as declarations in the package manifest. Exports omit secret values, secret IDs, provider references, and encrypted provider material. Use:

npx paperclipai secrets declarations --company-id {companyId}

to inspect the declarations that an export would emit before moving a package.