@seekrit/cli 0.45.0 → 0.46.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 +957 -734
  2. package/package.json +1 -1
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,
@@ -4873,7 +4876,7 @@ async function createAgentTaskToken() {
4873
4876
  }
4874
4877
  //#endregion
4875
4878
  //#region package.json
4876
- var version = "0.45.0";
4879
+ var version = "0.46.0";
4877
4880
  //#endregion
4878
4881
  //#region ../../packages/api-client/src/index.ts
4879
4882
  var SeekritApiError = class extends Error {
@@ -5025,6 +5028,10 @@ var SeekritClient = class {
5025
5028
  getEnv(orgId, envId) {
5026
5029
  return this.request("GET", `/v1/orgs/${orgId}/envs/${envId}`);
5027
5030
  }
5031
+ /** Rename an environment (display name only — the slug is immutable). */
5032
+ updateEnv(orgId, envId, input) {
5033
+ return this.request("PATCH", `/v1/orgs/${orgId}/envs/${envId}`, input);
5034
+ }
5028
5035
  deleteEnv(orgId, envId) {
5029
5036
  return this.request("DELETE", `/v1/orgs/${orgId}/envs/${envId}`);
5030
5037
  }
@@ -5178,6 +5185,10 @@ var SeekritClient = class {
5178
5185
  createToken(orgId, input) {
5179
5186
  return this.request("POST", `/v1/orgs/${orgId}/tokens`, input);
5180
5187
  }
5188
+ /** Rename a token. Role, environment binding, and expiry are immutable. */
5189
+ updateToken(orgId, tokenId, input) {
5190
+ return this.request("PATCH", `/v1/orgs/${orgId}/tokens/${tokenId}`, input);
5191
+ }
5181
5192
  revokeToken(orgId, tokenId) {
5182
5193
  return this.request("DELETE", `/v1/orgs/${orgId}/tokens/${tokenId}`);
5183
5194
  }
@@ -10065,584 +10076,273 @@ function registerOrgCommands(program) {
10065
10076
  });
10066
10077
  }
10067
10078
  //#endregion
10068
- //#region src/pg.ts
10079
+ //#region src/proxy-presets.ts
10080
+ /** `Authorization: Bearer {{seekrit:NAME}}` — the shape most providers take. */
10081
+ function placeholder(secret) {
10082
+ return `{{seekrit:${secret}}}`;
10083
+ }
10069
10084
  /**
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.
10085
+ * The catalogue. Ordered as `seekrit proxy presets` prints it: the three model
10086
+ * APIs an agent almost certainly calls, then the aggregators, then the generic
10087
+ * escape hatches.
10077
10088
  */
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);
10089
+ const PROXY_PRESETS = [
10090
+ {
10091
+ id: "openai",
10092
+ label: "OpenAI API (api.openai.com)",
10093
+ host: "api.openai.com",
10094
+ prefix: "/openai",
10095
+ secret: "OPENAI_API_KEY",
10096
+ methods: ["GET", "POST"],
10097
+ paths: ["/v1/**"],
10098
+ baseUrlSuffix: "/v1",
10099
+ env: (mode) => mode === "reverse" ? [{
10100
+ name: "OPENAI_BASE_URL",
10101
+ value: "{{base}}"
10102
+ }, {
10103
+ name: "OPENAI_API_KEY",
10104
+ value: placeholder("OPENAI_API_KEY")
10105
+ }] : [{
10106
+ name: "OPENAI_API_KEY",
10107
+ value: placeholder("OPENAI_API_KEY")
10108
+ }]
10109
+ },
10110
+ {
10111
+ id: "anthropic",
10112
+ label: "Anthropic API (api.anthropic.com)",
10113
+ host: "api.anthropic.com",
10114
+ prefix: "/anthropic",
10115
+ secret: "ANTHROPIC_API_KEY",
10116
+ methods: ["GET", "POST"],
10117
+ paths: ["/v1/**"],
10118
+ baseUrlSuffix: "",
10119
+ env: (mode) => mode === "reverse" ? [{
10120
+ name: "ANTHROPIC_BASE_URL",
10121
+ value: "{{base}}"
10122
+ }, {
10123
+ name: "ANTHROPIC_API_KEY",
10124
+ value: placeholder("ANTHROPIC_API_KEY")
10125
+ }] : [{
10126
+ name: "ANTHROPIC_API_KEY",
10127
+ value: placeholder("ANTHROPIC_API_KEY")
10128
+ }]
10129
+ },
10130
+ {
10131
+ id: "gemini",
10132
+ label: "Gemini API (generativelanguage.googleapis.com)",
10133
+ host: "generativelanguage.googleapis.com",
10134
+ prefix: "/gemini",
10135
+ secret: "GEMINI_API_KEY",
10136
+ methods: ["GET", "POST"],
10137
+ paths: ["/v1beta/**", "/v1/**"],
10138
+ baseUrlSuffix: "",
10139
+ env: (mode) => mode === "reverse" ? [{
10140
+ name: "GOOGLE_GEMINI_BASE_URL",
10141
+ value: "{{base}}",
10142
+ note: "gemini-cli reads GEMINI_BASE_URL instead — set both if you run either."
10143
+ }, {
10144
+ name: "GEMINI_API_KEY",
10145
+ value: placeholder("GEMINI_API_KEY")
10146
+ }] : [{
10147
+ name: "GEMINI_API_KEY",
10148
+ value: placeholder("GEMINI_API_KEY")
10149
+ }],
10150
+ note: "Gemini authenticates with an x-goog-api-key header; a ?key= query string is substituted too, but prefer the header."
10151
+ },
10152
+ {
10153
+ id: "openrouter",
10154
+ label: "OpenRouter (openrouter.ai) — OpenAI-compatible",
10155
+ host: "openrouter.ai",
10156
+ prefix: "/openrouter",
10157
+ secret: "OPENROUTER_API_KEY",
10158
+ methods: ["GET", "POST"],
10159
+ paths: ["/api/v1/**"],
10160
+ baseUrlSuffix: "/api/v1",
10161
+ env: (mode) => mode === "reverse" ? [{
10162
+ name: "OPENAI_BASE_URL",
10163
+ value: "{{base}}"
10164
+ }, {
10165
+ name: "OPENAI_API_KEY",
10166
+ value: placeholder("OPENROUTER_API_KEY")
10167
+ }] : [{
10168
+ name: "OPENROUTER_API_KEY",
10169
+ value: placeholder("OPENROUTER_API_KEY")
10170
+ }]
10171
+ },
10172
+ {
10173
+ id: "github",
10174
+ label: "GitHub REST + GraphQL API (api.github.com)",
10175
+ host: "api.github.com",
10176
+ prefix: "/github",
10177
+ secret: "GITHUB_TOKEN",
10178
+ methods: [],
10179
+ paths: [],
10180
+ baseUrlSuffix: "",
10181
+ env: (mode) => mode === "reverse" ? [{
10182
+ name: "GITHUB_API_URL",
10183
+ value: "{{base}}"
10184
+ }, {
10185
+ name: "GITHUB_TOKEN",
10186
+ value: placeholder("GITHUB_TOKEN")
10187
+ }] : [{
10188
+ name: "GITHUB_TOKEN",
10189
+ value: placeholder("GITHUB_TOKEN")
10190
+ }],
10191
+ note: "`gh` resolves api.github.com from GH_HOST, not a base URL — prefer forward mode for it."
10192
+ },
10193
+ {
10194
+ id: "openai-compatible",
10195
+ label: "Any OpenAI-compatible gateway — LiteLLM, vLLM, Ollama, Together, self-hosted",
10196
+ host: "",
10197
+ prefix: "/gateway",
10198
+ secret: "OPENAI_API_KEY",
10199
+ methods: ["GET", "POST"],
10200
+ paths: ["/v1/**"],
10201
+ baseUrlSuffix: "/v1",
10202
+ requiresBaseUrl: true,
10203
+ env: (mode) => mode === "reverse" ? [{
10204
+ name: "OPENAI_BASE_URL",
10205
+ value: "{{base}}"
10206
+ }, {
10207
+ name: "OPENAI_API_KEY",
10208
+ value: placeholder("OPENAI_API_KEY")
10209
+ }] : [{
10210
+ name: "OPENAI_API_KEY",
10211
+ value: placeholder("OPENAI_API_KEY")
10212
+ }],
10213
+ note: "Needs --base-url (e.g. --base-url https://litellm.internal:4000)."
10214
+ }
10215
+ ];
10216
+ function findPreset(id) {
10217
+ return PROXY_PRESETS.find((p) => p.id === id);
10088
10218
  }
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}`;
10219
+ function presetIds() {
10220
+ return PROXY_PRESETS.map((p) => p.id);
10096
10221
  }
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}`);
10222
+ /**
10223
+ * Apply `--base-url` / `--secret` / `--prefix` overrides to a preset.
10224
+ *
10225
+ * Returns a new preset rather than mutating the catalogue entry: the same
10226
+ * process can generate two configs in one run (a test does), and a preset that
10227
+ * remembered the last `--base-url` would be a genuinely confusing bug.
10228
+ */
10229
+ function specialize(preset, overrides) {
10230
+ let host = preset.host;
10231
+ let paths = preset.paths;
10232
+ let baseUrlSuffix = preset.baseUrlSuffix;
10233
+ if (overrides.baseUrl) {
10234
+ const url = new URL(overrides.baseUrl);
10235
+ host = url.hostname.toLowerCase();
10236
+ const upstreamPath = url.pathname.replace(/\/+$/, "");
10237
+ if (upstreamPath) {
10238
+ paths = [];
10239
+ baseUrlSuffix = `${upstreamPath}${preset.baseUrlSuffix}`;
10154
10240
  }
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
- });
10241
+ }
10242
+ const secret = overrides.secret ?? preset.secret;
10243
+ const specialized = {
10244
+ ...preset,
10245
+ host,
10246
+ paths,
10247
+ baseUrlSuffix,
10248
+ secret,
10249
+ prefix: overrides.prefix ?? preset.prefix
10250
+ };
10251
+ if (preset.allow && secret !== preset.secret) specialized.allow = preset.allow.map((name) => name === preset.secret ? secret : name);
10252
+ if (secret !== preset.secret) specialized.env = (mode) => preset.env(mode).map((hint) => ({
10253
+ ...hint,
10254
+ value: hint.value.replace(/\{\{seekrit:[A-Za-z0-9_]+\}\}/, placeholder(secret))
10255
+ }));
10256
+ return specialized;
10212
10257
  }
