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>
This commit is contained in:
Dotta 2026-08-05 21:49:40 -05:00 committed by GitHub
parent f950952de7
commit e43f187cad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 42693 additions and 77 deletions

View File

@ -0,0 +1,44 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import { applyPendingMigrations } from "./client.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
const MIGRATION_FILE = "0207_moaning_amazoness.sql";
const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function migrationHash(): Promise<string> {
const content = await fs.promises.readFile(new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), "utf8");
return createHash("sha256").update(content).digest("hex");
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres company secret proposals migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("company secret proposals migration", () => {
afterEach(async () => Promise.all(cleanups.splice(0).map((cleanup) => cleanup())));
it("can be reapplied after its migration journal entry is removed", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-secret-proposals-migration-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1 });
cleanups.push(async () => sql.end());
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await expect(applyPendingMigrations(database.connectionString)).resolves.toBeUndefined();
const [result] = await sql<{ constraints: number; indexes: number }[]>`
SELECT
(SELECT count(*)::int FROM pg_constraint WHERE conrelid = 'company_secret_proposals'::regclass AND contype <> 'n') AS constraints,
(SELECT count(*)::int FROM pg_indexes WHERE tablename = 'company_secret_proposals') AS indexes
`;
expect(result).toEqual({ constraints: 13, indexes: 5 });
});
});

View File

