@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.
- package/dist/index.js +1099 -732
- 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.
|
|
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/
|
|
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
|
|
10071
|
-
*
|
|
10072
|
-
*
|
|
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
|
-
|
|
10079
|
-
|
|
10080
|
-
|
|
10081
|
-
|
|
10082
|
-
|
|
10083
|
-
|
|
10084
|
-
|
|
10085
|
-
|
|
10086
|
-
|
|
10087
|
-
|
|
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
|
-
|
|
10090
|
-
|
|
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
|
-
|
|
10098
|
-
|
|
10099
|
-
|
|
10100
|
-
|
|
10101
|
-
|
|
10102
|
-
|
|
10103
|
-
|
|
10104
|
-
|
|
10105
|
-
|
|
10106
|
-
|
|
10107
|
-
|
|
10108
|
-
|
|
10109
|
-
|
|
10110
|
-
|
|
10111
|
-
const
|
|
10112
|
-
|
|
10113
|
-
|
|
10114
|
-
|
|
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
|
-
|
|
10157
|
-
|
|
10158
|
-
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
|
|
10170
|
-
|
|
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
|
-
/**
|
|
10214
|
-
function
|
|
10215
|
-
|
|
10216
|
-
return
|
|
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-
|
|
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
|
-
*
|
|
10258
|
-
*
|
|
10259
|
-
*
|
|
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
|
|
10264
|
-
|
|
10265
|
-
|
|
10266
|
-
|
|
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
|
-
|
|
10300
|
-
|
|
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
|
-
*
|
|
10389
|
+
* Claim `preferred` if nothing else has it, else derive one from `host`.
|
|
10306
10390
|
*
|
|
10307
|
-
*
|
|
10308
|
-
*
|
|
10309
|
-
*
|
|
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
|
-
|
|
10313
|
-
|
|
10314
|
-
|
|
10315
|
-
|
|
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
|
-
|
|
10319
|
-
|
|
10320
|
-
|
|
10321
|
-
|
|
10322
|
-
const
|
|
10323
|
-
|
|
10324
|
-
const
|
|
10325
|
-
|
|
10326
|
-
|
|
10327
|
-
|
|
10328
|
-
|
|
10329
|
-
|
|
10330
|
-
|
|
10331
|
-
|
|
10332
|
-
|
|
10333
|
-
|
|
10334
|
-
|
|
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
|
-
|
|
10337
|
-
|
|
10338
|
-
const
|
|
10339
|
-
|
|
10340
|
-
|
|
10341
|
-
|
|
10342
|
-
|
|
10343
|
-
|
|
10344
|
-
|
|
10345
|
-
|
|
10346
|
-
|
|
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
|
-
*
|
|
10994
|
+
* `seekrit pg` — temporary Postgres credentials (Vault-style dynamic secrets).
|
|
10827
10995
|
*
|
|
10828
|
-
*
|
|
10829
|
-
*
|
|
10830
|
-
*
|
|
10831
|
-
*
|
|
10832
|
-
*
|
|
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
|
-
|
|
10835
|
-
|
|
10836
|
-
const
|
|
10837
|
-
|
|
10838
|
-
|
|
10839
|
-
|
|
10840
|
-
|
|
10841
|
-
|
|
10842
|
-
|
|
10843
|
-
|
|
10844
|
-
|
|
10845
|
-
|
|
10846
|
-
|
|
10847
|
-
|
|
10848
|
-
|
|
10849
|
-
|
|
10850
|
-
|
|
10851
|
-
|
|
10852
|
-
|
|
10853
|
-
|
|
10854
|
-
|
|
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
|
-
|
|
10857
|
-
|
|
10858
|
-
|
|
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
|
-
/**
|
|
10893
|
-
|
|
10894
|
-
|
|
10895
|
-
"
|
|
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
|
-
|
|
10904
|
-
|
|
10905
|
-
|
|
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
|
-
*
|
|
11229
|
+
* Ensure a `seekrit-proxy` binary exists locally and return its path.
|
|
10914
11230
|
*
|
|
10915
|
-
*
|
|
10916
|
-
*
|
|
10917
|
-
*
|
|
10918
|
-
*
|
|
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
|
|
10922
|
-
const
|
|
10923
|
-
|
|
10924
|
-
|
|
10925
|
-
|
|
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
|
-
|
|
10977
|
-
|
|
10978
|
-
|
|
10979
|
-
|
|
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
|
-
|
|
10982
|
-
if (
|
|
10983
|
-
|
|
10984
|
-
|
|
10985
|
-
|
|
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
|
-
|
|
10988
|
-
|
|
10989
|
-
|
|
10990
|
-
|
|
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);
|