10213
- /** Collect a repeatable option into an array. */
10214
- function collect$2(value, acc) {
10215
- acc.push(value);
10216
- return acc;
10258
+ /** The secret names a preset's rule permits. Default-deny: empty means none. */
10259
+ function presetAllow(preset) {
10260
+ if (preset.allow) return preset.allow;
10261
+ return preset.secret ? [preset.secret] : [];
10217
10262
  }
10218
10263
  //#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
- */
10264
+ //#region src/proxy-config.ts
10247
10265
  /**
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.
10266
+ * A deliberately small TOML writer: basic strings and arrays of them, which is
10267
+ * every value in this config. Full TOML is not needed and a general emitter
10268
+ * would be one more thing that can disagree with the parser on an edge case.
10253
10269
  */
10254
- const PROXY_VERSION = "0.10.0";
10255
- const BIN = "seekrit-proxy";
10270
+ function tomlString(value) {
10271
+ 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) => {
10272
+ return `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`;
10273
+ })}"`;
10274
+ }
10275
+ function tomlArray(values) {
10276
+ return `[${values.map(tomlString).join(", ")}]`;
10277
+ }
10256
10278
  /**
10257
- * Host Rust target triple.
10279
+ * Claim `preferred` if nothing else has it, else derive one from `host`.
10258
10280
  *
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.
10281
+ * Two rules can name the same preset-known host (a narrow rule above a broad
10282
+ * one is the documented pattern), and two routes cannot share a prefix — so the
10283
+ * second one needs a distinct, still-recognisable name rather than an error.
10262
10284
  */
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`);
10285
+ function claimPrefix(preferred, host, taken) {
10286
+ if (preferred && !taken.has(preferred)) {
10287
+ taken.add(preferred);
10288
+ return preferred;
10282
10289
  }
10290
+ return prefixForHost(host, taken);
10283
10291
  }
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}`);
10298
- }
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());
10292
+ /** Slug for a route prefix, derived from a hostname. */
10293
+ function prefixForHost(host, taken) {
10294
+ const labels = host.split(".").filter(Boolean);
10295
+ while (labels.length > 1 && (labels[0] === "api" || labels[0] === "www")) labels.shift();
10296
+ const base = (labels[0] ?? "upstream").replace(/[^a-z0-9-]/gi, "").toLowerCase() || "upstream";
10297
+ let prefix = `/${base}`;
10298
+ let n = 2;
10299
+ while (taken.has(prefix)) prefix = `/${base}-${n++}`;
10300
+ taken.add(prefix);
10301
+ return prefix;
10303
10302
  }
