Files
PaperClipAI/ui/src/pages/secrets/ProposalsTab.tsx
T
e43f187cad feat(secrets): add human-approved secret proposals (#9934)
## 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>
2026-08-05 21:49:40 -05:00

214 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import {
AlertCircle,
ArrowRight,
ExternalLink,
Inbox,
KeyRound,
Link2,
Loader2,
} from "lucide-react";
import type { CompanySecretProviderConfig, SecretProposalView } from "@paperclipai/shared";
import { secretsApi } from "../../api/secrets";
import { queryKeys } from "../../lib/queryKeys";
import { cn } from "../../lib/utils";
import { EmptyState } from "../../components/EmptyState";
import { SecretPathName } from "./SecretPathName";
import {
AgentRefChip,
DeliveryBadge,
FingerprintChip,
ProposalActions,
ProposalJustification,
ProposedBadge,
bindingEnvKey,
bindingSecretLabel,
useProposalReview,
} from "./proposal-review";
/** ISO expiry → "expires in 12d" / "expires in 5h" / "expired". */
function expiryLabel(expiresAt: string): { text: string; urgent: boolean } {
const ms = new Date(expiresAt).getTime() - Date.now();
if (Number.isNaN(ms)) return { text: "no expiry", urgent: false };
if (ms <= 0) return { text: "expired", urgent: true };
const hours = Math.floor(ms / 3_600_000);
if (hours < 24) return { text: `expires in ${hours}h`, urgent: true };
const days = Math.floor(hours / 24);
return { text: `expires in ${days}d`, urgent: days <= 2 };
}
function ProposalRow({
proposal,
onApprove,
onReject,
disabled,
}: {
proposal: SecretProposalView;
onApprove: (p: SecretProposalView) => void;
onReject: (p: SecretProposalView) => void;
disabled: boolean;
}) {
const isSecret = proposal.kind === "secret";
const expiry = expiryLabel(proposal.expiresAt);
const secret = bindingSecretLabel(proposal);
const envKey = bindingEnvKey(proposal);
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-3 sm:flex-row sm:items-start sm:gap-3">
<span
className="mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-muted/40 text-muted-foreground"
aria-hidden="true"
>
{isSecret ? <KeyRound className="size-3.5" /> : <Link2 className="size-3.5" />}
</span>
<div className="min-w-0 flex-1 space-y-1.5">
{/* Headline */}
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{isSecret ? (
<SecretPathName name={proposal.proposedName ?? "—"} className="text-sm" />
) : (
<span className="flex min-w-0 flex-wrap items-center gap-1.5 text-sm">
{proposal.target ? (
<AgentRefChip agent={proposal.target} className="font-medium" />
) : (
<span className="text-muted-foreground">agent</span>
)}
<DeliveryBadge configPath={proposal.configPath} />
<code className="font-mono text-xs">{envKey || proposal.configPath}</code>
<ArrowRight className="size-3 text-muted-foreground" />
<span className="inline-flex items-center gap-1">
<KeyRound className="size-3 text-muted-foreground" />
<span className="font-medium">{secret.name}</span>
{secret.pending ? <ProposedBadge /> : null}
</span>
</span>
)}
</div>
{/* Provenance meta */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
by <AgentRefChip agent={proposal.proposedBy} className="font-medium text-foreground" />
</span>
{proposal.originIssue ? (
<>
<span aria-hidden="true">·</span>
<Link
to={`/issues/${proposal.originIssue.key}`}
className="inline-flex items-center gap-1 font-mono hover:text-foreground hover:underline"
title={proposal.originIssue.title}
>
{proposal.originIssue.key}
<ExternalLink className="size-3" />
</Link>
</>
) : null}
{isSecret ? (
<>
<span aria-hidden="true">·</span>
<FingerprintChip
fingerprint={proposal.valueFingerprintSha256}
length={proposal.valueLength}
/>
</>
) : null}
<span aria-hidden="true">·</span>
<span className={cn(expiry.urgent && "text-amber-600 dark:text-amber-400")}>
{expiry.text}
</span>
</div>
{/* Justification (agent-authored — framed as an untrusted claim). */}
<ProposalJustification justification={proposal.justification} />
</div>
<div className="shrink-0 sm:pt-0.5">
<ProposalActions
proposal={proposal}
onApprove={onApprove}
onReject={onReject}
disabled={disabled}
/>
</div>
</div>
);
}
export function ProposalsTab({
companyId,
providerConfigs,
}: {
companyId: string;
providerConfigs: CompanySecretProviderConfig[];
}) {
const proposalsQuery = useQuery({
queryKey: queryKeys.secrets.proposals(companyId, "pending"),
queryFn: () => secretsApi.listProposals(companyId, "pending"),
enabled: Boolean(companyId),
});
const review = useProposalReview(companyId, providerConfigs);
const proposals = proposalsQuery.data ?? EMPTY_PROPOSALS;
// Secret proposals first, then bindings — an approver usually lands the
// secret before (or alongside) the bindings that depend on it.
const sorted = useMemo(
() =>
[...proposals].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "secret" ? -1 : 1;
return a.createdAt.localeCompare(b.createdAt);
}),
[proposals],
);
if (proposalsQuery.isError) {
return (
<div className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="size-4" /> Couldn’t load proposals. Try again.
</div>
);
}
if (proposalsQuery.isPending) {
return (
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> Loading proposals…
</div>
);
}
if (sorted.length === 0) {
return (
<EmptyState
icon={Inbox}
title="No pending proposals"
message="When an agent proposes a secret or an access binding, it shows up here for review."
/>
);
}
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Agents propose credentials and access bindings; you approve or reject them here. Proposed
values are never shown — only a fingerprint and length.
</p>
{sorted.map((proposal) => (
<ProposalRow
key={proposal.id}
proposal={proposal}
onApprove={review.requestApprove}
onReject={review.requestReject}
disabled={review.isBusy}
/>
))}
{review.dialogs}
</div>
);
}
const EMPTY_PROPOSALS: SecretProposalView[] = [];