Add dynamic secret runtime UI: generate-at-runtime mode, test button, posture labeling

Stage 6 of the Dynamic Secrets epic (PAP-623). Inert behind the
enableDynamicSecrets experimental flag.

UI (ui/src/pages/Secrets.tsx):
- New "Generate at runtime" mode in the Create Secret modal, revealed only
  when enableDynamicSecrets is on. Captures name, key, generator command,
  a single cache TTL, and description; provider is fixed to host-command.
- Test button dry-runs the generator via the resolver and reports
  success/failure clearly without ever showing the produced value.
- Posture labeling (PRD §5): soft (same-UID / local-trusted) vs hard
  (sandboxed), driven by the Stage 5 capability flag and never claiming a
  hard boundary when the flag resolves to soft. §5 explainer covers
  privileged runtime minting, applicable posture, and operator-owned
  generator trust. Surfaced in the create modal and the secret detail tab.
- Lightweight fail-closed affordance for runtime generator failures (§7-2).

Binding UI (ui/src/components/EnvVarEditor.tsx):
- Operator-fixed static ARGV params per binding, revealed when a bound
  secret is dynamic_command. Never agent-supplied.

Backend:
- staticArgv threaded through EnvSecretRefBinding type/validator and the
  env-binding sync path (canonicalize, normalize, syncEnvBindingsForTarget).
- testDynamicCommand service + gated POST /companies/:id/secrets/dynamic-command/test
  route (returns pass/fail + byte length + posture, never the value).
- secretsApi.testDynamicCommand client + dynamicCommand on CreateSecretInput.

Tests: shared validator + server service/binding round-trip. Typecheck clean
across shared/server/ui.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nicky LeachandClaude Opus 4.8 committed 2026-06-25 22:40:14 +00:00
1 parent 9b5a735bb6
commit b00271280a
11 files changed
+567 -29

No files matched your search