10304
- /**
10305
- * Ensure a `seekrit-proxy` binary exists locally and return its path.
10306
- *
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.
10311
- */
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;
10303
+ const HEADER = `# seekrit-proxy configuration — generated by \`seekrit proxy init\`.
10304
+ #
10305
+ # The proxy resolves the secrets its service token grants (SEEKRIT_TOKEN in the
10306
+ # environment, never in this file), then swaps {{seekrit:NAME}} placeholders in
10307
+ # outbound requests for the decrypted values before forwarding upstream.
10308
+ #
10309
+ # Safe to commit: it contains hostnames, secret *names*, and thumbprints no
10310
+ # secret values and no credential. Review it before you rely on it; the
10311
+ # allowlist below is a security boundary, and a generator does not know your
10312
+ # threat model.`;
10313
+ /** Render the plan as the text of a `seekrit-proxy.toml`. */
10314
+ function renderProxyConfig(plan) {
10315
+ const out = [HEADER];
10316
+ const server = Boolean(plan.policy);
10317
+ if (plan.notes.length > 0) {
10318
+ out.push("#");
10319
+ for (const note of plan.notes) out.push(`# ${note}`);
10317
10320
  }
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.`);
10321
+ out.push("");
10322
+ const reverse = plan.mode === "reverse" || plan.mode === "both";
10323
+ const forward = plan.mode === "forward" || plan.mode === "both";
10324
+ if (reverse) {
10325
+ out.push(`listen = ${tomlString(plan.listen)}`);
10326
+ out.push("");
10327
+ } else {
10328
+ out.push("# Forward-proxy only: the reverse plane still binds this address and");
10329
+ out.push("# serves nothing, since no [[route]] is declared below.");
10330
+ out.push(`listen = ${tomlString(plan.listen)}`);
10331
+ out.push("");
10335
10332
  }
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("");
10632
- }
10633
- if (reverse) for (const route of plan.routes) {
10634
- out.push("[[route]]");
10635
- out.push(`prefix = ${tomlString(route.prefix)}`);
10636
- out.push(`upstream = ${tomlString(route.upstream)}`);
10637
- if (server) out.push("# allow/methods/paths come from published policy in server mode.");
10638
- else {
10639
- if (route.allow.length > 0) out.push(`allow = ${tomlArray(route.allow)}`);
10640
- else out.push("# No `allow`: this route permits the operation but carries no credential.");
10641
- if (route.methods.length > 0) out.push(`methods = ${tomlArray(route.methods)}`);
10642
- if (route.paths.length > 0) out.push(`paths = ${tomlArray(route.paths)}`);
10643
- if (route.label) out.push(`label = ${tomlString(route.label)}`);
10644
- }
10645
- out.push("");
10333
+ if (reverse) for (const route of plan.routes) {
10334
+ out.push("[[route]]");
10335
+ out.push(`prefix = ${tomlString(route.prefix)}`);
10336
+ out.push(`upstream = ${tomlString(route.upstream)}`);
10337
+ if (server) out.push("# allow/methods/paths come from published policy in server mode.");
10338
+ else {
10339
+ if (route.allow.length > 0) out.push(`allow = ${tomlArray(route.allow)}`);
10340
+ else out.push("# No `allow`: this route permits the operation but carries no credential.");
10341
+ if (route.methods.length > 0) out.push(`methods = ${tomlArray(route.methods)}`);
10342
+ if (route.paths.length > 0) out.push(`paths = ${tomlArray(route.paths)}`);
10343
+ if (route.label) out.push(`label = ${tomlString(route.label)}`);
10344
+ }
10345
+ out.push("");
10646
10346
  }
10647
10347
  if (forward) {
10648
10348
  out.push("[forward]");
@@ -10797,197 +10497,719 @@ function planFromPresets(presets, options) {
10797
10497
  });
10798
10498
  if (preset.note) notes.push(`${preset.id}: ${preset.note}`);
10799
10499
  }
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
- });
10808
- return {
10500
+ if (hintMode === "forward") envHints.unshift({
10501
+ name: "HTTPS_PROXY",
10502
+ value: `http://${options.forwardListen}`
10503
+ }, {
10504
+ name: "NODE_EXTRA_CA_CERTS",
10505
+ value: `$PWD/${options.caCert}`,
10506
+ note: "or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime."
10507
+ });
10508
+ return {
10509
+ mode: options.mode,
10510
+ listen: options.listen,
10511
+ forwardListen: options.forwardListen,
10512
+ routes,
10513
+ unmatched: options.unmatched,
10514
+ caCert: options.caCert,
10515
+ caKey: options.caKey,
10516
+ ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
10517
+ ...options.secretsRefresh ? { secretsRefresh: options.secretsRefresh } : {},
10518
+ ...options.control ? { control: options.control } : {},
10519
+ ...options.tasks ? { tasks: options.tasks } : {},
10520
+ ...options.activity ? { activity: options.activity } : {},
10521
+ envHints,
10522
+ notes
10523
+ };
10524
+ }
10525
+ /**
10526
+ * Build a server-policy plan from an agent's published rules.
10527
+ *
10528
+ * The rules are used for **routing only** — one `[[route]]` per distinct host,
10529
+ * so the workload has a base URL to point at — and never copied into the file as
10530
+ * authorization. That is the whole trade of server mode: adding an upstream
10531
+ * becomes a dashboard change, and a rule this file also stated would be a
10532
+ * startup error rather than a belt-and-braces duplicate.
10533
+ */
10534
+ function planFromPolicy(args, options) {
10535
+ const taken = /* @__PURE__ */ new Set();
10536
+ const routes = [];
10537
+ const envHints = [];
10538
+ const notes = [];
10539
+ const hintMode = options.mode === "forward" ? "forward" : "reverse";
10540
+ const seen = /* @__PURE__ */ new Set();
10541
+ for (const rule of args.rules) {
10542
+ if (!rule.host || seen.has(rule.host)) continue;
10543
+ seen.add(rule.host);
10544
+ const preset = PRESET_BY_HOST.get(rule.host);
10545
+ const prefix = claimPrefix(preset?.prefix, rule.host, taken);
10546
+ const baseUrl = baseUrlFor(options.listen, prefix, preset?.baseUrlSuffix ?? "");
10547
+ routes.push({
10548
+ prefix,
10549
+ upstream: `https://${rule.host}`,
10550
+ host: rule.host,
10551
+ allow: [],
10552
+ methods: [],
10553
+ paths: [],
10554
+ baseUrl
10555
+ });
10556
+ if (preset) for (const hint of preset.env(hintMode)) envHints.push({
10557
+ ...hint,
10558
+ value: hint.value.replace("{{base}}", baseUrl)
10559
+ });
10560
+ }
10561
+ if (hintMode === "forward") envHints.unshift({
10562
+ name: "HTTPS_PROXY",
10563
+ value: `http://${options.forwardListen}`
10564
+ }, {
10565
+ name: "NODE_EXTRA_CA_CERTS",
10566
+ value: `$PWD/${options.caCert}`,
10567
+ note: "or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime."
10568
+ });
10569
+ if (args.rules.length === 0) notes.push("The published policy has no rules yet, so this proxy permits nothing until one is published.");
10570
+ return {
10571
+ mode: options.mode,
10572
+ listen: options.listen,
10573
+ forwardListen: options.forwardListen,
10574
+ routes,
10575
+ policy: {
10576
+ agent: args.agent,
10577
+ agents: args.agents,
10578
+ refreshInterval: args.refreshInterval,
10579
+ signers: args.signers
10580
+ },
10581
+ unmatched: options.unmatched,
10582
+ caCert: options.caCert,
10583
+ caKey: options.caKey,
10584
+ ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
10585
+ ...options.control ? { control: options.control } : {},
10586
+ ...options.tasks ? { tasks: options.tasks } : {},
10587
+ ...options.activity ? { activity: options.activity } : {},
10588
+ envHints,
10589
+ notes
10590
+ };
10591
+ }
10592
+ /** Host → preset, for naming routes generated from published policy. */
10593
+ const PRESET_BY_HOST = /* @__PURE__ */ new Map();
10594
+ for (const id of [
10595
+ "openai",
10596
+ "anthropic",
10597
+ "openrouter",
10598
+ "github"
10599
+ ]) {
10600
+ const preset = findPreset(id);
10601
+ if (preset?.host) PRESET_BY_HOST.set(preset.host, preset);
10602
+ }
10603
+ /** YAML double-quoted scalar. Compose values here are hostnames and URLs. */
10604
+ function yamlString(value) {
10605
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
10606
+ }
10607
+ const COMPOSE_DEFAULTS = {
10608
+ service: "seekrit-proxy",
10609
+ workload: "agent",
10610
+ publish: false
10611
+ };
10612
+ /**
10613
+ * A `docker compose` sidecar snippet for a generated config.
10614
+ *
10615
+ * The container case differs from the local one in exactly the ways that break a
10616
+ * copied-from-the-docs compose file: the proxy has to bind `0.0.0.0` to be
10617
+ * reachable from a sibling container, the workload dials it by *service name*
10618
+ * rather than loopback, and in forward mode the CA has to live on a shared
10619
+ * volume or the workload trusts a certificate the proxy no longer has.
10620
+ */
10621
+ function renderComposeSnippet(plan, options) {
10622
+ const reverse = plan.mode === "reverse" || plan.mode === "both";
10623
+ const forward = plan.mode === "forward" || plan.mode === "both";
10624
+ const [, listenPort = "8080"] = splitHostPort(plan.listen);
10625
+ const [, forwardPort = "8081"] = splitHostPort(plan.forwardListen);
10626
+ const host = options.service;
10627
+ const out = [
10628
+ "# seekrit-proxy sidecar — generated by `seekrit proxy compose`.",
10629
+ "#",
10630
+ "# The proxy holds the decrypted secrets; the workload holds only placeholders.",
10631
+ "# Keeping them in separate containers is what makes that boundary real: the",
10632
+ "# service token is in the proxy's environment, where the workload cannot read it.",
10633
+ "services:",
10634
+ ` ${host}:`,
10635
+ ` image: ${options.image}`
10636
+ ];
10637
+ const command = [];
10638
+ if (reverse) command.push("--listen", `0.0.0.0:${listenPort}`);
10639
+ if (command.length > 0) out.push(` command: [${command.map(yamlString).join(", ")}]`);
10640
+ if (forward) {
10641
+ out.push(` # Forward mode: set \`[forward] listen = "0.0.0.0:${forwardPort}"\` in the`);
10642
+ out.push(" # config too — there is no flag for the forward plane's address.");
10643
+ }
10644
+ out.push(" environment:");
10645
+ out.push(" # Never inline the token. Compose reads it from your shell or a .env file.");
10646
+ out.push(" SEEKRIT_TOKEN: ${SEEKRIT_TOKEN:?SEEKRIT_TOKEN is required}");
10647
+ out.push(" volumes:");
10648
+ out.push(" - ./seekrit-proxy.toml:/seekrit-proxy.toml:ro");
10649
+ if (forward) {
10650
+ out.push(" # The interception CA must survive restarts, or the certificate the");
10651
+ out.push(" # workload trusts stops matching the one the proxy mints leaves from.");
10652
+ out.push(" - seekrit-proxy-ca:/ca");
10653
+ }
10654
+ if (options.publish) {
10655
+ out.push(" ports:");
10656
+ if (reverse) out.push(` - ${yamlString(`127.0.0.1:${listenPort}:${listenPort}`)}`);
10657
+ if (forward) out.push(` - ${yamlString(`127.0.0.1:${forwardPort}:${forwardPort}`)}`);
10658
+ } else {
10659
+ out.push(" # No `ports`: reachable on the compose network only, which is what you");
10660
+ out.push(" # want — nothing outside this project can ask the proxy to inject a key.");
10661
+ }
10662
+ out.push(" restart: unless-stopped");
10663
+ out.push("");
10664
+ out.push(` ${options.workload}:`);
10665
+ out.push(" # ← your workload. It never holds a real credential.");
10666
+ out.push(" image: your-agent:latest");
10667
+ out.push(" depends_on:");
10668
+ out.push(` - ${host}`);
10669
+ out.push(" environment:");
10670
+ if (forward) {
10671
+ out.push(` HTTPS_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
10672
+ out.push(` HTTP_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
10673
+ out.push(" NODE_EXTRA_CA_CERTS: \"/ca/seekrit-proxy-ca.pem\"");
10674
+ out.push(" # …or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime.");
10675
+ }
10676
+ for (const hint of plan.envHints) {
10677
+ if (hint.name === "HTTPS_PROXY" || hint.name === "NODE_EXTRA_CA_CERTS") continue;
10678
+ const value = hint.value.replace(/http:\/\/[^/]+/, `http://${host}:${listenPort}`);
10679
+ out.push(` ${hint.name}: ${yamlString(value)}`);
10680
+ }
10681
+ out.push("");
10682
+ if (forward) {
10683
+ out.push("volumes:");
10684
+ out.push(" seekrit-proxy-ca:");
10685
+ out.push("");
10686
+ }
10687
+ 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");
10688
+ 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");
10689
+ 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.");
10690
+ return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
10691
+ }
10692
+ //#endregion
10693
+ //#region src/paperclip.ts
10694
+ /**
10695
+ * `seekrit paperclip` — wire seekrit into a [Paperclip](https://docs.paperclip.ing)
10696
+ * agent.
10697
+ *
10698
+ * Paperclip is a control plane: it decides which agent runs, and its *adapter*
10699
+ * launches the runtime that does the work. Two consequences shape this command.
10700
+ *
10701
+ * First, **MCP config is per-runtime, not per-Paperclip-agent.** There is no
10702
+ * field in Paperclip's database that means "give this agent the seekrit tools" —
10703
+ * the adapter's runtime (Claude Code, Codex, Gemini CLI, OpenCode) reads its own
10704
+ * MCP config from the working directory Paperclip points it at. So attaching the
10705
+ * seekrit servers means writing a `.mcp.json` *there*, which is what this does.
10706
+ *
10707
+ * Second, **a seekrit secret cannot be a Paperclip `secret_ref`.** Paperclip's
10708
+ * provider list is closed (`local_encrypted`, `aws_secrets_manager`,
10709
+ * `gcp_secret_manager`, `vault`), so there is nothing to select. Values reach a
10710
+ * run through `seekrit run` / the `run_command` tool, or — better, for a run
10711
+ * whose output you cannot predict — through the egress proxy, where the adapter
10712
+ * env holds `{{seekrit:NAME}}` placeholders and never a key. The env block this
10713
+ * command prints is that second shape, ready to paste into the agent's
10714
+ * Configuration tab.
10715
+ *
10716
+ * The proxy's own config file stays with `seekrit proxy init`. That command
10717
+ * already owns the reviewable-security-artifact warnings, and a second
10718
+ * generator behind a different name is how the two drift apart.
10719
+ */
10720
+ /** Claude Code and friends read this name from the runtime's working directory. */
10721
+ const MCP_FILE = ".mcp.json";
10722
+ /**
10723
+ * The two seekrit MCP servers, in the shape a *runtime's* `.mcp.json` wants.
10724
+ *
10725
+ * These are the same two servers `agent-plugin/mcp.json` declares, and a test
10726
+ * pins them to that file so a rename cannot land in one place only. The one
10727
+ * field that is deliberately **not** copied is the remote transport's spelling:
10728
+ * the Agent Plugins manifest says `streamable-http`, while Claude Code's own
10729
+ * `.mcp.json` says `http`. Writing the manifest's spelling into a runtime config
10730
+ * produces a server the runtime silently declines to load, which reads as "the
10731
+ * hosted server is down".
10732
+ */
10733
+ const PAPERCLIP_MCP_SERVERS = {
10734
+ seekrit: {
10735
+ type: "stdio",
10736
+ command: "npx",
10737
+ args: ["-y", "@seekrit/mcp"]
10738
+ },
10739
+ "seekrit-cloud": {
10740
+ type: "http",
10741
+ url: "https://mcp.seekrit.dev/mcp"
10742
+ }
10743
+ };
10744
+ /**
10745
+ * Merge the seekrit servers into an existing `.mcp.json` without disturbing it.
10746
+ *
10747
+ * An agent's working directory is usually a real repository, so this file may
10748
+ * already carry servers someone else depends on — replacing it wholesale is a
10749
+ * silent regression in whatever they were doing. Unknown top-level keys are
10750
+ * preserved for the same reason: runtimes keep growing new ones.
10751
+ *
10752
+ * A `seekrit` entry that already exists and *differs* is left alone unless
10753
+ * forced. Someone pinning a version or adding an `env` did it on purpose.
10754
+ */
10755
+ function mergeMcpServers(existing, force) {
10756
+ const servers = { ...existing.mcpServers ?? {} };
10757
+ const added = [];
10758
+ const kept = [];
10759
+ for (const [name, entry] of Object.entries(PAPERCLIP_MCP_SERVERS)) {
10760
+ const current = servers[name];
10761
+ if (current && !force) {
10762
+ if (JSON.stringify(current) === JSON.stringify(entry)) continue;
10763
+ kept.push(name);
10764
+ continue;
10765
+ }
10766
+ servers[name] = entry;
10767
+ added.push(name);
10768
+ }
10769
+ return {
10770
+ merged: {
10771
+ ...existing,
10772
+ mcpServers: servers
10773
+ },
10774
+ added,
10775
+ kept
10776
+ };
10777
+ }
10778
+ function readMcpFile(path) {
10779
+ if (!existsSync(path)) return {};
10780
+ let raw;
10781
+ try {
10782
+ raw = readFileSync(path, "utf8");
10783
+ } catch (err) {
10784
+ fail(`could not read ${path}: ${err instanceof Error ? err.message : String(err)}`);
10785
+ }
10786
+ try {
10787
+ const parsed = JSON.parse(raw);
10788
+ 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`);
10789
+ return parsed;
10790
+ } catch (err) {
10791
+ if (err instanceof SyntaxError) fail(`${path} is not valid JSON (${err.message}) — fix or move it first`);
10792
+ throw err;
10793
+ }
10794
+ }
10795
+ /**
10796
+ * The env a Paperclip agent needs, given the upstreams it calls.
10797
+ *
10798
+ * Every value here is a plain string: a placeholder is not a secret, so none of
10799
+ * it needs a `secret_ref` and none of it trips
10800
+ * `PAPERCLIP_SECRETS_STRICT_MODE` — which is exactly why this is the path to
10801
+ * recommend. The env is derived from the same preset catalogue and the same
10802
+ * plan builder `seekrit proxy init` uses, so what gets pasted into Paperclip and
10803
+ * what the proxy enforces cannot disagree.
10804
+ *
10805
+ * One hint has to be rewritten on the way through. The shared generator writes
10806
+ * the CA path as `$PWD/seekrit-proxy-ca.pem`, which is correct for the `export`
10807
+ * lines it normally produces — a shell expands it. **Paperclip's env map is not a
10808
+ * shell.** Pasted verbatim it becomes a literal `$PWD/…`, the runtime cannot find
10809
+ * the CA, and every HTTPS call fails with a certificate error that looks like a
10810
+ * proxy bug. So it is replaced with a marker that cannot be mistaken for a
10811
+ * working value.
10812
+ */
10813
+ const ABSOLUTE_PATH_MARKER = "<absolute path to>";
10814
+ function adapterEnv(presets, options) {
10815
+ if (presets.length === 0) return [];
10816
+ return planFromPresets(presets, {
10817
+ ...PLAN_DEFAULTS,
10809
10818
  mode: options.mode,
10810
10819
  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
- };
10820
+ forwardListen: options.forwardListen
10821
+ }).envHints.map((hint) => {
10822
+ if (!hint.value.includes("$PWD/")) return { ...hint };
10823
+ return {
10824
+ ...hint,
10825
+ value: hint.value.replace("$PWD/", `${ABSOLUTE_PATH_MARKER} `),
10826
+ note: `${hint.note ? `${hint.note} ` : ""}Paperclip does not expand shell variables — paste the real path the proxy wrote this to.`
10827
+ };
10828
+ });
10829
+ }
10830
+ function registerPaperclipCommands(program) {
10831
+ 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) => {
10832
+ if (options.mode !== "forward" && options.mode !== "reverse") fail(`--mode must be forward or reverse (got "${options.mode}")`);
10833
+ const presets = [];
10834
+ for (const id of options.preset ?? []) {
10835
+ const preset = findPreset(id);
10836
+ if (!preset) fail(`unknown preset "${id}" — try one of: ${presetIds().join(", ")}`);
10837
+ 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://…`);
10838
+ presets.push(preset);
10839
+ }
10840
+ const dir = resolve(options.dir);
10841
+ const mcpPath = join(dir, MCP_FILE);
10842
+ let added = [];
10843
+ let kept = [];
10844
+ if (options.mcp) {
10845
+ if (!existsSync(dir)) fail(`no such directory: ${options.dir}\n Pass --dir with the agent's working directory (its Configuration tab shows it).`);
10846
+ const result = mergeMcpServers(readMcpFile(mcpPath), Boolean(options.force));
10847
+ added = result.added;
10848
+ kept = result.kept;
10849
+ writeFileSync(mcpPath, `${JSON.stringify(result.merged, null, 2)}\n`, { mode: 420 });
10850
+ }
10851
+ const env = adapterEnv(presets, {
10852
+ mode: options.mode,
10853
+ listen: options.listen,
10854
+ forwardListen: options.forwardListen
10855
+ });
10856
+ emit(options, {
10857
+ mcpFile: options.mcp ? mcpPath : null,
10858
+ added,
10859
+ kept,
10860
+ env
10861
+ }, () => {
10862
+ if (options.mcp) {
10863
+ process.stderr.write(added.length > 0 ? `Wrote ${mcpPath} (${added.join(", ")})\n` : `${mcpPath} already had both seekrit servers\n`);
10864
+ for (const name of kept) process.stderr.write(`seekrit: left the existing "${name}" entry alone — pass --force to replace it\n`);
10865
+ }
10866
+ if (env.length > 0) {
10867
+ 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");
10868
+ printTable(env, [col("VARIABLE", (h) => h.name), col("VALUE", (h) => h.value)], "no env for these presets");
10869
+ const notes = env.filter((h) => h.note);
10870
+ if (notes.length > 0) {
10871
+ process.stderr.write("\n");
10872
+ for (const hint of notes) process.stderr.write(` ${hint.name}: ${hint.note}\n`);
10873
+ }
10874
+ const presetFlags = presets.map((p) => `--preset ${p.id}`).join(" ");
10875
+ process.stderr.write(`\nThose values only resolve behind a running proxy. Write its config with:\n seekrit proxy init --mode ${options.mode} ${presetFlags}\n`);
10876
+ } 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");
10877
+ 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");
10878
+ });
10879
+ });
10824
10880
  }
