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