@seekrit/cli 0.42.0 → 0.44.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 +2551 -51
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
-
import { homedir, hostname, tmpdir, userInfo } from "node:os";
|
|
7
|
-
import { dirname, join, parse } from "node:path";
|
|
6
|
+
import { arch, homedir, hostname, platform, tmpdir, userInfo } from "node:os";
|
|
7
|
+
import { dirname, join, parse, resolve } from "node:path";
|
|
8
8
|
import { createInterface } from "node:readline";
|
|
9
9
|
import { Writable } from "node:stream";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
|
|
11
|
+
/** Default lifetime of a published bundle (7 days), in seconds. */
|
|
12
|
+
const POLICY_DEFAULT_TTL_SECONDS = 10080 * 60;
|
|
13
|
+
/** Bounds on a bundle's lifetime: an hour at the short end, 90 days at the long. */
|
|
14
|
+
const POLICY_MIN_TTL_SECONDS = 3600;
|
|
15
|
+
const POLICY_MAX_TTL_SECONDS = 2160 * 60 * 60;
|
|
12
16
|
/** A bare hostname: no scheme, no port, no path, no wildcard. */
|
|
13
17
|
const policyHostSchema = z.string().trim().min(1).max(253).toLowerCase().refine((h) => !/[:/\s*]/.test(h), { message: "host must be a bare hostname (no scheme, port, path, or wildcard)" }).refine((h) => /^[a-z0-9.-]+$/.test(h), { message: "host contains invalid characters" });
|
|
14
18
|
const policyMethodSchema = z.string().trim().toUpperCase().regex(/^[A-Z]{3,10}$/, "method must be an HTTP method name");
|
|
@@ -18,7 +22,7 @@ const policyMethodSchema = z.string().trim().toUpperCase().regex(/^[A-Z]{3,10}$/
|
|
|
18
22
|
*/
|
|
19
23
|
const policyPathSchema = z.string().trim().min(1).max(512).startsWith("/", "path pattern must start with /").refine((p) => !p.includes("?"), { message: "path patterns match the path only, not the query" });
|
|
20
24
|
const policySecretNameSchema = z.string().trim().regex(/^[A-Za-z0-9_]+$/, "secret names are letters, digits, and underscores");
|
|
21
|
-
z.object({
|
|
25
|
+
const agentPolicyRuleSchema = z.object({
|
|
22
26
|
host: policyHostSchema,
|
|
23
27
|
methods: z.array(policyMethodSchema).max(16).default([]),
|
|
24
28
|
paths: z.array(policyPathSchema).max(64).default([]),
|
|
@@ -45,6 +49,519 @@ z.object({
|
|
|
45
49
|
path: z.string().trim().min(1).max(2048),
|
|
46
50
|
secret: policySecretNameSchema.optional()
|
|
47
51
|
});
|
|
52
|
+
/**
|
|
53
|
+
* Serialize a bundle to the exact bytes that get signed and transported.
|
|
54
|
+
*
|
|
55
|
+
* Explicit field order rather than a generic sorted-key walk: the field list is
|
|
56
|
+
* the format, and a reviewer should be able to read it here. Optional fields are
|
|
57
|
+
* omitted rather than emitted as `null`, and empty `methods`/`paths`/`allow`
|
|
58
|
+
* arrays are kept, since their emptiness is meaningful (any/any/none).
|
|
59
|
+
*/
|
|
60
|
+
function canonicalizeAgentPolicy(bundle) {
|
|
61
|
+
const rules = bundle.rules.map((rule) => {
|
|
62
|
+
const out = {
|
|
63
|
+
host: rule.host.trim().toLowerCase(),
|
|
64
|
+
methods: rule.methods.map((m) => m.trim().toUpperCase()).sort(),
|
|
65
|
+
paths: rule.paths.map((p) => p.trim()),
|
|
66
|
+
allow: [...rule.allow].sort()
|
|
67
|
+
};
|
|
68
|
+
if (rule.label?.trim()) out.label = rule.label.trim();
|
|
69
|
+
return out;
|
|
70
|
+
});
|
|
71
|
+
return JSON.stringify({
|
|
72
|
+
v: bundle.v,
|
|
73
|
+
org: bundle.org,
|
|
74
|
+
agent: bundle.agent,
|
|
75
|
+
agent_slug: bundle.agent_slug,
|
|
76
|
+
policy_version: bundle.policy_version,
|
|
77
|
+
issued_at: bundle.issued_at,
|
|
78
|
+
expires_at: bundle.expires_at,
|
|
79
|
+
rules,
|
|
80
|
+
signer: {
|
|
81
|
+
kid: bundle.signer.kid,
|
|
82
|
+
jwk: {
|
|
83
|
+
crv: bundle.signer.jwk.crv,
|
|
84
|
+
kty: bundle.signer.jwk.kty,
|
|
85
|
+
x: bundle.signer.jwk.x,
|
|
86
|
+
y: bundle.signer.jwk.y
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* RFC 7638 JWK thumbprint (SHA-256, base64url, no padding) — the identifier an
|
|
93
|
+
* operator pins in the proxy's TOML.
|
|
94
|
+
*/
|
|
95
|
+
async function policySignerThumbprint(jwk) {
|
|
96
|
+
const canonical = JSON.stringify({
|
|
97
|
+
crv: jwk.crv,
|
|
98
|
+
kty: jwk.kty,
|
|
99
|
+
x: jwk.x,
|
|
100
|
+
y: jwk.y
|
|
101
|
+
});
|
|
102
|
+
const digest = await crypto.subtle.digest("SHA-256", utf8(canonical));
|
|
103
|
+
return base64url(new Uint8Array(digest));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Sign a draft bundle with the publishing admin's key, returning the `ap1.`
|
|
107
|
+
* envelope to hand to the API.
|
|
108
|
+
*
|
|
109
|
+
* `privateKey` must be an ECDSA P-256 key with the `sign` usage — in the
|
|
110
|
+
* dashboard that is the signed-in admin's existing principal key, re-imported
|
|
111
|
+
* for signing (`importPolicySigningKey` in `@seekrit/crypto`). No new key
|
|
112
|
+
* material is involved, which is what makes this shippable without a second
|
|
113
|
+
* passphrase-protected keypair to manage.
|
|
114
|
+
*/
|
|
115
|
+
async function signAgentPolicy(privateKey, signerJwk, draft) {
|
|
116
|
+
const kid = await policySignerThumbprint(signerJwk);
|
|
117
|
+
const bytes = utf8(canonicalizeAgentPolicy({
|
|
118
|
+
...draft,
|
|
119
|
+
signer: {
|
|
120
|
+
kid,
|
|
121
|
+
jwk: signerJwk
|
|
122
|
+
}
|
|
123
|
+
}));
|
|
124
|
+
const sig = new Uint8Array(await crypto.subtle.sign({
|
|
125
|
+
name: "ECDSA",
|
|
126
|
+
hash: "SHA-256"
|
|
127
|
+
}, privateKey, bytes));
|
|
128
|
+
return [
|
|
129
|
+
"ap1",
|
|
130
|
+
base64url(bytes),
|
|
131
|
+
base64url(sig)
|
|
132
|
+
].join(".");
|
|
133
|
+
}
|
|
134
|
+
/** A structurally invalid, unverifiable, or expired bundle. */
|
|
135
|
+
var AgentPolicyError = class extends Error {
|
|
136
|
+
constructor(message) {
|
|
137
|
+
super(message);
|
|
138
|
+
this.name = "AgentPolicyError";
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Read a bundle **without** verifying its signature — for display only.
|
|
143
|
+
*
|
|
144
|
+
* Named to be hard to misuse: anything that acts on policy must go through
|
|
145
|
+
* {@link verifyAgentPolicy} (or the Rust verifier). The dashboard uses this to
|
|
146
|
+
* render version history it fetched from the API, where the trust question is
|
|
147
|
+
* already settled differently: the API is the one showing you the list.
|
|
148
|
+
*/
|
|
149
|
+
function parseAgentPolicyUnverified(envelope) {
|
|
150
|
+
const parts = envelope.trim().split(".");
|
|
151
|
+
if (parts.length !== 3 || parts[0] !== "ap1") throw new AgentPolicyError(`not a policy bundle (expected a ap1. envelope)`);
|
|
152
|
+
let body;
|
|
153
|
+
try {
|
|
154
|
+
body = JSON.parse(utf8Decode$1(fromBase64url(parts[1])));
|
|
155
|
+
} catch (e) {
|
|
156
|
+
throw new AgentPolicyError(`policy bundle body is unreadable: ${e.message}`);
|
|
157
|
+
}
|
|
158
|
+
if (body?.v !== 1) throw new AgentPolicyError(`unsupported policy bundle version ${String(body?.v)}`);
|
|
159
|
+
if (!Array.isArray(body.rules) || !body.signer?.jwk) throw new AgentPolicyError("policy bundle is missing rules or signer");
|
|
160
|
+
return body;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Match a request path against a glob pattern: `*` matches within one segment,
|
|
164
|
+
* `**` matches any number of segments (including none, so `/v1/**` covers
|
|
165
|
+
* `/v1`). Case-sensitive, and the query string never participates.
|
|
166
|
+
*/
|
|
167
|
+
function matchPolicyPath(pattern, path) {
|
|
168
|
+
const bare = path.split("?")[0] ?? path;
|
|
169
|
+
return matchSegments(pattern.split("/"), bare.split("/"));
|
|
170
|
+
}
|
|
171
|
+
function matchSegments(pattern, segments) {
|
|
172
|
+
if (pattern.length === 0) return segments.length === 0;
|
|
173
|
+
const [head, ...rest] = pattern;
|
|
174
|
+
if (head === "**") {
|
|
175
|
+
for (let skip = 0; skip <= segments.length; skip++) if (matchSegments(rest, segments.slice(skip))) return true;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (segments.length === 0) return false;
|
|
179
|
+
return matchSegment(head, segments[0]) && matchSegments(rest, segments.slice(1));
|
|
180
|
+
}
|
|
181
|
+
function matchSegment(pattern, segment) {
|
|
182
|
+
if (!pattern.includes("*")) return pattern === segment;
|
|
183
|
+
const parts = pattern.split("*");
|
|
184
|
+
let rest = segment;
|
|
185
|
+
for (let i = 0; i < parts.length; i++) {
|
|
186
|
+
const part = parts[i];
|
|
187
|
+
if (part === "") continue;
|
|
188
|
+
if (i === 0) {
|
|
189
|
+
if (!rest.startsWith(part)) return false;
|
|
190
|
+
rest = rest.slice(part.length);
|
|
191
|
+
} else if (i === parts.length - 1) return rest.length >= part.length && rest.endsWith(part);
|
|
192
|
+
else {
|
|
193
|
+
const at = rest.indexOf(part);
|
|
194
|
+
if (at === -1) return false;
|
|
195
|
+
rest = rest.slice(at + part.length);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
function ruleCoversMethod(rule, method) {
|
|
201
|
+
if (rule.methods.length === 0) return true;
|
|
202
|
+
const wanted = method.trim().toUpperCase();
|
|
203
|
+
return rule.methods.some((m) => m.trim().toUpperCase() === wanted);
|
|
204
|
+
}
|
|
205
|
+
function ruleCoversPath(rule, path) {
|
|
206
|
+
if (rule.paths.length === 0) return true;
|
|
207
|
+
return rule.paths.some((p) => matchPolicyPath(p, path));
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Decide a request against an ordered rule set, first match wins — the same
|
|
211
|
+
* evaluation `RuleSet::decide` performs in the proxy, including which rule
|
|
212
|
+
* decided and *why* a refusal happened. Naming the constraint matters: a
|
|
213
|
+
* default-deny policy fails in exactly the confusing direction.
|
|
214
|
+
*/
|
|
215
|
+
function evaluatePolicy(rules, query) {
|
|
216
|
+
const host = query.host.trim().toLowerCase();
|
|
217
|
+
let hostMatched = false;
|
|
218
|
+
let pathMatchedIndex = null;
|
|
219
|
+
for (let i = 0; i < rules.length; i++) {
|
|
220
|
+
const rule = rules[i];
|
|
221
|
+
if (rule.host.trim().toLowerCase() !== host) continue;
|
|
222
|
+
hostMatched = true;
|
|
223
|
+
const pathsOk = ruleCoversPath(rule, query.path);
|
|
224
|
+
if (pathsOk && ruleCoversMethod(rule, query.method)) {
|
|
225
|
+
if (query.secret !== void 0 && !rule.allow.includes(query.secret)) return {
|
|
226
|
+
decision: "secret_not_allowed",
|
|
227
|
+
ruleIndex: i
|
|
228
|
+
};
|
|
229
|
+
return {
|
|
230
|
+
decision: "allow",
|
|
231
|
+
ruleIndex: i
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (pathsOk && pathMatchedIndex === null) pathMatchedIndex = i;
|
|
235
|
+
}
|
|
236
|
+
if (pathMatchedIndex !== null) return {
|
|
237
|
+
decision: "method_not_allowed",
|
|
238
|
+
ruleIndex: pathMatchedIndex
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
decision: hostMatched ? "path_not_allowed" : "no_rule",
|
|
242
|
+
ruleIndex: null
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/** A one-line, human-readable reason for a verdict — shared by UI and API. */
|
|
246
|
+
function describePolicyVerdict(verdict, rules) {
|
|
247
|
+
const rule = verdict.ruleIndex === null ? null : rules[verdict.ruleIndex];
|
|
248
|
+
switch (verdict.decision) {
|
|
249
|
+
case "allow": return `permitted by rule ${(verdict.ruleIndex ?? 0) + 1}${rule?.label ? ` (${rule.label})` : ""}`;
|
|
250
|
+
case "no_rule": return "no rule covers this host";
|
|
251
|
+
case "method_not_allowed": return `rule ${(verdict.ruleIndex ?? 0) + 1} covers this path but not this method`;
|
|
252
|
+
case "path_not_allowed": return "this host has rules, but none cover this path";
|
|
253
|
+
case "secret_not_allowed": return `rule ${(verdict.ruleIndex ?? 0) + 1} permits the request but not this secret`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Every host a rule set names — what a forward proxy would intercept. */
|
|
257
|
+
function policyHosts(rules) {
|
|
258
|
+
return [...new Set(rules.map((r) => r.host.trim().toLowerCase()))].sort();
|
|
259
|
+
}
|
|
260
|
+
function diffPolicyRules(before, after) {
|
|
261
|
+
const out = [];
|
|
262
|
+
const same = (a, b) => JSON.stringify(normalizeRule(a)) === JSON.stringify(normalizeRule(b));
|
|
263
|
+
for (let i = 0; i < Math.max(before.length, after.length); i++) {
|
|
264
|
+
const b = before[i];
|
|
265
|
+
const a = after[i];
|
|
266
|
+
if (b && a) out.push({
|
|
267
|
+
kind: same(b, a) ? "unchanged" : "changed",
|
|
268
|
+
index: i,
|
|
269
|
+
before: b,
|
|
270
|
+
after: a
|
|
271
|
+
});
|
|
272
|
+
else if (a) out.push({
|
|
273
|
+
kind: "added",
|
|
274
|
+
index: i,
|
|
275
|
+
after: a
|
|
276
|
+
});
|
|
277
|
+
else if (b) out.push({
|
|
278
|
+
kind: "removed",
|
|
279
|
+
index: i,
|
|
280
|
+
before: b
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
function normalizeRule(rule) {
|
|
286
|
+
return {
|
|
287
|
+
host: rule.host.trim().toLowerCase(),
|
|
288
|
+
methods: rule.methods.map((m) => m.toUpperCase()).sort(),
|
|
289
|
+
paths: [...rule.paths],
|
|
290
|
+
allow: [...rule.allow].sort(),
|
|
291
|
+
label: rule.label?.trim() || void 0
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function utf8(text) {
|
|
295
|
+
return new TextEncoder().encode(text);
|
|
296
|
+
}
|
|
297
|
+
function utf8Decode$1(bytes) {
|
|
298
|
+
return new TextDecoder().decode(bytes);
|
|
299
|
+
}
|
|
300
|
+
function base64url(bytes) {
|
|
301
|
+
let binary = "";
|
|
302
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
303
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
304
|
+
}
|
|
305
|
+
function fromBase64url(text) {
|
|
306
|
+
const padded = text.replace(/-/g, "+").replace(/_/g, "/");
|
|
307
|
+
const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
308
|
+
const out = new Uint8Array(binary.length);
|
|
309
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
/** One aggregated cell: a dimension tuple and how many times it happened. */
|
|
313
|
+
const activityEntrySchema = z.object({
|
|
314
|
+
host: policyHostSchema,
|
|
315
|
+
method: policyMethodSchema,
|
|
316
|
+
decision: z.enum([
|
|
317
|
+
"allow",
|
|
318
|
+
"no_rule",
|
|
319
|
+
"method_not_allowed",
|
|
320
|
+
"path_not_allowed",
|
|
321
|
+
"secret_not_allowed",
|
|
322
|
+
"unknown_secret",
|
|
323
|
+
"ratchet_withdrawn",
|
|
324
|
+
"policy_unavailable"
|
|
325
|
+
]),
|
|
326
|
+
/**
|
|
327
|
+
* Which published rule decided, when one did. Null for refusals that never
|
|
328
|
+
* reached a rule (`no_rule`, `policy_unavailable`) — the distinction matters to
|
|
329
|
+
* a review, because "rule 3 refused this" and "nothing covered this" call for
|
|
330
|
+
* opposite changes.
|
|
331
|
+
*/
|
|
332
|
+
ruleIndex: z.number().int().min(0).max(255).nullable(),
|
|
333
|
+
count: z.number().int().min(1).max(1e6),
|
|
334
|
+
/**
|
|
335
|
+
* Secret names actually injected, name → count. Only meaningful on `allow`.
|
|
336
|
+
* This is what lets a review say "rule 2 permits three secrets and the agent
|
|
337
|
+
* has only ever used one" — the most useful narrowing there is, and impossible
|
|
338
|
+
* to see from policy alone.
|
|
339
|
+
*/
|
|
340
|
+
secrets: z.record(policySecretNameSchema, z.number().int().min(1)).optional()
|
|
341
|
+
});
|
|
342
|
+
z.object({
|
|
343
|
+
/** Start of the window these counts cover (ISO 8601). */
|
|
344
|
+
windowStart: z.string().trim().min(20).max(40),
|
|
345
|
+
/** Policy version in force while they were collected, for the ledger. */
|
|
346
|
+
policyVersion: z.number().int().min(0).optional(),
|
|
347
|
+
/**
|
|
348
|
+
* Capped so one report cannot be unbounded work. A proxy with more distinct
|
|
349
|
+
* cells than this in a window has a policy far broader than a review can help
|
|
350
|
+
* with, and truncating loudly beats accepting anything.
|
|
351
|
+
*/
|
|
352
|
+
entries: z.array(activityEntrySchema).min(1).max(500)
|
|
353
|
+
});
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region ../../packages/core/src/agent-review.ts
|
|
356
|
+
function emptyStats() {
|
|
357
|
+
return {
|
|
358
|
+
allows: 0,
|
|
359
|
+
denials: 0,
|
|
360
|
+
methods: /* @__PURE__ */ new Set(),
|
|
361
|
+
secrets: /* @__PURE__ */ new Set()
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Compare an agent's live rules against what it actually did.
|
|
366
|
+
*
|
|
367
|
+
* Returns proposals ordered narrowing-first, because that is the order they
|
|
368
|
+
* should be read in: the safe changes, then the ones needing a judgment call.
|
|
369
|
+
*/
|
|
370
|
+
function reviewPolicy(input) {
|
|
371
|
+
const { rules, activity } = input;
|
|
372
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
373
|
+
/** Hosts refused with no rule to attribute it to → candidates for widening. */
|
|
374
|
+
const uncovered = /* @__PURE__ */ new Map();
|
|
375
|
+
for (const row of activity) {
|
|
376
|
+
if (row.ruleIndex === null) {
|
|
377
|
+
if (row.decision === "no_rule" || row.decision === "path_not_allowed") {
|
|
378
|
+
const entry = uncovered.get(row.host) ?? {
|
|
379
|
+
denials: 0,
|
|
380
|
+
methods: /* @__PURE__ */ new Set()
|
|
381
|
+
};
|
|
382
|
+
entry.denials += row.count;
|
|
383
|
+
entry.methods.add(row.method);
|
|
384
|
+
uncovered.set(row.host, entry);
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const stats = byRule.get(row.ruleIndex) ?? emptyStats();
|
|
389
|
+
if (row.decision === "allow") {
|
|
390
|
+
stats.allows += row.count;
|
|
391
|
+
stats.methods.add(row.method);
|
|
392
|
+
for (const name of Object.keys(row.secrets ?? {})) stats.secrets.add(name);
|
|
393
|
+
} else stats.denials += row.count;
|
|
394
|
+
byRule.set(row.ruleIndex, stats);
|
|
395
|
+
}
|
|
396
|
+
const narrowing = [];
|
|
397
|
+
const widening = [];
|
|
398
|
+
/**
|
|
399
|
+
* Did this agent demonstrably do anything in the window?
|
|
400
|
+
*
|
|
401
|
+
* This is what licenses a conclusion from *absence*. With permitted traffic on
|
|
402
|
+
* the record, a rule that never came up is unused. With none, the window says
|
|
403
|
+
* nothing about any rule, and every proposal below would be noise.
|
|
404
|
+
*/
|
|
405
|
+
const workedAtAll = [...byRule.values()].some((s) => s.allows > 0);
|
|
406
|
+
for (const [index, rule] of rules.entries()) {
|
|
407
|
+
const stats = byRule.get(index);
|
|
408
|
+
if (!stats) {
|
|
409
|
+
if (workedAtAll) narrowing.push({
|
|
410
|
+
kind: "remove_rule",
|
|
411
|
+
ruleIndex: index,
|
|
412
|
+
host: rule.host,
|
|
413
|
+
evidence: {
|
|
414
|
+
allows: 0,
|
|
415
|
+
denials: 0,
|
|
416
|
+
observations: 0
|
|
417
|
+
},
|
|
418
|
+
rationale: `rule ${index + 1} (${rule.host}) never matched a request, while other rules did`,
|
|
419
|
+
applicable: true
|
|
420
|
+
});
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const evidence = {
|
|
424
|
+
allows: stats.allows,
|
|
425
|
+
denials: stats.denials,
|
|
426
|
+
observations: stats.allows + stats.denials
|
|
427
|
+
};
|
|
428
|
+
if (stats.allows === 0) {
|
|
429
|
+
narrowing.push({
|
|
430
|
+
kind: "remove_rule",
|
|
431
|
+
ruleIndex: index,
|
|
432
|
+
host: rule.host,
|
|
433
|
+
evidence,
|
|
434
|
+
rationale: stats.denials > 0 ? `rule ${index + 1} (${rule.host}) authorized nothing in this window and refused ${stats.denials} — it may be misconfigured rather than unused` : `rule ${index + 1} (${rule.host}) authorized nothing in this window`,
|
|
435
|
+
applicable: true
|
|
436
|
+
});
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const unusedSecrets = rule.allow.filter((name) => !stats.secrets.has(name));
|
|
440
|
+
if (unusedSecrets.length > 0) narrowing.push({
|
|
441
|
+
kind: "remove_secret",
|
|
442
|
+
ruleIndex: index,
|
|
443
|
+
host: rule.host,
|
|
444
|
+
names: unusedSecrets,
|
|
445
|
+
evidence,
|
|
446
|
+
rationale: `rule ${index + 1} (${rule.host}) permits ${unusedSecrets.join(", ")}, never injected in ${stats.allows} permitted request(s)`,
|
|
447
|
+
applicable: true
|
|
448
|
+
});
|
|
449
|
+
const unusedMethods = rule.methods.filter((m) => !stats.methods.has(m.toUpperCase()));
|
|
450
|
+
if (rule.methods.length > 0 && unusedMethods.length > 0) narrowing.push({
|
|
451
|
+
kind: "narrow_methods",
|
|
452
|
+
ruleIndex: index,
|
|
453
|
+
host: rule.host,
|
|
454
|
+
names: unusedMethods,
|
|
455
|
+
evidence,
|
|
456
|
+
rationale: `rule ${index + 1} (${rule.host}) permits ${unusedMethods.join(", ")}, never used in ${stats.allows} permitted request(s)`,
|
|
457
|
+
applicable: true
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
for (const [host, entry] of uncovered) widening.push({
|
|
461
|
+
kind: "widen",
|
|
462
|
+
ruleIndex: null,
|
|
463
|
+
host,
|
|
464
|
+
names: [...entry.methods].sort(),
|
|
465
|
+
evidence: {
|
|
466
|
+
allows: 0,
|
|
467
|
+
denials: entry.denials,
|
|
468
|
+
observations: entry.denials
|
|
469
|
+
},
|
|
470
|
+
rationale: `${entry.denials} request(s) to ${host} matched no rule (${[...entry.methods].sort().join(", ")}) — the agent wants this and cannot have it`,
|
|
471
|
+
applicable: false
|
|
472
|
+
});
|
|
473
|
+
narrowing.sort((a, b) => (a.ruleIndex ?? 0) - (b.ruleIndex ?? 0));
|
|
474
|
+
widening.sort((a, b) => b.evidence.denials - a.evidence.denials);
|
|
475
|
+
return [...narrowing, ...widening];
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Apply the accepted narrowing proposals, returning a new rule list.
|
|
479
|
+
*
|
|
480
|
+
* Pure and total: the input rules are untouched, and anything not applicable is
|
|
481
|
+
* skipped rather than throwing, so a caller can pass the whole review back.
|
|
482
|
+
*
|
|
483
|
+
* Rule *positions* shift when one is removed, which matters because order decides
|
|
484
|
+
* (first match wins). Removals are therefore applied last, after the by-index
|
|
485
|
+
* edits, so an index in a proposal always refers to the policy the review was
|
|
486
|
+
* computed against.
|
|
487
|
+
*/
|
|
488
|
+
function applyProposals(rules, accepted) {
|
|
489
|
+
const applicable = accepted.filter((p) => p.applicable && p.ruleIndex !== null);
|
|
490
|
+
const next = rules.map((rule) => ({
|
|
491
|
+
...rule,
|
|
492
|
+
methods: [...rule.methods],
|
|
493
|
+
paths: [...rule.paths],
|
|
494
|
+
allow: [...rule.allow]
|
|
495
|
+
}));
|
|
496
|
+
for (const proposal of applicable) {
|
|
497
|
+
const index = proposal.ruleIndex;
|
|
498
|
+
const rule = next[index];
|
|
499
|
+
if (!rule) continue;
|
|
500
|
+
if (proposal.kind === "remove_secret") {
|
|
501
|
+
const drop = new Set(proposal.names ?? []);
|
|
502
|
+
rule.allow = rule.allow.filter((name) => !drop.has(name));
|
|
503
|
+
} else if (proposal.kind === "narrow_methods") {
|
|
504
|
+
const drop = new Set((proposal.names ?? []).map((m) => m.toUpperCase()));
|
|
505
|
+
rule.methods = rule.methods.filter((m) => !drop.has(m.toUpperCase()));
|
|
506
|
+
if (rule.methods.length === 0) rule.methods = [...rules[index]?.methods ?? []];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const removed = new Set(applicable.filter((p) => p.kind === "remove_rule").map((p) => p.ruleIndex));
|
|
510
|
+
return next.filter((_, index) => !removed.has(index));
|
|
511
|
+
}
|
|
512
|
+
/** How many of a review's proposals `applyProposals` would act on. */
|
|
513
|
+
function countApplicable(proposals) {
|
|
514
|
+
return proposals.filter((p) => p.applicable).length;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Twelve hours, matching `[control] max_ttl` in a proxy config. A task is meant
|
|
518
|
+
* to bound one run; something that needs longer wants a policy change, not a
|
|
519
|
+
* longer ticket.
|
|
520
|
+
*/
|
|
521
|
+
const TASK_MAX_TTL_SECONDS = 720 * 60;
|
|
522
|
+
/** An EC P-256 public JWK, for a sender-constraint proof key. */
|
|
523
|
+
const taskProofJwkSchema = z.object({
|
|
524
|
+
kty: z.literal("EC"),
|
|
525
|
+
crv: z.literal("P-256"),
|
|
526
|
+
x: z.string().min(1).max(128),
|
|
527
|
+
y: z.string().min(1).max(128)
|
|
528
|
+
});
|
|
529
|
+
z.object({
|
|
530
|
+
/**
|
|
531
|
+
* The public `skd_…` segment of the minted token. Sent because the API never
|
|
532
|
+
* sees the token at dispatch and still needs a readable handle for the audit
|
|
533
|
+
* row and for a revoke to name — the id half of a credential, without the
|
|
534
|
+
* secret half.
|
|
535
|
+
*/
|
|
536
|
+
taskRef: z.string().trim().regex(/^skd_[0-9A-Za-z]+$/, "taskRef must be the skd_… segment of the minted token"),
|
|
537
|
+
/**
|
|
538
|
+
* SHA-256 (base64url) of the token the dispatcher minted. The token itself
|
|
539
|
+
* never reaches this API on the dispatch path — only on introspection, where
|
|
540
|
+
* it is hashed and discarded.
|
|
541
|
+
*/
|
|
542
|
+
tokenHash: z.string().trim().min(16).max(128),
|
|
543
|
+
/**
|
|
544
|
+
* Secret names this run may use. Omit for "whatever the agent's policy
|
|
545
|
+
* allows" — mirroring `Session.scopes: Option<BTreeSet<String>>` in the proxy,
|
|
546
|
+
* so absent means unnarrowed in both places.
|
|
547
|
+
*/
|
|
548
|
+
scopes: z.array(policySecretNameSchema).max(64).optional(),
|
|
549
|
+
ttlSeconds: z.number().int().min(60).max(TASK_MAX_TTL_SECONDS).optional(),
|
|
550
|
+
/**
|
|
551
|
+
* What this run is for, for the audit row and the operator's task list. Free
|
|
552
|
+
* text, and **not** a security input: never put a secret value in it.
|
|
553
|
+
*/
|
|
554
|
+
label: z.string().trim().max(200).optional(),
|
|
555
|
+
/**
|
|
556
|
+
* Public half of a proof key the presenter holds, recorded as an RFC 7638
|
|
557
|
+
* thumbprint. See `AgentTaskSession.proofThumbprint` for what this does and —
|
|
558
|
+
* importantly — does not yet do.
|
|
559
|
+
*/
|
|
560
|
+
proofJwk: taskProofJwkSchema.optional()
|
|
561
|
+
});
|
|
562
|
+
z.object({
|
|
563
|
+
/** The presented token. In the body, never a URL — it is a credential. */
|
|
564
|
+
token: z.string().trim().min(8).max(512) });
|
|
48
565
|
/** All catalog keys as a runtime array (for iteration / zod enums). */
|
|
49
566
|
const ENTITLEMENT_KEYS = Object.keys({
|
|
50
567
|
"feature.kms": {
|
|
@@ -1230,7 +1747,9 @@ const AUDIT_ACTIONS = [
|
|
|
1230
1747
|
"agent.updated",
|
|
1231
1748
|
"agent.deleted",
|
|
1232
1749
|
"agent.policy_published",
|
|
1233
|
-
"agent.policy_rolled_back"
|
|
1750
|
+
"agent.policy_rolled_back",
|
|
1751
|
+
"agent.task_dispatched",
|
|
1752
|
+
"agent.task_revoked"
|
|
1234
1753
|
];
|
|
1235
1754
|
/**
|
|
1236
1755
|
* Transactional notification emails seekrit can send. Each id is one
|
|
@@ -1709,7 +2228,8 @@ const SYNC_PROVIDER_KINDS = [
|
|
|
1709
2228
|
"netlify",
|
|
1710
2229
|
"bunnyshell",
|
|
1711
2230
|
"github-actions",
|
|
1712
|
-
"gcp-secret-manager"
|
|
2231
|
+
"gcp-secret-manager",
|
|
2232
|
+
"langgraph-platform"
|
|
1713
2233
|
];
|
|
1714
2234
|
z.enum(SYNC_PROVIDER_KINDS);
|
|
1715
2235
|
/**
|
|
@@ -1990,6 +2510,66 @@ const gcpSecretManagerConnectionConfigSchema = z.object({
|
|
|
1990
2510
|
/** Project ID (`acme-prod`) or project number. */
|
|
1991
2511
|
projectId: gcpProjectSchema
|
|
1992
2512
|
});
|
|
2513
|
+
/**
|
|
2514
|
+
* The four hosts LangChain runs the deployment control plane on. A LangSmith
|
|
2515
|
+
* account lives in exactly one of them, and an API key minted in one is not
|
|
2516
|
+
* accepted by another — so this is the "which account" half of a LangGraph
|
|
2517
|
+
* Platform connection, the way Cloudflare's account id is.
|
|
2518
|
+
*
|
|
2519
|
+
* `us` is the default because it is what `https://smith.langchain.com` signs
|
|
2520
|
+
* into; the other three are chosen at signup and never change afterwards.
|
|
2521
|
+
*/
|
|
2522
|
+
const LANGGRAPH_PLATFORM_REGIONS = [
|
|
2523
|
+
"us",
|
|
2524
|
+
"eu",
|
|
2525
|
+
"apac",
|
|
2526
|
+
"aws-us"
|
|
2527
|
+
];
|
|
2528
|
+
/**
|
|
2529
|
+
* LangSmith workspace/tenant scope for LangGraph Platform.
|
|
2530
|
+
*
|
|
2531
|
+
* The API key is never here — it is wrapped to the connection's public key and
|
|
2532
|
+
* stored as ciphertext, exactly as Vercel's token is.
|
|
2533
|
+
*
|
|
2534
|
+
* Two optional fields, for two different situations, and setting both is
|
|
2535
|
+
* rejected rather than silently resolved:
|
|
2536
|
+
*
|
|
2537
|
+
* - `region` picks one of {@link LANGGRAPH_PLATFORM_HOSTS}. Omitted means
|
|
2538
|
+
* `us`, which is where an account created at `smith.langchain.com` lives.
|
|
2539
|
+
* - `baseUrl` points the connection at a **self-hosted** LangSmith install,
|
|
2540
|
+
* whose control plane is served from the customer's own host under
|
|
2541
|
+
* `/api-host` rather than from `*.api.host.langchain.com`.
|
|
2542
|
+
*
|
|
2543
|
+
* `tenantId` is the workspace a key was minted in. A workspace-scoped key names
|
|
2544
|
+
* its own tenant and does not need it; an organization-scoped key reaches
|
|
2545
|
+
* several workspaces and gets a bare 403 without it, which is the same trap
|
|
2546
|
+
* Vercel's `teamId` sets — so it is passed through as `X-Tenant-Id` whenever
|
|
2547
|
+
* it is present.
|
|
2548
|
+
*/
|
|
2549
|
+
const langgraphPlatformConnectionConfigSchema = z.object({
|
|
2550
|
+
provider: z.literal("langgraph-platform"),
|
|
2551
|
+
/** Control-plane region. Omit for `us`. Mutually exclusive with `baseUrl`. */
|
|
2552
|
+
region: z.enum(LANGGRAPH_PLATFORM_REGIONS).optional(),
|
|
2553
|
+
/**
|
|
2554
|
+
* Self-hosted LangSmith control-plane root, e.g.
|
|
2555
|
+
* `https://langsmith.acme.com/api-host`. Omit for LangChain's own hosts.
|
|
2556
|
+
* Must be `https:` — this URL carries the API key.
|
|
2557
|
+
*/
|
|
2558
|
+
baseUrl: z.string().trim().max(300).refine((value) => {
|
|
2559
|
+
let parsed;
|
|
2560
|
+
try {
|
|
2561
|
+
parsed = new URL(value);
|
|
2562
|
+
} catch {
|
|
2563
|
+
return false;
|
|
2564
|
+
}
|
|
2565
|
+
return parsed.protocol === "https:" && !parsed.username && !parsed.password;
|
|
2566
|
+
}, "must be an https:// URL — the self-hosted control-plane root, e.g. https://langsmith.acme.com/api-host").optional(),
|
|
2567
|
+
/** LangSmith workspace (tenant) UUID, sent as `X-Tenant-Id`. */
|
|
2568
|
+
tenantId: 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 LangSmith workspace UUID").optional()
|
|
2569
|
+
}).refine((c) => !(c.baseUrl !== void 0 && c.region !== void 0), {
|
|
2570
|
+
message: "set region for a LangChain-hosted account or baseUrl for a self-hosted one, not both",
|
|
2571
|
+
path: ["baseUrl"]
|
|
2572
|
+
});
|
|
1993
2573
|
const syncConnectionConfigSchema = z.discriminatedUnion("provider", [
|
|
1994
2574
|
vercelConnectionConfigSchema,
|
|
1995
2575
|
cloudflareWorkersConnectionConfigSchema,
|
|
@@ -2006,7 +2586,8 @@ const syncConnectionConfigSchema = z.discriminatedUnion("provider", [
|
|
|
2006
2586
|
netlifyConnectionConfigSchema,
|
|
2007
2587
|
bunnyshellConnectionConfigSchema,
|
|
2008
2588
|
githubActionsConnectionConfigSchema,
|
|
2009
|
-
gcpSecretManagerConnectionConfigSchema
|
|
2589
|
+
gcpSecretManagerConnectionConfigSchema,
|
|
2590
|
+
langgraphPlatformConnectionConfigSchema
|
|
2010
2591
|
]);
|
|
2011
2592
|
/** Vercel's three deployment targets. A binding writes to one or more. */
|
|
2012
2593
|
const VERCEL_TARGETS = [
|
|
@@ -2717,6 +3298,27 @@ const gcpSecretManagerDestinationSchema = z.object({
|
|
|
2717
3298
|
message: "a customer-managed key covers one location — use automatic replication, or a single location",
|
|
2718
3299
|
path: ["kmsKeyName"]
|
|
2719
3300
|
});
|
|
3301
|
+
/**
|
|
3302
|
+
* One LangGraph Platform (Agent Server) **deployment**, addressed by its id.
|
|
3303
|
+
*
|
|
3304
|
+
* A deployment is the whole unit here: its secrets are a property of the
|
|
3305
|
+
* deployment, delivered to the agent container as environment variables, and
|
|
3306
|
+
* there is nothing finer to point at — no per-revision or per-graph scope, and
|
|
3307
|
+
* no equivalent of Vercel's `production`/`preview` split. A deployment that
|
|
3308
|
+
* needs different values is a different deployment, so it is a different
|
|
3309
|
+
* binding.
|
|
3310
|
+
*
|
|
3311
|
+
* Validated as a UUID because `PATCH /v2/deployments/{deployment_id}` declares
|
|
3312
|
+
* the path parameter as one: a name or a URL slug in the slot fails validation
|
|
3313
|
+
* at the control plane hours later inside an alarm, with nobody watching. It is
|
|
3314
|
+
* the `id` from `GET /v2/deployments`, and the UUID in the deployment's
|
|
3315
|
+
* dashboard URL.
|
|
3316
|
+
*/
|
|
3317
|
+
const langgraphPlatformDestinationSchema = z.object({
|
|
3318
|
+
provider: z.literal("langgraph-platform"),
|
|
3319
|
+
/** Deployment UUID, from the dashboard URL or `GET /v2/deployments`. */
|
|
3320
|
+
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")
|
|
3321
|
+
});
|
|
2720
3322
|
const syncDestinationSchema = z.discriminatedUnion("provider", [
|
|
2721
3323
|
vercelDestinationSchema,
|
|
2722
3324
|
cloudflareWorkersDestinationSchema,
|
|
@@ -2733,7 +3335,8 @@ const syncDestinationSchema = z.discriminatedUnion("provider", [
|
|
|
2733
3335
|
netlifyDestinationSchema,
|
|
2734
3336
|
bunnyshellDestinationSchema,
|
|
2735
3337
|
githubActionsDestinationSchema,
|
|
2736
|
-
gcpSecretManagerDestinationSchema
|
|
3338
|
+
gcpSecretManagerDestinationSchema,
|
|
3339
|
+
langgraphPlatformDestinationSchema
|
|
2737
3340
|
]);
|
|
2738
3341
|
/**
|
|
2739
3342
|
* How seekrit secret names become destination key names. Applied in order:
|
|
@@ -3371,6 +3974,76 @@ async function decryptPrivateKey(passphrase, blob) {
|
|
|
3371
3974
|
}
|
|
3372
3975
|
}
|
|
3373
3976
|
//#endregion
|
|
3977
|
+
//#region ../../packages/crypto/src/policy-key.ts
|
|
3978
|
+
/**
|
|
3979
|
+
* Signing with a principal's **existing** keypair, for agent access policy.
|
|
3980
|
+
*
|
|
3981
|
+
* Policy bundles are signed in the browser so the API can serve a blob it cannot
|
|
3982
|
+
* forge (`docs/agent-access-governance.md` §1). Every user already has a P-256
|
|
3983
|
+
* keypair whose private half is passphrase-encrypted and opaque to the server
|
|
3984
|
+
* (`users.public_key_jwk`), so this feature needs **no new key material**: no
|
|
3985
|
+
* second passphrase, no wrapping, no schema, and nothing extra for an admin to
|
|
3986
|
+
* lose. That was a deliberate condition of the design.
|
|
3987
|
+
*
|
|
3988
|
+
* The catch is that WebCrypto keys are algorithm-bound. A principal key is
|
|
3989
|
+
* imported for ECDH (`deriveBits`) and cannot sign, even though the underlying
|
|
3990
|
+
* curve is the same one ECDSA uses. So the JWK is re-imported here with the
|
|
3991
|
+
* algorithm hints stripped — the same private scalar, presented as an ECDSA key.
|
|
3992
|
+
*
|
|
3993
|
+
* **The tradeoff, stated plainly:** this reuses one key for two algorithms,
|
|
3994
|
+
* which key-management hygiene (NIST SP 800-57 §5.2) advises against. We accept
|
|
3995
|
+
* it because the alternative — a second keypair per admin — is the kind of
|
|
3996
|
+
* ceremony that gets skipped, and because both uses stay inside the same trust
|
|
3997
|
+
* boundary: the key already authorizes reading every secret the admin can read,
|
|
3998
|
+
* so a signature capability adds no reach an attacker holding it wouldn't have.
|
|
3999
|
+
* If a future version wants separation, the clean path is a managed KMS `sign`
|
|
4000
|
+
* key granted to publishers — the thumbprint pinning in the proxy works
|
|
4001
|
+
* unchanged, which is why the format carries the key rather than a user id.
|
|
4002
|
+
*/
|
|
4003
|
+
const ECDSA_PARAMS$1 = {
|
|
4004
|
+
name: "ECDSA",
|
|
4005
|
+
namedCurve: "P-256"
|
|
4006
|
+
};
|
|
4007
|
+
/**
|
|
4008
|
+
* Re-import a principal's private key JWK as an ECDSA signing key.
|
|
4009
|
+
*
|
|
4010
|
+
* `key_ops`, `alg`, and `use` are dropped: they say "ECDH" on a principal key,
|
|
4011
|
+
* and WebCrypto refuses an import whose declared operations don't include the
|
|
4012
|
+
* requested usage. Everything that determines the key — `crv`, `d`, `x`, `y` —
|
|
4013
|
+
* is passed through untouched.
|
|
4014
|
+
*/
|
|
4015
|
+
async function importPolicySigningKey(privateKeyJwk) {
|
|
4016
|
+
const jwk = JSON.parse(privateKeyJwk);
|
|
4017
|
+
if (jwk.kty !== "EC" || jwk.crv !== "P-256") throw new SeekritCryptoError("MALFORMED_BLOB", "policy signing needs an EC P-256 principal key");
|
|
4018
|
+
if (!jwk.d) throw new SeekritCryptoError("MALFORMED_BLOB", "policy signing needs the private half of the key");
|
|
4019
|
+
const { kty, crv, d, x, y } = jwk;
|
|
4020
|
+
return crypto.subtle.importKey("jwk", {
|
|
4021
|
+
kty,
|
|
4022
|
+
crv,
|
|
4023
|
+
d,
|
|
4024
|
+
x,
|
|
4025
|
+
y
|
|
4026
|
+
}, ECDSA_PARAMS$1, false, ["sign"]);
|
|
4027
|
+
}
|
|
4028
|
+
/**
|
|
4029
|
+
* Trim a principal's public key JWK to the members a policy bundle carries.
|
|
4030
|
+
*
|
|
4031
|
+
* The bundle names its signer by thumbprint, which is computed over exactly
|
|
4032
|
+
* these four members — so anything else in the stored JWK (`key_ops`, `ext`,
|
|
4033
|
+
* `alg`) must be dropped here, or the thumbprint an admin pins would depend on
|
|
4034
|
+
* incidental fields.
|
|
4035
|
+
*/
|
|
4036
|
+
function policySignerJwk(publicKeyJwk) {
|
|
4037
|
+
const jwk = JSON.parse(publicKeyJwk);
|
|
4038
|
+
if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.x || !jwk.y) throw new SeekritCryptoError("MALFORMED_BLOB", "not an EC P-256 public key");
|
|
4039
|
+
return {
|
|
4040
|
+
kty: "EC",
|
|
4041
|
+
crv: "P-256",
|
|
4042
|
+
x: jwk.x,
|
|
4043
|
+
y: jwk.y
|
|
4044
|
+
};
|
|
4045
|
+
}
|
|
4046
|
+
//#endregion
|
|
3374
4047
|
//#region ../../packages/crypto/src/shamir.ts
|
|
3375
4048
|
/**
|
|
3376
4049
|
* Shamir's Secret Sharing over GF(2^8) — the same field AES uses, with the
|
|
@@ -3864,10 +4537,13 @@ function encodeOpensshPrivateKey(seed, pub, comment) {
|
|
|
3864
4537
|
*/
|
|
3865
4538
|
const TOKEN_PREFIX = "skt";
|
|
3866
4539
|
const CLI_SESSION_PREFIX = "skc";
|
|
4540
|
+
const TASK_PREFIX = "skd";
|
|
3867
4541
|
const TOKEN_ID_LENGTH = 22;
|
|
3868
4542
|
const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
3869
4543
|
/** 32 bytes of entropy for the CLI session secret. */
|
|
3870
4544
|
const CLI_SESSION_SECRET_BYTES = 32;
|
|
4545
|
+
/** 32 bytes for a task token's secret, matching the CLI session. */
|
|
4546
|
+
const TASK_SECRET_BYTES = 32;
|
|
3871
4547
|
function randomTokenId(prefix = TOKEN_PREFIX) {
|
|
3872
4548
|
let out = "";
|
|
3873
4549
|
while (out.length < TOKEN_ID_LENGTH) {
|
|
@@ -3937,9 +4613,18 @@ function parseCliSessionToken(token) {
|
|
|
3937
4613
|
function isCliSessionToken(value) {
|
|
3938
4614
|
return value.startsWith(`${CLI_SESSION_PREFIX}_`);
|
|
3939
4615
|
}
|
|
4616
|
+
async function createAgentTaskToken() {
|
|
4617
|
+
const taskRef = randomTokenId(TASK_PREFIX);
|
|
4618
|
+
const token = `${taskRef}_${toBase64Url(crypto.getRandomValues(new Uint8Array(TASK_SECRET_BYTES)))}`;
|
|
4619
|
+
return {
|
|
4620
|
+
token,
|
|
4621
|
+
taskRef,
|
|
4622
|
+
tokenHash: await hashToken(token)
|
|
4623
|
+
};
|
|
4624
|
+
}
|
|
3940
4625
|
//#endregion
|
|
3941
4626
|
//#region package.json
|
|
3942
|
-
var version = "0.
|
|
4627
|
+
var version = "0.44.0";
|
|
3943
4628
|
//#endregion
|
|
3944
4629
|
//#region ../../packages/api-client/src/index.ts
|
|
3945
4630
|
var SeekritApiError = class extends Error {
|
|
@@ -4299,6 +4984,77 @@ var SeekritClient = class {
|
|
|
4299
4984
|
getMyPolicySigner(orgId) {
|
|
4300
4985
|
return this.request("GET", `/v1/orgs/${orgId}/agents/signers/me`);
|
|
4301
4986
|
}
|
|
4987
|
+
/**
|
|
4988
|
+
* The bundle a proxy would see — `GET /v1/agents/:ref/policy`, the same route
|
|
4989
|
+
* `seekrit-proxy` polls, resolved by agent id or slug.
|
|
4990
|
+
*
|
|
4991
|
+
* Not org-scoped, because the caller is not: a proxy holds a service token that
|
|
4992
|
+
* knows an agent slug and nothing about org ids. Reachable with any service
|
|
4993
|
+
* token bound to the agent's org (or a user session), which is what lets
|
|
4994
|
+
* `seekrit proxy init` generate a config on the machine that holds the proxy's
|
|
4995
|
+
* own token rather than requiring an admin credential there.
|
|
4996
|
+
*
|
|
4997
|
+
* The `bundle` is signed and opaque to the API. Anything that *acts* on it must
|
|
4998
|
+
* verify the signature against locally pinned signers; decoding it for display
|
|
4999
|
+
* or to name a route is not acting on it.
|
|
5000
|
+
*/
|
|
5001
|
+
getAgentPolicyBundle(agentRef) {
|
|
5002
|
+
return this.request("GET", `/v1/agents/${encodeURIComponent(agentRef)}/policy`);
|
|
5003
|
+
}
|
|
5004
|
+
/**
|
|
5005
|
+
* Dispatch a task for one agent run.
|
|
5006
|
+
*
|
|
5007
|
+
* The caller mints the token (`createAgentTaskToken` in `@seekrit/crypto`) and
|
|
5008
|
+
* sends only its hash plus the public `skd_…` segment, so no presentable
|
|
5009
|
+
* credential ever reaches this API — the same shape as service-token and CLI
|
|
5010
|
+
* session creation. `scopes` may only narrow what the agent's published policy
|
|
5011
|
+
* already permits; a name outside it is refused rather than dropped.
|
|
5012
|
+
*
|
|
5013
|
+
* Not org-scoped, because an orchestrator is not: it knows an agent slug.
|
|
5014
|
+
*/
|
|
5015
|
+
dispatchAgentTask(agentRef, input) {
|
|
5016
|
+
return this.request("POST", `/v1/agents/${encodeURIComponent(agentRef)}/dispatch`, input);
|
|
5017
|
+
}
|
|
5018
|
+
/**
|
|
5019
|
+
* Exchange a presented token for the session it authorizes — what an
|
|
5020
|
+
* enforcement point calls once per task and caches until expiry.
|
|
5021
|
+
*
|
|
5022
|
+
* A POST because the token is a credential and must not land in a URL or an
|
|
5023
|
+
* access log. Fails closed and says which way: revoked, expired, or a disabled
|
|
5024
|
+
* identity are three different answers.
|
|
5025
|
+
*/
|
|
5026
|
+
introspectAgentTask(token) {
|
|
5027
|
+
return this.request("POST", "/v1/tasks/introspect", { token });
|
|
5028
|
+
}
|
|
5029
|
+
/** End a run's authority now. Idempotent. */
|
|
5030
|
+
revokeAgentTask(taskId) {
|
|
5031
|
+
return this.request("POST", `/v1/tasks/${taskId}/revoke`);
|
|
5032
|
+
}
|
|
5033
|
+
getAgentTask(taskId) {
|
|
5034
|
+
return this.request("GET", `/v1/tasks/${taskId}`);
|
|
5035
|
+
}
|
|
5036
|
+
/** Runs dispatched for one identity, newest first (admin). */
|
|
5037
|
+
listAgentTasks(orgId, agentId) {
|
|
5038
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}/tasks`);
|
|
5039
|
+
}
|
|
5040
|
+
/**
|
|
5041
|
+
* Report aggregate decisions. Called by an enforcement point, not a person.
|
|
5042
|
+
*
|
|
5043
|
+
* Counts only — hosts, methods, secret *names*, decisions, and rule indices.
|
|
5044
|
+
* Never a request path: see the module comment in `agent-activity.ts` for why
|
|
5045
|
+
* that line is drawn where it is.
|
|
5046
|
+
*/
|
|
5047
|
+
reportAgentActivity(agentRef, input) {
|
|
5048
|
+
return this.request("POST", `/v1/agents/${encodeURIComponent(agentRef)}/activity`, input);
|
|
5049
|
+
}
|
|
5050
|
+
/**
|
|
5051
|
+
* What an agent actually did, collapsed onto its dimensions — the evidence a
|
|
5052
|
+
* grant review reasons over. The proposals themselves are computed client-side
|
|
5053
|
+
* (`reviewPolicy` in `@seekrit/core`), so the API never opines on policy.
|
|
5054
|
+
*/
|
|
5055
|
+
getAgentActivity(orgId, agentId, days = 14) {
|
|
5056
|
+
return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}/activity?days=${encodeURIComponent(String(days))}`);
|
|
5057
|
+
}
|
|
4302
5058
|
/** Keys the caller can see: all org keys for admins, granted keys otherwise. */
|
|
4303
5059
|
listKmsKeys(orgId) {
|
|
4304
5060
|
return this.request("GET", `/v1/orgs/${orgId}/kms/keys`);
|
|
@@ -4610,6 +5366,24 @@ function fail(message) {
|
|
|
4610
5366
|
console.error(`error: ${message}`);
|
|
4611
5367
|
process.exit(1);
|
|
4612
5368
|
}
|
|
5369
|
+
/**
|
|
5370
|
+
* Parse a duration flag like `30m`, `24h`, `90d`, or a bare seconds count.
|
|
5371
|
+
*
|
|
5372
|
+
* Lives here rather than in a command module because more than one command
|
|
5373
|
+
* takes a duration and they must agree: `--every 7d` and `--ttl 7d` meaning
|
|
5374
|
+
* different things would be a nasty surprise. Invalid input is a flag error, so
|
|
5375
|
+
* it exits through `fail` with the accepted forms named.
|
|
5376
|
+
*/
|
|
5377
|
+
function parseDurationSeconds(input, flag) {
|
|
5378
|
+
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
5379
|
+
if (!m) fail(`invalid ${flag} "${input}" (try 12h, 7d, 90d)`);
|
|
5380
|
+
return Number(m[1]) * ({
|
|
5381
|
+
s: 1,
|
|
5382
|
+
m: 60,
|
|
5383
|
+
h: 3600,
|
|
5384
|
+
d: 86400
|
|
5385
|
+
}[m[2] || "s"] ?? 1);
|
|
5386
|
+
}
|
|
4613
5387
|
/** Prompt without echoing input (for passphrases). */
|
|
4614
5388
|
function promptHidden(question) {
|
|
4615
5389
|
const muted = new Writable({ write(_chunk, _encoding, callback) {
|
|
@@ -4745,6 +5519,31 @@ async function getPrivateKey(ctx) {
|
|
|
4745
5519
|
const { encryptedPrivateKey } = await ctx.client.getMyKeys();
|
|
4746
5520
|
return importPrivateKey(await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey));
|
|
4747
5521
|
}
|
|
5522
|
+
/**
|
|
5523
|
+
* Recover the caller's **policy signing** key: the same P-256 principal key,
|
|
5524
|
+
* re-imported for ECDSA rather than ECDH. The dashboard does exactly this in
|
|
5525
|
+
* `keyring.getPolicySigner`, so a bundle signed here and one signed in a browser
|
|
5526
|
+
* are indistinguishable — same key, same thumbprint, same pin in a proxy config.
|
|
5527
|
+
*
|
|
5528
|
+
* Service tokens are refused *here* rather than at the API, so the reason is
|
|
5529
|
+
* legible at the point of use: publishing policy is deliberately gated on a
|
|
5530
|
+
* human's key, because an agent that can widen its own authorization is not
|
|
5531
|
+
* governed by it. See the module comment in `apps/api/src/routes/agents.ts`.
|
|
5532
|
+
*
|
|
5533
|
+
* The honest caveat is `SEEKRIT_PASSPHRASE`: where it is set, anything that can
|
|
5534
|
+
* read the environment can sign. That is already true of every other CLI
|
|
5535
|
+
* decryption, but it matters more here, so the docs say to leave it unset on any
|
|
5536
|
+
* machine an agent shares.
|
|
5537
|
+
*/
|
|
5538
|
+
async function getPolicySigner(ctx) {
|
|
5539
|
+
if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) fail("publishing agent policy needs a human's signing key — sign in with `seekrit login` (a service token cannot publish, by design)");
|
|
5540
|
+
const { encryptedPrivateKey } = await ctx.client.getMyKeys();
|
|
5541
|
+
const privateKeyJwk = await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey);
|
|
5542
|
+
return {
|
|
5543
|
+
signingKey: await importPolicySigningKey(privateKeyJwk),
|
|
5544
|
+
jwk: policySignerJwk(privateKeyJwk)
|
|
5545
|
+
};
|
|
5546
|
+
}
|
|
4748
5547
|
/** Recover one environment's DEK for the current principal. */
|
|
4749
5548
|
async function getDek(ctx, orgId, envId) {
|
|
4750
5549
|
const [{ wrappedDek }, privateKey] = await Promise.all([ctx.client.getMyEnvKey(orgId, envId), getPrivateKey(ctx)]);
|
|
@@ -5075,36 +5874,673 @@ function registerAccountCommands(program) {
|
|
|
5075
5874
|
});
|
|
5076
5875
|
}
|
|
5077
5876
|
//#endregion
|
|
5078
|
-
//#region src/
|
|
5079
|
-
/**
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5877
|
+
//#region src/agents.ts
|
|
5878
|
+
/**
|
|
5879
|
+
* Agent access governance from the CLI.
|
|
5880
|
+
*
|
|
5881
|
+
* Policy lived only in the dashboard until now, which was backwards: the people
|
|
5882
|
+
* deploying agents work in a terminal and a repo, and a rule set is exactly the
|
|
5883
|
+
* kind of thing that wants review and version control. So the loop this module
|
|
5884
|
+
* exists for is:
|
|
5885
|
+
*
|
|
5886
|
+
* seekrit agents policy pull nova -o nova.policy.json # current rules
|
|
5887
|
+
* $EDITOR nova.policy.json # commit it, review it
|
|
5888
|
+
* seekrit agents policy publish nova -f nova.policy.json
|
|
5889
|
+
*
|
|
5890
|
+
* **Signing still happens here, on this machine, with the operator's own key**
|
|
5891
|
+
* (`getPolicySigner` in `context.ts`), so nothing about the trust argument in
|
|
5892
|
+
* `docs/agent-access-governance.md` §1 changes: the API receives an opaque
|
|
5893
|
+
* envelope it cannot forge, and a proxy verifies it against thumbprints pinned in
|
|
5894
|
+
* its own local file. A service token is refused — an agent that can publish its
|
|
5895
|
+
* own policy is not governed by it.
|
|
5896
|
+
*
|
|
5897
|
+
* Everything else here is read-only, and two commands are deliberately *local*
|
|
5898
|
+
* evaluations rather than API calls:
|
|
5899
|
+
*
|
|
5900
|
+
* - `simulate` runs `evaluatePolicy` from `@seekrit/core`, the mirror of
|
|
5901
|
+
* `RuleSet::decide` in the proxy — so a dry run and a real refusal give the
|
|
5902
|
+
* same verdict in the same words.
|
|
5903
|
+
* - `fetch` asks for the bytes a proxy would get (`GET /v1/agents/:ref/policy`)
|
|
5904
|
+
* and re-derives the signer thumbprint from the bundle itself, rather than
|
|
5905
|
+
* trusting the field the API echoes beside it.
|
|
5906
|
+
*/
|
|
5907
|
+
/** How many published versions the API returns per page. */
|
|
5908
|
+
const POLICY_PAGE = 50;
|
|
5909
|
+
/** The `-` sentinel every other seekrit command uses to mean stdin. */
|
|
5910
|
+
const STDIN = "-";
|
|
5911
|
+
/**
|
|
5912
|
+
* Resolve `nova`, or `agt_…`, to an identity.
|
|
5913
|
+
*
|
|
5914
|
+
* The org-scoped API routes take an id, but nobody types ids — so a slug is
|
|
5915
|
+
* resolved from the list. The list is small (agents are a per-deployment thing,
|
|
5916
|
+
* not a per-request one), which is why this costs one request rather than
|
|
5917
|
+
* needing a lookup route.
|
|
5918
|
+
*/
|
|
5919
|
+
async function resolveAgent(ctx, orgId, ref) {
|
|
5920
|
+
const { agents } = await ctx.client.listAgents(orgId);
|
|
5921
|
+
const found = agents.find((a) => a.slug === ref || a.id === ref);
|
|
5922
|
+
if (!found) {
|
|
5923
|
+
const known = agents.map((a) => a.slug).join(", ");
|
|
5924
|
+
fail(`no agent "${ref}"${known ? ` — this org has: ${known}` : " in this org"}`);
|
|
5094
5925
|
}
|
|
5095
|
-
|
|
5096
|
-
if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
|
|
5097
|
-
return {
|
|
5098
|
-
principalType: "user",
|
|
5099
|
-
principalId: user.id,
|
|
5100
|
-
publicKeyJwk: user.publicKeyJwk
|
|
5101
|
-
};
|
|
5926
|
+
return found;
|
|
5102
5927
|
}
|
|
5103
|
-
/**
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5928
|
+
/**
|
|
5929
|
+
* One published version: the newest by default, or an explicit `--version`.
|
|
5930
|
+
*
|
|
5931
|
+
* An agent with no published policy is a real state, not an error — a proxy for
|
|
5932
|
+
* it fails closed — so the caller decides how to report it.
|
|
5933
|
+
*/
|
|
5934
|
+
async function loadVersion(ctx, orgId, agent, version) {
|
|
5935
|
+
const { policies } = await ctx.client.listAgentPolicies(orgId, agent.id);
|
|
5936
|
+
if (version === void 0) return policies[0] ?? null;
|
|
5937
|
+
const found = policies.find((p) => p.version === version);
|
|
5938
|
+
if (!found) fail(policies.length >= POLICY_PAGE ? `no version ${version} in the last ${POLICY_PAGE} published` : `no version ${version} — published: ${policies.map((p) => p.version).join(", ") || "none"}`);
|
|
5939
|
+
return found;
|
|
5940
|
+
}
|
|
5941
|
+
/**
|
|
5942
|
+
* Read a rule file: either a bare array or `{ "rules": [...] }`, from a path or
|
|
5943
|
+
* stdin. Both shapes are accepted because `policy pull` writes the second and
|
|
5944
|
+
* hand-written files tend to be the first.
|
|
5945
|
+
*
|
|
5946
|
+
* Validation is the same zod schema the API and the dashboard use, so a file
|
|
5947
|
+
* rejected here would have been rejected there — before anything is signed.
|
|
5948
|
+
*
|
|
5949
|
+
* Exported for its own test: this is the one place a hand-written file meets the
|
|
5950
|
+
* schema, and its error messages are the whole user experience of a typo.
|
|
5951
|
+
*/
|
|
5952
|
+
function parseRuleFile(raw, source) {
|
|
5953
|
+
let parsed;
|
|
5954
|
+
try {
|
|
5955
|
+
parsed = JSON.parse(raw);
|
|
5956
|
+
} catch (err) {
|
|
5957
|
+
fail(`${source} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
5958
|
+
}
|
|
5959
|
+
const list = Array.isArray(parsed) ? parsed : parsed?.rules === void 0 ? fail(`${source} must be a JSON array of rules, or an object with a "rules" array`) : parsed.rules;
|
|
5960
|
+
if (!Array.isArray(list)) fail(`${source}: "rules" must be an array`);
|
|
5961
|
+
if (list.length === 0) console.error(`note: ${source} has no rules — publishing it denies every request`);
|
|
5962
|
+
return list.map((rule, i) => {
|
|
5963
|
+
const result = agentPolicyRuleSchema.safeParse(rule);
|
|
5964
|
+
if (!result.success) {
|
|
5965
|
+
const first = result.error.issues[0];
|
|
5966
|
+
fail(`${source}: rule ${i + 1} is invalid — ${first?.path.join(".") || "rule"}: ${first?.message ?? "unknown error"}`);
|
|
5967
|
+
}
|
|
5968
|
+
return result.data;
|
|
5969
|
+
});
|
|
5970
|
+
}
|
|
5971
|
+
/** `--file path` or `--file -`. */
|
|
5972
|
+
async function readRuleSource(file) {
|
|
5973
|
+
if (file === STDIN) return {
|
|
5974
|
+
raw: await readStdin(),
|
|
5975
|
+
label: "stdin"
|
|
5976
|
+
};
|
|
5977
|
+
try {
|
|
5978
|
+
return {
|
|
5979
|
+
raw: readFileSync(file, "utf8"),
|
|
5980
|
+
label: file
|
|
5981
|
+
};
|
|
5982
|
+
} catch (err) {
|
|
5983
|
+
return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
5984
|
+
}
|
|
5985
|
+
}
|
|
5986
|
+
/**
|
|
5987
|
+
* An empty `methods` or `paths` list means *any*: those fields narrow a rule that
|
|
5988
|
+
* already matched its host, so absent means unconstrained. A blank column would
|
|
5989
|
+
* read as the opposite, so it renders as `any`.
|
|
5990
|
+
*
|
|
5991
|
+
* Exported for its own test — this is a real invariant of the format, not a
|
|
5992
|
+
* cosmetic choice.
|
|
5993
|
+
*/
|
|
5994
|
+
function describeList(values) {
|
|
5995
|
+
return values.length === 0 ? "any" : values.join(" ");
|
|
5996
|
+
}
|
|
5997
|
+
/**
|
|
5998
|
+
* `allow` is the field where empty means the **opposite** — no secret may be
|
|
5999
|
+
* injected toward this host at all (`Decision::SecretNotAllowed` for every name;
|
|
6000
|
+
* see `crates/seekrit-core/src/policy.rs`). Such a rule is useful and
|
|
6001
|
+
* intentional: it permits the *request* while granting no credential, which is
|
|
6002
|
+
* how you let an agent read a public API through the proxy without handing it a
|
|
6003
|
+
* key. Rendering that as `any` would invert the meaning of the one column an
|
|
6004
|
+
* operator reviews most carefully, so it gets its own function.
|
|
6005
|
+
*/
|
|
6006
|
+
function describeSecrets(values) {
|
|
6007
|
+
return values.length === 0 ? "none" : values.join(" ");
|
|
6008
|
+
}
|
|
6009
|
+
function printRules(rules) {
|
|
6010
|
+
printTable(rules.map((rule, i) => ({
|
|
6011
|
+
n: i + 1,
|
|
6012
|
+
rule
|
|
6013
|
+
})), [
|
|
6014
|
+
col("#", (r) => r.n),
|
|
6015
|
+
col("host", (r) => r.rule.host),
|
|
6016
|
+
col("methods", (r) => describeList(r.rule.methods)),
|
|
6017
|
+
col("paths", (r) => describeList(r.rule.paths)),
|
|
6018
|
+
col("secrets", (r) => describeSecrets(r.rule.allow)),
|
|
6019
|
+
col("label", (r) => r.rule.label ?? "-")
|
|
6020
|
+
], "no rules — this policy denies every request");
|
|
6021
|
+
}
|
|
6022
|
+
/** One rule as a single line, for the publish diff. */
|
|
6023
|
+
function ruleLine(rule) {
|
|
6024
|
+
const parts = [
|
|
6025
|
+
rule.host,
|
|
6026
|
+
describeList(rule.methods),
|
|
6027
|
+
describeList(rule.paths),
|
|
6028
|
+
`secrets=${describeSecrets(rule.allow)}`
|
|
6029
|
+
];
|
|
6030
|
+
return rule.label ? `${parts.join(" ")} (${rule.label})` : parts.join(" ");
|
|
6031
|
+
}
|
|
6032
|
+
/**
|
|
6033
|
+
* The change a publish would make. Rules are compared by *position* because
|
|
6034
|
+
* order decides — first match wins — so a reordering is a real change and shows
|
|
6035
|
+
* up as one.
|
|
6036
|
+
*/
|
|
6037
|
+
function printPolicyDiff(before, after) {
|
|
6038
|
+
const changes = diffPolicyRules(before, after).filter((c) => c.kind !== "unchanged");
|
|
6039
|
+
if (changes.length === 0) {
|
|
6040
|
+
console.error(after.length === 0 ? "no rules — this policy denies every request" : "no rule changes — publishing would only extend the expiry");
|
|
6041
|
+
return;
|
|
6042
|
+
}
|
|
6043
|
+
for (const change of changes) {
|
|
6044
|
+
const n = change.index + 1;
|
|
6045
|
+
if (change.kind === "added") console.log(`+ ${n} ${ruleLine(change.after)}`);
|
|
6046
|
+
else if (change.kind === "removed") console.log(`- ${n} ${ruleLine(change.before)}`);
|
|
6047
|
+
else {
|
|
6048
|
+
console.log(`- ${n} ${ruleLine(change.before)}`);
|
|
6049
|
+
console.log(`+ ${n} ${ruleLine(change.after)}`);
|
|
6050
|
+
}
|
|
6051
|
+
}
|
|
6052
|
+
}
|
|
6053
|
+
/** Seconds until an ISO instant, or a negative number when it has passed. */
|
|
6054
|
+
function secondsUntil(iso) {
|
|
6055
|
+
return Math.round((new Date(iso).getTime() - Date.now()) / 1e3);
|
|
6056
|
+
}
|
|
6057
|
+
/** Collect a repeated `--scope NAME` into a list. */
|
|
6058
|
+
function collectScope(value, acc = []) {
|
|
6059
|
+
acc.push(value);
|
|
6060
|
+
return acc;
|
|
6061
|
+
}
|
|
6062
|
+
/**
|
|
6063
|
+
* A task's state, derived rather than stored: `revoked` beats `expired`, so a run
|
|
6064
|
+
* somebody killed does not read as one that merely lapsed.
|
|
6065
|
+
*
|
|
6066
|
+
* Exported for its own test — it mirrors `taskState` in
|
|
6067
|
+
* `apps/api/src/lib/agent-tasks.ts`, and the two disagreeing would mean the list
|
|
6068
|
+
* screen and the enforcement point describe the same run differently.
|
|
6069
|
+
*/
|
|
6070
|
+
function taskStateOf(task, now) {
|
|
6071
|
+
if (task.revokedAt) return "revoked";
|
|
6072
|
+
return Date.parse(task.expiresAt) <= now ? "expired" : "active";
|
|
6073
|
+
}
|
|
6074
|
+
/**
|
|
6075
|
+
* A detail view on **stderr**, so a command whose stdout is a credential can
|
|
6076
|
+
* still explain itself to a person without corrupting `$(…)`.
|
|
6077
|
+
*/
|
|
6078
|
+
function printFieldsToStderr(fields) {
|
|
6079
|
+
const present = fields.filter(([, value]) => value !== null && value !== void 0);
|
|
6080
|
+
const width = Math.max(...present.map(([label]) => label.length));
|
|
6081
|
+
for (const [label, value] of present) console.error(`${label.padEnd(width)} ${value}`);
|
|
6082
|
+
}
|
|
6083
|
+
/**
|
|
6084
|
+
* Exported for its own test: the expired branch is the one that must be loud.
|
|
6085
|
+
*
|
|
6086
|
+
* The granularity has to span both users of this — a policy bundle lasting a
|
|
6087
|
+
* week and a task lasting fifteen minutes. Rounding a task to the nearest hour
|
|
6088
|
+
* printed "in 0h", which is worse than no estimate at all.
|
|
6089
|
+
*/
|
|
6090
|
+
function describeExpiry(iso) {
|
|
6091
|
+
const seconds = secondsUntil(iso);
|
|
6092
|
+
if (seconds <= 0) return `${iso} (EXPIRED — proxies for this agent fail closed)`;
|
|
6093
|
+
const days = Math.floor(seconds / 86400);
|
|
6094
|
+
const hours = Math.floor(seconds % 86400 / 3600);
|
|
6095
|
+
const minutes = Math.floor(seconds % 3600 / 60);
|
|
6096
|
+
if (days > 0) return `${iso} (in ${days}d ${hours}h)`;
|
|
6097
|
+
if (hours > 0) return `${iso} (in ${hours}h ${minutes}m)`;
|
|
6098
|
+
if (minutes > 0) return `${iso} (in ${minutes}m)`;
|
|
6099
|
+
return `${iso} (in ${seconds}s)`;
|
|
6100
|
+
}
|
|
6101
|
+
function registerAgentCommands(program) {
|
|
6102
|
+
const agents = program.command("agents").description("agent access policy — which agent may reach which upstream with which secret");
|
|
6103
|
+
agents.command("list", { isDefault: true }).description("list agent identities").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (options) => {
|
|
6104
|
+
const ctx = buildContext();
|
|
6105
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6106
|
+
const { agents: rows } = await ctx.client.listAgents(org.id);
|
|
6107
|
+
emit(options, { agents: rows }, () => printTable(rows, [
|
|
6108
|
+
col("slug", (a) => a.slug),
|
|
6109
|
+
col("name", (a) => a.name),
|
|
6110
|
+
col("policy", (a) => a.currentPolicyVersion === 0 ? "none" : `v${a.currentPolicyVersion}`),
|
|
6111
|
+
col("enabled", (a) => a.enabled ? "yes" : "no"),
|
|
6112
|
+
col("last fetch", (a) => a.lastPolicyFetchAt ?? "never")
|
|
6113
|
+
], "no agent identities — create one with `seekrit agents create`"));
|
|
6114
|
+
});
|
|
6115
|
+
agents.command("create <name>").description("create an agent identity (a policy subject; it holds no key material)").requiredOption("--slug <slug>", "short name a proxy config and a ticket refer to").option("--org <slug>", "organization").option("--app <slug>", "application, when scoping the agent to one environment").option("--env <slug>", "environment whose secret names its rules may reference").option("--json", "machine-readable output").action(async (name, options) => {
|
|
6116
|
+
const ctx = buildContext();
|
|
6117
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6118
|
+
const environmentId = options.env ? (await resolveAppEnv(ctx, {
|
|
6119
|
+
org: options.org,
|
|
6120
|
+
app: options.app,
|
|
6121
|
+
env: options.env
|
|
6122
|
+
})).envId : void 0;
|
|
6123
|
+
const { agent } = await ctx.client.createAgent(org.id, {
|
|
6124
|
+
name,
|
|
6125
|
+
slug: options.slug,
|
|
6126
|
+
...environmentId ? { environmentId } : {}
|
|
6127
|
+
});
|
|
6128
|
+
emit(options, { agent }, () => {
|
|
6129
|
+
console.error(`created ${agent.slug} — no policy published yet, so any proxy for it fails closed`);
|
|
6130
|
+
console.error(`next: seekrit agents policy publish ${agent.slug} -f rules.json`);
|
|
6131
|
+
});
|
|
6132
|
+
});
|
|
6133
|
+
agents.command("show <ref>").description("an identity and its live rules").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6134
|
+
const ctx = buildContext();
|
|
6135
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6136
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6137
|
+
const { environment } = await ctx.client.getAgent(org.id, agent.id);
|
|
6138
|
+
const live = await loadVersion(ctx, org.id, agent);
|
|
6139
|
+
emit(options, {
|
|
6140
|
+
agent,
|
|
6141
|
+
environment,
|
|
6142
|
+
policy: live
|
|
6143
|
+
}, () => {
|
|
6144
|
+
printFields([
|
|
6145
|
+
["name", agent.name],
|
|
6146
|
+
["slug", agent.slug],
|
|
6147
|
+
["id", agent.id],
|
|
6148
|
+
["environment", environment ? environment.slug : "any in the org"],
|
|
6149
|
+
["enabled", agent.enabled ? "yes" : "no"],
|
|
6150
|
+
["policy", live ? `v${live.version}` : "none published"],
|
|
6151
|
+
["signer", live?.signerThumbprint],
|
|
6152
|
+
["expires", live ? describeExpiry(live.expiresAt) : void 0],
|
|
6153
|
+
["last fetch", agent.lastPolicyFetchAt ?? "never"]
|
|
6154
|
+
]);
|
|
6155
|
+
if (live) {
|
|
6156
|
+
section(`rules (v${live.version})`);
|
|
6157
|
+
printRules(live.rules);
|
|
6158
|
+
section("hosts a forward proxy would intercept");
|
|
6159
|
+
console.log(policyHosts(live.rules).join("\n") || "none");
|
|
6160
|
+
}
|
|
6161
|
+
});
|
|
6162
|
+
});
|
|
6163
|
+
agents.command("disable <ref>").description("stop serving this identity's policy (the revocation path)").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").action(async (ref, options) => {
|
|
6164
|
+
const ctx = buildContext();
|
|
6165
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6166
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6167
|
+
await confirmDestructive(options.yes, `Disable ${agent.slug}? Running proxies keep their current bundle until it expires.`);
|
|
6168
|
+
await ctx.client.updateAgent(org.id, agent.id, { enabled: false });
|
|
6169
|
+
console.error(`${agent.slug} disabled — its next policy refresh is refused`);
|
|
6170
|
+
});
|
|
6171
|
+
agents.command("enable <ref>").description("serve this identity's policy again").option("--org <slug>", "organization").action(async (ref, options) => {
|
|
6172
|
+
const ctx = buildContext();
|
|
6173
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6174
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6175
|
+
await ctx.client.updateAgent(org.id, agent.id, { enabled: true });
|
|
6176
|
+
console.error(`${agent.slug} enabled`);
|
|
6177
|
+
});
|
|
6178
|
+
agents.command("rm <ref>").description("delete an identity and its published history").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").action(async (ref, options) => {
|
|
6179
|
+
const ctx = buildContext();
|
|
6180
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6181
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6182
|
+
await confirmDestructive(options.yes, `Delete ${agent.slug} and all ${agent.currentPolicyVersion} published version(s)? The audit_log rows survive.`);
|
|
6183
|
+
await ctx.client.deleteAgent(org.id, agent.id);
|
|
6184
|
+
console.error(`${agent.slug} deleted`);
|
|
6185
|
+
});
|
|
6186
|
+
/**
|
|
6187
|
+
* The trust anchor. This prints a *public* key fingerprint — the security of
|
|
6188
|
+
* pinning comes from the operator putting it in a local file the API cannot
|
|
6189
|
+
* reach, not from this command being honest. Which is why it prints the TOML
|
|
6190
|
+
* line to paste rather than offering to write the config.
|
|
6191
|
+
*/
|
|
6192
|
+
agents.command("signer").description("your policy signing thumbprint, to pin in a proxy config").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (options) => {
|
|
6193
|
+
const ctx = buildContext();
|
|
6194
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6195
|
+
const { signer } = await ctx.client.getMyPolicySigner(org.id);
|
|
6196
|
+
if (!signer) fail("your account has no keypair yet — run `seekrit keys setup`");
|
|
6197
|
+
emit(options, { signer }, () => {
|
|
6198
|
+
printFields([["thumbprint", signer.thumbprint], ["user", signer.userId]]);
|
|
6199
|
+
section("pin it in seekrit-proxy.toml");
|
|
6200
|
+
console.log("[policy]");
|
|
6201
|
+
console.log(`signers = ["${signer.thumbprint}"]`);
|
|
6202
|
+
console.error("");
|
|
6203
|
+
console.error("pin a second admin's key too — one pinned signer means one lost passphrase leaves nobody able to publish");
|
|
6204
|
+
});
|
|
6205
|
+
});
|
|
6206
|
+
/**
|
|
6207
|
+
* The token is printed to **stdout** and everything else to stderr, so the
|
|
6208
|
+
* common thing works without parsing:
|
|
6209
|
+
*
|
|
6210
|
+
* TASK=$(seekrit agents dispatch nova --scope GITHUB_TOKEN --ttl 15m)
|
|
6211
|
+
*
|
|
6212
|
+
* It is shown exactly once. The API stores only a hash, so a lost token cannot
|
|
6213
|
+
* be recovered — dispatch another and revoke the first.
|
|
6214
|
+
*/
|
|
6215
|
+
agents.command("dispatch <ref>").description("mint task-scoped authority for one agent run").option("--scope <NAME>", "narrow this run to one secret (repeatable)", collectScope).option("--ttl <duration>", "how long the run stays authorized (e.g. 15m, 2h)").option("--label <text>", "what this run is for — recorded in the audit trail").option("--json", "machine-readable output (includes the token)").action(async (ref, options) => {
|
|
6216
|
+
const ctx = buildContext();
|
|
6217
|
+
const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : 900;
|
|
6218
|
+
if (ttlSeconds < 60 || ttlSeconds > 43200) fail(`--ttl must be between 60s and ${TASK_MAX_TTL_SECONDS / 3600}h`);
|
|
6219
|
+
const minted = await createAgentTaskToken();
|
|
6220
|
+
const { task, header } = await ctx.client.dispatchAgentTask(ref, {
|
|
6221
|
+
taskRef: minted.taskRef,
|
|
6222
|
+
tokenHash: minted.tokenHash,
|
|
6223
|
+
ttlSeconds,
|
|
6224
|
+
...options.scope ? { scopes: options.scope } : {},
|
|
6225
|
+
...options.label ? { label: options.label } : {}
|
|
6226
|
+
});
|
|
6227
|
+
if (options.json) {
|
|
6228
|
+
console.log(JSON.stringify({
|
|
6229
|
+
task,
|
|
6230
|
+
header,
|
|
6231
|
+
token: minted.token
|
|
6232
|
+
}, null, 2));
|
|
6233
|
+
return;
|
|
6234
|
+
}
|
|
6235
|
+
console.log(minted.token);
|
|
6236
|
+
printFieldsToStderr([
|
|
6237
|
+
["task", task.id],
|
|
6238
|
+
["agent", ref],
|
|
6239
|
+
["scopes", task.scopes ? describeSecrets(task.scopes) : "the agent's full policy"],
|
|
6240
|
+
["policy", `v${task.policyVersion}`],
|
|
6241
|
+
["expires", describeExpiry(task.expiresAt)],
|
|
6242
|
+
["header", header]
|
|
6243
|
+
]);
|
|
6244
|
+
});
|
|
6245
|
+
agents.command("tasks <ref>").description("runs dispatched for an agent identity, newest first").option("--org <slug>", "organization").option("--all", "include expired and revoked runs").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6246
|
+
const ctx = buildContext();
|
|
6247
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6248
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6249
|
+
const { tasks } = await ctx.client.listAgentTasks(org.id, agent.id);
|
|
6250
|
+
const now = Date.now();
|
|
6251
|
+
const rows = options.all ? tasks : tasks.filter((t) => taskStateOf(t, now) === "active");
|
|
6252
|
+
emit(options, { tasks: rows }, () => printTable(rows, [
|
|
6253
|
+
col("task", (t) => t.id),
|
|
6254
|
+
col("state", (t) => taskStateOf(t, now)),
|
|
6255
|
+
col("scopes", (t) => t.scopes ? describeSecrets(t.scopes) : "policy"),
|
|
6256
|
+
col("policy", (t) => `v${t.policyVersion}`),
|
|
6257
|
+
col("expires", (t) => t.expiresAt),
|
|
6258
|
+
col("last seen", (t) => t.lastSeenAt ?? "never"),
|
|
6259
|
+
col("label", (t) => t.label ?? "-")
|
|
6260
|
+
], options.all ? "no runs dispatched yet" : "no live runs (try --all)"));
|
|
6261
|
+
});
|
|
6262
|
+
agents.command("revoke <taskId>").description("end a run's authority now").action(async (taskId) => {
|
|
6263
|
+
const { task } = await buildContext().client.revokeAgentTask(taskId);
|
|
6264
|
+
console.error(`${task.id} revoked — enforcement points refuse it at their next introspection`);
|
|
6265
|
+
});
|
|
6266
|
+
registerPolicyCommands(agents.command("policy").description("published policy versions (`seekrit agents policy --help`)"));
|
|
6267
|
+
agents.command("activity <ref>").description("what this agent actually did — aggregate decisions, not a request log").option("--days <n>", "how far back to look (1–90)", "14").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6268
|
+
const ctx = buildContext();
|
|
6269
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6270
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6271
|
+
const days = Number(options.days ?? 14);
|
|
6272
|
+
if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
|
|
6273
|
+
const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
|
|
6274
|
+
emit(options, {
|
|
6275
|
+
activity,
|
|
6276
|
+
summary
|
|
6277
|
+
}, () => {
|
|
6278
|
+
printFields([
|
|
6279
|
+
["window", `${days}d`],
|
|
6280
|
+
["allowed", summary.allowed],
|
|
6281
|
+
["denied", summary.denied],
|
|
6282
|
+
["hosts", summary.hosts],
|
|
6283
|
+
["earliest", summary.from ?? "no activity reported"]
|
|
6284
|
+
]);
|
|
6285
|
+
if (activity.length === 0) {
|
|
6286
|
+
console.error("");
|
|
6287
|
+
console.error("nothing reported — add an [activity] block to the proxy (`seekrit proxy init --activity`)");
|
|
6288
|
+
return;
|
|
6289
|
+
}
|
|
6290
|
+
section("decisions");
|
|
6291
|
+
printTable(activity, [
|
|
6292
|
+
col("host", (a) => a.host),
|
|
6293
|
+
col("method", (a) => a.method),
|
|
6294
|
+
col("decision", (a) => a.decision),
|
|
6295
|
+
col("rule", (a) => a.ruleIndex === null ? "-" : String(a.ruleIndex + 1)),
|
|
6296
|
+
col("count", (a) => a.count),
|
|
6297
|
+
col("secrets", (a) => a.secrets ? Object.entries(a.secrets).map(([name, n]) => `${name}×${n}`).join(" ") : "-")
|
|
6298
|
+
]);
|
|
6299
|
+
});
|
|
6300
|
+
});
|
|
6301
|
+
/**
|
|
6302
|
+
* The grant review loop. Proposals are computed *here*, from activity the API
|
|
6303
|
+
* served — the API is deliberately not in the business of saying what a policy
|
|
6304
|
+
* should be. And `--out` writes a rule file rather than publishing: the change
|
|
6305
|
+
* still goes through `policy publish`, which still needs a human's key.
|
|
6306
|
+
*/
|
|
6307
|
+
agents.command("review <ref>").description("compare published policy against observed activity, and propose changes").option("--days <n>", "how far back to look (1–90)", "14").option("-o, --out <path>", "write the narrowed rules to a file, ready to publish").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6308
|
+
const ctx = buildContext();
|
|
6309
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6310
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6311
|
+
const days = Number(options.days ?? 14);
|
|
6312
|
+
if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
|
|
6313
|
+
const live = await loadVersion(ctx, org.id, agent);
|
|
6314
|
+
if (!live) fail(`${agent.slug} has no published policy to review`);
|
|
6315
|
+
const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
|
|
6316
|
+
const proposals = reviewPolicy({
|
|
6317
|
+
rules: live.rules,
|
|
6318
|
+
activity
|
|
6319
|
+
});
|
|
6320
|
+
emit(options, {
|
|
6321
|
+
proposals,
|
|
6322
|
+
summary,
|
|
6323
|
+
version: live.version
|
|
6324
|
+
}, () => {
|
|
6325
|
+
console.error(`${agent.slug} — policy v${live.version}, ${summary.allowed + summary.denied} decision(s) over ${days}d`);
|
|
6326
|
+
if (activity.length === 0) {
|
|
6327
|
+
console.error("");
|
|
6328
|
+
console.error("no activity reported yet — nothing to review against");
|
|
6329
|
+
return;
|
|
6330
|
+
}
|
|
6331
|
+
if (proposals.length === 0) {
|
|
6332
|
+
console.error("");
|
|
6333
|
+
console.error("no changes proposed: every rule is in use and nothing was refused");
|
|
6334
|
+
return;
|
|
6335
|
+
}
|
|
6336
|
+
section("proposals");
|
|
6337
|
+
for (const p of proposals) {
|
|
6338
|
+
const marker = p.applicable ? "-" : "?";
|
|
6339
|
+
console.log(`${marker} ${p.rationale}`);
|
|
6340
|
+
}
|
|
6341
|
+
const applicable = countApplicable(proposals);
|
|
6342
|
+
console.error("");
|
|
6343
|
+
console.error(`${applicable} narrowing change(s) can be applied; ${proposals.length - applicable} need a human decision`);
|
|
6344
|
+
});
|
|
6345
|
+
if (options.out) {
|
|
6346
|
+
const narrowed = applyProposals(live.rules, proposals);
|
|
6347
|
+
writeFileSync(options.out, `${JSON.stringify({ rules: narrowed }, null, 2)}\n`);
|
|
6348
|
+
console.error(`wrote ${narrowed.length} rule(s) to ${options.out} — review the diff, then: seekrit agents policy publish ${agent.slug} -f ${options.out}`);
|
|
6349
|
+
console.error("widening proposals are never applied: an agent must not earn permissions by retrying");
|
|
6350
|
+
}
|
|
6351
|
+
});
|
|
6352
|
+
agents.command("simulate <ref>").description("ask what a policy would decide, without sending a request").requiredOption("--host <host>", "upstream hostname").requiredOption("--path <path>", "request path").option("--method <method>", "HTTP method", "GET").option("--secret <NAME>", "the secret the request would inject").option("--version <n>", "evaluate a specific published version").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6353
|
+
const ctx = buildContext();
|
|
6354
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6355
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6356
|
+
const version = options.version === void 0 ? void 0 : Number(options.version);
|
|
6357
|
+
if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
|
|
6358
|
+
const published = await loadVersion(ctx, org.id, agent, version);
|
|
6359
|
+
if (!published) fail(`${agent.slug} has no published policy — every request is denied`);
|
|
6360
|
+
const verdict = evaluatePolicy(published.rules, {
|
|
6361
|
+
host: options.host,
|
|
6362
|
+
method: options.method,
|
|
6363
|
+
path: options.path,
|
|
6364
|
+
...options.secret === void 0 ? {} : { secret: options.secret }
|
|
6365
|
+
});
|
|
6366
|
+
const reason = describePolicyVerdict(verdict, published.rules);
|
|
6367
|
+
emit(options, {
|
|
6368
|
+
version: published.version,
|
|
6369
|
+
verdict,
|
|
6370
|
+
reason
|
|
6371
|
+
}, () => {
|
|
6372
|
+
const target = `${options.method.toUpperCase()} ${options.host}${options.path}`;
|
|
6373
|
+
console.log(`${verdict.decision === "allow" ? "allow" : "DENY"} ${target}${options.secret ? ` [${options.secret}]` : ""}`);
|
|
6374
|
+
console.log(` v${published.version}: ${reason}`);
|
|
6375
|
+
});
|
|
6376
|
+
if (verdict.decision !== "allow") process.exitCode = 1;
|
|
6377
|
+
});
|
|
6378
|
+
}
|
|
6379
|
+
function registerPolicyCommands(policy) {
|
|
6380
|
+
policy.command("list <ref>").description("published versions, newest first").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6381
|
+
const ctx = buildContext();
|
|
6382
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6383
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6384
|
+
const { policies } = await ctx.client.listAgentPolicies(org.id, agent.id);
|
|
6385
|
+
emit(options, { policies }, () => printTable(policies, [
|
|
6386
|
+
col("version", (p) => `v${p.version}`),
|
|
6387
|
+
col("rules", (p) => p.ruleCount),
|
|
6388
|
+
col("signer", (p) => p.signerThumbprint.slice(0, 12)),
|
|
6389
|
+
col("published", (p) => p.publishedAt),
|
|
6390
|
+
col("expires", (p) => secondsUntil(p.expiresAt) <= 0 ? `${p.expiresAt} (expired)` : p.expiresAt),
|
|
6391
|
+
col("note", (p) => p.rolledBackFromVersion ? `restored v${p.rolledBackFromVersion}` : "-")
|
|
6392
|
+
], "nothing published yet"));
|
|
6393
|
+
});
|
|
6394
|
+
policy.command("show <ref>").description("the rules in a published version (live by default)").option("--version <n>", "a specific version").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6395
|
+
const ctx = buildContext();
|
|
6396
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6397
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6398
|
+
const version = options.version === void 0 ? void 0 : Number(options.version);
|
|
6399
|
+
if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
|
|
6400
|
+
const published = await loadVersion(ctx, org.id, agent, version);
|
|
6401
|
+
if (!published) fail(`${agent.slug} has no published policy`);
|
|
6402
|
+
emit(options, { policy: published }, () => {
|
|
6403
|
+
printFields([
|
|
6404
|
+
["version", `v${published.version}`],
|
|
6405
|
+
["signer", published.signerThumbprint],
|
|
6406
|
+
["published", published.publishedAt],
|
|
6407
|
+
["expires", describeExpiry(published.expiresAt)]
|
|
6408
|
+
]);
|
|
6409
|
+
section("rules");
|
|
6410
|
+
printRules(published.rules);
|
|
6411
|
+
});
|
|
6412
|
+
});
|
|
6413
|
+
/**
|
|
6414
|
+
* Write the live rules out as the editable source for the next publish. This
|
|
6415
|
+
* is the half that makes policy-as-code work: what comes back out is exactly
|
|
6416
|
+
* the shape `publish` takes in, so a round trip is a no-op diff.
|
|
6417
|
+
*/
|
|
6418
|
+
policy.command("pull <ref>").description("write a version's rules to a JSON file you can edit and publish").option("-o, --out <path>", "where to write it (default: stdout)").option("--version <n>", "a specific version").option("--org <slug>", "organization").action(async (ref, options) => {
|
|
6419
|
+
const ctx = buildContext();
|
|
6420
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6421
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6422
|
+
const version = options.version === void 0 ? void 0 : Number(options.version);
|
|
6423
|
+
if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
|
|
6424
|
+
const published = await loadVersion(ctx, org.id, agent, version);
|
|
6425
|
+
const rules = published?.rules ?? [];
|
|
6426
|
+
const body = `${JSON.stringify({ rules }, null, 2)}\n`;
|
|
6427
|
+
if (options.out) {
|
|
6428
|
+
writeFileSync(options.out, body);
|
|
6429
|
+
console.error(published ? `wrote ${rules.length} rule(s) from v${published.version} to ${options.out}` : `wrote an empty rule set to ${options.out} (nothing published yet)`);
|
|
6430
|
+
return;
|
|
6431
|
+
}
|
|
6432
|
+
process.stdout.write(body);
|
|
6433
|
+
});
|
|
6434
|
+
policy.command("publish <ref>").description("sign a rule file with your own key and publish it").requiredOption("-f, --file <path>", `rule file, or ${STDIN} for stdin`).option("--ttl <duration>", "how long the bundle stays valid (e.g. 7d, 12h)").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6435
|
+
const ctx = buildContext();
|
|
6436
|
+
if (isTokenAuth(ctx)) fail("publishing agent policy needs a human's signing key — sign in with `seekrit login`. A service token cannot publish, so an agent cannot widen its own policy.");
|
|
6437
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6438
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6439
|
+
const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : POLICY_DEFAULT_TTL_SECONDS;
|
|
6440
|
+
if (ttlSeconds < 3600 || ttlSeconds > 7776e3) fail(`--ttl must be between ${POLICY_MIN_TTL_SECONDS / 3600}h and ${POLICY_MAX_TTL_SECONDS / 86400}d`);
|
|
6441
|
+
const { raw, label } = await readRuleSource(options.file);
|
|
6442
|
+
const rules = parseRuleFile(raw, label);
|
|
6443
|
+
const live = await loadVersion(ctx, org.id, agent);
|
|
6444
|
+
const nextVersion = agent.currentPolicyVersion + 1;
|
|
6445
|
+
const from = agent.currentPolicyVersion === 0 ? "nothing" : `v${agent.currentPolicyVersion}`;
|
|
6446
|
+
console.error(`${agent.slug}: ${from} → v${nextVersion}`);
|
|
6447
|
+
printPolicyDiff(live?.rules ?? [], rules);
|
|
6448
|
+
await confirmDestructive(options.yes, `Publish v${nextVersion} for ${agent.slug} (${rules.length} rule(s), valid ${options.ttl ?? "7d"})?`);
|
|
6449
|
+
const { signingKey, jwk } = await getPolicySigner(ctx);
|
|
6450
|
+
const issuedAt = Math.floor(Date.now() / 1e3);
|
|
6451
|
+
const bundle = await signAgentPolicy(signingKey, jwk, {
|
|
6452
|
+
v: 1,
|
|
6453
|
+
org: org.id,
|
|
6454
|
+
agent: agent.id,
|
|
6455
|
+
agent_slug: agent.slug,
|
|
6456
|
+
policy_version: nextVersion,
|
|
6457
|
+
issued_at: issuedAt,
|
|
6458
|
+
expires_at: issuedAt + ttlSeconds,
|
|
6459
|
+
rules
|
|
6460
|
+
});
|
|
6461
|
+
const { policy: published } = await ctx.client.publishAgentPolicy(org.id, agent.id, bundle);
|
|
6462
|
+
emit(options, { policy: published }, () => {
|
|
6463
|
+
console.error(`published v${published.version} — signed with ${published.signerThumbprint.slice(0, 12)}…, expires ${published.expiresAt}`);
|
|
6464
|
+
console.error("proxies pick it up at their next refresh");
|
|
6465
|
+
});
|
|
6466
|
+
});
|
|
6467
|
+
policy.command("rollback <ref> <version>").description("republish an earlier version's bundle as the newest one").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").action(async (ref, versionArg, options) => {
|
|
6468
|
+
const ctx = buildContext();
|
|
6469
|
+
const org = await resolveOrg(ctx, options.org);
|
|
6470
|
+
const agent = await resolveAgent(ctx, org.id, ref);
|
|
6471
|
+
const version = Number(versionArg);
|
|
6472
|
+
if (!Number.isInteger(version) || version < 1) fail("version must be a positive integer");
|
|
6473
|
+
const source = await loadVersion(ctx, org.id, agent, version);
|
|
6474
|
+
if (!source) fail(`no version ${version}`);
|
|
6475
|
+
if (secondsUntil(source.expiresAt) <= 0) fail(`v${version} expired at ${source.expiresAt} — a rollback keeps the original expiry, so it would be inert. Publish the rules again instead: seekrit agents policy pull ${agent.slug} --version ${version} -o rules.json`);
|
|
6476
|
+
await confirmDestructive(options.yes, `Roll ${agent.slug} back to v${version} (${source.ruleCount} rule(s))?`);
|
|
6477
|
+
const { policy: published } = await ctx.client.rollbackAgentPolicy(org.id, agent.id, version);
|
|
6478
|
+
console.error(`published v${published.version} carrying v${version}'s bundle`);
|
|
6479
|
+
});
|
|
6480
|
+
/**
|
|
6481
|
+
* What a proxy actually gets. Useful when a deployment misbehaves and the
|
|
6482
|
+
* question is whether the API, the pin, or the rules are at fault — so it
|
|
6483
|
+
* re-derives the thumbprint from the bundle rather than reporting the one the
|
|
6484
|
+
* API sends beside it, and says plainly when the two disagree.
|
|
6485
|
+
*/
|
|
6486
|
+
policy.command("fetch <ref>").description("the bundle a proxy would fetch, with its signer re-derived locally").option("--bundle", "print the raw ap1. envelope and nothing else").option("--json", "machine-readable output").action(async (ref, options) => {
|
|
6487
|
+
const fetched = await buildContext().client.getAgentPolicyBundle(ref);
|
|
6488
|
+
if (options.bundle) {
|
|
6489
|
+
process.stdout.write(`${fetched.bundle}\n`);
|
|
6490
|
+
return;
|
|
6491
|
+
}
|
|
6492
|
+
const body = parseAgentPolicyUnverified(fetched.bundle);
|
|
6493
|
+
const derived = await policySignerThumbprint(body.signer.jwk);
|
|
6494
|
+
emit(options, {
|
|
6495
|
+
...fetched,
|
|
6496
|
+
derivedThumbprint: derived,
|
|
6497
|
+
rules: body.rules
|
|
6498
|
+
}, () => {
|
|
6499
|
+
printFields([
|
|
6500
|
+
["agent", `${fetched.agent.slug} (${fetched.agent.name})`],
|
|
6501
|
+
["version", `v${fetched.version}`],
|
|
6502
|
+
["expires", describeExpiry(fetched.expiresAt)],
|
|
6503
|
+
["signer (in bundle)", derived],
|
|
6504
|
+
["signer (reported)", fetched.signerThumbprint === derived ? "matches" : `${fetched.signerThumbprint} — DOES NOT MATCH the bundle; a proxy would refuse this`]
|
|
6505
|
+
]);
|
|
6506
|
+
section("rules");
|
|
6507
|
+
printRules(body.rules);
|
|
6508
|
+
console.error("");
|
|
6509
|
+
console.error("this command does not verify the signature — only a pinned signer list can, which lives in the proxy's own config");
|
|
6510
|
+
});
|
|
6511
|
+
});
|
|
6512
|
+
}
|
|
6513
|
+
//#endregion
|
|
6514
|
+
//#region src/kms.ts
|
|
6515
|
+
/** Collect a repeatable option into a list. */
|
|
6516
|
+
function collect$6(value, acc = []) {
|
|
6517
|
+
acc.push(value);
|
|
6518
|
+
return acc;
|
|
6519
|
+
}
|
|
6520
|
+
/** The calling principal's identity + public key (for a self-grant). */
|
|
6521
|
+
async function kmsCallerIdentity(ctx) {
|
|
6522
|
+
if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
|
|
6523
|
+
const { tokenId, privateKey } = await parseServiceToken(ctx.auth.token);
|
|
6524
|
+
const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
|
|
6525
|
+
return {
|
|
6526
|
+
principalType: "service_token",
|
|
6527
|
+
principalId: tokenId,
|
|
6528
|
+
publicKeyJwk: JSON.stringify(pub)
|
|
6529
|
+
};
|
|
6530
|
+
}
|
|
6531
|
+
const { user } = await ctx.client.me();
|
|
6532
|
+
if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
|
|
6533
|
+
return {
|
|
6534
|
+
principalType: "user",
|
|
6535
|
+
principalId: user.id,
|
|
6536
|
+
publicKeyJwk: user.publicKeyJwk
|
|
6537
|
+
};
|
|
6538
|
+
}
|
|
6539
|
+
/** Look up an org member (by email) or service token (by id) as a grant recipient. */
|
|
6540
|
+
async function kmsResolveRecipient(ctx, orgId, who) {
|
|
6541
|
+
if (who.user) {
|
|
6542
|
+
const { members } = await ctx.client.listMembers(orgId);
|
|
6543
|
+
const m = members.find((x) => x.email === who.user);
|
|
5108
6544
|
if (!m) fail(`no member ${who.user}`);
|
|
5109
6545
|
if (!m.publicKeyJwk) fail(`${who.user} has not completed key setup`);
|
|
5110
6546
|
return {
|
|
@@ -7360,6 +8796,1051 @@ function collect$2(value, acc) {
|
|
|
7360
8796
|
return acc;
|
|
7361
8797
|
}
|
|
7362
8798
|
//#endregion
|
|
8799
|
+
//#region src/proxy-binary.ts
|
|
8800
|
+
/**
|
|
8801
|
+
* Fetch and run the `seekrit-proxy` binary without a Rust toolchain.
|
|
8802
|
+
*
|
|
8803
|
+
* The proxy is the strongest answer seekrit has for an untrusted workload — the
|
|
8804
|
+
* agent holds `{{seekrit:NAME}}` and never the key — and it was also the hardest
|
|
8805
|
+
* thing here to *try*, because trying it meant `cargo` and a TOML file. This
|
|
8806
|
+
* module removes the first half: it resolves a prebuilt, checksum-verified
|
|
8807
|
+
* binary for the host platform and execs it, so `npx @seekrit/proxy` and
|
|
8808
|
+
* `seekrit proxy run` behave like the proxy was already installed.
|
|
8809
|
+
*
|
|
8810
|
+
* The logic lives in the CLI (and is re-exported as `@seekrit/cli/proxy-launcher`)
|
|
8811
|
+
* for the same reason the MCP server does: `@seekrit/proxy` is a thin npx
|
|
8812
|
+
* entrypoint over it, and the two must not drift.
|
|
8813
|
+
*
|
|
8814
|
+
* Three properties worth stating, since this downloads and executes code:
|
|
8815
|
+
*
|
|
8816
|
+
* - **The checksum is verified before anything is executed**, against a
|
|
8817
|
+
* `.sha256` fetched from the same release. That is integrity, not provenance —
|
|
8818
|
+
* it proves the bytes match what the release published, which is exactly the
|
|
8819
|
+
* guarantee `install.sh` gives and no more.
|
|
8820
|
+
* - **Nothing is fetched when a binary is already available.** `SEEKRIT_PROXY_BIN`
|
|
8821
|
+
* short-circuits entirely, and a cached download for the same version+target is
|
|
8822
|
+
* reused, so this is a one-time cost per version.
|
|
8823
|
+
* - **Version is pinned, not floating.** A default of `latest` would make two
|
|
8824
|
+
* machines run different proxies from the same command; the pinned constant is
|
|
8825
|
+
* what this CLI was built against, overridable when you want otherwise.
|
|
8826
|
+
*/
|
|
8827
|
+
/**
|
|
8828
|
+
* The proxy version this CLI was built against.
|
|
8829
|
+
*
|
|
8830
|
+
* Bumped by release-please when `apps/proxy` releases (an `extra-files` entry in
|
|
8831
|
+
* release-please-config.json), so the pin follows the crate without anyone
|
|
8832
|
+
* remembering to move it.
|
|
8833
|
+
*/
|
|
8834
|
+
const PROXY_VERSION = "0.10.0";
|
|
8835
|
+
const BIN = "seekrit-proxy";
|
|
8836
|
+
/**
|
|
8837
|
+
* Host → Rust target triple.
|
|
8838
|
+
*
|
|
8839
|
+
* Linux always resolves to **musl**: that build is statically linked, so one
|
|
8840
|
+
* artifact covers glibc, musl, alpine, and distroless, and there is no libc
|
|
8841
|
+
* detection to get wrong on a machine where `ldd` says something unexpected.
|
|
8842
|
+
*/
|
|
8843
|
+
function detectTarget(os = platform(), cpu = arch()) {
|
|
8844
|
+
const machine = cpu === "x64" ? "x86_64" : cpu === "arm64" ? "aarch64" : null;
|
|
8845
|
+
if (!machine) throw new Error(`unsupported architecture "${cpu}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
|
|
8846
|
+
switch (os) {
|
|
8847
|
+
case "linux": return {
|
|
8848
|
+
target: `${machine}-unknown-linux-musl`,
|
|
8849
|
+
exe: ""
|
|
8850
|
+
};
|
|
8851
|
+
case "darwin": return {
|
|
8852
|
+
target: `${machine}-apple-darwin`,
|
|
8853
|
+
exe: ""
|
|
8854
|
+
};
|
|
8855
|
+
case "win32":
|
|
8856
|
+
if (machine !== "x86_64") throw new Error(`no prebuilt seekrit-proxy for ${machine} Windows — set SEEKRIT_PROXY_BIN to a binary you built`);
|
|
8857
|
+
return {
|
|
8858
|
+
target: "x86_64-pc-windows-msvc",
|
|
8859
|
+
exe: ".exe"
|
|
8860
|
+
};
|
|
8861
|
+
default: throw new Error(`unsupported platform "${os}" — build from source (apps/proxy) or set SEEKRIT_PROXY_BIN`);
|
|
8862
|
+
}
|
|
8863
|
+
}
|
|
8864
|
+
/** `latest` stays `latest`; everything else is normalized to `v<x.y.z>`. */
|
|
8865
|
+
function versionPrefix(version) {
|
|
8866
|
+
if (version === "latest") return "latest";
|
|
8867
|
+
return version.startsWith("v") ? version : `v${version}`;
|
|
8868
|
+
}
|
|
8869
|
+
function resolveVersion(explicit) {
|
|
8870
|
+
return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
|
|
8871
|
+
}
|
|
8872
|
+
function resolveBaseUrl(explicit) {
|
|
8873
|
+
return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
|
|
8874
|
+
}
|
|
8875
|
+
/** Where a resolved binary is kept, keyed so versions and targets never collide. */
|
|
8876
|
+
function proxyBinaryPath(version, target, exe) {
|
|
8877
|
+
return join(defaultCacheDir(), "proxy", versionPrefix(version), target, `${BIN}${exe}`);
|
|
8878
|
+
}
|
|
8879
|
+
async function fetchBytes(url) {
|
|
8880
|
+
const res = await fetch(url);
|
|
8881
|
+
if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
|
|
8882
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
8883
|
+
}
|
|
8884
|
+
/**
|
|
8885
|
+
* Ensure a `seekrit-proxy` binary exists locally and return its path.
|
|
8886
|
+
*
|
|
8887
|
+
* Order: an explicit `SEEKRIT_PROXY_BIN`, then a cached download for this
|
|
8888
|
+
* version+target, then a fresh download. A binary already on `PATH` is
|
|
8889
|
+
* deliberately *not* used — silently running a different version than the one
|
|
8890
|
+
* this CLI pins is the kind of surprise that costs an afternoon.
|
|
8891
|
+
*/
|
|
8892
|
+
async function resolveProxyBinary(options = {}) {
|
|
8893
|
+
const override = process.env.SEEKRIT_PROXY_BIN;
|
|
8894
|
+
if (override) {
|
|
8895
|
+
if (!existsSync(override)) throw new Error(`SEEKRIT_PROXY_BIN points at ${override}, which does not exist`);
|
|
8896
|
+
return override;
|
|
8897
|
+
}
|
|
8898
|
+
const version = resolveVersion(options.version);
|
|
8899
|
+
const { target, exe } = detectTarget();
|
|
8900
|
+
const dest = proxyBinaryPath(version, target, exe);
|
|
8901
|
+
if (!options.force && version !== "latest" && existsSync(dest)) return dest;
|
|
8902
|
+
const baseUrl = resolveBaseUrl(options.baseUrl);
|
|
8903
|
+
const prefix = versionPrefix(version);
|
|
8904
|
+
const name = `${BIN}-${target}${exe}`;
|
|
8905
|
+
const binUrl = `${baseUrl}/${prefix}/bin/${name}`;
|
|
8906
|
+
const sumUrl = `${binUrl}.sha256`;
|
|
8907
|
+
if (!options.quiet) process.stderr.write(`seekrit: fetching ${BIN} ${prefix} (${target})…\n`);
|
|
8908
|
+
let bytes;
|
|
8909
|
+
let expected;
|
|
8910
|
+
try {
|
|
8911
|
+
[bytes, expected] = await Promise.all([fetchBytes(binUrl), fetchBytes(sumUrl).then((b) => Buffer.from(b).toString("utf8").trim().split(/\s+/)[0] ?? "")]);
|
|
8912
|
+
} catch (err) {
|
|
8913
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
8914
|
+
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.`);
|
|
8915
|
+
}
|
|
8916
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
8917
|
+
if (!expected || actual !== expected.toLowerCase()) throw new Error(`checksum mismatch for ${name}: expected ${expected || "(none published)"}, got ${actual}. Refusing to run it.`);
|
|
8918
|
+
const dir = dirname(dest);
|
|
8919
|
+
mkdirSync(dir, { recursive: true });
|
|
8920
|
+
const staging = join(dir, `.${BIN}-${process.pid}-${actual.slice(0, 12)}${exe}`);
|
|
8921
|
+
try {
|
|
8922
|
+
writeFileSync(staging, bytes, { mode: 493 });
|
|
8923
|
+
renameSync(staging, dest);
|
|
8924
|
+
} catch (err) {
|
|
8925
|
+
rmSync(staging, { force: true });
|
|
8926
|
+
throw err;
|
|
8927
|
+
}
|
|
8928
|
+
chmodSync(dest, 493);
|
|
8929
|
+
return dest;
|
|
8930
|
+
}
|
|
8931
|
+
/**
|
|
8932
|
+
* Run the proxy, forwarding stdio, signals, and its exit status.
|
|
8933
|
+
*
|
|
8934
|
+
* The proxy is a long-lived foreground process, so this wrapper has to be
|
|
8935
|
+
* transparent: Node cannot exec-replace itself, and without relaying signals
|
|
8936
|
+
* Node's default SIGINT handler would kill *this* process on Ctrl-C and leave
|
|
8937
|
+
* the proxy running, holding decrypted secrets, with the shell prompt back.
|
|
8938
|
+
*/
|
|
8939
|
+
async function runProxyBinary(argv, options = {}) {
|
|
8940
|
+
const bin = await resolveProxyBinary(options);
|
|
8941
|
+
const child = spawn(bin, argv, {
|
|
8942
|
+
stdio: "inherit",
|
|
8943
|
+
env: {
|
|
8944
|
+
...process.env,
|
|
8945
|
+
...options.env
|
|
8946
|
+
}
|
|
8947
|
+
});
|
|
8948
|
+
const signals = [
|
|
8949
|
+
"SIGINT",
|
|
8950
|
+
"SIGTERM",
|
|
8951
|
+
"SIGHUP",
|
|
8952
|
+
"SIGQUIT"
|
|
8953
|
+
];
|
|
8954
|
+
const forward = (signal) => {
|
|
8955
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
8956
|
+
child.kill(signal);
|
|
8957
|
+
};
|
|
8958
|
+
for (const signal of signals) process.on(signal, forward);
|
|
8959
|
+
return new Promise((resolve, reject) => {
|
|
8960
|
+
child.on("error", (err) => {
|
|
8961
|
+
for (const s of signals) process.off(s, forward);
|
|
8962
|
+
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.`));
|
|
8963
|
+
});
|
|
8964
|
+
child.on("exit", (code, signal) => {
|
|
8965
|
+
for (const s of signals) process.off(s, forward);
|
|
8966
|
+
resolve(signal ? 128 + signalNumber(signal) : code ?? 0);
|
|
8967
|
+
});
|
|
8968
|
+
});
|
|
8969
|
+
}
|
|
8970
|
+
/** Signal name → number, for the 128+n exit convention. */
|
|
8971
|
+
function signalNumber(signal) {
|
|
8972
|
+
return {
|
|
8973
|
+
SIGHUP: 1,
|
|
8974
|
+
SIGINT: 2,
|
|
8975
|
+
SIGQUIT: 3,
|
|
8976
|
+
SIGKILL: 9,
|
|
8977
|
+
SIGTERM: 15
|
|
8978
|
+
}[signal] ?? 0;
|
|
8979
|
+
}
|
|
8980
|
+
//#endregion
|
|
8981
|
+
//#region src/proxy-presets.ts
|
|
8982
|
+
/** `Authorization: Bearer {{seekrit:NAME}}` — the shape most providers take. */
|
|
8983
|
+
function placeholder(secret) {
|
|
8984
|
+
return `{{seekrit:${secret}}}`;
|
|
8985
|
+
}
|
|
8986
|
+
/**
|
|
8987
|
+
* The catalogue. Ordered as `seekrit proxy presets` prints it: the two model
|
|
8988
|
+
* APIs an agent almost certainly calls, then the aggregators, then the generic
|
|
8989
|
+
* escape hatches.
|
|
8990
|
+
*/
|
|
8991
|
+
const PROXY_PRESETS = [
|
|
8992
|
+
{
|
|
8993
|
+
id: "openai",
|
|
8994
|
+
label: "OpenAI API (api.openai.com)",
|
|
8995
|
+
host: "api.openai.com",
|
|
8996
|
+
prefix: "/openai",
|
|
8997
|
+
secret: "OPENAI_API_KEY",
|
|
8998
|
+
methods: ["GET", "POST"],
|
|
8999
|
+
paths: ["/v1/**"],
|
|
9000
|
+
baseUrlSuffix: "/v1",
|
|
9001
|
+
env: (mode) => mode === "reverse" ? [{
|
|
9002
|
+
name: "OPENAI_BASE_URL",
|
|
9003
|
+
value: "{{base}}"
|
|
9004
|
+
}, {
|
|
9005
|
+
name: "OPENAI_API_KEY",
|
|
9006
|
+
value: placeholder("OPENAI_API_KEY")
|
|
9007
|
+
}] : [{
|
|
9008
|
+
name: "OPENAI_API_KEY",
|
|
9009
|
+
value: placeholder("OPENAI_API_KEY")
|
|
9010
|
+
}]
|
|
9011
|
+
},
|
|
9012
|
+
{
|
|
9013
|
+
id: "anthropic",
|
|
9014
|
+
label: "Anthropic API (api.anthropic.com)",
|
|
9015
|
+
host: "api.anthropic.com",
|
|
9016
|
+
prefix: "/anthropic",
|
|
9017
|
+
secret: "ANTHROPIC_API_KEY",
|
|
9018
|
+
methods: ["GET", "POST"],
|
|
9019
|
+
paths: ["/v1/**"],
|
|
9020
|
+
baseUrlSuffix: "",
|
|
9021
|
+
env: (mode) => mode === "reverse" ? [{
|
|
9022
|
+
name: "ANTHROPIC_BASE_URL",
|
|
9023
|
+
value: "{{base}}"
|
|
9024
|
+
}, {
|
|
9025
|
+
name: "ANTHROPIC_API_KEY",
|
|
9026
|
+
value: placeholder("ANTHROPIC_API_KEY")
|
|
9027
|
+
}] : [{
|
|
9028
|
+
name: "ANTHROPIC_API_KEY",
|
|
9029
|
+
value: placeholder("ANTHROPIC_API_KEY")
|
|
9030
|
+
}]
|
|
9031
|
+
},
|
|
9032
|
+
{
|
|
9033
|
+
id: "openrouter",
|
|
9034
|
+
label: "OpenRouter (openrouter.ai) — OpenAI-compatible",
|
|
9035
|
+
host: "openrouter.ai",
|
|
9036
|
+
prefix: "/openrouter",
|
|
9037
|
+
secret: "OPENROUTER_API_KEY",
|
|
9038
|
+
methods: ["GET", "POST"],
|
|
9039
|
+
paths: ["/api/v1/**"],
|
|
9040
|
+
baseUrlSuffix: "/api/v1",
|
|
9041
|
+
env: (mode) => mode === "reverse" ? [{
|
|
9042
|
+
name: "OPENAI_BASE_URL",
|
|
9043
|
+
value: "{{base}}"
|
|
9044
|
+
}, {
|
|
9045
|
+
name: "OPENAI_API_KEY",
|
|
9046
|
+
value: placeholder("OPENROUTER_API_KEY")
|
|
9047
|
+
}] : [{
|
|
9048
|
+
name: "OPENROUTER_API_KEY",
|
|
9049
|
+
value: placeholder("OPENROUTER_API_KEY")
|
|
9050
|
+
}]
|
|
9051
|
+
},
|
|
9052
|
+
{
|
|
9053
|
+
id: "github",
|
|
9054
|
+
label: "GitHub REST + GraphQL API (api.github.com)",
|
|
9055
|
+
host: "api.github.com",
|
|
9056
|
+
prefix: "/github",
|
|
9057
|
+
secret: "GITHUB_TOKEN",
|
|
9058
|
+
methods: [],
|
|
9059
|
+
paths: [],
|
|
9060
|
+
baseUrlSuffix: "",
|
|
9061
|
+
env: (mode) => mode === "reverse" ? [{
|
|
9062
|
+
name: "GITHUB_API_URL",
|
|
9063
|
+
value: "{{base}}"
|
|
9064
|
+
}, {
|
|
9065
|
+
name: "GITHUB_TOKEN",
|
|
9066
|
+
value: placeholder("GITHUB_TOKEN")
|
|
9067
|
+
}] : [{
|
|
9068
|
+
name: "GITHUB_TOKEN",
|
|
9069
|
+
value: placeholder("GITHUB_TOKEN")
|
|
9070
|
+
}],
|
|
9071
|
+
note: "`gh` resolves api.github.com from GH_HOST, not a base URL — prefer forward mode for it."
|
|
9072
|
+
},
|
|
9073
|
+
{
|
|
9074
|
+
id: "openai-compatible",
|
|
9075
|
+
label: "Any OpenAI-compatible gateway — LiteLLM, vLLM, Ollama, Together, self-hosted",
|
|
9076
|
+
host: "",
|
|
9077
|
+
prefix: "/gateway",
|
|
9078
|
+
secret: "OPENAI_API_KEY",
|
|
9079
|
+
methods: ["GET", "POST"],
|
|
9080
|
+
paths: ["/v1/**"],
|
|
9081
|
+
baseUrlSuffix: "/v1",
|
|
9082
|
+
requiresBaseUrl: true,
|
|
9083
|
+
env: (mode) => mode === "reverse" ? [{
|
|
9084
|
+
name: "OPENAI_BASE_URL",
|
|
9085
|
+
value: "{{base}}"
|
|
9086
|
+
}, {
|
|
9087
|
+
name: "OPENAI_API_KEY",
|
|
9088
|
+
value: placeholder("OPENAI_API_KEY")
|
|
9089
|
+
}] : [{
|
|
9090
|
+
name: "OPENAI_API_KEY",
|
|
9091
|
+
value: placeholder("OPENAI_API_KEY")
|
|
9092
|
+
}],
|
|
9093
|
+
note: "Needs --base-url (e.g. --base-url https://litellm.internal:4000)."
|
|
9094
|
+
}
|
|
9095
|
+
];
|
|
9096
|
+
function findPreset(id) {
|
|
9097
|
+
return PROXY_PRESETS.find((p) => p.id === id);
|
|
9098
|
+
}
|
|
9099
|
+
function presetIds() {
|
|
9100
|
+
return PROXY_PRESETS.map((p) => p.id);
|
|
9101
|
+
}
|
|
9102
|
+
/**
|
|
9103
|
+
* Apply `--base-url` / `--secret` / `--prefix` overrides to a preset.
|
|
9104
|
+
*
|
|
9105
|
+
* Returns a new preset rather than mutating the catalogue entry: the same
|
|
9106
|
+
* process can generate two configs in one run (a test does), and a preset that
|
|
9107
|
+
* remembered the last `--base-url` would be a genuinely confusing bug.
|
|
9108
|
+
*/
|
|
9109
|
+
function specialize(preset, overrides) {
|
|
9110
|
+
let host = preset.host;
|
|
9111
|
+
let paths = preset.paths;
|
|
9112
|
+
let baseUrlSuffix = preset.baseUrlSuffix;
|
|
9113
|
+
if (overrides.baseUrl) {
|
|
9114
|
+
const url = new URL(overrides.baseUrl);
|
|
9115
|
+
host = url.hostname.toLowerCase();
|
|
9116
|
+
const upstreamPath = url.pathname.replace(/\/+$/, "");
|
|
9117
|
+
if (upstreamPath) {
|
|
9118
|
+
paths = [];
|
|
9119
|
+
baseUrlSuffix = `${upstreamPath}${preset.baseUrlSuffix}`;
|
|
9120
|
+
}
|
|
9121
|
+
}
|
|
9122
|
+
const secret = overrides.secret ?? preset.secret;
|
|
9123
|
+
const specialized = {
|
|
9124
|
+
...preset,
|
|
9125
|
+
host,
|
|
9126
|
+
paths,
|
|
9127
|
+
baseUrlSuffix,
|
|
9128
|
+
secret,
|
|
9129
|
+
prefix: overrides.prefix ?? preset.prefix
|
|
9130
|
+
};
|
|
9131
|
+
if (preset.allow && secret !== preset.secret) specialized.allow = preset.allow.map((name) => name === preset.secret ? secret : name);
|
|
9132
|
+
if (secret !== preset.secret) specialized.env = (mode) => preset.env(mode).map((hint) => ({
|
|
9133
|
+
...hint,
|
|
9134
|
+
value: hint.value.replace(/\{\{seekrit:[A-Za-z0-9_]+\}\}/, placeholder(secret))
|
|
9135
|
+
}));
|
|
9136
|
+
return specialized;
|
|
9137
|
+
}
|
|
9138
|
+
/** The secret names a preset's rule permits. Default-deny: empty means none. */
|
|
9139
|
+
function presetAllow(preset) {
|
|
9140
|
+
if (preset.allow) return preset.allow;
|
|
9141
|
+
return preset.secret ? [preset.secret] : [];
|
|
9142
|
+
}
|
|
9143
|
+
//#endregion
|
|
9144
|
+
//#region src/proxy-config.ts
|
|
9145
|
+
/**
|
|
9146
|
+
* A deliberately small TOML writer: basic strings and arrays of them, which is
|
|
9147
|
+
* every value in this config. Full TOML is not needed and a general emitter
|
|
9148
|
+
* would be one more thing that can disagree with the parser on an edge case.
|
|
9149
|
+
*/
|
|
9150
|
+
function tomlString(value) {
|
|
9151
|
+
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) => {
|
|
9152
|
+
return `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
9153
|
+
})}"`;
|
|
9154
|
+
}
|
|
9155
|
+
function tomlArray(values) {
|
|
9156
|
+
return `[${values.map(tomlString).join(", ")}]`;
|
|
9157
|
+
}
|
|
9158
|
+
/**
|
|
9159
|
+
* Claim `preferred` if nothing else has it, else derive one from `host`.
|
|
9160
|
+
*
|
|
9161
|
+
* Two rules can name the same preset-known host (a narrow rule above a broad
|
|
9162
|
+
* one is the documented pattern), and two routes cannot share a prefix — so the
|
|
9163
|
+
* second one needs a distinct, still-recognisable name rather than an error.
|
|
9164
|
+
*/
|
|
9165
|
+
function claimPrefix(preferred, host, taken) {
|
|
9166
|
+
if (preferred && !taken.has(preferred)) {
|
|
9167
|
+
taken.add(preferred);
|
|
9168
|
+
return preferred;
|
|
9169
|
+
}
|
|
9170
|
+
return prefixForHost(host, taken);
|
|
9171
|
+
}
|
|
9172
|
+
/** Slug for a route prefix, derived from a hostname. */
|
|
9173
|
+
function prefixForHost(host, taken) {
|
|
9174
|
+
const labels = host.split(".").filter(Boolean);
|
|
9175
|
+
while (labels.length > 1 && (labels[0] === "api" || labels[0] === "www")) labels.shift();
|
|
9176
|
+
const base = (labels[0] ?? "upstream").replace(/[^a-z0-9-]/gi, "").toLowerCase() || "upstream";
|
|
9177
|
+
let prefix = `/${base}`;
|
|
9178
|
+
let n = 2;
|
|
9179
|
+
while (taken.has(prefix)) prefix = `/${base}-${n++}`;
|
|
9180
|
+
taken.add(prefix);
|
|
9181
|
+
return prefix;
|
|
9182
|
+
}
|
|
9183
|
+
const HEADER = `# seekrit-proxy configuration — generated by \`seekrit proxy init\`.
|
|
9184
|
+
#
|
|
9185
|
+
# The proxy resolves the secrets its service token grants (SEEKRIT_TOKEN in the
|
|
9186
|
+
# environment, never in this file), then swaps {{seekrit:NAME}} placeholders in
|
|
9187
|
+
# outbound requests for the decrypted values before forwarding upstream.
|
|
9188
|
+
#
|
|
9189
|
+
# Safe to commit: it contains hostnames, secret *names*, and thumbprints — no
|
|
9190
|
+
# secret values and no credential. Review it before you rely on it; the
|
|
9191
|
+
# allowlist below is a security boundary, and a generator does not know your
|
|
9192
|
+
# threat model.`;
|
|
9193
|
+
/** Render the plan as the text of a `seekrit-proxy.toml`. */
|
|
9194
|
+
function renderProxyConfig(plan) {
|
|
9195
|
+
const out = [HEADER];
|
|
9196
|
+
const server = Boolean(plan.policy);
|
|
9197
|
+
if (plan.notes.length > 0) {
|
|
9198
|
+
out.push("#");
|
|
9199
|
+
for (const note of plan.notes) out.push(`# ${note}`);
|
|
9200
|
+
}
|
|
9201
|
+
out.push("");
|
|
9202
|
+
const reverse = plan.mode === "reverse" || plan.mode === "both";
|
|
9203
|
+
const forward = plan.mode === "forward" || plan.mode === "both";
|
|
9204
|
+
if (reverse) {
|
|
9205
|
+
out.push(`listen = ${tomlString(plan.listen)}`);
|
|
9206
|
+
out.push("");
|
|
9207
|
+
} else {
|
|
9208
|
+
out.push("# Forward-proxy only: the reverse plane still binds this address and");
|
|
9209
|
+
out.push("# serves nothing, since no [[route]] is declared below.");
|
|
9210
|
+
out.push(`listen = ${tomlString(plan.listen)}`);
|
|
9211
|
+
out.push("");
|
|
9212
|
+
}
|
|
9213
|
+
if (reverse) for (const route of plan.routes) {
|
|
9214
|
+
out.push("[[route]]");
|
|
9215
|
+
out.push(`prefix = ${tomlString(route.prefix)}`);
|
|
9216
|
+
out.push(`upstream = ${tomlString(route.upstream)}`);
|
|
9217
|
+
if (server) out.push("# allow/methods/paths come from published policy in server mode.");
|
|
9218
|
+
else {
|
|
9219
|
+
if (route.allow.length > 0) out.push(`allow = ${tomlArray(route.allow)}`);
|
|
9220
|
+
else out.push("# No `allow`: this route permits the operation but carries no credential.");
|
|
9221
|
+
if (route.methods.length > 0) out.push(`methods = ${tomlArray(route.methods)}`);
|
|
9222
|
+
if (route.paths.length > 0) out.push(`paths = ${tomlArray(route.paths)}`);
|
|
9223
|
+
if (route.label) out.push(`label = ${tomlString(route.label)}`);
|
|
9224
|
+
}
|
|
9225
|
+
out.push("");
|
|
9226
|
+
}
|
|
9227
|
+
if (forward) {
|
|
9228
|
+
out.push("[forward]");
|
|
9229
|
+
out.push(`listen = ${tomlString(plan.forwardListen)}`);
|
|
9230
|
+
out.push(`unmatched_host_policy = ${tomlString(plan.unmatched)}`);
|
|
9231
|
+
out.push(`ca_cert = ${tomlString(plan.caCert)}`);
|
|
9232
|
+
out.push(`ca_key = ${tomlString(plan.caKey)}`);
|
|
9233
|
+
out.push("");
|
|
9234
|
+
if (server) {
|
|
9235
|
+
out.push("# Intercepted hosts come from published policy in server mode, so there are");
|
|
9236
|
+
out.push("# no [[forward.host]] blocks here — adding an upstream is a dashboard change.");
|
|
9237
|
+
out.push("");
|
|
9238
|
+
} else for (const route of plan.routes) {
|
|
9239
|
+
out.push("[[forward.host]]");
|
|
9240
|
+
out.push(`match = ${tomlString(route.host)}`);
|
|
9241
|
+
if (route.allow.length > 0) out.push(`allow = ${tomlArray(route.allow)}`);
|
|
9242
|
+
else out.push("# No `allow`: reachable, but no credential travels toward it.");
|
|
9243
|
+
if (route.methods.length > 0) out.push(`methods = ${tomlArray(route.methods)}`);
|
|
9244
|
+
if (route.paths.length > 0) out.push(`paths = ${tomlArray(route.paths)}`);
|
|
9245
|
+
if (route.label) out.push(`label = ${tomlString(route.label)}`);
|
|
9246
|
+
out.push("");
|
|
9247
|
+
}
|
|
9248
|
+
}
|
|
9249
|
+
if (plan.policy) {
|
|
9250
|
+
out.push("# ---------------------------------------------------------------------------");
|
|
9251
|
+
out.push("# Rules come from agent access policy in the dashboard. The bundle is signed");
|
|
9252
|
+
out.push("# in a publishing admin's browser, and this proxy refuses any bundle not");
|
|
9253
|
+
out.push("# signed by a key whose thumbprint is pinned below. So seekrit can withhold");
|
|
9254
|
+
out.push("# your policy (the proxy then fails closed) but cannot widen it.");
|
|
9255
|
+
out.push("# ---------------------------------------------------------------------------");
|
|
9256
|
+
out.push("[policy]");
|
|
9257
|
+
out.push("source = \"server\"");
|
|
9258
|
+
out.push(`agent = ${tomlString(plan.policy.agent)}`);
|
|
9259
|
+
if (plan.policy.agents.length > 1) out.push(`agents = ${tomlArray(plan.policy.agents)}`);
|
|
9260
|
+
out.push(`refresh_interval = ${tomlString(plan.policy.refreshInterval)}`);
|
|
9261
|
+
out.push("");
|
|
9262
|
+
out.push("# THE TRUST ANCHOR — the one value that must not come from the API. Confirm");
|
|
9263
|
+
out.push("# each thumbprint against the dashboard's trust-anchor panel before relying");
|
|
9264
|
+
out.push("# on this file, and pin a second admin's key too: one pinned signer means one");
|
|
9265
|
+
out.push("# lost passphrase leaves nobody able to publish.");
|
|
9266
|
+
for (const signer of plan.policy.signers) out.push(signer.provenance === "own" ? `# ${signer.thumbprint} — your own signing key (derived locally).` : `# ${signer.thumbprint} — read from the policy the API served. VERIFY THIS.`);
|
|
9267
|
+
if (plan.policy.signers.length === 0) {
|
|
9268
|
+
out.push("# (none found — paste the thumbprint from the dashboard.)");
|
|
9269
|
+
out.push("# signers = [\"<thumbprint>\"]");
|
|
9270
|
+
out.push("signers = [] # ← the proxy refuses to start until this is filled in.");
|
|
9271
|
+
} else out.push(`signers = ${tomlArray(plan.policy.signers.map((s) => s.thumbprint))}`);
|
|
9272
|
+
out.push("");
|
|
9273
|
+
} else if (plan.secretsRefresh) {
|
|
9274
|
+
out.push("# Re-resolve on an interval, so a secret added later reaches a running proxy.");
|
|
9275
|
+
out.push("# No new grant is involved: a new secret in an environment this proxy already");
|
|
9276
|
+
out.push("# has a key grant for decrypts with the key it already holds.");
|
|
9277
|
+
out.push("[secrets]");
|
|
9278
|
+
out.push(`refresh_interval = ${tomlString(plan.secretsRefresh)}`);
|
|
9279
|
+
out.push("");
|
|
9280
|
+
}
|
|
9281
|
+
if (plan.cache) {
|
|
9282
|
+
out.push("# Start on the last (encrypted) resolve response if the API is unreachable.");
|
|
9283
|
+
out.push("# A *refused* resolve still fails closed, and decrypting a cached entry still");
|
|
9284
|
+
out.push("# needs this proxy's service token.");
|
|
9285
|
+
out.push("[cache]");
|
|
9286
|
+
out.push("enabled = true");
|
|
9287
|
+
out.push(`max_age = ${tomlString(plan.cache.maxAge)}`);
|
|
9288
|
+
out.push("");
|
|
9289
|
+
}
|
|
9290
|
+
if (plan.control) {
|
|
9291
|
+
out.push("# One proxy fronting several agents: the orchestrator mints a ticket per");
|
|
9292
|
+
out.push("# agent. Requires SEEKRIT_PROXY_CONTROL_TOKEN in the environment, and that");
|
|
9293
|
+
out.push("# token must not be readable by the agents.");
|
|
9294
|
+
out.push("[control]");
|
|
9295
|
+
out.push(`listen = ${tomlString(plan.control.listen)}`);
|
|
9296
|
+
out.push(`ttl = ${tomlString(plan.control.ttl)}`);
|
|
9297
|
+
out.push(`max_ttl = ${tomlString(plan.control.maxTtl)}`);
|
|
9298
|
+
out.push("");
|
|
9299
|
+
}
|
|
9300
|
+
if (plan.tasks) {
|
|
9301
|
+
out.push("# Honour tasks dispatched through the API (`seekrit agents dispatch`): a run");
|
|
9302
|
+
out.push("# presents its skd_… token in the same header a local ticket uses, and this");
|
|
9303
|
+
out.push("# proxy asks the API what it authorizes. Opt-in, because it makes authorizing");
|
|
9304
|
+
out.push("# a new run depend on reaching seekrit — a refused or unreachable check denies");
|
|
9305
|
+
out.push("# the request rather than admitting it.");
|
|
9306
|
+
out.push("#");
|
|
9307
|
+
out.push("# cache_ttl bounds how long a revoked run keeps working. Short on purpose.");
|
|
9308
|
+
out.push("[tasks]");
|
|
9309
|
+
out.push(`cache_ttl = ${tomlString(plan.tasks.cacheTtl)}`);
|
|
9310
|
+
out.push("");
|
|
9311
|
+
}
|
|
9312
|
+
if (plan.activity) {
|
|
9313
|
+
out.push("# Report aggregate decisions back, so `seekrit agents review` can compare this");
|
|
9314
|
+
out.push("# policy against what the agent actually does. Counts only: hosts, methods,");
|
|
9315
|
+
out.push("# secret *names*, and which rule decided — never a request path, never a value.");
|
|
9316
|
+
out.push("# Full per-request detail stays in your own OTLP collector.");
|
|
9317
|
+
out.push("[activity]");
|
|
9318
|
+
out.push(`flush_interval = ${tomlString(plan.activity.flushInterval)}`);
|
|
9319
|
+
out.push("");
|
|
9320
|
+
}
|
|
9321
|
+
if (plan.envHints.length > 0) {
|
|
9322
|
+
out.push("# ---------------------------------------------------------------------------");
|
|
9323
|
+
out.push("# Point the workload at the proxy (these go in its environment, not here):");
|
|
9324
|
+
out.push("#");
|
|
9325
|
+
for (const hint of plan.envHints) {
|
|
9326
|
+
out.push(`# export ${hint.name}='${hint.value}'`);
|
|
9327
|
+
if (hint.note) out.push(`# ${hint.note}`);
|
|
9328
|
+
}
|
|
9329
|
+
out.push("# ---------------------------------------------------------------------------");
|
|
9330
|
+
}
|
|
9331
|
+
return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
|
|
9332
|
+
}
|
|
9333
|
+
const PLAN_DEFAULTS = {
|
|
9334
|
+
mode: "reverse",
|
|
9335
|
+
listen: "127.0.0.1:8080",
|
|
9336
|
+
forwardListen: "127.0.0.1:8081",
|
|
9337
|
+
unmatched: "tunnel",
|
|
9338
|
+
caCert: "seekrit-proxy-ca.pem",
|
|
9339
|
+
caKey: "seekrit-proxy-ca-key.pem"
|
|
9340
|
+
};
|
|
9341
|
+
/** The base URL a workload points an SDK at, for a route in reverse mode. */
|
|
9342
|
+
function baseUrlFor(listen, prefix, suffix) {
|
|
9343
|
+
const [hostPart = "127.0.0.1", port = "8080"] = splitHostPort(listen);
|
|
9344
|
+
return `http://${hostPart === "0.0.0.0" || hostPart === "[::]" ? "127.0.0.1" : hostPart}:${port}${prefix}${suffix}`;
|
|
9345
|
+
}
|
|
9346
|
+
/** Split `host:port`, tolerating a bracketed IPv6 literal. */
|
|
9347
|
+
function splitHostPort(addr) {
|
|
9348
|
+
const bracketed = /^\[(.+)\]:(\d+)$/.exec(addr);
|
|
9349
|
+
if (bracketed) return [`[${bracketed[1]}]`, bracketed[2]];
|
|
9350
|
+
const idx = addr.lastIndexOf(":");
|
|
9351
|
+
if (idx === -1) return [addr, "8080"];
|
|
9352
|
+
return [addr.slice(0, idx), addr.slice(idx + 1)];
|
|
9353
|
+
}
|
|
9354
|
+
/** Build a file-policy plan from presets and/or ad-hoc `host=SECRET` rules. */
|
|
9355
|
+
function planFromPresets(presets, options) {
|
|
9356
|
+
const taken = /* @__PURE__ */ new Set();
|
|
9357
|
+
const routes = [];
|
|
9358
|
+
const envHints = [];
|
|
9359
|
+
const notes = [];
|
|
9360
|
+
const hintMode = options.mode === "forward" ? "forward" : "reverse";
|
|
9361
|
+
for (const preset of presets) {
|
|
9362
|
+
const prefix = claimPrefix(preset.prefix, preset.host, taken);
|
|
9363
|
+
const baseUrl = baseUrlFor(options.listen, prefix, preset.baseUrlSuffix);
|
|
9364
|
+
routes.push({
|
|
9365
|
+
prefix,
|
|
9366
|
+
upstream: `https://${preset.host}`,
|
|
9367
|
+
host: preset.host,
|
|
9368
|
+
allow: presetAllow(preset),
|
|
9369
|
+
methods: preset.methods,
|
|
9370
|
+
paths: preset.paths,
|
|
9371
|
+
label: preset.label,
|
|
9372
|
+
baseUrl
|
|
9373
|
+
});
|
|
9374
|
+
for (const hint of preset.env(hintMode)) envHints.push({
|
|
9375
|
+
...hint,
|
|
9376
|
+
value: hint.value.replace("{{base}}", baseUrl)
|
|
9377
|
+
});
|
|
9378
|
+
if (preset.note) notes.push(`${preset.id}: ${preset.note}`);
|
|
9379
|
+
}
|
|
9380
|
+
if (hintMode === "forward") envHints.unshift({
|
|
9381
|
+
name: "HTTPS_PROXY",
|
|
9382
|
+
value: `http://${options.forwardListen}`
|
|
9383
|
+
}, {
|
|
9384
|
+
name: "NODE_EXTRA_CA_CERTS",
|
|
9385
|
+
value: `$PWD/${options.caCert}`,
|
|
9386
|
+
note: "or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime."
|
|
9387
|
+
});
|
|
9388
|
+
return {
|
|
9389
|
+
mode: options.mode,
|
|
9390
|
+
listen: options.listen,
|
|
9391
|
+
forwardListen: options.forwardListen,
|
|
9392
|
+
routes,
|
|
9393
|
+
unmatched: options.unmatched,
|
|
9394
|
+
caCert: options.caCert,
|
|
9395
|
+
caKey: options.caKey,
|
|
9396
|
+
...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
|
|
9397
|
+
...options.secretsRefresh ? { secretsRefresh: options.secretsRefresh } : {},
|
|
9398
|
+
...options.control ? { control: options.control } : {},
|
|
9399
|
+
...options.tasks ? { tasks: options.tasks } : {},
|
|
9400
|
+
...options.activity ? { activity: options.activity } : {},
|
|
9401
|
+
envHints,
|
|
9402
|
+
notes
|
|
9403
|
+
};
|
|
9404
|
+
}
|
|
9405
|
+
/**
|
|
9406
|
+
* Build a server-policy plan from an agent's published rules.
|
|
9407
|
+
*
|
|
9408
|
+
* The rules are used for **routing only** — one `[[route]]` per distinct host,
|
|
9409
|
+
* so the workload has a base URL to point at — and never copied into the file as
|
|
9410
|
+
* authorization. That is the whole trade of server mode: adding an upstream
|
|
9411
|
+
* becomes a dashboard change, and a rule this file also stated would be a
|
|
9412
|
+
* startup error rather than a belt-and-braces duplicate.
|
|
9413
|
+
*/
|
|
9414
|
+
function planFromPolicy(args, options) {
|
|
9415
|
+
const taken = /* @__PURE__ */ new Set();
|
|
9416
|
+
const routes = [];
|
|
9417
|
+
const envHints = [];
|
|
9418
|
+
const notes = [];
|
|
9419
|
+
const hintMode = options.mode === "forward" ? "forward" : "reverse";
|
|
9420
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9421
|
+
for (const rule of args.rules) {
|
|
9422
|
+
if (!rule.host || seen.has(rule.host)) continue;
|
|
9423
|
+
seen.add(rule.host);
|
|
9424
|
+
const preset = PRESET_BY_HOST.get(rule.host);
|
|
9425
|
+
const prefix = claimPrefix(preset?.prefix, rule.host, taken);
|
|
9426
|
+
const baseUrl = baseUrlFor(options.listen, prefix, preset?.baseUrlSuffix ?? "");
|
|
9427
|
+
routes.push({
|
|
9428
|
+
prefix,
|
|
9429
|
+
upstream: `https://${rule.host}`,
|
|
9430
|
+
host: rule.host,
|
|
9431
|
+
allow: [],
|
|
9432
|
+
methods: [],
|
|
9433
|
+
paths: [],
|
|
9434
|
+
baseUrl
|
|
9435
|
+
});
|
|
9436
|
+
if (preset) for (const hint of preset.env(hintMode)) envHints.push({
|
|
9437
|
+
...hint,
|
|
9438
|
+
value: hint.value.replace("{{base}}", baseUrl)
|
|
9439
|
+
});
|
|
9440
|
+
}
|
|
9441
|
+
if (hintMode === "forward") envHints.unshift({
|
|
9442
|
+
name: "HTTPS_PROXY",
|
|
9443
|
+
value: `http://${options.forwardListen}`
|
|
9444
|
+
}, {
|
|
9445
|
+
name: "NODE_EXTRA_CA_CERTS",
|
|
9446
|
+
value: `$PWD/${options.caCert}`,
|
|
9447
|
+
note: "or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime."
|
|
9448
|
+
});
|
|
9449
|
+
if (args.rules.length === 0) notes.push("The published policy has no rules yet, so this proxy permits nothing until one is published.");
|
|
9450
|
+
return {
|
|
9451
|
+
mode: options.mode,
|
|
9452
|
+
listen: options.listen,
|
|
9453
|
+
forwardListen: options.forwardListen,
|
|
9454
|
+
routes,
|
|
9455
|
+
policy: {
|
|
9456
|
+
agent: args.agent,
|
|
9457
|
+
agents: args.agents,
|
|
9458
|
+
refreshInterval: args.refreshInterval,
|
|
9459
|
+
signers: args.signers
|
|
9460
|
+
},
|
|
9461
|
+
unmatched: options.unmatched,
|
|
9462
|
+
caCert: options.caCert,
|
|
9463
|
+
caKey: options.caKey,
|
|
9464
|
+
...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
|
|
9465
|
+
...options.control ? { control: options.control } : {},
|
|
9466
|
+
...options.tasks ? { tasks: options.tasks } : {},
|
|
9467
|
+
...options.activity ? { activity: options.activity } : {},
|
|
9468
|
+
envHints,
|
|
9469
|
+
notes
|
|
9470
|
+
};
|
|
9471
|
+
}
|
|
9472
|
+
/** Host → preset, for naming routes generated from published policy. */
|
|
9473
|
+
const PRESET_BY_HOST = /* @__PURE__ */ new Map();
|
|
9474
|
+
for (const id of [
|
|
9475
|
+
"openai",
|
|
9476
|
+
"anthropic",
|
|
9477
|
+
"openrouter",
|
|
9478
|
+
"github"
|
|
9479
|
+
]) {
|
|
9480
|
+
const preset = findPreset(id);
|
|
9481
|
+
if (preset?.host) PRESET_BY_HOST.set(preset.host, preset);
|
|
9482
|
+
}
|
|
9483
|
+
/** YAML double-quoted scalar. Compose values here are hostnames and URLs. */
|
|
9484
|
+
function yamlString(value) {
|
|
9485
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
9486
|
+
}
|
|
9487
|
+
const COMPOSE_DEFAULTS = {
|
|
9488
|
+
service: "seekrit-proxy",
|
|
9489
|
+
workload: "agent",
|
|
9490
|
+
publish: false
|
|
9491
|
+
};
|
|
9492
|
+
/**
|
|
9493
|
+
* A `docker compose` sidecar snippet for a generated config.
|
|
9494
|
+
*
|
|
9495
|
+
* The container case differs from the local one in exactly the ways that break a
|
|
9496
|
+
* copied-from-the-docs compose file: the proxy has to bind `0.0.0.0` to be
|
|
9497
|
+
* reachable from a sibling container, the workload dials it by *service name*
|
|
9498
|
+
* rather than loopback, and in forward mode the CA has to live on a shared
|
|
9499
|
+
* volume or the workload trusts a certificate the proxy no longer has.
|
|
9500
|
+
*/
|
|
9501
|
+
function renderComposeSnippet(plan, options) {
|
|
9502
|
+
const reverse = plan.mode === "reverse" || plan.mode === "both";
|
|
9503
|
+
const forward = plan.mode === "forward" || plan.mode === "both";
|
|
9504
|
+
const [, listenPort = "8080"] = splitHostPort(plan.listen);
|
|
9505
|
+
const [, forwardPort = "8081"] = splitHostPort(plan.forwardListen);
|
|
9506
|
+
const host = options.service;
|
|
9507
|
+
const out = [
|
|
9508
|
+
"# seekrit-proxy sidecar — generated by `seekrit proxy compose`.",
|
|
9509
|
+
"#",
|
|
9510
|
+
"# The proxy holds the decrypted secrets; the workload holds only placeholders.",
|
|
9511
|
+
"# Keeping them in separate containers is what makes that boundary real: the",
|
|
9512
|
+
"# service token is in the proxy's environment, where the workload cannot read it.",
|
|
9513
|
+
"services:",
|
|
9514
|
+
` ${host}:`,
|
|
9515
|
+
` image: ${options.image}`
|
|
9516
|
+
];
|
|
9517
|
+
const command = [];
|
|
9518
|
+
if (reverse) command.push("--listen", `0.0.0.0:${listenPort}`);
|
|
9519
|
+
if (command.length > 0) out.push(` command: [${command.map(yamlString).join(", ")}]`);
|
|
9520
|
+
if (forward) {
|
|
9521
|
+
out.push(` # Forward mode: set \`[forward] listen = "0.0.0.0:${forwardPort}"\` in the`);
|
|
9522
|
+
out.push(" # config too — there is no flag for the forward plane's address.");
|
|
9523
|
+
}
|
|
9524
|
+
out.push(" environment:");
|
|
9525
|
+
out.push(" # Never inline the token. Compose reads it from your shell or a .env file.");
|
|
9526
|
+
out.push(" SEEKRIT_TOKEN: ${SEEKRIT_TOKEN:?SEEKRIT_TOKEN is required}");
|
|
9527
|
+
out.push(" volumes:");
|
|
9528
|
+
out.push(" - ./seekrit-proxy.toml:/seekrit-proxy.toml:ro");
|
|
9529
|
+
if (forward) {
|
|
9530
|
+
out.push(" # The interception CA must survive restarts, or the certificate the");
|
|
9531
|
+
out.push(" # workload trusts stops matching the one the proxy mints leaves from.");
|
|
9532
|
+
out.push(" - seekrit-proxy-ca:/ca");
|
|
9533
|
+
}
|
|
9534
|
+
if (options.publish) {
|
|
9535
|
+
out.push(" ports:");
|
|
9536
|
+
if (reverse) out.push(` - ${yamlString(`127.0.0.1:${listenPort}:${listenPort}`)}`);
|
|
9537
|
+
if (forward) out.push(` - ${yamlString(`127.0.0.1:${forwardPort}:${forwardPort}`)}`);
|
|
9538
|
+
} else {
|
|
9539
|
+
out.push(" # No `ports`: reachable on the compose network only, which is what you");
|
|
9540
|
+
out.push(" # want — nothing outside this project can ask the proxy to inject a key.");
|
|
9541
|
+
}
|
|
9542
|
+
out.push(" restart: unless-stopped");
|
|
9543
|
+
out.push("");
|
|
9544
|
+
out.push(` ${options.workload}:`);
|
|
9545
|
+
out.push(" # ← your workload. It never holds a real credential.");
|
|
9546
|
+
out.push(" image: your-agent:latest");
|
|
9547
|
+
out.push(" depends_on:");
|
|
9548
|
+
out.push(` - ${host}`);
|
|
9549
|
+
out.push(" environment:");
|
|
9550
|
+
if (forward) {
|
|
9551
|
+
out.push(` HTTPS_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
|
|
9552
|
+
out.push(` HTTP_PROXY: ${yamlString(`http://${host}:${forwardPort}`)}`);
|
|
9553
|
+
out.push(" NODE_EXTRA_CA_CERTS: \"/ca/seekrit-proxy-ca.pem\"");
|
|
9554
|
+
out.push(" # …or SSL_CERT_FILE / REQUESTS_CA_BUNDLE, depending on the runtime.");
|
|
9555
|
+
}
|
|
9556
|
+
for (const hint of plan.envHints) {
|
|
9557
|
+
if (hint.name === "HTTPS_PROXY" || hint.name === "NODE_EXTRA_CA_CERTS") continue;
|
|
9558
|
+
const value = hint.value.replace(/http:\/\/[^/]+/, `http://${host}:${listenPort}`);
|
|
9559
|
+
out.push(` ${hint.name}: ${yamlString(value)}`);
|
|
9560
|
+
}
|
|
9561
|
+
out.push("");
|
|
9562
|
+
if (forward) {
|
|
9563
|
+
out.push("volumes:");
|
|
9564
|
+
out.push(" seekrit-proxy-ca:");
|
|
9565
|
+
out.push("");
|
|
9566
|
+
}
|
|
9567
|
+
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");
|
|
9568
|
+
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");
|
|
9569
|
+
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.");
|
|
9570
|
+
return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
|
|
9571
|
+
}
|
|
9572
|
+
//#endregion
|
|
9573
|
+
//#region src/proxy.ts
|
|
9574
|
+
/**
|
|
9575
|
+
* `seekrit proxy` — get `seekrit-proxy` running without a Rust toolchain or a
|
|
9576
|
+
* hand-written TOML file.
|
|
9577
|
+
*
|
|
9578
|
+
* The proxy is the only thing here that keeps a decrypted secret out of an
|
|
9579
|
+
* untrusted workload's memory entirely, and it was also the least likely to be
|
|
9580
|
+
* tried: `cargo build` and a config file, before you learn anything. These
|
|
9581
|
+
* subcommands close that gap from both ends —
|
|
9582
|
+
*
|
|
9583
|
+
* seekrit proxy run --preset openai # nothing installed, nothing written
|
|
9584
|
+
* seekrit proxy init --agent nova # a reviewable file from live policy
|
|
9585
|
+
*
|
|
9586
|
+
* — and none of them changes what the proxy does. The generated config is the
|
|
9587
|
+
* same config; the fetched binary is the released binary, checksum-verified.
|
|
9588
|
+
*/
|
|
9589
|
+
const DEFAULT_CONFIG = "seekrit-proxy.toml";
|
|
9590
|
+
/**
|
|
9591
|
+
* The exact grammar `seekrit_cache::parse_duration` accepts: a positive integer,
|
|
9592
|
+
* optionally suffixed `s`/`m`/`h`/`d` (bare means seconds). Notably **not** `ms`
|
|
9593
|
+
* — `--refresh 500ms` is the typo this catches, and catching it here saves a
|
|
9594
|
+
* binary download before the proxy's own startup error.
|
|
9595
|
+
*/
|
|
9596
|
+
const DURATION = /^[1-9]\d*[smhd]?$/;
|
|
9597
|
+
function duration(value, flag) {
|
|
9598
|
+
if (value === void 0) return void 0;
|
|
9599
|
+
const trimmed = value.trim();
|
|
9600
|
+
if (!DURATION.test(trimmed)) fail(`${flag} must be a duration like 30s, 10m, 24h, or 7d (got "${value}")`);
|
|
9601
|
+
return trimmed;
|
|
9602
|
+
}
|
|
9603
|
+
function planOptions(options) {
|
|
9604
|
+
const mode = options.mode ?? PLAN_DEFAULTS.mode;
|
|
9605
|
+
if (mode !== "reverse" && mode !== "forward" && mode !== "both") fail(`--mode must be reverse, forward, or both (got "${mode}")`);
|
|
9606
|
+
const unmatched = options.unmatched ?? PLAN_DEFAULTS.unmatched;
|
|
9607
|
+
if (unmatched !== "tunnel" && unmatched !== "deny") fail(`--unmatched must be tunnel or deny (got "${unmatched}")`);
|
|
9608
|
+
const listen = options.listen ?? PLAN_DEFAULTS.listen;
|
|
9609
|
+
const forwardListen = options.forwardListen ?? PLAN_DEFAULTS.forwardListen;
|
|
9610
|
+
if (mode === "both" && listen === forwardListen) fail(`--listen and --forward-listen cannot both be ${listen} — the two planes need separate ports`);
|
|
9611
|
+
if (options.control && (options.control === listen || options.control === forwardListen)) fail(`--control cannot share an address with a data plane (${options.control})`);
|
|
9612
|
+
const refresh = duration(options.refresh, "--refresh");
|
|
9613
|
+
return {
|
|
9614
|
+
mode,
|
|
9615
|
+
listen,
|
|
9616
|
+
forwardListen,
|
|
9617
|
+
unmatched,
|
|
9618
|
+
caCert: options.caCert ?? PLAN_DEFAULTS.caCert,
|
|
9619
|
+
caKey: options.caKey ?? PLAN_DEFAULTS.caKey,
|
|
9620
|
+
...options.cache || options.cacheMaxAge ? { cacheMaxAge: duration(options.cacheMaxAge, "--cache-max-age") ?? "24h" } : {},
|
|
9621
|
+
...refresh ? { secretsRefresh: refresh } : {},
|
|
9622
|
+
...options.control ? { control: {
|
|
9623
|
+
listen: options.control,
|
|
9624
|
+
ttl: "1h",
|
|
9625
|
+
maxTtl: "12h"
|
|
9626
|
+
} } : {},
|
|
9627
|
+
...options.tasks || options.tasksCacheTtl ? { tasks: { cacheTtl: duration(options.tasksCacheTtl, "--tasks-cache-ttl") ?? "30s" } } : {},
|
|
9628
|
+
...options.activity || options.activityInterval ? { activity: { flushInterval: duration(options.activityInterval, "--activity-interval") ?? "60s" } } : {}
|
|
9629
|
+
};
|
|
9630
|
+
}
|
|
9631
|
+
/**
|
|
9632
|
+
* Turn `--host api.foo.com=FOO_KEY,BAR_KEY` into a preset-shaped rule.
|
|
9633
|
+
*
|
|
9634
|
+
* The `=SECRET` half is optional on purpose: a rule with no `allow` permits an
|
|
9635
|
+
* operation without letting a credential travel with it, which is a real thing
|
|
9636
|
+
* to want and impossible to express if the flag demanded a secret name.
|
|
9637
|
+
*/
|
|
9638
|
+
function presetFromHostSpec(spec, index) {
|
|
9639
|
+
const [hostPart = "", secretPart] = spec.split("=", 2);
|
|
9640
|
+
const host = hostPart.trim().toLowerCase();
|
|
9641
|
+
if (!host) fail(`--host needs a hostname (got "${spec}")`);
|
|
9642
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//.test(host) || host.includes("/")) fail(`--host takes a bare hostname, not a URL (got "${host}")`);
|
|
9643
|
+
if (host.includes(":")) fail(`--host takes a hostname without a port (got "${host}")`);
|
|
9644
|
+
const secrets = (secretPart ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
9645
|
+
for (const name of secrets) if (!/^[A-Za-z0-9_]+$/.test(name)) fail(`"${name}" is not a valid secret name (letters, digits, and _ only)`);
|
|
9646
|
+
const labels = host.split(".").filter(Boolean);
|
|
9647
|
+
while (labels.length > 1 && (labels[0] === "api" || labels[0] === "www")) labels.shift();
|
|
9648
|
+
return {
|
|
9649
|
+
id: host,
|
|
9650
|
+
label: host,
|
|
9651
|
+
host,
|
|
9652
|
+
prefix: `/${(labels[0] ?? `upstream${index}`).replace(/[^a-z0-9-]/g, "") || `upstream${index}`}`,
|
|
9653
|
+
secret: secrets[0] ?? "",
|
|
9654
|
+
allow: secrets,
|
|
9655
|
+
methods: [],
|
|
9656
|
+
paths: [],
|
|
9657
|
+
baseUrlSuffix: "",
|
|
9658
|
+
env: () => secrets[0] ? [{
|
|
9659
|
+
name: secrets[0],
|
|
9660
|
+
value: `{{seekrit:${secrets[0]}}}`
|
|
9661
|
+
}] : [],
|
|
9662
|
+
...secrets.length > 1 ? { note: `also allows ${secrets.slice(1).join(", ")} — pass each as a placeholder.` } : {}
|
|
9663
|
+
};
|
|
9664
|
+
}
|
|
9665
|
+
/** Collect the presets/hosts a `--preset`/`--host`-driven invocation names. */
|
|
9666
|
+
function gatherPresets(options) {
|
|
9667
|
+
const chosen = [];
|
|
9668
|
+
for (const id of options.preset ?? []) {
|
|
9669
|
+
const preset = findPreset(id);
|
|
9670
|
+
if (!preset) fail(`unknown preset "${id}" — try one of: ${presetIds().join(", ")}`);
|
|
9671
|
+
if (preset.requiresBaseUrl && !options.baseUrl) fail(`preset "${id}" needs --base-url (e.g. --base-url https://litellm.internal:4000)`);
|
|
9672
|
+
chosen.push(specialize(preset, {
|
|
9673
|
+
...options.baseUrl ? { baseUrl: options.baseUrl } : {},
|
|
9674
|
+
...options.secret ? { secret: options.secret } : {},
|
|
9675
|
+
...options.prefix ? { prefix: options.prefix } : {}
|
|
9676
|
+
}));
|
|
9677
|
+
}
|
|
9678
|
+
(options.host ?? []).forEach((spec, i) => {
|
|
9679
|
+
chosen.push(presetFromHostSpec(spec, i));
|
|
9680
|
+
});
|
|
9681
|
+
return chosen;
|
|
9682
|
+
}
|
|
9683
|
+
/**
|
|
9684
|
+
* Read an agent's published policy through the route the proxy itself polls.
|
|
9685
|
+
*
|
|
9686
|
+
* Deliberately the proxy-facing route rather than the org-scoped admin one: the
|
|
9687
|
+
* machine that runs `seekrit proxy init` is usually the machine that holds the
|
|
9688
|
+
* *proxy's* token, not an admin's, and a config generated from the same bytes the
|
|
9689
|
+
* proxy will fetch cannot disagree with what the proxy then does.
|
|
9690
|
+
*/
|
|
9691
|
+
async function fetchPolicy(ctx, agentRef, orgSlug) {
|
|
9692
|
+
let served;
|
|
9693
|
+
try {
|
|
9694
|
+
served = await ctx.client.getAgentPolicyBundle(agentRef);
|
|
9695
|
+
} catch (err) {
|
|
9696
|
+
fail(`could not read policy for agent "${agentRef}": ${err instanceof Error ? err.message : String(err)}\n Publish a policy for it in the dashboard first (Agents → the identity → Publish),
|
|
9697
|
+
or generate a file-policy config instead with --preset / --host.`);
|
|
9698
|
+
}
|
|
9699
|
+
let rules = [];
|
|
9700
|
+
try {
|
|
9701
|
+
rules = parseAgentPolicyUnverified(served.bundle).rules;
|
|
9702
|
+
} catch (err) {
|
|
9703
|
+
fail(`the API served a policy bundle this CLI cannot decode: ${err instanceof Error ? err.message : String(err)}`);
|
|
9704
|
+
}
|
|
9705
|
+
const signers = [];
|
|
9706
|
+
try {
|
|
9707
|
+
const org = await resolveOrg(ctx, orgSlug);
|
|
9708
|
+
const { signer } = await ctx.client.getMyPolicySigner(org.id);
|
|
9709
|
+
if (signer) signers.push({
|
|
9710
|
+
thumbprint: signer.thumbprint,
|
|
9711
|
+
provenance: "own"
|
|
9712
|
+
});
|
|
9713
|
+
} catch {}
|
|
9714
|
+
if (!signers.some((s) => s.thumbprint === served.signerThumbprint)) signers.push({
|
|
9715
|
+
thumbprint: served.signerThumbprint,
|
|
9716
|
+
provenance: "published"
|
|
9717
|
+
});
|
|
9718
|
+
return {
|
|
9719
|
+
slug: served.agent.slug,
|
|
9720
|
+
rules,
|
|
9721
|
+
signers,
|
|
9722
|
+
version: served.version
|
|
9723
|
+
};
|
|
9724
|
+
}
|
|
9725
|
+
/** Build the plan a `--agent` / `--preset` invocation describes. */
|
|
9726
|
+
async function buildPlan(options) {
|
|
9727
|
+
const plan = planOptions(options);
|
|
9728
|
+
if (options.agent) {
|
|
9729
|
+
if ((options.preset ?? []).length > 0 || (options.host ?? []).length > 0) fail("--agent takes the rules from published policy, so --preset/--host cannot also apply.\n Server-policy mode rejects local rules rather than silently ignoring them.");
|
|
9730
|
+
const policy = await fetchPolicy(buildContext(), options.agent, options.org);
|
|
9731
|
+
const agents = options.agents?.length ? options.agents : [policy.slug];
|
|
9732
|
+
process.stderr.write(`seekrit: agent ${policy.slug} — policy v${policy.version}, ${policy.rules.length} rule(s)\n`);
|
|
9733
|
+
return planFromPolicy({
|
|
9734
|
+
agent: policy.slug,
|
|
9735
|
+
agents,
|
|
9736
|
+
rules: policy.rules,
|
|
9737
|
+
signers: policy.signers,
|
|
9738
|
+
refreshInterval: plan.secretsRefresh ?? "10s"
|
|
9739
|
+
}, plan);
|
|
9740
|
+
}
|
|
9741
|
+
const presets = gatherPresets(options);
|
|
9742
|
+
if (presets.length === 0) fail("nothing to configure — pass --preset <name> (see `seekrit proxy presets`), --host <host>[=SECRET],\n or --agent <slug> to take the rules from published policy.");
|
|
9743
|
+
return planFromPresets(presets, plan);
|
|
9744
|
+
}
|
|
9745
|
+
/** Add the generation flags to a command, so `init` and `run` stay in step. */
|
|
9746
|
+
function withGenerateOptions(cmd) {
|
|
9747
|
+
return cmd.option("--preset <name>", "gateway preset (repeatable; see `seekrit proxy presets`)", (value, acc = []) => [...acc, value]).option("--host <host[=SECRET,…]>", "ad-hoc rule: bare hostname, optionally the secrets it may receive (repeatable)", (value, acc = []) => [...acc, value]).option("--base-url <url>", "upstream base URL for an OpenAI-compatible gateway").option("--secret <NAME>", "override a preset's secret name").option("--prefix <path>", "override a preset's route prefix").option("--agent <slug>", "take the rules from published agent policy (server mode)").option("--agents <slug>", "additional identities this proxy may serve", (v, a = []) => [...a, v]).option("--org <slug>", "organization (for --agent)").option("--mode <reverse|forward|both>", "which data plane(s) to configure", "reverse").option("--listen <addr>", `reverse-proxy address (default: ${PLAN_DEFAULTS.listen})`).option("--forward-listen <addr>", `forward-proxy address (default: ${PLAN_DEFAULTS.forwardListen})`).option("--unmatched <tunnel|deny>", "what to do with an unruled host in forward mode").option("--ca-cert <path>", "interception CA certificate path (forward mode)").option("--ca-key <path>", "interception CA key path (forward mode)").option("--cache", "add a [cache] block so the proxy can start during an outage").option("--cache-max-age <dur>", "how stale a cached resolve may be (implies --cache)").option("--refresh <dur>", "re-resolve/re-fetch interval").option("--control <addr>", "add a [control] listener for per-agent session tickets").option("--tasks", "add a [tasks] block so this proxy honours runs dispatched with `seekrit agents dispatch`").option("--tasks-cache-ttl <dur>", "how long an introspected task is reused (implies --tasks)").option("--activity", "add an [activity] block so this proxy reports aggregate decisions for `seekrit agents review`").option("--activity-interval <dur>", "how often counts are flushed (implies --activity)");
|
|
9748
|
+
}
|
|
9749
|
+
function registerProxyCommands(program) {
|
|
9750
|
+
const proxy = program.command("proxy").description("run and configure the agent egress proxy (`seekrit proxy --help`)");
|
|
9751
|
+
proxy.command("presets").description("list the ready-made upstream presets").option("--json", "machine-readable output").action((options) => {
|
|
9752
|
+
emit(options, { presets: PROXY_PRESETS }, () => {
|
|
9753
|
+
printTable(PROXY_PRESETS, [
|
|
9754
|
+
col("PRESET", (p) => p.id),
|
|
9755
|
+
col("HOST", (p) => p.host || "(--base-url)"),
|
|
9756
|
+
col("SECRET", (p) => p.secret),
|
|
9757
|
+
col("PREFIX", (p) => p.prefix),
|
|
9758
|
+
col("DESCRIPTION", (p) => p.label)
|
|
9759
|
+
], "no presets");
|
|
9760
|
+
process.stderr.write("\nUse one with: seekrit proxy run --preset openai\nAnything not listed here works too: --host api.example.com=EXAMPLE_API_KEY\n");
|
|
9761
|
+
});
|
|
9762
|
+
});
|
|
9763
|
+
withGenerateOptions(proxy.command("init").description("write a seekrit-proxy.toml from presets or published policy")).option("-o, --out <path>", "where to write it", DEFAULT_CONFIG).option("--print", "write to stdout instead of a file").option("--force", "overwrite an existing file").action(async (options) => {
|
|
9764
|
+
const plan = await buildPlan(options);
|
|
9765
|
+
const text = renderProxyConfig(plan);
|
|
9766
|
+
if (options.print) {
|
|
9767
|
+
process.stdout.write(text);
|
|
9768
|
+
return;
|
|
9769
|
+
}
|
|
9770
|
+
const out = resolve(options.out);
|
|
9771
|
+
if (existsSync(out) && !options.force) fail(`${options.out} already exists — pass --force to overwrite, or --print to review it`);
|
|
9772
|
+
writeFileSync(out, text, { mode: 420 });
|
|
9773
|
+
process.stderr.write(`Wrote ${options.out}\n`);
|
|
9774
|
+
process.stderr.write(`
|
|
9775
|
+
Next:
|
|
9776
|
+
export SEEKRIT_TOKEN=skt_… # a service token with a key grant
|
|
9777
|
+
seekrit proxy run --config ${options.out}\n`);
|
|
9778
|
+
if (plan.policy) process.stderr.write("\nBefore you rely on this: confirm each pinned thumbprint against the dashboard's\ntrust-anchor panel. `signers` is the one field that must not come from the API.\n");
|
|
9779
|
+
});
|
|
9780
|
+
withGenerateOptions(proxy.command("run").description("fetch the proxy binary if needed and run it")).option("-c, --config <path>", "config file to use", DEFAULT_CONFIG).option("--proxy-version <version>", `binary version (default: ${PROXY_VERSION})`).option("--print-config", "print the config that would be used, then exit").action(async (options) => {
|
|
9781
|
+
const generating = Boolean(options.agent) || (options.preset ?? []).length > 0 || (options.host ?? []).length > 0;
|
|
9782
|
+
let configPath = resolve(options.config);
|
|
9783
|
+
let ephemeralDir;
|
|
9784
|
+
if (generating) {
|
|
9785
|
+
const text = renderProxyConfig(await buildPlan(options));
|
|
9786
|
+
if (options.printConfig) {
|
|
9787
|
+
process.stdout.write(text);
|
|
9788
|
+
return;
|
|
9789
|
+
}
|
|
9790
|
+
ephemeralDir = mkdtempSync(join(tmpdir(), "seekrit-proxy-"));
|
|
9791
|
+
configPath = join(ephemeralDir, DEFAULT_CONFIG);
|
|
9792
|
+
writeFileSync(configPath, text, { mode: 384 });
|
|
9793
|
+
process.stderr.write("seekrit: running with a generated config (nothing written to the project — use `seekrit proxy init` to keep it)\n");
|
|
9794
|
+
} else if (options.printConfig) fail("--print-config needs generation flags (--preset/--host/--agent)");
|
|
9795
|
+
else if (!existsSync(configPath)) fail(`no ${options.config} here, and no --preset/--host/--agent to generate one.\n Try: seekrit proxy run --preset openai
|
|
9796
|
+
or: seekrit proxy init --preset openai (to write a reviewable file first)`);
|
|
9797
|
+
if (!process.env.SEEKRIT_TOKEN) process.stderr.write("seekrit: SEEKRIT_TOKEN is not set — the proxy needs a service token with a key\n grant for the environment it serves, and will refuse to start without one.\n");
|
|
9798
|
+
try {
|
|
9799
|
+
process.exitCode = await runProxyBinary(["--config", configPath], { ...options.proxyVersion ? { version: options.proxyVersion } : {} });
|
|
9800
|
+
} finally {
|
|
9801
|
+
if (ephemeralDir) rmSync(ephemeralDir, {
|
|
9802
|
+
recursive: true,
|
|
9803
|
+
force: true
|
|
9804
|
+
});
|
|
9805
|
+
}
|
|
9806
|
+
});
|
|
9807
|
+
proxy.command("install").description("download the proxy binary and print its path").option("--proxy-version <version>", `version to fetch (default: ${PROXY_VERSION})`).option("--force", "re-download even if it is already cached").action(async (options) => {
|
|
9808
|
+
const path = await resolveProxyBinary({
|
|
9809
|
+
...options.proxyVersion ? { version: options.proxyVersion } : {},
|
|
9810
|
+
...options.force ? { force: true } : {}
|
|
9811
|
+
});
|
|
9812
|
+
console.log(path);
|
|
9813
|
+
const { target } = detectTarget();
|
|
9814
|
+
process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.10.0")} (${target})\n`);
|
|
9815
|
+
});
|
|
9816
|
+
proxy.command("where").description("show which binary `seekrit proxy run` would use, without fetching it").option("--proxy-version <version>", `version to report (default: ${PROXY_VERSION})`).option("--json", "machine-readable output").action((options) => {
|
|
9817
|
+
const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
|
|
9818
|
+
const override = process.env.SEEKRIT_PROXY_BIN;
|
|
9819
|
+
const { target, exe } = detectTarget();
|
|
9820
|
+
const path = override ?? proxyBinaryPath(version, target, exe);
|
|
9821
|
+
const info = {
|
|
9822
|
+
path,
|
|
9823
|
+
version,
|
|
9824
|
+
target,
|
|
9825
|
+
source: override ? "SEEKRIT_PROXY_BIN" : existsSync(path) ? "cache" : "not-yet-downloaded",
|
|
9826
|
+
baseUrl: process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev"
|
|
9827
|
+
};
|
|
9828
|
+
emit(options, info, () => {
|
|
9829
|
+
console.log(info.path);
|
|
9830
|
+
process.stderr.write(`${info.version} · ${info.target} · ${info.source}\n`);
|
|
9831
|
+
});
|
|
9832
|
+
});
|
|
9833
|
+
withGenerateOptions(proxy.command("compose").description("print a docker compose sidecar snippet for a generated config")).option("--service <name>", "compose service name for the proxy", COMPOSE_DEFAULTS.service).option("--workload <name>", "compose service name for your workload", COMPOSE_DEFAULTS.workload).option("--image <ref>", "image to pin (default: seekritdev/proxy:<version>)").option("--publish", "also publish the proxy's ports to the host").action(async (options) => {
|
|
9834
|
+
const plan = await buildPlan(options);
|
|
9835
|
+
process.stdout.write(renderComposeSnippet(plan, {
|
|
9836
|
+
service: options.service,
|
|
9837
|
+
workload: options.workload,
|
|
9838
|
+
image: options.image ?? `seekritdev/proxy:0.10.0`,
|
|
9839
|
+
publish: Boolean(options.publish)
|
|
9840
|
+
}));
|
|
9841
|
+
});
|
|
9842
|
+
}
|
|
9843
|
+
//#endregion
|
|
7363
9844
|
//#region src/redis.ts
|
|
7364
9845
|
/**
|
|
7365
9846
|
* `seekrit redis` — temporary Redis (6+) credentials (Vault-style dynamic
|
|
@@ -7512,17 +9993,6 @@ function collect$1(value, acc) {
|
|
|
7512
9993
|
* Rotated values are never printed here. Read them like any other secret
|
|
7513
9994
|
* (`seekrit secrets get NAME`), which decrypts locally.
|
|
7514
9995
|
*/
|
|
7515
|
-
/** Parse a duration like `30m`, `24h`, `90d`, or a bare seconds count. */
|
|
7516
|
-
function parseDurationSeconds(input, flag) {
|
|
7517
|
-
const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
|
|
7518
|
-
if (!m) fail(`invalid ${flag} "${input}" (try 12h, 7d, 90d)`);
|
|
7519
|
-
return Number(m[1]) * ({
|
|
7520
|
-
s: 1,
|
|
7521
|
-
m: 60,
|
|
7522
|
-
h: 3600,
|
|
7523
|
-
d: 86400
|
|
7524
|
-
}[m[2] || "s"] ?? 1);
|
|
7525
|
-
}
|
|
7526
9996
|
function formatInterval(seconds) {
|
|
7527
9997
|
if (seconds % 86400 === 0) return `${seconds / 86400}d`;
|
|
7528
9998
|
if (seconds % 3600 === 0) return `${seconds / 3600}h`;
|
|
@@ -8023,6 +10493,17 @@ function assertRailwayId(value, flag) {
|
|
|
8023
10493
|
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} should be a Railway UUID, not "${id}"`);
|
|
8024
10494
|
return id;
|
|
8025
10495
|
}
|
|
10496
|
+
/**
|
|
10497
|
+
* A LangGraph Platform deployment id is a UUID — the one in its dashboard URL.
|
|
10498
|
+
* The deployment *name* in the slot is the common slip, and it fails at the
|
|
10499
|
+
* control plane rather than here.
|
|
10500
|
+
*/
|
|
10501
|
+
function assertLanggraphDeploymentId(value) {
|
|
10502
|
+
if (!value) fail("--langgraph-deployment is required for langgraph-platform (a UUID)");
|
|
10503
|
+
const id = value.trim();
|
|
10504
|
+
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(`--langgraph-deployment should be the deployment UUID from its dashboard URL, not "${id}"`);
|
|
10505
|
+
return id;
|
|
10506
|
+
}
|
|
8026
10507
|
/** Reject a single value outside a known set, naming the choices. */
|
|
8027
10508
|
function assertMember(value, allowed, flag, fallback) {
|
|
8028
10509
|
if (value === void 0) return fallback;
|
|
@@ -8147,6 +10628,7 @@ function assertRepoIds(raw) {
|
|
|
8147
10628
|
function credentialNoun(provider) {
|
|
8148
10629
|
if (provider.startsWith("aws-")) return "secret access key";
|
|
8149
10630
|
if (provider === "gcp-secret-manager") return "service-account key JSON";
|
|
10631
|
+
if (provider === "langgraph-platform") return "LangSmith API key";
|
|
8150
10632
|
return "API token";
|
|
8151
10633
|
}
|
|
8152
10634
|
/** Account-scope config for a connection (never the credential itself). */
|
|
@@ -8203,6 +10685,17 @@ function buildConfig(provider, options) {
|
|
|
8203
10685
|
provider: "gcp-secret-manager",
|
|
8204
10686
|
projectId: options.projectId
|
|
8205
10687
|
};
|
|
10688
|
+
case "langgraph-platform": {
|
|
10689
|
+
const region = options.langgraphRegion?.trim();
|
|
10690
|
+
if (region && options.baseUrl) fail("pass --langgraph-region for a LangChain-hosted account or --base-url for a self-hosted one, not both");
|
|
10691
|
+
if (region && !LANGGRAPH_PLATFORM_REGIONS.includes(region)) fail(`unknown --langgraph-region ${region} — one of: ${LANGGRAPH_PLATFORM_REGIONS.join(", ")}`);
|
|
10692
|
+
return {
|
|
10693
|
+
provider: "langgraph-platform",
|
|
10694
|
+
...region ? { region } : {},
|
|
10695
|
+
...options.baseUrl ? { baseUrl: options.baseUrl } : {},
|
|
10696
|
+
...options.langgraphTenant ? { tenantId: options.langgraphTenant.trim() } : {}
|
|
10697
|
+
};
|
|
10698
|
+
}
|
|
8206
10699
|
}
|
|
8207
10700
|
}
|
|
8208
10701
|
/** Where inside the platform a binding writes. */
|
|
@@ -8401,6 +10894,10 @@ function buildDestination(provider, options) {
|
|
|
8401
10894
|
...options.gcpPruneVersions ? { pruneVersions: true } : {}
|
|
8402
10895
|
};
|
|
8403
10896
|
}
|
|
10897
|
+
case "langgraph-platform": return {
|
|
10898
|
+
provider: "langgraph-platform",
|
|
10899
|
+
deploymentId: assertLanggraphDeploymentId(options.langgraphDeployment)
|
|
10900
|
+
};
|
|
8404
10901
|
}
|
|
8405
10902
|
}
|
|
8406
10903
|
/** One-line description of a destination, for list output. */
|
|
@@ -8421,6 +10918,7 @@ function describeDestination(destination) {
|
|
|
8421
10918
|
case "netlify": return `${destination.siteId} (${destination.contexts.map((context) => context === "branch" ? `branch @${destination.branch}` : context).join(", ")})`;
|
|
8422
10919
|
case "bunnyshell": return destination.kind === "environment" ? `environment ${destination.environmentId}` : `project ${destination.projectId} (inherited by new environments)`;
|
|
8423
10920
|
case "gcp-secret-manager": return destination.layout === "json-bundle" ? `${destination.secretId} · json` : `${destination.idPrefix ?? ""}* · one per name`;
|
|
10921
|
+
case "langgraph-platform": return `deployment ${destination.deploymentId}`;
|
|
8424
10922
|
case "github-actions": switch (destination.kind) {
|
|
8425
10923
|
case "repo": return `${destination.owner}/${destination.repo}`;
|
|
8426
10924
|
case "environment": return `${destination.owner}/${destination.repo} @${destination.environment}`;
|
|
@@ -8436,7 +10934,7 @@ function describeDestination(destination) {
|
|
|
8436
10934
|
* application whose environment the binding reads from.
|
|
8437
10935
|
*/
|
|
8438
10936
|
function destinationOptions(command) {
|
|
8439
|
-
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("--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");
|
|
10937
|
+
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");
|
|
8440
10938
|
}
|
|
8441
10939
|
/** Find a connection by id or name — nobody keeps `syc_…` ids in their head. */
|
|
8442
10940
|
async function resolveConnection(ctx, orgId, ref) {
|
|
@@ -8460,7 +10958,7 @@ function registerSyncCommands(program) {
|
|
|
8460
10958
|
col("id", (c) => c.id)
|
|
8461
10959
|
], "no connections — add one with `seekrit sync connect`"));
|
|
8462
10960
|
});
|
|
8463
|
-
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)").option("--project-id <id>", "gcp: project ID or number whose Secret Manager to write").action(async (options) => {
|
|
10961
|
+
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) => {
|
|
8464
10962
|
const provider = assertProvider(options.provider);
|
|
8465
10963
|
const ctx = buildContext();
|
|
8466
10964
|
const ref = await resolveOrg(ctx, options.org);
|
|
@@ -9327,6 +11825,8 @@ registerPgCommands(program);
|
|
|
9327
11825
|
registerMysqlCommands(program);
|
|
9328
11826
|
registerRedisCommands(program);
|
|
9329
11827
|
registerProvisionerCommands(program);
|
|
11828
|
+
registerProxyCommands(program);
|
|
11829
|
+
registerAgentCommands(program);
|
|
9330
11830
|
registerSshCommands(program);
|
|
9331
11831
|
registerAwsCommands(program);
|
|
9332
11832
|
registerGcpCommands(program);
|