@ -0,0 +1,94 @@
CREATE TABLE IF NOT EXISTS "company_secret_proposals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"kind" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"proposed_name" text,
"proposed_key" text,
"proposed_description" text,
"justification" text NOT NULL,
"value_ciphertext" jsonb,
"value_fingerprint_sha256" text,
"value_length" integer,
"secret_id" uuid,
"secret_proposal_id" uuid,
"target_type" text,
"target_id" uuid,
"config_path" text,
"projection_class" text DEFAULT 'unclassified' NOT NULL,
"binding_target_policy_snapshot" text,
"proposer_ancestor_ids_snapshot" jsonb,
"target_ancestor_ids_snapshot" jsonb,
"proposed_by_agent_id" uuid NOT NULL,
"origin_issue_id" uuid,
"origin_run_id" uuid NOT NULL,
"resolved_by_user_id" text,
"resolved_at" timestamp with time zone,
"resolution_reason" text,
"created_secret_id" uuid,
"applied_binding_config_path" text,
"ciphertext_scrubbed_at" timestamp with time zone,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "company_secret_proposals_kind_check" CHECK ("kind" in ('secret', 'binding')),
CONSTRAINT "company_secret_proposals_status_check" CHECK ("status" in ('pending', 'approved', 'rejected', 'withdrawn', 'expired')),
CONSTRAINT "company_secret_proposals_projection_check" CHECK ("projection_class" = 'unclassified'),
CONSTRAINT "company_secret_proposals_shape_check" CHECK (("kind" = 'secret' and "proposed_name" is not null and "proposed_key" is not null and "secret_id" is null and "secret_proposal_id" is null and "target_type" is null and "target_id" is null and "config_path" is null) or ("kind" = 'binding' and (("secret_id" is not null)::int + ("secret_proposal_id" is not null)::int) = 1 and "target_type" = 'agent' and "target_id" is not null and "config_path" is not null))
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_proposals_company_id_companies_id_fk' AND conrelid = 'company_secret_proposals'::regclass) THEN
ALTER TABLE "company_secret_proposals" ADD CONSTRAINT "company_secret_proposals_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "companies"("id");
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_proposals_secret_id_company_secrets_id_fk' AND conrelid = 'company_secret_proposals'::regclass) THEN
ALTER TABLE "company_secret_proposals" ADD CONSTRAINT "company_secret_proposals_secret_id_company_secrets_id_fk" FOREIGN KEY ("secret_id") REFERENCES "company_secrets"("id") ON DELETE set null;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_proposals_secret_proposal_id_company_secret_proposals_id_fk' AND conrelid = 'company_secret_proposals'::regclass) THEN
ALTER TABLE "company_secret_proposals" ADD CONSTRAINT "company_secret_proposals_secret_proposal_id_company_secret_proposals_id_fk" FOREIGN KEY ("secret_proposal_id") REFERENCES "company_secret_proposals"("id") ON DELETE cascade;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_proposals_target_id_agents_id_fk' AND conrelid = 'company_secret_proposals'::regclass) THEN
ALTER TABLE "company_secret_proposals" ADD CONSTRAINT "company_secret_proposals_target_id_agents_id_fk" FOREIGN KEY ("target_id") REFERENCES "agents"("id") ON DELETE cascade;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_proposals_proposed_by_agent_id_agents_id_fk' AND conrelid = 'company_secret_proposals'::regclass) THEN
ALTER TABLE "company_secret_proposals" ADD CONSTRAINT "company_secret_proposals_proposed_by_agent_id_agents_id_fk" FOREIGN KEY ("proposed_by_agent_id") REFERENCES "agents"("id") ON DELETE cascade;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_proposals_origin_issue_id_issues_id_fk' AND conrelid = 'company_secret_proposals'::regclass) THEN
ALTER TABLE "company_secret_proposals" ADD CONSTRAINT "company_secret_proposals_origin_issue_id_issues_id_fk" FOREIGN KEY ("origin_issue_id") REFERENCES "issues"("id") ON DELETE set null;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_proposals_origin_run_id_heartbeat_runs_id_fk' AND conrelid = 'company_secret_proposals'::regclass) THEN
ALTER TABLE "company_secret_proposals" ADD CONSTRAINT "company_secret_proposals_origin_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("origin_run_id") REFERENCES "heartbeat_runs"("id") ON DELETE cascade;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'company_secret_proposals_created_secret_id_company_secrets_id_fk' AND conrelid = 'company_secret_proposals'::regclass) THEN
ALTER TABLE "company_secret_proposals" ADD CONSTRAINT "company_secret_proposals_created_secret_id_company_secrets_id_fk" FOREIGN KEY ("created_secret_id") REFERENCES "company_secrets"("id") ON DELETE set null;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "company_secret_proposals_company_status_idx" ON "company_secret_proposals" ("company_id", "status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "company_secret_proposals_proposer_status_idx" ON "company_secret_proposals" ("proposed_by_agent_id", "status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "company_secret_proposals_expiry_idx" ON "company_secret_proposals" ("status", "expires_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "company_secret_proposals_secret_proposal_idx" ON "company_secret_proposals" ("secret_proposal_id");

File diff suppressed because it is too large Load Diff

View File

@ -1436,6 +1436,13 @@
"when": 1785853648732,
"tag": "0206_review_path_recovery_idempotency_index",
"breakpoints": true
},
{
"idx": 207,
"version": "7",
"when": 1785903493352,
"tag": "0207_moaning_amazoness",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,70 @@
import { sql } from "drizzle-orm";
import { type AnyPgColumn, check, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { companySecrets } from "./company_secrets.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { issues } from "./issues.js";
export const companySecretProposals = pgTable(
"company_secret_proposals",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
kind: text("kind").notNull(),
status: text("status").notNull().default("pending"),
proposedName: text("proposed_name"),
proposedKey: text("proposed_key"),
proposedDescription: text("proposed_description"),
justification: text("justification").notNull(),
valueCiphertext: jsonb("value_ciphertext").$type<Record<string, unknown> | null>(),
valueFingerprintSha256: text("value_fingerprint_sha256"),
valueLength: integer("value_length"),
secretId: uuid("secret_id").references(() => companySecrets.id, { onDelete: "set null" }),
secretProposalId: uuid("secret_proposal_id").references((): AnyPgColumn => companySecretProposals.id, { onDelete: "cascade" }),
targetType: text("target_type"),
targetId: uuid("target_id").references(() => agents.id, { onDelete: "cascade" }),
configPath: text("config_path"),
projectionClass: text("projection_class").notNull().default("unclassified"),
bindingTargetPolicySnapshot: text("binding_target_policy_snapshot"),
proposerAncestorIdsSnapshot: jsonb("proposer_ancestor_ids_snapshot").$type<string[] | null>(),
targetAncestorIdsSnapshot: jsonb("target_ancestor_ids_snapshot").$type<string[] | null>(),
proposedByAgentId: uuid("proposed_by_agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
originIssueId: uuid("origin_issue_id").references(() => issues.id, { onDelete: "set null" }),
originRunId: uuid("origin_run_id").notNull().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
resolvedByUserId: text("resolved_by_user_id"),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
resolutionReason: text("resolution_reason"),
createdSecretId: uuid("created_secret_id").references(() => companySecrets.id, { onDelete: "set null" }),
appliedBindingConfigPath: text("applied_binding_config_path"),
ciphertextScrubbedAt: timestamp("ciphertext_scrubbed_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyStatusIdx: index("company_secret_proposals_company_status_idx").on(table.companyId, table.status),
proposerStatusIdx: index("company_secret_proposals_proposer_status_idx").on(table.proposedByAgentId, table.status),
expiryIdx: index("company_secret_proposals_expiry_idx").on(table.status, table.expiresAt),
secretProposalIdx: index("company_secret_proposals_secret_proposal_idx").on(table.secretProposalId),
kindCheck: check("company_secret_proposals_kind_check", sql`${table.kind} in ('secret', 'binding')`),
statusCheck: check("company_secret_proposals_status_check", sql`${table.status} in ('pending', 'approved', 'rejected', 'withdrawn', 'expired')`),
projectionCheck: check("company_secret_proposals_projection_check", sql`${table.projectionClass} = 'unclassified'`),
shapeCheck: check("company_secret_proposals_shape_check", sql`(
${table.kind} = 'secret'
and ${table.proposedName} is not null
and ${table.proposedKey} is not null
and ${table.secretId} is null
and ${table.secretProposalId} is null
and ${table.targetType} is null
and ${table.targetId} is null
and ${table.configPath} is null
) or (
${table.kind} = 'binding'
and ((${table.secretId} is not null)::int + (${table.secretProposalId} is not null)::int) = 1
and ${table.targetType} = 'agent'
and ${table.targetId} is not null
and ${table.configPath} is not null
)`),
}),
);

View File

@ -119,6 +119,7 @@ export { userSecretDefinitions } from "./user_secret_definitions.js";
export { companySecrets } from "./company_secrets.js";
export { companySecretVersions } from "./company_secret_versions.js";
export { companySecretBindings } from "./company_secret_bindings.js";
export { companySecretProposals } from "./company_secret_proposals.js";
export { userSecretDeclarations } from "./user_secret_declarations.js";
export { secretAccessEvents } from "./secret_access_events.js";
export {

View File

@ -1191,6 +1191,13 @@ export type {
SecretStatus,
SecretVersionSelector,
SecretVersionStatus,
SecretProposalKind,
SecretProposalStatus,
SecretProposalAgentRef,
SecretProposalIssueRef,
SecretProposalView,
ApproveSecretProposalInput,
RejectSecretProposalInput,
ConnectToolAppResult,
ToolOAuthStartResult,
ToolActionRequest,

View File

@ -753,6 +753,13 @@ export type {
SecretProviderDescriptor,
SecretStatus,
SecretVersionStatus,
SecretProposalKind,
SecretProposalStatus,
SecretProposalAgentRef,
SecretProposalIssueRef,
SecretProposalView,
ApproveSecretProposalInput,
RejectSecretProposalInput,
} from "./secrets.js";
export type {
Routine,

View File

@ -349,3 +349,109 @@ export interface RemoteSecretImportResult {
errorCount: number;
results: RemoteSecretImportRowResult[];
}
/* -------------------------------------------------------------------------- */
/* Proposed secrets & bindings (PAP-14731) */
/* -------------------------------------------------------------------------- */
export type SecretProposalKind = "secret" | "binding";
export type SecretProposalStatus =
| "pending"
| "approved"
| "rejected"
| "withdrawn"
| "expired";
/** Minimal agent reference surfaced on a proposal (proposer / binding target). */
export interface SecretProposalAgentRef {
id: string;
name: string;
/** lucide icon slug, if the agent has one. */
icon: string | null;
}
/** Provenance link to the issue a proposal originated from. */
export interface SecretProposalIssueRef {
id: string;
/** Human key, e.g. `PAP-14743`. */
key: string;
title: string;
}
/**
* Board-facing view of a secret/binding proposal. The proposed value is NEVER
* included secret-kind proposals expose only `valueFingerprintSha256` and
* `valueLength`, mirroring the no-human-value-read posture (plan §Security 4).
*/
export interface SecretProposalView {
id: string;
companyId: string;
kind: SecretProposalKind;
status: SecretProposalStatus;
justification: string;
// --- secret-kind ---
proposedName: string | null;
proposedKey: string | null;
proposedDescription: string | null;
valueFingerprintSha256: string | null;
valueLength: number | null;
// --- binding-kind ---
/** Set when the binding references an existing live secret. */
secretId: string | null;
/** Resolved name of the live secret referenced by `secretId`, for display. */
secretName: string | null;
/** Set when the binding depends on a still-pending secret proposal (cascade pairing). */
secretProposalId: string | null;
/** Resolved proposed name of the dependency secret proposal, for display. */
secretProposalName: string | null;
/** Binding target type (`"agent"` in v1). */
targetType: SecretBindingTargetType | null;
/** Resolved target agent for the binding. */
target: SecretProposalAgentRef | null;
/** Delivery path: `env.<KEY>` (env var) or `access.<ALIAS>` (agent API). */
configPath: string | null;
// --- provenance ---
proposedBy: SecretProposalAgentRef;
originIssue: SecretProposalIssueRef | null;
originRunId: string;
/** ISO timestamp; pending proposals auto-expire (default 14d). */
expiresAt: string;
createdAt: string;
// --- resolution (terminal statuses only) ---
resolvedByUserId: string | null;
resolvedAt: string | null;
resolutionReason: string | null;
createdSecretId: string | null;
appliedBindingConfigPath: string | null;
/**
* Server-computed permission preflight for the current viewer, mirroring the
* exact authz the approve path enforces. Secret-kind approval uses the same
* company-secret write boundary as the normal company-secret create route;
* binding-kind approval additionally requires `agent_config:update` on the
* target agent. Approve is disabled with `approveBlockReason` shown when this
* is `false`.
*/
viewerCanApprove: boolean;
approveBlockReason: string | null;
}
/** Approve body: cascade a proposed dependency secret and/or re-folder/rename before landing. */
export interface ApproveSecretProposalInput {
/** Approve a pending dependency secret proposal in the same transaction. */
cascade?: boolean;
/** Re-folder / rename / re-provider a secret-kind proposal before it lands. */
overrides?: {
name?: string;
description?: string | null;
providerConfigId?: string | null;
};
}
export interface RejectSecretProposalInput {
reason: string;
}

View File

@ -28,6 +28,10 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
listCompanyIds: vi.fn(),
}));
const mockRunSecretRedactionRegistry = vi.hoisted(() => ({
redactForRun: vi.fn(async (_companyId: string, _runId: string, value: unknown) => value),
}));
const routeAgentId = "11111111-1111-4111-8111-111111111111";
function registerModuleMocks() {
@ -49,6 +53,10 @@ function registerModuleMocks() {
issueService: () => mockIssueService,
}));
vi.doMock("../services/run-secret-redaction.js", () => ({
createRunSecretRedactionRegistry: () => mockRunSecretRedactionRegistry,
}));
vi.doMock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => ({}),

View File

@ -22,6 +22,7 @@ import { LOW_TRUST_REVIEW_PRESET, type AgentApiKeyScope } from "@paperclipai/sha
import { errorHandler } from "../middleware/error-handler.js";
import { secretRoutes } from "../routes/secrets.js";
import { secretService } from "../services/secrets.js";
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@ -196,6 +197,11 @@ describeEmbeddedPostgres("agent secret routes", () => {
expect(fetched.status).toBe(200);
expect(fetched.headers["cache-control"]).toBe("no-store");
expect(fetched.body).toEqual({ key: "env_only_key", value: "env-secret-value", version: 1 });
const [registeredRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.heartbeatRunId));
expect(JSON.stringify(registeredRun.contextSnapshot)).not.toContain("env-secret-value");
expect(registeredRun.contextSnapshot).toMatchObject({
paperclipSecretRedactions: [expect.objectContaining({ fingerprintSha256: expect.any(String), material: expect.any(Object) })],
});
expect(await db.select().from(secretAccessEvents)).toEqual([
expect.objectContaining({ secretId: envSecret.id, outcome: "success", consumerType: "agent_api" }),
]);
@ -212,14 +218,36 @@ describeEmbeddedPostgres("agent secret routes", () => {
const denied = await request(createApp(fixture)).post("/api/agents/me/secrets/unbound_key/value");
expect(denied.status).toBe(403);
expect(await db.select().from(secretAccessEvents)).toEqual(expect.arrayContaining([
expect.objectContaining({ secretId: unboundSecret.id, outcome: "failure", errorCode: "binding_missing" }),
expect(await db.select().from(secretAccessEvents)).not.toEqual(expect.arrayContaining([
expect.objectContaining({ secretId: unboundSecret.id }),
]));
expect(await db.select().from(activityLog)).toEqual(expect.arrayContaining([
expect(await db.select().from(activityLog)).not.toEqual(expect.arrayContaining([
expect.objectContaining({ action: "secret.value.read", entityId: unboundSecret.id }),
]));
});
it("fails closed when run redaction registration cannot be persisted", async () => {
const fixture = await seedAgentRun();
await expect(createRunSecretRedactionRegistry(db).register(fixture.companyId, randomUUID(), "must-not-return"))
.rejects.toThrow("Heartbeat run redaction registration failed");
});
it("deduplicates concurrent redaction registrations for the same run and value", async () => {
const fixture = await seedAgentRun();
const registry = createRunSecretRedactionRegistry(db);
await Promise.all([
registry.register(fixture.companyId, fixture.heartbeatRunId, "duplicate-secret"),
registry.register(fixture.companyId, fixture.heartbeatRunId, "duplicate-secret"),
]);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.heartbeatRunId));
expect(run.contextSnapshot).toMatchObject({
paperclipSecretRedactions: [expect.objectContaining({ fingerprintSha256: expect.any(String) })],
});
expect((run.contextSnapshot as { paperclipSecretRedactions: unknown[] }).paperclipSecretRedactions).toHaveLength(1);
});
it("denies low-trust, task-bridge, and skill-test callers on both routes", async () => {
const lowTrust = await seedAgentRun({
trustPreset: LOW_TRUST_REVIEW_PRESET,

View File

@ -2412,4 +2412,32 @@ describeEmbeddedPostgres("authorization service", () => {
resource: { type: "company", companyId: company.id },
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
});
it("denies secrets proposals for scoped tokens regardless of request source", async () => {
const company = await createCompany(db, "SecretProposalScopes");
const actorAgent = await createAgent(db, company.id);
const resource = { type: "company" as const, companyId: company.id };
const authz = authorizationService(db);
for (const actor of [
{
type: "agent" as const,
agentId: actorAgent.id,
companyId: company.id,
source: "agent_jwt" as const,
keyId: randomUUID(),
keyScope: { kind: "task_bridge" as const, parentIssueId: randomUUID() },
},
{
type: "agent" as const,
agentId: actorAgent.id,
companyId: company.id,
source: "agent_jwt" as const,
keyScope: { kind: "skill_test" as const, issueId: randomUUID() },
},
]) {
await expect(authz.decide({ actor, action: "secrets:propose", resource }))
.resolves.toMatchObject({ allowed: false, reason: "deny_scope" });
}
});
});

View File

@ -10,7 +10,13 @@ describe("agent session wake messages", () => {
].join("\n");
const wakePayload = await buildPaperclipWakePayload({
db: {} as never,
db: {
select: () => ({
from: () => ({
where: async () => [],
}),
}),
} as never,
companyId: "company-1",
contextSnapshot: {
wakeReason: "issue_assigned",

View File

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { REDACTED_EVENT_VALUE } from "../redaction.js";
import { redactRegisteredSecretValues } from "../services/run-secret-redaction.js";
const secret = "q2a-exact-secret-value";
describe("registered run secret redaction", () => {
it("redacts exact values across comment and heartbeat/wake projections", () => {
const result = redactRegisteredSecretValues({
comment: { body: `agent pasted ${secret} in a comment` },
heartbeatContext: {
issue: { description: `do not expose ${secret}` },
wakeComment: { body: secret },
},
wakePayload: {
comments: [{ body: `prefix-${secret}-suffix` }],
continuationSummary: { body: secret },
},
}, [secret]);
expect(result).toEqual({
comment: { body: `agent pasted ${REDACTED_EVENT_VALUE} in a comment` },
heartbeatContext: {
issue: { description: `do not expose ${REDACTED_EVENT_VALUE}` },
wakeComment: { body: REDACTED_EVENT_VALUE },
},
wakePayload: {
comments: [{ body: `prefix-${REDACTED_EVENT_VALUE}-suffix` }],
continuationSummary: { body: REDACTED_EVENT_VALUE },
},
});
});
it("redacts run detail, event, and transcript fields and strips registry material", () => {
const result = redactRegisteredSecretValues({
contextSnapshot: {
issueId: "issue-1",
paperclipSecretRedactions: [{ material: { ciphertext: "encrypted" } }],
},
stdoutExcerpt: `stdout ${secret}`,
events: [{ message: secret, payload: { output: secret } }],
log: { content: `tool returned ${secret}` },
}, [secret]);
expect(result).toEqual({
contextSnapshot: { issueId: "issue-1" },
stdoutExcerpt: `stdout ${REDACTED_EVENT_VALUE}`,
events: [{ message: REDACTED_EVENT_VALUE, payload: { output: REDACTED_EVENT_VALUE } }],
log: { content: `tool returned ${REDACTED_EVENT_VALUE}` },
});
});
it("replaces longer registered values before overlapping shorter values", () => {
expect(redactRegisteredSecretValues("token-extended token", ["token-extended", "token"]))
.toBe(`${REDACTED_EVENT_VALUE} ${REDACTED_EVENT_VALUE}`);
});
});

View File

@ -0,0 +1,863 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
companies,
companySecretBindings,
companySecretProposals,
companySecretProviderConfigs,
companySecretVersions,
companySecrets,
createDb,
heartbeatRuns,
issueComments,
issues,
} from "@paperclipai/db";
import { conflict } from "../errors.js";
import { errorHandler } from "../middleware/error-handler.js";
import { secretRoutes } from "../routes/secrets.js";
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
import type { IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js";
import { issueService } from "../services/issues.js";
import { createSecretProposalsService } from "../services/secret-proposals.js";
import { secretService } from "../services/secrets.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("secret proposal routes", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-secret-proposals-${randomUUID()}`);
beforeAll(async () => {
mkdirSync(secretsTmpDir, { recursive: true });
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
const started = await startEmbeddedPostgresTestDatabase("secret-proposal-routes");
stopDb = started.cleanup;
db = createDb(started.connectionString);
});
afterEach(async () => {
vi.restoreAllMocks();
await db.delete(activityLog);
await db.delete(issueComments);
await db.delete(companySecretProposals);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(companySecretProviderConfigs);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await stopDb?.();
if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
rmSync(secretsTmpDir, { recursive: true, force: true });
});
async function seedRun() {
const companyId = randomUUID();
const agentId = randomUUID();
const heartbeatRunId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Secret proposals",
issuePrefix: `P${companyId.slice(0, 7)}`.toUpperCase(),
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Proposer",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {},
permissions: {},
status: "idle",
});
await db.insert(heartbeatRuns).values({
id: heartbeatRunId,
companyId,
agentId,
status: "running",
contextSnapshot: { issueId },
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Needs credential",
identifier: "SEC-1",
status: "in_progress",
executionRunId: heartbeatRunId,
});
return { companyId, agentId, heartbeatRunId, issueId };
}
function createAgentApp(
fixture: Awaited<ReturnType<typeof seedRun>>,
source: "agent_jwt" | "agent_key" = "agent_jwt",
keyScope: { kind: "standard" } | { kind: "task_bridge"; parentIssueId: string } = { kind: "standard" },
) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = {
type: "agent",
agentId: fixture.agentId,
companyId: fixture.companyId,
runId: fixture.heartbeatRunId,
source,
keyScope,
};
next();
});
app.use("/api", secretRoutes(db));
app.use(errorHandler);
return app;
}
function createBoardApp(
fixture: Awaited<ReturnType<typeof seedRun>>,
options?: {
admin?: boolean;
heartbeat?: IssueAssignmentWakeupDeps;
issues?: Pick<ReturnType<typeof issueService>, "getById" | "addComment">;
},
) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = {
type: "board",
userId: "board-user",
companyIds: [fixture.companyId],
source: options?.admin === false ? "session" : "local_implicit",
memberships: options?.admin === false
? [{ companyId: fixture.companyId, status: "active", membershipRole: "member" }]
: undefined,
};
next();
});
app.use("/api", secretRoutes(db, { heartbeat: options?.heartbeat, issues: options?.issues }));
app.use(errorHandler);
return app;
}
it("requires company admin access to reject secret proposals", async () => {
const fixture = await seedRun();
const proposed = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({ kind: "secret", name: "dev/reject/token", value: "reject-secret", justification: "Needed" });
const rejected = await request(createBoardApp(fixture, { admin: false }))
.post(`/api/companies/${fixture.companyId}/secret-proposals/${proposed.body.id}/reject`)
.send({ reason: "Not approved" });
expect(rejected.status).toBe(403);
expect(await db.select().from(companySecretProposals).where(eq(companySecretProposals.id, proposed.body.id)))
.toEqual([expect.objectContaining({ status: "pending", valueCiphertext: expect.any(Object) })]);
});
it("keeps committed approve and reject responses successful when resolution notifications fail", async () => {
const fixture = await seedRun();
await db.update(issues)
.set({ assigneeAgentId: fixture.agentId })
.where(eq(issues.id, fixture.issueId));
const wakeup = vi.fn().mockRejectedValue(new Error("wakeup queue unavailable"));
const persistedIssues = issueService(db);
const addComment = vi.fn().mockRejectedValue(new Error("comment store unavailable"));
const boardApp = createBoardApp(fixture, {
heartbeat: { wakeup },
issues: { getById: persistedIssues.getById, addComment },
});
const approvedProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "secret",
name: "dev/notification/approve",
value: "approval-value",
justification: "Needed by task",
});
const approved = await request(boardApp)
.post(`/api/companies/${fixture.companyId}/secret-proposals/${approvedProposal.body.id}/approve`)
.send({});
expect(approved.status).toBe(200);
expect(approved.body.status).toBe("approved");
const rejectedProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "secret",
name: "dev/notification/reject",
value: "rejection-value",
justification: "Needed by task",
});
const rejected = await request(boardApp)
.post(`/api/companies/${fixture.companyId}/secret-proposals/${rejectedProposal.body.id}/reject`)
.send({ reason: "No longer needed" });
expect(rejected.status).toBe(200);
expect(rejected.body.status).toBe("rejected");
expect(addComment).toHaveBeenCalledTimes(2);
expect(wakeup).toHaveBeenCalledTimes(2);
expect(await db.select().from(companySecretProposals)).toEqual(expect.arrayContaining([
expect.objectContaining({ id: approvedProposal.body.id, status: "approved", valueCiphertext: null }),
expect.objectContaining({ id: rejectedProposal.body.id, status: "rejected", valueCiphertext: null }),
]));
expect(await db.select().from(issueComments)).toHaveLength(0);
});
it("uses the selected provider vault when approving a secret proposal", async () => {
const fixture = await seedRun();
const vault = await secretService(db).createProviderConfig(fixture.companyId, {
provider: "aws_secrets_manager",
displayName: "AWS production",
config: { region: "us-east-1", namespace: "prod-use1" },
});
const externalRef =
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/proposed-token";
const createSecret = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId: externalRef,
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
externalRef,
providerVersionRef: "aws-version-1",
});
const proposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "secret",
name: "dev/provider/token",
value: "provider-secret",
justification: "Store this credential in the company vault",
});
const approved = await request(createBoardApp(fixture))
.post(`/api/companies/${fixture.companyId}/secret-proposals/${proposal.body.id}/approve`)
.send({ overrides: { providerConfigId: vault.id } });
expect(approved.status).toBe(200);
expect(createSecret).toHaveBeenCalledWith(expect.objectContaining({
value: "provider-secret",
providerConfig: expect.objectContaining({ id: vault.id, provider: "aws_secrets_manager" }),
}));
expect(await db.select().from(companySecrets)).toEqual([
expect.objectContaining({
provider: "aws_secrets_manager",
providerConfigId: vault.id,
status: "active",
}),
]);
});
it("requires agent JWT and atomically cascade-approves a secret plus binding with dual audits", async () => {
const fixture = await seedRun();
const denied = await request(createAgentApp(fixture, "agent_key"))
.post("/api/agents/me/secret-proposals")
.send({ kind: "secret", name: "dev/vendor/token", value: "top-secret", justification: "Needed by task" });
expect(denied.status).toBe(403);
const scopedDenied = await request(createAgentApp(
fixture,
"agent_jwt",
{ kind: "task_bridge", parentIssueId: fixture.issueId },
)).post("/api/agents/me/secret-proposals").send({
kind: "secret",
name: "dev/vendor/token",
value: "top-secret",
justification: "Needed by task",
});
expect(scopedDenied.status).toBe(403);
const secretResponse = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "secret",
name: "dev/vendor/token",
key: "VENDOR_TOKEN",
value: "top-secret",
justification: "Needed by task",
});
expect(secretResponse.status).toBe(201);
expect(JSON.stringify(secretResponse.body)).not.toContain("top-secret");
expect(secretResponse.body).not.toHaveProperty("valueFingerprintSha256");
const [registeredRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.heartbeatRunId));
expect(JSON.stringify(registeredRun.contextSnapshot)).not.toContain("top-secret");
expect(registeredRun.contextSnapshot).toMatchObject({
paperclipSecretRedactions: [expect.objectContaining({ fingerprintSha256: expect.any(String), material: expect.any(Object) })],
});
const bindingResponse = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "binding",
secretProposalId: secretResponse.body.id,
configPath: "env.VENDOR_TOKEN",
justification: "Inject for the task",
});
expect(bindingResponse.status).toBe(201);
const agentApprovalDenied = await request(createAgentApp(fixture))
.post(`/api/companies/${fixture.companyId}/secret-proposals/${secretResponse.body.id}/approve`)
.send({});
expect(agentApprovalDenied.status).toBe(403);
expect(await db.select().from(companySecrets)).toHaveLength(0);
const prerequisite = await request(createBoardApp(fixture))
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingResponse.body.id}/approve`)
.send({});
expect(prerequisite.status).toBe(409);
expect(await db.select().from(companySecrets)).toHaveLength(0);
const approved = await request(createBoardApp(fixture))
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingResponse.body.id}/approve`)
.send({ cascade: true });
expect(approved.status).toBe(200);
expect(approved.body).toMatchObject({
status: "approved",
appliedBindingConfigPath: "env.VENDOR_TOKEN",
viewerCanApprove: false,
});
const proposalRows = await db.select().from(companySecretProposals);
expect(proposalRows).toEqual(expect.arrayContaining([
expect.objectContaining({ id: secretResponse.body.id, status: "approved", valueCiphertext: null }),
expect.objectContaining({ id: bindingResponse.body.id, status: "approved" }),
]));
const [secret] = await db.select().from(companySecrets);
expect(secret).toMatchObject({
name: "dev/vendor/token",
key: "vendor_token",
createdByAgentId: fixture.agentId,
createdByUserId: "board-user",
});
expect(await db.select().from(companySecretBindings)).toEqual([
expect.objectContaining({ secretId: secret.id, targetId: fixture.agentId, configPath: "env.VENDOR_TOKEN" }),
]);
const [agent] = await db.select().from(agents);
expect(agent.adapterConfig).toMatchObject({
env: { VENDOR_TOKEN: { type: "secret_ref", secretId: secret.id, version: "latest" } },
});
const actions = (await db.select().from(activityLog)).map((row) => row.action);
expect(actions.filter((action) => action === "secret.proposal.approved")).toHaveLength(2);
expect(actions).toEqual(expect.arrayContaining(["secret.proposal.created", "secret.created", "agent.updated"]));
expect(await db.select().from(issueComments)).toEqual([
expect.objectContaining({ issueId: fixture.issueId, authorUserId: "board-user" }),
]);
});
it("approves a binding without cascade after its secret proposal was approved separately", async () => {
const fixture = await seedRun();
const agentApp = createAgentApp(fixture);
const boardApp = createBoardApp(fixture);
const secretProposal = await request(agentApp)
.post("/api/agents/me/secret-proposals")
.send({
kind: "secret",
name: "dev/sequential/token",
value: "sequential-secret",
justification: "Approve this first",
});
expect(secretProposal.status).toBe(201);
const bindingProposal = await request(agentApp)
.post("/api/agents/me/secret-proposals")
.send({
kind: "binding",
secretProposalId: secretProposal.body.id,
configPath: "env.SEQUENTIAL_TOKEN",
justification: "Bind after separate approval",
});
expect(bindingProposal.status).toBe(201);
const approvedSecret = await request(boardApp)
.post(`/api/companies/${fixture.companyId}/secret-proposals/${secretProposal.body.id}/approve`)
.send({});
expect(approvedSecret.status).toBe(200);
expect(approvedSecret.body).toMatchObject({ status: "approved", createdSecretId: expect.any(String) });
const approvedBinding = await request(boardApp)
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingProposal.body.id}/approve`)
.send({});
expect(approvedBinding.status).toBe(200);
expect(approvedBinding.body).toMatchObject({
status: "approved",
appliedBindingConfigPath: "env.SEQUENTIAL_TOKEN",
});
expect(await db.select().from(companySecrets)).toHaveLength(1);
expect(await db.select().from(companySecretBindings)).toEqual([
expect.objectContaining({
secretId: approvedSecret.body.createdSecretId,
targetId: fixture.agentId,
configPath: "env.SEQUENTIAL_TOKEN",
}),
]);
});
it("serializes proposal quotas and exposes bounded list pagination", async () => {
const fixture = await seedRun();
const liveSecret = await secretService(db).create(fixture.companyId, {
name: "dev/quota/source",
key: "QUOTA_SOURCE",
provider: "local_encrypted",
value: "quota-source-secret",
});
const agentApp = createAgentApp(fixture);
const responses = await Promise.all(Array.from({ length: 21 }, (_, index) =>
request(agentApp)
.post("/api/agents/me/secret-proposals")
.send({
kind: "binding",
secretId: liveSecret.id,
configPath: `env.QUOTA_${index}`,
justification: "Exercise the concurrent proposal cap",
})));
expect(responses.filter((response) => response.status === 201)).toHaveLength(20);
expect(responses.filter((response) => response.status === 422)).toHaveLength(1);
expect(await db.select().from(companySecretProposals)).toHaveLength(20);
expect(await db.select().from(activityLog)).toEqual(expect.arrayContaining([
expect.objectContaining({ action: "secret.proposal.denied" }),
]));
const firstPage = await request(agentApp).get("/api/agents/me/secret-proposals?limit=7&offset=0");
expect(firstPage.status).toBe(200);
expect(firstPage.body.proposals).toHaveLength(7);
expect(firstPage.body.nextOffset).toBe(7);
expect(firstPage.headers["x-next-offset"]).toBe("7");
const lastBoardPage = await request(createBoardApp(fixture))
.get(`/api/companies/${fixture.companyId}/secret-proposals?status=pending&limit=7&offset=14`);
expect(lastBoardPage.status).toBe(200);
expect(lastBoardPage.body).toHaveLength(6);
expect(lastBoardPage.headers["x-next-offset"]).toBeUndefined();
});
it("bounds each expiry sweep batch", async () => {
const fixture = await seedRun();
const liveSecret = await secretService(db).create(fixture.companyId, {
name: "dev/sweep/source",
key: "SWEEP_SOURCE",
provider: "local_encrypted",
value: "sweep-source-secret",
});
const agentApp = createAgentApp(fixture);
for (let index = 0; index < 3; index += 1) {
const response = await request(agentApp)
.post("/api/agents/me/secret-proposals")
.send({
kind: "binding",
secretId: liveSecret.id,
configPath: `env.SWEEP_${index}`,
justification: "Exercise bounded expiry cleanup",
});
expect(response.status).toBe(201);
}
await db.update(companySecretProposals).set({ expiresAt: new Date(Date.now() - 1_000) });
const proposals = createSecretProposalsService(db);
let injectedCollision = false;
await expect(proposals.sweepExpired(new Date(), 2, async (companyId, proposalId) => {
if (!injectedCollision) {
injectedCollision = true;
throw conflict("Proposal is no longer pending");
}
return proposals.transition(companyId, proposalId, "expired", { reason: "Pending proposal expired" });
})).resolves.toBe(1);
expect((await db.select().from(companySecretProposals)).filter((proposal) => proposal.status === "pending"))
.toHaveLength(2);
await expect(proposals.sweepExpired(new Date(), 2)).resolves.toBe(2);
});
it("denies direct and cascade approval after a proposal expires", async () => {
const fixture = await seedRun();
const secretProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "secret",
name: "dev/expired/token",
value: "expired-secret",
justification: "Needed before the retention window elapsed",
});
const bindingProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "binding",
secretProposalId: secretProposal.body.id,
configPath: "env.EXPIRED_TOKEN",
justification: "Inject the proposed secret",
});
await db.update(companySecretProposals)
.set({ expiresAt: new Date(Date.now() - 1_000) })
.where(eq(companySecretProposals.id, secretProposal.body.id));
const directApproval = await request(createBoardApp(fixture))
.post(`/api/companies/${fixture.companyId}/secret-proposals/${secretProposal.body.id}/approve`)
.send({});
expect(directApproval.status).toBe(409);
const cascadeApproval = await request(createBoardApp(fixture))
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingProposal.body.id}/approve`)
.send({ cascade: true });
expect(cascadeApproval.status).toBe(409);
expect(await db.select().from(companySecrets)).toHaveLength(0);
});
it("rechecks expiry after acquiring the proposal approval lock", async () => {
const fixture = await seedRun();
const proposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "secret",
name: "dev/expiry-race/token",
value: "expiry-race-secret",
justification: "Exercise the approval boundary",
});
let approvalPromise: Promise<{ status: number; body: Record<string, unknown> }> | null = null;
await db.transaction(async (tx) => {
await tx.select().from(companySecretProposals)
.where(eq(companySecretProposals.id, proposal.body.id))
.for("update");
approvalPromise = request(createBoardApp(fixture))
.post(`/api/companies/${fixture.companyId}/secret-proposals/${proposal.body.id}/approve`)
.send({})
.then((response) => response);
await new Promise((resolve) => setTimeout(resolve, 100));
await tx.update(companySecretProposals)
.set({ expiresAt: new Date(Date.now() - 1_000) })
.where(eq(companySecretProposals.id, proposal.body.id));
});
const approval = await approvalPromise;
expect(approval?.status).toBe(409);
expect(await db.select().from(companySecrets)).toHaveLength(0);
expect(await db.select().from(companySecretProposals)).toEqual([
expect.objectContaining({ id: proposal.body.id, status: "pending" }),
]);
});
it("returns 409 without mutation when the current org graph no longer permits the stored binding target", async () => {
const fixture = await seedRun();
const targetAgentId = randomUUID();
await db.insert(agents).values({
id: targetAgentId,
companyId: fixture.companyId,
name: "Report",
role: "engineer",
reportsTo: fixture.agentId,
adapterType: "codex_local",
adapterConfig: {},
permissions: {},
status: "idle",
});
const secretProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({ kind: "secret", name: "dev/reorg/token", value: "reorg-secret", justification: "Needed by report" });
const bindingProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "binding",
secretProposalId: secretProposal.body.id,
targetAgentId,
configPath: "access.REORG_TOKEN",
justification: "Give the report API access",
});
expect(bindingProposal.status).toBe(201);
await db.update(agents).set({ reportsTo: null }).where(eq(agents.id, targetAgentId));
const response = await request(createBoardApp(fixture))
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingProposal.body.id}/approve`)
.send({ cascade: true });
expect(response.status).toBe(409);
expect(await db.select().from(companySecrets)).toHaveLength(0);
expect((await db.select().from(companySecretProposals)).every((proposal) => proposal.status === "pending")).toBe(true);
});
it("rejects bindings that reference terminal secret proposals", async () => {
const fixture = await seedRun();
const secretProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "secret",
name: "dev/withdrawn/token",
value: "withdrawn-secret",
justification: "No longer needed",
});
expect(secretProposal.status).toBe(201);
const withdrawn = await request(createAgentApp(fixture))
.delete(`/api/agents/me/secret-proposals/${secretProposal.body.id}`);
expect(withdrawn.status).toBe(200);
expect(withdrawn.body.status).toBe("withdrawn");
const bindingProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "binding",
secretProposalId: secretProposal.body.id,
configPath: "env.WITHDRAWN_TOKEN",
justification: "Reference a terminal dependency",
});
expect(bindingProposal.status).toBe(422);
expect(bindingProposal.body.error).toContain("no longer pending");
expect(await db.select().from(companySecretProposals)).toEqual([
expect.objectContaining({ id: secretProposal.body.id, status: "withdrawn" }),
]);
});
it("serializes binding creation with concurrent dependency rejection", async () => {
const fixture = await seedRun();
const secretProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "secret",
name: "dev/concurrent-reject/token",
value: "concurrent-reject-secret",
justification: "Create a binding while review is pending",
});
expect(secretProposal.status).toBe(201);
const advisoryLockKey = 147460186;
await db.execute(sql.raw(`
CREATE OR REPLACE FUNCTION paperclip_test_pause_binding_proposal()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
IF NEW.kind = 'binding' THEN
PERFORM pg_advisory_xact_lock(${advisoryLockKey});
PERFORM pg_sleep(1);
END IF;
RETURN NEW;
END
$function$;
CREATE TRIGGER paperclip_test_pause_binding_proposal
BEFORE INSERT ON company_secret_proposals
FOR EACH ROW EXECUTE FUNCTION paperclip_test_pause_binding_proposal();
`));
try {
const bindingPromise = request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "binding",
secretProposalId: secretProposal.body.id,
configPath: "env.CONCURRENT_REJECT_TOKEN",
justification: "Exercise dependency serialization",
})
.then((response) => response);
let bindingPaused = false;
for (let attempt = 0; attempt < 80; attempt += 1) {
const lockAvailable = await db.transaction(async (tx) => {
const [result] = await tx.execute<{ acquired: boolean }>(
sql`SELECT pg_try_advisory_lock(${advisoryLockKey}) AS acquired`,
);
if (result?.acquired) {
await tx.execute(sql`SELECT pg_advisory_unlock(${advisoryLockKey})`);
}
return result?.acquired ?? false;
});
if (!lockAvailable) {
bindingPaused = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(bindingPaused).toBe(true);
let rejectionFinished = false;
const rejectionPromise = request(createBoardApp(fixture))
.post(`/api/companies/${fixture.companyId}/secret-proposals/${secretProposal.body.id}/reject`)
.send({ reason: "Reject during binding creation" })
.then((response) => {
rejectionFinished = true;
return response;
});
await new Promise((resolve) => setTimeout(resolve, 150));
expect(rejectionFinished).toBe(false);
const binding = await bindingPromise;
const rejection = await rejectionPromise;
expect(binding.status).toBe(201);
expect(rejection.status).toBe(200);
} finally {
await db.execute(sql.raw(`
DROP TRIGGER IF EXISTS paperclip_test_pause_binding_proposal ON company_secret_proposals;
DROP FUNCTION IF EXISTS paperclip_test_pause_binding_proposal();
`));
}
expect(await db.select().from(companySecretProposals)).toEqual(expect.arrayContaining([
expect.objectContaining({ id: secretProposal.body.id, status: "rejected" }),
expect.objectContaining({
kind: "binding",
secretProposalId: secretProposal.body.id,
status: "rejected",
resolutionReason: `Dependent secret proposal ${secretProposal.body.id} was rejected`,
}),
]));
});
it("cascade-rejects pending binding proposals when their secret proposal is withdrawn", async () => {
const fixture = await seedRun();
const secretProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "secret",
name: "dev/cascade/token",
value: "cascade-secret",
justification: "Temporary access",
});
const bindingProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "binding",
secretProposalId: secretProposal.body.id,
configPath: "env.CASCADE_TOKEN",
justification: "Use the temporary secret",
});
expect(bindingProposal.status).toBe(201);
const withdrawn = await request(createAgentApp(fixture))
.delete(`/api/agents/me/secret-proposals/${secretProposal.body.id}`);
expect(withdrawn.status).toBe(200);
expect(await db.select().from(companySecretProposals)).toEqual(expect.arrayContaining([
expect.objectContaining({ id: secretProposal.body.id, status: "withdrawn" }),
expect.objectContaining({
id: bindingProposal.body.id,
status: "rejected",
resolutionReason: `Dependent secret proposal ${secretProposal.body.id} was withdrawn`,
}),
]));
});
it("holds the org graph stable until binding approval commits", async () => {
const fixture = await seedRun();
const targetAgentId = randomUUID();
await db.insert(agents).values({
id: targetAgentId,
companyId: fixture.companyId,
name: "Concurrent report",
role: "engineer",
reportsTo: fixture.agentId,
adapterType: "codex_local",
adapterConfig: {},
permissions: {},
status: "idle",
});
const secretProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "secret",
name: "dev/concurrent-reorg/token",
value: "concurrent-reorg-secret",
justification: "Needed by report",
});
const bindingProposal = await request(createAgentApp(fixture))
.post("/api/agents/me/secret-proposals")
.send({
kind: "binding",
secretProposalId: secretProposal.body.id,
targetAgentId,
configPath: "access.CONCURRENT_REORG_TOKEN",
justification: "Give the report API access",
});
expect(bindingProposal.status).toBe(201);
const advisoryLockKey = 147460185;
await db.execute(sql.raw(`
CREATE OR REPLACE FUNCTION paperclip_test_pause_secret_approval()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
PERFORM pg_advisory_xact_lock(${advisoryLockKey});
PERFORM pg_sleep(1);
RETURN NEW;
END
$function$;
CREATE TRIGGER paperclip_test_pause_secret_approval
BEFORE INSERT ON company_secrets
FOR EACH ROW EXECUTE FUNCTION paperclip_test_pause_secret_approval();
`));
try {
const approvalPromise = request(createBoardApp(fixture))
.post(`/api/companies/${fixture.companyId}/secret-proposals/${bindingProposal.body.id}/approve`)
.send({ cascade: true })
.then((response) => response);
let approvalPaused = false;
for (let attempt = 0; attempt < 80; attempt += 1) {
const lockAvailable = await db.transaction(async (tx) => {
const [result] = await tx.execute<{ acquired: boolean }>(
sql`SELECT pg_try_advisory_lock(${advisoryLockKey}) AS acquired`,
);
if (result?.acquired) {
await tx.execute(sql`SELECT pg_advisory_unlock(${advisoryLockKey})`);
}
return result?.acquired ?? false;
});
if (!lockAvailable) {
approvalPaused = true;
break;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(approvalPaused).toBe(true);
let reorgFinished = false;
const reorgPromise = db.update(agents)
.set({ reportsTo: null })
.where(eq(agents.id, targetAgentId))
.then(() => {
reorgFinished = true;
});
await new Promise((resolve) => setTimeout(resolve, 150));
expect(reorgFinished).toBe(false);
const approval = await approvalPromise;
expect(approval.status).toBe(200);
await reorgPromise;
} finally {
await db.execute(sql.raw(`
DROP TRIGGER IF EXISTS paperclip_test_pause_secret_approval ON company_secrets;
DROP FUNCTION IF EXISTS paperclip_test_pause_secret_approval();
`));
}
expect(await db.select().from(companySecretBindings)).toEqual([
expect.objectContaining({ targetId: targetAgentId, configPath: "access.CONCURRENT_REORG_TOKEN" }),
]);
expect(await db.select({ reportsTo: agents.reportsTo }).from(agents).where(eq(agents.id, targetAgentId)))
.toEqual([{ reportsTo: null }]);
});
});

View File

@ -312,6 +312,12 @@ vi.mock("../services/index.js", () => ({
})),
}));
vi.mock("../services/secret-proposals.js", () => ({
createSecretProposalsService: vi.fn(() => ({
sweepExpired: vi.fn(async () => 0),
})),
}));
vi.mock("../storage/index.js", () => ({
createStorageServiceFromConfig: vi.fn(() => ({ id: "storage-service" })),
}));

View File

@ -63,6 +63,7 @@ import {
toolAccessService,
} from "./services/index.js";
import { queueIssueAssignmentWakeup } from "./services/issue-assignment-wakeup.js";
import { createSecretProposalsService } from "./services/secret-proposals.js";
import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js";
import {
parseAdapterRegistryEnv,
@ -958,6 +959,7 @@ export async function startServer(): Promise<StartedServer> {
};
if (heartbeat) {
const secretProposals = createSecretProposalsService(db as any);
const decisionExecutor = decisionService(db as any, decisionServiceOptions);
const retentionExecutor = decisionRetentionService(db as any, {
notifyOriginAgent: createDecisionRetentionNotifyOriginAgent(heartbeat.wakeup),
@ -1254,6 +1256,14 @@ export async function startServer(): Promise<StartedServer> {
logger.error({ err }, "periodic tool connection health sweep failed");
}));
trackHeartbeatSchedulerWork(secretProposals.sweepExpired()
.then((expired) => {
if (expired > 0) logger.warn({ expired }, "periodic secret proposal expiry scrubbed proposals");
})
.catch((err) => {
logger.error({ err }, "periodic secret proposal expiry sweep failed");
}));
if (heartbeatSchedulerStopped) return;
if (!(await heartbeat.resolveSchedulingSuppression()).suppressed) {
// Periodically reap orphaned runs (5-min staleness threshold) and make sure

View File

@ -56,6 +56,7 @@ import {
workspaceOperationService,
} from "../services/index.js";
import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
@ -191,6 +192,7 @@ export function agentRoutes(
const environmentRuntime = environmentRuntimeService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
const runRedactions = createRunSecretRedactionRegistry(db);
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
@ -3717,7 +3719,7 @@ export function agentRoutes(
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined;
const summary = req.query.summary === "true" || req.query.summary === "1";
const runs = await heartbeat.list(companyId, agentId, limit, { summary });
res.json(runs);
res.json(await Promise.all(runs.map((run) => runRedactions.redactForRun(companyId, run.id, run))));
});
router.get("/companies/:companyId/live-runs", async (req, res) => {
@ -3792,14 +3794,14 @@ export function agentRoutes(
.limit(targetRunCount - liveRuns.length);
const rows = [...liveRuns, ...recentRuns];
res.json(await Promise.all(rows.map(async (run) => ({
res.json(await Promise.all(rows.map(async (run) => runRedactions.redactForRun(companyId, run.id, {
...heartbeat.decorateActiveRunStatus(run),
outputSilence: await heartbeat.buildRunOutputSilence(run),
}))));
return;
}
res.json(await Promise.all(liveRuns.map(async (run) => ({
res.json(await Promise.all(liveRuns.map(async (run) => runRedactions.redactForRun(companyId, run.id, {
...heartbeat.decorateActiveRunStatus(run),
outputSilence: await heartbeat.buildRunOutputSilence(run),
}))));
@ -3811,12 +3813,14 @@ export function agentRoutes(
if (!run) return;
const retryExhaustedReason = await heartbeat.getRetryExhaustedReason(runId);
const decoratedRun = heartbeat.decorateActiveRunStatus(run);
res.json(
res.json(await runRedactions.redactForRun(
run.companyId,
run.id,
redactCurrentUserValue(
{ ...decoratedRun, retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) },
await getCurrentUserRedactionOptions(),
),
);
));
});
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
@ -3896,7 +3900,7 @@ export function agentRoutes(
payload: redactEventPayload(event.payload),
}, currentUserRedactionOptions),
);
res.json(redactedEvents);
res.json(await runRedactions.redactForRun(run.companyId, run.id, redactedEvents));
});
router.get("/heartbeat-runs/:runId/log", async (req, res) => {
@ -3912,7 +3916,7 @@ export function agentRoutes(
});
res.set("Cache-Control", "no-cache, no-store");
res.json(result);
res.json(await runRedactions.redactForRun(run.companyId, run.id, result));
});
router.get("/heartbeat-runs/:runId/workspace-operations", async (req, res) => {

View File

@ -186,6 +186,7 @@ import { stalledReviewDecisionService } from "../services/stalled-review-decisio
import { environmentService } from "../services/environments.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import { redactSensitiveText } from "../redaction.js";
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
import {
createCompanySearchRateLimiter,
type CompanySearchRateLimiter,
@ -2691,6 +2692,7 @@ export function issueRoutes(
) {
const router = Router();
const svc = issueService(db);
const runRedactions = createRunSecretRedactionRegistry(db);
const access = accessService(db);
const heartbeat = heartbeatService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
@ -5672,7 +5674,7 @@ export function issueRoutes(
includeForIssueComment: wakeCommentId !== null,
});
res.json({
const response = {
issue: {
id: issue.id,
identifier: issue.identifier,
@ -5744,7 +5746,8 @@ export function issueRoutes(
: null,
planReviewContext,
currentExecutionWorkspace: compactIssueExecutionWorkspace(currentExecutionWorkspace),
});
};
res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, response));
});
router.get("/issues/:id/diagnostics/blockers", async (req, res) => {
@ -9979,7 +9982,7 @@ export function issueRoutes(
order,
limit,
});
res.json(comments);
res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, comments));
});
router.get("/issues/:id/interactions", async (req, res) => {
@ -10574,7 +10577,7 @@ export function issueRoutes(
res.status(404).json({ error: "Comment not found" });
return;
}
res.json(comment);
res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, comment));
});
router.delete("/issues/:id/comments/:commentId", async (req, res) => {

View File

@ -1683,6 +1683,61 @@ const AgentSecretListResponseSchema = z.object({
})),
});
const createAgentSecretProposalSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("secret"),
name: z.string().min(1),
description: z.string().optional().nullable(),
value: z.string().min(1),
justification: z.string().min(1),
}),
z.object({
kind: z.literal("binding"),
secretId: z.string().uuid().optional(),
secretProposalId: z.string().uuid().optional(),
targetAgentId: z.string().uuid().optional(),
configPath: z.string().min(1),
justification: z.string().min(1),
}),
]);
const approveSecretProposalSchema = z.object({
cascade: z.boolean().optional(),
overrides: z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
providerConfigId: z.string().uuid().optional().nullable(),
}).optional(),
});
const rejectSecretProposalSchema = z.object({ reason: z.string().min(1) });
registry.registerPath({
method: "post",
path: "/api/agents/me/secret-proposals",
tags: ["secrets"],
summary: "Propose a company secret or agent secret binding",
request: { body: jsonBody(createAgentSecretProposalSchema) },
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable },
});
registry.registerPath({
method: "get",
path: "/api/agents/me/secret-proposals",
tags: ["secrets"],
summary: "List secret proposals visible to the current agent run",
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
});
registry.registerPath({
method: "delete",
path: "/api/agents/me/secret-proposals/{id}",
tags: ["secrets"],
summary: "Withdraw a pending secret proposal",
request: { params: z.object({ id: z.string().uuid() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
});
registry.registerPath({
method: "get",
path: "/api/agents/me/secrets",
@ -2821,6 +2876,42 @@ registry.registerPath({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/secret-proposals",
tags: ["secrets"],
summary: "List company secret proposals for board review",
request: {
params: z.object({ companyId: z.string().uuid() }),
query: z.object({ status: z.enum(["pending", "approved", "rejected", "withdrawn", "expired"]).optional() }),
},
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
});
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/secret-proposals/{id}/approve",
tags: ["secrets"],
summary: "Approve and execute a secret proposal as the approving board user",
request: {
params: z.object({ companyId: z.string().uuid(), id: z.string().uuid() }),
body: jsonBody(approveSecretProposalSchema),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable },
});
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/secret-proposals/{id}/reject",
tags: ["secrets"],
summary: "Reject a pending secret proposal and dependent bindings",
request: {
params: z.object({ companyId: z.string().uuid(), id: z.string().uuid() }),
body: jsonBody(rejectSecretProposalSchema),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable },
});
registry.registerPath({
method: "patch",
path: "/api/secrets/{id}",

View File

@ -1,4 +1,4 @@
import { Router } from "express";
import { Router, type Response } from "express";
import type { Db } from "@paperclipai/db";
import {
createSecretProviderConfigSchema,
@ -18,20 +18,73 @@ import {
import { validate } from "../middleware/validate.js";
import { assertBoard, assertCompanyAccess, getAccessibleResource } from "./authz.js";
import { logActivity, secretService } from "../services/index.js";
import { createSecretProposalsService } from "../services/secret-proposals.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import { forbidden, unauthorized } from "../errors.js";
import { forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
import { authorizationDeniedDetails } from "../services/authorization.js";
import { accessService } from "../services/access.js";
import { heartbeatService } from "../services/heartbeat.js";
import { issueService } from "../services/issues.js";
import {
queueIssueAssignmentWakeup,
type IssueAssignmentWakeupDeps,
} from "../services/issue-assignment-wakeup.js";
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
import { logger } from "../middleware/logger.js";
function assertSecretDefinitionAdmin(req: Parameters<typeof assertBoard>[0], companyId: string) {
type SecretRoutesDeps = {
heartbeat?: IssueAssignmentWakeupDeps;
issues?: Pick<ReturnType<typeof issueService>, "getById" | "addComment">;
};
const DEFAULT_PROPOSAL_LIST_LIMIT = 100;
const MAX_PROPOSAL_LIST_LIMIT = 200;
function proposalListPage(query: Record<string, unknown>) {
const rawLimit = query.limit;
const rawOffset = query.offset;
if (rawLimit !== undefined && (typeof rawLimit !== "string" || !/^\d+$/.test(rawLimit))) {
throw unprocessable("limit must be a positive integer");
}
if (rawOffset !== undefined && (typeof rawOffset !== "string" || !/^\d+$/.test(rawOffset))) {
throw unprocessable("offset must be a non-negative integer");
}
const requestedLimit = rawLimit === undefined ? DEFAULT_PROPOSAL_LIST_LIMIT : Number.parseInt(rawLimit, 10);
if (requestedLimit < 1) throw unprocessable("limit must be a positive integer");
return {
limit: Math.min(requestedLimit, MAX_PROPOSAL_LIST_LIMIT),
offset: rawOffset === undefined ? 0 : Number.parseInt(rawOffset, 10),
};
}
function setProposalPaginationHeaders(
res: Response,
page: { limit: number; offset: number },
hasMore: boolean,
) {
res.setHeader("X-Page-Limit", String(page.limit));
res.setHeader("X-Page-Offset", String(page.offset));
if (hasMore) res.setHeader("X-Next-Offset", String(page.offset + page.limit));
}
function hasSecretDefinitionAdminAccess(req: Parameters<typeof assertBoard>[0], companyId: string) {
assertBoard(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
const membership = req.actor.memberships?.find((item) => item.companyId === companyId);
if (membership?.status === "active" && ["owner", "admin"].includes(String(membership.membershipRole))) {
return;
}
return membership?.status === "active" && ["owner", "admin"].includes(String(membership.membershipRole));
}
function assertSecretDefinitionAdmin(req: Parameters<typeof assertBoard>[0], companyId: string) {
if (hasSecretDefinitionAdminAccess(req, companyId)) return;
throw forbidden("Company admin access required");
}
function assertCompanySecretWrite(req: Parameters<typeof assertBoard>[0], companyId: string) {
assertBoard(req);
assertCompanyAccess(req, companyId);
}
function currentUserId(req: Parameters<typeof assertBoard>[0]) {
assertBoard(req);
if (req.actor.userId) return req.actor.userId;
@ -55,9 +108,14 @@ function isCompanyScopedSecret(secret: { scope?: string | null }) {
return (secret.scope ?? "company") === "company";
}
export function secretRoutes(db: Db) {
export function secretRoutes(db: Db, deps: SecretRoutesDeps = {}) {
const router = Router();
const svc = secretService(db);
const proposals = createSecretProposalsService(db);
const access = accessService(db);
const issues = deps.issues ?? issueService(db);
const heartbeat = deps.heartbeat ?? heartbeatService(db);
const runRedactions = createRunSecretRedactionRegistry(db);
const defaultProvider = getConfiguredSecretProvider();
function agentSecretContext(req: Parameters<typeof assertBoard>[0]) {
@ -75,6 +133,206 @@ export function secretRoutes(db: Db) {
};
}
async function proposalAgentContext(req: Parameters<typeof assertBoard>[0]) {
const context = agentSecretContext(req);
if (req.actor.source !== "agent_jwt" || req.actor.keyScope?.kind === "task_bridge" || req.actor.keyScope?.kind === "skill_test") {
throw forbidden("Secret proposals require a verified run-bound agent token");
}
const decision = await access.decide({
actor: req.actor,
action: "secrets:propose",
resource: { type: "company", companyId: context.companyId },
});
if (!decision.allowed) {
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
return context;
}
function agentProposalView(row: Awaited<ReturnType<typeof proposals.listForAgent>>[number]) {
const { valueFingerprintSha256: _fingerprint, valueLength: _length, ...safe } = row;
return safe;
}
async function bindingApprovalDecision(req: Parameters<typeof assertBoard>[0], proposal: {
kind: string;
targetId: string | null;
}) {
if (proposal.kind !== "binding") return null;
if (!proposal.targetId) throw unprocessable("Binding proposal target is missing");
return access.decide({
actor: req.actor,
action: "agent_config:update",
resource: { type: "agent", companyId: req.params.companyId as string, agentId: proposal.targetId },
scope: { requiresChangeGrant: true },
});
}
async function assertCanResolveProposal(req: Parameters<typeof assertBoard>[0], proposal: {
kind: string;
targetId: string | null;
}) {
if (proposal.kind === "secret") {
assertSecretDefinitionAdmin(req, req.params.companyId as string);
return;
}
const decision = await bindingApprovalDecision(req, proposal);
if (decision && !decision.allowed) {
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
}
async function boardProposalView(req: Parameters<typeof assertBoard>[0], proposal: Awaited<ReturnType<typeof proposals.listForBoard>>[number]) {
if (proposal.status !== "pending") {
return { ...proposal, viewerCanApprove: false, approveBlockReason: "Proposal is no longer pending" };
}
if (proposal.kind === "secret" && !hasSecretDefinitionAdminAccess(req, req.params.companyId as string)) {
return { ...proposal, viewerCanApprove: false, approveBlockReason: "Company admin access required" };
}
const decision = await bindingApprovalDecision(req, proposal);
return {
...proposal,
viewerCanApprove: decision?.allowed ?? true,
approveBlockReason: decision && !decision.allowed ? decision.explanation : null,
};
}
async function notifyProposalResolution(input: {
proposal: { originIssueId: string | null; kind: string; proposedName: string | null; configPath: string | null };
status: "approved" | "rejected";
userId: string;
reason?: string | null;
}) {
if (!input.proposal.originIssueId) return;
try {
const issue = await issues.getById(input.proposal.originIssueId);
if (!issue) return;
const subject = input.proposal.kind === "secret"
? `secret proposal \`${input.proposal.proposedName ?? "unnamed"}\``
: `binding proposal \`${input.proposal.configPath ?? "unknown"}\``;
const reason = input.reason ? `\n\nReason: ${input.reason}` : "";
try {
await issues.addComment(
issue.id,
`Secret proposal resolution\n\n- Proposal: ${subject}\n- Status: **${input.status}**${reason}`,
{ userId: input.userId },
);
} catch (err) {
logger.warn(
{ err, issueId: issue.id, proposalStatus: input.status },
"failed to post secret proposal resolution comment",
);
}
await queueIssueAssignmentWakeup({
heartbeat,
issue,
reason: "secret_proposal_resolved",
mutation: `secret_proposal_${input.status}`,
contextSource: "secret.proposal.resolution",
requestedByActorType: "user",
requestedByActorId: input.userId,
});
} catch (err) {
logger.warn(
{ err, issueId: input.proposal.originIssueId, proposalStatus: input.status },
"failed to notify origin issue about secret proposal resolution",
);
}
}
router.post("/agents/me/secret-proposals", async (req, res) => {
const context = await proposalAgentContext(req);
const body = req.body ?? {};
const proposal = body.kind === "secret"
? await proposals.createSecret({
companyId: context.companyId,
heartbeatRunId: context.heartbeatRunId,
registerForRedaction: (value) => runRedactions.register(context.companyId, context.heartbeatRunId, value),
}, {
name: body.name,
key: body.key,
description: body.description,
value: body.value,
justification: body.justification,
})
: body.kind === "binding"
? await proposals.createBinding({ companyId: context.companyId, heartbeatRunId: context.heartbeatRunId }, {
secretId: body.secretId, secretProposalId: body.secretProposalId, targetAgentId: body.targetAgentId,
configPath: body.configPath, justification: body.justification, bindingTargetPolicy: "self_and_reports",
})
: (() => { throw unprocessable("kind must be secret or binding"); })();
res.status(201).json(agentProposalView(await proposals.view(proposal)));
});
router.get("/agents/me/secret-proposals", async (req, res) => {
const context = await proposalAgentContext(req);
const page = proposalListPage(req.query);
const rows = await proposals.listForAgent(context.companyId, context.agentId, {
limit: page.limit + 1,
offset: page.offset,
});
const hasMore = rows.length > page.limit;
setProposalPaginationHeaders(res, page, hasMore);
res.json({
proposals: rows.slice(0, page.limit).map(agentProposalView),
nextOffset: hasMore ? page.offset + page.limit : null,
});
});
router.delete("/agents/me/secret-proposals/:id", async (req, res) => {
const context = await proposalAgentContext(req);
const proposal = await proposals.transition(context.companyId, req.params.id as string, "withdrawn", { proposerAgentId: context.agentId });
res.json(agentProposalView(await proposals.view(proposal)));
});
router.get("/companies/:companyId/secret-proposals", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const status = typeof req.query.status === "string" ? req.query.status : null;
const page = proposalListPage(req.query);
const rows = await proposals.listForBoard(companyId, status, {
limit: page.limit + 1,
offset: page.offset,
});
const hasMore = rows.length > page.limit;
setProposalPaginationHeaders(res, page, hasMore);
res.json(await Promise.all(rows.slice(0, page.limit).map((proposal) => boardProposalView(req, proposal))));
});
router.post("/companies/:companyId/secret-proposals/:id/approve", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanySecretWrite(req, companyId);
const proposal = await proposals.getById(companyId, req.params.id as string);
if (!proposal) throw notFound("Secret proposal not found");
await assertCanResolveProposal(req, proposal);
const resolvedByUserId = req.actor.userId ?? "board";
const approved = await proposals.approve(companyId, proposal.id, {
resolvedByUserId,
cascade: req.body?.cascade === true,
overrides: req.body?.overrides,
});
await notifyProposalResolution({ proposal, status: "approved", userId: resolvedByUserId });
res.json(await boardProposalView(req, await proposals.view(approved)));
});
router.post("/companies/:companyId/secret-proposals/:id/reject", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const reason = typeof req.body?.reason === "string" ? req.body.reason.trim() : "";
if (!reason) throw unprocessable("Rejection reason is required");
const existing = await proposals.getById(companyId, req.params.id as string);
if (!existing) throw notFound("Secret proposal not found");
await assertCanResolveProposal(req, existing);
const resolvedByUserId = req.actor.userId ?? "board";
const proposal = await proposals.transition(companyId, req.params.id as string, "rejected", {
resolvedByUserId, reason,
});
await notifyProposalResolution({ proposal: existing, status: "rejected", userId: resolvedByUserId, reason });
res.json(await boardProposalView(req, await proposals.view(proposal)));
});
router.get("/agents/me/secrets", async (req, res) => {
const context = agentSecretContext(req);
const secrets = await svc.listAgentSecretAccess(context.companyId, context);
@ -98,23 +356,22 @@ export function secretRoutes(db: Db) {
const context = agentSecretContext(req);
const available = await svc.listAgentSecretAccess(context.companyId, context);
const secret = available.find((entry) => entry.key === req.params.key);
const unresolvedSecret = secret ? null : await svc.getByKey(context.companyId, req.params.key);
if (!secret && !unresolvedSecret) throw forbidden("Secret access is not granted for this agent");
if (!secret) throw forbidden("Secret access is not granted for this agent");
const resolution = await svc.resolveSecretValueForAgentAccess(
context.companyId,
secret?.secretId ?? unresolvedSecret!.id,
secret?.versionSelector ?? "latest",
secret.secretId,
secret.versionSelector,
{
...context,
configPath: secret?.configPath ?? `access.${req.params.key}`,
bindingId: secret?.bindingId ?? null,
configPath: secret.configPath,
bindingId: secret.bindingId,
issueId: null,
registerForRedaction: () => undefined,
registerForRedaction: (value) => runRedactions.register(context.companyId, context.heartbeatRunId, value),
},
);
res.set("Cache-Control", "no-store");
res.json({
key: secret?.key ?? unresolvedSecret!.key,
key: secret.key,
value: resolution.value,
version: resolution.version,
});
@ -635,9 +892,8 @@ export function secretRoutes(db: Db) {
});
router.post("/companies/:companyId/secrets", validate(createSecretSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertCompanySecretWrite(req, companyId);
const created = await svc.create(
companyId,

View File

@ -530,9 +530,9 @@ export function agentService(db: Db) {
const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch);
const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null;
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const updated = await tx
type AgentUpdateResult = Awaited<ReturnType<typeof getById>>;
const applyUpdate = async (txDb: Db): Promise<AgentUpdateResult> => {
const updated = await txDb
.update(agents)
.set({ ...normalizedPatch, updatedAt: new Date() })
.where(eq(agents.id, id))
@ -553,7 +553,7 @@ export function agentService(db: Db) {
const afterConfig = buildConfigSnapshot(normalizedUpdated);
const changedKeys = diffConfigSnapshot(beforeConfig, afterConfig);
if (changedKeys.length > 0) {
await tx.insert(agentConfigRevisions).values({
await txDb.insert(agentConfigRevisions).values({
companyId: normalizedUpdated.companyId,
agentId: normalizedUpdated.id,
createdByAgentId: options?.recordRevision?.createdByAgentId ?? null,
@ -568,7 +568,13 @@ export function agentService(db: Db) {
}
return normalizedUpdated;
});
};
const transaction = (db as unknown as {
transaction?: (callback: (tx: unknown) => Promise<AgentUpdateResult>) => Promise<AgentUpdateResult>;
}).transaction;
if (typeof transaction !== "function") return applyUpdate(db);
return transaction.call(db, async (tx) => applyUpdate(tx as unknown as Db));
}
return {

View File

@ -71,7 +71,8 @@ export type AuthorizationAction =
| "issue:read"
| "project:read"
| "runtime:manage"
| "secrets:read";
| "secrets:read"
| "secrets:propose";
export type AuthorizationResource =
| { type: "company"; companyId: string }
@ -155,7 +156,8 @@ function permissionForAction(action: AuthorizationAction): PermissionKey | null
action === "issue:read" ||
action === "project:read" ||
action === "runtime:manage" ||
action === "secrets:read"
action === "secrets:read" ||
action === "secrets:propose"
) {
return null;
}
@ -990,7 +992,8 @@ export function authorizationService(db: Db) {
input.action === "skill_config:update" ||
input.action === "inbox:manage" ||
input.action === "runtime:manage" ||
input.action === "secrets:read"
input.action === "secrets:read" ||
input.action === "secrets:propose"
) {
return lowTrustDeny(
`${LOW_TRUST_REVIEW_PRESET} agents cannot use company-wide or privileged ${input.action} APIs by default.`,
@ -1163,7 +1166,8 @@ export function authorizationService(db: Db) {
input.action === "agent:wake" ||
input.action === "project:read" ||
input.action === "runtime:manage" ||
input.action === "secrets:read"
input.action === "secrets:read" ||
input.action === "secrets:propose"
) {
return denyBridge("Task bridge keys cannot use company-wide, peer-agent, project, runtime, or secret APIs.");
}
@ -1233,6 +1237,7 @@ export function authorizationService(db: Db) {
input.action === "project:read" ||
input.action === "runtime:manage" ||
input.action === "secrets:read" ||
input.action === "secrets:propose" ||
input.action === "tasks:assign"
) {
return denySkillTest("Skill-test run tokens cannot use company-wide, peer-agent, project, runtime, secret, or task-create APIs.");
@ -1742,7 +1747,8 @@ export function authorizationService(db: Db) {
input.action === "issue:read" ||
input.action === "project:read" ||
input.action === "runtime:manage" ||
input.action === "secrets:read"
input.action === "secrets:read" ||
input.action === "secrets:propose"
) {
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
// Mirroring the tasks:assign carve-out above, viewers keep the
@ -1880,7 +1886,8 @@ export function authorizationService(db: Db) {
input.action === "issue:read" ||
input.action === "project:read" ||
input.action === "runtime:manage" ||
input.action === "secrets:read"
input.action === "secrets:read" ||
input.action === "secrets:propose"
) {
return lowTrustDecision;
}
@ -2025,7 +2032,8 @@ export function authorizationService(db: Db) {
input.action === "issue:read" ||
input.action === "project:read" ||
input.action === "runtime:manage" ||
input.action === "secrets:read"
input.action === "secrets:read" ||
input.action === "secrets:propose"
) {
return allow({
action: input.action,

View File

@ -258,6 +258,7 @@ import {
type CurrentUserRedactionOptions,
} from "../log-redaction.js";
import { redactEventPayload, redactSensitiveText } from "../redaction.js";
import { createRunSecretRedactionRegistry } from "./run-secret-redaction.js";
import {
hasSessionCompactionThresholds,
resolveSessionCompactionPolicy,
@ -5680,7 +5681,7 @@ export async function buildPaperclipWakePayload(input: {
.then((rows) => rows[0] ?? null)
: null;
return {
const payload = {
reason: readNonEmptyString(input.contextSnapshot.wakeReason),
recovery: recoveryAction || recoveryCause
? {
@ -5776,6 +5777,9 @@ export async function buildPaperclipWakePayload(input: {
truncated: payloadTruncated,
fallbackFetchNeeded: payloadTruncated || missingCommentCount > 0,
};
return issueId
? createRunSecretRedactionRegistry(input.db).redactForIssue(input.companyId, issueId, payload)
: payload;
}
function runTaskKey(run: typeof heartbeatRuns.$inferSelect) {
@ -13828,6 +13832,28 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
} else {
delete context.paperclipTaskMarkdownCompact;
}
if (issueRef) {
const redactedWakeContext = await createRunSecretRedactionRegistry(db).redactForIssue(
agent.companyId,
issueRef.id,
{
paperclipIssue: context.paperclipIssue,
paperclipWakeComment: context.paperclipWakeComment,
paperclipTaskMarkdown: context.paperclipTaskMarkdown,
paperclipTaskMarkdownCompact: context.paperclipTaskMarkdownCompact,
},
);
context.paperclipIssue = redactedWakeContext.paperclipIssue;
if (redactedWakeContext.paperclipWakeComment) {
context.paperclipWakeComment = redactedWakeContext.paperclipWakeComment;
}
if (redactedWakeContext.paperclipTaskMarkdown) {
context.paperclipTaskMarkdown = redactedWakeContext.paperclipTaskMarkdown;
}
if (redactedWakeContext.paperclipTaskMarkdownCompact) {
context.paperclipTaskMarkdownCompact = redactedWakeContext.paperclipTaskMarkdownCompact;
}
}
const requestedExecutionWorkspaceId = readNonEmptyString(issueRef?.executionWorkspaceId);
const existingExecutionWorkspace =
requestedExecutionWorkspaceId ? await executionWorkspacesSvc.getById(requestedExecutionWorkspaceId) : null;

View File

@ -83,6 +83,8 @@ export type {
export { approvalService } from "./approvals.js";
export { budgetService } from "./budgets.js";
export { secretService } from "./secrets.js";
export { createRunSecretRedactionRegistry } from "./run-secret-redaction.js";
export { createSecretProposalsService } from "./secret-proposals.js";
export { googleSheetsRobotEmailFromEnv, toolAccessService } from "./tool-access.js";
export { smokeLabService } from "./smoke-lab.js";
export { backfillLegacyToolOAuthTokens } from "./tool-oauth-legacy-backfill.js";

View File

@ -0,0 +1,121 @@
import { createHash } from "node:crypto";
import { and, eq, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { heartbeatRuns } from "@paperclipai/db";
import { REDACTED_EVENT_VALUE } from "../redaction.js";
import { getSecretProvider } from "../secrets/provider-registry.js";
import type { StoredSecretVersionMaterial } from "../secrets/types.js";
const REGISTRY_KEY = "paperclipSecretRedactions";
type RegistryEntry = {
fingerprintSha256: string;
material: StoredSecretVersionMaterial;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function registryEntries(contextSnapshot: unknown): RegistryEntry[] {
const context = asRecord(contextSnapshot);
const raw = context?.[REGISTRY_KEY];
if (!Array.isArray(raw)) return [];
return raw.flatMap((value) => {
const entry = asRecord(value);
const material = asRecord(entry?.material);
return typeof entry?.fingerprintSha256 === "string" && material
? [{ fingerprintSha256: entry.fingerprintSha256, material }]
: [];
});
}
function redactText(input: string, values: string[]) {
return values.reduce(
(result, value) => value.length > 0 ? result.split(value).join(REDACTED_EVENT_VALUE) : result,
input,
);
}
export function redactRegisteredSecretValues<T>(input: T, values: string[]): T {
if (typeof input === "string") return redactText(input, values) as T;
if (Array.isArray(input)) return input.map((value) => redactRegisteredSecretValues(value, values)) as T;
const record = asRecord(input);
if (!record) return input;
return Object.fromEntries(
Object.entries(record)
.filter(([key]) => key !== REGISTRY_KEY)
.map(([key, value]) => [key, redactRegisteredSecretValues(value, values)]),
) as T;
}
export function createRunSecretRedactionRegistry(db: Db) {
const provider = getSecretProvider("local_encrypted");
async function valuesForRuns(rows: Array<{ contextSnapshot: unknown }>) {
const entries = rows.flatMap((row) => registryEntries(row.contextSnapshot));
const unique = new Map(entries.map((entry) => [entry.fingerprintSha256, entry]));
const values = await Promise.all(
[...unique.values()].map((entry) => provider.resolveVersion({
material: entry.material,
externalRef: null,
})),
);
return values.sort((left, right) => right.length - left.length);
}
async function valuesForRun(companyId: string, runId: string) {
const rows = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
return valuesForRuns(rows);
}
async function valuesForIssue(companyId: string, issueId: string) {
const rows = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.companyId, companyId),
or(
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
sql`${heartbeatRuns.contextSnapshot} -> 'paperclipIssue' ->> 'id' = ${issueId}`,
),
));
return valuesForRuns(rows);
}
return {
register: async (companyId: string, runId: string, value: string) => {
const fingerprintSha256 = createHash("sha256").update(value).digest("hex");
await db.transaction(async (tx) => {
const row = await tx.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)))
.for("update")
.then((rows) => rows[0] ?? null);
if (!row) throw new Error("Heartbeat run redaction registration failed");
if (registryEntries(row.contextSnapshot).some((entry) => entry.fingerprintSha256 === fingerprintSha256)) {
return;
}
const prepared = await provider.createSecret({ value });
const entry: RegistryEntry = { fingerprintSha256, material: prepared.material };
const contextSnapshot = asRecord(row.contextSnapshot) ?? {};
const currentEntries = Array.isArray(contextSnapshot[REGISTRY_KEY])
? contextSnapshot[REGISTRY_KEY]
: [];
await tx.update(heartbeatRuns)
.set({
contextSnapshot: { ...contextSnapshot, [REGISTRY_KEY]: [...currentEntries, entry] },
updatedAt: new Date(),
})
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)));
});
},
redactForRun: async <T>(companyId: string, runId: string, value: T): Promise<T> =>
redactRegisteredSecretValues(value, await valuesForRun(companyId, runId)),
redactForIssue: async <T>(companyId: string, issueId: string, value: T): Promise<T> =>
redactRegisteredSecretValues(value, await valuesForIssue(companyId, issueId)),
};
}

View File

@ -0,0 +1,633 @@
import { and, count, desc, eq, gte, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, companySecretProposals, companySecrets, heartbeatRuns, issues } from "@paperclipai/db";
import type { SecretProvider } from "@paperclipai/shared";
import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { getSecretProvider } from "../secrets/provider-registry.js";
import { agentService } from "./agents.js";
import { logActivity } from "./activity-log.js";
import { normalizeSecretKey, secretService } from "./secrets.js";
const CONFIG_PATH_RE = /^(?:env\.[A-Za-z_][A-Za-z0-9_]*|access\.[A-Za-z_][A-Za-z0-9_]*)$/;
const SECRET_NAME_RE = /^[^/\s]+(?:\/[^/\s]+)*$/;
const MAX_PENDING_PROPOSALS_PER_AGENT = 20;
const MAX_PROPOSALS_PER_MINUTE = 20;
const MAX_SECRET_VALUE_BYTES = 64 * 1024;
const PENDING_EXPIRY_MS = 14 * 24 * 60 * 60 * 1000;
const DEFAULT_PROPOSAL_LIST_LIMIT = 100;
const DEFAULT_EXPIRY_SWEEP_LIMIT = 100;
export type SecretProposalTerminalStatus = "approved" | "rejected" | "withdrawn" | "expired";
export type ProposalRunContext = {
companyId: string;
heartbeatRunId: string;
registerForRedaction: (value: string) => void | Promise<void>;
};
type Proposal = typeof companySecretProposals.$inferSelect;
async function loadRunContext(db: Db, context: Pick<ProposalRunContext, "companyId" | "heartbeatRunId">) {
const run = await db.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, context.heartbeatRunId),
eq(heartbeatRuns.companyId, context.companyId),
)).then((rows) => rows[0] ?? null);
if (!run) throw forbidden("Secret proposals require a verified run-bound agent token");
const issue = await db.select({ id: issues.id }).from(issues).where(and(
eq(issues.companyId, context.companyId),
or(eq(issues.executionRunId, run.id), eq(issues.checkoutRunId, run.id)),
)).then((rows) => rows[0] ?? null);
return { run, originIssueId: issue?.id ?? null };
}
async function ancestorIds(db: Db, companyId: string, agentId: string, lockForUpdate = false) {
const query = db.select({ id: agents.id, reportsTo: agents.reportsTo }).from(agents)
.where(eq(agents.companyId, companyId));
const rows = lockForUpdate ? await query.for("update") : await query;
const byId = new Map(rows.map((row) => [row.id, row.reportsTo]));
if (!byId.has(agentId)) throw notFound("Agent not found");
const result: string[] = [];
const seen = new Set<string>([agentId]);
let current = byId.get(agentId) ?? null;
while (current && !seen.has(current)) {
result.push(current);
seen.add(current);
current = byId.get(current) ?? null;
}
return result;
}
function bindingTargetAllowed(proposerAgentId: string, targetAgentId: string, targetAncestorIds: string[]) {
return proposerAgentId === targetAgentId || targetAncestorIds.includes(proposerAgentId);
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
export function createSecretProposalsService(db: Db) {
async function getById(companyId: string, proposalId: string, dbClient: Db = db, lockForUpdate = false) {
const query = dbClient.select().from(companySecretProposals).where(and(
eq(companySecretProposals.id, proposalId),
eq(companySecretProposals.companyId, companyId),
));
if (lockForUpdate) return query.for("update").then((rows) => rows[0] ?? null);
return query.then((rows) => rows[0] ?? null);
}
async function requirePending(companyId: string, proposalId: string, dbClient: Db = db, lockForUpdate = false) {
const proposal = await getById(companyId, proposalId, dbClient, lockForUpdate);
if (!proposal) throw notFound("Secret proposal not found");
if (proposal.status !== "pending") throw conflict("Only pending proposals can be resolved");
return proposal;
}
function assertNotExpired(proposal: Proposal) {
if (proposal.expiresAt.getTime() <= Date.now()) {
throw conflict("Expired proposals cannot be approved");
}
}
type CreationQuotaInput = { companyId: string; agentId: string; runId: string; issueId: string | null };
async function creationQuotaDenial(dbClient: Db, input: CreationQuotaInput) {
const [pending, recent] = await Promise.all([
dbClient.select({ value: count() }).from(companySecretProposals).where(and(
eq(companySecretProposals.companyId, input.companyId),
eq(companySecretProposals.proposedByAgentId, input.agentId),
eq(companySecretProposals.status, "pending"),
)).then((rows) => Number(rows[0]?.value ?? 0)),
dbClient.select({ value: count() }).from(companySecretProposals).where(and(
eq(companySecretProposals.companyId, input.companyId),
eq(companySecretProposals.proposedByAgentId, input.agentId),
gte(companySecretProposals.createdAt, new Date(Date.now() - 60_000)),
)).then((rows) => Number(rows[0]?.value ?? 0)),
]);
const denial = pending >= MAX_PENDING_PROPOSALS_PER_AGENT
? { code: "pending_cap", message: `Agents may have at most ${MAX_PENDING_PROPOSALS_PER_AGENT} pending secret proposals` }
: recent >= MAX_PROPOSALS_PER_MINUTE
? { code: "rate_limit", message: `Agents may create at most ${MAX_PROPOSALS_PER_MINUTE} secret proposals per minute` }
: null;
return denial ? { ...denial, pending, recent } : null;
}
async function createWithinQuota<T>(input: CreationQuotaInput, create: (txDb: Db) => Promise<T>) {
const result = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${input.companyId}), hashtext(${input.agentId}))`);
const denial = await creationQuotaDenial(txDb, input);
if (denial) {
await logActivity(txDb, {
companyId: input.companyId,
actorType: "agent",
actorId: input.agentId,
action: "secret.proposal.denied",
entityType: "agent",
entityId: input.agentId,
agentId: input.agentId,
runId: input.runId,
details: { code: denial.code, issueId: input.issueId, pending: denial.pending, recent: denial.recent },
});
return { denial, value: null };
}
return { denial: null, value: await create(txDb) };
});
if (result.denial) throw unprocessable(result.denial.message);
return result.value as T;
}
async function recordCreated(proposal: Proposal, dbClient: Db = db) {
await logActivity(dbClient, {
companyId: proposal.companyId,
actorType: "agent",
actorId: proposal.proposedByAgentId,
action: "secret.proposal.created",
entityType: "company_secret_proposal",
entityId: proposal.id,
agentId: proposal.proposedByAgentId,
runId: proposal.originRunId,
details: {
kind: proposal.kind,
issueId: proposal.originIssueId,
targetAgentId: proposal.targetId,
configPath: proposal.configPath,
valueFingerprintSha256: proposal.valueFingerprintSha256,
},
});
}
async function createSecret(context: ProposalRunContext, input: {
name: string;
key?: string | null;
description?: string | null;
value: string;
justification: string;
}) {
const name = input.name.trim();
const justification = input.justification.trim();
if (!SECRET_NAME_RE.test(name)) throw unprocessable("Secret name must be a slash-separated path without empty segments");
if (!justification) throw unprocessable("Justification is required");
if (!input.value) throw unprocessable("Secret value is required");
if (Buffer.byteLength(input.value, "utf8") > MAX_SECRET_VALUE_BYTES) {
throw unprocessable(`Secret value must be at most ${MAX_SECRET_VALUE_BYTES} bytes`);
}
const proposedKey = normalizeSecretKey(input.key?.trim() || name.split("/").at(-1) || "");
if (!proposedKey) throw unprocessable("Secret key is required");
const { run, originIssueId } = await loadRunContext(db, context);
const prepared = await getSecretProvider("local_encrypted").createSecret({ value: input.value });
await context.registerForRedaction(input.value);
return createWithinQuota(
{ companyId: context.companyId, agentId: run.agentId, runId: run.id, issueId: originIssueId },
async (txDb) => {
const proposal = await txDb.insert(companySecretProposals).values({
companyId: context.companyId,
kind: "secret",
proposedName: name,
proposedKey,
proposedDescription: input.description?.trim() || null,
justification,
valueCiphertext: prepared.material,
valueFingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
valueLength: Buffer.byteLength(input.value, "utf8"),
proposedByAgentId: run.agentId,
originIssueId,
originRunId: run.id,
expiresAt: new Date(Date.now() + PENDING_EXPIRY_MS),
}).returning().then((rows) => rows[0]);
await recordCreated(proposal, txDb);
return proposal;
},
);
}
async function createBinding(context: Pick<ProposalRunContext, "companyId" | "heartbeatRunId">, input: {
secretId?: string | null;
secretProposalId?: string | null;
targetAgentId?: string | null;
configPath: string;
justification: string;
bindingTargetPolicy: "self_and_reports";
}) {
if (Boolean(input.secretId) === Boolean(input.secretProposalId)) {
throw unprocessable("Binding proposals require exactly one of secretId or secretProposalId");
}
if (!CONFIG_PATH_RE.test(input.configPath)) throw unprocessable("configPath must use env.<KEY> or access.<ALIAS>");
if (!input.justification.trim()) throw unprocessable("Justification is required");
const { run, originIssueId } = await loadRunContext(db, context);
const targetAgentId = input.targetAgentId ?? run.agentId;
const [proposerAncestors, targetAncestors] = await Promise.all([
ancestorIds(db, context.companyId, run.agentId),
ancestorIds(db, context.companyId, targetAgentId),
]);
if (!bindingTargetAllowed(run.agentId, targetAgentId, targetAncestors)) {
throw forbidden("Binding proposals may target only the proposing agent or its reports");
}
if (input.secretId) {
const secret = await db.select().from(companySecrets).where(and(
eq(companySecrets.id, input.secretId),
eq(companySecrets.companyId, context.companyId),
)).then((rows) => rows[0] ?? null);
if (!secret || secret.scope !== "company" || secret.status === "deleted") throw notFound("Secret not found");
}
return createWithinQuota(
{ companyId: context.companyId, agentId: run.agentId, runId: run.id, issueId: originIssueId },
async (txDb) => {
if (input.secretProposalId) {
const dependency = await txDb.select().from(companySecretProposals).where(and(
eq(companySecretProposals.id, input.secretProposalId),
eq(companySecretProposals.companyId, context.companyId),
)).for("update").then((rows) => rows[0] ?? null);
if (!dependency || dependency.kind !== "secret") throw notFound("Secret proposal not found");
if (dependency.status !== "pending") {
throw unprocessable(
"Prerequisite secret proposal is no longer pending; use secretId to reference an approved secret",
);
}
}
const proposal = await txDb.insert(companySecretProposals).values({
companyId: context.companyId,
kind: "binding",
justification: input.justification.trim(),
secretId: input.secretId ?? null,
secretProposalId: input.secretProposalId ?? null,
targetType: "agent",
targetId: targetAgentId,
configPath: input.configPath,
bindingTargetPolicySnapshot: input.bindingTargetPolicy,
proposerAncestorIdsSnapshot: proposerAncestors,
targetAncestorIdsSnapshot: targetAncestors,
proposedByAgentId: run.agentId,
originIssueId,
originRunId: run.id,
expiresAt: new Date(Date.now() + PENDING_EXPIRY_MS),
}).returning().then((rows) => rows[0]);
await recordCreated(proposal, txDb);
return proposal;
},
);
}
async function enrich(proposal: Proposal) {
const [proposer, target, originIssue, secret, secretProposal] = await Promise.all([
db.select({ id: agents.id, name: agents.name, icon: agents.icon }).from(agents)
.where(eq(agents.id, proposal.proposedByAgentId)).then((rows) => rows[0] ?? null),
proposal.targetId
? db.select({ id: agents.id, name: agents.name, icon: agents.icon }).from(agents)
.where(eq(agents.id, proposal.targetId)).then((rows) => rows[0] ?? null)
: Promise.resolve(null),
proposal.originIssueId
? db.select({ id: issues.id, key: issues.identifier, title: issues.title }).from(issues)
.where(eq(issues.id, proposal.originIssueId)).then((rows) => rows[0] ?? null)
: Promise.resolve(null),
proposal.secretId
? db.select({ name: companySecrets.name }).from(companySecrets)
.where(eq(companySecrets.id, proposal.secretId)).then((rows) => rows[0] ?? null)
: Promise.resolve(null),
proposal.secretProposalId
? db.select({ proposedName: companySecretProposals.proposedName }).from(companySecretProposals)
.where(eq(companySecretProposals.id, proposal.secretProposalId)).then((rows) => rows[0] ?? null)
: Promise.resolve(null),
]);
if (!proposer) throw notFound("Proposal agent not found");
const {
valueCiphertext: _ciphertext,
bindingTargetPolicySnapshot: _policy,
proposerAncestorIdsSnapshot: _proposerAncestors,
targetAncestorIdsSnapshot: _targetAncestors,
...safe
} = proposal;
return {
...safe,
secretName: secret?.name ?? null,
secretProposalName: secretProposal?.proposedName ?? null,
proposedBy: proposer,
target,
originIssue,
};
}
async function listForAgent(
companyId: string,
agentId: string,
options: { limit?: number; offset?: number } = {},
) {
const rows = await db.select().from(companySecretProposals).where(and(
eq(companySecretProposals.companyId, companyId),
or(
eq(companySecretProposals.proposedByAgentId, agentId),
and(eq(companySecretProposals.kind, "binding"), eq(companySecretProposals.targetId, agentId)),
),
)).orderBy(desc(companySecretProposals.createdAt))
.limit(options.limit ?? DEFAULT_PROPOSAL_LIST_LIMIT)
.offset(options.offset ?? 0);
return Promise.all(rows.map(enrich));
}
async function listForBoard(
companyId: string,
status?: string | null,
options: { limit?: number; offset?: number } = {},
) {
const rows = await db.select().from(companySecretProposals).where(and(
eq(companySecretProposals.companyId, companyId),
status ? eq(companySecretProposals.status, status) : undefined,
)).orderBy(desc(companySecretProposals.createdAt))
.limit(options.limit ?? DEFAULT_PROPOSAL_LIST_LIMIT)
.offset(options.offset ?? 0);
return Promise.all(rows.map(enrich));
}
async function assertBindingSnapshotCurrent(proposal: Proposal, dbClient: Db = db, lockForUpdate = false) {
if (proposal.kind !== "binding" || !proposal.targetId) return;
const snapshotAllowed = proposal.bindingTargetPolicySnapshot === "self_and_reports"
&& bindingTargetAllowed(proposal.proposedByAgentId, proposal.targetId, proposal.targetAncestorIdsSnapshot ?? []);
const currentTargetAncestors = await ancestorIds(
dbClient,
proposal.companyId,
proposal.targetId,
lockForUpdate,
);
const currentAllowed = bindingTargetAllowed(proposal.proposedByAgentId, proposal.targetId, currentTargetAncestors);
if (!snapshotAllowed || !currentAllowed) {
throw conflict("Binding proposal target is no longer allowed by its proposal-time and current chain-of-command policy");
}
}
async function applySecretApproval(txDb: Db, proposal: Proposal, input: {
resolvedByUserId: string;
overrides?: { name?: string; description?: string | null; providerConfigId?: string | null };
}) {
if (!proposal.valueCiphertext) throw conflict("Proposed secret value is no longer available");
const name = input.overrides?.name?.trim() || proposal.proposedName || "";
if (!SECRET_NAME_RE.test(name)) throw unprocessable("Secret name must be a slash-separated path without empty segments");
const value = await getSecretProvider("local_encrypted").resolveVersion({
material: proposal.valueCiphertext,
externalRef: null,
});
const secrets = secretService(txDb);
const providerConfigId = input.overrides?.providerConfigId ?? null;
const providerConfig = providerConfigId
? await secrets.getProviderConfigById(providerConfigId)
: null;
if (providerConfigId && (!providerConfig || providerConfig.companyId !== proposal.companyId)) {
throw notFound("Provider vault not found");
}
const provider = (providerConfig?.provider ?? "local_encrypted") as SecretProvider;
const created = await secrets.create(
proposal.companyId,
{
name,
key: proposal.proposedKey || normalizeSecretKey(name.split("/").at(-1) || ""),
provider,
providerConfigId,
value,
description: input.overrides?.description === undefined
? proposal.proposedDescription
: input.overrides.description,
},
{ userId: input.resolvedByUserId, agentId: proposal.proposedByAgentId },
);
await logActivity(txDb, {
companyId: proposal.companyId,
actorType: "user",
actorId: input.resolvedByUserId,
action: "secret.created",
entityType: "secret",
entityId: created.id,
agentId: proposal.proposedByAgentId,
runId: proposal.originRunId,
details: { name: created.name, provider: created.provider, proposalId: proposal.id },
});
return created;
}
async function markApproved(txDb: Db, proposal: Proposal, input: {
resolvedByUserId: string;
createdSecretId?: string | null;
appliedBindingConfigPath?: string | null;
}) {
const now = new Date();
const updated = await txDb.update(companySecretProposals).set({
status: "approved",
resolvedByUserId: input.resolvedByUserId,
resolvedAt: now,
createdSecretId: input.createdSecretId ?? null,
appliedBindingConfigPath: input.appliedBindingConfigPath ?? null,
valueCiphertext: null,
ciphertextScrubbedAt: now,
updatedAt: now,
}).where(and(
eq(companySecretProposals.id, proposal.id),
eq(companySecretProposals.status, "pending"),
)).returning().then((rows) => rows[0] ?? null);
if (!updated) throw conflict("Proposal is no longer pending");
await logActivity(txDb, {
companyId: proposal.companyId,
actorType: "user",
actorId: input.resolvedByUserId,
action: "secret.proposal.approved",
entityType: "company_secret_proposal",
entityId: proposal.id,
agentId: proposal.proposedByAgentId,
runId: proposal.originRunId,
details: {
issueId: proposal.originIssueId,
createdSecretId: input.createdSecretId ?? null,
appliedBindingConfigPath: input.appliedBindingConfigPath ?? null,
ciphertextScrubbed: true,
},
});
return updated;
}
async function applyBindingApproval(txDb: Db, proposal: Proposal, secretId: string, resolvedByUserId: string) {
if (!proposal.targetId || !proposal.configPath) throw conflict("Binding proposal is incomplete");
const agentSvc = agentService(txDb);
const target = await agentSvc.getById(proposal.targetId);
if (!target || target.companyId !== proposal.companyId) throw notFound("Target agent not found");
const adapterConfig = { ...asRecord(target.adapterConfig) };
const [namespace, key] = proposal.configPath.split(".", 2);
const binding = { type: "secret_ref", secretId, version: "latest" };
if (namespace === "env") {
const env = { ...asRecord(adapterConfig.env) };
const existing = env[key];
if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(binding)) {
throw conflict(`Agent config path already exists: ${proposal.configPath}`);
}
adapterConfig.env = { ...env, [key]: binding };
} else {
const existing = adapterConfig[proposal.configPath];
if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(binding)) {
throw conflict(`Agent config path already exists: ${proposal.configPath}`);
}
adapterConfig[proposal.configPath] = binding;
}
const updated = await agentSvc.update(target.id, { adapterConfig }, {
recordRevision: { createdByUserId: resolvedByUserId, source: "patch" },
});
if (!updated) throw notFound("Target agent not found");
await logActivity(txDb, {
companyId: proposal.companyId,
actorType: "user",
actorId: resolvedByUserId,
action: "agent.updated",
entityType: "agent",
entityId: target.id,
details: { adapterConfig: true, proposalId: proposal.id, configPath: proposal.configPath },
});
}
async function approve(companyId: string, proposalId: string, input: {
resolvedByUserId: string;
cascade?: boolean;
overrides?: { name?: string; description?: string | null; providerConfigId?: string | null };
}) {
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const proposal = await requirePending(companyId, proposalId, txDb, true);
assertNotExpired(proposal);
await assertBindingSnapshotCurrent(proposal, txDb, true);
if (proposal.kind === "secret") {
const created = await applySecretApproval(txDb, proposal, input);
return markApproved(txDb, proposal, {
resolvedByUserId: input.resolvedByUserId,
createdSecretId: created.id,
});
}
let secretId = proposal.secretId;
if (proposal.secretProposalId) {
const dependency = await getById(companyId, proposal.secretProposalId, txDb, true);
if (!dependency || dependency.kind !== "secret") throw notFound("Prerequisite secret proposal not found");
if (dependency.status !== "pending") {
if (dependency.status !== "approved" || !dependency.createdSecretId) {
throw conflict(`Prerequisite secret proposal ${dependency.id} is not approvable`);
}
secretId = dependency.createdSecretId;
} else {
if (!input.cascade) {
throw conflict(`Binding proposal requires pending secret proposal ${dependency.id}; retry with cascade=true`);
}
assertNotExpired(dependency);
const created = await applySecretApproval(txDb, dependency, input);
await markApproved(txDb, dependency, {
resolvedByUserId: input.resolvedByUserId,
createdSecretId: created.id,
});
secretId = created.id;
}
}
if (!secretId) throw conflict("Binding proposal has no approved secret");
const liveSecret = await secretService(txDb).getById(secretId);
if (!liveSecret || liveSecret.companyId !== companyId || liveSecret.status !== "active") {
throw conflict("Binding proposal secret is not an active company secret");
}
await applyBindingApproval(txDb, proposal, secretId, input.resolvedByUserId);
return markApproved(txDb, proposal, {
resolvedByUserId: input.resolvedByUserId,
appliedBindingConfigPath: proposal.configPath,
});
});
}
async function transition(companyId: string, proposalId: string, status: Exclude<SecretProposalTerminalStatus, "approved">, input: {
resolvedByUserId?: string | null;
reason?: string | null;
proposerAgentId?: string | null;
} = {}) {
const proposal = await requirePending(companyId, proposalId);
if (status === "withdrawn" && proposal.proposedByAgentId !== input.proposerAgentId) {
throw forbidden("Only the proposer can withdraw this proposal");
}
const now = new Date();
return db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const updated = await tx.update(companySecretProposals).set({
status,
resolvedByUserId: input.resolvedByUserId ?? null,
resolvedAt: now,
resolutionReason: input.reason ?? null,
valueCiphertext: null,
ciphertextScrubbedAt: now,
updatedAt: now,
}).where(and(eq(companySecretProposals.id, proposalId), eq(companySecretProposals.status, "pending")))
.returning().then((rows) => rows[0] ?? null);
if (!updated) throw conflict("Proposal is no longer pending");
const dependents = proposal.kind === "secret" && (status === "rejected" || status === "expired" || status === "withdrawn")
? await tx.update(companySecretProposals).set({
status: "rejected",
resolvedByUserId: input.resolvedByUserId ?? null,
resolvedAt: now,
resolutionReason: `Dependent secret proposal ${proposal.id} was ${status}`,
valueCiphertext: null,
ciphertextScrubbedAt: now,
updatedAt: now,
}).where(and(
eq(companySecretProposals.companyId, companyId),
eq(companySecretProposals.status, "pending"),
eq(companySecretProposals.secretProposalId, proposal.id),
)).returning()
: [];
const actorType = input.resolvedByUserId ? "user" as const : status === "withdrawn" ? "agent" as const : "system" as const;
const actorId = input.resolvedByUserId ?? input.proposerAgentId ?? "system";
await logActivity(txDb, {
companyId,
actorType,
actorId,
action: `secret.proposal.${status}`,
entityType: "company_secret_proposal",
entityId: proposal.id,
agentId: proposal.proposedByAgentId,
runId: proposal.originRunId,
details: { ciphertextScrubbed: true, issueId: proposal.originIssueId, reason: input.reason ?? null },
});
for (const dependent of dependents) {
await logActivity(txDb, {
companyId,
actorType,
actorId,
action: "secret.proposal.rejected",
entityType: "company_secret_proposal",
entityId: dependent.id,
agentId: dependent.proposedByAgentId,
runId: dependent.originRunId,
details: {
ciphertextScrubbed: true,
issueId: dependent.originIssueId,
reason: dependent.resolutionReason,
cascadeFromProposalId: proposal.id,
},
});
}
return updated;
});
}
async function sweepExpired(
now = new Date(),
limit = DEFAULT_EXPIRY_SWEEP_LIMIT,
expireProposal: (companyId: string, proposalId: string) => Promise<unknown> =
(companyId, proposalId) => transition(companyId, proposalId, "expired", { reason: "Pending proposal expired" }),
) {
const expired = await db.select({ id: companySecretProposals.id, companyId: companySecretProposals.companyId })
.from(companySecretProposals)
.where(and(eq(companySecretProposals.status, "pending"), lte(companySecretProposals.expiresAt, now)))
.orderBy(companySecretProposals.expiresAt)
.limit(limit);
let expiredCount = 0;
for (const proposal of expired) {
try {
await expireProposal(proposal.companyId, proposal.id);
expiredCount += 1;
} catch (error) {
if (error instanceof HttpError && error.status === 409) continue;
throw error;
}
}
return expiredCount;
}
return { getById, view: enrich, createSecret, createBinding, listForAgent, listForBoard, assertBindingSnapshotCurrent, approve, transition, sweepExpired };
}

View File

@ -531,7 +531,7 @@ function isSensitiveEnvKey(key: string) {
return SENSITIVE_ENV_KEY_RE.test(key);
}
function normalizeSecretKey(input: string) {
export function normalizeSecretKey(input: string) {
return input
.trim()
.toLowerCase()

View File

@ -445,6 +445,13 @@ When an issue needs browser/manual QA or a preview server, inspect its current e
For commands, response fields, and MCP tools, read:
`skills/paperclip/references/issue-workspaces.md`
## Proposing Credentials Safely
**When you receive a credential, propose it as a Paperclip secret immediately with `POST /api/agents/me/secret-proposals`. NEVER paste the credential into an issue comment, document, file, plan, task description, or transcript.** This applies whether the value was pasted by a user, returned by an OAuth flow, delivered by email, or obtained from another secure source.
Before proposing a credential you MUST read the "Agent secret proposals" section in:
`skills/paperclip/references/api-reference.md`
## Reading Granted Secrets
When authenticated with the current run's agent JWT, list the secrets available to that run before fetching a value:
@ -594,6 +601,7 @@ If `plan` already exists, fetch the current document first and send its latest `
| Execution workspace + runtime | `GET /api/execution-workspaces/:id``POST …/runtime-services/:action` |
| Set agent instructions path | `PATCH /api/agents/:agentId/instructions-path` |
| List agents | `GET /api/companies/:companyId/agents` |
| Secret proposals | `POST\|GET /api/agents/me/secret-proposals``DELETE /api/agents/me/secret-proposals/:id` |
| Dashboard | `GET /api/companies/:companyId/dashboard` |
Full endpoint table (company imports/exports, OpenClaw invites, company skills, routines, etc.) lives in `references/api-reference.md`.

View File

@ -1345,9 +1345,73 @@ Terminal states: `done`, `cancelled`
| GET | `/api/companies/:companyId/secrets` | List secrets (metadata only) |
| POST | `/api/companies/:companyId/secrets` | Create secret |
| PATCH | `/api/secrets/:secretId` | Update secret value (creates new version) |
| POST | `/api/agents/me/secret-proposals` | Propose a secret or agent binding for board approval |
| GET | `/api/agents/me/secret-proposals` | List proposals created by the agent and incoming bindings targeting it |
| DELETE | `/api/agents/me/secret-proposals/:id` | Withdraw one pending proposal created by the agent |
| GET | `/api/agents/me/secrets` | List secrets accessible to the current run (metadata only) |
| POST | `/api/agents/me/secrets/:key/value` | Fetch one granted secret value; request body is empty |
#### Agent secret proposals
**Never paste a credential into a comment, document, file, or transcript.** When a credential is supplied to an agent or returned by a secure flow — pasted by a user, returned by an OAuth flow, delivered by email, or obtained from another secure source — send it directly to `POST /api/agents/me/secret-proposals` using the current run-bound agent JWT. Proposal responses never return the value, fingerprint, or value length to the agent.
Keep the credential in memory or pass it directly from the secure source; do not place the literal value in the command text or echo it. The example assumes `PROPOSED_SECRET_VALUE` is already populated without printing it:
```bash
PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"
PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"
jq -n \
--arg name "integrations/vendor/api-token" \
--arg value "$PROPOSED_SECRET_VALUE" \
--arg justification "Credential supplied for the current task" \
'{kind:"secret", name:$name, value:$value, justification:$justification}' |
curl -s -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
"$PAPERCLIP_API_BASE/api/agents/me/secret-proposals"
unset PROPOSED_SECRET_VALUE
```
Full request body fields for a secret proposal:
```json
{
"kind": "secret",
"name": "integrations/vendor/api-token",
"description": "Optional operator-facing description",
"value": "<pass directly from the secure source; do not paste into a transcript>",
"justification": "Credential supplied for the current task"
}
```
`name` is a slash-separated path without whitespace or empty segments. The value is limited to 64 KiB. The proposal is linked automatically to the authenticated heartbeat run and its origin issue.
The response omits the credential. Use the returned proposal `id` to propose a binding; a binding to the proposing agent omits `targetAgentId`:
```bash
jq -n \
--arg secretProposalId "$SECRET_PROPOSAL_ID" \
--arg configPath "env.VENDOR_API_TOKEN" \
--arg justification "Inject the approved credential into my adapter environment" \
'{kind:"binding", secretProposalId:$secretProposalId, configPath:$configPath, justification:$justification}' |
curl -s -X POST \
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
"$PAPERCLIP_API_BASE/api/agents/me/secret-proposals"
```
A binding must specify exactly one of `secretProposalId` or `secretId`. `configPath` accepts `env.<KEY>` for environment injection or `access.<ALIAS>` for API-only access. Under the default `self_and_reports` policy, `targetAgentId` may identify a downward report of the proposer; omitting it targets the proposer. Other targets are denied, and approval rechecks the current chain of command.
`GET /api/agents/me/secret-proposals` returns `{ "proposals": [...] }` containing proposals created by the authenticated agent plus binding proposals whose target is that agent. Secret values, value fingerprints, and value lengths are omitted. `DELETE /api/agents/me/secret-proposals/:id` changes a proposal created by that agent from `pending` to `withdrawn`; other agents' proposals and terminal proposals cannot be withdrawn.
Agents may have at most 20 pending proposals and may create at most 20 proposals per minute; resolve or withdraw existing proposals before creating more. Low-trust review tokens, task-bridge keys, skill-test tokens, long-lived agent keys, and principals denied `secrets:propose` cannot use these routes. Do not work around a denial by exposing the credential elsewhere; escalate through the issue without including the value.
Board approval creates a secret through the normal secret service. Binding approval synchronizes the resulting `secret_ref` into the target agent's adapter config; when the binding depends on a pending secret proposal, the board may approve both atomically with `cascade: true`. Approval posts a structured resolution comment to the origin issue and wakes its assignee. Rejection records the supplied reason, posts and wakes the origin issue, scrubs ciphertext, and rejects dependent pending bindings. Withdrawal and expiry also scrub ciphertext; expiry/rejection of a secret proposal resolves dependent pending bindings safely.
#### Agent secret access
Agent secret access requires the current run-bound agent JWT. An `env.*` binding implies API read access; an `access.*` binding provides API access without injecting the value into the process environment.
List response:

View File

@ -12,6 +12,10 @@ import type {
SecretProviderConfigHealthResponse,
SecretProviderDescriptor,
SecretStatus,
SecretProposalView,
SecretProposalStatus,
ApproveSecretProposalInput,
RejectSecretProposalInput,
UserSecretCoverageSummary,
UserSecretDefinition,
} from "@paperclipai/shared";
@ -230,4 +234,22 @@ export const secretsApi = {
),
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,
),
};

View File

@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent, Environment } from "@paperclipai/shared";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ToastProvider } from "../context/ToastContext";
import { AgentConfigForm } from "./AgentConfigForm";
import { defaultCreateValues } from "./agent-config-defaults";
@ -29,6 +30,7 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
const mockSecretsApi = vi.hoisted(() => ({
list: vi.fn(),
listProposals: vi.fn(),
}));
vi.mock("../api/agents", () => ({
@ -208,16 +210,18 @@ async function renderForm(
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<AgentConfigForm
mode="edit"
agent={makeAgent(agentOverrides)}
onSave={vi.fn()}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
</TooltipProvider>
<ToastProvider>
<TooltipProvider>
<AgentConfigForm
mode="edit"
agent={makeAgent(agentOverrides)}
onSave={vi.fn()}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
@ -253,16 +257,18 @@ async function renderCreateForm(
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<AgentConfigForm
mode="create"
values={values}
onChange={onChange}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
</TooltipProvider>
<ToastProvider>
<TooltipProvider>
<AgentConfigForm
mode="create"
values={values}
onChange={onChange}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
@ -289,6 +295,7 @@ describe("AgentConfigForm environment selector", () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true });
mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "any" });
mockSecretsApi.list.mockResolvedValue([]);
mockSecretsApi.listProposals.mockResolvedValue([]);
});
afterEach(async () => {

View File

@ -54,6 +54,7 @@ import {
type EnvironmentVariablesEditorHandle,
} from "./environment-variables-editor";
import { AgentSecretAccessEditor } from "./AgentSecretAccessEditor";
import { useProposalReview } from "../pages/secrets/proposal-review";
import { AGENT_ACCESS_CONFIG_PATH_PREFIX } from "../lib/secret-delivery";
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
@ -234,6 +235,25 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
enabled: Boolean(selectedCompanyId),
retry: false,
});
// Pending binding proposals targeting this agent (PAP-14731). Board-only route;
// non-permitted viewers simply get an empty list.
const editAgentId = !isCreate ? props.agent.id : null;
const { data: pendingProposals = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.proposals(selectedCompanyId, "pending")
: ["secret-proposals", "none"],
queryFn: () => secretsApi.listProposals(selectedCompanyId!, "pending"),
enabled: Boolean(selectedCompanyId) && !isCreate,
retry: false,
});
const agentBindingProposals = useMemo(
() =>
pendingProposals.filter(
(proposal) => proposal.kind === "binding" && proposal.target?.id === editAgentId,
),
[pendingProposals, editAgentId],
);
const proposalReview = useProposalReview(selectedCompanyId, []);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
@ -1444,7 +1464,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
config={{ ...config, ...overlay.adapterConfig }}
secrets={availableSecrets}
onChange={applyAccessGrants}
proposals={agentBindingProposals}
onApproveProposal={proposalReview.requestApprove}
onRejectProposal={proposalReview.requestReject}
/>
{proposalReview.dialogs}
</Field>
)}

View File

@ -160,4 +160,66 @@ describe("AgentSecretAccessEditor component", () => {
const last = emitted.at(-1)!;
expect(last).toEqual({ STRIPE: { type: "secret_ref", secretId: "s1", version: "latest" } });
});
it("renders pending binding proposals as Proposed rows with approve/reject", () => {
const approved: string[] = [];
const rejected: string[] = [];
const proposal = {
id: "prop-1",
companyId: "co",
kind: "binding" as const,
status: "pending" as const,
justification: "Bind Stripe for the billing agent.",
proposedName: null,
proposedKey: null,
proposedDescription: null,
valueFingerprintSha256: null,
valueLength: null,
secretId: "s1",
secretName: "STRIPE_KEY",
secretProposalId: null,
secretProposalName: null,
targetType: "agent" as const,
target: { id: "agent-1", name: "BillingBot", icon: null },
configPath: "access.STRIPE",
proposedBy: { id: "agent-2", name: "ClaudeCoder", icon: null },
originIssue: null,
originRunId: "run-1",
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
createdAt: new Date(0).toISOString(),
resolvedByUserId: null,
resolvedAt: null,
resolutionReason: null,
createdSecretId: null,
appliedBindingConfigPath: null,
viewerCanApprove: true,
approveBlockReason: null,
};
render(
<AgentSecretAccessEditor
config={{}}
secrets={secrets}
onChange={() => {}}
proposals={[proposal]}
onApproveProposal={(p) => approved.push(p.id)}
onRejectProposal={(p) => rejected.push(p.id)}
/>,
);
expect(container.textContent).toContain("Proposed access");
expect(container.textContent).toContain("STRIPE"); // alias + bound secret name
expect(container.textContent).toContain("ClaudeCoder"); // proposer
const approveButton = [...container.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Approve",
)!;
flushSync(() => approveButton.click());
expect(approved).toEqual(["prop-1"]);
const rejectButton = [...container.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Reject",
)!;
flushSync(() => rejectButton.click());
expect(rejected).toEqual(["prop-1"]);
});
});

View File

@ -1,6 +1,11 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { KeyRound, Plus, ServerCog, Trash2, Variable } from "lucide-react";
import type { CompanySecret, EnvSecretRefBinding, SecretVersionSelector } from "@paperclipai/shared";
import type {
CompanySecret,
EnvSecretRefBinding,
SecretProposalView,
SecretVersionSelector,
} from "@paperclipai/shared";
import { cn } from "../lib/utils";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@ -12,6 +17,13 @@ import {
deliveryModeDescription,
} from "../lib/secret-delivery";
import { envKeyFromSecretName } from "./environment-variables-editor/model";
import {
DeliveryBadge as ProposalDeliveryBadge,
ProposalActions,
ProposedBadge,
bindingEnvKey,
bindingSecretLabel,
} from "../pages/secrets/proposal-review";
/* -------------------------------------------------------------------------- */
/* Pure model (exported for tests) */
@ -146,6 +158,12 @@ export interface AgentSecretAccessEditorProps {
*/
onChange: (next: Record<string, EnvSecretRefBinding>) => void;
disabled?: boolean;
/** Pending binding proposals targeting this agent (PAP-14731). */
proposals?: readonly SecretProposalView[];
/** Open the approve confirm dialog for a proposal (wired by the parent surface). */
onApproveProposal?: (proposal: SecretProposalView) => void;
/** Open the reject dialog for a proposal (wired by the parent surface). */
onRejectProposal?: (proposal: SecretProposalView) => void;
}
function DeliveryBadge({ mode }: { mode: "env" | "api" }) {
@ -169,7 +187,19 @@ function DeliveryBadge({ mode }: { mode: "env" | "api" }) {
);
}
export function AgentSecretAccessEditor({ config, secrets, onChange, disabled }: AgentSecretAccessEditorProps) {
export function AgentSecretAccessEditor({
config,
secrets,
onChange,
disabled,
proposals,
onApproveProposal,
onRejectProposal,
}: AgentSecretAccessEditorProps) {
const bindingProposals = useMemo(
() => (proposals ?? []).filter((proposal) => proposal.kind === "binding"),
[proposals],
);
const envBindings = useMemo(() => parseEnvSecretRefs(config), [config]);
const apiBindings = useMemo(() => parseAccessGrants(config), [config]);
const summaries = useMemo(() => summarizeAgentBindings(envBindings, apiBindings), [envBindings, apiBindings]);
@ -251,6 +281,48 @@ export function AgentSecretAccessEditor({ config, secrets, onChange, disabled }:
<p className="text-sm text-muted-foreground">No secrets are bound to this agent yet.</p>
)}
{/* Pending binding proposals targeting this agent (PAP-14731). */}
{bindingProposals.length > 0 && onApproveProposal && onRejectProposal ? (
<div className="space-y-2">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
Proposed access
</div>
{bindingProposals.map((proposal) => {
const secret = bindingSecretLabel(proposal);
const envKey = bindingEnvKey(proposal);
return (
<div
key={proposal.id}
className="flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-2.5 py-2 text-xs sm:flex-row sm:items-center"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<ProposalDeliveryBadge configPath={proposal.configPath} />
<code className="font-mono">{envKey || proposal.configPath}</code>
<span className="text-muted-foreground"></span>
<KeyRound className="size-3 text-muted-foreground" />
<span className="font-medium">{secret.name}</span>
{secret.pending ? <ProposedBadge /> : null}
</div>
<p className="flex flex-wrap items-center gap-1 text-muted-foreground">
<span>proposed by {proposal.proposedBy.name}</span>
<span aria-hidden="true">·</span>
<span className="truncate italic">{proposal.justification}</span>
</p>
</div>
<ProposalActions
proposal={proposal}
onApprove={onApproveProposal}
onReject={onRejectProposal}
disabled={disabled}
size="xs"
/>
</div>
);
})}
</div>
) : null}
{/* Editable API-access grants (access.<ALIAS>). */}
<div className="space-y-2">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">

View File

@ -382,6 +382,8 @@ export const queryKeys = {
userDefinitionCoverage: (companyId: string, definitionId: string) =>
["user-secret-definitions", companyId, definitionId, "coverage"] as const,
myUserSecrets: (companyId: string) => ["my-user-secrets", companyId] as const,
proposals: (companyId: string, status: string = "pending") =>
["secret-proposals", companyId, status] as const,
},
companySearch: {
search: (companyId: string, q: string, scope: string, limit: number, offset: number) =>

View File

@ -50,6 +50,9 @@ const mockSecretsApi = vi.hoisted(() => ({
updateMyUserSecret: vi.fn(),
rotateMyUserSecret: vi.fn(),
removeMyUserSecret: vi.fn(),
listProposals: vi.fn(),
approveProposal: vi.fn(),
rejectProposal: vi.fn(),
}));
const mockAgentsApi = vi.hoisted(() => ({
@ -382,6 +385,7 @@ describe("Secrets page layout", () => {
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]);
mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage);
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
mockSecretsApi.listProposals.mockResolvedValue([]);
mockAgentsApi.list.mockResolvedValue([]);
});

View File

@ -110,6 +110,7 @@ import { PageTabBar } from "../components/PageTabBar";
import { AgentSelect } from "../components/AgentMultiSelect";
import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog";
import { MyUserSecretsTab } from "./secrets/MyUserSecretsTab";
import { ProposalsTab } from "./secrets/ProposalsTab";
import { SecretPathName } from "./secrets/SecretPathName";
import {
buildSecretPathBreadcrumbs,
@ -132,7 +133,7 @@ type CreateMode = "managed" | "external";
type RotateMode = "value" | "reference";
type SecretValueProvider = "company" | "user";
type ProvidedByFilter = "all" | SecretValueProvider;
type SecretsTab = "secrets" | "my-secrets" | "vaults";
type SecretsTab = "secrets" | "my-secrets" | "vaults" | "proposals";
type SecretsViewMode = "folders" | "flat";
const SECRETS_VIEW_MODE_STORAGE_KEY = "paperclip.secrets.viewMode";
@ -773,8 +774,17 @@ export function Secrets() {
retry: false,
});
const proposalsQuery = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.proposals(selectedCompanyId, "pending")
: ["secret-proposals", "__disabled__"],
queryFn: () => secretsApi.listProposals(selectedCompanyId!, "pending"),
enabled: Boolean(selectedCompanyId),
});
const secrets = secretsQuery.data ?? EMPTY_SECRETS;
const userDefinitions = userDefinitionsQuery.data ?? EMPTY_USER_SECRET_DEFINITIONS;
const pendingProposalCount = proposalsQuery.data?.length ?? 0;
const myUserSecrets = myUserSecretsQuery.data ?? EMPTY_MY_USER_SECRETS;
const providers = providersQuery.data ?? EMPTY_SECRET_PROVIDERS;
const providerConfigs = providerConfigsQuery.data ?? EMPTY_PROVIDER_CONFIGS;
@ -1799,6 +1809,22 @@ export function Secrets() {
{ value: "secrets", label: "Secrets" },
{ value: "my-secrets", label: "My secrets" },
{ value: "vaults", label: "Provider vaults" },
{
value: "proposals",
label: (
<span className="inline-flex items-center gap-1.5">
Proposals
{pendingProposalCount > 0 ? (
<Badge
variant="outline"
className="h-4 min-w-4 justify-center rounded-full border-amber-500/40 bg-amber-500/10 px-1 text-(length:--text-nano) font-medium text-amber-700 dark:text-amber-300"
>
{pendingProposalCount}
</Badge>
) : null}
</span>
),
},
]}
align="start"
value={activeTab}
@ -2184,6 +2210,11 @@ export function Secrets() {
}
/>
</TabsContent>
<TabsContent value="proposals" className="min-h-0 flex-1 overflow-y-auto">
{selectedCompanyId ? (
<ProposalsTab companyId={selectedCompanyId} providerConfigs={providerConfigs} />
) : null}
</TabsContent>
</Tabs>
<Sheet

View File

@ -262,13 +262,15 @@ describe("AuditFeed", () => {
);
expect(listAgentActionsMock.mock.calls.some(([, filters]) => filters.from)).toBe(true);
expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual(
expect.objectContaining({ actorScope: "all", from: undefined }),
);
expect(container.textContent).toContain("commented on");
expect(container.textContent).not.toContain("Paperclip Enterprise view");
expect(container.textContent).not.toContain("All agents");
expect(container.textContent).not.toContain("Export CSV");
await vi.waitFor(() => {
expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual(
expect.objectContaining({ actorScope: "all", from: undefined }),
);
expect(container.textContent).toContain("commented on");
expect(container.textContent).not.toContain("Paperclip Enterprise view");
expect(container.textContent).not.toContain("All agents");
expect(container.textContent).not.toContain("Export CSV");
});
});
it("drops cached privileged pages when pagination observes an access downgrade", async () => {

View File

@ -0,0 +1,322 @@
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CompanySecretProviderConfig, SecretProposalView } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProposalsTab } from "./ProposalsTab";
const mockSecretsApi = vi.hoisted(() => ({
listProposals: vi.fn(),
approveProposal: vi.fn(),
rejectProposal: vi.fn(),
}));
const mockPushToast = vi.hoisted(() => vi.fn());
vi.mock("../../api/secrets", () => ({ secretsApi: mockSecretsApi }));
vi.mock("../../context/ToastContext", () => ({
useToast: () => ({ pushToast: mockPushToast }),
useToastActions: () => ({ pushToast: mockPushToast }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const providerConfigs = [
{
id: "vault-local",
provider: "local_encrypted",
displayName: "Local default",
status: "ready",
isDefault: true,
healthStatus: "ready",
healthCheckedAt: null,
healthMessage: null,
healthDetails: null,
},
] satisfies Partial<CompanySecretProviderConfig>[];
function makeSecretProposal(overrides: Partial<SecretProposalView> = {}): SecretProposalView {
return {
id: "prop-secret-1",
companyId: "company-1",
kind: "secret",
status: "pending",
justification: "Need the GitHub token to open the PR.",
proposedName: "dev/github/token",
proposedKey: "token",
proposedDescription: null,
valueFingerprintSha256: "abcdef0123456789abcdef",
valueLength: 40,
secretId: null,
secretName: null,
secretProposalId: null,
secretProposalName: null,
targetType: null,
target: null,
configPath: null,
proposedBy: { id: "agent-coder", name: "ClaudeCoder", icon: "code" },
originIssue: { id: "issue-1", key: "PAP-14743", title: "UI review surface" },
originRunId: "run-1",
expiresAt: new Date(Date.now() + 12 * 24 * 3600_000).toISOString(),
createdAt: "2026-07-20T00:00:00.000Z",
resolvedByUserId: null,
resolvedAt: null,
resolutionReason: null,
createdSecretId: null,
appliedBindingConfigPath: null,
viewerCanApprove: true,
approveBlockReason: null,
...overrides,
};
}
function makeBindingProposal(overrides: Partial<SecretProposalView> = {}): SecretProposalView {
return {
...makeSecretProposal(),
id: "prop-binding-1",
kind: "binding",
justification: "Bind the token to the deployer agent.",
proposedName: null,
proposedKey: null,
valueFingerprintSha256: null,
valueLength: null,
secretId: null,
secretName: null,
secretProposalId: "prop-secret-1",
secretProposalName: "dev/github/token",
targetType: "agent",
target: { id: "agent-deployer", name: "DeployBot", icon: "rocket" },
configPath: "env.GITHUB_TOKEN",
...overrides,
};
}
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function waitForReact(predicate: () => boolean, attempts = 20) {
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (predicate()) return;
await flushReact();
}
throw new Error("Timed out waiting for React state to settle");
}
function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
const proto =
input instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function findButton(label: string): HTMLButtonElement | undefined {
return [...document.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === label,
) as HTMLButtonElement | undefined;
}
let activeRoot: ReturnType<typeof createRoot> | null = null;
let activeContainer: HTMLDivElement | null = null;
async function renderTab(
companyId = "company-1",
configs: CompanySecretProviderConfig[] = providerConfigs as CompanySecretProviderConfig[],
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
activeRoot = root;
activeContainer = container;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ProposalsTab
companyId={companyId}
providerConfigs={configs}
/>
</MemoryRouter>
</QueryClientProvider>,
);
});
await flushReact();
return { container, root };
}
describe("ProposalsTab", () => {
afterEach(async () => {
// Unmount the React root first so Radix dialog portals clean themselves up
// before the DOM is wiped (else jsdom throws NotFoundError on teardown).
if (activeRoot) await act(async () => activeRoot!.unmount());
activeContainer?.remove();
activeRoot = null;
activeContainer = null;
document.body.innerHTML = "";
vi.clearAllMocks();
});
beforeEach(() => {
mockSecretsApi.approveProposal.mockResolvedValue(makeSecretProposal({ status: "approved" }));
mockSecretsApi.rejectProposal.mockResolvedValue(makeSecretProposal({ status: "rejected" }));
});
it("shows a fingerprint and provenance but never the value", async () => {
mockSecretsApi.listProposals.mockResolvedValue([makeSecretProposal()]);
await renderTab();
await waitForReact(() => document.body.textContent?.includes("dev/github/token") ?? false);
const text = document.body.textContent ?? "";
expect(text).toContain("token"); // folder-styled leaf
expect(text).toContain("sha256:abcdef0123"); // fingerprint
expect(text).toContain("40 bytes"); // length
expect(text).toContain("ClaudeCoder"); // proposer
expect(text).toContain("PAP-14743"); // origin issue
// Justification is framed as an untrusted, agent-authored claim (Q0 concern).
expect(text).toContain("Reason given by the agent");
// Never renders a raw secret value.
expect(text).not.toContain("ghp_");
});
it("disables Approve with the authz explanation when the viewer can't approve", async () => {
mockSecretsApi.listProposals.mockResolvedValue([
makeSecretProposal({
viewerCanApprove: false,
approveBlockReason: "You need secrets write to approve this.",
}),
]);
await renderTab();
await waitForReact(() => Boolean(findButton("Approve")));
const approve = findButton("Approve");
expect(approve?.disabled).toBe(true);
});
it("opens the secret confirm dialog with re-folder/rename and approves with overrides", async () => {
mockSecretsApi.listProposals.mockResolvedValue([makeSecretProposal()]);
await renderTab();
await waitForReact(() => Boolean(findButton("Approve")));
await act(async () => findButton("Approve")?.click());
await flushReact();
const nameInput = document.getElementById("approve-name") as HTMLInputElement | null;
const folderInput = document.getElementById("approve-folder") as HTMLInputElement | null;
expect(nameInput?.value).toBe("token");
expect(folderInput?.value).toBe("dev/github");
// Dialog restates the fingerprint, never a value.
expect(document.body.textContent).toContain("sha256:abcdef0123");
await act(async () => {
if (nameInput) setInputValue(nameInput, "client-secret");
});
await flushReact();
await act(async () => findButton("Approve & create")?.click());
await waitForReact(() => mockSecretsApi.approveProposal.mock.calls.length > 0);
expect(mockSecretsApi.approveProposal).toHaveBeenCalledWith("company-1", "prop-secret-1", {
overrides: {
name: "dev/github/client-secret",
description: null,
providerConfigId: "vault-local",
},
});
});
it("does not preselect a non-local default vault for secret approval", async () => {
mockSecretsApi.listProposals.mockResolvedValue([makeSecretProposal()]);
await renderTab("company-1", [
{ ...providerConfigs[0], id: "vault-aws", provider: "aws_secrets_manager" },
{ ...providerConfigs[0], isDefault: false },
] as CompanySecretProviderConfig[]);
await waitForReact(() => Boolean(findButton("Approve")));
await act(async () => findButton("Approve")?.click());
await flushReact();
await act(async () => findButton("Approve & create")?.click());
await waitForReact(() => mockSecretsApi.approveProposal.mock.calls.length > 0);
expect(mockSecretsApi.approveProposal).toHaveBeenCalledWith("company-1", "prop-secret-1", {
overrides: {
name: "dev/github/token",
description: null,
providerConfigId: null,
},
});
});
it("surfaces the cascade pairing and approves the binding with cascade", async () => {
mockSecretsApi.listProposals.mockResolvedValue([makeBindingProposal()]);
await renderTab();
await waitForReact(() => Boolean(findButton("Approve")));
// Row shows target, delivery + env key, and the pending-secret pill.
const rowText = document.body.textContent ?? "";
expect(rowText).toContain("DeployBot");
expect(rowText).toContain("GITHUB_TOKEN");
expect(rowText).toContain("Env var");
expect(rowText).toContain("Proposed");
await act(async () => findButton("Approve")?.click());
await flushReact();
// Cascade is pre-checked because the secret is still a pending proposal.
await act(async () => findButton("Approve secret & bind")?.click());
await waitForReact(() => mockSecretsApi.approveProposal.mock.calls.length > 0);
expect(mockSecretsApi.approveProposal).toHaveBeenCalledWith("company-1", "prop-binding-1", {
cascade: true,
});
});
it("requires a reason to reject", async () => {
mockSecretsApi.listProposals.mockResolvedValue([makeSecretProposal()]);
await renderTab();
await waitForReact(() => Boolean(findButton("Reject")));
await act(async () => findButton("Reject")?.click());
await flushReact();
const dialog = document.querySelector('[role="dialog"]');
expect(dialog).not.toBeNull();
const dialogReject = () =>
[...(dialog?.querySelectorAll("button") ?? [])].find(
(b) => b.textContent?.trim() === "Reject",
) as HTMLButtonElement | undefined;
// The dialog's confirm button is disabled until a reason is typed.
const reason = dialog?.querySelector("#reject-reason") as HTMLTextAreaElement | null;
expect(reason).not.toBeNull();
expect(dialogReject()?.disabled).toBe(true);
await act(async () => {
if (reason) setInputValue(reason, "Not needed for this task.");
});
await flushReact();
expect(dialogReject()?.disabled).toBe(false);
await act(async () => dialogReject()?.click());
await waitForReact(() => mockSecretsApi.rejectProposal.mock.calls.length > 0);
expect(mockSecretsApi.rejectProposal).toHaveBeenCalledWith("company-1", "prop-secret-1", {
reason: "Not needed for this task.",
});
});
});

View File

@ -0,0 +1,213 @@
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" /> Couldnt 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[] = [];

View File

@ -0,0 +1,702 @@
import { useCallback, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Copy, Fingerprint, KeyRound, Link2, ShieldAlert, Variable, ServerCog } from "lucide-react";
import type {
CompanySecretProviderConfig,
SecretProposalAgentRef,
SecretProposalView,
} from "@paperclipai/shared";
import { AgentIcon } from "@/components/AgentIconPicker";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useToastActions } from "../../context/ToastContext";
import { secretsApi } from "../../api/secrets";
import { ApiError } from "../../api/client";
import { queryKeys } from "../../lib/queryKeys";
import {
aliasFromConfigPath,
deliveryModeForConfigPath,
deliveryModeLabel,
} from "../../lib/secret-delivery";
import { cn } from "../../lib/utils";
import { copyTextToClipboard } from "../../lib/clipboard";
import { SecretPathName } from "./SecretPathName";
/* -------------------------------------------------------------------------- */
/* Presentation helpers (shared by the tab + agent-settings surfaces) */
/* -------------------------------------------------------------------------- */
/** Short, non-reversible fingerprint label. Never renders the value. */
export function fingerprintLabel(fingerprint: string | null, length: number | null): string {
const digest = fingerprint ? `sha256:${fingerprint.slice(0, 10)}` : "no fingerprint";
const size = typeof length === "number" ? `${length} ${length === 1 ? "byte" : "bytes"}` : null;
return size ? `${digest} · ${size}` : digest;
}
/**
* Fingerprint + length label. When a full digest is present the truncated form
* is click-to-copy so an approver can verify a rotation against a known SHA-256
* (10 hex chars isn't enough entropy to compare on sight). Never the value.
*/
export function FingerprintChip({
fingerprint,
length,
className,
}: {
fingerprint: string | null;
length: number | null;
className?: string;
}) {
const { pushToast } = useToastActions();
const label = fingerprintLabel(fingerprint, length);
if (!fingerprint) {
return (
<span className={cn("inline-flex items-center gap-1 font-mono", className)}>
<Fingerprint className="size-3 shrink-0" />
{label}
</span>
);
}
const full = `sha256:${fingerprint}`;
return (
<button
type="button"
onClick={() => {
copyTextToClipboard(full)
.then(() => pushToast({ title: "Fingerprint copied", tone: "success" }))
.catch(() => pushToast({ title: "Couldnt copy fingerprint", tone: "error" }));
}}
title={`Copy full digest — ${full}`}
className={cn(
"inline-flex items-center gap-1 font-mono hover:text-foreground",
className,
)}
>
<Fingerprint className="size-3 shrink-0" />
{label}
<Copy className="size-3 shrink-0 opacity-60" />
</button>
);
}
/**
* An agent-authored justification, framed as an *untrusted claim* rather than
* trusted UI copy. The muted "Reason given by the agent" caption primes the
* approver to read it skeptically the Q0 social-engineering concern: an agent
* can write "Pre-approved by the CEO" and otherwise inherit the interface's
* credibility.
*/
export function ProposalJustification({
justification,
className,
}: {
justification: string;
className?: string;
}) {
return (
<div className={cn("space-y-0.5", className)}>
<p className="text-(length:--text-micro) text-muted-foreground">Reason given by the agent</p>
<p className="whitespace-pre-wrap break-words text-xs text-foreground/80">
{justification}
</p>
</div>
);
}
/** Compact agent chip: icon + name. */
export function AgentRefChip({
agent,
className,
}: {
agent: SecretProposalAgentRef;
className?: string;
}) {
return (
<span className={cn("inline-flex min-w-0 items-center gap-1", className)}>
<AgentIcon icon={agent.icon ?? null} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{agent.name}</span>
</span>
);
}
/** Env-var vs API-access delivery badge for a binding `configPath`. */
export function DeliveryBadge({ configPath }: { configPath: string | null }) {
const mode = deliveryModeForConfigPath(configPath);
const isEnv = mode === "env";
const isApi = mode === "api";
return (
<Badge
variant="outline"
className={cn(
"h-5 gap-1 px-1.5 text-(length:--text-nano) font-normal",
isEnv && "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300",
isApi && "border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300",
!isEnv && !isApi && "border-border bg-muted/40 text-muted-foreground",
)}
>
{isEnv ? <Variable className="size-3" /> : isApi ? <ServerCog className="size-3" /> : null}
{deliveryModeLabel(mode)}
</Badge>
);
}
/** Distinct "Proposed" pill used wherever a proposal is inlined among live rows. */
export function ProposedBadge({ className }: { className?: string }) {
return (
<Badge
variant="outline"
className={cn(
"h-5 gap-1 px-1.5 text-(length:--text-nano) font-medium",
"border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
className,
)}
>
<ShieldAlert className="size-3" /> Proposed
</Badge>
);
}
/** The env KEY / access ALIAS a binding proposal delivers under, e.g. `GITHUB_TOKEN`. */
export function bindingEnvKey(proposal: SecretProposalView): string {
return aliasFromConfigPath(proposal.configPath);
}
/** Human label for the secret a binding proposal references (live or cascade). */
export function bindingSecretLabel(proposal: SecretProposalView): {
name: string;
pending: boolean;
} {
if (proposal.secretProposalId) {
return { name: proposal.secretProposalName ?? "proposed secret", pending: true };
}
return { name: proposal.secretName ?? "secret", pending: false };
}
function readableError(error: unknown): string {
if (error instanceof ApiError) return error.message || `Request failed: ${error.status}`;
if (error instanceof Error) return error.message;
return "Something went wrong. Try again.";
}
/* -------------------------------------------------------------------------- */
/* Review hook: approve/reject dialogs + mutations, shared across surfaces */
/* -------------------------------------------------------------------------- */
interface ApproveDraft {
proposal: SecretProposalView;
// secret-kind editable fields (re-folder / rename before landing)
folder: string;
leaf: string;
description: string;
providerConfigId: string;
// binding-kind: approve the pending dependency secret in the same transaction
cascade: boolean;
}
export interface UseProposalReview {
/** Open the approve confirm dialog for a proposal. */
requestApprove: (proposal: SecretProposalView) => void;
/** Open the reject (reason required) dialog for a proposal. */
requestReject: (proposal: SecretProposalView) => void;
/** True while an approve/reject request is in flight. */
isBusy: boolean;
/** Render once inside the surface — hosts both dialogs. */
dialogs: React.ReactNode;
}
/**
* Shared approve/reject workflow for secret & binding proposals. Both the
* board Proposals tab and the agent-settings "Proposed" rows use this so the
* confirm/reject affordances behave identically everywhere.
*/
export function useProposalReview(
companyId: string | null,
providerConfigs: CompanySecretProviderConfig[] = [],
): UseProposalReview {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [approveDraft, setApproveDraft] = useState<ApproveDraft | null>(null);
const [rejectTarget, setRejectTarget] = useState<SecretProposalView | null>(null);
const [rejectReason, setRejectReason] = useState("");
const [error, setError] = useState<string | null>(null);
const invalidate = useCallback(() => {
if (!companyId) return;
queryClient.invalidateQueries({ queryKey: ["secret-proposals", companyId] });
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(companyId) });
}, [companyId, queryClient]);
const approveMutation = useMutation({
mutationFn: (draft: ApproveDraft) => {
const { proposal } = draft;
if (proposal.kind === "secret") {
const name = draft.folder.trim()
? `${draft.folder.trim().replace(/\/+$/, "")}/${draft.leaf.trim()}`
: draft.leaf.trim();
return secretsApi.approveProposal(companyId!, proposal.id, {
overrides: {
name,
description: draft.description.trim() || null,
providerConfigId: draft.providerConfigId || null,
},
});
}
return secretsApi.approveProposal(companyId!, proposal.id, {
cascade: draft.cascade || undefined,
});
},
onSuccess: (result) => {
pushToast({
title: result.kind === "secret" ? "Secret approved" : "Binding approved",
body:
result.kind === "secret"
? (result.proposedName ?? "Secret created")
: `${result.target?.name ?? "Agent"} · ${bindingEnvKey(result) || "binding"}`,
tone: "success",
});
setApproveDraft(null);
setError(null);
invalidate();
},
onError: (err) => setError(readableError(err)),
});
const rejectMutation = useMutation({
mutationFn: ({ proposal, reason }: { proposal: SecretProposalView; reason: string }) =>
secretsApi.rejectProposal(companyId!, proposal.id, { reason: reason.trim() }),
onSuccess: (result) => {
pushToast({
title: "Proposal rejected",
body: result.kind === "secret" ? (result.proposedName ?? undefined) : undefined,
tone: "info",
});
setRejectTarget(null);
setRejectReason("");
setError(null);
invalidate();
},
onError: (err) => setError(readableError(err)),
});
const requestApprove = useCallback(
(proposal: SecretProposalView) => {
setError(null);
const segments = (proposal.proposedName ?? "").split("/");
const leaf = segments.pop() ?? "";
setApproveDraft({
proposal,
folder: segments.join("/"),
leaf,
description: proposal.proposedDescription ?? "",
// "" = deployment default vault; the dialog dropdown lets the approver pick one.
providerConfigId:
providerConfigs.find(
(config) => config.provider === "local_encrypted" && config.isDefault,
)?.id ?? "",
// A binding on a still-pending secret proposal REQUIRES cascade to land.
cascade: Boolean(proposal.secretProposalId),
});
},
[providerConfigs],
);
const requestReject = useCallback((proposal: SecretProposalView) => {
setError(null);
setRejectReason("");
setRejectTarget(proposal);
}, []);
const dialogs = (
<>
<ApproveDialog
draft={approveDraft}
error={error}
pending={approveMutation.isPending}
providerConfigs={providerConfigs}
onChange={setApproveDraft}
onCancel={() => {
setApproveDraft(null);
setError(null);
}}
onConfirm={() => approveDraft && approveMutation.mutate(approveDraft)}
/>
<RejectDialog
proposal={rejectTarget}
reason={rejectReason}
error={error}
pending={rejectMutation.isPending}
onReasonChange={setRejectReason}
onCancel={() => {
setRejectTarget(null);
setRejectReason("");
setError(null);
}}
onConfirm={() =>
rejectTarget && rejectMutation.mutate({ proposal: rejectTarget, reason: rejectReason })
}
/>
</>
);
return {
requestApprove,
requestReject,
isBusy: approveMutation.isPending || rejectMutation.isPending,
dialogs,
};
}
/* -------------------------------------------------------------------------- */
/* Approve dialog */
/* -------------------------------------------------------------------------- */
function ApproveDialog({
draft,
error,
pending,
providerConfigs,
onChange,
onCancel,
onConfirm,
}: {
draft: ApproveDraft | null;
error: string | null;
pending: boolean;
providerConfigs: CompanySecretProviderConfig[];
onChange: (next: ApproveDraft) => void;
onCancel: () => void;
onConfirm: () => void;
}) {
const isSecret = draft?.proposal.kind === "secret";
const previewName = draft
? draft.folder.trim()
? `${draft.folder.trim().replace(/\/+$/, "")}/${draft.leaf.trim()}`
: draft.leaf.trim()
: "";
const localConfigs = providerConfigs.filter((config) => config.provider === "local_encrypted");
const canConfirm = isSecret ? Boolean(draft && draft.leaf.trim()) : true;
return (
<Dialog open={Boolean(draft)} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="sm:max-w-lg">
{draft ? (
<>
<DialogHeader>
<DialogTitle>
{isSecret ? "Approve & create secret" : "Approve binding"}
</DialogTitle>
<DialogDescription>
{isSecret
? "The value is created as the proposing agent recorded it. Re-folder or rename it before it lands."
: "Grant the target agent access to this secret. This runs with your permissions."}
</DialogDescription>
</DialogHeader>
{/* Provenance recap — keeps the social-engineering surface visible. */}
<div className="space-y-1.5 rounded-md border border-border bg-muted/30 p-2.5 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<span>Proposed by</span>
<AgentRefChip agent={draft.proposal.proposedBy} className="font-medium text-foreground" />
</div>
<ProposalJustification justification={draft.proposal.justification} />
</div>
{isSecret ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="approve-folder">Folder</Label>
<Input
id="approve-folder"
value={draft.folder}
onChange={(event) => onChange({ ...draft, folder: event.target.value })}
placeholder="dev/github"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="approve-name">Name</Label>
<Input
id="approve-name"
value={draft.leaf}
onChange={(event) => onChange({ ...draft, leaf: event.target.value })}
placeholder="client-secret"
autoFocus
aria-invalid={!draft.leaf.trim()}
className="font-mono text-xs"
/>
</div>
</div>
<p className="text-(length:--text-micro) text-muted-foreground">
Lands as{" "}
{previewName ? (
<SecretPathName name={previewName} className="font-mono" />
) : (
<span className="italic">enter a name</span>
)}
</p>
<div className="space-y-1">
<Label htmlFor="approve-description">Description</Label>
<Input
id="approve-description"
value={draft.description}
onChange={(event) => onChange({ ...draft, description: event.target.value })}
placeholder="Optional"
/>
</div>
{localConfigs.length > 0 ? (
<div className="space-y-1">
<Label htmlFor="approve-provider-config">Provider vault</Label>
<select
id="approve-provider-config"
value={draft.providerConfigId}
onChange={(event) =>
onChange({ ...draft, providerConfigId: event.target.value })
}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="">Deployment default</option>
{localConfigs.map((config) => (
<option key={config.id} value={config.id}>
{config.displayName}
</option>
))}
</select>
</div>
) : null}
<div className="rounded-md border border-border bg-background px-2.5 py-1.5 text-xs text-muted-foreground">
<FingerprintChip
fingerprint={draft.proposal.valueFingerprintSha256}
length={draft.proposal.valueLength}
/>
</div>
</div>
) : (
<BindingApproveBody draft={draft} onChange={onChange} />
)}
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
<DialogFooter>
<Button variant="ghost" onClick={onCancel} disabled={pending}>
Cancel
</Button>
<Button onClick={onConfirm} disabled={pending || !canConfirm}>
{pending
? "Approving…"
: isSecret
? "Approve & create"
: draft.cascade
? "Approve secret & bind"
: "Approve binding"}
</Button>
</DialogFooter>
</>
) : null}
</DialogContent>
</Dialog>
);
}
function BindingApproveBody({
draft,
onChange,
}: {
draft: ApproveDraft;
onChange: (next: ApproveDraft) => void;
}) {
const { proposal } = draft;
const secret = bindingSecretLabel(proposal);
const envKey = bindingEnvKey(proposal);
return (
<div className="space-y-3 text-sm">
<div className="space-y-2 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Target agent</span>
{proposal.target ? (
<AgentRefChip agent={proposal.target} className="text-sm font-medium" />
) : (
<span className="text-muted-foreground"></span>
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Delivered as</span>
<span className="flex items-center gap-1.5">
<DeliveryBadge configPath={proposal.configPath} />
<code className="font-mono text-xs">{envKey || proposal.configPath}</code>
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Secret</span>
<span className="flex items-center gap-1.5">
<KeyRound className="size-3.5 text-muted-foreground" />
<span className="font-medium">{secret.name}</span>
{secret.pending ? <ProposedBadge /> : null}
</span>
</div>
</div>
{/* Cascade pairing: the secret is itself still a pending proposal. */}
{proposal.secretProposalId ? (
<label className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-2.5 text-xs">
<Checkbox
checked={draft.cascade}
onCheckedChange={(checked) => onChange({ ...draft, cascade: checked === true })}
className="mt-0.5"
aria-label="Also approve the proposed secret"
/>
<span className="text-foreground/90">
Also approve the proposed secret{" "}
<span className="font-medium">{secret.name}</span> and create it in the same step. The
binding cant land without it.
</span>
</label>
) : null}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Reject dialog */
/* -------------------------------------------------------------------------- */
function RejectDialog({
proposal,
reason,
error,
pending,
onReasonChange,
onCancel,
onConfirm,
}: {
proposal: SecretProposalView | null;
reason: string;
error: string | null;
pending: boolean;
onReasonChange: (value: string) => void;
onCancel: () => void;
onConfirm: () => void;
}) {
const canConfirm = reason.trim().length > 0;
return (
<Dialog open={Boolean(proposal)} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="sm:max-w-md">
{proposal ? (
<>
<DialogHeader>
<DialogTitle>Reject proposal</DialogTitle>
<DialogDescription>
The reason is sent back to{" "}
<AgentRefChip agent={proposal.proposedBy} className="text-foreground" />. Dependent
bindings are rejected too.
</DialogDescription>
</DialogHeader>
<div className="space-y-1">
<Label htmlFor="reject-reason">Reason</Label>
<Textarea
id="reject-reason"
value={reason}
onChange={(event) => onReasonChange(event.target.value)}
rows={3}
autoFocus
placeholder="Why is this being rejected?"
/>
</div>
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
<DialogFooter>
<Button variant="ghost" onClick={onCancel} disabled={pending}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm} disabled={pending || !canConfirm}>
{pending ? "Rejecting…" : "Reject"}
</Button>
</DialogFooter>
</>
) : null}
</DialogContent>
</Dialog>
);
}
/* -------------------------------------------------------------------------- */
/* Shared approve/reject action buttons (with permission preflight) */
/* -------------------------------------------------------------------------- */
export function ProposalActions({
proposal,
onApprove,
onReject,
disabled,
size = "sm",
}: {
proposal: SecretProposalView;
onApprove: (proposal: SecretProposalView) => void;
onReject: (proposal: SecretProposalView) => void;
disabled?: boolean;
size?: "sm" | "xs";
}) {
const blocked = !proposal.viewerCanApprove;
const heightClass = size === "xs" ? "h-7 px-2 text-xs" : "";
const approveButton = (
<Button
size="sm"
className={heightClass}
disabled={disabled || blocked}
onClick={() => onApprove(proposal)}
>
Approve
</Button>
);
return (
<div className="flex items-center gap-1.5">
{blocked ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>{approveButton}</span>
</TooltipTrigger>
<TooltipContent className="max-w-72">
{proposal.approveBlockReason ?? "You dont have permission to approve this."}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
approveButton
)}
<Button
size="sm"
variant="outline"
className={heightClass}
disabled={disabled}
onClick={() => onReject(proposal)}
>
Reject
</Button>
</div>
);
}