10881
+ //#endregion
10882
+ //#region src/pg.ts
10825
10883
  /**
10826
- * Build a server-policy plan from an agent's published rules.
10884
+ * `seekrit pg` temporary Postgres credentials (Vault-style dynamic secrets).
10827
10885
  *
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.
10886
+ * Zero-knowledge: minting generates the password and its SCRAM verifier on THIS
10887
+ * machine and sends only the verifier; the plaintext password never reaches the
10888
+ * API or gets stored. Registering a target wraps the admin connection string to
10889
+ * the broker's public key locally, so the control plane only ever stores
10890
+ * ciphertext.
10833
10891
  */
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
10892
+ /** Parse a duration like `30m`, `1h`, `7d`, or a bare seconds count. */
10893
+ function parseTtlSeconds$2(input) {
10894
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
10895
+ if (!m) fail(`invalid --ttl "${input}" (try 30m, 1h, 7d)`);
10896
+ return Number(m[1]) * ({
10897
+ s: 1,
10898
+ m: 60,
10899
+ h: 3600,
10900
+ d: 86400
10901
+ }[m[2] || "s"] ?? 1);
10902
+ }
10903
+ /** A fresh, valid Postgres role name: `tmp_` + lowercase alphanumerics. */
10904
+ function generateRoleName(prefix = "tmp") {
10905
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
10906
+ let out = "";
10907
+ const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
10908
+ for (const b of bytes) out += alphabet[b % 36];
10909
+ return `${prefix}_${out}`;
10910
+ }
10911
+ function registerPgCommands(program) {
10912
+ const pg = program.command("pg").description("temporary Postgres credentials (short-lived, zero-knowledge)");
10913
+ const target = pg.command("target").description("manage provisioning targets");
10914
+ 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) => {
10915
+ const ctx = buildContext();
10916
+ const org = await resolveOrg(ctx, options.org);
10917
+ const executor = options.executor === "remote" ? "remote" : "in_do";
10918
+ if (executor === "remote" && !options.provisionerUrl) fail("--provisioner-url is required for the remote executor");
10919
+ if (![
10920
+ "readonly",
10921
+ "readwrite",
10922
+ "custom"
10923
+ ].includes(options.access)) fail("--access must be readonly, readwrite, or custom");
10924
+ const accessLevel = options.access;
10925
+ const adminSecret = resolveLeaseAdminSecret({
10926
+ executor,
10927
+ hmacKey: options.hmacKey,
10928
+ adminUrl: options.adminUrl,
10929
+ adminUrlEnv: "SEEKRIT_PG_ADMIN_URL"
10855
10930
  });
10856
- if (preset) for (const hint of preset.env(hintMode)) envHints.push({
10857
- ...hint,
10858
- value: hint.value.replace("{{base}}", baseUrl)
10931
+ const config = {
10932
+ provider: "postgres",
10933
+ executor,
10934
+ accessLevel,
10935
+ connection: {
10936
+ host: options.host,
10937
+ port: Number.parseInt(options.port, 10),
10938
+ database: options.database
10939
+ },
10940
+ ...accessLevel === "custom" ? {
10941
+ ...options.createStatement.length ? { createStatements: options.createStatement } : {},
10942
+ ...options.revokeStatement.length ? { revokeStatements: options.revokeStatement } : {}
10943
+ } : { schema: options.schema },
10944
+ ...options.provisionerUrl ? { provisionerUrl: options.provisionerUrl } : {}
10945
+ };
10946
+ const { publicKeyJwk } = await ctx.client.getLeaseBrokerKey(org.id);
10947
+ const wrappedAdminSecret = await wrapDek(new TextEncoder().encode(adminSecret), publicKeyJwk);
10948
+ const { target: created } = await ctx.client.registerLeaseTarget(org.id, {
10949
+ name: options.name,
10950
+ config,
10951
+ wrappedAdminSecret
10952
+ });
10953
+ console.error(`registered ${accessLevel} target ${created.name} (${created.id})`);
10954
+ const bootstrap = postgresGroupBootstrapSql(config);
10955
+ if (bootstrap) {
10956
+ console.error("\nRun this once in your database as an admin (safe to re-run):\n");
10957
+ console.log(bootstrap);
10958
+ }
10959
+ });
10960
+ target.command("list").description("list provisioning targets").option("--org <slug>").action(async (options) => {
10961
+ const ctx = buildContext();
10962
+ const org = await resolveOrg(ctx, options.org);
10963
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
10964
+ for (const t of targets) {
10965
+ const cfg = t.config;
10966
+ if (cfg.provider !== "postgres") continue;
10967
+ console.log(`${t.id}\t${t.name}\t${cfg.connection.host}:${cfg.connection.port}/${cfg.connection.database}\t${cfg.accessLevel ?? "custom"}\t${cfg.executor}`);
10968
+ }
10969
+ });
10970
+ 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) => {
10971
+ const ctx = buildContext();
10972
+ const org = await resolveOrg(ctx, options.org);
10973
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
10974
+ const t = targets.find((x) => x.id === targetId || x.name === targetId);
10975
+ if (!t) fail(`no target "${targetId}" in ${org.slug}`);
10976
+ const cfg = t.config;
10977
+ if (cfg.provider !== "postgres") fail("not a postgres target (see `seekrit ssh`)");
10978
+ const bootstrap = postgresGroupBootstrapSql(cfg);
10979
+ if (!bootstrap) fail("this is a custom target — it has no generated setup SQL");
10980
+ console.log(bootstrap);
10981
+ });
10982
+ target.command("rm <targetId>").description("remove a provisioning target").option("--org <slug>").action(async (targetId, options) => {
10983
+ const ctx = buildContext();
10984
+ const org = await resolveOrg(ctx, options.org);
10985
+ await ctx.client.deleteLeaseTarget(org.id, targetId);
10986
+ console.error(`removed ${targetId}`);
10987
+ });
10988
+ 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) => {
10989
+ const ctx = buildContext();
10990
+ const org = await resolveOrg(ctx, options.org);
10991
+ const { targets } = await ctx.client.listLeaseTargets(org.id);
10992
+ const target = targets.find((t) => t.id === targetRef || t.name === targetRef);
10993
+ if (!target) fail(`no target "${targetRef}" in ${org.slug}`);
10994
+ if (target.config.provider !== "postgres") fail(`"${target.name}" is not a postgres target (see \`seekrit ssh\`)`);
10995
+ const roleName = options.role ?? generateRoleName();
10996
+ const ttlSeconds = parseTtlSeconds$2(options.ttl);
10997
+ const { password, verifier } = await generatePostgresCredential();
10998
+ const { connection } = await ctx.client.mintLease(org.id, {
10999
+ provider: "postgres",
11000
+ targetId: target.id,
11001
+ roleName,
11002
+ verifier,
11003
+ ttlSeconds
10859
11004
  });
11005
+ const url = `postgres://${roleName}:${encodeURIComponent(password)}@${connection.host}:${connection.port}/${connection.database}`;
11006
+ console.error(`leased ${roleName} on ${connection.host}/${connection.database} — expires ${connection.expiresAt}`);
11007
+ if (options.json) console.log(JSON.stringify({
11008
+ ...connection,
11009
+ password,
11010
+ url
11011
+ }, null, 2));
11012
+ else console.log(url);
11013
+ });
11014
+ pg.command("leases").description("list leases (the ledger — never secret material)").option("--org <slug>").action(async (options) => {
11015
+ const ctx = buildContext();
11016
+ const org = await resolveOrg(ctx, options.org);
11017
+ const { leases } = await ctx.client.listLeases(org.id);
11018
+ for (const l of leases) console.log(`${l.id}\t${l.status}\t${l.resourceRef}\texpires ${l.expiresAt}`);
11019
+ });
11020
+ pg.command("revoke <leaseId>").description("revoke a lease now (drops the role immediately)").option("--org <slug>").action(async (leaseId, options) => {
11021
+ const ctx = buildContext();
11022
+ const org = await resolveOrg(ctx, options.org);
11023
+ await ctx.client.revokeLease(org.id, leaseId);
11024
+ console.error(`revoked ${leaseId}`);
11025
+ });
11026
+ }
11027
+ /** Collect a repeatable option into an array. */
11028
+ function collect$2(value, acc) {
11029
+ acc.push(value);
11030
+ return acc;
11031
+ }
11032
+ //#endregion
11033
+ //#region src/proxy-binary.ts
11034
+ /**
11035
+ * Fetch and run the `seekrit-proxy` binary without a Rust toolchain.
11036
+ *
11037
+ * The proxy is the strongest answer seekrit has for an untrusted workload — the
11038
+ * agent holds `{{seekrit:NAME}}` and never the key — and it was also the hardest
11039
+ * thing here to *try*, because trying it meant `cargo` and a TOML file. This
11040
+ * module removes the first half: it resolves a prebuilt, checksum-verified
11041
+ * binary for the host platform and execs it, so `npx @seekrit/proxy` and
11042
+ * `seekrit proxy run` behave like the proxy was already installed.
11043
+ *
11044
+ * The logic lives in the CLI (and is re-exported as `@seekrit/cli/proxy-launcher`)
11045
+ * for the same reason the MCP server does: `@seekrit/proxy` is a thin npx
11046
+ * entrypoint over it, and the two must not drift.
11047
+ *
11048
+ * Three properties worth stating, since this downloads and executes code:
11049
+ *
11050
+ * - **The checksum is verified before anything is executed**, against a
11051
+ * `.sha256` fetched from the same release. That is integrity, not provenance —
11052
+ * it proves the bytes match what the release published, which is exactly the
11053
+ * guarantee `install.sh` gives and no more.
11054
+ * - **Nothing is fetched when a binary is already available.** `SEEKRIT_PROXY_BIN`
11055
+ * short-circuits entirely, and a cached download for the same version+target is
11056
+ * reused, so this is a one-time cost per version.
11057
+ * - **Version is pinned, not floating.** A default of `latest` would make two
11058
+ * machines run different proxies from the same command; the pinned constant is
11059
+ * what this CLI was built against, overridable when you want otherwise.
11060
+ */
11061
+ /**
11062
+ * The proxy version this CLI was built against.
11063
+ *
11064
+ * Bumped by release-please when `apps/proxy` releases (an `extra-files` entry in
11065
+ * release-please-config.json), so the pin follows the crate without anyone
11066
+ * remembering to move it.
11067
+ */
11068
+ const PROXY_VERSION = "0.10.0";
11069
+ const BIN = "seekrit-proxy";
11070
+ /**
11071
+ * Host → Rust target triple.
11072
+ *
11073
+ * Linux always resolves to **musl**: that build is statically linked, so one
11074
+ * artifact covers glibc, musl, alpine, and distroless, and there is no libc
11075
+ * detection to get wrong on a machine where `ldd` says something unexpected.
11076
+ */
11077
+ function detectTarget(os = platform(), cpu = arch()) {
11078
+ const machine = cpu === "x64" ? "x86_64" : cpu === "arm64" ? "aarch64" : null;
11079
+ if (!machine) throw new Error(`unsupported architecture "${cpu}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
11080
+ switch (os) {
11081
+ case "linux": return {
11082
+ target: `${machine}-unknown-linux-musl`,
11083
+ exe: ""
11084
+ };
11085
+ case "darwin": return {
11086
+ target: `${machine}-apple-darwin`,
11087
+ exe: ""
11088
+ };
11089
+ case "win32":
11090
+ if (machine !== "x86_64") throw new Error(`no prebuilt seekrit-proxy for ${machine} Windows — set SEEKRIT_PROXY_BIN to a binary you built`);
11091
+ return {
11092
+ target: "x86_64-pc-windows-msvc",
11093
+ exe: ".exe"
11094
+ };
11095
+ default: throw new Error(`unsupported platform "${os}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
10860
11096
  }
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
11097
  }
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);
11098
+ /** `latest` stays `latest`; everything else is normalized to `v<x.y.z>`. */
11099
+ function versionPrefix(version) {
11100
+ if (version === "latest") return "latest";
11101
+ return version.startsWith("v") ? version : `v${version}`;
10902
11102
  }
