diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 00bc307766c..eeb99a57b96 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index 92e750f82d8..a9784f824ae 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -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. diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index c49ada7b0af..fdf9d27ee9d 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -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 { diff --git a/packages/shared/src/validators/secret.test.ts b/packages/shared/src/validators/secret.test.ts index b6ec07d09f7..b38c4a33ddc 100644 --- a/packages/shared/src/validators/secret.test.ts +++ b/packages/shared/src/validators/secret.test.ts @@ -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", diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index 887e4ba7e5d..b02e6849839 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -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; + export const createSecretSchema = z.object({ name: z.string().min(1), key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(), diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 9f9aff5e625..25191792e81 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -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); diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 06cc489614d..e005a5164bb 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -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), diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index dfa22c2914c..d0fa68a08b5 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -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, })), ); }; diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts index f04705f3a8b..9109d6ab546 100644 --- a/ui/src/api/secrets.ts +++ b/ui/src/api/secrets.ts @@ -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 | 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(`/secret-provider-configs/${id}/health`, {}), create: (companyId: string, data: CreateSecretInput) => api.post(`/companies/${companyId}/secrets`, data), + testDynamicCommand: (companyId: string, data: TestDynamicCommandInput) => + api.post( + `/companies/${companyId}/secrets/dynamic-command/test`, + data, + ), update: (id: string, data: UpdateSecretInput) => api.patch(`/secrets/${id}`, data), rotate: (id: string, data: RotateSecretInput) => diff --git a/ui/src/components/EnvVarEditor.tsx b/ui/src/components/EnvVarEditor.tsx index faabf9db09f..c148f8a536a 100644 --- a/ui/src/components/EnvVarEditor.tsx +++ b/ui/src/components/EnvVarEditor.tsx @@ -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 | 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 | 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 | 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 ( -
+
+
)}
+ {isDynamicBinding ? ( +
+ +