Files
PaperClipAI/server/src/services/secret-proposals.ts
T
DottaandClaude Opus 5.5 d2fe85e57c fix(secrets): delete a secret that a binding proposal referenced (#20193)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents can propose a secret binding. A person approves or rejects it
on a review card.
> - Each binding proposal row refers to one source: a secret or a
pending secret proposal. A check constraint enforces this.
> - The secret reference used `ON DELETE SET NULL`. When a person
deletes the secret, Postgres sets the reference to null. The check
constraint then rejects the row.
> - So a person cannot delete any secret that a binding proposal ever
referenced. The API returns 500.
> - This pull request deletes the proposal rows together with the
secret, as the other source reference already does. Before the delete,
it rejects pending proposals so their cards close with a reason.
> - The benefit is that secret deletion works again and no review card
points at a removed proposal.

## Linked Issues or Issue Description

Refs #15644 (related: block secret deletion while bindings still exist).

**What happened**
`DELETE /api/secrets/:id` returned 500 for a secret that a binding
proposal referenced. The server log showed `new row for relation
"company_secret_proposals" violates check constraint
"company_secret_proposals_shape_check"`. This happened for pending,
rejected and approved proposals. It also happened after the person
removed the agent's binding.

**Expected behavior**
The person can delete the secret. Proposals that referenced it go with
it. A pending review card closes as rejected and tells why.

**Steps to reproduce**
1. An agent proposes a binding for an existing company secret (`POST
/api/agents/me/secret-proposals` with `secretId`).
2. Approve or reject the proposal, or leave it pending.
3. Delete the secret with `DELETE /api/secrets/:id`.
4. The response is 500 and the secret stays.

## What Changed

- `packages/db`: `company_secret_proposals.secret_id` now uses `ON
DELETE CASCADE`, the same as `secret_proposal_id`. Migration
`0452_secret_proposal_secret_cascade.sql` replaces the foreign key
(`DROP CONSTRAINT IF EXISTS`, then `ADD CONSTRAINT`).
- `server/src/services/secrets.ts`: `remove` accepts an optional
`beforeDelete` hook. The hook runs after the secret is marked deleted
and before its row is removed.
- `server/src/services/secret-proposals.ts`: new
`rejectPendingBindingsForSecret`. It rejects pending binding proposals
for a secret with a reason and returns them. This closes their review
cards and writes the normal activity entries. `createBinding` now holds
the secret row `FOR SHARE` in its insert transaction and refuses a
deleted secret. So no new pending proposal can start after the delete
marks the secret deleted.
- `server/src/routes/secrets.ts`: the delete route rejects pending
proposals in `beforeDelete`. For each one it sends the normal rejection
notice and wake (`notifySecretProposalResolution`), so the proposing
agent continues.
- `server/src/__tests__/secret-proposals-routes.test.ts`: a regression
test deletes a secret with a pending, rejected and approved binding
proposal through the route. It checks the secret and proposal are gone,
the card status is correct, and only the pending case sends one
rejection notice and one wake. A second test checks that a binding
proposal for a secret that the delete already marked deleted fails with
404.

## Verification

- Red first: the new test failed on `origin/master` with the shape-check
violation for all three cases.
- `cd server && vitest run --config vitest.config.ts
src/__tests__/secret-proposals-routes.test.ts`: 47 passed.
- `vitest run src/__tests__/chat-run-milestone-governed-work.test.ts
src/__tests__/agent-secrets-routes.test.ts
src/__tests__/secrets-service.test.ts`: 112 passed.
- `tsc --noEmit` for `server` and `packages/db`: pass. `pnpm --filter
@paperclipai/db run check:migrations`: pass.
- Found by the `secrets.secret-proposal` chat evaluation. Its cleanup
deletes the fixture secret and failed on this error in runs 42563093640
and 42571127911.

## Risks

- The migration only replaces one foreign key. It does not rewrite rows.
Existing rows already have a non-null `secret_id` or
`secret_proposal_id`, so nothing else changes.
- Deleting a secret now also deletes its binding proposal rows. The
activity log keeps the proposal history (`secret.proposal.*` and
`secret.deleted`). Secret-kind proposal rows that created the secret
keep `created_secret_id ON DELETE SET NULL`, so their history stays.
- Other paths that delete secrets (company deletion, user secret
cleanup) now cascade instead of failing on the same constraint. They do
not reject pending cards first.

## Model Used

Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used with tool use and
code execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:54:07 -06:00

957 lines
41 KiB
TypeScript

import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
import { withAgentAppearance } from "@paperclipai/shared";
import { and, count, desc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
companySecretBindings,
companySecretProposals,
companySecrets,
heartbeatRuns,
issueThreadInteractions,
issues,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
import type { SecretProvider } from "@paperclipai/shared";
import { badRequest, 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 };
}
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.secretProposalExpiry, input.companyId);
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 createBindingInteraction(
txDb: Db,
proposal: Proposal,
sourceSecretLabel: string,
) {
if (!proposal.originIssueId || !proposal.targetId || !proposal.configPath) return proposal;
const target = await txDb
.select({ name: agents.name })
.from(agents)
.where(and(eq(agents.id, proposal.targetId), eq(agents.companyId, proposal.companyId)))
.then((rows) => rows[0] ?? null);
if (!target) throw notFound("Target agent not found");
const [interaction] = await txDb
.insert(issueThreadInteractions)
.values({
companyId: proposal.companyId,
issueId: proposal.originIssueId,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "governed_action",
idempotencyKey: `secret-proposal:${proposal.id}`,
sourceRunId: proposal.originRunId,
title: "Confirm secret binding",
summary: `Bind ${sourceSecretLabel} to ${target.name} as ${proposal.configPath}`,
createdByAgentId: proposal.proposedByAgentId,
addresseeAgentId: null,
payload: {
version: 1,
prompt: `Bind secret ${sourceSecretLabel} to ${target.name} as ${proposal.configPath}?`,
acceptLabel: "Create binding",
rejectLabel: "Reject",
rejectRequiresReason: true,
rejectReasonLabel: "Why should this binding not be created?",
allowDeclineReason: true,
supersedeOnUserComment: false,
secretProposal: {
version: 1,
proposalId: proposal.id,
sourceSecretLabel,
configPath: proposal.configPath,
targetAgentId: proposal.targetId,
targetAgentName: target.name,
justification: proposal.justification,
expiresAt: proposal.expiresAt.toISOString(),
},
},
})
.returning();
const [linked] = await txDb
.update(companySecretProposals)
.set({ interactionId: interaction.id, updatedAt: new Date() })
.where(eq(companySecretProposals.id, proposal.id))
.returning();
await txDb.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, proposal.originIssueId));
await logActivity(txDb, {
companyId: proposal.companyId,
actorType: "agent",
actorId: proposal.proposedByAgentId,
action: "issue.thread_interaction_created",
entityType: "issue",
entityId: proposal.originIssueId,
agentId: proposal.proposedByAgentId,
runId: proposal.originRunId,
details: {
interactionId: interaction.id,
interactionKind: "request_confirmation",
interactionStatus: "pending",
continuationPolicy: "wake_assignee",
addresseeAgentId: null,
requestedResolverPolicy: "board_only",
effectiveResolverPolicy: "board_only",
serverOwnedPayload: "secretProposal",
},
});
return linked;
}
async function reflectProposalLifecycleOnInteraction(
txDb: Db,
proposal: Proposal,
outcome: "approved" | "rejected" | "withdrawn" | "expired",
input: { resolvedByUserId?: string | null; reason?: string | null } = {},
) {
if (!proposal.interactionId) return;
const current = await txDb
.select()
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.id, proposal.interactionId),
eq(issueThreadInteractions.companyId, proposal.companyId),
))
.for("update")
.then((rows) => rows[0] ?? null);
if (!current || (current.status !== "pending" && !(outcome === "approved" && current.status === "accepted"))) return;
const payload = asRecord(current.payload);
const linked = asRecord(payload.secretProposal);
if (linked.proposalId !== proposal.id) return;
const now = new Date();
const currentResult = asRecord(current.result);
const status = outcome === "approved"
? "accepted"
: outcome === "rejected"
? "rejected"
: outcome === "withdrawn"
? "cancelled"
: "expired";
const resultOutcome = outcome === "approved"
? "accepted"
: outcome === "rejected"
? "rejected"
: "withdrawn";
if (current.status !== status) await notifyChatRunMilestones(txDb, current);
await txDb
.update(issueThreadInteractions)
.set({
status,
result: {
...currentResult,
version: 1,
outcome: resultOutcome,
...(input.reason ? { reason: input.reason } : {}),
secretProposal: {
version: 1,
status: outcome === "approved" ? "executed" : outcome,
updatedAt: now.toISOString(),
},
},
resolvedByUserId: input.resolvedByUserId ?? current.resolvedByUserId ?? null,
resolvedAt: current.resolvedAt ?? now,
updatedAt: now,
})
.where(and(
eq(issueThreadInteractions.id, current.id),
inArray(issueThreadInteractions.status, outcome === "approved" ? ["pending", "accepted"] : ["pending"]),
));
}
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;
sourceConfigPath?: string | null;
secretProposalId?: string | null;
targetAgentId?: string | null;
configPath: string;
justification: string;
bindingTargetPolicy: "self_and_reports";
}) {
const referenceCount = [input.secretId, input.sourceConfigPath, input.secretProposalId]
.filter((value) => Boolean(value)).length;
if (referenceCount !== 1) {
throw badRequest(
"Binding proposals require exactly one of secretId, sourceConfigPath, or secretProposalId",
);
}
if (!CONFIG_PATH_RE.test(input.configPath)) throw unprocessable("configPath must use env.<KEY> or access.<ALIAS>");
if (input.sourceConfigPath && !CONFIG_PATH_RE.test(input.sourceConfigPath)) {
throw unprocessable("sourceConfigPath must use env.<KEY> or access.<ALIAS>");
}
if (!input.justification.trim()) throw unprocessable("Justification is required");
if (input.justification.trim().length > 20_000) throw unprocessable("Justification must be at most 20000 characters");
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");
}
let resolvedSecretId = input.secretId ?? null;
let sourceSecretLabel: string | null = null;
if (input.sourceConfigPath) {
const sourceBinding = await db.select({ secretId: companySecretBindings.secretId })
.from(companySecretBindings)
.where(and(
eq(companySecretBindings.companyId, context.companyId),
eq(companySecretBindings.targetType, "agent"),
eq(companySecretBindings.targetId, run.agentId),
eq(companySecretBindings.configPath, input.sourceConfigPath),
))
.then((rows) => rows[0] ?? null);
if (sourceBinding) {
resolvedSecretId = sourceBinding.secretId;
} else if (run.responsibleUserId) {
const sourceDeclaration = await db
.select({ secretId: companySecrets.id })
.from(userSecretDeclarations)
.innerJoin(companySecrets, and(
eq(companySecrets.companyId, context.companyId),
eq(companySecrets.scope, "user"),
eq(companySecrets.ownerUserId, run.responsibleUserId),
eq(companySecrets.userSecretDefinitionId, userSecretDeclarations.userSecretDefinitionId),
eq(companySecrets.status, "active"),
))
.where(and(
eq(userSecretDeclarations.companyId, context.companyId),
eq(userSecretDeclarations.targetType, "agent"),
eq(userSecretDeclarations.targetId, run.agentId),
eq(userSecretDeclarations.configPath, input.sourceConfigPath),
))
.then((rows) => rows[0] ?? null);
resolvedSecretId = sourceDeclaration?.secretId ?? null;
}
if (!resolvedSecretId) throw notFound("Source secret binding not found");
}
if (resolvedSecretId) {
const secret = await db.select().from(companySecrets).where(and(
eq(companySecrets.id, resolvedSecretId),
eq(companySecrets.companyId, context.companyId),
)).then((rows) => rows[0] ?? null);
const sourceBindingAllowsUserSecret = Boolean(input.sourceConfigPath) && secret?.scope === "user";
if (
!secret
|| secret.status === "deleted"
|| (secret.scope !== "company" && !sourceBindingAllowsUserSecret)
) {
throw notFound("Secret not found");
}
sourceSecretLabel = secret.name;
}
return createWithinQuota(
{ companyId: context.companyId, agentId: run.agentId, runId: run.id, issueId: originIssueId },
async (txDb) => {
if (resolvedSecretId) {
// Hold the secret against a concurrent delete: the delete marks it
// deleted first, then settles the proposals that reference it.
const live = await txDb.select({ status: companySecrets.status }).from(companySecrets)
.where(eq(companySecrets.id, resolvedSecretId)).for("share").then((rows) => rows[0] ?? null);
if (!live || live.status === "deleted") throw notFound("Secret not found");
}
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",
);
}
sourceSecretLabel = dependency.proposedName;
}
const proposal = await txDb.insert(companySecretProposals).values({
companyId: context.companyId,
kind: "binding",
justification: input.justification.trim(),
secretId: resolvedSecretId,
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);
if (!sourceSecretLabel) throw conflict("Binding proposal source secret label is unavailable");
return createBindingInteraction(txDb, proposal, sourceSecretLabel);
},
);
}
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, appearance: agents.appearance }).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, appearance: agents.appearance }).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: withAgentAppearance(proposer),
target: target ? withAgentAppearance(target) : null,
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();
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.secretProposalExpiry, proposal.companyId);
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,
},
});
await reflectProposalLifecycleOnInteraction(txDb, proposal, "approved", {
resolvedByUserId: input.resolvedByUserId,
});
return updated;
}
async function applyBindingApproval(
txDb: Db,
proposal: Proposal,
secret: typeof companySecrets.$inferSelect,
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 userSecretDefinition = secret.scope === "user" && secret.userSecretDefinitionId
? await txDb.select({ key: userSecretDefinitions.key }).from(userSecretDefinitions).where(and(
eq(userSecretDefinitions.id, secret.userSecretDefinitionId),
eq(userSecretDefinitions.companyId, proposal.companyId),
eq(userSecretDefinitions.status, "active"),
)).then((rows) => rows[0] ?? null)
: null;
if (secret.scope === "user" && !userSecretDefinition) {
throw conflict("Binding proposal user secret definition is not active");
}
const binding = userSecretDefinition
? {
type: "user_secret_ref",
key: userSecretDefinition.key,
version: "latest",
required: true,
allowMissingOverride: false,
}
: { type: "secret_ref", secretId: secret.id, 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 updateAgentConfigurationInTransaction(txDb, 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 };
assertCanResolve?: (proposal: Proposal, txDb: Db) => Promise<void>;
}) {
const loadForApproval = async (txDb: Db) => {
const proposal = await requirePending(companyId, proposalId, txDb, true);
assertNotExpired(proposal);
await input.assertCanResolve?.(proposal, txDb);
await assertBindingSnapshotCurrent(proposal, txDb, true);
return proposal;
};
const saved = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const proposal = await loadForApproval(txDb);
if (proposal.kind === "secret") {
const created = await applySecretApproval(txDb, proposal, input);
return { kind: "secret" as const, proposal: await 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");
return { kind: "binding" as const, secretId };
});
if (saved.kind === "secret") return saved.proposal;
// Keep the approved secret if applying its reference fails.
return db.transaction(async tx => {
const txDb = tx as unknown as Db;
const proposal = await loadForApproval(txDb);
const liveSecret = await secretService(txDb).getById(saved.secretId);
if (!liveSecret || liveSecret.companyId !== companyId || liveSecret.status !== "active") {
throw conflict("Binding proposal secret is not active");
}
await applyBindingApproval(txDb, proposal, liveSecret, 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;
await notifyDeliveryWork(tx, DELIVERY_QUEUES.secretProposalExpiry, companyId);
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,
},
});
await reflectProposalLifecycleOnInteraction(txDb, dependent, "rejected", {
resolvedByUserId: input.resolvedByUserId,
reason: dependent.resolutionReason,
});
}
await reflectProposalLifecycleOnInteraction(txDb, proposal, status, {
resolvedByUserId: input.resolvedByUserId,
reason: input.reason ?? (status === "expired" ? "Pending proposal expired" : null),
});
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" }),
companyId?: string,
) {
const expired = await db.select({ id: companySecretProposals.id, companyId: companySecretProposals.companyId })
.from(companySecretProposals)
.where(and(eq(companySecretProposals.status, "pending"), lte(companySecretProposals.expiresAt, now),
companyId === undefined ? undefined : eq(companySecretProposals.companyId, companyId)))
.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;
}
async function sweepExpiredForCompany(companyId: string) {
return sweepExpired(new Date(), DEFAULT_EXPIRY_SWEEP_LIMIT, undefined, companyId);
}
async function expiryDeadlines(companyId?: string) {
const rows = await db.select({ companyId: companySecretProposals.companyId,
at: sql<string | Date>`min(${companySecretProposals.expiresAt})` })
.from(companySecretProposals)
.where(and(eq(companySecretProposals.status, "pending"),
companyId === undefined ? undefined : eq(companySecretProposals.companyId, companyId)))
.groupBy(companySecretProposals.companyId);
return rows.map(row => ({ companyId: row.companyId, dueAt: new Date(row.at).getTime() }));
}
async function nextExpiryAt(): Promise<number | null> {
const [row] = await db.select({ at: sql<string | Date | null>`min(${companySecretProposals.expiresAt})` })
.from(companySecretProposals).where(eq(companySecretProposals.status, "pending"));
return row?.at == null ? null : new Date(row.at).getTime();
}
/**
* Deleting a secret also deletes the binding proposals that reference it.
* Settle pending ones first, so their review cards close as rejected with
* the reason instead of pointing at a proposal that no longer exists. Call
* it after the secret is marked deleted: createBinding holds the secret row
* and refuses a deleted one, so no new pending proposal can follow.
*/
async function rejectPendingBindingsForSecret(companyId: string, secretId: string, input: { resolvedByUserId?: string | null; reason: string }) {
const pending = await db.select().from(companySecretProposals).where(and(
eq(companySecretProposals.companyId, companyId),
eq(companySecretProposals.secretId, secretId),
eq(companySecretProposals.kind, "binding"),
eq(companySecretProposals.status, "pending"),
));
const rejected: Proposal[] = [];
for (const row of pending) {
try {
await transition(companyId, row.id, "rejected", {
resolvedByUserId: input.resolvedByUserId ?? null,
reason: input.reason,
});
rejected.push(row);
} catch (error) {
// Resolved concurrently; the secret deletion still removes the row.
if (!(error instanceof HttpError) || error.status !== 409) throw error;
}
}
return rejected;
}
return { getById, view: enrich, createSecret, createBinding, listForAgent, listForBoard, assertBindingSnapshotCurrent, approve, transition, rejectPendingBindingsForSecret, sweepExpired, sweepExpiredForCompany, expiryDeadlines, nextExpiryAt };
}