@seekrit/cli 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +2791 -55
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8,6 +8,11 @@ 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
+ /** 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;
11
16
  /** A bare hostname: no scheme, no port, no path, no wildcard. */
12
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" });
13
18
  const policyMethodSchema = z.string().trim().toUpperCase().regex(/^[A-Z]{3,10}$/, "method must be an HTTP method name");
@@ -17,7 +22,7 @@ const policyMethodSchema = z.string().trim().toUpperCase().regex(/^[A-Z]{3,10}$/
17
22
  */
18
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" });
19
24
  const policySecretNameSchema = z.string().trim().regex(/^[A-Za-z0-9_]+$/, "secret names are letters, digits, and underscores");
20
- z.object({
25
+ const agentPolicyRuleSchema = z.object({
21
26
  host: policyHostSchema,
22
27
  methods: z.array(policyMethodSchema).max(16).default([]),
23
28
  paths: z.array(policyPathSchema).max(64).default([]),
@@ -44,6 +49,88 @@ z.object({
44
49
  path: z.string().trim().min(1).max(2048),
45
50
  secret: policySecretNameSchema.optional()
46
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
+ }
47
134
  /** A structurally invalid, unverifiable, or expired bundle. */
48
135
  var AgentPolicyError = class extends Error {
49
136
  constructor(message) {
@@ -72,9 +159,149 @@ function parseAgentPolicyUnverified(envelope) {
72
159
  if (!Array.isArray(body.rules) || !body.signer?.jwk) throw new AgentPolicyError("policy bundle is missing rules or signer");
73
160
  return body;
74
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
+ }
75
297
  function utf8Decode$1(bytes) {
76
298
  return new TextDecoder().decode(bytes);
77
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
+ }
78
305
  function fromBase64url(text) {
79
306
  const padded = text.replace(/-/g, "+").replace(/_/g, "/");
80
307
  const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
@@ -82,6 +309,500 @@ function fromBase64url(text) {
82
309
  for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
83
310
  return out;
84
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) });
565
+ //#endregion
566
+ //#region ../../packages/core/src/archive.ts
567
+ /**
568
+ * The **break-glass archive** format: one signed JSON file holding everything
569
+ * seekrit stores for an org, in the form seekrit stores it — ciphertext stays
570
+ * ciphertext. Its whole purpose is to be openable on a machine that has never
571
+ * heard of seekrit, so the format is plain JSON, self-describing, and versioned
572
+ * by name (`seekrit-archive/v1`); a breaking change ships a new format string
573
+ * rather than mutating this one, exactly like the `sc1.`/`wd1.` blob prefixes.
574
+ *
575
+ * Three parts:
576
+ * - `manifest` — what this archive is, and a SHA-256 digest per section.
577
+ * - `signature` — Ed25519 over the canonical manifest, or null when the
578
+ * producing deployment has no signing key configured.
579
+ * - `data` — the sections themselves.
580
+ *
581
+ * Integrity fields (`digest`, `signature.value`, `publicKey`, `keyId`) are
582
+ * lowercase hex. Every blob *inside* `data` keeps its native base64url form, so
583
+ * the one encoding rule to remember is "the archive's own bookkeeping is hex,
584
+ * seekrit's blobs are unchanged".
585
+ *
586
+ * See docs/break-glass-export.md for what is deliberately excluded and why.
587
+ */
588
+ const ARCHIVE_FORMAT = "seekrit-archive/v1";
589
+ /**
590
+ * Section order is part of the format: `manifest.digest` covers the section
591
+ * headers in this order, so a verifier that re-serializes must produce the same
592
+ * list. Appending a section is backwards-compatible; reordering is not.
593
+ */
594
+ const ARCHIVE_SECTIONS = [
595
+ "organization",
596
+ "users",
597
+ "memberships",
598
+ "invites",
599
+ "applications",
600
+ "groups",
601
+ "environments",
602
+ "environmentGroups",
603
+ "environmentKeys",
604
+ "secrets",
605
+ "secretVersions",
606
+ "serviceTokens",
607
+ "m2mClients",
608
+ "kmsKeys",
609
+ "kmsKeyVersions",
610
+ "kmsKeyGrants",
611
+ "recoveryConfig",
612
+ "recoveryShares",
613
+ "rotations",
614
+ "syncConnections",
615
+ "syncBindings",
616
+ "leaseTargets",
617
+ "agentIdentities",
618
+ "agentPolicies",
619
+ "auditLog",
620
+ "keyMaterial"
621
+ ];
622
+ /**
623
+ * Deterministic JSON: object keys sorted recursively, no whitespace. A digest
624
+ * has to be reproducible by a verifier that parsed the file and re-serialized
625
+ * it, and parsing loses key order — so key order must not matter.
626
+ *
627
+ * Arrays keep their order (it is data). Every numeric field in the schema is an
628
+ * integer, so JSON's float formatting never comes into it. `undefined` members
629
+ * are dropped, matching `JSON.stringify`.
630
+ */
631
+ function canonicalJson(value) {
632
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
633
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
634
+ return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
635
+ }
636
+ function bytesToHex(bytes) {
637
+ let out = "";
638
+ for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
639
+ return out;
640
+ }
641
+ /** Throws on odd length or a non-hex character — a malformed field, not a mismatch. */
642
+ function hexToBytes(hex) {
643
+ if (hex.length % 2 !== 0) throw new Error("hex string has odd length");
644
+ const out = new Uint8Array(hex.length / 2);
645
+ for (let i = 0; i < out.length; i++) {
646
+ const byte = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
647
+ if (Number.isNaN(byte)) throw new Error("hex string contains a non-hex character");
648
+ out[i] = byte;
649
+ }
650
+ return out;
651
+ }
652
+ /** `sha256:<hex>` over the UTF-8 bytes of `text`. */
653
+ async function sha256Hex(text) {
654
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
655
+ return `sha256:${bytesToHex(new Uint8Array(digest))}`;
656
+ }
657
+ /** Number a section header reports: array length, or 0/1 for the singleton sections. */
658
+ function sectionCount(value) {
659
+ if (value === null || value === void 0) return 0;
660
+ return Array.isArray(value) ? value.length : 1;
661
+ }
662
+ /** Digest one section's value. Absent singletons hash as `null`, not as omitted. */
663
+ function sectionDigest(value) {
664
+ return sha256Hex(canonicalJson(value ?? null));
665
+ }
666
+ /**
667
+ * Digest over the section-header list. Covers each section's digest, count, and
668
+ * truncation flag, so trimming rows *and* fixing up their digest still breaks
669
+ * the manifest.
670
+ */
671
+ function manifestDigest(sections) {
672
+ return sha256Hex(canonicalJson(sections));
673
+ }
674
+ /** The exact bytes the signature covers: the canonical manifest, digest included. */
675
+ function signingPayload(manifest) {
676
+ return new TextEncoder().encode(canonicalJson(manifest));
677
+ }
678
+ /**
679
+ * Verify an archive's digests and signature. Pure and offline — WebCrypto only,
680
+ * no I/O, so it runs identically in the CLI, a browser page, and a test.
681
+ *
682
+ * Reports every problem it finds rather than throwing on the first, because the
683
+ * useful answer to "my archive won't verify" is *which part*.
684
+ */
685
+ async function verifyArchive(archive, options = {}) {
686
+ const declared = new Map(archive.manifest.sections.map((s) => [s.name, s]));
687
+ const badSections = [];
688
+ const missingSections = [];
689
+ const truncatedSections = [];
690
+ const data = archive.data ?? {};
691
+ for (const header of archive.manifest.sections) {
692
+ if (header.truncated) truncatedSections.push(header.name);
693
+ if (!(header.name in data)) {
694
+ missingSections.push(header.name);
695
+ continue;
696
+ }
697
+ const value = data[header.name];
698
+ if (sectionCount(value) !== header.count || await sectionDigest(value) !== header.digest) badSections.push(header.name);
699
+ }
700
+ const undeclaredSections = Object.keys(data).filter((key) => !declared.has(key));
701
+ const manifestDigestOk = await manifestDigest(archive.manifest.sections) === archive.manifest.digest;
702
+ let signature = "unsigned";
703
+ let signatureNote;
704
+ if (options.skipSignature) {
705
+ signature = "unverifiable";
706
+ signatureNote = "signature checking was skipped";
707
+ } else if (archive.signature) if (options.expectKeyId && options.expectKeyId !== archive.signature.keyId) {
708
+ signature = "invalid";
709
+ signatureNote = `signed by key ${archive.signature.keyId}, expected ${options.expectKeyId}`;
710
+ } else try {
711
+ signature = await verifySignature(archive.manifest, archive.signature) ? "valid" : "invalid";
712
+ } catch (err) {
713
+ signature = "unverifiable";
714
+ signatureNote = err instanceof Error ? err.message : String(err);
715
+ }
716
+ return {
717
+ ok: badSections.length === 0 && missingSections.length === 0 && undeclaredSections.length === 0 && manifestDigestOk && signature === "valid",
718
+ badSections,
719
+ undeclaredSections,
720
+ missingSections,
721
+ manifestDigestOk,
722
+ signature,
723
+ signatureNote,
724
+ truncatedSections
725
+ };
726
+ }
727
+ /**
728
+ * Ed25519 verify over the canonical manifest. Plain WebCrypto: raw public-key
729
+ * import plus `verify` is supported in Workers, Node ≥18, and current browsers,
730
+ * so no verifier anywhere needs a dependency. Throws (rather than returning
731
+ * false) when the runtime has no Ed25519 at all, so "old browser" is reported
732
+ * as unverifiable instead of as a bad signature.
733
+ */
734
+ async function verifySignature(manifest, signature) {
735
+ if (signature.algorithm !== "ed25519") throw new Error(`unsupported signature algorithm: ${signature.algorithm}`);
736
+ const publicKey = await crypto.subtle.importKey("raw", hexToBytes(signature.publicKey), { name: "Ed25519" }, false, ["verify"]);
737
+ return crypto.subtle.verify({ name: "Ed25519" }, publicKey, hexToBytes(signature.value), signingPayload(manifest));
738
+ }
739
+ const sectionHeaderSchema = z.object({
740
+ name: z.enum(ARCHIVE_SECTIONS),
741
+ count: z.number().int().min(0),
742
+ truncated: z.boolean(),
743
+ digest: z.string().regex(/^sha256:[0-9a-f]{64}$/)
744
+ });
745
+ const manifestSchema = z.object({
746
+ archiveId: z.string().min(1),
747
+ createdAt: z.string().min(1),
748
+ org: z.object({
749
+ id: z.string(),
750
+ slug: z.string(),
751
+ name: z.string()
752
+ }),
753
+ producer: z.object({
754
+ service: z.string(),
755
+ environment: z.string(),
756
+ formatVersion: z.string()
757
+ }),
758
+ requestedBy: z.object({
759
+ actorType: z.string(),
760
+ actorId: z.string(),
761
+ label: z.string().nullable()
762
+ }),
763
+ options: z.object({
764
+ includeVersions: z.boolean(),
765
+ includeAudit: z.boolean(),
766
+ auditLimit: z.number().int().min(0)
767
+ }),
768
+ sections: z.array(sectionHeaderSchema),
769
+ digest: z.string().regex(/^sha256:[0-9a-f]{64}$/)
770
+ });
771
+ const signatureSchema = z.object({
772
+ algorithm: z.literal("ed25519"),
773
+ publicKey: z.string().regex(/^[0-9a-f]{64}$/),
774
+ keyId: z.string().regex(/^[0-9a-f]{16}$/),
775
+ value: z.string().regex(/^[0-9a-f]{128}$/)
776
+ });
777
+ /**
778
+ * Envelope schema. `data` is validated only as an object: a v1 verifier must
779
+ * still be able to check and decrypt an archive from a later producer that
780
+ * appended a section, and unknown sections are caught by
781
+ * `verifyArchive`'s undeclared/declared cross-check rather than by rejecting
782
+ * the file outright.
783
+ */
784
+ const archiveSchema = z.object({
785
+ format: z.literal(ARCHIVE_FORMAT),
786
+ manifest: manifestSchema,
787
+ signature: signatureSchema.nullable(),
788
+ data: z.record(z.string(), z.unknown())
789
+ });
790
+ /** Parse untrusted JSON text into an archive. Throws with a readable message. */
791
+ function parseArchive(text) {
792
+ let json;
793
+ try {
794
+ json = JSON.parse(text);
795
+ } catch {
796
+ throw new Error("not valid JSON — is this a seekrit archive?");
797
+ }
798
+ const parsed = archiveSchema.safeParse(json);
799
+ if (!parsed.success) {
800
+ const first = parsed.error.issues[0];
801
+ const where = first?.path.join(".") || "archive";
802
+ throw new Error(`not a ${ARCHIVE_FORMAT} archive: ${where}: ${first?.message ?? "invalid"}`);
803
+ }
804
+ return parsed.data;
805
+ }
85
806
  /** All catalog keys as a runtime array (for iteration / zod enums). */
86
807
  const ENTITLEMENT_KEYS = Object.keys({
87
808
  "feature.kms": {
@@ -1183,6 +1904,7 @@ const AUDIT_ACTIONS = [
1183
1904
  "org.member_invited",
1184
1905
  "org.invite_revoked",
1185
1906
  "org.mfa_policy_changed",
1907
+ "org.exported",
1186
1908
  "user.keys_updated",
1187
1909
  "user.notification_prefs_updated",
1188
1910
  "app.created",
@@ -1267,7 +1989,9 @@ const AUDIT_ACTIONS = [
1267
1989
  "agent.updated",
1268
1990
  "agent.deleted",
1269
1991
  "agent.policy_published",
1270
- "agent.policy_rolled_back"
1992
+ "agent.policy_rolled_back",
1993
+ "agent.task_dispatched",
1994
+ "agent.task_revoked"
1271
1995
  ];
1272
1996
  /**
1273
1997
  * Transactional notification emails seekrit can send. Each id is one
@@ -1366,6 +2090,13 @@ const inviteRoleSchema = z.enum(["admin", "member"]);
1366
2090
  const principalTypeSchema = z.enum(["user", "service_token"]);
1367
2091
  /** Org-level capability a service token can hold (never `owner`). */
1368
2092
  const serviceTokenRoleSchema = z.enum(["admin", "member"]);
2093
+ z.object({
2094
+ /** Include the full append-only ciphertext history of every secret. */
2095
+ includeVersions: z.boolean().optional(),
2096
+ includeAudit: z.boolean().optional(),
2097
+ /** Newest audit rows to keep. Exceeding the server cap is a 400, not a silent trim. */
2098
+ auditLimit: z.number().int().min(0).optional()
2099
+ });
1369
2100
  z.object({
1370
2101
  name: nameSchema,
1371
2102
  slug: slugSchema
@@ -3492,6 +4223,76 @@ async function decryptPrivateKey(passphrase, blob) {
3492
4223
  }
3493
4224
  }
3494
4225
  //#endregion
4226
+ //#region ../../packages/crypto/src/policy-key.ts
4227
+ /**
4228
+ * Signing with a principal's **existing** keypair, for agent access policy.
4229
+ *
4230
+ * Policy bundles are signed in the browser so the API can serve a blob it cannot
4231
+ * forge (`docs/agent-access-governance.md` §1). Every user already has a P-256
4232
+ * keypair whose private half is passphrase-encrypted and opaque to the server
4233
+ * (`users.public_key_jwk`), so this feature needs **no new key material**: no
4234
+ * second passphrase, no wrapping, no schema, and nothing extra for an admin to
4235
+ * lose. That was a deliberate condition of the design.
4236
+ *
4237
+ * The catch is that WebCrypto keys are algorithm-bound. A principal key is
4238
+ * imported for ECDH (`deriveBits`) and cannot sign, even though the underlying
4239
+ * curve is the same one ECDSA uses. So the JWK is re-imported here with the
4240
+ * algorithm hints stripped — the same private scalar, presented as an ECDSA key.
4241
+ *
4242
+ * **The tradeoff, stated plainly:** this reuses one key for two algorithms,
4243
+ * which key-management hygiene (NIST SP 800-57 §5.2) advises against. We accept
4244
+ * it because the alternative — a second keypair per admin — is the kind of
4245
+ * ceremony that gets skipped, and because both uses stay inside the same trust
4246
+ * boundary: the key already authorizes reading every secret the admin can read,
4247
+ * so a signature capability adds no reach an attacker holding it wouldn't have.
4248
+ * If a future version wants separation, the clean path is a managed KMS `sign`
4249
+ * key granted to publishers — the thumbprint pinning in the proxy works
4250
+ * unchanged, which is why the format carries the key rather than a user id.
4251
+ */
4252
+ const ECDSA_PARAMS$1 = {
4253
+ name: "ECDSA",
4254
+ namedCurve: "P-256"
4255
+ };
4256
+ /**
4257
+ * Re-import a principal's private key JWK as an ECDSA signing key.
4258
+ *
4259
+ * `key_ops`, `alg`, and `use` are dropped: they say "ECDH" on a principal key,
4260
+ * and WebCrypto refuses an import whose declared operations don't include the
4261
+ * requested usage. Everything that determines the key — `crv`, `d`, `x`, `y` —
4262
+ * is passed through untouched.
4263
+ */
4264
+ async function importPolicySigningKey(privateKeyJwk) {
4265
+ const jwk = JSON.parse(privateKeyJwk);
4266
+ if (jwk.kty !== "EC" || jwk.crv !== "P-256") throw new SeekritCryptoError("MALFORMED_BLOB", "policy signing needs an EC P-256 principal key");
4267
+ if (!jwk.d) throw new SeekritCryptoError("MALFORMED_BLOB", "policy signing needs the private half of the key");
4268
+ const { kty, crv, d, x, y } = jwk;
4269
+ return crypto.subtle.importKey("jwk", {
4270
+ kty,
4271
+ crv,
4272
+ d,
4273
+ x,
4274
+ y
4275
+ }, ECDSA_PARAMS$1, false, ["sign"]);
4276
+ }
4277
+ /**
4278
+ * Trim a principal's public key JWK to the members a policy bundle carries.
4279
+ *
4280
+ * The bundle names its signer by thumbprint, which is computed over exactly
4281
+ * these four members — so anything else in the stored JWK (`key_ops`, `ext`,
4282
+ * `alg`) must be dropped here, or the thumbprint an admin pins would depend on
4283
+ * incidental fields.
4284
+ */
4285
+ function policySignerJwk(publicKeyJwk) {
4286
+ const jwk = JSON.parse(publicKeyJwk);
4287
+ if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.x || !jwk.y) throw new SeekritCryptoError("MALFORMED_BLOB", "not an EC P-256 public key");
4288
+ return {
4289
+ kty: "EC",
4290
+ crv: "P-256",
4291
+ x: jwk.x,
4292
+ y: jwk.y
4293
+ };
4294
+ }
4295
+ //#endregion
3495
4296
  //#region ../../packages/crypto/src/shamir.ts
3496
4297
  /**
3497
4298
  * Shamir's Secret Sharing over GF(2^8) — the same field AES uses, with the
@@ -3985,10 +4786,13 @@ function encodeOpensshPrivateKey(seed, pub, comment) {
3985
4786
  */
3986
4787
  const TOKEN_PREFIX = "skt";
3987
4788
  const CLI_SESSION_PREFIX = "skc";
4789
+ const TASK_PREFIX = "skd";
3988
4790
  const TOKEN_ID_LENGTH = 22;
3989
4791
  const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
3990
4792
  /** 32 bytes of entropy for the CLI session secret. */
3991
4793
  const CLI_SESSION_SECRET_BYTES = 32;
4794
+ /** 32 bytes for a task token's secret, matching the CLI session. */
4795
+ const TASK_SECRET_BYTES = 32;
3992
4796
  function randomTokenId(prefix = TOKEN_PREFIX) {
3993
4797
  let out = "";
3994
4798
  while (out.length < TOKEN_ID_LENGTH) {
@@ -4058,9 +4862,18 @@ function parseCliSessionToken(token) {
4058
4862
  function isCliSessionToken(value) {
4059
4863
  return value.startsWith(`${CLI_SESSION_PREFIX}_`);
4060
4864
  }
4865
+ async function createAgentTaskToken() {
4866
+ const taskRef = randomTokenId(TASK_PREFIX);
4867
+ const token = `${taskRef}_${toBase64Url(crypto.getRandomValues(new Uint8Array(TASK_SECRET_BYTES)))}`;
4868
+ return {
4869
+ token,
4870
+ taskRef,
4871
+ tokenHash: await hashToken(token)
4872
+ };
4873
+ }
4061
4874
  //#endregion
4062
4875
  //#region package.json
4063
- var version = "0.43.0";
4876
+ var version = "0.45.0";
4064
4877
  //#endregion
4065
4878
  //#region ../../packages/api-client/src/index.ts
4066
4879
  var SeekritApiError = class extends Error {
@@ -4437,6 +5250,60 @@ var SeekritClient = class {
4437
5250
  getAgentPolicyBundle(agentRef) {
4438
5251
  return this.request("GET", `/v1/agents/${encodeURIComponent(agentRef)}/policy`);
4439
5252
  }
5253
+ /**
5254
+ * Dispatch a task for one agent run.
5255
+ *
5256
+ * The caller mints the token (`createAgentTaskToken` in `@seekrit/crypto`) and
5257
+ * sends only its hash plus the public `skd_…` segment, so no presentable
5258
+ * credential ever reaches this API — the same shape as service-token and CLI
5259
+ * session creation. `scopes` may only narrow what the agent's published policy
5260
+ * already permits; a name outside it is refused rather than dropped.
5261
+ *
5262
+ * Not org-scoped, because an orchestrator is not: it knows an agent slug.
5263
+ */
5264
+ dispatchAgentTask(agentRef, input) {
5265
+ return this.request("POST", `/v1/agents/${encodeURIComponent(agentRef)}/dispatch`, input);
5266
+ }
5267
+ /**
5268
+ * Exchange a presented token for the session it authorizes — what an
5269
+ * enforcement point calls once per task and caches until expiry.
5270
+ *
5271
+ * A POST because the token is a credential and must not land in a URL or an
5272
+ * access log. Fails closed and says which way: revoked, expired, or a disabled
5273
+ * identity are three different answers.
5274
+ */
5275
+ introspectAgentTask(token) {
5276
+ return this.request("POST", "/v1/tasks/introspect", { token });
5277
+ }
5278
+ /** End a run's authority now. Idempotent. */
5279
+ revokeAgentTask(taskId) {
5280
+ return this.request("POST", `/v1/tasks/${taskId}/revoke`);
5281
+ }
5282
+ getAgentTask(taskId) {
5283
+ return this.request("GET", `/v1/tasks/${taskId}`);
5284
+ }
5285
+ /** Runs dispatched for one identity, newest first (admin). */
5286
+ listAgentTasks(orgId, agentId) {
5287
+ return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}/tasks`);
5288
+ }
5289
+ /**
5290
+ * Report aggregate decisions. Called by an enforcement point, not a person.
5291
+ *
5292
+ * Counts only — hosts, methods, secret *names*, decisions, and rule indices.
5293
+ * Never a request path: see the module comment in `agent-activity.ts` for why
5294
+ * that line is drawn where it is.
5295
+ */
5296
+ reportAgentActivity(agentRef, input) {
5297
+ return this.request("POST", `/v1/agents/${encodeURIComponent(agentRef)}/activity`, input);
5298
+ }
5299
+ /**
5300
+ * What an agent actually did, collapsed onto its dimensions — the evidence a
5301
+ * grant review reasons over. The proposals themselves are computed client-side
5302
+ * (`reviewPolicy` in `@seekrit/core`), so the API never opines on policy.
5303
+ */
5304
+ getAgentActivity(orgId, agentId, days = 14) {
5305
+ return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}/activity?days=${encodeURIComponent(String(days))}`);
5306
+ }
4440
5307
  /** Keys the caller can see: all org keys for admins, granted keys otherwise. */
4441
5308
  listKmsKeys(orgId) {
4442
5309
  return this.request("GET", `/v1/orgs/${orgId}/kms/keys`);
@@ -4589,6 +5456,17 @@ var SeekritClient = class {
4589
5456
  const qs = params.size > 0 ? `?${params}` : "";
4590
5457
  return this.request("GET", `/v1/orgs/${orgId}/audit${qs}`);
4591
5458
  }
5459
+ /**
5460
+ * Export the org as one signed archive: every row seekrit holds for it, with
5461
+ * ciphertext still ciphertext (docs/break-glass-export.md).
5462
+ *
5463
+ * The archive comes back inline rather than as a job handle, and it can be
5464
+ * megabytes — buffer it to a file rather than holding several copies. Requires
5465
+ * admin; deliberately not entitlement-gated.
5466
+ */
5467
+ exportArchive(orgId, input = {}) {
5468
+ return this.request("POST", `/v1/orgs/${orgId}/export`, input);
5469
+ }
4592
5470
  getLogSink(orgId) {
4593
5471
  return this.request("GET", `/v1/orgs/${orgId}/log-sink`);
4594
5472
  }
@@ -4748,6 +5626,24 @@ function fail(message) {
4748
5626
  console.error(`error: ${message}`);
4749
5627
  process.exit(1);
4750
5628
  }
5629
+ /**
5630
+ * Parse a duration flag like `30m`, `24h`, `90d`, or a bare seconds count.
5631
+ *
5632
+ * Lives here rather than in a command module because more than one command
5633
+ * takes a duration and they must agree: `--every 7d` and `--ttl 7d` meaning
5634
+ * different things would be a nasty surprise. Invalid input is a flag error, so
5635
+ * it exits through `fail` with the accepted forms named.
5636
+ */
5637
+ function parseDurationSeconds(input, flag) {
5638
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
5639
+ if (!m) fail(`invalid ${flag} "${input}" (try 12h, 7d, 90d)`);
5640
+ return Number(m[1]) * ({
5641
+ s: 1,
5642
+ m: 60,
5643
+ h: 3600,
5644
+ d: 86400
5645
+ }[m[2] || "s"] ?? 1);
5646
+ }
4751
5647
  /** Prompt without echoing input (for passphrases). */
4752
5648
  function promptHidden(question) {
4753
5649
  const muted = new Writable({ write(_chunk, _encoding, callback) {
@@ -4883,6 +5779,31 @@ async function getPrivateKey(ctx) {
4883
5779
  const { encryptedPrivateKey } = await ctx.client.getMyKeys();
4884
5780
  return importPrivateKey(await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey));
4885
5781
  }
5782
+ /**
5783
+ * Recover the caller's **policy signing** key: the same P-256 principal key,
5784
+ * re-imported for ECDSA rather than ECDH. The dashboard does exactly this in
5785
+ * `keyring.getPolicySigner`, so a bundle signed here and one signed in a browser
5786
+ * are indistinguishable — same key, same thumbprint, same pin in a proxy config.
5787
+ *
5788
+ * Service tokens are refused *here* rather than at the API, so the reason is
5789
+ * legible at the point of use: publishing policy is deliberately gated on a
5790
+ * human's key, because an agent that can widen its own authorization is not
5791
+ * governed by it. See the module comment in `apps/api/src/routes/agents.ts`.
5792
+ *
5793
+ * The honest caveat is `SEEKRIT_PASSPHRASE`: where it is set, anything that can
5794
+ * read the environment can sign. That is already true of every other CLI
5795
+ * decryption, but it matters more here, so the docs say to leave it unset on any
5796
+ * machine an agent shares.
5797
+ */
5798
+ async function getPolicySigner(ctx) {
5799
+ 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)");
5800
+ const { encryptedPrivateKey } = await ctx.client.getMyKeys();
5801
+ const privateKeyJwk = await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey);
5802
+ return {
5803
+ signingKey: await importPolicySigningKey(privateKeyJwk),
5804
+ jwk: policySignerJwk(privateKeyJwk)
5805
+ };
5806
+ }
4886
5807
  /** Recover one environment's DEK for the current principal. */
4887
5808
  async function getDek(ctx, orgId, envId) {
4888
5809
  const [{ wrappedDek }, privateKey] = await Promise.all([ctx.client.getMyEnvKey(orgId, envId), getPrivateKey(ctx)]);
@@ -5213,24 +6134,661 @@ function registerAccountCommands(program) {
5213
6134
  });
5214
6135
  }
5215
6136
  //#endregion
5216
- //#region src/kms.ts
5217
- /** Collect a repeatable option into a list. */
5218
- function collect$6(value, acc = []) {
5219
- acc.push(value);
5220
- return acc;
5221
- }
5222
- /** The calling principal's identity + public key (for a self-grant). */
5223
- async function kmsCallerIdentity(ctx) {
5224
- if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
5225
- const { tokenId, privateKey } = await parseServiceToken(ctx.auth.token);
5226
- const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
5227
- return {
5228
- principalType: "service_token",
5229
- principalId: tokenId,
5230
- publicKeyJwk: JSON.stringify(pub)
5231
- };
6137
+ //#region src/agents.ts
6138
+ /**
6139
+ * Agent access governance from the CLI.
6140
+ *
6141
+ * Policy lived only in the dashboard until now, which was backwards: the people
6142
+ * deploying agents work in a terminal and a repo, and a rule set is exactly the
6143
+ * kind of thing that wants review and version control. So the loop this module
6144
+ * exists for is:
6145
+ *
6146
+ * seekrit agents policy pull nova -o nova.policy.json # current rules
6147
+ * $EDITOR nova.policy.json # commit it, review it
6148
+ * seekrit agents policy publish nova -f nova.policy.json
6149
+ *
6150
+ * **Signing still happens here, on this machine, with the operator's own key**
6151
+ * (`getPolicySigner` in `context.ts`), so nothing about the trust argument in
6152
+ * `docs/agent-access-governance.md` §1 changes: the API receives an opaque
6153
+ * envelope it cannot forge, and a proxy verifies it against thumbprints pinned in
6154
+ * its own local file. A service token is refused — an agent that can publish its
6155
+ * own policy is not governed by it.
6156
+ *
6157
+ * Everything else here is read-only, and two commands are deliberately *local*
6158
+ * evaluations rather than API calls:
6159
+ *
6160
+ * - `simulate` runs `evaluatePolicy` from `@seekrit/core`, the mirror of
6161
+ * `RuleSet::decide` in the proxy — so a dry run and a real refusal give the
6162
+ * same verdict in the same words.
6163
+ * - `fetch` asks for the bytes a proxy would get (`GET /v1/agents/:ref/policy`)
6164
+ * and re-derives the signer thumbprint from the bundle itself, rather than
6165
+ * trusting the field the API echoes beside it.
6166
+ */
6167
+ /** How many published versions the API returns per page. */
6168
+ const POLICY_PAGE = 50;
6169
+ /** The `-` sentinel every other seekrit command uses to mean stdin. */
6170
+ const STDIN = "-";
6171
+ /**
6172
+ * Resolve `nova`, or `agt_…`, to an identity.
6173
+ *
6174
+ * The org-scoped API routes take an id, but nobody types ids — so a slug is
6175
+ * resolved from the list. The list is small (agents are a per-deployment thing,
6176
+ * not a per-request one), which is why this costs one request rather than
6177
+ * needing a lookup route.
6178
+ */
6179
+ async function resolveAgent(ctx, orgId, ref) {
6180
+ const { agents } = await ctx.client.listAgents(orgId);
6181
+ const found = agents.find((a) => a.slug === ref || a.id === ref);
6182
+ if (!found) {
6183
+ const known = agents.map((a) => a.slug).join(", ");
6184
+ fail(`no agent "${ref}"${known ? ` — this org has: ${known}` : " in this org"}`);
5232
6185
  }
5233
- const { user } = await ctx.client.me();
6186
+ return found;
6187
+ }
6188
+ /**
6189
+ * One published version: the newest by default, or an explicit `--version`.
6190
+ *
6191
+ * An agent with no published policy is a real state, not an error — a proxy for
6192
+ * it fails closed — so the caller decides how to report it.
6193
+ */
6194
+ async function loadVersion(ctx, orgId, agent, version) {
6195
+ const { policies } = await ctx.client.listAgentPolicies(orgId, agent.id);
6196
+ if (version === void 0) return policies[0] ?? null;
6197
+ const found = policies.find((p) => p.version === version);
6198
+ 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"}`);
6199
+ return found;
6200
+ }
6201
+ /**
6202
+ * Read a rule file: either a bare array or `{ "rules": [...] }`, from a path or
6203
+ * stdin. Both shapes are accepted because `policy pull` writes the second and
6204
+ * hand-written files tend to be the first.
6205
+ *
6206
+ * Validation is the same zod schema the API and the dashboard use, so a file
6207
+ * rejected here would have been rejected there — before anything is signed.
6208
+ *
6209
+ * Exported for its own test: this is the one place a hand-written file meets the
6210
+ * schema, and its error messages are the whole user experience of a typo.
6211
+ */
6212
+ function parseRuleFile(raw, source) {
6213
+ let parsed;
6214
+ try {
6215
+ parsed = JSON.parse(raw);
6216
+ } catch (err) {
6217
+ fail(`${source} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
6218
+ }
6219
+ 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;
6220
+ if (!Array.isArray(list)) fail(`${source}: "rules" must be an array`);
6221
+ if (list.length === 0) console.error(`note: ${source} has no rules — publishing it denies every request`);
6222
+ return list.map((rule, i) => {
6223
+ const result = agentPolicyRuleSchema.safeParse(rule);
6224
+ if (!result.success) {
6225
+ const first = result.error.issues[0];
6226
+ fail(`${source}: rule ${i + 1} is invalid — ${first?.path.join(".") || "rule"}: ${first?.message ?? "unknown error"}`);
6227
+ }
6228
+ return result.data;
6229
+ });
6230
+ }
6231
+ /** `--file path` or `--file -`. */
6232
+ async function readRuleSource(file) {
6233
+ if (file === STDIN) return {
6234
+ raw: await readStdin(),
6235
+ label: "stdin"
6236
+ };
6237
+ try {
6238
+ return {
6239
+ raw: readFileSync(file, "utf8"),
6240
+ label: file
6241
+ };
6242
+ } catch (err) {
6243
+ return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
6244
+ }
6245
+ }
6246
+ /**
6247
+ * An empty `methods` or `paths` list means *any*: those fields narrow a rule that
6248
+ * already matched its host, so absent means unconstrained. A blank column would
6249
+ * read as the opposite, so it renders as `any`.
6250
+ *
6251
+ * Exported for its own test — this is a real invariant of the format, not a
6252
+ * cosmetic choice.
6253
+ */
6254
+ function describeList(values) {
6255
+ return values.length === 0 ? "any" : values.join(" ");
6256
+ }
6257
+ /**
6258
+ * `allow` is the field where empty means the **opposite** — no secret may be
6259
+ * injected toward this host at all (`Decision::SecretNotAllowed` for every name;
6260
+ * see `crates/seekrit-core/src/policy.rs`). Such a rule is useful and
6261
+ * intentional: it permits the *request* while granting no credential, which is
6262
+ * how you let an agent read a public API through the proxy without handing it a
6263
+ * key. Rendering that as `any` would invert the meaning of the one column an
6264
+ * operator reviews most carefully, so it gets its own function.
6265
+ */
6266
+ function describeSecrets(values) {
6267
+ return values.length === 0 ? "none" : values.join(" ");
6268
+ }
6269
+ function printRules(rules) {
6270
+ printTable(rules.map((rule, i) => ({
6271
+ n: i + 1,
6272
+ rule
6273
+ })), [
6274
+ col("#", (r) => r.n),
6275
+ col("host", (r) => r.rule.host),
6276
+ col("methods", (r) => describeList(r.rule.methods)),
6277
+ col("paths", (r) => describeList(r.rule.paths)),
6278
+ col("secrets", (r) => describeSecrets(r.rule.allow)),
6279
+ col("label", (r) => r.rule.label ?? "-")
6280
+ ], "no rules — this policy denies every request");
6281
+ }
6282
+ /** One rule as a single line, for the publish diff. */
6283
+ function ruleLine(rule) {
6284
+ const parts = [
6285
+ rule.host,
6286
+ describeList(rule.methods),
6287
+ describeList(rule.paths),
6288
+ `secrets=${describeSecrets(rule.allow)}`
6289
+ ];
6290
+ return rule.label ? `${parts.join(" ")} (${rule.label})` : parts.join(" ");
6291
+ }
6292
+ /**
6293
+ * The change a publish would make. Rules are compared by *position* because
6294
+ * order decides — first match wins — so a reordering is a real change and shows
6295
+ * up as one.
6296
+ */
6297
+ function printPolicyDiff(before, after) {
6298
+ const changes = diffPolicyRules(before, after).filter((c) => c.kind !== "unchanged");
6299
+ if (changes.length === 0) {
6300
+ console.error(after.length === 0 ? "no rules — this policy denies every request" : "no rule changes — publishing would only extend the expiry");
6301
+ return;
6302
+ }
6303
+ for (const change of changes) {
6304
+ const n = change.index + 1;
6305
+ if (change.kind === "added") console.log(`+ ${n} ${ruleLine(change.after)}`);
6306
+ else if (change.kind === "removed") console.log(`- ${n} ${ruleLine(change.before)}`);
6307
+ else {
6308
+ console.log(`- ${n} ${ruleLine(change.before)}`);
6309
+ console.log(`+ ${n} ${ruleLine(change.after)}`);
6310
+ }
6311
+ }
6312
+ }
6313
+ /** Seconds until an ISO instant, or a negative number when it has passed. */
6314
+ function secondsUntil(iso) {
6315
+ return Math.round((new Date(iso).getTime() - Date.now()) / 1e3);
6316
+ }
6317
+ /** Collect a repeated `--scope NAME` into a list. */
6318
+ function collectScope(value, acc = []) {
6319
+ acc.push(value);
6320
+ return acc;
6321
+ }
6322
+ /**
6323
+ * A task's state, derived rather than stored: `revoked` beats `expired`, so a run
6324
+ * somebody killed does not read as one that merely lapsed.
6325
+ *
6326
+ * Exported for its own test — it mirrors `taskState` in
6327
+ * `apps/api/src/lib/agent-tasks.ts`, and the two disagreeing would mean the list
6328
+ * screen and the enforcement point describe the same run differently.
6329
+ */
6330
+ function taskStateOf(task, now) {
6331
+ if (task.revokedAt) return "revoked";
6332
+ return Date.parse(task.expiresAt) <= now ? "expired" : "active";
6333
+ }
6334
+ /**
6335
+ * A detail view on **stderr**, so a command whose stdout is a credential can
6336
+ * still explain itself to a person without corrupting `$(…)`.
6337
+ */
6338
+ function printFieldsToStderr(fields) {
6339
+ const present = fields.filter(([, value]) => value !== null && value !== void 0);
6340
+ const width = Math.max(...present.map(([label]) => label.length));
6341
+ for (const [label, value] of present) console.error(`${label.padEnd(width)} ${value}`);
6342
+ }
6343
+ /**
6344
+ * Exported for its own test: the expired branch is the one that must be loud.
6345
+ *
6346
+ * The granularity has to span both users of this — a policy bundle lasting a
6347
+ * week and a task lasting fifteen minutes. Rounding a task to the nearest hour
6348
+ * printed "in 0h", which is worse than no estimate at all.
6349
+ */
6350
+ function describeExpiry(iso) {
6351
+ const seconds = secondsUntil(iso);
6352
+ if (seconds <= 0) return `${iso} (EXPIRED — proxies for this agent fail closed)`;
6353
+ const days = Math.floor(seconds / 86400);
6354
+ const hours = Math.floor(seconds % 86400 / 3600);
6355
+ const minutes = Math.floor(seconds % 3600 / 60);
6356
+ if (days > 0) return `${iso} (in ${days}d ${hours}h)`;
6357
+ if (hours > 0) return `${iso} (in ${hours}h ${minutes}m)`;
6358
+ if (minutes > 0) return `${iso} (in ${minutes}m)`;
6359
+ return `${iso} (in ${seconds}s)`;
6360
+ }
6361
+ function registerAgentCommands(program) {
6362
+ const agents = program.command("agents").description("agent access policy — which agent may reach which upstream with which secret");
6363
+ agents.command("list", { isDefault: true }).description("list agent identities").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (options) => {
6364
+ const ctx = buildContext();
6365
+ const org = await resolveOrg(ctx, options.org);
6366
+ const { agents: rows } = await ctx.client.listAgents(org.id);
6367
+ emit(options, { agents: rows }, () => printTable(rows, [
6368
+ col("slug", (a) => a.slug),
6369
+ col("name", (a) => a.name),
6370
+ col("policy", (a) => a.currentPolicyVersion === 0 ? "none" : `v${a.currentPolicyVersion}`),
6371
+ col("enabled", (a) => a.enabled ? "yes" : "no"),
6372
+ col("last fetch", (a) => a.lastPolicyFetchAt ?? "never")
6373
+ ], "no agent identities — create one with `seekrit agents create`"));
6374
+ });
6375
+ 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) => {
6376
+ const ctx = buildContext();
6377
+ const org = await resolveOrg(ctx, options.org);
6378
+ const environmentId = options.env ? (await resolveAppEnv(ctx, {
6379
+ org: options.org,
6380
+ app: options.app,
6381
+ env: options.env
6382
+ })).envId : void 0;
6383
+ const { agent } = await ctx.client.createAgent(org.id, {
6384
+ name,
6385
+ slug: options.slug,
6386
+ ...environmentId ? { environmentId } : {}
6387
+ });
6388
+ emit(options, { agent }, () => {
6389
+ console.error(`created ${agent.slug} — no policy published yet, so any proxy for it fails closed`);
6390
+ console.error(`next: seekrit agents policy publish ${agent.slug} -f rules.json`);
6391
+ });
6392
+ });
6393
+ agents.command("show <ref>").description("an identity and its live rules").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
6394
+ const ctx = buildContext();
6395
+ const org = await resolveOrg(ctx, options.org);
6396
+ const agent = await resolveAgent(ctx, org.id, ref);
6397
+ const { environment } = await ctx.client.getAgent(org.id, agent.id);
6398
+ const live = await loadVersion(ctx, org.id, agent);
6399
+ emit(options, {
6400
+ agent,
6401
+ environment,
6402
+ policy: live
6403
+ }, () => {
6404
+ printFields([
6405
+ ["name", agent.name],
6406
+ ["slug", agent.slug],
6407
+ ["id", agent.id],
6408
+ ["environment", environment ? environment.slug : "any in the org"],
6409
+ ["enabled", agent.enabled ? "yes" : "no"],
6410
+ ["policy", live ? `v${live.version}` : "none published"],
6411
+ ["signer", live?.signerThumbprint],
6412
+ ["expires", live ? describeExpiry(live.expiresAt) : void 0],
6413
+ ["last fetch", agent.lastPolicyFetchAt ?? "never"]
6414
+ ]);
6415
+ if (live) {
6416
+ section(`rules (v${live.version})`);
6417
+ printRules(live.rules);
6418
+ section("hosts a forward proxy would intercept");
6419
+ console.log(policyHosts(live.rules).join("\n") || "none");
6420
+ }
6421
+ });
6422
+ });
6423
+ 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) => {
6424
+ const ctx = buildContext();
6425
+ const org = await resolveOrg(ctx, options.org);
6426
+ const agent = await resolveAgent(ctx, org.id, ref);
6427
+ await confirmDestructive(options.yes, `Disable ${agent.slug}? Running proxies keep their current bundle until it expires.`);
6428
+ await ctx.client.updateAgent(org.id, agent.id, { enabled: false });
6429
+ console.error(`${agent.slug} disabled — its next policy refresh is refused`);
6430
+ });
6431
+ agents.command("enable <ref>").description("serve this identity's policy again").option("--org <slug>", "organization").action(async (ref, options) => {
6432
+ const ctx = buildContext();
6433
+ const org = await resolveOrg(ctx, options.org);
6434
+ const agent = await resolveAgent(ctx, org.id, ref);
6435
+ await ctx.client.updateAgent(org.id, agent.id, { enabled: true });
6436
+ console.error(`${agent.slug} enabled`);
6437
+ });
6438
+ 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) => {
6439
+ const ctx = buildContext();
6440
+ const org = await resolveOrg(ctx, options.org);
6441
+ const agent = await resolveAgent(ctx, org.id, ref);
6442
+ await confirmDestructive(options.yes, `Delete ${agent.slug} and all ${agent.currentPolicyVersion} published version(s)? The audit_log rows survive.`);
6443
+ await ctx.client.deleteAgent(org.id, agent.id);
6444
+ console.error(`${agent.slug} deleted`);
6445
+ });
6446
+ /**
6447
+ * The trust anchor. This prints a *public* key fingerprint — the security of
6448
+ * pinning comes from the operator putting it in a local file the API cannot
6449
+ * reach, not from this command being honest. Which is why it prints the TOML
6450
+ * line to paste rather than offering to write the config.
6451
+ */
6452
+ 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) => {
6453
+ const ctx = buildContext();
6454
+ const org = await resolveOrg(ctx, options.org);
6455
+ const { signer } = await ctx.client.getMyPolicySigner(org.id);
6456
+ if (!signer) fail("your account has no keypair yet — run `seekrit keys setup`");
6457
+ emit(options, { signer }, () => {
6458
+ printFields([["thumbprint", signer.thumbprint], ["user", signer.userId]]);
6459
+ section("pin it in seekrit-proxy.toml");
6460
+ console.log("[policy]");
6461
+ console.log(`signers = ["${signer.thumbprint}"]`);
6462
+ console.error("");
6463
+ console.error("pin a second admin's key too — one pinned signer means one lost passphrase leaves nobody able to publish");
6464
+ });
6465
+ });
6466
+ /**
6467
+ * The token is printed to **stdout** and everything else to stderr, so the
6468
+ * common thing works without parsing:
6469
+ *
6470
+ * TASK=$(seekrit agents dispatch nova --scope GITHUB_TOKEN --ttl 15m)
6471
+ *
6472
+ * It is shown exactly once. The API stores only a hash, so a lost token cannot
6473
+ * be recovered — dispatch another and revoke the first.
6474
+ */
6475
+ 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) => {
6476
+ const ctx = buildContext();
6477
+ const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : 900;
6478
+ if (ttlSeconds < 60 || ttlSeconds > 43200) fail(`--ttl must be between 60s and ${TASK_MAX_TTL_SECONDS / 3600}h`);
6479
+ const minted = await createAgentTaskToken();
6480
+ const { task, header } = await ctx.client.dispatchAgentTask(ref, {
6481
+ taskRef: minted.taskRef,
6482
+ tokenHash: minted.tokenHash,
6483
+ ttlSeconds,
6484
+ ...options.scope ? { scopes: options.scope } : {},
6485
+ ...options.label ? { label: options.label } : {}
6486
+ });
6487
+ if (options.json) {
6488
+ console.log(JSON.stringify({
6489
+ task,
6490
+ header,
6491
+ token: minted.token
6492
+ }, null, 2));
6493
+ return;
6494
+ }
6495
+ console.log(minted.token);
6496
+ printFieldsToStderr([
6497
+ ["task", task.id],
6498
+ ["agent", ref],
6499
+ ["scopes", task.scopes ? describeSecrets(task.scopes) : "the agent's full policy"],
6500
+ ["policy", `v${task.policyVersion}`],
6501
+ ["expires", describeExpiry(task.expiresAt)],
6502
+ ["header", header]
6503
+ ]);
6504
+ });
6505
+ 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) => {
6506
+ const ctx = buildContext();
6507
+ const org = await resolveOrg(ctx, options.org);
6508
+ const agent = await resolveAgent(ctx, org.id, ref);
6509
+ const { tasks } = await ctx.client.listAgentTasks(org.id, agent.id);
6510
+ const now = Date.now();
6511
+ const rows = options.all ? tasks : tasks.filter((t) => taskStateOf(t, now) === "active");
6512
+ emit(options, { tasks: rows }, () => printTable(rows, [
6513
+ col("task", (t) => t.id),
6514
+ col("state", (t) => taskStateOf(t, now)),
6515
+ col("scopes", (t) => t.scopes ? describeSecrets(t.scopes) : "policy"),
6516
+ col("policy", (t) => `v${t.policyVersion}`),
6517
+ col("expires", (t) => t.expiresAt),
6518
+ col("last seen", (t) => t.lastSeenAt ?? "never"),
6519
+ col("label", (t) => t.label ?? "-")
6520
+ ], options.all ? "no runs dispatched yet" : "no live runs (try --all)"));
6521
+ });
6522
+ agents.command("revoke <taskId>").description("end a run's authority now").action(async (taskId) => {
6523
+ const { task } = await buildContext().client.revokeAgentTask(taskId);
6524
+ console.error(`${task.id} revoked — enforcement points refuse it at their next introspection`);
6525
+ });
6526
+ registerPolicyCommands(agents.command("policy").description("published policy versions (`seekrit agents policy --help`)"));
6527
+ 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) => {
6528
+ const ctx = buildContext();
6529
+ const org = await resolveOrg(ctx, options.org);
6530
+ const agent = await resolveAgent(ctx, org.id, ref);
6531
+ const days = Number(options.days ?? 14);
6532
+ if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
6533
+ const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
6534
+ emit(options, {
6535
+ activity,
6536
+ summary
6537
+ }, () => {
6538
+ printFields([
6539
+ ["window", `${days}d`],
6540
+ ["allowed", summary.allowed],
6541
+ ["denied", summary.denied],
6542
+ ["hosts", summary.hosts],
6543
+ ["earliest", summary.from ?? "no activity reported"]
6544
+ ]);
6545
+ if (activity.length === 0) {
6546
+ console.error("");
6547
+ console.error("nothing reported — add an [activity] block to the proxy (`seekrit proxy init --activity`)");
6548
+ return;
6549
+ }
6550
+ section("decisions");
6551
+ printTable(activity, [
6552
+ col("host", (a) => a.host),
6553
+ col("method", (a) => a.method),
6554
+ col("decision", (a) => a.decision),
6555
+ col("rule", (a) => a.ruleIndex === null ? "-" : String(a.ruleIndex + 1)),
6556
+ col("count", (a) => a.count),
6557
+ col("secrets", (a) => a.secrets ? Object.entries(a.secrets).map(([name, n]) => `${name}×${n}`).join(" ") : "-")
6558
+ ]);
6559
+ });
6560
+ });
6561
+ /**
6562
+ * The grant review loop. Proposals are computed *here*, from activity the API
6563
+ * served — the API is deliberately not in the business of saying what a policy
6564
+ * should be. And `--out` writes a rule file rather than publishing: the change
6565
+ * still goes through `policy publish`, which still needs a human's key.
6566
+ */
6567
+ 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) => {
6568
+ const ctx = buildContext();
6569
+ const org = await resolveOrg(ctx, options.org);
6570
+ const agent = await resolveAgent(ctx, org.id, ref);
6571
+ const days = Number(options.days ?? 14);
6572
+ if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
6573
+ const live = await loadVersion(ctx, org.id, agent);
6574
+ if (!live) fail(`${agent.slug} has no published policy to review`);
6575
+ const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
6576
+ const proposals = reviewPolicy({
6577
+ rules: live.rules,
6578
+ activity
6579
+ });
6580
+ emit(options, {
6581
+ proposals,
6582
+ summary,
6583
+ version: live.version
6584
+ }, () => {
6585
+ console.error(`${agent.slug} — policy v${live.version}, ${summary.allowed + summary.denied} decision(s) over ${days}d`);
6586
+ if (activity.length === 0) {
6587
+ console.error("");
6588
+ console.error("no activity reported yet — nothing to review against");
6589
+ return;
6590
+ }
6591
+ if (proposals.length === 0) {
6592
+ console.error("");
6593
+ console.error("no changes proposed: every rule is in use and nothing was refused");
6594
+ return;
6595
+ }
6596
+ section("proposals");
6597
+ for (const p of proposals) {
6598
+ const marker = p.applicable ? "-" : "?";
6599
+ console.log(`${marker} ${p.rationale}`);
6600
+ }
6601
+ const applicable = countApplicable(proposals);
6602
+ console.error("");
6603
+ console.error(`${applicable} narrowing change(s) can be applied; ${proposals.length - applicable} need a human decision`);
6604
+ });
6605
+ if (options.out) {
6606
+ const narrowed = applyProposals(live.rules, proposals);
6607
+ writeFileSync(options.out, `${JSON.stringify({ rules: narrowed }, null, 2)}\n`);
6608
+ console.error(`wrote ${narrowed.length} rule(s) to ${options.out} — review the diff, then: seekrit agents policy publish ${agent.slug} -f ${options.out}`);
6609
+ console.error("widening proposals are never applied: an agent must not earn permissions by retrying");
6610
+ }
6611
+ });
6612
+ 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) => {
6613
+ const ctx = buildContext();
6614
+ const org = await resolveOrg(ctx, options.org);
6615
+ const agent = await resolveAgent(ctx, org.id, ref);
6616
+ const version = options.version === void 0 ? void 0 : Number(options.version);
6617
+ if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
6618
+ const published = await loadVersion(ctx, org.id, agent, version);
6619
+ if (!published) fail(`${agent.slug} has no published policy — every request is denied`);
6620
+ const verdict = evaluatePolicy(published.rules, {
6621
+ host: options.host,
6622
+ method: options.method,
6623
+ path: options.path,
6624
+ ...options.secret === void 0 ? {} : { secret: options.secret }
6625
+ });
6626
+ const reason = describePolicyVerdict(verdict, published.rules);
6627
+ emit(options, {
6628
+ version: published.version,
6629
+ verdict,
6630
+ reason
6631
+ }, () => {
6632
+ const target = `${options.method.toUpperCase()} ${options.host}${options.path}`;
6633
+ console.log(`${verdict.decision === "allow" ? "allow" : "DENY"} ${target}${options.secret ? ` [${options.secret}]` : ""}`);
6634
+ console.log(` v${published.version}: ${reason}`);
6635
+ });
6636
+ if (verdict.decision !== "allow") process.exitCode = 1;
6637
+ });
6638
+ }
6639
+ function registerPolicyCommands(policy) {
6640
+ policy.command("list <ref>").description("published versions, newest first").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
6641
+ const ctx = buildContext();
6642
+ const org = await resolveOrg(ctx, options.org);
6643
+ const agent = await resolveAgent(ctx, org.id, ref);
6644
+ const { policies } = await ctx.client.listAgentPolicies(org.id, agent.id);
6645
+ emit(options, { policies }, () => printTable(policies, [
6646
+ col("version", (p) => `v${p.version}`),
6647
+ col("rules", (p) => p.ruleCount),
6648
+ col("signer", (p) => p.signerThumbprint.slice(0, 12)),
6649
+ col("published", (p) => p.publishedAt),
6650
+ col("expires", (p) => secondsUntil(p.expiresAt) <= 0 ? `${p.expiresAt} (expired)` : p.expiresAt),
6651
+ col("note", (p) => p.rolledBackFromVersion ? `restored v${p.rolledBackFromVersion}` : "-")
6652
+ ], "nothing published yet"));
6653
+ });
6654
+ 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) => {
6655
+ const ctx = buildContext();
6656
+ const org = await resolveOrg(ctx, options.org);
6657
+ const agent = await resolveAgent(ctx, org.id, ref);
6658
+ const version = options.version === void 0 ? void 0 : Number(options.version);
6659
+ if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
6660
+ const published = await loadVersion(ctx, org.id, agent, version);
6661
+ if (!published) fail(`${agent.slug} has no published policy`);
6662
+ emit(options, { policy: published }, () => {
6663
+ printFields([
6664
+ ["version", `v${published.version}`],
6665
+ ["signer", published.signerThumbprint],
6666
+ ["published", published.publishedAt],
6667
+ ["expires", describeExpiry(published.expiresAt)]
6668
+ ]);
6669
+ section("rules");
6670
+ printRules(published.rules);
6671
+ });
6672
+ });
6673
+ /**
6674
+ * Write the live rules out as the editable source for the next publish. This
6675
+ * is the half that makes policy-as-code work: what comes back out is exactly
6676
+ * the shape `publish` takes in, so a round trip is a no-op diff.
6677
+ */
6678
+ 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) => {
6679
+ const ctx = buildContext();
6680
+ const org = await resolveOrg(ctx, options.org);
6681
+ const agent = await resolveAgent(ctx, org.id, ref);
6682
+ const version = options.version === void 0 ? void 0 : Number(options.version);
6683
+ if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
6684
+ const published = await loadVersion(ctx, org.id, agent, version);
6685
+ const rules = published?.rules ?? [];
6686
+ const body = `${JSON.stringify({ rules }, null, 2)}\n`;
6687
+ if (options.out) {
6688
+ writeFileSync(options.out, body);
6689
+ 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)`);
6690
+ return;
6691
+ }
6692
+ process.stdout.write(body);
6693
+ });
6694
+ 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) => {
6695
+ const ctx = buildContext();
6696
+ 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.");
6697
+ const org = await resolveOrg(ctx, options.org);
6698
+ const agent = await resolveAgent(ctx, org.id, ref);
6699
+ const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : POLICY_DEFAULT_TTL_SECONDS;
6700
+ if (ttlSeconds < 3600 || ttlSeconds > 7776e3) fail(`--ttl must be between ${POLICY_MIN_TTL_SECONDS / 3600}h and ${POLICY_MAX_TTL_SECONDS / 86400}d`);
6701
+ const { raw, label } = await readRuleSource(options.file);
6702
+ const rules = parseRuleFile(raw, label);
6703
+ const live = await loadVersion(ctx, org.id, agent);
6704
+ const nextVersion = agent.currentPolicyVersion + 1;
6705
+ const from = agent.currentPolicyVersion === 0 ? "nothing" : `v${agent.currentPolicyVersion}`;
6706
+ console.error(`${agent.slug}: ${from} → v${nextVersion}`);
6707
+ printPolicyDiff(live?.rules ?? [], rules);
6708
+ await confirmDestructive(options.yes, `Publish v${nextVersion} for ${agent.slug} (${rules.length} rule(s), valid ${options.ttl ?? "7d"})?`);
6709
+ const { signingKey, jwk } = await getPolicySigner(ctx);
6710
+ const issuedAt = Math.floor(Date.now() / 1e3);
6711
+ const bundle = await signAgentPolicy(signingKey, jwk, {
6712
+ v: 1,
6713
+ org: org.id,
6714
+ agent: agent.id,
6715
+ agent_slug: agent.slug,
6716
+ policy_version: nextVersion,
6717
+ issued_at: issuedAt,
6718
+ expires_at: issuedAt + ttlSeconds,
6719
+ rules
6720
+ });
6721
+ const { policy: published } = await ctx.client.publishAgentPolicy(org.id, agent.id, bundle);
6722
+ emit(options, { policy: published }, () => {
6723
+ console.error(`published v${published.version} — signed with ${published.signerThumbprint.slice(0, 12)}…, expires ${published.expiresAt}`);
6724
+ console.error("proxies pick it up at their next refresh");
6725
+ });
6726
+ });
6727
+ 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) => {
6728
+ const ctx = buildContext();
6729
+ const org = await resolveOrg(ctx, options.org);
6730
+ const agent = await resolveAgent(ctx, org.id, ref);
6731
+ const version = Number(versionArg);
6732
+ if (!Number.isInteger(version) || version < 1) fail("version must be a positive integer");
6733
+ const source = await loadVersion(ctx, org.id, agent, version);
6734
+ if (!source) fail(`no version ${version}`);
6735
+ 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`);
6736
+ await confirmDestructive(options.yes, `Roll ${agent.slug} back to v${version} (${source.ruleCount} rule(s))?`);
6737
+ const { policy: published } = await ctx.client.rollbackAgentPolicy(org.id, agent.id, version);
6738
+ console.error(`published v${published.version} carrying v${version}'s bundle`);
6739
+ });
6740
+ /**
6741
+ * What a proxy actually gets. Useful when a deployment misbehaves and the
6742
+ * question is whether the API, the pin, or the rules are at fault — so it
6743
+ * re-derives the thumbprint from the bundle rather than reporting the one the
6744
+ * API sends beside it, and says plainly when the two disagree.
6745
+ */
6746
+ 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) => {
6747
+ const fetched = await buildContext().client.getAgentPolicyBundle(ref);
6748
+ if (options.bundle) {
6749
+ process.stdout.write(`${fetched.bundle}\n`);
6750
+ return;
6751
+ }
6752
+ const body = parseAgentPolicyUnverified(fetched.bundle);
6753
+ const derived = await policySignerThumbprint(body.signer.jwk);
6754
+ emit(options, {
6755
+ ...fetched,
6756
+ derivedThumbprint: derived,
6757
+ rules: body.rules
6758
+ }, () => {
6759
+ printFields([
6760
+ ["agent", `${fetched.agent.slug} (${fetched.agent.name})`],
6761
+ ["version", `v${fetched.version}`],
6762
+ ["expires", describeExpiry(fetched.expiresAt)],
6763
+ ["signer (in bundle)", derived],
6764
+ ["signer (reported)", fetched.signerThumbprint === derived ? "matches" : `${fetched.signerThumbprint} — DOES NOT MATCH the bundle; a proxy would refuse this`]
6765
+ ]);
6766
+ section("rules");
6767
+ printRules(body.rules);
6768
+ console.error("");
6769
+ console.error("this command does not verify the signature — only a pinned signer list can, which lives in the proxy's own config");
6770
+ });
6771
+ });
6772
+ }
6773
+ //#endregion
6774
+ //#region src/kms.ts
6775
+ /** Collect a repeatable option into a list. */
6776
+ function collect$7(value, acc = []) {
6777
+ acc.push(value);
6778
+ return acc;
6779
+ }
6780
+ /** The calling principal's identity + public key (for a self-grant). */
6781
+ async function kmsCallerIdentity(ctx) {
6782
+ if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) {
6783
+ const { tokenId, privateKey } = await parseServiceToken(ctx.auth.token);
6784
+ const { d: _d, key_ops: _ops, ext: _ext, ...pub } = await crypto.subtle.exportKey("jwk", privateKey);
6785
+ return {
6786
+ principalType: "service_token",
6787
+ principalId: tokenId,
6788
+ publicKeyJwk: JSON.stringify(pub)
6789
+ };
6790
+ }
6791
+ const { user } = await ctx.client.me();
5234
6792
  if (!user.publicKeyJwk) fail("run `seekrit keys setup` first");
5235
6793
  return {
5236
6794
  principalType: "user",
@@ -5283,7 +6841,7 @@ async function kmsRecoverMaterial(ctx, orgId, keyId, version) {
5283
6841
  }
5284
6842
  function registerKmsCommands(program) {
5285
6843
  const kms = program.command("kms").description("managed keys for application-layer encryption & signing (client-side)");
5286
- kms.command("create").description("create a managed key (material is generated locally and wrapped, never sent)").requiredOption("--name <name>", "org-unique key name").requiredOption("--purpose <purpose>", "encrypt | sign").option("--org <slug>").option("--app <slug>", "scope the key to an application").option("--group <slug>", "scope the key to a group").option("--grant-user <email>", "also grant an org member (repeatable)", collect$6, []).option("--grant-token <tokenId>", "also grant a service token (repeatable)", collect$6, []).action(async (options) => {
6844
+ kms.command("create").description("create a managed key (material is generated locally and wrapped, never sent)").requiredOption("--name <name>", "org-unique key name").requiredOption("--purpose <purpose>", "encrypt | sign").option("--org <slug>").option("--app <slug>", "scope the key to an application").option("--group <slug>", "scope the key to a group").option("--grant-user <email>", "also grant an org member (repeatable)", collect$7, []).option("--grant-token <tokenId>", "also grant a service token (repeatable)", collect$7, []).action(async (options) => {
5287
6845
  if (options.purpose !== "encrypt" && options.purpose !== "sign") fail("--purpose must be encrypt or sign");
5288
6846
  if (options.app && options.group) fail("pass at most one of --app or --group");
5289
6847
  const ctx = buildContext();
@@ -5502,7 +7060,7 @@ function registerKmsCommands(program) {
5502
7060
  //#endregion
5503
7061
  //#region src/recovery.ts
5504
7062
  /** Collect a repeatable option into a list. */
5505
- function collect$5(value, acc = []) {
7063
+ function collect$6(value, acc = []) {
5506
7064
  acc.push(value);
5507
7065
  return acc;
5508
7066
  }
@@ -5599,7 +7157,7 @@ function registerRecoveryCommands(program) {
5599
7157
  for (const cst of status.custodians) console.log(` - ${cst.label ?? cst.principalId} (${cst.principalType}, share #${cst.shareIndex})`);
5600
7158
  if (status.coverage.unprotectedEnvIds.length > 0) console.log(`${status.coverage.unprotectedEnvIds.length} environment(s) not yet protected — run \`seekrit recovery sync\``);
5601
7159
  });
5602
- recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$5, []).option("--org <slug>").action(async (options) => {
7160
+ recovery.command("setup").description("enable recovery: split a fresh recovery key across custodians").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$6, []).option("--org <slug>").action(async (options) => {
5603
7161
  const ctx = buildContext();
5604
7162
  const org = await resolveOrg(ctx, options.org);
5605
7163
  const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
@@ -5617,7 +7175,7 @@ function registerRecoveryCommands(program) {
5617
7175
  const { wrapped, skipped } = await syncRecoveryGrants(ctx, (await resolveOrg(ctx, options.org)).id);
5618
7176
  console.error(`recovery-protected ${wrapped} environment(s); skipped ${skipped} you cannot decrypt`);
5619
7177
  });
5620
- recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$5, []).option("--org <slug>").action(async (options) => {
7178
+ recovery.command("rotate").description("rotate the recovery key (new keypair, custodians, and env re-wraps)").requiredOption("--threshold <M>", "custodians required to recover (M in M-of-N)").option("--custodian <email|skt_id>", "a recovery custodian (repeatable)", collect$6, []).option("--org <slug>").action(async (options) => {
5621
7179
  const ctx = buildContext();
5622
7180
  const org = await resolveOrg(ctx, options.org);
5623
7181
  const config = await buildRecoveryConfig(ctx, org.id, options.threshold, options.custodian);
@@ -5897,6 +7455,1179 @@ function registerAppCommands(program) {
5897
7455
  });
5898
7456
  }
5899
7457
  //#endregion
7458
+ //#region src/decryptor.ts
7459
+ /**
7460
+ * The standalone offline decryptor: one self-contained HTML file, written out by
7461
+ * `seekrit archive decryptor`, that opens a break-glass archive in any browser
7462
+ * with no install, no network, and no seekrit.
7463
+ *
7464
+ * Why a duplicate of the decrypt path instead of a bundle of `@seekrit/crypto`:
7465
+ * the artifact has to be a single file a customer can store next to their
7466
+ * archives for years and open from `file://`, which rules out a module graph and
7467
+ * a build step. So it is a second implementation — pinned, like every other
7468
+ * second implementation in this repo (the four SDKs, `crates/seekrit-core`), by a
7469
+ * test that runs *this* script against ciphertext produced by the real library:
7470
+ * `test/decryptor.test.ts`. If you change a blob format, that test fails here
7471
+ * too, which is the point.
7472
+ *
7473
+ * Two rules for editing the embedded script:
7474
+ * - **No backticks and no `${`** anywhere inside it — it lives in a template
7475
+ * literal. Use string concatenation.
7476
+ * - **No network of any kind.** The page declares
7477
+ * `Content-Security-Policy: default-src 'none'`, which is the property that
7478
+ * makes it safe to type a passphrase into. Anything that needs a fetch does
7479
+ * not belong here.
7480
+ */
7481
+ const OFFLINE_DECRYPTOR_HTML = `<!doctype html>
7482
+ <html lang="en">
7483
+ <head>
7484
+ <meta charset="utf-8">
7485
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7486
+ <!--
7487
+ The whole security argument for this page, in one header: with default-src
7488
+ 'none' the browser refuses every outbound request the page could make, so the
7489
+ passphrase you type and the plaintext it produces cannot leave this machine.
7490
+ 'unsafe-inline' covers the page's own inline script and styles; there is no
7491
+ connect-src, no img-src, no form-action.
7492
+ -->
7493
+ <meta http-equiv="Content-Security-Policy"
7494
+ content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
7495
+ <title>seekrit — offline archive decryptor</title>
7496
+ <style>
7497
+ :root {
7498
+ color-scheme: dark light;
7499
+ --bg: #0b0c0e; --fg: #e7e9ea; --dim: #9aa0a6; --line: #23262b;
7500
+ --panel: #101215; --accent: #7dd3a0; --warn: #f0b76b; --bad: #f08a8a;
7501
+ }
7502
+ @media (prefers-color-scheme: light) {
7503
+ :root {
7504
+ --bg: #fbfbfa; --fg: #14161a; --dim: #5f6673; --line: #e3e5e8;
7505
+ --panel: #ffffff; --accent: #1a7f4b; --warn: #9a6413; --bad: #b23b3b;
7506
+ }
7507
+ }
7508
+ * { box-sizing: border-box; }
7509
+ body {
7510
+ margin: 0; padding: 2rem 1.25rem 4rem; background: var(--bg); color: var(--fg);
7511
+ font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
7512
+ }
7513
+ main { max-width: 62rem; margin: 0 auto; }
7514
+ h1 { font-size: 1.1rem; letter-spacing: 0.02em; margin: 0 0 0.25rem; }
7515
+ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.12em;
7516
+ color: var(--dim); margin: 2rem 0 0.75rem; font-weight: 500; }
7517
+ p { margin: 0.4rem 0; color: var(--dim); }
7518
+ .panel { border: 1px solid var(--line); background: var(--panel); border-radius: 6px;
7519
+ padding: 1rem 1.1rem; }
7520
+ .drop { border: 1px dashed var(--line); border-radius: 6px; padding: 2rem 1rem;
7521
+ text-align: center; color: var(--dim); }
7522
+ .drop.over { border-color: var(--accent); color: var(--fg); }
7523
+ label { display: block; color: var(--dim); margin: 0.75rem 0 0.25rem; }
7524
+ input, select, textarea, button {
7525
+ font: inherit; color: var(--fg); background: var(--bg);
7526
+ border: 1px solid var(--line); border-radius: 4px; padding: 0.5rem 0.6rem;
7527
+ }
7528
+ input, select, textarea { width: 100%; }
7529
+ textarea { min-height: 5rem; }
7530
+ button { cursor: pointer; background: var(--panel); }
7531
+ button:hover { border-color: var(--accent); }
7532
+ button.primary { border-color: var(--accent); color: var(--accent); }
7533
+ .row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
7534
+ table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
7535
+ th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--line);
7536
+ vertical-align: top; word-break: break-all; }
7537
+ th { color: var(--dim); font-weight: 500; font-size: 0.85rem; }
7538
+ .kv { display: grid; grid-template-columns: 12rem 1fr; gap: 0.15rem 1rem; }
7539
+ .kv dt { color: var(--dim); }
7540
+ .kv dd { margin: 0; word-break: break-all; }
7541
+ .ok { color: var(--accent); } .warn { color: var(--warn); } .bad { color: var(--bad); }
7542
+ .hidden { display: none; }
7543
+ .env { margin-bottom: 1.5rem; }
7544
+ .muted { color: var(--dim); font-size: 0.85rem; }
7545
+ pre { white-space: pre-wrap; word-break: break-all; margin: 0.5rem 0 0; }
7546
+ .value { font-family: inherit; }
7547
+ ul { margin: 0.3rem 0 0; padding-left: 1.2rem; color: var(--dim); }
7548
+ </style>
7549
+ </head>
7550
+ <body>
7551
+ <main>
7552
+ <h1>seekrit — offline archive decryptor</h1>
7553
+ <p>
7554
+ Opens a <code>seekrit-archive/v1</code> file with your own key. This page has
7555
+ no network access at all (see the CSP in its source) &mdash; your passphrase
7556
+ and your secrets never leave this machine. Nothing is uploaded, and nothing
7557
+ needs to be installed.
7558
+ </p>
7559
+
7560
+ <h2>1 &middot; the archive</h2>
7561
+ <div class="drop panel" id="drop">
7562
+ <input type="file" id="file" accept=".json,application/json" style="width:auto">
7563
+ <p class="muted">or drop the file here</p>
7564
+ </div>
7565
+ <div id="manifest" class="panel hidden" style="margin-top:0.75rem"></div>
7566
+
7567
+ <div id="step2" class="hidden">
7568
+ <h2>2 &middot; your key</h2>
7569
+ <div class="panel">
7570
+ <label for="method">how you hold it</label>
7571
+ <select id="method">
7572
+ <option value="passphrase">passphrase (unlocks the key inside the archive)</option>
7573
+ <option value="token">service token (skt_&hellip;)</option>
7574
+ <option value="jwk">private key (JWK)</option>
7575
+ <option value="shares">custodian shares (recovery quorum)</option>
7576
+ </select>
7577
+
7578
+ <div id="field-passphrase">
7579
+ <label for="passphrase">passphrase</label>
7580
+ <input type="password" id="passphrase" autocomplete="off" spellcheck="false">
7581
+ <p class="muted" id="key-owner"></p>
7582
+ </div>
7583
+ <div id="field-token" class="hidden">
7584
+ <label for="token">service token</label>
7585
+ <input type="password" id="token" autocomplete="off" spellcheck="false"
7586
+ placeholder="skt_...">
7587
+ </div>
7588
+ <div id="field-jwk" class="hidden">
7589
+ <label for="jwk">private key JWK</label>
7590
+ <textarea id="jwk" spellcheck="false" placeholder='{"kty":"EC","crv":"P-256",...}'></textarea>
7591
+ </div>
7592
+ <div id="field-shares" class="hidden">
7593
+ <label for="shares">custodian shares</label>
7594
+ <textarea id="shares" spellcheck="false"
7595
+ placeholder="paste the contents of each share file from 'seekrit archive share', one after another"></textarea>
7596
+ <p class="muted">
7597
+ Reconstructs the org recovery key from a quorum, which opens every
7598
+ environment &mdash; for when the key-holder is gone.
7599
+ </p>
7600
+ </div>
7601
+
7602
+ <div class="row" style="margin-top:1rem">
7603
+ <button class="primary" id="decrypt">decrypt</button>
7604
+ <span id="status" class="muted"></span>
7605
+ </div>
7606
+ </div>
7607
+ </div>
7608
+
7609
+ <div id="results"></div>
7610
+ </main>
7611
+ <script>
7612
+ (function () {
7613
+ "use strict";
7614
+
7615
+ // ── encoding ────────────────────────────────────────────────────────────
7616
+ function b64uToBytes(text) {
7617
+ var base64 = text.replace(/-/g, "+").replace(/_/g, "/");
7618
+ var binary = atob(base64);
7619
+ var out = new Uint8Array(binary.length);
7620
+ for (var i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
7621
+ return out;
7622
+ }
7623
+ function hexToBytes(hex) {
7624
+ if (hex.length % 2 !== 0) throw new Error("malformed hex");
7625
+ var out = new Uint8Array(hex.length / 2);
7626
+ for (var i = 0; i < out.length; i++) {
7627
+ var byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
7628
+ if (Number.isNaN(byte)) throw new Error("malformed hex");
7629
+ out[i] = byte;
7630
+ }
7631
+ return out;
7632
+ }
7633
+ function bytesToHex(bytes) {
7634
+ var out = "";
7635
+ for (var i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, "0");
7636
+ return out;
7637
+ }
7638
+ function utf8(text) { return new TextEncoder().encode(text); }
7639
+ function fromUtf8(bytes) { return new TextDecoder().decode(bytes); }
7640
+
7641
+ // ── canonical JSON + digests (must match packages/core/src/archive.ts) ──
7642
+ function canonicalJson(value) {
7643
+ if (value === null || typeof value !== "object") {
7644
+ var scalar = JSON.stringify(value);
7645
+ return scalar === undefined ? "null" : scalar;
7646
+ }
7647
+ if (Array.isArray(value)) {
7648
+ return "[" + value.map(canonicalJson).join(",") + "]";
7649
+ }
7650
+ var keys = Object.keys(value).filter(function (key) { return value[key] !== undefined; });
7651
+ keys.sort();
7652
+ var parts = keys.map(function (key) {
7653
+ return JSON.stringify(key) + ":" + canonicalJson(value[key]);
7654
+ });
7655
+ return "{" + parts.join(",") + "}";
7656
+ }
7657
+ async function sha256Hex(text) {
7658
+ var digest = await crypto.subtle.digest("SHA-256", utf8(text));
7659
+ return "sha256:" + bytesToHex(new Uint8Array(digest));
7660
+ }
7661
+ function sectionCount(value) {
7662
+ if (value === null || value === undefined) return 0;
7663
+ return Array.isArray(value) ? value.length : 1;
7664
+ }
7665
+
7666
+ // ── the three blob formats ──────────────────────────────────────────────
7667
+ function splitBlob(blob, prefix, parts) {
7668
+ var pieces = String(blob).split(".");
7669
+ if (pieces.length !== parts + 1 || pieces[0] !== prefix) {
7670
+ throw new Error("not a " + prefix + ". blob");
7671
+ }
7672
+ return pieces.slice(1);
7673
+ }
7674
+
7675
+ /** pk1.<iterations>.<salt>.<iv>.<ciphertext> — PBKDF2-SHA256 + AES-256-GCM. */
7676
+ async function decryptPrivateKeyBlob(passphrase, blob) {
7677
+ var parts = splitBlob(blob, "pk1", 4);
7678
+ var iterations = parseInt(parts[0], 10);
7679
+ if (!Number.isFinite(iterations) || iterations < 1) throw new Error("bad iteration count");
7680
+ var material = await crypto.subtle.importKey("raw", utf8(passphrase), "PBKDF2", false, [
7681
+ "deriveKey",
7682
+ ]);
7683
+ var kek = await crypto.subtle.deriveKey(
7684
+ { name: "PBKDF2", hash: "SHA-256", salt: b64uToBytes(parts[1]), iterations: iterations },
7685
+ material,
7686
+ { name: "AES-GCM", length: 256 },
7687
+ false,
7688
+ ["decrypt"]
7689
+ );
7690
+ var plaintext = await crypto.subtle.decrypt(
7691
+ { name: "AES-GCM", iv: b64uToBytes(parts[2]) },
7692
+ kek,
7693
+ b64uToBytes(parts[3])
7694
+ );
7695
+ return fromUtf8(new Uint8Array(plaintext));
7696
+ }
7697
+
7698
+ /** wd1.<ephemeral pub>.<hkdf salt>.<iv>.<ciphertext> — ECDH P-256 + HKDF + AES-GCM. */
7699
+ async function unwrap(blob, privateKey) {
7700
+ var parts = splitBlob(blob, "wd1", 4);
7701
+ var ephemeral = await crypto.subtle.importKey(
7702
+ "raw",
7703
+ b64uToBytes(parts[0]),
7704
+ { name: "ECDH", namedCurve: "P-256" },
7705
+ false,
7706
+ []
7707
+ );
7708
+ var shared = await crypto.subtle.deriveBits(
7709
+ { name: "ECDH", public: ephemeral },
7710
+ privateKey,
7711
+ 256
7712
+ );
7713
+ var hkdf = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
7714
+ var wrappingKey = await crypto.subtle.deriveKey(
7715
+ {
7716
+ name: "HKDF",
7717
+ hash: "SHA-256",
7718
+ salt: b64uToBytes(parts[1]),
7719
+ info: utf8("seekrit/wrap-dek/v1"),
7720
+ },
7721
+ hkdf,
7722
+ { name: "AES-GCM", length: 256 },
7723
+ false,
7724
+ ["decrypt"]
7725
+ );
7726
+ var opened = await crypto.subtle.decrypt(
7727
+ { name: "AES-GCM", iv: b64uToBytes(parts[2]) },
7728
+ wrappingKey,
7729
+ b64uToBytes(parts[3])
7730
+ );
7731
+ return new Uint8Array(opened);
7732
+ }
7733
+
7734
+ /** sc1.<iv>.<ciphertext>, authenticated over "<environmentId>/<NAME>". */
7735
+ async function decryptSecret(dek, blob, aad) {
7736
+ var parts = splitBlob(blob, "sc1", 2);
7737
+ var key = await crypto.subtle.importKey("raw", dek, { name: "AES-GCM" }, false, ["decrypt"]);
7738
+ var plaintext = await crypto.subtle.decrypt(
7739
+ { name: "AES-GCM", iv: b64uToBytes(parts[0]), additionalData: utf8(aad) },
7740
+ key,
7741
+ b64uToBytes(parts[1])
7742
+ );
7743
+ return fromUtf8(new Uint8Array(plaintext));
7744
+ }
7745
+
7746
+ function importPrivateJwk(jwkText) {
7747
+ return crypto.subtle.importKey(
7748
+ "jwk",
7749
+ JSON.parse(jwkText),
7750
+ { name: "ECDH", namedCurve: "P-256" },
7751
+ true,
7752
+ ["deriveBits"]
7753
+ );
7754
+ }
7755
+
7756
+ /** skt_<id>_<pkcs8 base64url>: a service token carries its own private key. */
7757
+ async function importToken(token) {
7758
+ var match = /^(skt_[0-9A-Za-z]+)_([A-Za-z0-9_-]+)$/.exec(String(token).trim());
7759
+ if (!match) throw new Error("not a valid seekrit service token");
7760
+ var privateKey = await crypto.subtle.importKey(
7761
+ "pkcs8",
7762
+ b64uToBytes(match[2]),
7763
+ { name: "ECDH", namedCurve: "P-256" },
7764
+ true,
7765
+ ["deriveBits"]
7766
+ );
7767
+ return { principalId: match[1], privateKey: privateKey };
7768
+ }
7769
+
7770
+ // ── Shamir over GF(2^8) (must match packages/crypto/src/shamir.ts) ──────
7771
+ function gfMul(a, b) {
7772
+ var result = 0;
7773
+ var x = a;
7774
+ var y = b;
7775
+ for (var i = 0; i < 8; i++) {
7776
+ if (y & 1) result ^= x;
7777
+ var high = x & 0x80;
7778
+ x = (x << 1) & 0xff;
7779
+ if (high) x ^= 0x1b;
7780
+ y >>= 1;
7781
+ }
7782
+ return result & 0xff;
7783
+ }
7784
+ function gfPow(base, exponent) {
7785
+ var result = 1;
7786
+ for (var i = 0; i < exponent; i++) result = gfMul(result, base);
7787
+ return result;
7788
+ }
7789
+ function gfInv(value) {
7790
+ if (value === 0) throw new Error("no inverse for 0");
7791
+ return gfPow(value, 254);
7792
+ }
7793
+ /**
7794
+ * Lagrange interpolation at x=0 over the shares. Each share is
7795
+ * [x, y0, y1, ...]; the secret is the vector of y-intercepts.
7796
+ */
7797
+ function combineShares(shares) {
7798
+ if (shares.length === 0) throw new Error("no shares");
7799
+ var length = shares[0].length - 1;
7800
+ var out = new Uint8Array(length);
7801
+ for (var byte = 0; byte < length; byte++) {
7802
+ var acc = 0;
7803
+ for (var i = 0; i < shares.length; i++) {
7804
+ var xi = shares[i][0];
7805
+ var yi = shares[i][byte + 1];
7806
+ var numerator = 1;
7807
+ var denominator = 1;
7808
+ for (var j = 0; j < shares.length; j++) {
7809
+ if (i === j) continue;
7810
+ var xj = shares[j][0];
7811
+ numerator = gfMul(numerator, xj);
7812
+ denominator = gfMul(denominator, xi ^ xj);
7813
+ }
7814
+ acc ^= gfMul(yi, gfMul(numerator, gfInv(denominator)));
7815
+ }
7816
+ out[byte] = acc;
7817
+ }
7818
+ return out;
7819
+ }
7820
+
7821
+ // ── verification ────────────────────────────────────────────────────────
7822
+ async function verifyArchive(archive) {
7823
+ var problems = [];
7824
+ var truncated = [];
7825
+ var data = archive.data || {};
7826
+ var declared = {};
7827
+ for (var i = 0; i < archive.manifest.sections.length; i++) {
7828
+ var header = archive.manifest.sections[i];
7829
+ declared[header.name] = true;
7830
+ if (header.truncated) truncated.push(header.name);
7831
+ if (!(header.name in data)) {
7832
+ problems.push("section " + header.name + " is missing");
7833
+ continue;
7834
+ }
7835
+ var value = data[header.name] === undefined ? null : data[header.name];
7836
+ var digest = await sha256Hex(canonicalJson(value === undefined ? null : value));
7837
+ if (digest !== header.digest || sectionCount(value) !== header.count) {
7838
+ problems.push("section " + header.name + " does not match its digest");
7839
+ }
7840
+ }
7841
+ Object.keys(data).forEach(function (key) {
7842
+ if (!declared[key]) problems.push("section " + key + " is present but undeclared");
7843
+ });
7844
+ var manifestDigest = await sha256Hex(canonicalJson(archive.manifest.sections));
7845
+ if (manifestDigest !== archive.manifest.digest) {
7846
+ problems.push("the manifest digest does not cover its sections");
7847
+ }
7848
+
7849
+ var signature = "unsigned";
7850
+ var note = "";
7851
+ if (archive.signature) {
7852
+ try {
7853
+ var publicKey = await crypto.subtle.importKey(
7854
+ "raw",
7855
+ hexToBytes(archive.signature.publicKey),
7856
+ { name: "Ed25519" },
7857
+ false,
7858
+ ["verify"]
7859
+ );
7860
+ var valid = await crypto.subtle.verify(
7861
+ { name: "Ed25519" },
7862
+ publicKey,
7863
+ hexToBytes(archive.signature.value),
7864
+ utf8(canonicalJson(archive.manifest))
7865
+ );
7866
+ signature = valid ? "valid" : "invalid";
7867
+ if (!valid) problems.push("the signature does not match the manifest");
7868
+ } catch (err) {
7869
+ signature = "unverifiable";
7870
+ note = "this browser cannot check Ed25519 signatures";
7871
+ }
7872
+ }
7873
+ return {
7874
+ integrityOk: problems.length === 0,
7875
+ problems: problems,
7876
+ truncated: truncated,
7877
+ signature: signature,
7878
+ signatureNote: note,
7879
+ };
7880
+ }
7881
+
7882
+ // ── unlocking + decrypting ──────────────────────────────────────────────
7883
+ async function unlockPassphrase(archive, passphrase) {
7884
+ var keyMaterial = archive.data.keyMaterial;
7885
+ if (!keyMaterial) {
7886
+ throw new Error(
7887
+ "this archive carries no key material - use a service token, a private key, or a custodian quorum"
7888
+ );
7889
+ }
7890
+ var jwk = await decryptPrivateKeyBlob(passphrase, keyMaterial.encryptedPrivateKey);
7891
+ return {
7892
+ principalId: keyMaterial.principalId,
7893
+ privateKey: await importPrivateJwk(jwk),
7894
+ how: keyMaterial.principalType + " " + keyMaterial.principalId,
7895
+ };
7896
+ }
7897
+
7898
+ async function unlockJwk(jwkText) {
7899
+ return {
7900
+ principalId: null,
7901
+ privateKey: await importPrivateJwk(jwkText.trim()),
7902
+ how: "a private key",
7903
+ };
7904
+ }
7905
+
7906
+ async function unlockToken(token) {
7907
+ var opened = await importToken(token);
7908
+ return {
7909
+ principalId: opened.principalId,
7910
+ privateKey: opened.privateKey,
7911
+ how: "service token " + opened.principalId,
7912
+ };
7913
+ }
7914
+
7915
+ /**
7916
+ * Reconstruct the org recovery key from pasted share files. Accepts several
7917
+ * JSON objects one after another, which is what you get from concatenating
7918
+ * the output of "seekrit archive share".
7919
+ */
7920
+ async function unlockShares(archive, text) {
7921
+ var shares = [];
7922
+ var pattern = /"share"\\s*:\\s*"([0-9a-f]+)"/g;
7923
+ var match = pattern.exec(text);
7924
+ while (match !== null) {
7925
+ shares.push(hexToBytes(match[1]));
7926
+ match = pattern.exec(text);
7927
+ }
7928
+ if (shares.length === 0) throw new Error("no shares found in that text");
7929
+ var jwk = fromUtf8(combineShares(shares));
7930
+ return {
7931
+ principalId: archive.manifest.org.id,
7932
+ privateKey: await importPrivateJwk(jwk),
7933
+ how: shares.length + " custodian shares",
7934
+ };
7935
+ }
7936
+
7937
+ function envLabel(archive, env) {
7938
+ var i;
7939
+ if (env.groupId) {
7940
+ for (i = 0; i < archive.data.groups.length; i++) {
7941
+ if (archive.data.groups[i].id === env.groupId) {
7942
+ return archive.data.groups[i].slug + "@" + env.slug;
7943
+ }
7944
+ }
7945
+ return env.groupId + "@" + env.slug;
7946
+ }
7947
+ for (i = 0; i < archive.data.applications.length; i++) {
7948
+ if (archive.data.applications[i].id === env.applicationId) {
7949
+ return archive.data.applications[i].slug + "/" + env.slug;
7950
+ }
7951
+ }
7952
+ return env.applicationId + "/" + env.slug;
7953
+ }
7954
+
7955
+ async function decryptAll(archive, key) {
7956
+ var environments = [];
7957
+ var skipped = [];
7958
+ for (var e = 0; e < archive.data.environments.length; e++) {
7959
+ var env = archive.data.environments[e];
7960
+ var dek = null;
7961
+ for (var g = 0; g < archive.data.environmentKeys.length; g++) {
7962
+ var grant = archive.data.environmentKeys[g];
7963
+ if (grant.environmentId !== env.id) continue;
7964
+ if (key.principalId !== null && grant.principalId !== key.principalId) continue;
7965
+ try {
7966
+ dek = await unwrap(grant.wrappedDek, key.privateKey);
7967
+ break;
7968
+ } catch (err) {
7969
+ // Not this key's grant. A principal with no grant here is normal.
7970
+ }
7971
+ }
7972
+ if (dek === null) {
7973
+ skipped.push(envLabel(archive, env));
7974
+ continue;
7975
+ }
7976
+ var values = [];
7977
+ var failures = [];
7978
+ for (var s = 0; s < archive.data.secrets.length; s++) {
7979
+ var secret = archive.data.secrets[s];
7980
+ if (secret.environmentId !== env.id) continue;
7981
+ try {
7982
+ values.push({
7983
+ name: secret.name,
7984
+ value: await decryptSecret(
7985
+ dek,
7986
+ secret.ciphertext,
7987
+ secret.environmentId + "/" + secret.name
7988
+ ),
7989
+ });
7990
+ } catch (err) {
7991
+ failures.push(secret.name);
7992
+ }
7993
+ }
7994
+ values.sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; });
7995
+ environments.push({ label: envLabel(archive, env), values: values, failures: failures });
7996
+ }
7997
+ return { environments: environments, skipped: skipped };
7998
+ }
7999
+
8000
+ /**
8001
+ * dotenv quoting, mirroring packages/core/src/dotenv.ts — single quotes when
8002
+ * they are safe (literal, so a JSON credential survives untouched), double
8003
+ * quotes with written-out escapes otherwise.
8004
+ */
8005
+ function needsQuoting(value) {
8006
+ return /[\\s"'\`$\\\\#]/.test(value) || value === "";
8007
+ }
8008
+ function dotenvQuote(value) {
8009
+ if (!needsQuoting(value)) return value;
8010
+ if (value.indexOf("'") === -1 && !/[\\n\\r]/.test(value)) return "'" + value + "'";
8011
+ return (
8012
+ '"' +
8013
+ value
8014
+ .replace(/\\\\/g, "\\\\\\\\")
8015
+ .replace(/"/g, '\\\\"')
8016
+ .replace(/\\n/g, "\\\\n")
8017
+ .replace(/\\r/g, "\\\\r") +
8018
+ '"'
8019
+ );
8020
+ }
8021
+ function toDotenv(values) {
8022
+ return values
8023
+ .map(function (entry) { return entry.name + "=" + dotenvQuote(entry.value); })
8024
+ .join("\\n");
8025
+ }
8026
+
8027
+ var api = {
8028
+ canonicalJson: canonicalJson,
8029
+ verifyArchive: verifyArchive,
8030
+ unlockPassphrase: unlockPassphrase,
8031
+ unlockToken: unlockToken,
8032
+ unlockJwk: unlockJwk,
8033
+ unlockShares: unlockShares,
8034
+ decryptAll: decryptAll,
8035
+ toDotenv: toDotenv,
8036
+ };
8037
+ globalThis.seekritOffline = api;
8038
+
8039
+ // Everything above is pure and runs headless; the test suite drives it through
8040
+ // globalThis.seekritOffline. Only what follows needs a document.
8041
+ if (typeof document === "undefined") return;
8042
+
8043
+ var archive = null;
8044
+ var el = function (id) { return document.getElementById(id); };
8045
+ var text = function (value) { return document.createTextNode(String(value)); };
8046
+
8047
+ function node(tag, className, content) {
8048
+ var element = document.createElement(tag);
8049
+ if (className) element.className = className;
8050
+ if (content !== undefined) element.appendChild(text(content));
8051
+ return element;
8052
+ }
8053
+
8054
+ function setStatus(message, kind) {
8055
+ var status = el("status");
8056
+ status.className = kind ? kind : "muted";
8057
+ status.textContent = message;
8058
+ }
8059
+
8060
+ async function loadArchive(fileText) {
8061
+ try {
8062
+ archive = JSON.parse(fileText);
8063
+ } catch (err) {
8064
+ archive = null;
8065
+ setStatus("that file is not JSON", "bad");
8066
+ return;
8067
+ }
8068
+ if (!archive || archive.format !== "seekrit-archive/v1" || !archive.manifest) {
8069
+ archive = null;
8070
+ el("manifest").className = "panel bad";
8071
+ el("manifest").textContent = "not a seekrit-archive/v1 file";
8072
+ return;
8073
+ }
8074
+ var check = await verifyArchive(archive);
8075
+ var panel = el("manifest");
8076
+ panel.className = "panel";
8077
+ panel.textContent = "";
8078
+
8079
+ var list = document.createElement("dl");
8080
+ list.className = "kv";
8081
+ var rows = [
8082
+ ["organization", archive.manifest.org.name + " (" + archive.manifest.org.slug + ")"],
8083
+ ["created", archive.manifest.createdAt],
8084
+ ["exported by", archive.manifest.requestedBy.label || archive.manifest.requestedBy.actorId],
8085
+ ["producer", archive.manifest.producer.service + " / " + archive.manifest.producer.environment],
8086
+ ["environments", String(sectionCount(archive.data.environments))],
8087
+ ["secrets", String(sectionCount(archive.data.secrets))],
8088
+ ["key grants", String(sectionCount(archive.data.environmentKeys))],
8089
+ ["integrity", check.integrityOk ? "every section matches its digest" : "FAILED"],
8090
+ [
8091
+ "signature",
8092
+ check.signature === "valid"
8093
+ ? "valid, key " + archive.signature.keyId
8094
+ : check.signature + (check.signatureNote ? " (" + check.signatureNote + ")" : ""),
8095
+ ],
8096
+ ];
8097
+ rows.forEach(function (row) {
8098
+ list.appendChild(node("dt", null, row[0]));
8099
+ var value = node("dd", null, row[1]);
8100
+ if (row[0] === "integrity") value.className = check.integrityOk ? "ok" : "bad";
8101
+ if (row[0] === "signature") {
8102
+ value.className =
8103
+ check.signature === "valid" ? "ok" : check.signature === "invalid" ? "bad" : "warn";
8104
+ }
8105
+ list.appendChild(value);
8106
+ });
8107
+ panel.appendChild(list);
8108
+
8109
+ if (check.problems.length > 0) {
8110
+ var problems = document.createElement("ul");
8111
+ check.problems.forEach(function (problem) {
8112
+ problems.appendChild(node("li", "bad", problem));
8113
+ });
8114
+ panel.appendChild(problems);
8115
+ }
8116
+ if (check.truncated.length > 0) {
8117
+ panel.appendChild(
8118
+ node("p", "warn", "truncated sections: " + check.truncated.join(", "))
8119
+ );
8120
+ }
8121
+ panel.classList.remove("hidden");
8122
+ el("step2").classList.remove("hidden");
8123
+ el("key-owner").textContent = archive.data.keyMaterial
8124
+ ? "unlocks " + archive.data.keyMaterial.principalType + " " + archive.data.keyMaterial.principalId
8125
+ : "this archive carries no key material - use another method";
8126
+ setStatus("");
8127
+ }
8128
+
8129
+ function showResults(result, key) {
8130
+ var container = el("results");
8131
+ container.textContent = "";
8132
+ container.appendChild(node("h2", null, "3 - plaintext"));
8133
+ container.appendChild(
8134
+ node("p", "muted", "unlocked with " + key.how + ". Values are as stored: a \${REF} reference is expanded when an app reads it, not here.")
8135
+ );
8136
+
8137
+ result.environments.forEach(function (entry) {
8138
+ var panel = node("div", "panel env");
8139
+ panel.appendChild(node("strong", null, entry.label));
8140
+ panel.appendChild(
8141
+ node("span", "muted", " " + entry.values.length + (entry.values.length === 1 ? " secret" : " secrets"))
8142
+ );
8143
+
8144
+ var table = document.createElement("table");
8145
+ var head = document.createElement("tr");
8146
+ head.appendChild(node("th", null, "name"));
8147
+ head.appendChild(node("th", null, "value"));
8148
+ table.appendChild(head);
8149
+ entry.values.forEach(function (item) {
8150
+ var row = document.createElement("tr");
8151
+ row.appendChild(node("td", null, item.name));
8152
+ var cell = node("td", "value");
8153
+ var masked = node("span", null, "•".repeat(Math.min(24, Math.max(6, item.value.length))));
8154
+ var reveal = node("button", null, "reveal");
8155
+ reveal.style.marginLeft = "0.5rem";
8156
+ reveal.addEventListener("click", function () {
8157
+ if (reveal.textContent === "reveal") {
8158
+ masked.textContent = item.value;
8159
+ reveal.textContent = "hide";
8160
+ } else {
8161
+ masked.textContent = "•".repeat(Math.min(24, Math.max(6, item.value.length)));
8162
+ reveal.textContent = "reveal";
8163
+ }
8164
+ });
8165
+ cell.appendChild(masked);
8166
+ cell.appendChild(reveal);
8167
+ row.appendChild(cell);
8168
+ table.appendChild(row);
8169
+ });
8170
+ panel.appendChild(table);
8171
+
8172
+ if (entry.failures.length > 0) {
8173
+ panel.appendChild(node("p", "bad", "failed to decrypt: " + entry.failures.join(", ")));
8174
+ }
8175
+
8176
+ var area = document.createElement("textarea");
8177
+ area.readOnly = true;
8178
+ area.spellcheck = false;
8179
+ area.className = "hidden";
8180
+ area.value = toDotenv(entry.values);
8181
+
8182
+ var show = node("button", null, "show as .env");
8183
+ show.addEventListener("click", function () {
8184
+ area.classList.toggle("hidden");
8185
+ show.textContent = area.classList.contains("hidden") ? "show as .env" : "hide .env";
8186
+ });
8187
+ var copy = node("button", null, "copy .env");
8188
+ copy.addEventListener("click", function () {
8189
+ area.classList.remove("hidden");
8190
+ area.select();
8191
+ try {
8192
+ document.execCommand("copy");
8193
+ copy.textContent = "copied";
8194
+ } catch (err) {
8195
+ copy.textContent = "select and copy";
8196
+ }
8197
+ });
8198
+ var actions = node("div", "row");
8199
+ actions.style.marginTop = "0.75rem";
8200
+ actions.appendChild(show);
8201
+ actions.appendChild(copy);
8202
+ panel.appendChild(actions);
8203
+ panel.appendChild(area);
8204
+ container.appendChild(panel);
8205
+ });
8206
+
8207
+ if (result.skipped.length > 0) {
8208
+ container.appendChild(
8209
+ node(
8210
+ "p",
8211
+ "muted",
8212
+ "skipped " + result.skipped.length +
8213
+ (result.skipped.length === 1 ? " environment" : " environments") +
8214
+ " this key holds no grant on: " + result.skipped.join(", ")
8215
+ )
8216
+ );
8217
+ }
8218
+ if (result.environments.length === 0) {
8219
+ container.appendChild(
8220
+ node("p", "bad", "this key opens none of the environments in the archive")
8221
+ );
8222
+ }
8223
+ }
8224
+
8225
+ el("file").addEventListener("change", function (event) {
8226
+ var file = event.target.files && event.target.files[0];
8227
+ if (!file) return;
8228
+ var reader = new FileReader();
8229
+ reader.onload = function () { loadArchive(String(reader.result)); };
8230
+ reader.readAsText(file);
8231
+ });
8232
+
8233
+ var drop = el("drop");
8234
+ ["dragenter", "dragover"].forEach(function (name) {
8235
+ drop.addEventListener(name, function (event) {
8236
+ event.preventDefault();
8237
+ drop.classList.add("over");
8238
+ });
8239
+ });
8240
+ ["dragleave", "drop"].forEach(function (name) {
8241
+ drop.addEventListener(name, function (event) {
8242
+ event.preventDefault();
8243
+ drop.classList.remove("over");
8244
+ });
8245
+ });
8246
+ drop.addEventListener("drop", function (event) {
8247
+ var file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
8248
+ if (!file) return;
8249
+ var reader = new FileReader();
8250
+ reader.onload = function () { loadArchive(String(reader.result)); };
8251
+ reader.readAsText(file);
8252
+ });
8253
+
8254
+ el("method").addEventListener("change", function () {
8255
+ var method = el("method").value;
8256
+ ["passphrase", "token", "jwk", "shares"].forEach(function (name) {
8257
+ el("field-" + name).classList.toggle("hidden", name !== method);
8258
+ });
8259
+ });
8260
+
8261
+ el("decrypt").addEventListener("click", async function () {
8262
+ if (!archive) {
8263
+ setStatus("load an archive first", "warn");
8264
+ return;
8265
+ }
8266
+ setStatus("working...");
8267
+ try {
8268
+ var method = el("method").value;
8269
+ var key;
8270
+ if (method === "passphrase") key = await unlockPassphrase(archive, el("passphrase").value);
8271
+ else if (method === "token") key = await unlockToken(el("token").value);
8272
+ else if (method === "jwk") key = await unlockJwk(el("jwk").value);
8273
+ else key = await unlockShares(archive, el("shares").value);
8274
+ var result = await decryptAll(archive, key);
8275
+ showResults(result, key);
8276
+ setStatus(
8277
+ "decrypted " + result.environments.length +
8278
+ (result.environments.length === 1 ? " environment" : " environments"),
8279
+ "ok"
8280
+ );
8281
+ } catch (err) {
8282
+ setStatus(err && err.message ? err.message : "could not decrypt", "bad");
8283
+ }
8284
+ });
8285
+ })();
8286
+ <\/script>
8287
+ </body>
8288
+ </html>
8289
+ `;
8290
+ //#endregion
8291
+ //#region src/format.ts
8292
+ function shellQuote(value) {
8293
+ return `'${value.replaceAll("'", `'\\''`)}'`;
8294
+ }
8295
+ function formatSecrets(values, format) {
8296
+ const names = Object.keys(values).sort();
8297
+ switch (format) {
8298
+ case "json": return JSON.stringify(values, names, 2);
8299
+ case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
8300
+ case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
8301
+ }
8302
+ }
8303
+ //#endregion
8304
+ //#region src/archive.ts
8305
+ /**
8306
+ * Break-glass archives (docs/break-glass-export.md).
8307
+ *
8308
+ * `create` is the only subcommand that talks to the API. `info`, `verify`,
8309
+ * `share`, `decrypt`, and `decryptor` are **strictly offline**: they never build
8310
+ * a client, never read credentials, and never touch the network, because the day
8311
+ * you need them is the day seekrit may not be there. Keep it that way — a
8312
+ * `buildContext()` in any of them silently breaks the promise the feature makes.
8313
+ */
8314
+ /** Collect a repeatable option into a list. */
8315
+ function collect$5(value, acc = []) {
8316
+ acc.push(value);
8317
+ return acc;
8318
+ }
8319
+ /** "1 secret" / "2 secrets" — these lines are read by people, not parsed. */
8320
+ function count(n, noun) {
8321
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
8322
+ }
8323
+ function readArchiveFile(path) {
8324
+ let text;
8325
+ try {
8326
+ text = readFileSync(path, "utf8");
8327
+ } catch (err) {
8328
+ return fail(`cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
8329
+ }
8330
+ try {
8331
+ return parseArchive(text);
8332
+ } catch (err) {
8333
+ return fail(err instanceof Error ? err.message : String(err));
8334
+ }
8335
+ }
8336
+ function writeOut(path, contents) {
8337
+ mkdirSync(dirname(path), { recursive: true });
8338
+ writeFileSync(path, contents, { mode: 384 });
8339
+ }
8340
+ /** One line per problem, for a verification that failed. */
8341
+ function verificationProblems(result) {
8342
+ const problems = [];
8343
+ if (!result.manifestDigestOk) problems.push("the manifest's digest does not cover its sections");
8344
+ for (const name of result.badSections) problems.push(`section "${name}" does not match its digest`);
8345
+ for (const name of result.missingSections) problems.push(`section "${name}" is missing`);
8346
+ for (const name of result.undeclaredSections) problems.push(`section "${name}" is present but undeclared`);
8347
+ if (result.signature === "invalid") problems.push(`signature is invalid${result.signatureNote ? ` (${result.signatureNote})` : ""}`);
8348
+ if (result.signature === "unsigned") problems.push("archive is unsigned — integrity is checkable, provenance is not");
8349
+ if (result.signature === "unverifiable") problems.push(`signature could not be checked: ${result.signatureNote ?? "unknown reason"}`);
8350
+ return problems;
8351
+ }
8352
+ function printVerification(result) {
8353
+ printFields([
8354
+ ["integrity", result.badSections.length === 0 && result.manifestDigestOk ? "ok" : "FAILED"],
8355
+ ["signature", result.signature],
8356
+ ["truncated", result.truncatedSections.length > 0 ? result.truncatedSections.join(", ") : "no"]
8357
+ ]);
8358
+ const problems = verificationProblems(result);
8359
+ if (problems.length > 0) {
8360
+ section("problems");
8361
+ for (const problem of problems) console.log(`- ${problem}`);
8362
+ }
8363
+ }
8364
+ /** `app/env`, or `group@env` for a group-owned environment. */
8365
+ function envLabel(archive, env) {
8366
+ if (env.groupId) return `${archive.data.groups.find((g) => g.id === env.groupId)?.slug ?? env.groupId}@${env.slug}`;
8367
+ return `${archive.data.applications.find((a) => a.id === env.applicationId)?.slug ?? env.applicationId}/${env.slug}`;
8368
+ }
8369
+ /** Filesystem path for an environment's output file, mirroring its label. */
8370
+ function envPath(archive, env, extension) {
8371
+ if (env.groupId) return join("groups", archive.data.groups.find((g) => g.id === env.groupId)?.slug ?? env.groupId, `${env.slug}.${extension}`);
8372
+ return join("apps", archive.data.applications.find((a) => a.id === env.applicationId)?.slug ?? env.applicationId ?? "unknown", `${env.slug}.${extension}`);
8373
+ }
8374
+ /**
8375
+ * Recover a private key to open the archive with, from (in order) a service
8376
+ * token, a private-key JWK file, a custodian quorum, or the archive's own
8377
+ * `keyMaterial` plus a passphrase.
8378
+ */
8379
+ async function resolveDecryptKey(archive, options) {
8380
+ if (options.token) {
8381
+ if (!isServiceToken(options.token)) fail("--token expects a `skt_…` service token");
8382
+ const { tokenId, privateKey } = await parseServiceToken(options.token);
8383
+ return {
8384
+ privateKey,
8385
+ principalId: tokenId,
8386
+ how: `service token ${tokenId}`
8387
+ };
8388
+ }
8389
+ if (options.keyFile) {
8390
+ let jwk;
8391
+ try {
8392
+ jwk = readFileSync(options.keyFile, "utf8").trim();
8393
+ } catch (err) {
8394
+ fail(`cannot read ${options.keyFile}: ${err instanceof Error ? err.message : String(err)}`);
8395
+ }
8396
+ return {
8397
+ privateKey: await importPrivateKey(jwk).catch(() => fail(`${options.keyFile} is not a P-256 private key JWK`)),
8398
+ principalId: null,
8399
+ how: `private key from ${options.keyFile}`
8400
+ };
8401
+ }
8402
+ if (options.share && options.share.length > 0) {
8403
+ const shares = options.share.map((path) => readShareFile(path));
8404
+ return {
8405
+ privateKey: await combineRecoveryShares(shares).catch((err) => fail(`could not reconstruct the recovery key from ${count(shares.length, "share")}: ${err instanceof Error ? err.message : String(err)}`)),
8406
+ principalId: archive.manifest.org.id,
8407
+ how: count(shares.length, "custodian share")
8408
+ };
8409
+ }
8410
+ const keyMaterial = archive.data.keyMaterial;
8411
+ if (!keyMaterial) fail("this archive carries no key material — decrypt it with --token, --key-file, or a custodian quorum (--share)");
8412
+ return {
8413
+ privateKey: await importPrivateKey(await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden(`Passphrase for ${keyMaterial.principalId}: `), keyMaterial.encryptedPrivateKey).catch((err) => fail(err instanceof Error ? err.message : String(err)))),
8414
+ principalId: keyMaterial.principalId,
8415
+ how: `${keyMaterial.principalType} ${keyMaterial.principalId} (passphrase)`
8416
+ };
8417
+ }
8418
+ const SHARE_FORMAT = "seekrit-recovery-share/v1";
8419
+ function readShareFile(path) {
8420
+ let parsed;
8421
+ try {
8422
+ parsed = JSON.parse(readFileSync(path, "utf8"));
8423
+ } catch (err) {
8424
+ return fail(`cannot read share ${path}: ${err instanceof Error ? err.message : String(err)}`);
8425
+ }
8426
+ if (parsed.format !== SHARE_FORMAT || typeof parsed.share !== "string") return fail(`${path} is not a ${SHARE_FORMAT} file`);
8427
+ try {
8428
+ return hexToBytes(parsed.share);
8429
+ } catch {
8430
+ return fail(`${path} holds a malformed share`);
8431
+ }
8432
+ }
8433
+ /**
8434
+ * Decrypt what one key can reach. Environments the key holds no grant on are
8435
+ * *skipped*, not failed: an archive spans the whole org and no single principal
8436
+ * is expected to open all of it.
8437
+ */
8438
+ async function decryptArchive(archive, key, filter) {
8439
+ const environments = [];
8440
+ const skipped = [];
8441
+ const failures = [];
8442
+ for (const env of archive.data.environments) {
8443
+ const label = envLabel(archive, env);
8444
+ if (filter && label !== filter && env.slug !== filter && env.id !== filter) continue;
8445
+ const grants = archive.data.environmentKeys.filter((grant) => grant.environmentId === env.id && (key.principalId === null || grant.principalId === key.principalId));
8446
+ let dek = null;
8447
+ for (const grant of grants) try {
8448
+ dek = await unwrapDek(grant.wrappedDek, key.privateKey);
8449
+ break;
8450
+ } catch {}
8451
+ if (!dek) {
8452
+ skipped.push(label);
8453
+ continue;
8454
+ }
8455
+ const values = {};
8456
+ for (const secret of archive.data.secrets.filter((s) => s.environmentId === env.id)) try {
8457
+ values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(secret.environmentId, secret.name));
8458
+ } catch (err) {
8459
+ failures.push(`${label} ${secret.name}: ${err instanceof Error ? err.message : "failed"}`);
8460
+ }
8461
+ environments.push({
8462
+ env,
8463
+ label,
8464
+ values
8465
+ });
8466
+ }
8467
+ return {
8468
+ environments,
8469
+ skipped,
8470
+ failures
8471
+ };
8472
+ }
8473
+ function registerArchiveCommands(program) {
8474
+ const archive = program.command("archive").description("export the whole org as one signed file, and open it offline");
8475
+ archive.command("create").description("download a signed archive of everything seekrit stores for the org").option("--org <slug>").option("-o, --out <file>", "write the archive here (default: ./seekrit-<org>-<date>.json)").option("--no-versions", "omit each secret's ciphertext history").option("--no-audit", "omit the audit trail").option("--audit-limit <n>", "keep at most this many of the newest audit rows", Number).option("--json", "print the archive to stdout instead of writing a file").action(async (options) => {
8476
+ const ctx = buildContext();
8477
+ const ref = await resolveOrg(ctx, options.org);
8478
+ const input = {
8479
+ includeVersions: options.versions,
8480
+ includeAudit: options.audit
8481
+ };
8482
+ if (options.auditLimit !== void 0) input.auditLimit = options.auditLimit;
8483
+ const result = await ctx.client.exportArchive(ref.id, input);
8484
+ if (options.json) {
8485
+ console.log(JSON.stringify(result, null, 2));
8486
+ return;
8487
+ }
8488
+ const stamp = result.manifest.createdAt.slice(0, 10);
8489
+ const path = options.out ?? `seekrit-${result.manifest.org.slug}-${stamp}.json`;
8490
+ writeOut(path, JSON.stringify(result, null, 2));
8491
+ const check = await verifyArchive(result);
8492
+ console.error(`wrote ${path}`);
8493
+ printTable(result.manifest.sections.filter((s) => s.count > 0), [
8494
+ {
8495
+ header: "section",
8496
+ value: (s) => s.name
8497
+ },
8498
+ {
8499
+ header: "rows",
8500
+ value: (s) => String(s.count)
8501
+ },
8502
+ {
8503
+ header: "truncated",
8504
+ value: (s) => s.truncated ? "yes" : ""
8505
+ }
8506
+ ], "the archive is empty");
8507
+ console.error(check.ok ? `verified: digests ok, signed by key ${result.signature?.keyId}` : `WARNING: ${verificationProblems(check).join("; ")}`);
8508
+ console.error("keep it with `seekrit archive decryptor` — together they open without seekrit, offline.");
8509
+ });
8510
+ archive.command("info <file>").description("summarize an archive (offline)").option("--json", "print the manifest as JSON").action(async (file, options) => {
8511
+ const parsed = readArchiveFile(file);
8512
+ const check = await verifyArchive(parsed);
8513
+ emit(options, {
8514
+ manifest: parsed.manifest,
8515
+ verification: check
8516
+ }, () => {
8517
+ printFields([
8518
+ ["archive", parsed.manifest.archiveId],
8519
+ ["created", parsed.manifest.createdAt],
8520
+ ["org", `${parsed.manifest.org.name} (${parsed.manifest.org.slug})`],
8521
+ ["producer", `${parsed.manifest.producer.service} / ${parsed.manifest.producer.environment}`],
8522
+ ["format", parsed.manifest.producer.formatVersion],
8523
+ ["requested by", parsed.manifest.requestedBy.label ?? parsed.manifest.requestedBy.actorId],
8524
+ ["key material", parsed.data.keyMaterial ? parsed.data.keyMaterial.principalId : "none"],
8525
+ ["signature", parsed.signature ? `${parsed.signature.algorithm} / ${parsed.signature.keyId}` : "unsigned"]
8526
+ ]);
8527
+ section("sections");
8528
+ printTable(parsed.manifest.sections, [
8529
+ {
8530
+ header: "section",
8531
+ value: (s) => s.name
8532
+ },
8533
+ {
8534
+ header: "rows",
8535
+ value: (s) => String(s.count)
8536
+ },
8537
+ {
8538
+ header: "truncated",
8539
+ value: (s) => s.truncated ? "yes" : ""
8540
+ }
8541
+ ], "none");
8542
+ section("verification");
8543
+ printVerification(check);
8544
+ });
8545
+ });
8546
+ archive.command("verify <file>").description("check an archive's digests and signature (offline); exits non-zero if it fails").option("--key-id <id>", "require this signing key id (see /.well-known/seekrit-export-signing-key)").option("--skip-signature", "check digests only").option("--json", "print the verification result as JSON").action(async (file, options) => {
8547
+ const check = await verifyArchive(readArchiveFile(file), {
8548
+ expectKeyId: options.keyId,
8549
+ skipSignature: options.skipSignature
8550
+ });
8551
+ const integrityOk = check.badSections.length === 0 && check.missingSections.length === 0 && check.undeclaredSections.length === 0 && check.manifestDigestOk;
8552
+ const passed = options.skipSignature ? integrityOk : check.ok;
8553
+ emit(options, check, () => printVerification(check));
8554
+ if (!passed) process.exit(1);
8555
+ });
8556
+ archive.command("share <file>").description("unwrap your own recovery share from an archive, for an offline quorum (offline)").option("-o, --out <file>", "write the share here (default: stdout)").option("--token <skt_…>", "unwrap with a service token instead of a passphrase").option("--key-file <path>", "unwrap with a private key JWK file").action(async (file, options) => {
8557
+ const parsed = readArchiveFile(file);
8558
+ if (parsed.data.recoveryShares.length === 0) fail("this archive holds no recovery shares — customer-controlled recovery is not set up");
8559
+ const key = await resolveDecryptKey(parsed, {
8560
+ token: options.token,
8561
+ keyFile: options.keyFile
8562
+ });
8563
+ const candidates = parsed.data.recoveryShares.filter((share) => key.principalId === null || share.custodianId === key.principalId);
8564
+ for (const candidate of candidates) try {
8565
+ const bytes = await unwrapRecoveryShare(candidate.wrappedShare, key.privateKey);
8566
+ const payload = {
8567
+ format: SHARE_FORMAT,
8568
+ org: {
8569
+ id: parsed.manifest.org.id,
8570
+ slug: parsed.manifest.org.slug
8571
+ },
8572
+ custodianType: candidate.custodianType,
8573
+ custodianId: candidate.custodianId,
8574
+ shareIndex: candidate.shareIndex,
8575
+ share: bytesToHex(bytes),
8576
+ note: "Sensitive: a threshold of these shares reconstructs the org recovery key, which opens every environment. Destroy this file after the ceremony."
8577
+ };
8578
+ const text = JSON.stringify(payload, null, 2);
8579
+ if (options.out) {
8580
+ writeOut(options.out, text);
8581
+ console.error(`wrote share ${candidate.shareIndex} to ${options.out}`);
8582
+ } else console.log(text);
8583
+ console.error(`combine ${parsed.data.recoveryConfig?.threshold ?? "M"} shares with: seekrit archive decrypt ${file} --share … --share …`);
8584
+ return;
8585
+ } catch {}
8586
+ fail("none of the recovery shares in this archive unwrap with that key");
8587
+ });
8588
+ archive.command("decrypt <file>").description("decrypt an archive's secrets with your own key (offline)").option("-o, --out <dir>", "write one file per environment into this directory").option("--stdout", "print plaintext to stdout instead of writing files").option("--env <label>", "only this environment (app/env, group@env, slug, or id)").option("--format <format>", "dotenv | json | shell", "dotenv").option("--token <skt_…>", "decrypt as a service token instead of a passphrase").option("--key-file <path>", "decrypt with a private key JWK file").option("--share <file>", "custodian share for an offline quorum (repeatable)", collect$5).option("--yes", "skip the confirmation when printing plaintext to stdout").action(async (file, options) => {
8589
+ if (!options.out && !options.stdout) fail("choose a destination: --out <dir> to write files, or --stdout to print plaintext");
8590
+ if (![
8591
+ "dotenv",
8592
+ "json",
8593
+ "shell"
8594
+ ].includes(options.format)) fail(`unknown --format "${options.format}" (dotenv | json | shell)`);
8595
+ const parsed = readArchiveFile(file);
8596
+ const check = await verifyArchive(parsed);
8597
+ if (check.badSections.length > 0 || !check.manifestDigestOk) fail(`refusing to decrypt: ${verificationProblems(check).join("; ")} — run \`seekrit archive verify ${file}\``);
8598
+ if (check.signature !== "valid") console.error(`warning: ${verificationProblems(check).join("; ")}`);
8599
+ if (options.stdout) await confirmDestructive(options.yes, "This prints decrypted secret values to stdout, where they may land in scrollback or CI logs. Continue?");
8600
+ const key = await resolveDecryptKey(parsed, options);
8601
+ const result = await decryptArchive(parsed, key, options.env);
8602
+ console.error(`unlocked with ${key.how}`);
8603
+ const extension = options.format === "json" ? "json" : options.format === "shell" ? "sh" : "env";
8604
+ let written = 0;
8605
+ for (const entry of result.environments) {
8606
+ const body = formatSecrets(entry.values, options.format);
8607
+ if (options.out) {
8608
+ const path = join(options.out, envPath(parsed, entry.env, extension));
8609
+ writeOut(path, `${body}\n`);
8610
+ written += 1;
8611
+ console.error(`${path} (${count(Object.keys(entry.values).length, "secret")})`);
8612
+ } else {
8613
+ console.log(`# ${entry.label}`);
8614
+ console.log(body);
8615
+ console.log("");
8616
+ }
8617
+ }
8618
+ if (result.skipped.length > 0) console.error(`skipped ${count(result.skipped.length, "environment")} this key holds no grant on: ${result.skipped.join(", ")}`);
8619
+ for (const failure of result.failures) console.error(`failed: ${failure}`);
8620
+ if (options.out) console.error(`wrote ${count(written, "file")} under ${options.out}`);
8621
+ if (result.environments.length === 0) fail("nothing decrypted — this key opens none of the environments in the archive");
8622
+ console.error(`note: values are as stored — \`\${REF}\` references are expanded at read time, not here.`);
8623
+ });
8624
+ archive.command("decryptor").description("write the standalone offline decryptor (a single HTML file, no install, no network)").option("-o, --out <file>", "where to write it", "seekrit-decrypt.html").action((options) => {
8625
+ writeOut(options.out, OFFLINE_DECRYPTOR_HTML);
8626
+ console.error(`wrote ${options.out}`);
8627
+ console.error("open it in any browser — it declares a Content-Security-Policy of `default-src 'none'`, so it cannot reach the network.");
8628
+ });
8629
+ }
8630
+ //#endregion
5900
8631
  //#region src/audit.ts
5901
8632
  /** The API's per-page ceiling (`auditQuerySchema.limit`). */
5902
8633
  const MAX_PAGE = 200;
@@ -6423,19 +9154,6 @@ function message(err) {
6423
9154
  return err instanceof Error ? err.message : String(err);
6424
9155
  }
6425
9156
  //#endregion
6426
- //#region src/format.ts
6427
- function shellQuote(value) {
6428
- return `'${value.replaceAll("'", `'\\''`)}'`;
6429
- }
6430
- function formatSecrets(values, format) {
6431
- const names = Object.keys(values).sort();
6432
- switch (format) {
6433
- case "json": return JSON.stringify(values, names, 2);
6434
- case "shell": return names.map((name) => `export ${name}=${shellQuote(values[name] ?? "")}`).join("\n");
6435
- case "dotenv": return names.map((name) => `${name}=${dotenvQuote(values[name] ?? "")}`).join("\n");
6436
- }
6437
- }
6438
- //#endregion
6439
9157
  //#region src/gcp.ts
6440
9158
  /**
6441
9159
  * `seekrit gcp` — temporary GCP credentials via IAM Credentials
@@ -7533,7 +10251,7 @@ function collect$2(value, acc) {
7533
10251
  * release-please-config.json), so the pin follows the crate without anyone
7534
10252
  * remembering to move it.
7535
10253
  */
7536
- const PROXY_VERSION = "0.8.0";
10254
+ const PROXY_VERSION = "0.10.0";
7537
10255
  const BIN = "seekrit-proxy";
7538
10256
  /**
7539
10257
  * Host → Rust target triple.
@@ -7569,7 +10287,7 @@ function versionPrefix(version) {
7569
10287
  return version.startsWith("v") ? version : `v${version}`;
7570
10288
  }
7571
10289
  function resolveVersion(explicit) {
7572
- return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.8.0";
10290
+ return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
7573
10291
  }
7574
10292
  function resolveBaseUrl(explicit) {
7575
10293
  return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
@@ -7999,6 +10717,27 @@ function renderProxyConfig(plan) {
7999
10717
  out.push(`max_ttl = ${tomlString(plan.control.maxTtl)}`);
8000
10718
  out.push("");
8001
10719
  }
10720
+ if (plan.tasks) {
10721
+ out.push("# Honour tasks dispatched through the API (`seekrit agents dispatch`): a run");
10722
+ out.push("# presents its skd_… token in the same header a local ticket uses, and this");
10723
+ out.push("# proxy asks the API what it authorizes. Opt-in, because it makes authorizing");
10724
+ out.push("# a new run depend on reaching seekrit — a refused or unreachable check denies");
10725
+ out.push("# the request rather than admitting it.");
10726
+ out.push("#");
10727
+ out.push("# cache_ttl bounds how long a revoked run keeps working. Short on purpose.");
10728
+ out.push("[tasks]");
10729
+ out.push(`cache_ttl = ${tomlString(plan.tasks.cacheTtl)}`);
10730
+ out.push("");
10731
+ }
10732
+ if (plan.activity) {
10733
+ out.push("# Report aggregate decisions back, so `seekrit agents review` can compare this");
10734
+ out.push("# policy against what the agent actually does. Counts only: hosts, methods,");
10735
+ out.push("# secret *names*, and which rule decided — never a request path, never a value.");
10736
+ out.push("# Full per-request detail stays in your own OTLP collector.");
10737
+ out.push("[activity]");
10738
+ out.push(`flush_interval = ${tomlString(plan.activity.flushInterval)}`);
10739
+ out.push("");
10740
+ }
8002
10741
  if (plan.envHints.length > 0) {
8003
10742
  out.push("# ---------------------------------------------------------------------------");
8004
10743
  out.push("# Point the workload at the proxy (these go in its environment, not here):");
@@ -8077,6 +10816,8 @@ function planFromPresets(presets, options) {
8077
10816
  ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
8078
10817
  ...options.secretsRefresh ? { secretsRefresh: options.secretsRefresh } : {},
8079
10818
  ...options.control ? { control: options.control } : {},
10819
+ ...options.tasks ? { tasks: options.tasks } : {},
10820
+ ...options.activity ? { activity: options.activity } : {},
8080
10821
  envHints,
8081
10822
  notes
8082
10823
  };
@@ -8142,6 +10883,8 @@ function planFromPolicy(args, options) {
8142
10883
  caKey: options.caKey,
8143
10884
  ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
8144
10885
  ...options.control ? { control: options.control } : {},
10886
+ ...options.tasks ? { tasks: options.tasks } : {},
10887
+ ...options.activity ? { activity: options.activity } : {},
8145
10888
  envHints,
8146
10889
  notes
8147
10890
  };
@@ -8300,7 +11043,9 @@ function planOptions(options) {
8300
11043
  listen: options.control,
8301
11044
  ttl: "1h",
8302
11045
  maxTtl: "12h"
8303
- } } : {}
11046
+ } } : {},
11047
+ ...options.tasks || options.tasksCacheTtl ? { tasks: { cacheTtl: duration(options.tasksCacheTtl, "--tasks-cache-ttl") ?? "30s" } } : {},
11048
+ ...options.activity || options.activityInterval ? { activity: { flushInterval: duration(options.activityInterval, "--activity-interval") ?? "60s" } } : {}
8304
11049
  };
8305
11050
  }
8306
11051
  /**
@@ -8419,7 +11164,7 @@ async function buildPlan(options) {
8419
11164
  }
8420
11165
  /** Add the generation flags to a command, so `init` and `run` stay in step. */
8421
11166
  function withGenerateOptions(cmd) {
8422
- 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");
11167
+ 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)");
8423
11168
  }
8424
11169
  function registerProxyCommands(program) {
8425
11170
  const proxy = program.command("proxy").description("run and configure the agent egress proxy (`seekrit proxy --help`)");
@@ -8486,10 +11231,10 @@ Next:
8486
11231
  });
8487
11232
  console.log(path);
8488
11233
  const { target } = detectTarget();
8489
- process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.8.0")} (${target})\n`);
11234
+ process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.10.0")} (${target})\n`);
8490
11235
  });
8491
11236
  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) => {
8492
- const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.8.0";
11237
+ const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
8493
11238
  const override = process.env.SEEKRIT_PROXY_BIN;
8494
11239
  const { target, exe } = detectTarget();
8495
11240
  const path = override ?? proxyBinaryPath(version, target, exe);
@@ -8510,7 +11255,7 @@ Next:
8510
11255
  process.stdout.write(renderComposeSnippet(plan, {
8511
11256
  service: options.service,
8512
11257
  workload: options.workload,
8513
- image: options.image ?? `seekritdev/proxy:0.8.0`,
11258
+ image: options.image ?? `seekritdev/proxy:0.10.0`,
8514
11259
  publish: Boolean(options.publish)
8515
11260
  }));
8516
11261
  });
@@ -8668,17 +11413,6 @@ function collect$1(value, acc) {
8668
11413
  * Rotated values are never printed here. Read them like any other secret
8669
11414
  * (`seekrit secrets get NAME`), which decrypts locally.
8670
11415
  */
8671
- /** Parse a duration like `30m`, `24h`, `90d`, or a bare seconds count. */
8672
- function parseDurationSeconds(input, flag) {
8673
- const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
8674
- if (!m) fail(`invalid ${flag} "${input}" (try 12h, 7d, 90d)`);
8675
- return Number(m[1]) * ({
8676
- s: 1,
8677
- m: 60,
8678
- h: 3600,
8679
- d: 86400
8680
- }[m[2] || "s"] ?? 1);
8681
- }
8682
11416
  function formatInterval(seconds) {
8683
11417
  if (seconds % 86400 === 0) return `${seconds / 86400}d`;
8684
11418
  if (seconds % 3600 === 0) return `${seconds / 3600}h`;
@@ -10512,6 +13246,7 @@ registerMysqlCommands(program);
10512
13246
  registerRedisCommands(program);
10513
13247
  registerProvisionerCommands(program);
10514
13248
  registerProxyCommands(program);
13249
+ registerAgentCommands(program);
10515
13250
  registerSshCommands(program);
10516
13251
  registerAwsCommands(program);
10517
13252
  registerGcpCommands(program);
@@ -10525,6 +13260,7 @@ program.command("mcp").description("run an MCP server over stdio so AI agents ca
10525
13260
  });
10526
13261
  registerAuditCommands(program);
10527
13262
  registerAccountCommands(program);
13263
+ registerArchiveCommands(program);
10528
13264
  registerLogSinkCommands(program);
10529
13265
  registerSyncCommands(program);
10530
13266
  registerBillingCommands(program);