10903
- /** YAML double-quoted scalar. Compose values here are hostnames and URLs. */
10904
- function yamlString(value) {
10905
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
11103
+ function resolveVersion(explicit) {
11104
+ return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
11105
+ }
11106
+ function resolveBaseUrl(explicit) {
11107
+ return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
11108
+ }
11109
+ /** Where a resolved binary is kept, keyed so versions and targets never collide. */
11110
+ function proxyBinaryPath(version, target, exe) {
11111
+ return join(defaultCacheDir(), "proxy", versionPrefix(version), target, `${BIN}${exe}`);
11112
+ }
11113
+ async function fetchBytes(url) {
11114
+ const res = await fetch(url);
11115
+ if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
11116
+ return new Uint8Array(await res.arrayBuffer());
10906
11117
  }
10907
- const COMPOSE_DEFAULTS = {
10908
- service: "seekrit-proxy",
10909
- workload: "agent",
10910
- publish: false
10911
- };
10912
11118
  /**
10913
- * A `docker compose` sidecar snippet for a generated config.
11119
+ * Ensure a `seekrit-proxy` binary exists locally and return its path.
10914
11120
  *
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.
11121
+ * Order: an explicit `SEEKRIT_PROXY_BIN`, then a cached download for this
11122
+ * version+target, then a fresh download. A binary already on `PATH` is
11123
+ * deliberately *not* used silently running a different version than the one
11124
+ * this CLI pins is the kind of surprise that costs an afternoon.
10920
11125
  */
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.");
11126
+ async function resolveProxyBinary(options = {}) {
11127
+ const override = process.env.SEEKRIT_PROXY_BIN;
11128
+ if (override) {
11129
+ if (!existsSync(override)) throw new Error(`SEEKRIT_PROXY_BIN points at ${override}, which does not exist`);
11130
+ return override;
10975
11131
  }
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)}`);
11132
+ const version = resolveVersion(options.version);
11133
+ const { target, exe } = detectTarget();
11134
+ const dest = proxyBinaryPath(version, target, exe);
11135
+ if (!options.force && version !== "latest" && existsSync(dest)) return dest;
11136
+ const baseUrl = resolveBaseUrl(options.baseUrl);
11137
+ const prefix = versionPrefix(version);
11138
+ const name = `${BIN}-${target}${exe}`;
11139
+ const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
11140
+ const sumUrl = `${binUrl}.sha256`;
11141
+ if (!options.quiet) process.stderr.write(`seekrit: fetching ${BIN} ${prefix} (${target})…\n`);
11142
+ let bytes;
11143
+ let expected;
11144
+ try {
11145
+ [bytes, expected] = await Promise.all([fetchBytes(binUrl), fetchBytes(sumUrl).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
11146
+ } catch (err) {
11147
+ const message = err instanceof Error ? err.message : String(err);
11148
+ 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
11149
  }
10981
- out.push("");
10982
- if (forward) {
10983
- out.push("volumes:");
10984
- out.push(" seekrit-proxy-ca:");
10985
- out.push("");
11150
+ const actual = createHash("sha256").update(bytes).digest("hex");
11151
+ if (!expected || actual !== expected.toLowerCase()) throw new Error(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to run it.`);
11152
+ const dir = dirname(dest);
11153
+ mkdirSync(dir, { recursive: true });
11154
+ const staging = join(dir, `.${BIN}-${process.pid}-${actual.slice(0, 12)}${exe}`);
11155
+ try {
11156
+ writeFileSync(staging, bytes, { mode: 493 });
11157
+ renameSync(staging, dest);
11158
+ } catch (err) {
11159
+ rmSync(staging, { force: true });
11160
+ throw err;
10986
11161
  }
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`;
11162
+ chmodSync(dest, 493);
11163
+ return dest;
11164
+ }
11165
+ /**
11166
+ * Run the proxy, forwarding stdio, signals, and its exit status.
11167
+ *
11168
+ * The proxy is a long-lived foreground process, so this wrapper has to be
11169
+ * transparent: Node cannot exec-replace itself, and without relaying signals
11170
+ * Node's default SIGINT handler would kill *this* process on Ctrl-C and leave
11171
+ * the proxy running, holding decrypted secrets, with the shell prompt back.
11172
+ */
11173
+ async function runProxyBinary(argv, options = {}) {
11174
+ const bin = await resolveProxyBinary(options);
11175
+ const child = spawn(bin, argv, {
11176
+ stdio: "inherit",
11177
+ env: {
11178
+ ...process.env,
11179
+ ...options.env
11180
+ }
11181
+ });
11182
+ const signals = [
11183
+ "SIGINT",
11184
+ "SIGTERM",
11185
+ "SIGHUP",
11186
+ "SIGQUIT"
11187
+ ];
11188
+ const forward = (signal) => {
11189
+ if (child.exitCode !== null || child.signalCode !== null) return;
11190
+ child.kill(signal);
11191
+ };
11192
+ for (const signal of signals) process.on(signal, forward);
11193
+ return new Promise((resolve, reject) => {
11194
+ child.on("error", (err) => {
11195
+ for (const s of signals) process.off(s, forward);
11196
+ 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.`));
11197
+ });
11198
+ child.on("exit", (code, signal) => {
11199
+ for (const s of signals) process.off(s, forward);
11200
+ resolve(signal ? 128 + signalNumber(signal) : code ?? 0);
11201
+ });
11202
+ });
11203
+ }
11204
+ /** Signal name → number, for the 128+n exit convention. */
11205
+ function signalNumber(signal) {
11206
+ return {
11207
+ SIGHUP: 1,
11208
+ SIGINT: 2,
11209
+ SIGQUIT: 3,
11210
+ SIGKILL: 9,
11211
+ SIGTERM: 15
11212
+ }[signal] ?? 0;
10991
11213
  }
10992
11214
  //#endregion
10993
11215
  //#region src/proxy.ts
@@ -13247,6 +13469,7 @@ registerRedisCommands(program);
13247
13469
  registerProvisionerCommands(program);
13248
13470
  registerProxyCommands(program);
13249
13471
  registerAgentCommands(program);
13472
+ registerPaperclipCommands(program);
13250
13473
  registerSshCommands(program);
13251
13474
  registerAwsCommands(program);
13252
13475
  registerGcpCommands(program);