+2
View File
@@ -1193,6 +1193,7 @@ export {
envConfigSchema,
dynamicSecretCommandSchema,
staticArgvSchema,
testDynamicSecretCommandSchema,
createSecretSchema,
createSecretProviderConfigSchema,
updateSecretProviderConfigSchema,
@@ -1229,6 +1230,7 @@ export {
type RemoteSecretImportSelection,
type RotateSecret,
type UpdateSecret,
type TestDynamicSecretCommand,
type CreateRoutine,
type UpdateRoutine,
type CreateRoutineTrigger,
+4
View File
@@ -31,6 +31,10 @@ export interface EnvSecretRefBinding {
type: "secret_ref";
secretId: string;
version?: SecretVersionSelector;
// Operator-fixed positional arguments passed to a dynamic (host-command)
// generator at injection time. Never agent-supplied. Ignored for
// non-dynamic secrets.
staticArgv?: string[];
}
// Backward-compatible: legacy plaintext string values are still accepted.
+2
View File
@@ -441,6 +441,7 @@ export {
envConfigSchema,
dynamicSecretCommandSchema,
staticArgvSchema,
testDynamicSecretCommandSchema,
createSecretSchema,
createSecretProviderConfigSchema,
updateSecretProviderConfigSchema,
@@ -467,6 +468,7 @@ export {
type RemoteSecretImportSelection,
type RotateSecret,
type UpdateSecret,
type TestDynamicSecretCommand,
} from "./secret.js";
export {
@@ -3,14 +3,44 @@ import {
createSecretBindingSchema,
createSecretProviderConfigSchema,
createSecretSchema,
envBindingSecretRefSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
secretProviderConfigDiscoveryPreviewSchema,
secretProviderConfigPayloadSchema,
testDynamicSecretCommandSchema,
updateSecretProviderConfigSchema,
} from "./secret.js";
describe("secret validators", () => {
it("captures static argv on a secret_ref env binding", () => {
const parsed = envBindingSecretRefSchema.parse({
type: "secret_ref",
secretId: "11111111-1111-1111-1111-111111111111",
staticArgv: ["--repo", "acme/widgets"],
});
expect(parsed.staticArgv).toEqual(["--repo", "acme/widgets"]);
});
it("accepts a secret_ref env binding without static argv", () => {
const parsed = envBindingSecretRefSchema.parse({
type: "secret_ref",
secretId: "11111111-1111-1111-1111-111111111111",
});
expect(parsed.staticArgv).toBeUndefined();
});
it("validates a dynamic-command test payload and defaults argv to []", () => {
const parsed = testDynamicSecretCommandSchema.parse({
command: "/usr/local/bin/mint-github-token",
});
expect(parsed.staticArgv).toEqual([]);
});
it("rejects an empty command on the dynamic-command test payload", () => {
expect(() => testDynamicSecretCommandSchema.parse({ command: " " })).toThrow();
});
it("allows dynamic command secrets with host-command generator config", () => {
const parsed = createSecretSchema.parse({
name: "GitHub App token",
+14
View File
@@ -17,6 +17,9 @@ export const envBindingSecretRefSchema = z.object({
type: z.literal("secret_ref"),
secretId: z.string().uuid(),
version: z.union([z.literal("latest"), z.number().int().positive()]).optional(),
// Operator-fixed argv for dynamic (host-command) generators, captured at
// attach time. Never agent-supplied. Optional/empty for static secrets.
staticArgv: z.array(z.string().max(4096)).max(64).optional(),
});
// Backward-compatible union that accepts legacy inline values.
@@ -36,6 +39,17 @@ export const dynamicSecretCommandSchema = z.object({
export const staticArgvSchema = z.array(z.string().max(4096)).max(64).default([]);
// Operator dry-run of a dynamic (host-command) generator before saving a
// secret or binding. The resolved value is never returned to the caller —
// only a pass/fail signal plus non-sensitive metadata.
export const testDynamicSecretCommandSchema = z.object({
command: z.string().trim().min(1).max(2048),
ttlSeconds: z.number().int().min(1).max(86_400).optional(),
staticArgv: staticArgvSchema,
}).strict();
export type TestDynamicSecretCommand = z.infer<typeof testDynamicSecretCommandSchema>;
export const createSecretSchema = z.object({
name: z.string().min(1),
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
@@ -340,6 +340,72 @@ console.log("minted-" + count + ":" + process.argv.slice(2).join("|"));
expect(activities).toHaveLength(0);
});
it("dry-runs a dynamic generator without returning the value", async () => {
await seedCompany();
const svc = secretService(db);
const scriptPath = writeGeneratorScript(
"dynamic-test-ok",
"console.log('minted:' + process.argv.slice(2).join('|'));",
);
const ok = await svc.testDynamicCommand({
command: process.execPath,
staticArgv: [scriptPath, "--installation", "999"],
});
expect(ok.ok).toBe(true);
expect(ok.posture).toBe("soft");
expect(ok.bytes).toBe("minted:--installation|999".length);
expect(JSON.stringify(ok)).not.toContain("minted");
const failing = writeGeneratorScript(
"dynamic-test-fail",
"process.stderr.write('boom'); process.exit(2);",
);
const failure = await svc.testDynamicCommand({
command: process.execPath,
staticArgv: [failing],
});
expect(failure.ok).toBe(false);
expect(failure.posture).toBe("soft");
expect(failure.errorCode).toBe("dynamic_secret_command_failed");
expect(failure.bytes).toBeUndefined();
});
it("threads operator static argv from env bindings into binding rows", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `dynamic-binding-${randomUUID()}`,
provider: "host_command",
managedMode: "dynamic_command",
dynamicCommand: {
provider: "host-command",
command: process.execPath,
ttlSeconds: 60,
},
});
await svc.syncEnvBindingsForTarget(
companyId,
{ targetType: "agent", targetId: "agent-argv" },
{
GITHUB_TOKEN: {
type: "secret_ref",
secretId: secret.id,
version: "latest",
staticArgv: ["--installation", "12345"],
},
},
);
const bindings = await db
.select()
.from(companySecretBindings)
.where(eq(companySecretBindings.targetId, "agent-argv"));
expect(bindings).toHaveLength(1);
expect(bindings[0]?.staticArgv).toEqual(["--installation", "12345"]);
});
it("syncs top-level secret refs idempotently", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
+34 -1
View File
@@ -7,12 +7,14 @@ import {
remoteSecretImportSchema,
rotateSecretSchema,
secretProviderConfigDiscoveryPreviewSchema,
testDynamicSecretCommandSchema,
updateSecretProviderConfigSchema,
updateSecretSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { logActivity, secretService } from "../services/index.js";
import { instanceSettingsService, logActivity, secretService } from "../services/index.js";
import { forbidden } from "../errors.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
export function secretRoutes(db: Db) {
@@ -305,6 +307,37 @@ export function secretRoutes(db: Db) {
res.status(201).json(created);
});
// Operator-only dry-run of a dynamic (host-command) generator before saving.
// Gated behind the experimental flag and the board scope. Returns only a
// pass/fail signal and non-sensitive metadata — never the generated value.
router.post(
"/companies/:companyId/secrets/dynamic-command/test",
validate(testDynamicSecretCommandSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const experimental = await instanceSettingsService(db).getExperimental();
if (experimental.enableDynamicSecrets !== true) {
throw forbidden("Dynamic secrets are not enabled for this instance.");
}
const result = await svc.testDynamicCommand({
command: req.body.command,
staticArgv: req.body.staticArgv,
});
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.dynamic_command.tested",
entityType: "secret",
entityId: "dynamic-command-test",
details: { ok: result.ok, errorCode: result.errorCode ?? null },
});
res.json(result);
},
);
router.post(
"/companies/:companyId/secrets/remote-import/preview",
validate(remoteSecretImportPreviewSchema),
+42 -1
View File
@@ -218,7 +218,7 @@ async function cleanupPreparedProviderWrite(input: {
type CanonicalEnvBinding =
| { type: "plain"; value: string }
| { type: "secret_ref"; secretId: string; version: number | "latest" };
| { type: "secret_ref"; secretId: string; version: number | "latest"; staticArgv?: string[] };
type SecretConsumerContext = {
consumerType: SecretBindingTargetType;
@@ -409,6 +409,9 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
type: "secret_ref",
secretId: binding.secretId,
version: binding.version ?? "latest",
...(Array.isArray(binding.staticArgv) && binding.staticArgv.length > 0
? { staticArgv: binding.staticArgv.filter((arg): arg is string => typeof arg === "string") }
: {}),
};
}
@@ -1050,6 +1053,9 @@ export function secretService(db: Db) {
type: "secret_ref",
secretId: binding.secretId,
version: binding.version,
...(binding.staticArgv && binding.staticArgv.length > 0
? { staticArgv: binding.staticArgv }
: {}),
};
}
return normalized;
@@ -2406,6 +2412,38 @@ export function secretService(db: Db) {
.then((rows) => rows[0] ?? null);
},
// Operator dry-run of a dynamic (host-command) generator. Runs the command
// under Paperclip's privileged control exactly as injection would, but
// never returns the resolved value — only a pass/fail signal plus
// non-sensitive metadata (byte length, posture). Posture is "soft" because
// an unbound generator is not yet attached to any hard-isolation
// environment; the runtime resolver labels the bound posture per Stage 5.
testDynamicCommand: async (input: {
command: string;
staticArgv?: string[];
}): Promise<{
ok: boolean;
posture: "hard" | "soft";
bytes?: number;
errorCode?: SecretResolutionErrorCode;
message?: string;
}> => {
try {
const value = await runDynamicSecretCommand({
command: input.command,
staticArgv: normalizeStaticArgv(input.staticArgv ?? EMPTY_STATIC_ARGV),
});
return { ok: true, posture: "soft", bytes: Buffer.byteLength(value) };
} catch (err) {
return {
ok: false,
posture: "soft",
errorCode: secretResolutionErrorCode(err),
message: err instanceof Error ? err.message : "Dynamic secret generator failed.",
};
}
},
createBinding: async (input: {
companyId: string;
secretId: string;
@@ -2553,6 +2591,7 @@ export function secretService(db: Db) {
secretId: string;
configPath: string;
versionSelector: SecretVersionSelector;
staticArgv: string[];
}> = [];
const pathPrefix = target.pathPrefix ?? "env";
const bindingDb = options?.db ?? db;
@@ -2566,6 +2605,7 @@ export function secretService(db: Db) {
secretId: binding.secretId,
configPath: `${pathPrefix}.${key}`,
versionSelector: binding.version,
staticArgv: binding.staticArgv ?? EMPTY_STATIC_ARGV,
});
}
@@ -2590,6 +2630,7 @@ export function secretService(db: Db) {
configPath: ref.configPath,
versionSelector: String(ref.versionSelector),
required: true,
staticArgv: ref.staticArgv,
})),
);
};
+21
View File
@@ -2,6 +2,7 @@ import type {
CompanySecret,
CompanySecretUsageBinding,
CompanySecretProviderConfig,
DynamicSecretCommandConfig,
SecretProviderConfigDiscoveryPreviewResult,
RemoteSecretImportPreviewResult,
RemoteSecretImportResult,
@@ -26,6 +27,7 @@ export interface CreateSecretInput {
provider?: SecretProvider;
managedMode?: SecretManagedMode;
value?: string | null;
dynamicCommand?: DynamicSecretCommandConfig | null;
description?: string | null;
externalRef?: string | null;
providerVersionRef?: string | null;
@@ -33,6 +35,20 @@ export interface CreateSecretInput {
providerMetadata?: Record<string, unknown> | null;
}
export interface TestDynamicCommandInput {
command: string;
ttlSeconds?: number;
staticArgv?: string[];
}
export interface TestDynamicCommandResult {
ok: boolean;
posture: "hard" | "soft";
bytes?: number;
errorCode?: string;
message?: string;
}
export interface SecretProviderHealthResponse {
providers: Array<{
provider: SecretProvider;
@@ -134,6 +150,11 @@ export const secretsApi = {
api.post<SecretProviderConfigHealthResponse>(`/secret-provider-configs/${id}/health`, {}),
create: (companyId: string, data: CreateSecretInput) =>
api.post<CompanySecret>(`/companies/${companyId}/secrets`, data),
testDynamicCommand: (companyId: string, data: TestDynamicCommandInput) =>
api.post<TestDynamicCommandResult>(
`/companies/${companyId}/secrets/dynamic-command/test`,
data,
),
update: (id: string, data: UpdateSecretInput) =>
api.patch<CompanySecret>(`/secrets/${id}`, data),
rotate: (id: string, data: RotateSecretInput) =>
+57 -10
View File
@@ -36,19 +36,27 @@ type Row = {
plainValue: string;
secretId: string;
version: SecretVersionSelector;
// Operator-fixed argv for dynamic (host-command) generators. One entry per
// positional argument; never agent-supplied. Empty for non-dynamic secrets.
staticArgv: string[];
};
function emptyRow(): Row {
return { key: "", source: "plain", plainValue: "", secretId: "", version: "latest" };
return { key: "", source: "plain", plainValue: "", secretId: "", version: "latest", staticArgv: [] };
}
function readStaticArgv(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === "string");
}
function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
if (!rec || typeof rec !== "object") {
return [emptyRow()];
}
const entries = Object.entries(rec).map(([key, binding]) => {
const entries = Object.entries(rec).map(([key, binding]): Row => {
if (typeof binding === "string") {
return { key, source: "plain" as const, plainValue: binding, secretId: "", version: "latest" as const };
return { key, source: "plain", plainValue: binding, secretId: "", version: "latest", staticArgv: [] };
}
if (
typeof binding === "object" &&
@@ -56,16 +64,17 @@ function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
"type" in binding &&
(binding as { type?: unknown }).type === "secret_ref"
) {
const record = binding as { secretId?: unknown; version?: unknown };
const record = binding as { secretId?: unknown; version?: unknown; staticArgv?: unknown };
const version: SecretVersionSelector = typeof record.version === "number"
? record.version
: "latest";
return {
key,
source: "secret" as const,
source: "secret",
plainValue: "",
secretId: typeof record.secretId === "string" ? record.secretId : "",
version,
staticArgv: readStaticArgv(record.staticArgv),
};
}
if (
@@ -77,13 +86,14 @@ function toRows(rec: Record<string, EnvBinding> | null | undefined): Row[] {
const record = binding as { value?: unknown };
return {
key,
source: "plain" as const,
source: "plain",
plainValue: typeof record.value === "string" ? record.value : "",
secretId: "",
version: "latest" as const,
version: "latest",
staticArgv: [],
};
}
return { key, source: "plain" as const, plainValue: "", secretId: "", version: "latest" as const };
return { key, source: "plain", plainValue: "", secretId: "", version: "latest", staticArgv: [] };
});
return [...entries, emptyRow()];
}
@@ -129,7 +139,15 @@ export function EnvVarEditor({
if (!key) continue;
if (row.source === "secret") {
if (row.secretId) {
rec[key] = { type: "secret_ref", secretId: row.secretId, version: row.version };
const isDynamic =
secrets.find((s) => s.id === row.secretId)?.managedMode === "dynamic_command";
const argv = row.staticArgv.filter((arg) => arg.length > 0);
rec[key] = {
type: "secret_ref",
secretId: row.secretId,
version: row.version,
...(isDynamic && argv.length > 0 ? { staticArgv: argv } : {}),
};
} else {
rec[key] = { type: "plain", value: row.plainValue };
}
@@ -227,8 +245,14 @@ export function EnvVarEditor({
!row.key &&
!row.plainValue &&
!row.secretId;
const boundSecret = row.secretId
? secrets.find((s) => s.id === row.secretId)
: undefined;
const isDynamicBinding =
row.source === "secret" && boundSecret?.managedMode === "dynamic_command";
return (
<div key={index} className="flex items-center gap-1.5">
<div key={index} className="space-y-1">
<div className="flex items-center gap-1.5">
<input
className={cn(inputClass, "flex-[2]")}
placeholder="KEY"
@@ -356,6 +380,29 @@ export function EnvVarEditor({
<div className="w-[26px] shrink-0" />
)}
</div>
{isDynamicBinding ? (
<div className="ml-1 rounded-md border border-amber-500/30 bg-amber-500/5 px-2 py-1.5">
<label className="flex items-center gap-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
<KeyRound className="h-3 w-3" />
Static args for generator (operator-fixed, one per line)
</label>
<textarea
className={cn(inputClass, "mt-1 min-h-[44px] resize-y")}
placeholder={"--repo\nacme/widgets"}
value={row.staticArgv.join("\n")}
onChange={(event) =>
updateRow(index, {
staticArgv: event.target.value.split("\n"),
})
}
/>
<p className="mt-1 text-[10px] text-muted-foreground">
Passed verbatim to <span className="font-mono">{boundSecret?.name}</span> at injection.
Agents can never set or override these.
</p>
</div>
) : null}
</div>
);
})}
{(() => {
+295 -17
View File
@@ -18,6 +18,10 @@ import {
RefreshCw,
Search,
ShieldCheck,
ShieldAlert,
Clock,
TerminalSquare,
Play,
Star,
Trash2,
X,
@@ -46,8 +50,10 @@ import {
type CreateSecretInput,
type CreateSecretProviderConfigInput,
type SecretProviderHealthResponse,
type TestDynamicCommandResult,
type UpdateSecretProviderConfigInput,
} from "../api/secrets";
import { instanceSettingsApi } from "../api/instanceSettings";
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
import { EmptyState } from "../components/EmptyState";
@@ -77,7 +83,72 @@ import { cn } from "../lib/utils";
import { PageTabBar } from "../components/PageTabBar";
import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog";
type CreateMode = "managed" | "external";
type CreateMode = "managed" | "external" | "dynamic";
const DEFAULT_DYNAMIC_TTL_SECONDS = 3600;
/**
* Trust-boundary posture for runtime-generated secrets (PRD §5). Driven by the
* Stage 5 capability flag — soft until a generator runs inside a hard-isolation
* (sandboxed) environment. We never render a hard-boundary claim when the flag
* resolves to soft.
*/
function RuntimePostureLabel({ posture }: { posture: "hard" | "soft" }) {
const isHard = posture === "hard";
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
isHard
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
)}
>
{isHard ? <ShieldCheck className="h-3 w-3" /> : <ShieldAlert className="h-3 w-3" />}
{isHard ? "Hard boundary (sandboxed)" : "Soft boundary (same-UID / local-trusted)"}
</span>
);
}
/**
* PRD §5 explainer for runtime-generated secrets: (a) the value is produced at
* runtime under Paperclip's privileged control, (b) which trust posture applies,
* and (c) the operator owns generator trustworthiness.
*/
function RuntimePostureExplainer({ posture }: { posture: "hard" | "soft" }) {
return (
<div className="space-y-2 rounded-md border border-border bg-muted/30 p-2.5 text-[11px] text-muted-foreground">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-foreground">Runtime trust posture</span>
<RuntimePostureLabel posture={posture} />
</div>
<ul className="list-disc space-y-1 pl-4">
<li>
The value is <span className="text-foreground">minted fresh at injection</span> by your
generator command, run under Paperclip's privileged control. It is never stored and never
re-displayed.
</li>
<li>
{posture === "hard" ? (
<>
This generator resolves inside a <span className="text-foreground">hard, sandboxed</span>{" "}
boundary isolating it from the host.
</>
) : (
<>
This generator resolves under a <span className="text-foreground">soft boundary</span> —
it runs at the same UID as Paperclip on a local-trusted host, not in a sandbox.
</>
)}
</li>
<li>
You own the generator's trustworthiness: only attach commands you trust to produce the
intended value. Agents can never invoke it out-of-band or supply its arguments.
</li>
</ul>
</div>
);
}
type SecretsTab = "secrets" | "vaults";
type ProviderVaultForm = {
@@ -247,13 +318,19 @@ function normalizeSecretKeyForPreview(input: string) {
function modeLabel(managedMode: SecretManagedMode) {
return managedMode === "paperclip_managed" ? "Paperclip-managed" : "Linked external";
if (managedMode === "paperclip_managed") return "Paperclip-managed";
if (managedMode === "dynamic_command") return "Generated at runtime";
return "Linked external";
}
function modeDescription(managedMode: SecretManagedMode) {
return managedMode === "paperclip_managed"
? "Paperclip owns create and rotation writes for this provider secret."
: "Paperclip resolves this provider reference but does not rotate the provider value.";
if (managedMode === "paperclip_managed") {
return "Paperclip owns create and rotation writes for this provider secret.";
}
if (managedMode === "dynamic_command") {
return "Paperclip mints a fresh value at injection time by running the generator command. Nothing is stored.";
}
return "Paperclip resolves this provider reference but does not rotate the provider value.";
}
function healthEntryForProvider(
@@ -415,8 +492,11 @@ export function Secrets() {
externalRef: "",
provider: "local_encrypted" as SecretProvider,
providerConfigId: "",
command: "",
ttlSeconds: String(DEFAULT_DYNAMIC_TTL_SECONDS),
});
const [createError, setCreateError] = useState<string | null>(null);
const [testResult, setTestResult] = useState<TestDynamicCommandResult | null>(null);
const [rotateOpen, setRotateOpen] = useState(false);
const [rotateValue, setRotateValue] = useState("");
const [rotateExternalRef, setRotateExternalRef] = useState("");
@@ -472,6 +552,14 @@ export function Secrets() {
retry: false,
});
const experimentalQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
staleTime: 5 * 60_000,
retry: false,
});
const dynamicSecretsEnabled = experimentalQuery.data?.enableDynamicSecrets === true;
const secrets = secretsQuery.data ?? [];
const providers = providersQuery.data ?? [];
const providerConfigs = providerConfigsQuery.data ?? [];
@@ -567,8 +655,26 @@ export function Secrets() {
}
}
const dynamicTtlSeconds = Number.parseInt(createForm.ttlSeconds, 10);
const dynamicTtlValid = Number.isFinite(dynamicTtlSeconds) && dynamicTtlSeconds >= 1 && dynamicTtlSeconds <= 86_400;
const createMutation = useMutation({
mutationFn: () => {
if (createMode === "dynamic") {
const input: CreateSecretInput = {
name: createForm.name.trim(),
provider: "host_command",
managedMode: "dynamic_command",
description: createForm.description.trim() || null,
dynamicCommand: {
provider: "host-command",
command: createForm.command.trim(),
ttlSeconds: dynamicTtlSeconds,
},
};
if (createForm.key.trim()) input.key = createForm.key.trim();
return secretsApi.create(selectedCompanyId!, input);
}
const input: CreateSecretInput = {
name: createForm.name.trim(),
provider: createForm.provider,
@@ -595,8 +701,11 @@ export function Secrets() {
externalRef: "",
provider: createForm.provider,
providerConfigId: getDefaultProviderConfigId(providerConfigs, createForm.provider),
command: "",
ttlSeconds: String(DEFAULT_DYNAMIC_TTL_SECONDS),
});
setCreateError(null);
setTestResult(null);
setSelectedSecretId(created.id);
invalidateAll([created.id]);
},
@@ -605,6 +714,24 @@ export function Secrets() {
},
});
const testDynamicMutation = useMutation({
mutationFn: () =>
secretsApi.testDynamicCommand(selectedCompanyId!, {
command: createForm.command.trim(),
ttlSeconds: dynamicTtlValid ? dynamicTtlSeconds : undefined,
}),
onSuccess: (result) => {
setTestResult(result);
},
onError: (error) => {
setTestResult({
ok: false,
posture: "soft",
message: error instanceof ApiError ? error.message : (error as Error).message,
});
},
});
const rotateMutation = useMutation({
mutationFn: () => {
if (!selectedSecret) throw new Error("Select a secret first");
@@ -789,7 +916,7 @@ export function Secrets() {
});
useEffect(() => {
if (!createOpen || providers.length === 0) return;
if (!createOpen || createMode === "dynamic" || providers.length === 0) return;
const currentBlockReason = getCreateProviderBlockReason(
providers.find((provider) => provider.id === createForm.provider) ?? null,
createMode,
@@ -810,14 +937,14 @@ export function Secrets() {
}, [createForm.provider, createMode, createOpen, providerConfigs, providerHealthQuery.data, providers]);
useEffect(() => {
if (!createOpen) return;
if (!createOpen || createMode === "dynamic") return;
const current = providerConfigs.find((config) => config.id === createForm.providerConfigId);
if (current?.provider === createForm.provider) return;
setCreateForm((form) => ({
...form,
providerConfigId: getDefaultProviderConfigId(providerConfigs, form.provider),
}));
}, [createForm.provider, createForm.providerConfigId, createOpen, providerConfigs]);
}, [createForm.provider, createForm.providerConfigId, createMode, createOpen, providerConfigs]);
useEffect(() => {
if (!rotateOpen || !selectedSecret) return;
@@ -1201,19 +1328,40 @@ export function Secrets() {
/>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) {
setTestResult(null);
testDynamicMutation.reset();
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create secret</DialogTitle>
<DialogDescription>
Choose whether Paperclip should own future provider writes, or only resolve an existing
provider reference at runtime.
{createMode === "dynamic"
? "Define a generator command Paperclip runs at injection time to mint a fresh value. The value is never stored."
: "Choose whether Paperclip should own future provider writes, or only resolve an existing provider reference at runtime."}
</DialogDescription>
</DialogHeader>
<Tabs value={createMode} onValueChange={(value) => setCreateMode(value as CreateMode)}>
<TabsList className="w-full grid grid-cols-2">
<Tabs
value={createMode}
onValueChange={(value) => {
setCreateMode(value as CreateMode);
setTestResult(null);
}}
>
<TabsList
className={cn("w-full grid", dynamicSecretsEnabled ? "grid-cols-3" : "grid-cols-2")}
>
<TabsTrigger value="managed">Managed value</TabsTrigger>
<TabsTrigger value="external">External reference</TabsTrigger>
{dynamicSecretsEnabled ? (
<TabsTrigger value="dynamic">Generate at runtime</TabsTrigger>
) : null}
</TabsList>
</Tabs>
<div className="space-y-3">
@@ -1244,6 +1392,8 @@ export function Secrets() {
/>
</div>
</div>
{createMode !== "dynamic" ? (
<>
<div>
<label className="text-xs font-medium" htmlFor="new-secret-provider">Provider</label>
<select
@@ -1317,6 +1467,8 @@ export function Secrets() {
</p>
)}
</div>
</>
) : null}
{createMode === "managed" ? (
<>
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-2 text-[11px] text-emerald-700 dark:text-emerald-300">
@@ -1345,7 +1497,7 @@ export function Secrets() {
/>
</div>
</>
) : (
) : createMode === "external" ? (
<div>
<label className="text-xs font-medium" htmlFor="new-secret-ref">External reference</label>
<Input
@@ -1362,6 +1514,110 @@ export function Secrets() {
then update this reference only if the path, ARN, or version changes.
</p>
</div>
) : (
<>
<div>
<label className="text-xs font-medium" htmlFor="new-secret-command">
Generator command
</label>
<div className="relative">
<TerminalSquare className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/70" />
<Input
id="new-secret-command"
value={createForm.command}
onChange={(event) => {
const command = event.target.value;
setCreateForm((current) => ({ ...current, command }));
setTestResult(null);
}}
placeholder="/usr/local/bin/mint-github-token"
className="pl-7 font-mono text-xs"
/>
</div>
<p className="mt-1 text-[11px] text-muted-foreground">
Absolute path or command on the Paperclip host. Run with no shell. Operator-fixed
arguments are set per binding when you attach this secret.
</p>
</div>
<div className="w-40">
<label className="text-xs font-medium" htmlFor="new-secret-ttl">
Cache TTL (seconds)
</label>
<div className="relative">
<Clock className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/70" />
<Input
id="new-secret-ttl"
type="number"
min={1}
max={86_400}
value={createForm.ttlSeconds}
onChange={(event) =>
setCreateForm((current) => ({ ...current, ttlSeconds: event.target.value }))
}
className="pl-7 font-mono text-xs"
/>
</div>
{!dynamicTtlValid ? (
<p className="mt-1 flex items-center gap-1 text-[11px] text-destructive">
<AlertCircle className="h-3 w-3" />1–86400 seconds
</p>
) : null}
</div>
<RuntimePostureExplainer posture={testResult?.posture ?? "soft"} />
<div className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-medium">Test generator before saving</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setTestResult(null);
testDynamicMutation.mutate();
}}
disabled={testDynamicMutation.isPending || !createForm.command.trim()}
>
{testDynamicMutation.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Play className="mr-1 h-3.5 w-3.5" />
)}
Test
</Button>
</div>
{testResult ? (
testResult.ok ? (
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
Generator produced a fresh value
{typeof testResult.bytes === "number" ? ` (${testResult.bytes} bytes)` : ""}.
The value is not shown.
</p>
) : (
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-destructive">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Generator failed{testResult.errorCode ? ` (${testResult.errorCode})` : ""}.{" "}
{testResult.message ?? "Check the command and try again."}
</span>
</p>
)
) : (
<p className="mt-2 text-[11px] text-muted-foreground">
Runs the command once under Paperclip's control to confirm it returns a value.
Nothing is saved.
</p>
)}
</div>
<div className="flex items-start gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/5 p-2 text-[11px] text-amber-700 dark:text-amber-300">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Fail-closed: if the generator errors, times out, or returns nothing at injection
time, the run fails and no value is injected — agents never receive a stale or
empty secret.
</span>
</div>
</>
)}
<div>
<label className="text-xs font-medium" htmlFor="new-secret-description">
@@ -1389,13 +1645,19 @@ export function Secrets() {
}}
disabled={
createMutation.isPending ||
Boolean(createProviderBlockReason) ||
!createForm.name.trim() ||
(createMode === "managed" ? !createForm.value : !createForm.externalRef.trim())
(createMode === "dynamic"
? !createForm.command.trim() || !dynamicTtlValid
: Boolean(createProviderBlockReason) ||
(createMode === "managed" ? !createForm.value : !createForm.externalRef.trim()))
}
>
{createMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
{createMode === "managed" ? "Create secret" : "Link reference"}
{createMode === "managed"
? "Create secret"
: createMode === "external"
? "Link reference"
: "Create generator"}
</Button>
</DialogFooter>
</DialogContent>
@@ -2471,6 +2733,22 @@ function SecretDetailsTab({
</dd>
</div>
) : null}
{secret.managedMode === "dynamic_command" && secret.dynamicCommand ? (
<>
<div className="col-span-2">
<dt className="text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
Generator command
</dt>
<dd className="font-mono text-xs break-all flex items-center gap-1">
<TerminalSquare className="h-3 w-3 shrink-0" /> {secret.dynamicCommand.command}
</dd>
</div>
<DetailRow label="Cache TTL">{secret.dynamicCommand.ttlSeconds}s</DetailRow>
<div className="col-span-2">
<RuntimePostureExplainer posture="soft" />
</div>
</>
) : null}
<div className="col-span-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-2 text-[11px] text-amber-700 dark:text-amber-300">
{modeDescription(secret.managedMode)} Paperclip never re-displays stored values.
</div>