@seekrit/cli 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1099 -732
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1936,6 +1936,7 @@ const AUDIT_ACTIONS = [
1936
1936
  "secret.rotated",
1937
1937
  "secret.rotation_failed",
1938
1938
  "token.created",
1939
+ "token.updated",
1939
1940
  "token.revoked",
1940
1941
  "token.deleted",
1941
1942
  "honey_token.created",
@@ -2108,6 +2109,8 @@ z.object({
2108
2109
  z.object({ name: nameSchema });
2109
2110
  z.object({ name: nameSchema });
2110
2111
  z.object({ name: nameSchema });
2112
+ z.object({ name: nameSchema });
2113
+ z.object({ name: nameSchema });
2111
2114
  z.object({ required: z.boolean() });
2112
2115
  z.object({
2113
2116
  email: emailSchema,
@@ -2478,7 +2481,8 @@ const SYNC_PROVIDER_KINDS = [
2478
2481
  "bunnyshell",
2479
2482
  "github-actions",
2480
2483
  "gcp-secret-manager",
2481
- "langgraph-platform"
2484
+ "langgraph-platform",
2485
+ "azure-key-vault"
2482
2486
  ];
2483
2487
  z.enum(SYNC_PROVIDER_KINDS);
2484
2488
  /**
@@ -2819,6 +2823,54 @@ const langgraphPlatformConnectionConfigSchema = z.object({
2819
2823
  message: "set region for a LangChain-hosted account or baseUrl for a self-hosted one, not both",
2820
2824
  path: ["baseUrl"]
2821
2825
  });
2826
+ /**
2827
+ * The Azure clouds a vault can live in.
2828
+ *
2829
+ * Unlike AWS, where the China partition can be read off the region string
2830
+ * (`cn-…`), nothing about a tenant id or a vault name says which cloud it
2831
+ * belongs to — and two hosts have to agree with the answer: the Entra authority
2832
+ * that issues the token and the DNS suffix the vault answers on. Getting either
2833
+ * wrong is a failure inside an alarm with nobody watching, so it is stated.
2834
+ */
2835
+ const AZURE_CLOUDS = [
2836
+ "public",
2837
+ "usgov",
2838
+ "china"
2839
+ ];
2840
+ /**
2841
+ * A Microsoft Entra directory (tenant) or application (client) id.
2842
+ *
2843
+ * Both are GUIDs. Entra accepts a verified domain name in place of a tenant id
2844
+ * in the token URL, but not a client id, and taking only the GUID for both
2845
+ * keeps one rule — a tenant's GUID is on the same admin-center page as the
2846
+ * client id it pairs with, so nothing is harder to find.
2847
+ */
2848
+ const azureGuidSchema = z.string().trim().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "must be a GUID, as the Entra admin center shows it");
2849
+ /**
2850
+ * Azure account scope: which directory to authenticate against, as which app.
2851
+ *
2852
+ * The split follows AWS's, and for the same reason. A service principal's
2853
+ * **client secret** is the credential and is wrapped to the connection's key;
2854
+ * the tenant and client ids are *identifiers* — they appear in the token
2855
+ * request URL, in sign-in logs, and on the app registration blade. Keeping them
2856
+ * here lets the dashboard say which principal a connection authenticates as,
2857
+ * which is the first thing worth knowing when a connection starts failing after
2858
+ * a secret expires.
2859
+ *
2860
+ * Client secrets are the only credential kind here: a certificate or federated
2861
+ * credential would need a private key or a trust relationship the sync engine
2862
+ * has nowhere to keep, and Entra caps a client secret at 24 months, which is a
2863
+ * rotation the connection's `lastError` will make loud.
2864
+ */
2865
+ const azureKeyVaultConnectionConfigSchema = z.object({
2866
+ provider: z.literal("azure-key-vault"),
2867
+ /** Entra directory (tenant) ID. */
2868
+ tenantId: azureGuidSchema,
2869
+ /** Application (client) ID of the service principal seekrit signs in as. */
2870
+ clientId: azureGuidSchema,
2871
+ /** Which Azure cloud the tenant and its vaults live in. */
2872
+ cloud: z.enum(AZURE_CLOUDS).default("public")
2873
+ });
2822
2874
  const syncConnectionConfigSchema = z.discriminatedUnion("provider", [
2823
2875
  vercelConnectionConfigSchema,
2824
2876
  cloudflareWorkersConnectionConfigSchema,
@@ -2836,7 +2888,8 @@ const syncConnectionConfigSchema = z.discriminatedUnion("provider", [
2836
2888
  bunnyshellConnectionConfigSchema,
2837
2889
  githubActionsConnectionConfigSchema,
2838
2890
  gcpSecretManagerConnectionConfigSchema,
2839
- langgraphPlatformConnectionConfigSchema
2891
+ langgraphPlatformConnectionConfigSchema,
2892
+ azureKeyVaultConnectionConfigSchema
2840
2893
  ]);
2841
2894
  /** Vercel's three deployment targets. A binding writes to one or more. */
2842
2895
  const VERCEL_TARGETS = [
@@ -3568,6 +3621,65 @@ const langgraphPlatformDestinationSchema = z.object({
3568
3621
  /** Deployment UUID, from the dashboard URL or `GET /v2/deployments`. */
3569
3622
  deploymentId: z.string().trim().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "must be a LangGraph Platform deployment UUID")
3570
3623
  });
3624
+ /**
3625
+ * A Key Vault's name — the leftmost label of its DNS name, so the vault
3626
+ * `acme-prod` answers at `acme-prod.vault.azure.net`.
3627
+ *
3628
+ * Azure's own rule, stated here because the failure it prevents is confusing:
3629
+ * 3–24 characters, alphanumerics and hyphens, starting with a letter, ending
3630
+ * with a letter or digit, and no run of two hyphens. A name that breaks it
3631
+ * cannot exist, so a typo is a DNS failure rather than a 404 — an error that
3632
+ * says nothing about what was wrong.
3633
+ *
3634
+ * The *base URL* is not taken instead. It would let a connection be pointed at
3635
+ * any host, and the whole address seekrit needs is this label plus the cloud
3636
+ * already named on the connection.
3637
+ */
3638
+ const azureVaultNameSchema = z.string().trim().regex(/^[A-Za-z](?!.*--)[A-Za-z0-9-]{1,22}[A-Za-z0-9]$/, "must be a Key Vault name: 3–24 letters, digits, and single hyphens, starting with a letter");
3639
+ /**
3640
+ * What to do with a name Key Vault cannot store.
3641
+ *
3642
+ * Key Vault secret names are `^[0-9a-zA-Z-]+$` — **no underscores** — and
3643
+ * `DATABASE_URL` is what a secret is actually called nearly everywhere. So this
3644
+ * is not an edge case to fail on: it is the common case, and a connector that
3645
+ * rejected it would push nothing at all.
3646
+ *
3647
+ * - `dash` — `_` becomes `-`, so `DATABASE_URL` is stored as `DATABASE-URL`.
3648
+ * The default, and what every other tool bridging this gap does. The rename
3649
+ * is visible: the dashboard and the run ledger both show it.
3650
+ * - `reject` — fail each such name instead, for an operator who would rather
3651
+ * name every secret explicitly with the binding's `rename` than have seekrit
3652
+ * choose. Nothing is renamed silently under either setting; this one just
3653
+ * refuses rather than translating.
3654
+ *
3655
+ * Translating can make two seekrit names collide (`A_B` and `A-B` both give
3656
+ * `A-B`). That is caught per run and fails *both* names rather than letting
3657
+ * whichever sorts last win — the same rule, and the same reasoning, as
3658
+ * {@link mapSecretNames}.
3659
+ */
3660
+ const AZURE_KEY_VAULT_NAME_MODES = ["dash", "reject"];
3661
+ /**
3662
+ * Which vault a binding writes to, and under what names.
3663
+ *
3664
+ * There is no layout choice as Secrets Manager has: the reason a `json-bundle`
3665
+ * exists there is billing — AWS charges per secret per month — and Key Vault
3666
+ * charges per *operation*, so fifty names cost the same stored fifty ways. One
3667
+ * secret per name is simply correct here.
3668
+ */
3669
+ const azureKeyVaultDestinationSchema = z.object({
3670
+ provider: z.literal("azure-key-vault"),
3671
+ /** Vault name, e.g. `acme-prod` for `acme-prod.vault.azure.net`. */
3672
+ vault: azureVaultNameSchema,
3673
+ /**
3674
+ * Prepended to every secret name, e.g. `storefront-`. Key Vault has no
3675
+ * hierarchy — its names are flat, and `/` is not among the characters it
3676
+ * accepts — so unlike Parameter Store's `path` this is a naming convention
3677
+ * and nothing more. Worth setting in a vault that holds anything else.
3678
+ */
3679
+ prefix: z.string().trim().max(64).regex(/^[A-Za-z0-9-]*$/, "may contain letters, digits, and hyphens").optional(),
3680
+ /** What to do with a name Key Vault cannot store — see {@link AZURE_KEY_VAULT_NAME_MODES}. */
3681
+ nameMode: z.enum(AZURE_KEY_VAULT_NAME_MODES).default("dash")
3682
+ });
3571
3683
  const syncDestinationSchema = z.discriminatedUnion("provider", [
3572
3684
  vercelDestinationSchema,
3573
3685
  cloudflareWorkersDestinationSchema,
@@ -3585,7 +3697,8 @@ const syncDestinationSchema = z.discriminatedUnion("provider", [
3585
3697
  bunnyshellDestinationSchema,
3586
3698
  githubActionsDestinationSchema,
3587
3699
  gcpSecretManagerDestinationSchema,
3588
- langgraphPlatformDestinationSchema
3700
+ langgraphPlatformDestinationSchema,
3701
+ azureKeyVaultDestinationSchema
3589
3702
  ]);
3590
3703
  /**
3591
3704
  * How seekrit secret names become destination key names. Applied in order:
@@ -4873,7 +4986,7 @@ async function createAgentTaskToken() {
4873
4986
  }
4874
4987
  //#endregion
4875
4988
  //#region package.json
4876
- var version = "0.45.0";
4989
+ var version = "0.47.0";
4877
4990
  //#endregion
4878
4991
  //#region ../../packages/api-client/src/index.ts
4879
4992
  var SeekritApiError = class extends Error {
@@ -5025,6 +5138,10 @@ var SeekritClient = class {
5025
5138
  getEnv(orgId, envId) {
5026
5139
  return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}`);
5027
5140
  }
5141
+ /** Rename an environment (display name only — the slug is immutable). */
5142
+ updateEnv(orgId, envId, input) {
5143
+ return this.request("PATCH", `/v1/orgs/${orgId}/envs/${envId}`, input);
5144
+ }
5028
5145
  deleteEnv(orgId, envId) {
5029
5146
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
5030
5147
  }
@@ -5178,6 +5295,10 @@ var SeekritClient = class {
5178
5295
  createToken(orgId, input) {
5179
5296
  return this.request("POST", `/v1/orgs/${orgId}/tokens`, input);
5180
5297
  }
5298
+ /** Rename a token. Role, environment binding, and expiry are immutable. */
5299
+ updateToken(orgId, tokenId, input) {
5300
+ return this.request("PATCH", `/v1/orgs/${orgId}/tokens/${tokenId}`, input);
5301
+ }
5181
5302
  revokeToken(orgId, tokenId) {
5182
5303
  return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}`);
5183
5304
  }
@@ -10065,570 +10186,259 @@ function registerOrgCommands(program) {
10065
10186
  });
10066
10187
  }
10067
10188
  //#endregion
10068
- //#region src/pg.ts
10189
+ //#region src/proxy-presets.ts
10190
+ /** `Authorization: Bearer {{seekrit:NAME}}` — the shape most providers take. */
10191
+ function placeholder(secret) {
10192
+ return `{{seekrit:${secret}}}`;
10193
+ }
10069
10194
  /**
10070
- * `seekrit pg` temporary Postgres credentials (Vault-style dynamic secrets).
10071
- *
10072
- * Zero-knowledge: minting generates the password and its SCRAM verifier on THIS
10073
- * machine and sends only the verifier; the plaintext password never reaches the
10074
- * API or gets stored. Registering a target wraps the admin connection string to
10075
- * the broker's public key locally, so the control plane only ever stores
10076
- * ciphertext.
10195
+ * The catalogue. Ordered as `seekrit proxy presets` prints it: the three model
10196
+ * APIs an agent almost certainly calls, then the aggregators, then the generic
10197
+ * escape hatches.
10077
10198
  */
10078
- /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
10079
- function parseTtlSeconds$2(input) {
10080
- const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
10081
- if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
10082
- return Number(m[1]) * ({
10083
- s: 1,
10084
- m: 60,
10085
- h: 3600,
10086
- d: 86400
10087
- }[m[2] || "s"] ?? 1);
10199
+ const PROXY_PRESETS = [
10200
+ {
10201
+ id: "openai",
10202
+ label: "OpenAI API (api.openai.com)",
10203
+ host: "api.openai.com",
10204
+ prefix: "/openai",
10205
+ secret: "OPENAI_API_KEY",
10206
+ methods: ["GET", "POST"],
10207
+ paths: ["/v1/**"],
10208
+ baseUrlSuffix: "/v1",
10209
+ env: (mode) => mode === "reverse" ? [{
10210
+ name: "OPENAI_BASE_URL",
10211
+ value: "{{base}}"
10212
+ }, {
10213
+ name: "OPENAI_API_KEY",
10214
+ value: placeholder("OPENAI_API_KEY")
10215
+ }] : [{
10216
+ name: "OPENAI_API_KEY",
10217
+ value: placeholder("OPENAI_API_KEY")
10218
+ }]
10219
+ },
10220
+ {
10221
+ id: "anthropic",
10222
+ label: "Anthropic API (api.anthropic.com)",
10223
+ host: "api.anthropic.com",
10224
+ prefix: "/anthropic",
10225
+ secret: "ANTHROPIC_API_KEY",
10226
+ methods: ["GET", "POST"],
10227
+ paths: ["/v1/**"],
10228
+ baseUrlSuffix: "",
10229
+ env: (mode) => mode === "reverse" ? [{
10230
+ name: "ANTHROPIC_BASE_URL",
10231
+ value: "{{base}}"
10232
+ }, {
10233
+ name: "ANTHROPIC_API_KEY",
10234
+ value: placeholder("ANTHROPIC_API_KEY")
10235
+ }] : [{
10236
+ name: "ANTHROPIC_API_KEY",
10237
+ value: placeholder("ANTHROPIC_API_KEY")
10238
+ }]
10239
+ },
10240
+ {
10241
+ id: "gemini",
10242
+ label: "Gemini API (generativelanguage.googleapis.com)",
10243
+ host: "generativelanguage.googleapis.com",
10244
+ prefix: "/gemini",
10245
+ secret: "GEMINI_API_KEY",
10246
+ methods: ["GET", "POST"],
10247
+ paths: ["/v1beta/**", "/v1/**"],
10248
+ baseUrlSuffix: "",
10249
+ env: (mode) => mode === "reverse" ? [{
10250
+ name: "GOOGLE_GEMINI_BASE_URL",
10251
+ value: "{{base}}",
10252
+ note: "gemini-cli reads GEMINI_BASE_URL instead — set both if you run either."
10253
+ }, {
10254
+ name: "GEMINI_API_KEY",
10255
+ value: placeholder("GEMINI_API_KEY")
10256
+ }] : [{
10257
+ name: "GEMINI_API_KEY",
10258
+ value: placeholder("GEMINI_API_KEY")
10259
+ }],
10260
+ note: "Gemini authenticates with an x-goog-api-key header; a ?key= query string is substituted too, but prefer the header."
10261
+ },
10262
+ {
10263
+ id: "openrouter",
10264
+ label: "OpenRouter (openrouter.ai) — OpenAI-compatible",
10265
+ host: "openrouter.ai",
10266
+ prefix: "/openrouter",
10267
+ secret: "OPENROUTER_API_KEY",
10268
+ methods: ["GET", "POST"],
10269
+ paths: ["/api/v1/**"],
10270
+ baseUrlSuffix: "/api/v1",
10271
+ env: (mode) => mode === "reverse" ? [{
10272
+ name: "OPENAI_BASE_URL",
10273
+ value: "{{base}}"
10274
+ }, {
10275
+ name: "OPENAI_API_KEY",
10276
+ value: placeholder("OPENROUTER_API_KEY")
10277
+ }] : [{
10278
+ name: "OPENROUTER_API_KEY",
10279
+ value: placeholder("OPENROUTER_API_KEY")
10280
+ }]
10281
+ },
10282
+ {
10283
+ id: "github",
10284
+ label: "GitHub REST + GraphQL API (api.github.com)",
10285
+ host: "api.github.com",
10286
+ prefix: "/github",
10287
+ secret: "GITHUB_TOKEN",
10288
+ methods: [],
10289
+ paths: [],
10290
+ baseUrlSuffix: "",
10291
+ env: (mode) => mode === "reverse" ? [{
10292
+ name: "GITHUB_API_URL",
10293
+ value: "{{base}}"
10294
+ }, {
10295
+ name: "GITHUB_TOKEN",
10296
+ value: placeholder("GITHUB_TOKEN")
10297
+ }] : [{
10298
+ name: "GITHUB_TOKEN",
10299
+ value: placeholder("GITHUB_TOKEN")
10300
+ }],
10301
+ note: "`gh` resolves api.github.com from GH_HOST, not a base URL — prefer forward mode for it."
10302
+ },
10303
+ {
10304
+ id: "openai-compatible",
10305
+ label: "Any OpenAI-compatible gateway — LiteLLM, vLLM, Ollama, Together, self-hosted",
10306
+ host: "",
10307
+ prefix: "/gateway",
10308
+ secret: "OPENAI_API_KEY",
10309
+ methods: ["GET", "POST"],
10310
+ paths: ["/v1/**"],
10311
+ baseUrlSuffix: "/v1",
10312
+ requiresBaseUrl: true,
10313
+ env: (mode) => mode === "reverse" ? [{
10314
+ name: "OPENAI_BASE_URL",
10315
+ value: "{{base}}"
10316
+ }, {
10317
+ name: "OPENAI_API_KEY",
10318
+ value: placeholder("OPENAI_API_KEY")
10319
+ }] : [{
10320
+ name: "OPENAI_API_KEY",
10321
+ value: placeholder("OPENAI_API_KEY")
10322
+ }],
10323
+ note: "Needs --base-url (e.g. --base-url https://litellm.internal:4000)."
10324
+ }
10325
+ ];
10326
+ function findPreset(id) {
10327
+ return PROXY_PRESETS.find((p) => p.id === id);
10088
10328
  }
10089
- /** A fresh, valid Postgres role name: `tmp_` + lowercase alphanumerics. */
10090
- function generateRoleName(prefix = "tmp") {
10091
- const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
10092
- let out = "";
10093
- const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
10094
- for (const b of bytes) out += alphabet[b % 36];
10095
- return `${prefix}_${out}`;
10329
+ function presetIds() {
10330
+ return PROXY_PRESETS.map((p) => p.id);
10096
10331
  }
10097
- function registerPgCommands(program) {
10098
- const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
10099
- const target = pg.command("target").description("manage provisioning targets");
10100
- target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--schema <name>", "schema for preset grants", "public").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$2, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$2, []).action(async (options) => {
10101
- const ctx = buildContext();
10102
- const org = await resolveOrg(ctx, options.org);
10103
- const executor = options.executor === "remote" ? "remote" : "in_do";
10104
- if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
10105
- if (![
10106
- "readonly",
10107
- "readwrite",
10108
- "custom"
10109
- ].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
10110
- const accessLevel = options.access;
10111
- const adminSecret = resolveLeaseAdminSecret({
10112
- executor,
10113
- hmacKey: options.hmacKey,
10114
- adminUrl: options.adminUrl,
10115
- adminUrlEnv: "SEEKRIT_PG_ADMIN_URL"
10116
- });
10117
- const config = {
10118
- provider: "postgres",
10119
- executor,
10120
- accessLevel,
10121
- connection: {
10122
- host: options.host,
10123
- port: Number.parseInt(options.port, 10),
10124
- database: options.database
10125
- },
10126
- ...accessLevel === "custom" ? {
10127
- ...options.createStatement.length ? { createStatements: options.createStatement } : {},
10128
- ...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
10129
- } : { schema: options.schema },
10130
- ...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
10131
- };
10132
- const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
10133
- const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
10134
- const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
10135
- name: options.name,
10136
- config,
10137
- wrappedAdminSecret
10138
- });
10139
- console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
10140
- const bootstrap = postgresGroupBootstrapSql(config);
10141
- if (bootstrap) {
10142
- console.error("\nRun this once in your database as an admin (safe to re-run):\n");
10143
- console.log(bootstrap);
10144
- }
10145
- });
10146
- target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
10147
- const ctx = buildContext();
10148
- const org = await resolveOrg(ctx, options.org);
10149
- const { targets } = await ctx.client.listLeaseTargets(org.id);
10150
- for (const t of targets) {
10151
- const cfg = t.config;
10152
- if (cfg.provider !== "postgres") continue;
10153
- console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
10332
+ /**
10333
+ * Apply `--base-url` / `--secret` / `--prefix` overrides to a preset.
10334
+ *
10335
+ * Returns a new preset rather than mutating the catalogue entry: the same
10336
+ * process can generate two configs in one run (a test does), and a preset that
10337
+ * remembered the last `--base-url` would be a genuinely confusing bug.
10338
+ */
10339
+ function specialize(preset, overrides) {
10340
+ let host = preset.host;
10341
+ let paths = preset.paths;
10342
+ let baseUrlSuffix = preset.baseUrlSuffix;
10343
+ if (overrides.baseUrl) {
10344
+ const url = new URL(overrides.baseUrl);
10345
+ host = url.hostname.toLowerCase();
10346
+ const upstreamPath = url.pathname.replace(/\/+$/, "");
10347
+ if (upstreamPath) {
10348
+ paths = [];
10349
+ baseUrlSuffix = `${upstreamPath}${preset.baseUrlSuffix}`;
10154
10350
  }
10155
- });
10156
- target.command("setup-sql <targetId>").description("print the one-time group-role setup SQL for a preset target").option("--org <slug>").action(async (targetId, options) => {
10157
- const ctx = buildContext();
10158
- const org = await resolveOrg(ctx, options.org);
10159
- const { targets } = await ctx.client.listLeaseTargets(org.id);
10160
- const t = targets.find((x) => x.id === targetId || x.name === targetId);
10161
- if (!t) fail(`no target "${targetId}" in ${org.slug}`);
10162
- const cfg = t.config;
10163
- if (cfg.provider !== "postgres") fail("not a postgres target (see `seekrit ssh`)");
10164
- const bootstrap = postgresGroupBootstrapSql(cfg);
10165
- if (!bootstrap) fail("this is a custom target it has no generated setup SQL");
10166
- console.log(bootstrap);
10167
- });
10168
- target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
10169
- const ctx = buildContext();
10170
- const org = await resolveOrg(ctx, options.org);
10171
- await ctx.client.deleteLeaseTarget(org.id, targetId);
10172
- console.error(`removed ${targetId}`);
10173
- });
10174
- pg.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>").option("--role <name>", "role name to create (default: a random tmp_ name)").option("--ttl <duration>", "lifetime, e.g. 30m, 1h, 7d", "1h").option("--json", "print the full connection as JSON").action(async (targetRef, options) => {
10175
- const ctx = buildContext();
10176
- const org = await resolveOrg(ctx, options.org);
10177
- const { targets } = await ctx.client.listLeaseTargets(org.id);
10178
- const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
10179
- if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
10180
- if (target.config.provider !== "postgres") fail(`"${target.name}" is not a postgres target (see \`seekrit ssh\`)`);
10181
- const roleName = options.role ?? generateRoleName();
10182
- const ttlSeconds = parseTtlSeconds$2(options.ttl);
10183
- const { password, verifier } = await generatePostgresCredential();
10184
- const { connection } = await ctx.client.mintLease(org.id, {
10185
- provider: "postgres",
10186
- targetId: target.id,
10187
- roleName,
10188
- verifier,
10189
- ttlSeconds
10190
- });
10191
- const url = `postgres://${roleName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
10192
- console.error(`leased ${roleName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
10193
- if (options.json) console.log(JSON.stringify({
10194
- ...connection,
10195
- password,
10196
- url
10197
- }, null, 2));
10198
- else console.log(url);
10199
- });
10200
- pg.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
10201
- const ctx = buildContext();
10202
- const org = await resolveOrg(ctx, options.org);
10203
- const { leases } = await ctx.client.listLeases(org.id);
10204
- for (const l of leases) console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
10205
- });
10206
- pg.command("revoke <leaseId>").description("revoke a lease now (drops the role immediately)").option("--org <slug>").action(async (leaseId, options) => {
10207
- const ctx = buildContext();
10208
- const org = await resolveOrg(ctx, options.org);
10209
- await ctx.client.revokeLease(org.id, leaseId);
10210
- console.error(`revoked ${leaseId}`);
10211
- });
10351
+ }
10352
+ const secret = overrides.secret ?? preset.secret;
10353
+ const specialized = {
10354
+ ...preset,
10355
+ host,
10356
+ paths,
10357
+ baseUrlSuffix,
10358
+ secret,
10359
+ prefix: overrides.prefix ?? preset.prefix
10360
+ };
10361
+ if (preset.allow && secret !== preset.secret) specialized.allow = preset.allow.map((name) => name === preset.secret ? secret : name);
10362
+ if (secret !== preset.secret) specialized.env = (mode) => preset.env(mode).map((hint) => ({
10363
+ ...hint,
10364
+ value: hint.value.replace(/\{\{seekrit:[A-Za-z0-9_]+\}\}/, placeholder(secret))
10365
+ }));
10366
+ return specialized;
10212
10367
  }
10213
- /** Collect a repeatable option into an array. */
10214
- function collect$2(value, acc) {
10215
- acc.push(value);
10216
- return acc;
10368
+ /** The secret names a preset's rule permits. Default-deny: empty means none. */
10369
+ function presetAllow(preset) {
10370
+ if (preset.allow) return preset.allow;
10371
+ return preset.secret ? [preset.secret] : [];
10217
10372
  }
10218
10373
  //#endregion
10219
- //#region src/proxy-binary.ts
10220
- /**
10221
- * Fetch and run the `seekrit-proxy` binary without a Rust toolchain.
10222
- *
10223
- * The proxy is the strongest answer seekrit has for an untrusted workload — the
10224
- * agent holds `{{seekrit:NAME}}` and never the key — and it was also the hardest
10225
- * thing here to *try*, because trying it meant `cargo` and a TOML file. This
10226
- * module removes the first half: it resolves a prebuilt, checksum-verified
10227
- * binary for the host platform and execs it, so `npx @seekrit/proxy` and
10228
- * `seekrit proxy run` behave like the proxy was already installed.
10229
- *
10230
- * The logic lives in the CLI (and is re-exported as `@seekrit/cli/proxy-launcher`)
10231
- * for the same reason the MCP server does: `@seekrit/proxy` is a thin npx
10232
- * entrypoint over it, and the two must not drift.
10233
- *
10234
- * Three properties worth stating, since this downloads and executes code:
10235
- *
10236
- * - **The checksum is verified before anything is executed**, against a
10237
- * `.sha256` fetched from the same release. That is integrity, not provenance —
10238
- * it proves the bytes match what the release published, which is exactly the
10239
- * guarantee `install.sh` gives and no more.
10240
- * - **Nothing is fetched when a binary is already available.** `SEEKRIT_PROXY_BIN`
10241
- * short-circuits entirely, and a cached download for the same version+target is
10242
- * reused, so this is a one-time cost per version.
10243
- * - **Version is pinned, not floating.** A default of `latest` would make two
10244
- * machines run different proxies from the same command; the pinned constant is
10245
- * what this CLI was built against, overridable when you want otherwise.
10246
- */
10247
- /**
10248
- * The proxy version this CLI was built against.
10249
- *
10250
- * Bumped by release-please when `apps/proxy` releases (an `extra-files` entry in
10251
- * release-please-config.json), so the pin follows the crate without anyone
10252
- * remembering to move it.
10253
- */
10254
- const PROXY_VERSION = "0.10.0";
10255
- const BIN = "seekrit-proxy";
10374
+ //#region src/proxy-config.ts
10256
10375
  /**
10257
- * Host Rust target triple.
10258
- *
10259
- * Linux always resolves to **musl**: that build is statically linked, so one
10260
- * artifact covers glibc, musl, alpine, and distroless, and there is no libc
10261
- * detection to get wrong on a machine where `ldd` says something unexpected.
10376
+ * A deliberately small TOML writer: basic strings and arrays of them, which is
10377
+ * every value in this config. Full TOML is not needed and a general emitter
10378
+ * would be one more thing that can disagree with the parser on an edge case.
10262
10379
  */
10263
- function detectTarget(os = platform(), cpu = arch()) {
10264
- const machine = cpu === "x64" ? "x86_64" : cpu === "arm64" ? "aarch64" : null;
10265
- if (!machine) throw new Error(`unsupported architecture "${cpu}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
10266
- switch (os) {
10267
- case "linux": return {
10268
- target: `${machine}-unknown-linux-musl`,
10269
- exe: ""
10270
- };
10271
- case "darwin": return {
10272
- target: `${machine}-apple-darwin`,
10273
- exe: ""
10274
- };
10275
- case "win32":
10276
- if (machine !== "x86_64") throw new Error(`no prebuilt seekrit-proxy for ${machine} Windows — set SEEKRIT_PROXY_BIN to a binary you built`);
10277
- return {
10278
- target: "x86_64-pc-windows-msvc",
10279
- exe: ".exe"
10280
- };
10281
- default: throw new Error(`unsupported platform "${os}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
10282
- }
10283
- }
10284
- /** `latest` stays `latest`; everything else is normalized to `v<x.y.z>`. */
10285
- function versionPrefix(version) {
10286
- if (version === "latest") return "latest";
10287
- return version.startsWith("v") ? version : `v${version}`;
10288
- }
10289
- function resolveVersion(explicit) {
10290
- return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
10291
- }
10292
- function resolveBaseUrl(explicit) {
10293
- return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
10294
- }
10295
- /** Where a resolved binary is kept, keyed so versions and targets never collide. */
10296
- function proxyBinaryPath(version, target, exe) {
10297
- return join(defaultCacheDir(), "proxy", versionPrefix(version), target, `${BIN}${exe}`);
10380
+ function tomlString(value) {
10381
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, (c) => {
10382
+ return `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`;
10383
+ })}"`;
10298
10384
  }
10299
- async function fetchBytes(url) {
10300
- const res = await fetch(url);
10301
- if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
10302
- return new Uint8Array(await res.arrayBuffer());
10385
+ function tomlArray(values) {
10386
+ return `[${values.map(tomlString).join(", ")}]`;
10303
10387
  }
10304
10388
  /**
10305
- * Ensure a `seekrit-proxy` binary exists locally and return its path.
10389
+ * Claim `preferred` if nothing else has it, else derive one from `host`.
10306
10390
  *
10307
- * Order: an explicit `SEEKRIT_PROXY_BIN`, then a cached download for this
10308
- * version+target, then a fresh download. A binary already on `PATH` is
10309
- * deliberately *not* used silently running a different version than the one
10310
- * this CLI pins is the kind of surprise that costs an afternoon.
10391
+ * Two rules can name the same preset-known host (a narrow rule above a broad
10392
+ * one is the documented pattern), and two routes cannot share a prefix so the
10393
+ * second one needs a distinct, still-recognisable name rather than an error.
10311
10394
  */
10312
- async function resolveProxyBinary(options = {}) {
10313
- const override = process.env.SEEKRIT_PROXY_BIN;
10314
- if (override) {
10315
- if (!existsSync(override)) throw new Error(`SEEKRIT_PROXY_BIN points at ${override}, which does not exist`);
10316
- return override;
10395
+ function claimPrefix(preferred, host, taken) {
10396
+ if (preferred && !taken.has(preferred)) {
10397
+ taken.add(preferred);
10398
+ return preferred;
10317
10399
  }
10318
- const version = resolveVersion(options.version);
10319
- const { target, exe } = detectTarget();
10320
- const dest = proxyBinaryPath(version, target, exe);
10321
- if (!options.force && version !== "latest" && existsSync(dest)) return dest;
10322
- const baseUrl = resolveBaseUrl(options.baseUrl);
10323
- const prefix = versionPrefix(version);
10324
- const name = `${BIN}-${target}${exe}`;
10325
- const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
10326
- const sumUrl = `${binUrl}.sha256`;
10327
- if (!options.quiet) process.stderr.write(`seekrit: fetching ${BIN} ${prefix} (${target})…\n`);
10328
- let bytes;
10329
- let expected;
10330
- try {
10331
- [bytes, expected] = await Promise.all([fetchBytes(binUrl), fetchBytes(sumUrl).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
10332
- } catch (err) {
10333
- const message = err instanceof Error ? err.message : String(err);
10334
- throw new Error(`could not download ${BIN} ${prefix} for ${target}: ${message}\n Set SEEKRIT_PROXY_BIN to a binary you already have, or build it from apps/proxy.`);
10400
+ return prefixForHost(host, taken);
10401
+ }
10402
+ /** Slug for a route prefix, derived from a hostname. */
10403
+ function prefixForHost(host, taken) {
10404
+ const labels = host.split(".").filter(Boolean);
10405
+ while (labels.length > 1 && (labels[0] === "api" || labels[0] === "www")) labels.shift();
10406
+ const base = (labels[0] ?? "upstream").replace(/[^a-z0-9-]/gi, "").toLowerCase() || "upstream";
10407
+ let prefix = `/${base}`;
10408
+ let n = 2;
10409
+ while (taken.has(prefix)) prefix = `/${base}-${n++}`;
10410
+ taken.add(prefix);
10411
+ return prefix;
10412
+ }
10413
+ const HEADER = `# seekrit-proxy configuration generated by \`seekrit proxy init\`.
10414
+ #
10415
+ # The proxy resolves the secrets its service token grants (SEEKRIT_TOKEN in the
10416
+ # environment, never in this file), then swaps {{seekrit:NAME}} placeholders in
10417
+ # outbound requests for the decrypted values before forwarding upstream.
10418
+ #
10419
+ # Safe to commit: it contains hostnames, secret *names*, and thumbprints — no
10420
+ # secret values and no credential. Review it before you rely on it; the
10421
+ # allowlist below is a security boundary, and a generator does not know your
10422
+ # threat model.`;
10423
+ /** Render the plan as the text of a `seekrit-proxy.toml`. */
10424
+ function renderProxyConfig(plan) {
10425
+ const out = [HEADER];
10426
+ const server = Boolean(plan.policy);
10427
+ if (plan.notes.length > 0) {
10428
+ out.push("#");
10429
+ for (const note of plan.notes) out.push(`# ${note}`);
10335
10430
  }
10336
- const actual = createHash("sha256").update(bytes).digest("hex");
10337
- if (!expected || actual !== expected.toLowerCase()) throw new Error(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to run it.`);
10338
- const dir = dirname(dest);
10339
- mkdirSync(dir, { recursive: true });
10340
- const staging = join(dir, `.${BIN}-${process.pid}-${actual.slice(0, 12)}${exe}`);
10341
- try {
10342
- writeFileSync(staging, bytes, { mode: 493 });
10343
- renameSync(staging, dest);
10344
- } catch (err) {
10345
- rmSync(staging, { force: true });
10346
- throw err;
10347
- }
10348
- chmodSync(dest, 493);
10349
- return dest;
10350
- }
10351
- /**
10352
- * Run the proxy, forwarding stdio, signals, and its exit status.
10353
- *
10354
- * The proxy is a long-lived foreground process, so this wrapper has to be
10355
- * transparent: Node cannot exec-replace itself, and without relaying signals
10356
- * Node's default SIGINT handler would kill *this* process on Ctrl-C and leave
10357
- * the proxy running, holding decrypted secrets, with the shell prompt back.
10358
- */
10359
- async function runProxyBinary(argv, options = {}) {
10360
- const bin = await resolveProxyBinary(options);
10361
- const child = spawn(bin, argv, {
10362
- stdio: "inherit",
10363
- env: {
10364
- ...process.env,
10365
- ...options.env
10366
- }
10367
- });
10368
- const signals = [
10369
- "SIGINT",
10370
- "SIGTERM",
10371
- "SIGHUP",
10372
- "SIGQUIT"
10373
- ];
10374
- const forward = (signal) => {
10375
- if (child.exitCode !== null || child.signalCode !== null) return;
10376
- child.kill(signal);
10377
- };
10378
- for (const signal of signals) process.on(signal, forward);
10379
- return new Promise((resolve, reject) => {
10380
- child.on("error", (err) => {
10381
- for (const s of signals) process.off(s, forward);
10382
- reject(/* @__PURE__ */ new Error(`could not start ${bin}: ${err.message}\n If this is a fresh download, the platform may not match — set SEEKRIT_PROXY_BIN.`));
10383
- });
10384
- child.on("exit", (code, signal) => {
10385
- for (const s of signals) process.off(s, forward);
10386
- resolve(signal ? 128 + signalNumber(signal) : code ?? 0);
10387
- });
10388
- });
10389
- }
10390
- /** Signal name → number, for the 128+n exit convention. */
10391
- function signalNumber(signal) {
10392
- return {
10393
- SIGHUP: 1,
10394
- SIGINT: 2,
10395
- SIGQUIT: 3,
10396
- SIGKILL: 9,
10397
- SIGTERM: 15
10398
- }[signal] ?? 0;
10399
- }
10400
- //#endregion
10401
- //#region src/proxy-presets.ts
10402
- /** `Authorization: Bearer {{seekrit:NAME}}` — the shape most providers take. */
10403
- function placeholder(secret) {
10404
- return `{{seekrit:${secret}}}`;
10405
- }
10406
- /**
10407
- * The catalogue. Ordered as `seekrit proxy presets` prints it: the two model
10408
- * APIs an agent almost certainly calls, then the aggregators, then the generic
10409
- * escape hatches.
10410
- */
10411
- const PROXY_PRESETS = [
10412
- {
10413
- id: "openai",
10414
- label: "OpenAI API (api.openai.com)",
10415
- host: "api.openai.com",
10416
- prefix: "/openai",
10417
- secret: "OPENAI_API_KEY",
10418
- methods: ["GET", "POST"],
10419
- paths: ["/v1/**"],
10420
- baseUrlSuffix: "/v1",
10421
- env: (mode) => mode === "reverse" ? [{
10422
- name: "OPENAI_BASE_URL",
10423
- value: "{{base}}"
10424
- }, {
10425
- name: "OPENAI_API_KEY",
10426
- value: placeholder("OPENAI_API_KEY")
10427
- }] : [{
10428
- name: "OPENAI_API_KEY",
10429
- value: placeholder("OPENAI_API_KEY")
10430
- }]
10431
- },
10432
- {
10433
- id: "anthropic",
10434
- label: "Anthropic API (api.anthropic.com)",
10435
- host: "api.anthropic.com",
10436
- prefix: "/anthropic",
10437
- secret: "ANTHROPIC_API_KEY",
10438
- methods: ["GET", "POST"],
10439
- paths: ["/v1/**"],
10440
- baseUrlSuffix: "",
10441
- env: (mode) => mode === "reverse" ? [{
10442
- name: "ANTHROPIC_BASE_URL",
10443
- value: "{{base}}"
10444
- }, {
10445
- name: "ANTHROPIC_API_KEY",
10446
- value: placeholder("ANTHROPIC_API_KEY")
10447
- }] : [{
10448
- name: "ANTHROPIC_API_KEY",
10449
- value: placeholder("ANTHROPIC_API_KEY")
10450
- }]
10451
- },
10452
- {
10453
- id: "openrouter",
10454
- label: "OpenRouter (openrouter.ai) — OpenAI-compatible",
10455
- host: "openrouter.ai",
10456
- prefix: "/openrouter",
10457
- secret: "OPENROUTER_API_KEY",
10458
- methods: ["GET", "POST"],
10459
- paths: ["/api/v1/**"],
10460
- baseUrlSuffix: "/api/v1",
10461
- env: (mode) => mode === "reverse" ? [{
10462
- name: "OPENAI_BASE_URL",
10463
- value: "{{base}}"
10464
- }, {
10465
- name: "OPENAI_API_KEY",
10466
- value: placeholder("OPENROUTER_API_KEY")
10467
- }] : [{
10468
- name: "OPENROUTER_API_KEY",
10469
- value: placeholder("OPENROUTER_API_KEY")
10470
- }]
10471
- },
10472
- {
10473
- id: "github",
10474
- label: "GitHub REST + GraphQL API (api.github.com)",
10475
- host: "api.github.com",
10476
- prefix: "/github",
10477
- secret: "GITHUB_TOKEN",
10478
- methods: [],
10479
- paths: [],
10480
- baseUrlSuffix: "",
10481
- env: (mode) => mode === "reverse" ? [{
10482
- name: "GITHUB_API_URL",
10483
- value: "{{base}}"
10484
- }, {
10485
- name: "GITHUB_TOKEN",
10486
- value: placeholder("GITHUB_TOKEN")
10487
- }] : [{
10488
- name: "GITHUB_TOKEN",
10489
- value: placeholder("GITHUB_TOKEN")
10490
- }],
10491
- note: "`gh` resolves api.github.com from GH_HOST, not a base URL — prefer forward mode for it."
10492
- },
10493
- {
10494
- id: "openai-compatible",
10495
- label: "Any OpenAI-compatible gateway — LiteLLM, vLLM, Ollama, Together, self-hosted",
10496
- host: "",
10497
- prefix: "/gateway",
10498
- secret: "OPENAI_API_KEY",
10499
- methods: ["GET", "POST"],
10500
- paths: ["/v1/**"],
10501
- baseUrlSuffix: "/v1",
10502
- requiresBaseUrl: true,
10503
- env: (mode) => mode === "reverse" ? [{
10504
- name: "OPENAI_BASE_URL",
10505
- value: "{{base}}"
10506
- }, {
10507
- name: "OPENAI_API_KEY",
10508
- value: placeholder("OPENAI_API_KEY")
10509
- }] : [{
10510
- name: "OPENAI_API_KEY",
10511
- value: placeholder("OPENAI_API_KEY")
10512
- }],
10513
- note: "Needs --base-url (e.g. --base-url https://litellm.internal:4000)."
10514
- }
10515
- ];
10516
- function findPreset(id) {
10517
- return PROXY_PRESETS.find((p) => p.id === id);
10518
- }
10519
- function presetIds() {
10520
- return PROXY_PRESETS.map((p) => p.id);
10521
- }
10522
- /**
10523
- * Apply `--base-url` / `--secret` / `--prefix` overrides to a preset.
10524
- *
10525
- * Returns a new preset rather than mutating the catalogue entry: the same
10526
- * process can generate two configs in one run (a test does), and a preset that
10527
- * remembered the last `--base-url` would be a genuinely confusing bug.
10528
- */
10529
- function specialize(preset, overrides) {
10530
- let host = preset.host;
10531
- let paths = preset.paths;
10532
- let baseUrlSuffix = preset.baseUrlSuffix;
10533
- if (overrides.baseUrl) {
10534
- const url = new URL(overrides.baseUrl);
10535
- host = url.hostname.toLowerCase();
10536
- const upstreamPath = url.pathname.replace(/\/+$/, "");
10537
- if (upstreamPath) {
10538
- paths = [];
10539
- baseUrlSuffix = `${upstreamPath}${preset.baseUrlSuffix}`;
10540
- }
10541
- }
10542
- const secret = overrides.secret ?? preset.secret;
10543
- const specialized = {
10544
- ...preset,
10545
- host,
10546
- paths,
10547
- baseUrlSuffix,
10548
- secret,
10549
- prefix: overrides.prefix ?? preset.prefix
10550
- };
10551
- if (preset.allow && secret !== preset.secret) specialized.allow = preset.allow.map((name) => name === preset.secret ? secret : name);
10552
- if (secret !== preset.secret) specialized.env = (mode) => preset.env(mode).map((hint) => ({
10553
- ...hint,
10554
- value: hint.value.replace(/\{\{seekrit:[A-Za-z0-9_]+\}\}/, placeholder(secret))
10555
- }));
10556
- return specialized;
10557
- }
10558
- /** The secret names a preset's rule permits. Default-deny: empty means none. */
10559
- function presetAllow(preset) {
10560
- if (preset.allow) return preset.allow;
10561
- return preset.secret ? [preset.secret] : [];
10562
- }
10563
- //#endregion
10564
- //#region src/proxy-config.ts
10565
- /**
10566
- * A deliberately small TOML writer: basic strings and arrays of them, which is
10567
- * every value in this config. Full TOML is not needed and a general emitter
10568
- * would be one more thing that can disagree with the parser on an edge case.
10569
- */
10570
- function tomlString(value) {
10571
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, (c) => {
10572
- return `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`;
10573
- })}"`;
10574
- }
10575
- function tomlArray(values) {
10576
- return `[${values.map(tomlString).join(", ")}]`;
10577
- }
10578
- /**
10579
- * Claim `preferred` if nothing else has it, else derive one from `host`.
10580
- *
10581
- * Two rules can name the same preset-known host (a narrow rule above a broad
10582
- * one is the documented pattern), and two routes cannot share a prefix — so the
10583
- * second one needs a distinct, still-recognisable name rather than an error.
10584
- */
10585
- function claimPrefix(preferred, host, taken) {
10586
- if (preferred && !taken.has(preferred)) {
10587
- taken.add(preferred);
10588
- return preferred;
10589
- }
10590
- return prefixForHost(host, taken);
10591
- }
10592
- /** Slug for a route prefix, derived from a hostname. */
10593
- function prefixForHost(host, taken) {
10594
- const labels = host.split(".").filter(Boolean);
10595
- while (labels.length > 1 && (labels[0] === "api" || labels[0] === "www")) labels.shift();
10596
- const base = (labels[0] ?? "upstream").replace(/[^a-z0-9-]/gi, "").toLowerCase() || "upstream";
10597
- let prefix = `/${base}`;
10598
- let n = 2;
10599
- while (taken.has(prefix)) prefix = `/${base}-${n++}`;
10600
- taken.add(prefix);
10601
- return prefix;
10602
- }
10603
- const HEADER = `# seekrit-proxy configuration — generated by \`seekrit proxy init\`.
10604
- #
10605
- # The proxy resolves the secrets its service token grants (SEEKRIT_TOKEN in the
10606
- # environment, never in this file), then swaps {{seekrit:NAME}} placeholders in
10607
- # outbound requests for the decrypted values before forwarding upstream.
10608
- #
10609
- # Safe to commit: it contains hostnames, secret *names*, and thumbprints — no
10610
- # secret values and no credential. Review it before you rely on it; the
10611
- # allowlist below is a security boundary, and a generator does not know your
10612
- # threat model.`;
10613
- /** Render the plan as the text of a `seekrit-proxy.toml`. */
10614
- function renderProxyConfig(plan) {
10615
- const out = [HEADER];
10616
- const server = Boolean(plan.policy);
10617
- if (plan.notes.length > 0) {
10618
- out.push("#");
10619
- for (const note of plan.notes) out.push(`# ${note}`);
10620
- }
10621
- out.push("");
10622
- const reverse = plan.mode === "reverse" || plan.mode === "both";
10623
- const forward = plan.mode === "forward" || plan.mode === "both";
10624
- if (reverse) {
10625
- out.push(`listen = ${tomlString(plan.listen)}`);
10626
- out.push("");
10627
- } else {
10628
- out.push("# Forward-proxy only: the reverse plane still binds this address and");
10629
- out.push("# serves nothing, since no [[route]] is declared below.");
10630
- out.push(`listen = ${tomlString(plan.listen)}`);
10631
- out.push("");
10431
+ out.push("");
10432
+ const reverse = plan.mode === "reverse" || plan.mode === "both";
10433
+ const forward = plan.mode === "forward" || plan.mode === "both";
10434
+ if (reverse) {
10435
+ out.push(`listen = ${tomlString(plan.listen)}`);
10436
+ out.push("");
10437
+ } else {
10438
+ out.push("# Forward-proxy only: the reverse plane still binds this address and");
10439
+ out.push("# serves nothing, since no [[route]] is declared below.");
10440
+ out.push(`listen = ${tomlString(plan.listen)}`);
10441
+ out.push("");
10632
10442
  }
10633
10443
  if (reverse) for (const route of plan.routes) {
10634
10444
  out.push("[[route]]");
@@ -10791,203 +10601,725 @@ function planFromPresets(presets, options) {
10791
10601
  label: preset.label,
10792
10602
  baseUrl
10793
10603
  });
10794
- for (const hint of preset.env(hintMode)) envHints.push({
10795
- ...hint,
10796
- value: hint.value.replace("{{base}}", baseUrl)
10604
+ for (const hint of preset.env(hintMode)) envHints.push({
10605
+ ...hint,
10606
+ value: hint.value.replace("{{base}}", baseUrl)
10607
+ });
10608
+ if (preset.note) notes.push(`${preset.id}: ${preset.note}`);
10609
+ }
10610
+ if (hintMode === "forward") envHints.unshift({
10611
+ name: "HTTPS_PROXY",
10612
+ value: `http://${options.forwardListen}`
10613
+ }, {
10614
+ name: "NODE_EXTRA_CA_CERTS",
10615
+ value: `$PWD/${options.caCert}`,
10616
+ note: "or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime."
10617
+ });
10618
+ return {
10619
+ mode: options.mode,
10620
+ listen: options.listen,
10621
+ forwardListen: options.forwardListen,
10622
+ routes,
10623
+ unmatched: options.unmatched,
10624
+ caCert: options.caCert,
10625
+ caKey: options.caKey,
10626
+ ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
10627
+ ...options.secretsRefresh ? { secretsRefresh: options.secretsRefresh } : {},
10628
+ ...options.control ? { control: options.control } : {},
10629
+ ...options.tasks ? { tasks: options.tasks } : {},
10630
+ ...options.activity ? { activity: options.activity } : {},
10631
+ envHints,
10632
+ notes
10633
+ };
10634
+ }
10635
+ /**
10636
+ * Build a server-policy plan from an agent's published rules.
10637
+ *
10638
+ * The rules are used for **routing only** — one `[[route]]` per distinct host,
10639
+ * so the workload has a base URL to point at — and never copied into the file as
10640
+ * authorization. That is the whole trade of server mode: adding an upstream
10641
+ * becomes a dashboard change, and a rule this file also stated would be a
10642
+ * startup error rather than a belt-and-braces duplicate.
10643
+ */
10644
+ function planFromPolicy(args, options) {
10645
+ const taken = /* @__PURE__ */ new Set();
10646
+ const routes = [];
10647
+ const envHints = [];
10648
+ const notes = [];
10649
+ const hintMode = options.mode === "forward" ? "forward" : "reverse";
10650
+ const seen = /* @__PURE__ */ new Set();
10651
+ for (const rule of args.rules) {
10652
+ if (!rule.host || seen.has(rule.host)) continue;
10653
+ seen.add(rule.host);
10654
+ const preset = PRESET_BY_HOST.get(rule.host);
10655
+ const prefix = claimPrefix(preset?.prefix, rule.host, taken);
10656
+ const baseUrl = baseUrlFor(options.listen, prefix, preset?.baseUrlSuffix ?? "");
10657
+ routes.push({
10658
+ prefix,
10659
+ upstream: `https://${rule.host}`,
10660
+ host: rule.host,
10661
+ allow: [],
10662
+ methods: [],
10663
+ paths: [],
10664
+ baseUrl
10665
+ });
10666
+ if (preset) for (const hint of preset.env(hintMode)) envHints.push({
10667
+ ...hint,
10668
+ value: hint.value.replace("{{base}}", baseUrl)
10669
+ });
10670
+ }
10671
+ if (hintMode === "forward") envHints.unshift({
10672
+ name: "HTTPS_PROXY",
10673
+ value: `http://${options.forwardListen}`
10674
+ }, {
10675
+ name: "NODE_EXTRA_CA_CERTS",
10676
+ value: `$PWD/${options.caCert}`,
10677
+ note: "or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime."
10678
+ });
10679
+ if (args.rules.length === 0) notes.push("The published policy has no rules yet, so this proxy permits nothing until one is published.");
10680
+ return {
10681
+ mode: options.mode,
10682
+ listen: options.listen,
10683
+ forwardListen: options.forwardListen,
10684
+ routes,
10685
+ policy: {
10686
+ agent: args.agent,
10687
+ agents: args.agents,
10688
+ refreshInterval: args.refreshInterval,
10689
+ signers: args.signers
10690
+ },
10691
+ unmatched: options.unmatched,
10692
+ caCert: options.caCert,
10693
+ caKey: options.caKey,
10694
+ ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
10695
+ ...options.control ? { control: options.control } : {},
10696
+ ...options.tasks ? { tasks: options.tasks } : {},
10697
+ ...options.activity ? { activity: options.activity } : {},
10698
+ envHints,
10699
+ notes
10700
+ };
10701
+ }
10702
+ /** Host → preset, for naming routes generated from published policy. */
10703
+ const PRESET_BY_HOST = /* @__PURE__ */ new Map();
10704
+ for (const id of [
10705
+ "openai",
10706
+ "anthropic",
10707
+ "openrouter",
10708
+ "github"
10709
+ ]) {
10710
+ const preset = findPreset(id);
10711
+ if (preset?.host) PRESET_BY_HOST.set(preset.host, preset);
10712
+ }
10713
+ /** YAML double-quoted scalar. Compose values here are hostnames and URLs. */
10714
+ function yamlString(value) {
10715
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
10716
+ }
10717
+ const COMPOSE_DEFAULTS = {
10718
+ service: "seekrit-proxy",
10719
+ workload: "agent",
10720
+ publish: false
10721
+ };
10722
+ /**
10723
+ * A `docker compose` sidecar snippet for a generated config.
10724
+ *
10725
+ * The container case differs from the local one in exactly the ways that break a
10726
+ * copied-from-the-docs compose file: the proxy has to bind `0.0.0.0` to be
10727
+ * reachable from a sibling container, the workload dials it by *service name*
10728
+ * rather than loopback, and in forward mode the CA has to live on a shared
10729
+ * volume or the workload trusts a certificate the proxy no longer has.
10730
+ */
10731
+ function renderComposeSnippet(plan, options) {
10732
+ const reverse = plan.mode === "reverse" || plan.mode === "both";
10733
+ const forward = plan.mode === "forward" || plan.mode === "both";
10734
+ const [, listenPort = "8080"] = splitHostPort(plan.listen);
10735
+ const [, forwardPort = "8081"] = splitHostPort(plan.forwardListen);
10736
+ const host = options.service;
10737
+ const out = [
10738
+ "# seekrit-proxy sidecar — generated by `seekrit proxy compose`.",
10739
+ "#",
10740
+ "# The proxy holds the decrypted secrets; the workload holds only placeholders.",
10741
+ "# Keeping them in separate containers is what makes that boundary real: the",
10742
+ "# service token is in the proxy's environment, where the workload cannot read it.",
10743
+ "services:",
10744
+ ` ${host}:`,
10745
+ ` image: ${options.image}`
10746
+ ];
10747
+ const command = [];
10748
+ if (reverse) command.push("--listen", `0.0.0.0:${listenPort}`);
10749
+ if (command.length > 0) out.push(` command: [${command.map(yamlString).join(", ")}]`);
10750
+ if (forward) {
10751
+ out.push(` # Forward mode: set \`[forward] listen = "0.0.0.0:${forwardPort}"\` in the`);
10752
+ out.push(" # config too — there is no flag for the forward plane's address.");
10753
+ }
10754
+ out.push(" environment:");
10755
+ out.push(" # Never inline the token. Compose reads it from your shell or a .env file.");
10756
+ out.push(" SEEKRIT_TOKEN: ${SEEKRIT_TOKEN:?SEEKRIT_TOKEN is required}");
10757
+ out.push(" volumes:");
10758
+ out.push(" - ./seekrit-proxy.toml:/seekrit-proxy.toml:ro");
10759
+ if (forward) {
10760
+ out.push(" # The interception CA must survive restarts, or the certificate the");
10761
+ out.push(" # workload trusts stops matching the one the proxy mints leaves from.");
10762
+ out.push(" - seekrit-proxy-ca:/ca");
10763
+ }
10764
+ if (options.publish) {
10765
+ out.push(" ports:");
10766
+ if (reverse) out.push(` - ${yamlString(`127.0.0.1:${listenPort}:${listenPort}`)}`);
10767
+ if (forward) out.push(` - ${yamlString(`127.0.0.1:${forwardPort}:${forwardPort}`)}`);
10768
+ } else {
10769
+ out.push(" # No `ports`: reachable on the compose network only, which is what you");
10770
+ out.push(" # want — nothing outside this project can ask the proxy to inject a key.");
10771
+ }
10772
+ out.push(" restart: unless-stopped");
10773
+ out.push("");
10774
+ out.push(` ${options.workload}:`);
10775
+ out.push(" # ← your workload. It never holds a real credential.");
10776
+ out.push(" image: your-agent:latest");
10777
+ out.push(" depends_on:");
10778
+ out.push(` - ${host}`);
10779
+ out.push(" environment:");
10780
+ if (forward) {
10781
+ out.push(` HTTPS_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
10782
+ out.push(` HTTP_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
10783
+ out.push(" NODE_EXTRA_CA_CERTS: \"/ca/seekrit-proxy-ca.pem\"");
10784
+ out.push(" # …or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime.");
10785
+ }
10786
+ for (const hint of plan.envHints) {
10787
+ if (hint.name === "HTTPS_PROXY" || hint.name === "NODE_EXTRA_CA_CERTS") continue;
10788
+ const value = hint.value.replace(/http:\/\/[^/]+/, `http://${host}:${listenPort}`);
10789
+ out.push(` ${hint.name}: ${yamlString(value)}`);
10790
+ }
10791
+ out.push("");
10792
+ if (forward) {
10793
+ out.push("volumes:");
10794
+ out.push(" seekrit-proxy-ca:");
10795
+ out.push("");
10796
+ }
10797
+ out.push(forward ? "# The workload can unset HTTPS_PROXY, so in a threat model where the workload" : "# The workload can ignore the base URL above, so in a threat model where the");
10798
+ out.push(forward ? "# is the adversary, make the proxy the only route out: put the workload on an" : "# workload is the adversary, make the proxy the only route out: put the workload");
10799
+ out.push(forward ? "# `internal: true` network with the proxy as its only peer." : "# on an `internal: true` network with the proxy as its only peer.");
10800
+ return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
10801
+ }
10802
+ //#endregion
10803
+ //#region src/paperclip.ts
10804
+ /**
10805
+ * `seekrit paperclip` — wire seekrit into a [Paperclip](https://docs.paperclip.ing)
10806
+ * agent.
10807
+ *
10808
+ * Paperclip is a control plane: it decides which agent runs, and its *adapter*
10809
+ * launches the runtime that does the work. Two consequences shape this command.
10810
+ *
10811
+ * First, **MCP config is per-runtime, not per-Paperclip-agent.** There is no
10812
+ * field in Paperclip's database that means "give this agent the seekrit tools" —
10813
+ * the adapter's runtime (Claude Code, Codex, Gemini CLI, OpenCode) reads its own
10814
+ * MCP config from the working directory Paperclip points it at. So attaching the
10815
+ * seekrit servers means writing a `.mcp.json` *there*, which is what this does.
10816
+ *
10817
+ * Second, **a seekrit secret cannot be a Paperclip `secret_ref`.** Paperclip's
10818
+ * provider list is closed (`local_encrypted`, `aws_secrets_manager`,
10819
+ * `gcp_secret_manager`, `vault`), so there is nothing to select. Values reach a
10820
+ * run through `seekrit run` / the `run_command` tool, or — better, for a run
10821
+ * whose output you cannot predict — through the egress proxy, where the adapter
10822
+ * env holds `{{seekrit:NAME}}` placeholders and never a key. The env block this
10823
+ * command prints is that second shape, ready to paste into the agent's
10824
+ * Configuration tab.
10825
+ *
10826
+ * The proxy's own config file stays with `seekrit proxy init`. That command
10827
+ * already owns the reviewable-security-artifact warnings, and a second
10828
+ * generator behind a different name is how the two drift apart.
10829
+ */
10830
+ /** Claude Code and friends read this name from the runtime's working directory. */
10831
+ const MCP_FILE = ".mcp.json";
10832
+ /**
10833
+ * The two seekrit MCP servers, in the shape a *runtime's* `.mcp.json` wants.
10834
+ *
10835
+ * These are the same two servers `agent-plugin/mcp.json` declares, and a test
10836
+ * pins them to that file so a rename cannot land in one place only. The one
10837
+ * field that is deliberately **not** copied is the remote transport's spelling:
10838
+ * the Agent Plugins manifest says `streamable-http`, while Claude Code's own
10839
+ * `.mcp.json` says `http`. Writing the manifest's spelling into a runtime config
10840
+ * produces a server the runtime silently declines to load, which reads as "the
10841
+ * hosted server is down".
10842
+ */
10843
+ const PAPERCLIP_MCP_SERVERS = {
10844
+ seekrit: {
10845
+ type: "stdio",
10846
+ command: "npx",
10847
+ args: ["-y", "@seekrit/mcp"]
10848
+ },
10849
+ "seekrit-cloud": {
10850
+ type: "http",
10851
+ url: "https://mcp.seekrit.dev/mcp"
10852
+ }
10853
+ };
10854
+ /**
10855
+ * Merge the seekrit servers into an existing `.mcp.json` without disturbing it.
10856
+ *
10857
+ * An agent's working directory is usually a real repository, so this file may
10858
+ * already carry servers someone else depends on — replacing it wholesale is a
10859
+ * silent regression in whatever they were doing. Unknown top-level keys are
10860
+ * preserved for the same reason: runtimes keep growing new ones.
10861
+ *
10862
+ * A `seekrit` entry that already exists and *differs* is left alone unless
10863
+ * forced. Someone pinning a version or adding an `env` did it on purpose.
10864
+ */
10865
+ function mergeMcpServers(existing, force) {
10866
+ const servers = { ...existing.mcpServers ?? {} };
10867
+ const added = [];
10868
+ const kept = [];
10869
+ for (const [name, entry] of Object.entries(PAPERCLIP_MCP_SERVERS)) {
10870
+ const current = servers[name];
10871
+ if (current && !force) {
10872
+ if (JSON.stringify(current) === JSON.stringify(entry)) continue;
10873
+ kept.push(name);
10874
+ continue;
10875
+ }
10876
+ servers[name] = entry;
10877
+ added.push(name);
10878
+ }
10879
+ return {
10880
+ merged: {
10881
+ ...existing,
10882
+ mcpServers: servers
10883
+ },
10884
+ added,
10885
+ kept
10886
+ };
10887
+ }
10888
+ function readMcpFile(path) {
10889
+ if (!existsSync(path)) return {};
10890
+ let raw;
10891
+ try {
10892
+ raw = readFileSync(path, "utf8");
10893
+ } catch (err) {
10894
+ fail(`could not read ${path}: ${err instanceof Error ? err.message : String(err)}`);
10895
+ }
10896
+ try {
10897
+ const parsed = JSON.parse(raw);
10898
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) fail(`${path} is not a JSON object — fix or move it before writing MCP servers there`);
10899
+ return parsed;
10900
+ } catch (err) {
10901
+ if (err instanceof SyntaxError) fail(`${path} is not valid JSON (${err.message}) — fix or move it first`);
10902
+ throw err;
10903
+ }
10904
+ }
10905
+ /**
10906
+ * The env a Paperclip agent needs, given the upstreams it calls.
10907
+ *
10908
+ * Every value here is a plain string: a placeholder is not a secret, so none of
10909
+ * it needs a `secret_ref` and none of it trips
10910
+ * `PAPERCLIP_SECRETS_STRICT_MODE` — which is exactly why this is the path to
10911
+ * recommend. The env is derived from the same preset catalogue and the same
10912
+ * plan builder `seekrit proxy init` uses, so what gets pasted into Paperclip and
10913
+ * what the proxy enforces cannot disagree.
10914
+ *
10915
+ * One hint has to be rewritten on the way through. The shared generator writes
10916
+ * the CA path as `$PWD/seekrit-proxy-ca.pem`, which is correct for the `export`
10917
+ * lines it normally produces — a shell expands it. **Paperclip's env map is not a
10918
+ * shell.** Pasted verbatim it becomes a literal `$PWD/…`, the runtime cannot find
10919
+ * the CA, and every HTTPS call fails with a certificate error that looks like a
10920
+ * proxy bug. So it is replaced with a marker that cannot be mistaken for a
10921
+ * working value.
10922
+ */
10923
+ const ABSOLUTE_PATH_MARKER = "<absolute path to>";
10924
+ function adapterEnv(presets, options) {
10925
+ if (presets.length === 0) return [];
10926
+ return planFromPresets(presets, {
10927
+ ...PLAN_DEFAULTS,
10928
+ mode: options.mode,
10929
+ listen: options.listen,
10930
+ forwardListen: options.forwardListen
10931
+ }).envHints.map((hint) => {
10932
+ if (!hint.value.includes("$PWD/")) return { ...hint };
10933
+ return {
10934
+ ...hint,
10935
+ value: hint.value.replace("$PWD/", `${ABSOLUTE_PATH_MARKER} `),
10936
+ note: `${hint.note ? `${hint.note} ` : ""}Paperclip does not expand shell variables — paste the real path the proxy wrote this to.`
10937
+ };
10938
+ });
10939
+ }
10940
+ function registerPaperclipCommands(program) {
10941
+ program.command("paperclip").description("wire seekrit into a Paperclip agent (`seekrit paperclip --help`)").command("init").description("attach the seekrit MCP servers to a Paperclip agent's working directory").option("-d, --dir <path>", "the agent's working directory", ".").option("--preset <id...>", `upstreams the agent calls, for the printed adapter env (${presetIds().join(", ")})`).option("--mode <mode>", "proxy mode the printed env assumes: forward or reverse", "forward").option("--listen <addr>", "reverse-mode proxy address", PLAN_DEFAULTS.listen).option("--forward-listen <addr>", "forward-mode proxy address", PLAN_DEFAULTS.forwardListen).option("--no-mcp", "print the adapter env only, without writing .mcp.json").option("--force", "overwrite a seekrit entry that already differs").option("--json", "machine-readable output").action((options) => {
10942
+ if (options.mode !== "forward" && options.mode !== "reverse") fail(`--mode must be forward or reverse (got "${options.mode}")`);
10943
+ const presets = [];
10944
+ for (const id of options.preset ?? []) {
10945
+ const preset = findPreset(id);
10946
+ if (!preset) fail(`unknown preset "${id}" — try one of: ${presetIds().join(", ")}`);
10947
+ if (preset.requiresBaseUrl) fail(`preset "${id}" needs a base URL, which this command does not take —\n generate its env with: seekrit proxy init --preset ${id} --base-url https://…`);
10948
+ presets.push(preset);
10949
+ }
10950
+ const dir = resolve(options.dir);
10951
+ const mcpPath = join(dir, MCP_FILE);
10952
+ let added = [];
10953
+ let kept = [];
10954
+ if (options.mcp) {
10955
+ if (!existsSync(dir)) fail(`no such directory: ${options.dir}\n Pass --dir with the agent's working directory (its Configuration tab shows it).`);
10956
+ const result = mergeMcpServers(readMcpFile(mcpPath), Boolean(options.force));
10957
+ added = result.added;
10958
+ kept = result.kept;
10959
+ writeFileSync(mcpPath, `${JSON.stringify(result.merged, null, 2)}\n`, { mode: 420 });
10960
+ }
10961
+ const env = adapterEnv(presets, {
10962
+ mode: options.mode,
10963
+ listen: options.listen,
10964
+ forwardListen: options.forwardListen
10965
+ });
10966
+ emit(options, {
10967
+ mcpFile: options.mcp ? mcpPath : null,
10968
+ added,
10969
+ kept,
10970
+ env
10971
+ }, () => {
10972
+ if (options.mcp) {
10973
+ process.stderr.write(added.length > 0 ? `Wrote ${mcpPath} (${added.join(", ")})\n` : `${mcpPath} already had both seekrit servers\n`);
10974
+ for (const name of kept) process.stderr.write(`seekrit: left the existing "${name}" entry alone — pass --force to replace it\n`);
10975
+ }
10976
+ if (env.length > 0) {
10977
+ process.stderr.write("\nAdapter environment variables (Agent → Configuration → Environment variables).\nEvery value is a plain string — a placeholder is not a secret, so none of\nthese needs a Paperclip secret_ref:\n\n");
10978
+ printTable(env, [col("VARIABLE", (h) => h.name), col("VALUE", (h) => h.value)], "no env for these presets");
10979
+ const notes = env.filter((h) => h.note);
10980
+ if (notes.length > 0) {
10981
+ process.stderr.write("\n");
10982
+ for (const hint of notes) process.stderr.write(` ${hint.name}: ${hint.note}\n`);
10983
+ }
10984
+ const presetFlags = presets.map((p) => `--preset ${p.id}`).join(" ");
10985
+ process.stderr.write(`\nThose values only resolve behind a running proxy. Write its config with:\n seekrit proxy init --mode ${options.mode} ${presetFlags}\n`);
10986
+ } else process.stderr.write("\nNo --preset given, so no adapter env was printed. Pass the upstreams this\nagent calls to get the placeholder env for them, e.g.\n seekrit paperclip init --preset anthropic --preset openai\n");
10987
+ process.stderr.write("\nAlso worth doing once per company:\n npx paperclipai plugin install @seekrit/paperclip-plugin # tools, skills, panel\n Skills page → https://github.com/seekritdev/agent-plugin # skills alone\n");
10797
10988
  });
10798
- if (preset.note) notes.push(`${preset.id}: ${preset.note}`);
10799
- }
10800
- if (hintMode === "forward") envHints.unshift({
10801
- name: "HTTPS_PROXY",
10802
- value: `http://${options.forwardListen}`
10803
- }, {
10804
- name: "NODE_EXTRA_CA_CERTS",
10805
- value: `$PWD/${options.caCert}`,
10806
- note: "or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime."
10807
10989
  });
10808
- return {
10809
- mode: options.mode,
10810
- listen: options.listen,
10811
- forwardListen: options.forwardListen,
10812
- routes,
10813
- unmatched: options.unmatched,
10814
- caCert: options.caCert,
10815
- caKey: options.caKey,
10816
- ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
10817
- ...options.secretsRefresh ? { secretsRefresh: options.secretsRefresh } : {},
10818
- ...options.control ? { control: options.control } : {},
10819
- ...options.tasks ? { tasks: options.tasks } : {},
10820
- ...options.activity ? { activity: options.activity } : {},
10821
- envHints,
10822
- notes
10823
- };
10824
10990
  }
10991
+ //#endregion
10992
+ //#region src/pg.ts
10825
10993
  /**
10826
- * Build a server-policy plan from an agent's published rules.
10994
+ * `seekrit pg` temporary Postgres credentials (Vault-style dynamic secrets).
10827
10995
  *
10828
- * The rules are used for **routing only** one `[[route]]` per distinct host,
10829
- * so the workload has a base URL to point at — and never copied into the file as
10830
- * authorization. That is the whole trade of server mode: adding an upstream
10831
- * becomes a dashboard change, and a rule this file also stated would be a
10832
- * startup error rather than a belt-and-braces duplicate.
10996
+ * Zero-knowledge: minting generates the password and its SCRAM verifier on THIS
10997
+ * machine and sends only the verifier; the plaintext password never reaches the
10998
+ * API or gets stored. Registering a target wraps the admin connection string to
10999
+ * the broker's public key locally, so the control plane only ever stores
11000
+ * ciphertext.
10833
11001
  */
10834
- function planFromPolicy(args, options) {
10835
- const taken = /* @__PURE__ */ new Set();
10836
- const routes = [];
10837
- const envHints = [];
10838
- const notes = [];
10839
- const hintMode = options.mode === "forward" ? "forward" : "reverse";
10840
- const seen = /* @__PURE__ */ new Set();
10841
- for (const rule of args.rules) {
10842
- if (!rule.host || seen.has(rule.host)) continue;
10843
- seen.add(rule.host);
10844
- const preset = PRESET_BY_HOST.get(rule.host);
10845
- const prefix = claimPrefix(preset?.prefix, rule.host, taken);
10846
- const baseUrl = baseUrlFor(options.listen, prefix, preset?.baseUrlSuffix ?? "");
10847
- routes.push({
10848
- prefix,
10849
- upstream: `https://${rule.host}`,
10850
- host: rule.host,
10851
- allow: [],
10852
- methods: [],
10853
- paths: [],
10854
- baseUrl
11002
+ /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
11003
+ function parseTtlSeconds$2(input) {
11004
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
11005
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
11006
+ return Number(m[1]) * ({
11007
+ s: 1,
11008
+ m: 60,
11009
+ h: 3600,
11010
+ d: 86400
11011
+ }[m[2] || "s"] ?? 1);
11012
+ }
11013
+ /** A fresh, valid Postgres role name: `tmp_` + lowercase alphanumerics. */
11014
+ function generateRoleName(prefix = "tmp") {
11015
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
11016
+ let out = "";
11017
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
11018
+ for (const b of bytes) out += alphabet[b % 36];
11019
+ return `${prefix}_${out}`;
11020
+ }
11021
+ function registerPgCommands(program) {
11022
+ const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
11023
+ const target = pg.command("target").description("manage provisioning targets");
11024
+ target.command("add").description("register a Postgres cluster to lease credentials from").requiredOption("--name <name>", "display name, e.g. prod-db").option("--org <slug>").requiredOption("--host <host>", "database host").option("--port <port>", "database port", "5432").requiredOption("--database <name>", "database to connect to").option("--access <level>", "readonly | readwrite | custom", "readonly").option("--schema <name>", "schema for preset grants", "public").option("--executor <mode>", "in_do | remote", "in_do").option("--provisioner-url <url>", "customer provisioner URL (remote executor)").option("--hmac-key <base64>", "shared HMAC key for the remote executor (or set SEEKRIT_PROVISIONER_HMAC_KEY); `seekrit provisioner keygen` mints one").option("--admin-url <url>", "in_do: admin postgres:// connection string (or set SEEKRIT_PG_ADMIN_URL); wrapped locally").option("--create-statement <sql>", "custom CREATE template (repeatable)", collect$2, []).option("--revoke-statement <sql>", "custom REVOKE template (repeatable)", collect$2, []).action(async (options) => {
11025
+ const ctx = buildContext();
11026
+ const org = await resolveOrg(ctx, options.org);
11027
+ const executor = options.executor === "remote" ? "remote" : "in_do";
11028
+ if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
11029
+ if (![
11030
+ "readonly",
11031
+ "readwrite",
11032
+ "custom"
11033
+ ].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
11034
+ const accessLevel = options.access;
11035
+ const adminSecret = resolveLeaseAdminSecret({
11036
+ executor,
11037
+ hmacKey: options.hmacKey,
11038
+ adminUrl: options.adminUrl,
11039
+ adminUrlEnv: "SEEKRIT_PG_ADMIN_URL"
10855
11040
  });
10856
- if (preset) for (const hint of preset.env(hintMode)) envHints.push({
10857
- ...hint,
10858
- value: hint.value.replace("{{base}}", baseUrl)
11041
+ const config = {
11042
+ provider: "postgres",
11043
+ executor,
11044
+ accessLevel,
11045
+ connection: {
11046
+ host: options.host,
11047
+ port: Number.parseInt(options.port, 10),
11048
+ database: options.database
11049
+ },
11050
+ ...accessLevel === "custom" ? {
11051
+ ...options.createStatement.length ? { createStatements: options.createStatement } : {},
11052
+ ...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
11053
+ } : { schema: options.schema },
11054
+ ...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
11055
+ };
11056
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
11057
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
11058
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
11059
+ name: options.name,
11060
+ config,
11061
+ wrappedAdminSecret
11062
+ });
11063
+ console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
11064
+ const bootstrap = postgresGroupBootstrapSql(config);
11065
+ if (bootstrap) {
11066
+ console.error("\nRun this once in your database as an admin (safe to re-run):\n");
11067
+ console.log(bootstrap);
11068
+ }
11069
+ });
11070
+ target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
11071
+ const ctx = buildContext();
11072
+ const org = await resolveOrg(ctx, options.org);
11073
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
11074
+ for (const t of targets) {
11075
+ const cfg = t.config;
11076
+ if (cfg.provider !== "postgres") continue;
11077
+ console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
11078
+ }
11079
+ });
11080
+ target.command("setup-sql <targetId>").description("print the one-time group-role setup SQL for a preset target").option("--org <slug>").action(async (targetId, options) => {
11081
+ const ctx = buildContext();
11082
+ const org = await resolveOrg(ctx, options.org);
11083
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
11084
+ const t = targets.find((x) => x.id === targetId || x.name === targetId);
11085
+ if (!t) fail(`no target "${targetId}" in ${org.slug}`);
11086
+ const cfg = t.config;
11087
+ if (cfg.provider !== "postgres") fail("not a postgres target (see `seekrit ssh`)");
11088
+ const bootstrap = postgresGroupBootstrapSql(cfg);
11089
+ if (!bootstrap) fail("this is a custom target — it has no generated setup SQL");
11090
+ console.log(bootstrap);
11091
+ });
11092
+ target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
11093
+ const ctx = buildContext();
11094
+ const org = await resolveOrg(ctx, options.org);
11095
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
11096
+ console.error(`removed ${targetId}`);
11097
+ });
11098
+ pg.command("lease <target>").description("mint a temporary credential; prints a ready-to-use connection URL").option("--org <slug>").option("--role <name>", "role name to create (default: a random tmp_ name)").option("--ttl <duration>", "lifetime, e.g. 30m, 1h, 7d", "1h").option("--json", "print the full connection as JSON").action(async (targetRef, options) => {
11099
+ const ctx = buildContext();
11100
+ const org = await resolveOrg(ctx, options.org);
11101
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
11102
+ const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
11103
+ if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
11104
+ if (target.config.provider !== "postgres") fail(`"${target.name}" is not a postgres target (see \`seekrit ssh\`)`);
11105
+ const roleName = options.role ?? generateRoleName();
11106
+ const ttlSeconds = parseTtlSeconds$2(options.ttl);
11107
+ const { password, verifier } = await generatePostgresCredential();
11108
+ const { connection } = await ctx.client.mintLease(org.id, {
11109
+ provider: "postgres",
11110
+ targetId: target.id,
11111
+ roleName,
11112
+ verifier,
11113
+ ttlSeconds
10859
11114
  });
11115
+ const url = `postgres://${roleName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
11116
+ console.error(`leased ${roleName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
11117
+ if (options.json) console.log(JSON.stringify({
11118
+ ...connection,
11119
+ password,
11120
+ url
11121
+ }, null, 2));
11122
+ else console.log(url);
11123
+ });
11124
+ pg.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
11125
+ const ctx = buildContext();
11126
+ const org = await resolveOrg(ctx, options.org);
11127
+ const { leases } = await ctx.client.listLeases(org.id);
11128
+ for (const l of leases) console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
11129
+ });
11130
+ pg.command("revoke <leaseId>").description("revoke a lease now (drops the role immediately)").option("--org <slug>").action(async (leaseId, options) => {
11131
+ const ctx = buildContext();
11132
+ const org = await resolveOrg(ctx, options.org);
11133
+ await ctx.client.revokeLease(org.id, leaseId);
11134
+ console.error(`revoked ${leaseId}`);
11135
+ });
11136
+ }
11137
+ /** Collect a repeatable option into an array. */
11138
+ function collect$2(value, acc) {
11139
+ acc.push(value);
11140
+ return acc;
11141
+ }
11142
+ //#endregion
11143
+ //#region src/proxy-binary.ts
11144
+ /**
11145
+ * Fetch and run the `seekrit-proxy` binary without a Rust toolchain.
11146
+ *
11147
+ * The proxy is the strongest answer seekrit has for an untrusted workload — the
11148
+ * agent holds `{{seekrit:NAME}}` and never the key — and it was also the hardest
11149
+ * thing here to *try*, because trying it meant `cargo` and a TOML file. This
11150
+ * module removes the first half: it resolves a prebuilt, checksum-verified
11151
+ * binary for the host platform and execs it, so `npx @seekrit/proxy` and
11152
+ * `seekrit proxy run` behave like the proxy was already installed.
11153
+ *
11154
+ * The logic lives in the CLI (and is re-exported as `@seekrit/cli/proxy-launcher`)
11155
+ * for the same reason the MCP server does: `@seekrit/proxy` is a thin npx
11156
+ * entrypoint over it, and the two must not drift.
11157
+ *
11158
+ * Three properties worth stating, since this downloads and executes code:
11159
+ *
11160
+ * - **The checksum is verified before anything is executed**, against a
11161
+ * `.sha256` fetched from the same release. That is integrity, not provenance —
11162
+ * it proves the bytes match what the release published, which is exactly the
11163
+ * guarantee `install.sh` gives and no more.
11164
+ * - **Nothing is fetched when a binary is already available.** `SEEKRIT_PROXY_BIN`
11165
+ * short-circuits entirely, and a cached download for the same version+target is
11166
+ * reused, so this is a one-time cost per version.
11167
+ * - **Version is pinned, not floating.** A default of `latest` would make two
11168
+ * machines run different proxies from the same command; the pinned constant is
11169
+ * what this CLI was built against, overridable when you want otherwise.
11170
+ */
11171
+ /**
11172
+ * The proxy version this CLI was built against.
11173
+ *
11174
+ * Bumped by release-please when `apps/proxy` releases (an `extra-files` entry in
11175
+ * release-please-config.json), so the pin follows the crate without anyone
11176
+ * remembering to move it.
11177
+ */
11178
+ const PROXY_VERSION = "0.10.0";
11179
+ const BIN = "seekrit-proxy";
11180
+ /**
11181
+ * Host → Rust target triple.
11182
+ *
11183
+ * Linux always resolves to **musl**: that build is statically linked, so one
11184
+ * artifact covers glibc, musl, alpine, and distroless, and there is no libc
11185
+ * detection to get wrong on a machine where `ldd` says something unexpected.
11186
+ */
11187
+ function detectTarget(os = platform(), cpu = arch()) {
11188
+ const machine = cpu === "x64" ? "x86_64" : cpu === "arm64" ? "aarch64" : null;
11189
+ if (!machine) throw new Error(`unsupported architecture "${cpu}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
11190
+ switch (os) {
11191
+ case "linux": return {
11192
+ target: `${machine}-unknown-linux-musl`,
11193
+ exe: ""
11194
+ };
11195
+ case "darwin": return {
11196
+ target: `${machine}-apple-darwin`,
11197
+ exe: ""
11198
+ };
11199
+ case "win32":
11200
+ if (machine !== "x86_64") throw new Error(`no prebuilt seekrit-proxy for ${machine} Windows — set SEEKRIT_PROXY_BIN to a binary you built`);
11201
+ return {
11202
+ target: "x86_64-pc-windows-msvc",
11203
+ exe: ".exe"
11204
+ };
11205
+ default: throw new Error(`unsupported platform "${os}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
10860
11206
  }
10861
- if (hintMode === "forward") envHints.unshift({
10862
- name: "HTTPS_PROXY",
10863
- value: `http://${options.forwardListen}`
10864
- }, {
10865
- name: "NODE_EXTRA_CA_CERTS",
10866
- value: `$PWD/${options.caCert}`,
10867
- note: "or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime."
10868
- });
10869
- if (args.rules.length === 0) notes.push("The published policy has no rules yet, so this proxy permits nothing until one is published.");
10870
- return {
10871
- mode: options.mode,
10872
- listen: options.listen,
10873
- forwardListen: options.forwardListen,
10874
- routes,
10875
- policy: {
10876
- agent: args.agent,
10877
- agents: args.agents,
10878
- refreshInterval: args.refreshInterval,
10879
- signers: args.signers
10880
- },
10881
- unmatched: options.unmatched,
10882
- caCert: options.caCert,
10883
- caKey: options.caKey,
10884
- ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
10885
- ...options.control ? { control: options.control } : {},
10886
- ...options.tasks ? { tasks: options.tasks } : {},
10887
- ...options.activity ? { activity: options.activity } : {},
10888
- envHints,
10889
- notes
10890
- };
10891
11207
  }
10892
- /** Host preset, for naming routes generated from published policy. */
10893
- const PRESET_BY_HOST = /* @__PURE__ */ new Map();
10894
- for (const id of [
10895
- "openai",
10896
- "anthropic",
10897
- "openrouter",
10898
- "github"
10899
- ]) {
10900
- const preset = findPreset(id);
10901
- if (preset?.host) PRESET_BY_HOST.set(preset.host, preset);
11208
+ /** `latest` stays `latest`; everything else is normalized to `v<x.y.z>`. */
11209
+ function versionPrefix(version) {
11210
+ if (version === "latest") return "latest";
11211
+ return version.startsWith("v") ? version : `v${version}`;
10902
11212
  }
10903
- /** YAML double-quoted scalar. Compose values here are hostnames and URLs. */
10904
- function yamlString(value) {
10905
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
11213
+ function resolveVersion(explicit) {
11214
+ return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
11215
+ }
11216
+ function resolveBaseUrl(explicit) {
11217
+ return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
11218
+ }
11219
+ /** Where a resolved binary is kept, keyed so versions and targets never collide. */
11220
+ function proxyBinaryPath(version, target, exe) {
11221
+ return join(defaultCacheDir(), "proxy", versionPrefix(version), target, `${BIN}${exe}`);
11222
+ }
11223
+ async function fetchBytes(url) {
11224
+ const res = await fetch(url);
11225
+ if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
11226
+ return new Uint8Array(await res.arrayBuffer());
10906
11227
  }
10907
- const COMPOSE_DEFAULTS = {
10908
- service: "seekrit-proxy",
10909
- workload: "agent",
10910
- publish: false
10911
- };
10912
11228
  /**
10913
- * A `docker compose` sidecar snippet for a generated config.
11229
+ * Ensure a `seekrit-proxy` binary exists locally and return its path.
10914
11230
  *
10915
- * The container case differs from the local one in exactly the ways that break a
10916
- * copied-from-the-docs compose file: the proxy has to bind `0.0.0.0` to be
10917
- * reachable from a sibling container, the workload dials it by *service name*
10918
- * rather than loopback, and in forward mode the CA has to live on a shared
10919
- * volume or the workload trusts a certificate the proxy no longer has.
11231
+ * Order: an explicit `SEEKRIT_PROXY_BIN`, then a cached download for this
11232
+ * version+target, then a fresh download. A binary already on `PATH` is
11233
+ * deliberately *not* used silently running a different version than the one
11234
+ * this CLI pins is the kind of surprise that costs an afternoon.
10920
11235
  */
10921
- function renderComposeSnippet(plan, options) {
10922
- const reverse = plan.mode === "reverse" || plan.mode === "both";
10923
- const forward = plan.mode === "forward" || plan.mode === "both";
10924
- const [, listenPort = "8080"] = splitHostPort(plan.listen);
10925
- const [, forwardPort = "8081"] = splitHostPort(plan.forwardListen);
10926
- const host = options.service;
10927
- const out = [
10928
- "# seekrit-proxy sidecar — generated by `seekrit proxy compose`.",
10929
- "#",
10930
- "# The proxy holds the decrypted secrets; the workload holds only placeholders.",
10931
- "# Keeping them in separate containers is what makes that boundary real: the",
10932
- "# service token is in the proxy's environment, where the workload cannot read it.",
10933
- "services:",
10934
- ` ${host}:`,
10935
- ` image: ${options.image}`
10936
- ];
10937
- const command = [];
10938
- if (reverse) command.push("--listen", `0.0.0.0:${listenPort}`);
10939
- if (command.length > 0) out.push(` command: [${command.map(yamlString).join(", ")}]`);
10940
- if (forward) {
10941
- out.push(` # Forward mode: set \`[forward] listen = "0.0.0.0:${forwardPort}"\` in the`);
10942
- out.push(" # config too — there is no flag for the forward plane's address.");
10943
- }
10944
- out.push(" environment:");
10945
- out.push(" # Never inline the token. Compose reads it from your shell or a .env file.");
10946
- out.push(" SEEKRIT_TOKEN: ${SEEKRIT_TOKEN:?SEEKRIT_TOKEN is required}");
10947
- out.push(" volumes:");
10948
- out.push(" - ./seekrit-proxy.toml:/seekrit-proxy.toml:ro");
10949
- if (forward) {
10950
- out.push(" # The interception CA must survive restarts, or the certificate the");
10951
- out.push(" # workload trusts stops matching the one the proxy mints leaves from.");
10952
- out.push(" - seekrit-proxy-ca:/ca");
10953
- }
10954
- if (options.publish) {
10955
- out.push(" ports:");
10956
- if (reverse) out.push(` - ${yamlString(`127.0.0.1:${listenPort}:${listenPort}`)}`);
10957
- if (forward) out.push(` - ${yamlString(`127.0.0.1:${forwardPort}:${forwardPort}`)}`);
10958
- } else {
10959
- out.push(" # No `ports`: reachable on the compose network only, which is what you");
10960
- out.push(" # want — nothing outside this project can ask the proxy to inject a key.");
10961
- }
10962
- out.push(" restart: unless-stopped");
10963
- out.push("");
10964
- out.push(` ${options.workload}:`);
10965
- out.push(" # ← your workload. It never holds a real credential.");
10966
- out.push(" image: your-agent:latest");
10967
- out.push(" depends_on:");
10968
- out.push(` - ${host}`);
10969
- out.push(" environment:");
10970
- if (forward) {
10971
- out.push(` HTTPS_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
10972
- out.push(` HTTP_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
10973
- out.push(" NODE_EXTRA_CA_CERTS: \"/ca/seekrit-proxy-ca.pem\"");
10974
- out.push(" # …or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime.");
11236
+ async function resolveProxyBinary(options = {}) {
11237
+ const override = process.env.SEEKRIT_PROXY_BIN;
11238
+ if (override) {
11239
+ if (!existsSync(override)) throw new Error(`SEEKRIT_PROXY_BIN points at ${override}, which does not exist`);
11240
+ return override;
10975
11241
  }
10976
- for (const hint of plan.envHints) {
10977
- if (hint.name === "HTTPS_PROXY" || hint.name === "NODE_EXTRA_CA_CERTS") continue;
10978
- const value = hint.value.replace(/http:\/\/[^/]+/, `http://${host}:${listenPort}`);
10979
- out.push(` ${hint.name}: ${yamlString(value)}`);
11242
+ const version = resolveVersion(options.version);
11243
+ const { target, exe } = detectTarget();
11244
+ const dest = proxyBinaryPath(version, target, exe);
11245
+ if (!options.force && version !== "latest" && existsSync(dest)) return dest;
11246
+ const baseUrl = resolveBaseUrl(options.baseUrl);
11247
+ const prefix = versionPrefix(version);
11248
+ const name = `${BIN}-${target}${exe}`;
11249
+ const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
11250
+ const sumUrl = `${binUrl}.sha256`;
11251
+ if (!options.quiet) process.stderr.write(`seekrit: fetching ${BIN} ${prefix} (${target})…\n`);
11252
+ let bytes;
11253
+ let expected;
11254
+ try {
11255
+ [bytes, expected] = await Promise.all([fetchBytes(binUrl), fetchBytes(sumUrl).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
11256
+ } catch (err) {
11257
+ const message = err instanceof Error ? err.message : String(err);
11258
+ throw new Error(`could not download ${BIN} ${prefix} for ${target}: ${message}\n Set SEEKRIT_PROXY_BIN to a binary you already have, or build it from apps/proxy.`);
10980
11259
  }
10981
- out.push("");
10982
- if (forward) {
10983
- out.push("volumes:");
10984
- out.push(" seekrit-proxy-ca:");
10985
- out.push("");
11260
+ const actual = createHash("sha256").update(bytes).digest("hex");
11261
+ if (!expected || actual !== expected.toLowerCase()) throw new Error(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to run it.`);
11262
+ const dir = dirname(dest);
11263
+ mkdirSync(dir, { recursive: true });
11264
+ const staging = join(dir, `.${BIN}-${process.pid}-${actual.slice(0, 12)}${exe}`);
11265
+ try {
11266
+ writeFileSync(staging, bytes, { mode: 493 });
11267
+ renameSync(staging, dest);
11268
+ } catch (err) {
11269
+ rmSync(staging, { force: true });
11270
+ throw err;
10986
11271
  }
10987
- out.push(forward ? "# The workload can unset HTTPS_PROXY, so in a threat model where the workload" : "# The workload can ignore the base URL above, so in a threat model where the");
10988
- out.push(forward ? "# is the adversary, make the proxy the only route out: put the workload on an" : "# workload is the adversary, make the proxy the only route out: put the workload");
10989
- out.push(forward ? "# `internal: true` network with the proxy as its only peer." : "# on an `internal: true` network with the proxy as its only peer.");
10990
- return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
11272
+ chmodSync(dest, 493);
11273
+ return dest;
11274
+ }
11275
+ /**
11276
+ * Run the proxy, forwarding stdio, signals, and its exit status.
11277
+ *
11278
+ * The proxy is a long-lived foreground process, so this wrapper has to be
11279
+ * transparent: Node cannot exec-replace itself, and without relaying signals
11280
+ * Node's default SIGINT handler would kill *this* process on Ctrl-C and leave
11281
+ * the proxy running, holding decrypted secrets, with the shell prompt back.
11282
+ */
11283
+ async function runProxyBinary(argv, options = {}) {
11284
+ const bin = await resolveProxyBinary(options);
11285
+ const child = spawn(bin, argv, {
11286
+ stdio: "inherit",
11287
+ env: {
11288
+ ...process.env,
11289
+ ...options.env
11290
+ }
11291
+ });
11292
+ const signals = [
11293
+ "SIGINT",
11294
+ "SIGTERM",
11295
+ "SIGHUP",
11296
+ "SIGQUIT"
11297
+ ];
11298
+ const forward = (signal) => {
11299
+ if (child.exitCode !== null || child.signalCode !== null) return;
11300
+ child.kill(signal);
11301
+ };
11302
+ for (const signal of signals) process.on(signal, forward);
11303
+ return new Promise((resolve, reject) => {
11304
+ child.on("error", (err) => {
11305
+ for (const s of signals) process.off(s, forward);
11306
+ reject(/* @__PURE__ */ new Error(`could not start ${bin}: ${err.message}\n If this is a fresh download, the platform may not match — set SEEKRIT_PROXY_BIN.`));
11307
+ });
11308
+ child.on("exit", (code, signal) => {
11309
+ for (const s of signals) process.off(s, forward);
11310
+ resolve(signal ? 128 + signalNumber(signal) : code ?? 0);
11311
+ });
11312
+ });
11313
+ }
11314
+ /** Signal name → number, for the 128+n exit convention. */
11315
+ function signalNumber(signal) {
11316
+ return {
11317
+ SIGHUP: 1,
11318
+ SIGINT: 2,
11319
+ SIGQUIT: 3,
11320
+ SIGKILL: 9,
11321
+ SIGTERM: 15
11322
+ }[signal] ?? 0;
10991
11323
  }
10992
11324
  //#endregion
10993
11325
  //#region src/proxy.ts
@@ -12049,8 +12381,21 @@ function credentialNoun(provider) {
12049
12381
  if (provider.startsWith("aws-")) return "secret access key";
12050
12382
  if (provider === "gcp-secret-manager") return "service-account key JSON";
12051
12383
  if (provider === "langgraph-platform") return "LangSmith API key";
12384
+ if (provider === "azure-key-vault") return "client secret";
12052
12385
  return "API token";
12053
12386
  }
12387
+ /**
12388
+ * Entra takes a verified domain name in place of a tenant id, but never in
12389
+ * place of a client id — so both are held to the GUID, which keeps one rule
12390
+ * and costs nothing: the admin center shows a tenant's GUID on the same page
12391
+ * as the application id it pairs with.
12392
+ */
12393
+ function assertAzureGuid(value, flag, what) {
12394
+ if (!value) fail(`${flag} is required for azure-key-vault (${what})`);
12395
+ const id = value.trim();
12396
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) fail(`${flag} "${id}" is not a GUID — the Entra admin center shows it under ${what}`);
12397
+ return id;
12398
+ }
12054
12399
  /** Account-scope config for a connection (never the credential itself). */
12055
12400
  function buildConfig(provider, options) {
12056
12401
  switch (provider) {
@@ -12116,6 +12461,15 @@ function buildConfig(provider, options) {
12116
12461
  ...options.langgraphTenant ? { tenantId: options.langgraphTenant.trim() } : {}
12117
12462
  };
12118
12463
  }
12464
+ case "azure-key-vault": {
12465
+ const cloud = assertMembers(list(options.azureCloud, "public"), AZURE_CLOUDS, "--azure-cloud")[0];
12466
+ return {
12467
+ provider: "azure-key-vault",
12468
+ tenantId: assertAzureGuid(options.azureTenantId, "--azure-tenant-id", "Directory (tenant) ID"),
12469
+ clientId: assertAzureGuid(options.azureClientId, "--azure-client-id", "Application (client) ID"),
12470
+ cloud: cloud ?? "public"
12471
+ };
12472
+ }
12119
12473
  }
12120
12474
  }
12121
12475
  /** Where inside the platform a binding writes. */
@@ -12318,6 +12672,17 @@ function buildDestination(provider, options) {
12318
12672
  provider: "langgraph-platform",
12319
12673
  deploymentId: assertLanggraphDeploymentId(options.langgraphDeployment)
12320
12674
  };
12675
+ case "azure-key-vault": {
12676
+ if (!options.vault) fail("--vault is required for azure-key-vault — the vault name, e.g. acme-prod");
12677
+ const prefix = options.path?.trim();
12678
+ const nameMode = assertMembers(list(options.nameMode, "dash"), AZURE_KEY_VAULT_NAME_MODES, "--name-mode")[0];
12679
+ return {
12680
+ provider: "azure-key-vault",
12681
+ vault: options.vault.trim(),
12682
+ ...prefix ? { prefix } : {},
12683
+ nameMode: nameMode ?? "dash"
12684
+ };
12685
+ }
12321
12686
  }
12322
12687
  }
12323
12688
  /** One-line description of a destination, for list output. */
@@ -12339,6 +12704,7 @@ function describeDestination(destination) {
12339
12704
  case "bunnyshell": return destination.kind === "environment" ? `environment ${destination.environmentId}` : `project ${destination.projectId} (inherited by new environments)`;
12340
12705
  case "gcp-secret-manager": return destination.layout === "json-bundle" ? `${destination.secretId} · json` : `${destination.idPrefix ?? ""}* · one per name`;
12341
12706
  case "langgraph-platform": return `deployment ${destination.deploymentId}`;
12707
+ case "azure-key-vault": return `${destination.vault}${destination.prefix ? ` (${destination.prefix}*)` : ""}`;
12342
12708
  case "github-actions": switch (destination.kind) {
12343
12709
  case "repo": return `${destination.owner}/${destination.repo}`;
12344
12710
  case "environment": return `${destination.owner}/${destination.repo} @${destination.environment}`;
@@ -12354,7 +12720,7 @@ function describeDestination(destination) {
12354
12720
  * application whose environment the binding reads from.
12355
12721
  */
12356
12722
  function destinationOptions(command) {
12357
- return command.option("--project <id>", "vercel: project id or name · cloudflare-pages / northflank: project name or slug · bunnyshell: project ID (writes variables new environments inherit)").option("--target <list>", "vercel / cloudflare-pages: comma-separated deployment targets", "production").option("--git-branch <branch>", "vercel: restrict preview writes to one branch").option("--script <name>", "cloudflare-workers: Worker script name").option("--store-id <id>", "cloudflare-secrets-store: store ID (32 hex)").option("--scopes <list>", "cloudflare-secrets-store: comma-separated scopes", "workers").option("--railway-project <id>", "railway: project ID (a UUID)").option("--railway-environment <id>", "railway: environment ID (a UUID)").option("--service <id>", "railway: service ID (omit for the environment's shared variables) · render: service ID (srv-…, or crn-… for a cron job)").option("--skip-deploys", "railway: stage values without triggering a redeploy").option("--path <path>", "aws-parameter-store: hierarchy, e.g. /prod/storefront/ · aws-secrets-manager: name prefix").option("--layout <layout>", `aws-secrets-manager: ${AWS_SECRETS_MANAGER_LAYOUTS.join(" | ")}`).option("--secret-name <name>", "aws-secrets-manager: the secret a json-bundle writes to").option("--param-type <type>", `aws-parameter-store: ${AWS_PARAMETER_TYPES.join(" | ")}`).option("--tier <tier>", `aws-parameter-store: ${AWS_PARAMETER_TIERS.join(" | ")}`).option("--kms-key-id <id>", "aws: customer-managed KMS key id, ARN, or alias").option("--env-group <id>", "render: environment group ID (evg-…)").option("--fly-app <name>", "fly: app name, as `fly apps list` shows it").option("--secret-group <id>", "northflank: secret group ID (the slug in its URL)").option("--do-app <id>", "digitalocean: App Platform app ID (the UUID in its URL)").option("--component <name>", "digitalocean: write to one component's variables (omit for app-level)").option("--env-scope <scope>", `digitalocean: ${DIGITALOCEAN_ENV_SCOPES.join(" | ")}`).option("--heroku-app <name>", "heroku: app name, as `heroku apps` shows it (or its UUID)").option("--netlify-site <id>", "netlify: site API ID (the UUID under Project configuration)").option("--no-netlify-secret", "netlify: create readable variables instead of Netlify secrets (write-only)").option("--bunnyshell-environment <id>", "bunnyshell: environment ID (omit to write a project)").option("--no-bunnyshell-secret", "bunnyshell: create variables visible in the dashboard instead of secret ones").option("--gh-repo <owner/name>", "github-actions: repository, e.g. acme/storefront").option("--gh-environment <name>", "github-actions: write to one deployment environment's secrets (needs --gh-repo)").option("--gh-org <login>", "github-actions: write organization secrets instead of a repo's").option("--gh-visibility <v>", `github-actions org secrets: ${GITHUB_ACTIONS_VISIBILITIES.join(" | ")}`).option("--gh-repo-ids <ids>", "github-actions: comma-separated numeric repository IDs for --gh-visibility selected").option("--langgraph-deployment <id>", "langgraph-platform: deployment UUID (the one in its dashboard URL)").option("--gcp-prefix <prefix>", "gcp: prepended to every secret ID, e.g. prod-storefront-").option("--gcp-replication <policy>", `gcp: ${GCP_REPLICATION_POLICIES.join(" | ")}`, "automatic").option("--gcp-locations <list>", "gcp: regions for user-managed replication, e.g. us-east1").option("--gcp-kms-key <name>", "gcp: Cloud KMS key (projects/…/cryptoKeys/…)").option("--gcp-prune-versions", "gcp: destroy the version each push supersedes, keeping one active version");
12723
+ return command.option("--project <id>", "vercel: project id or name · cloudflare-pages / northflank: project name or slug · bunnyshell: project ID (writes variables new environments inherit)").option("--target <list>", "vercel / cloudflare-pages: comma-separated deployment targets", "production").option("--git-branch <branch>", "vercel: restrict preview writes to one branch").option("--script <name>", "cloudflare-workers: Worker script name").option("--store-id <id>", "cloudflare-secrets-store: store ID (32 hex)").option("--scopes <list>", "cloudflare-secrets-store: comma-separated scopes", "workers").option("--railway-project <id>", "railway: project ID (a UUID)").option("--railway-environment <id>", "railway: environment ID (a UUID)").option("--service <id>", "railway: service ID (omit for the environment's shared variables) · render: service ID (srv-…, or crn-… for a cron job)").option("--skip-deploys", "railway: stage values without triggering a redeploy").option("--path <path>", "aws-parameter-store: hierarchy, e.g. /prod/storefront/ · aws-secrets-manager, azure-key-vault: name prefix").option("--layout <layout>", `aws-secrets-manager: ${AWS_SECRETS_MANAGER_LAYOUTS.join(" | ")}`).option("--secret-name <name>", "aws-secrets-manager: the secret a json-bundle writes to").option("--param-type <type>", `aws-parameter-store: ${AWS_PARAMETER_TYPES.join(" | ")}`).option("--tier <tier>", `aws-parameter-store: ${AWS_PARAMETER_TIERS.join(" | ")}`).option("--kms-key-id <id>", "aws: customer-managed KMS key id, ARN, or alias").option("--env-group <id>", "render: environment group ID (evg-…)").option("--fly-app <name>", "fly: app name, as `fly apps list` shows it").option("--secret-group <id>", "northflank: secret group ID (the slug in its URL)").option("--do-app <id>", "digitalocean: App Platform app ID (the UUID in its URL)").option("--component <name>", "digitalocean: write to one component's variables (omit for app-level)").option("--env-scope <scope>", `digitalocean: ${DIGITALOCEAN_ENV_SCOPES.join(" | ")}`).option("--heroku-app <name>", "heroku: app name, as `heroku apps` shows it (or its UUID)").option("--netlify-site <id>", "netlify: site API ID (the UUID under Project configuration)").option("--no-netlify-secret", "netlify: create readable variables instead of Netlify secrets (write-only)").option("--bunnyshell-environment <id>", "bunnyshell: environment ID (omit to write a project)").option("--no-bunnyshell-secret", "bunnyshell: create variables visible in the dashboard instead of secret ones").option("--gh-repo <owner/name>", "github-actions: repository, e.g. acme/storefront").option("--gh-environment <name>", "github-actions: write to one deployment environment's secrets (needs --gh-repo)").option("--gh-org <login>", "github-actions: write organization secrets instead of a repo's").option("--gh-visibility <v>", `github-actions org secrets: ${GITHUB_ACTIONS_VISIBILITIES.join(" | ")}`).option("--gh-repo-ids <ids>", "github-actions: comma-separated numeric repository IDs for --gh-visibility selected").option("--langgraph-deployment <id>", "langgraph-platform: deployment UUID (the one in its dashboard URL)").option("--gcp-prefix <prefix>", "gcp: prepended to every secret ID, e.g. prod-storefront-").option("--gcp-replication <policy>", `gcp: ${GCP_REPLICATION_POLICIES.join(" | ")}`, "automatic").option("--gcp-locations <list>", "gcp: regions for user-managed replication, e.g. us-east1").option("--gcp-kms-key <name>", "gcp: Cloud KMS key (projects/…/cryptoKeys/…)").option("--gcp-prune-versions", "gcp: destroy the version each push supersedes, keeping one active version").option("--vault <name>", "azure-key-vault: vault name, e.g. acme-prod").option("--name-mode <mode>", `azure-key-vault: ${AZURE_KEY_VAULT_NAME_MODES.join(" | ")} — Key Vault stores no underscores, so DATABASE_URL becomes DATABASE-URL unless you reject instead`);
12358
12724
  }
12359
12725
  /** Find a connection by id or name — nobody keeps `syc_…` ids in their head. */
12360
12726
  async function resolveConnection(ctx, orgId, ref) {
@@ -12378,7 +12744,7 @@ function registerSyncCommands(program) {
12378
12744
  col("id", (c) => c.id)
12379
12745
  ], "no connections — add one with `seekrit sync connect`"));
12380
12746
  });
12381
- sync.command("connect").description("register a destination account (reads its API token from stdin)").option("--org <slug>").requiredOption("--name <name>", "what to call this account, e.g. acme-vercel").option("--provider <kind>", SYNC_PROVIDER_KINDS.join(" | "), "vercel").option("--team-id <id>", "vercel: Team id (omit for a personal account)").option("--token-kind <kind>", `railway: ${RAILWAY_TOKEN_KINDS.join(" | ")}`, "account").option("--account-id <id>", "cloudflare: account ID (32 hex, from the dashboard sidebar) · netlify: team slug or account ID").option("--region <region>", "aws: region ID, e.g. us-east-1").option("--access-key-id <id>", "aws: IAM access key ID (the secret key is read from stdin)").option("--base-url <url>", "github-actions: GitHub Enterprise Server API root (omit for github.com) · langgraph-platform: self-hosted LangSmith control-plane root").option("--project-id <id>", "gcp: project ID or number whose Secret Manager to write").option("--langgraph-region <region>", `langgraph-platform: ${LANGGRAPH_PLATFORM_REGIONS.join(" | ")} (omit for us)`).option("--langgraph-tenant <id>", "langgraph-platform: LangSmith workspace UUID (only an org-scoped key needs it)").action(async (options) => {
12747
+ sync.command("connect").description("register a destination account (reads its API token from stdin)").option("--org <slug>").requiredOption("--name <name>", "what to call this account, e.g. acme-vercel").option("--provider <kind>", SYNC_PROVIDER_KINDS.join(" | "), "vercel").option("--team-id <id>", "vercel: Team id (omit for a personal account)").option("--token-kind <kind>", `railway: ${RAILWAY_TOKEN_KINDS.join(" | ")}`, "account").option("--account-id <id>", "cloudflare: account ID (32 hex, from the dashboard sidebar) · netlify: team slug or account ID").option("--region <region>", "aws: region ID, e.g. us-east-1").option("--access-key-id <id>", "aws: IAM access key ID (the secret key is read from stdin)").option("--azure-tenant-id <id>", "azure-key-vault: Entra Directory (tenant) ID, a GUID").option("--azure-client-id <id>", "azure-key-vault: Application (client) ID of the service principal, a GUID (its client secret is read from stdin)").option("--azure-cloud <cloud>", `azure-key-vault: ${AZURE_CLOUDS.join(" | ")}`, "public").option("--base-url <url>", "github-actions: GitHub Enterprise Server API root (omit for github.com) · langgraph-platform: self-hosted LangSmith control-plane root").option("--project-id <id>", "gcp: project ID or number whose Secret Manager to write").option("--langgraph-region <region>", `langgraph-platform: ${LANGGRAPH_PLATFORM_REGIONS.join(" | ")} (omit for us)`).option("--langgraph-tenant <id>", "langgraph-platform: LangSmith workspace UUID (only an org-scoped key needs it)").action(async (options) => {
12382
12748
  const provider = assertProvider(options.provider);
12383
12749
  const ctx = buildContext();
12384
12750
  const ref = await resolveOrg(ctx, options.org);
@@ -13247,6 +13613,7 @@ registerRedisCommands(program);
13247
13613
  registerProvisionerCommands(program);
13248
13614
  registerProxyCommands(program);
13249
13615
  registerAgentCommands(program);
13616
+ registerPaperclipCommands(program);
13250
13617
  registerSshCommands(program);
13251
13618
  registerAwsCommands(program);
13252
13619
  registerGcpCommands(program);