mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
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:
1 parent
9b5a735bb6
commit
b00271280a
11 files changed
+567
-29
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user