@seekrit/cli 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1336 -21
  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,259 @@ 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) });
85
565
  /** All catalog keys as a runtime array (for iteration / zod enums). */
86
566
  const ENTITLEMENT_KEYS = Object.keys({
87
567
  "feature.kms": {
@@ -1267,7 +1747,9 @@ const AUDIT_ACTIONS = [
1267
1747
  "agent.updated",
1268
1748
  "agent.deleted",
1269
1749
  "agent.policy_published",
1270
- "agent.policy_rolled_back"
1750
+ "agent.policy_rolled_back",
1751
+ "agent.task_dispatched",
1752
+ "agent.task_revoked"
1271
1753
  ];
1272
1754
  /**
1273
1755
  * Transactional notification emails seekrit can send. Each id is one
@@ -3492,6 +3974,76 @@ async function decryptPrivateKey(passphrase, blob) {
3492
3974
  }
3493
3975
  }
3494
3976
  //#endregion
3977
+ //#region ../../packages/crypto/src/policy-key.ts
3978
+ /**
3979
+ * Signing with a principal's **existing** keypair, for agent access policy.
3980
+ *
3981
+ * Policy bundles are signed in the browser so the API can serve a blob it cannot
3982
+ * forge (`docs/agent-access-governance.md` §1). Every user already has a P-256
3983
+ * keypair whose private half is passphrase-encrypted and opaque to the server
3984
+ * (`users.public_key_jwk`), so this feature needs **no new key material**: no
3985
+ * second passphrase, no wrapping, no schema, and nothing extra for an admin to
3986
+ * lose. That was a deliberate condition of the design.
3987
+ *
3988
+ * The catch is that WebCrypto keys are algorithm-bound. A principal key is
3989
+ * imported for ECDH (`deriveBits`) and cannot sign, even though the underlying
3990
+ * curve is the same one ECDSA uses. So the JWK is re-imported here with the
3991
+ * algorithm hints stripped — the same private scalar, presented as an ECDSA key.
3992
+ *
3993
+ * **The tradeoff, stated plainly:** this reuses one key for two algorithms,
3994
+ * which key-management hygiene (NIST SP 800-57 §5.2) advises against. We accept
3995
+ * it because the alternative — a second keypair per admin — is the kind of
3996
+ * ceremony that gets skipped, and because both uses stay inside the same trust
3997
+ * boundary: the key already authorizes reading every secret the admin can read,
3998
+ * so a signature capability adds no reach an attacker holding it wouldn't have.
3999
+ * If a future version wants separation, the clean path is a managed KMS `sign`
4000
+ * key granted to publishers — the thumbprint pinning in the proxy works
4001
+ * unchanged, which is why the format carries the key rather than a user id.
4002
+ */
4003
+ const ECDSA_PARAMS$1 = {
4004
+ name: "ECDSA",
4005
+ namedCurve: "P-256"
4006
+ };
4007
+ /**
4008
+ * Re-import a principal's private key JWK as an ECDSA signing key.
4009
+ *
4010
+ * `key_ops`, `alg`, and `use` are dropped: they say "ECDH" on a principal key,
4011
+ * and WebCrypto refuses an import whose declared operations don't include the
4012
+ * requested usage. Everything that determines the key — `crv`, `d`, `x`, `y` —
4013
+ * is passed through untouched.
4014
+ */
4015
+ async function importPolicySigningKey(privateKeyJwk) {
4016
+ const jwk = JSON.parse(privateKeyJwk);
4017
+ if (jwk.kty !== "EC" || jwk.crv !== "P-256") throw new SeekritCryptoError("MALFORMED_BLOB", "policy signing needs an EC P-256 principal key");
4018
+ if (!jwk.d) throw new SeekritCryptoError("MALFORMED_BLOB", "policy signing needs the private half of the key");
4019
+ const { kty, crv, d, x, y } = jwk;
4020
+ return crypto.subtle.importKey("jwk", {
4021
+ kty,
4022
+ crv,
4023
+ d,
4024
+ x,
4025
+ y
4026
+ }, ECDSA_PARAMS$1, false, ["sign"]);
4027
+ }
4028
+ /**
4029
+ * Trim a principal's public key JWK to the members a policy bundle carries.
4030
+ *
4031
+ * The bundle names its signer by thumbprint, which is computed over exactly
4032
+ * these four members — so anything else in the stored JWK (`key_ops`, `ext`,
4033
+ * `alg`) must be dropped here, or the thumbprint an admin pins would depend on
4034
+ * incidental fields.
4035
+ */
4036
+ function policySignerJwk(publicKeyJwk) {
4037
+ const jwk = JSON.parse(publicKeyJwk);
4038
+ if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.x || !jwk.y) throw new SeekritCryptoError("MALFORMED_BLOB", "not an EC P-256 public key");
4039
+ return {
4040
+ kty: "EC",
4041
+ crv: "P-256",
4042
+ x: jwk.x,
4043
+ y: jwk.y
4044
+ };
4045
+ }
4046
+ //#endregion
3495
4047
  //#region ../../packages/crypto/src/shamir.ts
3496
4048
  /**
3497
4049
  * Shamir's Secret Sharing over GF(2^8) — the same field AES uses, with the
@@ -3985,10 +4537,13 @@ function encodeOpensshPrivateKey(seed, pub, comment) {
3985
4537
  */
3986
4538
  const TOKEN_PREFIX = "skt";
3987
4539
  const CLI_SESSION_PREFIX = "skc";
4540
+ const TASK_PREFIX = "skd";
3988
4541
  const TOKEN_ID_LENGTH = 22;
3989
4542
  const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
3990
4543
  /** 32 bytes of entropy for the CLI session secret. */
3991
4544
  const CLI_SESSION_SECRET_BYTES = 32;
4545
+ /** 32 bytes for a task token's secret, matching the CLI session. */
4546
+ const TASK_SECRET_BYTES = 32;
3992
4547
  function randomTokenId(prefix = TOKEN_PREFIX) {
3993
4548
  let out = "";
3994
4549
  while (out.length < TOKEN_ID_LENGTH) {
@@ -4058,9 +4613,18 @@ function parseCliSessionToken(token) {
4058
4613
  function isCliSessionToken(value) {
4059
4614
  return value.startsWith(`${CLI_SESSION_PREFIX}_`);
4060
4615
  }
4616
+ async function createAgentTaskToken() {
4617
+ const taskRef = randomTokenId(TASK_PREFIX);
4618
+ const token = `${taskRef}_${toBase64Url(crypto.getRandomValues(new Uint8Array(TASK_SECRET_BYTES)))}`;
4619
+ return {
4620
+ token,
4621
+ taskRef,
4622
+ tokenHash: await hashToken(token)
4623
+ };
4624
+ }
4061
4625
  //#endregion
4062
4626
  //#region package.json
4063
- var version = "0.43.0";
4627
+ var version = "0.44.0";
4064
4628
  //#endregion
4065
4629
  //#region ../../packages/api-client/src/index.ts
4066
4630
  var SeekritApiError = class extends Error {
@@ -4437,6 +5001,60 @@ var SeekritClient = class {
4437
5001
  getAgentPolicyBundle(agentRef) {
4438
5002
  return this.request("GET", `/v1/agents/${encodeURIComponent(agentRef)}/policy`);
4439
5003
  }
5004
+ /**
5005
+ * Dispatch a task for one agent run.
5006
+ *
5007
+ * The caller mints the token (`createAgentTaskToken` in `@seekrit/crypto`) and
5008
+ * sends only its hash plus the public `skd_…` segment, so no presentable
5009
+ * credential ever reaches this API — the same shape as service-token and CLI
5010
+ * session creation. `scopes` may only narrow what the agent's published policy
5011
+ * already permits; a name outside it is refused rather than dropped.
5012
+ *
5013
+ * Not org-scoped, because an orchestrator is not: it knows an agent slug.
5014
+ */
5015
+ dispatchAgentTask(agentRef, input) {
5016
+ return this.request("POST", `/v1/agents/${encodeURIComponent(agentRef)}/dispatch`, input);
5017
+ }
5018
+ /**
5019
+ * Exchange a presented token for the session it authorizes — what an
5020
+ * enforcement point calls once per task and caches until expiry.
5021
+ *
5022
+ * A POST because the token is a credential and must not land in a URL or an
5023
+ * access log. Fails closed and says which way: revoked, expired, or a disabled
5024
+ * identity are three different answers.
5025
+ */
5026
+ introspectAgentTask(token) {
5027
+ return this.request("POST", "/v1/tasks/introspect", { token });
5028
+ }
5029
+ /** End a run's authority now. Idempotent. */
5030
+ revokeAgentTask(taskId) {
5031
+ return this.request("POST", `/v1/tasks/${taskId}/revoke`);
5032
+ }
5033
+ getAgentTask(taskId) {
5034
+ return this.request("GET", `/v1/tasks/${taskId}`);
5035
+ }
5036
+ /** Runs dispatched for one identity, newest first (admin). */
5037
+ listAgentTasks(orgId, agentId) {
5038
+ return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}/tasks`);
5039
+ }
5040
+ /**
5041
+ * Report aggregate decisions. Called by an enforcement point, not a person.
5042
+ *
5043
+ * Counts only — hosts, methods, secret *names*, decisions, and rule indices.
5044
+ * Never a request path: see the module comment in `agent-activity.ts` for why
5045
+ * that line is drawn where it is.
5046
+ */
5047
+ reportAgentActivity(agentRef, input) {
5048
+ return this.request("POST", `/v1/agents/${encodeURIComponent(agentRef)}/activity`, input);
5049
+ }
5050
+ /**
5051
+ * What an agent actually did, collapsed onto its dimensions — the evidence a
5052
+ * grant review reasons over. The proposals themselves are computed client-side
5053
+ * (`reviewPolicy` in `@seekrit/core`), so the API never opines on policy.
5054
+ */
5055
+ getAgentActivity(orgId, agentId, days = 14) {
5056
+ return this.request("GET", `/v1/orgs/${orgId}/agents/${agentId}/activity?days=${encodeURIComponent(String(days))}`);
5057
+ }
4440
5058
  /** Keys the caller can see: all org keys for admins, granted keys otherwise. */
4441
5059
  listKmsKeys(orgId) {
4442
5060
  return this.request("GET", `/v1/orgs/${orgId}/kms/keys`);
@@ -4748,6 +5366,24 @@ function fail(message) {
4748
5366
  console.error(`error: ${message}`);
4749
5367
  process.exit(1);
4750
5368
  }
5369
+ /**
5370
+ * Parse a duration flag like `30m`, `24h`, `90d`, or a bare seconds count.
5371
+ *
5372
+ * Lives here rather than in a command module because more than one command
5373
+ * takes a duration and they must agree: `--every 7d` and `--ttl 7d` meaning
5374
+ * different things would be a nasty surprise. Invalid input is a flag error, so
5375
+ * it exits through `fail` with the accepted forms named.
5376
+ */
5377
+ function parseDurationSeconds(input, flag) {
5378
+ const m = /^(\d+)\s*([smhd]?)$/.exec(input.trim());
5379
+ if (!m) fail(`invalid ${flag} "${input}" (try 12h, 7d, 90d)`);
5380
+ return Number(m[1]) * ({
5381
+ s: 1,
5382
+ m: 60,
5383
+ h: 3600,
5384
+ d: 86400
5385
+ }[m[2] || "s"] ?? 1);
5386
+ }
4751
5387
  /** Prompt without echoing input (for passphrases). */
4752
5388
  function promptHidden(question) {
4753
5389
  const muted = new Writable({ write(_chunk, _encoding, callback) {
@@ -4883,6 +5519,31 @@ async function getPrivateKey(ctx) {
4883
5519
  const { encryptedPrivateKey } = await ctx.client.getMyKeys();
4884
5520
  return importPrivateKey(await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey));
4885
5521
  }
5522
+ /**
5523
+ * Recover the caller's **policy signing** key: the same P-256 principal key,
5524
+ * re-imported for ECDSA rather than ECDH. The dashboard does exactly this in
5525
+ * `keyring.getPolicySigner`, so a bundle signed here and one signed in a browser
5526
+ * are indistinguishable — same key, same thumbprint, same pin in a proxy config.
5527
+ *
5528
+ * Service tokens are refused *here* rather than at the API, so the reason is
5529
+ * legible at the point of use: publishing policy is deliberately gated on a
5530
+ * human's key, because an agent that can widen its own authorization is not
5531
+ * governed by it. See the module comment in `apps/api/src/routes/agents.ts`.
5532
+ *
5533
+ * The honest caveat is `SEEKRIT_PASSPHRASE`: where it is set, anything that can
5534
+ * read the environment can sign. That is already true of every other CLI
5535
+ * decryption, but it matters more here, so the docs say to leave it unset on any
5536
+ * machine an agent shares.
5537
+ */
5538
+ async function getPolicySigner(ctx) {
5539
+ if (ctx.auth.type === "bearer" && isServiceToken(ctx.auth.token)) fail("publishing agent policy needs a human's signing key — sign in with `seekrit login` (a service token cannot publish, by design)");
5540
+ const { encryptedPrivateKey } = await ctx.client.getMyKeys();
5541
+ const privateKeyJwk = await decryptPrivateKey(process.env.SEEKRIT_PASSPHRASE ?? await promptHidden("Passphrase: "), encryptedPrivateKey);
5542
+ return {
5543
+ signingKey: await importPolicySigningKey(privateKeyJwk),
5544
+ jwk: policySignerJwk(privateKeyJwk)
5545
+ };
5546
+ }
4886
5547
  /** Recover one environment's DEK for the current principal. */
4887
5548
  async function getDek(ctx, orgId, envId) {
4888
5549
  const [{ wrappedDek }, privateKey] = await Promise.all([ctx.client.getMyEnvKey(orgId, envId), getPrivateKey(ctx)]);
@@ -5213,6 +5874,643 @@ function registerAccountCommands(program) {
5213
5874
  });
5214
5875
  }
5215
5876
  //#endregion
5877
+ //#region src/agents.ts
5878
+ /**
5879
+ * Agent access governance from the CLI.
5880
+ *
5881
+ * Policy lived only in the dashboard until now, which was backwards: the people
5882
+ * deploying agents work in a terminal and a repo, and a rule set is exactly the
5883
+ * kind of thing that wants review and version control. So the loop this module
5884
+ * exists for is:
5885
+ *
5886
+ * seekrit agents policy pull nova -o nova.policy.json # current rules
5887
+ * $EDITOR nova.policy.json # commit it, review it
5888
+ * seekrit agents policy publish nova -f nova.policy.json
5889
+ *
5890
+ * **Signing still happens here, on this machine, with the operator's own key**
5891
+ * (`getPolicySigner` in `context.ts`), so nothing about the trust argument in
5892
+ * `docs/agent-access-governance.md` §1 changes: the API receives an opaque
5893
+ * envelope it cannot forge, and a proxy verifies it against thumbprints pinned in
5894
+ * its own local file. A service token is refused — an agent that can publish its
5895
+ * own policy is not governed by it.
5896
+ *
5897
+ * Everything else here is read-only, and two commands are deliberately *local*
5898
+ * evaluations rather than API calls:
5899
+ *
5900
+ * - `simulate` runs `evaluatePolicy` from `@seekrit/core`, the mirror of
5901
+ * `RuleSet::decide` in the proxy — so a dry run and a real refusal give the
5902
+ * same verdict in the same words.
5903
+ * - `fetch` asks for the bytes a proxy would get (`GET /v1/agents/:ref/policy`)
5904
+ * and re-derives the signer thumbprint from the bundle itself, rather than
5905
+ * trusting the field the API echoes beside it.
5906
+ */
5907
+ /** How many published versions the API returns per page. */
5908
+ const POLICY_PAGE = 50;
5909
+ /** The `-` sentinel every other seekrit command uses to mean stdin. */
5910
+ const STDIN = "-";
5911
+ /**
5912
+ * Resolve `nova`, or `agt_…`, to an identity.
5913
+ *
5914
+ * The org-scoped API routes take an id, but nobody types ids — so a slug is
5915
+ * resolved from the list. The list is small (agents are a per-deployment thing,
5916
+ * not a per-request one), which is why this costs one request rather than
5917
+ * needing a lookup route.
5918
+ */
5919
+ async function resolveAgent(ctx, orgId, ref) {
5920
+ const { agents } = await ctx.client.listAgents(orgId);
5921
+ const found = agents.find((a) => a.slug === ref || a.id === ref);
5922
+ if (!found) {
5923
+ const known = agents.map((a) => a.slug).join(", ");
5924
+ fail(`no agent "${ref}"${known ? ` — this org has: ${known}` : " in this org"}`);
5925
+ }
5926
+ return found;
5927
+ }
5928
+ /**
5929
+ * One published version: the newest by default, or an explicit `--version`.
5930
+ *
5931
+ * An agent with no published policy is a real state, not an error — a proxy for
5932
+ * it fails closed — so the caller decides how to report it.
5933
+ */
5934
+ async function loadVersion(ctx, orgId, agent, version) {
5935
+ const { policies } = await ctx.client.listAgentPolicies(orgId, agent.id);
5936
+ if (version === void 0) return policies[0] ?? null;
5937
+ const found = policies.find((p) => p.version === version);
5938
+ if (!found) fail(policies.length >= POLICY_PAGE ? `no version ${version} in the last ${POLICY_PAGE} published` : `no version ${version} — published: ${policies.map((p) => p.version).join(", ") || "none"}`);
5939
+ return found;
5940
+ }
5941
+ /**
5942
+ * Read a rule file: either a bare array or `{ "rules": [...] }`, from a path or
5943
+ * stdin. Both shapes are accepted because `policy pull` writes the second and
5944
+ * hand-written files tend to be the first.
5945
+ *
5946
+ * Validation is the same zod schema the API and the dashboard use, so a file
5947
+ * rejected here would have been rejected there — before anything is signed.
5948
+ *
5949
+ * Exported for its own test: this is the one place a hand-written file meets the
5950
+ * schema, and its error messages are the whole user experience of a typo.
5951
+ */
5952
+ function parseRuleFile(raw, source) {
5953
+ let parsed;
5954
+ try {
5955
+ parsed = JSON.parse(raw);
5956
+ } catch (err) {
5957
+ fail(`${source} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
5958
+ }
5959
+ const list = Array.isArray(parsed) ? parsed : parsed?.rules === void 0 ? fail(`${source} must be a JSON array of rules, or an object with a "rules" array`) : parsed.rules;
5960
+ if (!Array.isArray(list)) fail(`${source}: "rules" must be an array`);
5961
+ if (list.length === 0) console.error(`note: ${source} has no rules — publishing it denies every request`);
5962
+ return list.map((rule, i) => {
5963
+ const result = agentPolicyRuleSchema.safeParse(rule);
5964
+ if (!result.success) {
5965
+ const first = result.error.issues[0];
5966
+ fail(`${source}: rule ${i + 1} is invalid — ${first?.path.join(".") || "rule"}: ${first?.message ?? "unknown error"}`);
5967
+ }
5968
+ return result.data;
5969
+ });
5970
+ }
5971
+ /** `--file path` or `--file -`. */
5972
+ async function readRuleSource(file) {
5973
+ if (file === STDIN) return {
5974
+ raw: await readStdin(),
5975
+ label: "stdin"
5976
+ };
5977
+ try {
5978
+ return {
5979
+ raw: readFileSync(file, "utf8"),
5980
+ label: file
5981
+ };
5982
+ } catch (err) {
5983
+ return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
5984
+ }
5985
+ }
5986
+ /**
5987
+ * An empty `methods` or `paths` list means *any*: those fields narrow a rule that
5988
+ * already matched its host, so absent means unconstrained. A blank column would
5989
+ * read as the opposite, so it renders as `any`.
5990
+ *
5991
+ * Exported for its own test — this is a real invariant of the format, not a
5992
+ * cosmetic choice.
5993
+ */
5994
+ function describeList(values) {
5995
+ return values.length === 0 ? "any" : values.join(" ");
5996
+ }
5997
+ /**
5998
+ * `allow` is the field where empty means the **opposite** — no secret may be
5999
+ * injected toward this host at all (`Decision::SecretNotAllowed` for every name;
6000
+ * see `crates/seekrit-core/src/policy.rs`). Such a rule is useful and
6001
+ * intentional: it permits the *request* while granting no credential, which is
6002
+ * how you let an agent read a public API through the proxy without handing it a
6003
+ * key. Rendering that as `any` would invert the meaning of the one column an
6004
+ * operator reviews most carefully, so it gets its own function.
6005
+ */
6006
+ function describeSecrets(values) {
6007
+ return values.length === 0 ? "none" : values.join(" ");
6008
+ }
6009
+ function printRules(rules) {
6010
+ printTable(rules.map((rule, i) => ({
6011
+ n: i + 1,
6012
+ rule
6013
+ })), [
6014
+ col("#", (r) => r.n),
6015
+ col("host", (r) => r.rule.host),
6016
+ col("methods", (r) => describeList(r.rule.methods)),
6017
+ col("paths", (r) => describeList(r.rule.paths)),
6018
+ col("secrets", (r) => describeSecrets(r.rule.allow)),
6019
+ col("label", (r) => r.rule.label ?? "-")
6020
+ ], "no rules — this policy denies every request");
6021
+ }
6022
+ /** One rule as a single line, for the publish diff. */
6023
+ function ruleLine(rule) {
6024
+ const parts = [
6025
+ rule.host,
6026
+ describeList(rule.methods),
6027
+ describeList(rule.paths),
6028
+ `secrets=${describeSecrets(rule.allow)}`
6029
+ ];
6030
+ return rule.label ? `${parts.join(" ")} (${rule.label})` : parts.join(" ");
6031
+ }
6032
+ /**
6033
+ * The change a publish would make. Rules are compared by *position* because
6034
+ * order decides — first match wins — so a reordering is a real change and shows
6035
+ * up as one.
6036
+ */
6037
+ function printPolicyDiff(before, after) {
6038
+ const changes = diffPolicyRules(before, after).filter((c) => c.kind !== "unchanged");
6039
+ if (changes.length === 0) {
6040
+ console.error(after.length === 0 ? "no rules — this policy denies every request" : "no rule changes — publishing would only extend the expiry");
6041
+ return;
6042
+ }
6043
+ for (const change of changes) {
6044
+ const n = change.index + 1;
6045
+ if (change.kind === "added") console.log(`+ ${n} ${ruleLine(change.after)}`);
6046
+ else if (change.kind === "removed") console.log(`- ${n} ${ruleLine(change.before)}`);
6047
+ else {
6048
+ console.log(`- ${n} ${ruleLine(change.before)}`);
6049
+ console.log(`+ ${n} ${ruleLine(change.after)}`);
6050
+ }
6051
+ }
6052
+ }
6053
+ /** Seconds until an ISO instant, or a negative number when it has passed. */
6054
+ function secondsUntil(iso) {
6055
+ return Math.round((new Date(iso).getTime() - Date.now()) / 1e3);
6056
+ }
6057
+ /** Collect a repeated `--scope NAME` into a list. */
6058
+ function collectScope(value, acc = []) {
6059
+ acc.push(value);
6060
+ return acc;
6061
+ }
6062
+ /**
6063
+ * A task's state, derived rather than stored: `revoked` beats `expired`, so a run
6064
+ * somebody killed does not read as one that merely lapsed.
6065
+ *
6066
+ * Exported for its own test — it mirrors `taskState` in
6067
+ * `apps/api/src/lib/agent-tasks.ts`, and the two disagreeing would mean the list
6068
+ * screen and the enforcement point describe the same run differently.
6069
+ */
6070
+ function taskStateOf(task, now) {
6071
+ if (task.revokedAt) return "revoked";
6072
+ return Date.parse(task.expiresAt) <= now ? "expired" : "active";
6073
+ }
6074
+ /**
6075
+ * A detail view on **stderr**, so a command whose stdout is a credential can
6076
+ * still explain itself to a person without corrupting `$(…)`.
6077
+ */
6078
+ function printFieldsToStderr(fields) {
6079
+ const present = fields.filter(([, value]) => value !== null && value !== void 0);
6080
+ const width = Math.max(...present.map(([label]) => label.length));
6081
+ for (const [label, value] of present) console.error(`${label.padEnd(width)} ${value}`);
6082
+ }
6083
+ /**
6084
+ * Exported for its own test: the expired branch is the one that must be loud.
6085
+ *
6086
+ * The granularity has to span both users of this — a policy bundle lasting a
6087
+ * week and a task lasting fifteen minutes. Rounding a task to the nearest hour
6088
+ * printed "in 0h", which is worse than no estimate at all.
6089
+ */
6090
+ function describeExpiry(iso) {
6091
+ const seconds = secondsUntil(iso);
6092
+ if (seconds <= 0) return `${iso} (EXPIRED — proxies for this agent fail closed)`;
6093
+ const days = Math.floor(seconds / 86400);
6094
+ const hours = Math.floor(seconds % 86400 / 3600);
6095
+ const minutes = Math.floor(seconds % 3600 / 60);
6096
+ if (days > 0) return `${iso} (in ${days}d ${hours}h)`;
6097
+ if (hours > 0) return `${iso} (in ${hours}h ${minutes}m)`;
6098
+ if (minutes > 0) return `${iso} (in ${minutes}m)`;
6099
+ return `${iso} (in ${seconds}s)`;
6100
+ }
6101
+ function registerAgentCommands(program) {
6102
+ const agents = program.command("agents").description("agent access policy — which agent may reach which upstream with which secret");
6103
+ agents.command("list", { isDefault: true }).description("list agent identities").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (options) => {
6104
+ const ctx = buildContext();
6105
+ const org = await resolveOrg(ctx, options.org);
6106
+ const { agents: rows } = await ctx.client.listAgents(org.id);
6107
+ emit(options, { agents: rows }, () => printTable(rows, [
6108
+ col("slug", (a) => a.slug),
6109
+ col("name", (a) => a.name),
6110
+ col("policy", (a) => a.currentPolicyVersion === 0 ? "none" : `v${a.currentPolicyVersion}`),
6111
+ col("enabled", (a) => a.enabled ? "yes" : "no"),
6112
+ col("last fetch", (a) => a.lastPolicyFetchAt ?? "never")
6113
+ ], "no agent identities — create one with `seekrit agents create`"));
6114
+ });
6115
+ agents.command("create <name>").description("create an agent identity (a policy subject; it holds no key material)").requiredOption("--slug <slug>", "short name a proxy config and a ticket refer to").option("--org <slug>", "organization").option("--app <slug>", "application, when scoping the agent to one environment").option("--env <slug>", "environment whose secret names its rules may reference").option("--json", "machine-readable output").action(async (name, options) => {
6116
+ const ctx = buildContext();
6117
+ const org = await resolveOrg(ctx, options.org);
6118
+ const environmentId = options.env ? (await resolveAppEnv(ctx, {
6119
+ org: options.org,
6120
+ app: options.app,
6121
+ env: options.env
6122
+ })).envId : void 0;
6123
+ const { agent } = await ctx.client.createAgent(org.id, {
6124
+ name,
6125
+ slug: options.slug,
6126
+ ...environmentId ? { environmentId } : {}
6127
+ });
6128
+ emit(options, { agent }, () => {
6129
+ console.error(`created ${agent.slug} — no policy published yet, so any proxy for it fails closed`);
6130
+ console.error(`next: seekrit agents policy publish ${agent.slug} -f rules.json`);
6131
+ });
6132
+ });
6133
+ agents.command("show <ref>").description("an identity and its live rules").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
6134
+ const ctx = buildContext();
6135
+ const org = await resolveOrg(ctx, options.org);
6136
+ const agent = await resolveAgent(ctx, org.id, ref);
6137
+ const { environment } = await ctx.client.getAgent(org.id, agent.id);
6138
+ const live = await loadVersion(ctx, org.id, agent);
6139
+ emit(options, {
6140
+ agent,
6141
+ environment,
6142
+ policy: live
6143
+ }, () => {
6144
+ printFields([
6145
+ ["name", agent.name],
6146
+ ["slug", agent.slug],
6147
+ ["id", agent.id],
6148
+ ["environment", environment ? environment.slug : "any in the org"],
6149
+ ["enabled", agent.enabled ? "yes" : "no"],
6150
+ ["policy", live ? `v${live.version}` : "none published"],
6151
+ ["signer", live?.signerThumbprint],
6152
+ ["expires", live ? describeExpiry(live.expiresAt) : void 0],
6153
+ ["last fetch", agent.lastPolicyFetchAt ?? "never"]
6154
+ ]);
6155
+ if (live) {
6156
+ section(`rules (v${live.version})`);
6157
+ printRules(live.rules);
6158
+ section("hosts a forward proxy would intercept");
6159
+ console.log(policyHosts(live.rules).join("\n") || "none");
6160
+ }
6161
+ });
6162
+ });
6163
+ agents.command("disable <ref>").description("stop serving this identity's policy (the revocation path)").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").action(async (ref, options) => {
6164
+ const ctx = buildContext();
6165
+ const org = await resolveOrg(ctx, options.org);
6166
+ const agent = await resolveAgent(ctx, org.id, ref);
6167
+ await confirmDestructive(options.yes, `Disable ${agent.slug}? Running proxies keep their current bundle until it expires.`);
6168
+ await ctx.client.updateAgent(org.id, agent.id, { enabled: false });
6169
+ console.error(`${agent.slug} disabled — its next policy refresh is refused`);
6170
+ });
6171
+ agents.command("enable <ref>").description("serve this identity's policy again").option("--org <slug>", "organization").action(async (ref, options) => {
6172
+ const ctx = buildContext();
6173
+ const org = await resolveOrg(ctx, options.org);
6174
+ const agent = await resolveAgent(ctx, org.id, ref);
6175
+ await ctx.client.updateAgent(org.id, agent.id, { enabled: true });
6176
+ console.error(`${agent.slug} enabled`);
6177
+ });
6178
+ agents.command("rm <ref>").description("delete an identity and its published history").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").action(async (ref, options) => {
6179
+ const ctx = buildContext();
6180
+ const org = await resolveOrg(ctx, options.org);
6181
+ const agent = await resolveAgent(ctx, org.id, ref);
6182
+ await confirmDestructive(options.yes, `Delete ${agent.slug} and all ${agent.currentPolicyVersion} published version(s)? The audit_log rows survive.`);
6183
+ await ctx.client.deleteAgent(org.id, agent.id);
6184
+ console.error(`${agent.slug} deleted`);
6185
+ });
6186
+ /**
6187
+ * The trust anchor. This prints a *public* key fingerprint — the security of
6188
+ * pinning comes from the operator putting it in a local file the API cannot
6189
+ * reach, not from this command being honest. Which is why it prints the TOML
6190
+ * line to paste rather than offering to write the config.
6191
+ */
6192
+ agents.command("signer").description("your policy signing thumbprint, to pin in a proxy config").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (options) => {
6193
+ const ctx = buildContext();
6194
+ const org = await resolveOrg(ctx, options.org);
6195
+ const { signer } = await ctx.client.getMyPolicySigner(org.id);
6196
+ if (!signer) fail("your account has no keypair yet — run `seekrit keys setup`");
6197
+ emit(options, { signer }, () => {
6198
+ printFields([["thumbprint", signer.thumbprint], ["user", signer.userId]]);
6199
+ section("pin it in seekrit-proxy.toml");
6200
+ console.log("[policy]");
6201
+ console.log(`signers = ["${signer.thumbprint}"]`);
6202
+ console.error("");
6203
+ console.error("pin a second admin's key too — one pinned signer means one lost passphrase leaves nobody able to publish");
6204
+ });
6205
+ });
6206
+ /**
6207
+ * The token is printed to **stdout** and everything else to stderr, so the
6208
+ * common thing works without parsing:
6209
+ *
6210
+ * TASK=$(seekrit agents dispatch nova --scope GITHUB_TOKEN --ttl 15m)
6211
+ *
6212
+ * It is shown exactly once. The API stores only a hash, so a lost token cannot
6213
+ * be recovered — dispatch another and revoke the first.
6214
+ */
6215
+ agents.command("dispatch <ref>").description("mint task-scoped authority for one agent run").option("--scope <NAME>", "narrow this run to one secret (repeatable)", collectScope).option("--ttl <duration>", "how long the run stays authorized (e.g. 15m, 2h)").option("--label <text>", "what this run is for — recorded in the audit trail").option("--json", "machine-readable output (includes the token)").action(async (ref, options) => {
6216
+ const ctx = buildContext();
6217
+ const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : 900;
6218
+ if (ttlSeconds < 60 || ttlSeconds > 43200) fail(`--ttl must be between 60s and ${TASK_MAX_TTL_SECONDS / 3600}h`);
6219
+ const minted = await createAgentTaskToken();
6220
+ const { task, header } = await ctx.client.dispatchAgentTask(ref, {
6221
+ taskRef: minted.taskRef,
6222
+ tokenHash: minted.tokenHash,
6223
+ ttlSeconds,
6224
+ ...options.scope ? { scopes: options.scope } : {},
6225
+ ...options.label ? { label: options.label } : {}
6226
+ });
6227
+ if (options.json) {
6228
+ console.log(JSON.stringify({
6229
+ task,
6230
+ header,
6231
+ token: minted.token
6232
+ }, null, 2));
6233
+ return;
6234
+ }
6235
+ console.log(minted.token);
6236
+ printFieldsToStderr([
6237
+ ["task", task.id],
6238
+ ["agent", ref],
6239
+ ["scopes", task.scopes ? describeSecrets(task.scopes) : "the agent's full policy"],
6240
+ ["policy", `v${task.policyVersion}`],
6241
+ ["expires", describeExpiry(task.expiresAt)],
6242
+ ["header", header]
6243
+ ]);
6244
+ });
6245
+ agents.command("tasks <ref>").description("runs dispatched for an agent identity, newest first").option("--org <slug>", "organization").option("--all", "include expired and revoked runs").option("--json", "machine-readable output").action(async (ref, options) => {
6246
+ const ctx = buildContext();
6247
+ const org = await resolveOrg(ctx, options.org);
6248
+ const agent = await resolveAgent(ctx, org.id, ref);
6249
+ const { tasks } = await ctx.client.listAgentTasks(org.id, agent.id);
6250
+ const now = Date.now();
6251
+ const rows = options.all ? tasks : tasks.filter((t) => taskStateOf(t, now) === "active");
6252
+ emit(options, { tasks: rows }, () => printTable(rows, [
6253
+ col("task", (t) => t.id),
6254
+ col("state", (t) => taskStateOf(t, now)),
6255
+ col("scopes", (t) => t.scopes ? describeSecrets(t.scopes) : "policy"),
6256
+ col("policy", (t) => `v${t.policyVersion}`),
6257
+ col("expires", (t) => t.expiresAt),
6258
+ col("last seen", (t) => t.lastSeenAt ?? "never"),
6259
+ col("label", (t) => t.label ?? "-")
6260
+ ], options.all ? "no runs dispatched yet" : "no live runs (try --all)"));
6261
+ });
6262
+ agents.command("revoke <taskId>").description("end a run's authority now").action(async (taskId) => {
6263
+ const { task } = await buildContext().client.revokeAgentTask(taskId);
6264
+ console.error(`${task.id} revoked — enforcement points refuse it at their next introspection`);
6265
+ });
6266
+ registerPolicyCommands(agents.command("policy").description("published policy versions (`seekrit agents policy --help`)"));
6267
+ agents.command("activity <ref>").description("what this agent actually did — aggregate decisions, not a request log").option("--days <n>", "how far back to look (1–90)", "14").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
6268
+ const ctx = buildContext();
6269
+ const org = await resolveOrg(ctx, options.org);
6270
+ const agent = await resolveAgent(ctx, org.id, ref);
6271
+ const days = Number(options.days ?? 14);
6272
+ if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
6273
+ const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
6274
+ emit(options, {
6275
+ activity,
6276
+ summary
6277
+ }, () => {
6278
+ printFields([
6279
+ ["window", `${days}d`],
6280
+ ["allowed", summary.allowed],
6281
+ ["denied", summary.denied],
6282
+ ["hosts", summary.hosts],
6283
+ ["earliest", summary.from ?? "no activity reported"]
6284
+ ]);
6285
+ if (activity.length === 0) {
6286
+ console.error("");
6287
+ console.error("nothing reported — add an [activity] block to the proxy (`seekrit proxy init --activity`)");
6288
+ return;
6289
+ }
6290
+ section("decisions");
6291
+ printTable(activity, [
6292
+ col("host", (a) => a.host),
6293
+ col("method", (a) => a.method),
6294
+ col("decision", (a) => a.decision),
6295
+ col("rule", (a) => a.ruleIndex === null ? "-" : String(a.ruleIndex + 1)),
6296
+ col("count", (a) => a.count),
6297
+ col("secrets", (a) => a.secrets ? Object.entries(a.secrets).map(([name, n]) => `${name}×${n}`).join(" ") : "-")
6298
+ ]);
6299
+ });
6300
+ });
6301
+ /**
6302
+ * The grant review loop. Proposals are computed *here*, from activity the API
6303
+ * served — the API is deliberately not in the business of saying what a policy
6304
+ * should be. And `--out` writes a rule file rather than publishing: the change
6305
+ * still goes through `policy publish`, which still needs a human's key.
6306
+ */
6307
+ agents.command("review <ref>").description("compare published policy against observed activity, and propose changes").option("--days <n>", "how far back to look (1–90)", "14").option("-o, --out <path>", "write the narrowed rules to a file, ready to publish").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
6308
+ const ctx = buildContext();
6309
+ const org = await resolveOrg(ctx, options.org);
6310
+ const agent = await resolveAgent(ctx, org.id, ref);
6311
+ const days = Number(options.days ?? 14);
6312
+ if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
6313
+ const live = await loadVersion(ctx, org.id, agent);
6314
+ if (!live) fail(`${agent.slug} has no published policy to review`);
6315
+ const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
6316
+ const proposals = reviewPolicy({
6317
+ rules: live.rules,
6318
+ activity
6319
+ });
6320
+ emit(options, {
6321
+ proposals,
6322
+ summary,
6323
+ version: live.version
6324
+ }, () => {
6325
+ console.error(`${agent.slug} — policy v${live.version}, ${summary.allowed + summary.denied} decision(s) over ${days}d`);
6326
+ if (activity.length === 0) {
6327
+ console.error("");
6328
+ console.error("no activity reported yet — nothing to review against");
6329
+ return;
6330
+ }
6331
+ if (proposals.length === 0) {
6332
+ console.error("");
6333
+ console.error("no changes proposed: every rule is in use and nothing was refused");
6334
+ return;
6335
+ }
6336
+ section("proposals");
6337
+ for (const p of proposals) {
6338
+ const marker = p.applicable ? "-" : "?";
6339
+ console.log(`${marker} ${p.rationale}`);
6340
+ }
6341
+ const applicable = countApplicable(proposals);
6342
+ console.error("");
6343
+ console.error(`${applicable} narrowing change(s) can be applied; ${proposals.length - applicable} need a human decision`);
6344
+ });
6345
+ if (options.out) {
6346
+ const narrowed = applyProposals(live.rules, proposals);
6347
+ writeFileSync(options.out, `${JSON.stringify({ rules: narrowed }, null, 2)}\n`);
6348
+ console.error(`wrote ${narrowed.length} rule(s) to ${options.out} — review the diff, then: seekrit agents policy publish ${agent.slug} -f ${options.out}`);
6349
+ console.error("widening proposals are never applied: an agent must not earn permissions by retrying");
6350
+ }
6351
+ });
6352
+ agents.command("simulate <ref>").description("ask what a policy would decide, without sending a request").requiredOption("--host <host>", "upstream hostname").requiredOption("--path <path>", "request path").option("--method <method>", "HTTP method", "GET").option("--secret <NAME>", "the secret the request would inject").option("--version <n>", "evaluate a specific published version").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
6353
+ const ctx = buildContext();
6354
+ const org = await resolveOrg(ctx, options.org);
6355
+ const agent = await resolveAgent(ctx, org.id, ref);
6356
+ const version = options.version === void 0 ? void 0 : Number(options.version);
6357
+ if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
6358
+ const published = await loadVersion(ctx, org.id, agent, version);
6359
+ if (!published) fail(`${agent.slug} has no published policy — every request is denied`);
6360
+ const verdict = evaluatePolicy(published.rules, {
6361
+ host: options.host,
6362
+ method: options.method,
6363
+ path: options.path,
6364
+ ...options.secret === void 0 ? {} : { secret: options.secret }
6365
+ });
6366
+ const reason = describePolicyVerdict(verdict, published.rules);
6367
+ emit(options, {
6368
+ version: published.version,
6369
+ verdict,
6370
+ reason
6371
+ }, () => {
6372
+ const target = `${options.method.toUpperCase()} ${options.host}${options.path}`;
6373
+ console.log(`${verdict.decision === "allow" ? "allow" : "DENY"} ${target}${options.secret ? ` [${options.secret}]` : ""}`);
6374
+ console.log(` v${published.version}: ${reason}`);
6375
+ });
6376
+ if (verdict.decision !== "allow") process.exitCode = 1;
6377
+ });
6378
+ }
6379
+ function registerPolicyCommands(policy) {
6380
+ policy.command("list <ref>").description("published versions, newest first").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
6381
+ const ctx = buildContext();
6382
+ const org = await resolveOrg(ctx, options.org);
6383
+ const agent = await resolveAgent(ctx, org.id, ref);
6384
+ const { policies } = await ctx.client.listAgentPolicies(org.id, agent.id);
6385
+ emit(options, { policies }, () => printTable(policies, [
6386
+ col("version", (p) => `v${p.version}`),
6387
+ col("rules", (p) => p.ruleCount),
6388
+ col("signer", (p) => p.signerThumbprint.slice(0, 12)),
6389
+ col("published", (p) => p.publishedAt),
6390
+ col("expires", (p) => secondsUntil(p.expiresAt) <= 0 ? `${p.expiresAt} (expired)` : p.expiresAt),
6391
+ col("note", (p) => p.rolledBackFromVersion ? `restored v${p.rolledBackFromVersion}` : "-")
6392
+ ], "nothing published yet"));
6393
+ });
6394
+ policy.command("show <ref>").description("the rules in a published version (live by default)").option("--version <n>", "a specific version").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
6395
+ const ctx = buildContext();
6396
+ const org = await resolveOrg(ctx, options.org);
6397
+ const agent = await resolveAgent(ctx, org.id, ref);
6398
+ const version = options.version === void 0 ? void 0 : Number(options.version);
6399
+ if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
6400
+ const published = await loadVersion(ctx, org.id, agent, version);
6401
+ if (!published) fail(`${agent.slug} has no published policy`);
6402
+ emit(options, { policy: published }, () => {
6403
+ printFields([
6404
+ ["version", `v${published.version}`],
6405
+ ["signer", published.signerThumbprint],
6406
+ ["published", published.publishedAt],
6407
+ ["expires", describeExpiry(published.expiresAt)]
6408
+ ]);
6409
+ section("rules");
6410
+ printRules(published.rules);
6411
+ });
6412
+ });
6413
+ /**
6414
+ * Write the live rules out as the editable source for the next publish. This
6415
+ * is the half that makes policy-as-code work: what comes back out is exactly
6416
+ * the shape `publish` takes in, so a round trip is a no-op diff.
6417
+ */
6418
+ policy.command("pull <ref>").description("write a version's rules to a JSON file you can edit and publish").option("-o, --out <path>", "where to write it (default: stdout)").option("--version <n>", "a specific version").option("--org <slug>", "organization").action(async (ref, options) => {
6419
+ const ctx = buildContext();
6420
+ const org = await resolveOrg(ctx, options.org);
6421
+ const agent = await resolveAgent(ctx, org.id, ref);
6422
+ const version = options.version === void 0 ? void 0 : Number(options.version);
6423
+ if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
6424
+ const published = await loadVersion(ctx, org.id, agent, version);
6425
+ const rules = published?.rules ?? [];
6426
+ const body = `${JSON.stringify({ rules }, null, 2)}\n`;
6427
+ if (options.out) {
6428
+ writeFileSync(options.out, body);
6429
+ console.error(published ? `wrote ${rules.length} rule(s) from v${published.version} to ${options.out}` : `wrote an empty rule set to ${options.out} (nothing published yet)`);
6430
+ return;
6431
+ }
6432
+ process.stdout.write(body);
6433
+ });
6434
+ policy.command("publish <ref>").description("sign a rule file with your own key and publish it").requiredOption("-f, --file <path>", `rule file, or ${STDIN} for stdin`).option("--ttl <duration>", "how long the bundle stays valid (e.g. 7d, 12h)").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").option("--json", "machine-readable output").action(async (ref, options) => {
6435
+ const ctx = buildContext();
6436
+ if (isTokenAuth(ctx)) fail("publishing agent policy needs a human's signing key — sign in with `seekrit login`. A service token cannot publish, so an agent cannot widen its own policy.");
6437
+ const org = await resolveOrg(ctx, options.org);
6438
+ const agent = await resolveAgent(ctx, org.id, ref);
6439
+ const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : POLICY_DEFAULT_TTL_SECONDS;
6440
+ if (ttlSeconds < 3600 || ttlSeconds > 7776e3) fail(`--ttl must be between ${POLICY_MIN_TTL_SECONDS / 3600}h and ${POLICY_MAX_TTL_SECONDS / 86400}d`);
6441
+ const { raw, label } = await readRuleSource(options.file);
6442
+ const rules = parseRuleFile(raw, label);
6443
+ const live = await loadVersion(ctx, org.id, agent);
6444
+ const nextVersion = agent.currentPolicyVersion + 1;
6445
+ const from = agent.currentPolicyVersion === 0 ? "nothing" : `v${agent.currentPolicyVersion}`;
6446
+ console.error(`${agent.slug}: ${from} → v${nextVersion}`);
6447
+ printPolicyDiff(live?.rules ?? [], rules);
6448
+ await confirmDestructive(options.yes, `Publish v${nextVersion} for ${agent.slug} (${rules.length} rule(s), valid ${options.ttl ?? "7d"})?`);
6449
+ const { signingKey, jwk } = await getPolicySigner(ctx);
6450
+ const issuedAt = Math.floor(Date.now() / 1e3);
6451
+ const bundle = await signAgentPolicy(signingKey, jwk, {
6452
+ v: 1,
6453
+ org: org.id,
6454
+ agent: agent.id,
6455
+ agent_slug: agent.slug,
6456
+ policy_version: nextVersion,
6457
+ issued_at: issuedAt,
6458
+ expires_at: issuedAt + ttlSeconds,
6459
+ rules
6460
+ });
6461
+ const { policy: published } = await ctx.client.publishAgentPolicy(org.id, agent.id, bundle);
6462
+ emit(options, { policy: published }, () => {
6463
+ console.error(`published v${published.version} — signed with ${published.signerThumbprint.slice(0, 12)}…, expires ${published.expiresAt}`);
6464
+ console.error("proxies pick it up at their next refresh");
6465
+ });
6466
+ });
6467
+ policy.command("rollback <ref> <version>").description("republish an earlier version's bundle as the newest one").option("--org <slug>", "organization").option("-y, --yes", "skip the confirmation").action(async (ref, versionArg, options) => {
6468
+ const ctx = buildContext();
6469
+ const org = await resolveOrg(ctx, options.org);
6470
+ const agent = await resolveAgent(ctx, org.id, ref);
6471
+ const version = Number(versionArg);
6472
+ if (!Number.isInteger(version) || version < 1) fail("version must be a positive integer");
6473
+ const source = await loadVersion(ctx, org.id, agent, version);
6474
+ if (!source) fail(`no version ${version}`);
6475
+ if (secondsUntil(source.expiresAt) <= 0) fail(`v${version} expired at ${source.expiresAt} — a rollback keeps the original expiry, so it would be inert. Publish the rules again instead: seekrit agents policy pull ${agent.slug} --version ${version} -o rules.json`);
6476
+ await confirmDestructive(options.yes, `Roll ${agent.slug} back to v${version} (${source.ruleCount} rule(s))?`);
6477
+ const { policy: published } = await ctx.client.rollbackAgentPolicy(org.id, agent.id, version);
6478
+ console.error(`published v${published.version} carrying v${version}'s bundle`);
6479
+ });
6480
+ /**
6481
+ * What a proxy actually gets. Useful when a deployment misbehaves and the
6482
+ * question is whether the API, the pin, or the rules are at fault — so it
6483
+ * re-derives the thumbprint from the bundle rather than reporting the one the
6484
+ * API sends beside it, and says plainly when the two disagree.
6485
+ */
6486
+ policy.command("fetch <ref>").description("the bundle a proxy would fetch, with its signer re-derived locally").option("--bundle", "print the raw ap1. envelope and nothing else").option("--json", "machine-readable output").action(async (ref, options) => {
6487
+ const fetched = await buildContext().client.getAgentPolicyBundle(ref);
6488
+ if (options.bundle) {
6489
+ process.stdout.write(`${fetched.bundle}\n`);
6490
+ return;
6491
+ }
6492
+ const body = parseAgentPolicyUnverified(fetched.bundle);
6493
+ const derived = await policySignerThumbprint(body.signer.jwk);
6494
+ emit(options, {
6495
+ ...fetched,
6496
+ derivedThumbprint: derived,
6497
+ rules: body.rules
6498
+ }, () => {
6499
+ printFields([
6500
+ ["agent", `${fetched.agent.slug} (${fetched.agent.name})`],
6501
+ ["version", `v${fetched.version}`],
6502
+ ["expires", describeExpiry(fetched.expiresAt)],
6503
+ ["signer (in bundle)", derived],
6504
+ ["signer (reported)", fetched.signerThumbprint === derived ? "matches" : `${fetched.signerThumbprint} — DOES NOT MATCH the bundle; a proxy would refuse this`]
6505
+ ]);
6506
+ section("rules");
6507
+ printRules(body.rules);
6508
+ console.error("");
6509
+ console.error("this command does not verify the signature — only a pinned signer list can, which lives in the proxy's own config");
6510
+ });
6511
+ });
6512
+ }
6513
+ //#endregion
5216
6514
  //#region src/kms.ts
5217
6515
  /** Collect a repeatable option into a list. */
5218
6516
  function collect$6(value, acc = []) {
@@ -7533,7 +8831,7 @@ function collect$2(value, acc) {
7533
8831
  * release-please-config.json), so the pin follows the crate without anyone
7534
8832
  * remembering to move it.
7535
8833
  */
7536
- const PROXY_VERSION = "0.8.0";
8834
+ const PROXY_VERSION = "0.10.0";
7537
8835
  const BIN = "seekrit-proxy";
7538
8836
  /**
7539
8837
  * Host → Rust target triple.
@@ -7569,7 +8867,7 @@ function versionPrefix(version) {
7569
8867
  return version.startsWith("v") ? version : `v${version}`;
7570
8868
  }
7571
8869
  function resolveVersion(explicit) {
7572
- return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.8.0";
8870
+ return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
7573
8871
  }
7574
8872
  function resolveBaseUrl(explicit) {
7575
8873
  return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
@@ -7999,6 +9297,27 @@ function renderProxyConfig(plan) {
7999
9297
  out.push(`max_ttl = ${tomlString(plan.control.maxTtl)}`);
8000
9298
  out.push("");
8001
9299
  }
9300
+ if (plan.tasks) {
9301
+ out.push("# Honour tasks dispatched through the API (`seekrit agents dispatch`): a run");
9302
+ out.push("# presents its skd_… token in the same header a local ticket uses, and this");
9303
+ out.push("# proxy asks the API what it authorizes. Opt-in, because it makes authorizing");
9304
+ out.push("# a new run depend on reaching seekrit — a refused or unreachable check denies");
9305
+ out.push("# the request rather than admitting it.");
9306
+ out.push("#");
9307
+ out.push("# cache_ttl bounds how long a revoked run keeps working. Short on purpose.");
9308
+ out.push("[tasks]");
9309
+ out.push(`cache_ttl = ${tomlString(plan.tasks.cacheTtl)}`);
9310
+ out.push("");
9311
+ }
9312
+ if (plan.activity) {
9313
+ out.push("# Report aggregate decisions back, so `seekrit agents review` can compare this");
9314
+ out.push("# policy against what the agent actually does. Counts only: hosts, methods,");
9315
+ out.push("# secret *names*, and which rule decided — never a request path, never a value.");
9316
+ out.push("# Full per-request detail stays in your own OTLP collector.");
9317
+ out.push("[activity]");
9318
+ out.push(`flush_interval = ${tomlString(plan.activity.flushInterval)}`);
9319
+ out.push("");
9320
+ }
8002
9321
  if (plan.envHints.length > 0) {
8003
9322
  out.push("# ---------------------------------------------------------------------------");
8004
9323
  out.push("# Point the workload at the proxy (these go in its environment, not here):");
@@ -8077,6 +9396,8 @@ function planFromPresets(presets, options) {
8077
9396
  ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
8078
9397
  ...options.secretsRefresh ? { secretsRefresh: options.secretsRefresh } : {},
8079
9398
  ...options.control ? { control: options.control } : {},
9399
+ ...options.tasks ? { tasks: options.tasks } : {},
9400
+ ...options.activity ? { activity: options.activity } : {},
8080
9401
  envHints,
8081
9402
  notes
8082
9403
  };
@@ -8142,6 +9463,8 @@ function planFromPolicy(args, options) {
8142
9463
  caKey: options.caKey,
8143
9464
  ...options.cacheMaxAge ? { cache: { maxAge: options.cacheMaxAge } } : {},
8144
9465
  ...options.control ? { control: options.control } : {},
9466
+ ...options.tasks ? { tasks: options.tasks } : {},
9467
+ ...options.activity ? { activity: options.activity } : {},
8145
9468
  envHints,
8146
9469
  notes
8147
9470
  };
@@ -8300,7 +9623,9 @@ function planOptions(options) {
8300
9623
  listen: options.control,
8301
9624
  ttl: "1h",
8302
9625
  maxTtl: "12h"
8303
- } } : {}
9626
+ } } : {},
9627
+ ...options.tasks || options.tasksCacheTtl ? { tasks: { cacheTtl: duration(options.tasksCacheTtl, "--tasks-cache-ttl") ?? "30s" } } : {},
9628
+ ...options.activity || options.activityInterval ? { activity: { flushInterval: duration(options.activityInterval, "--activity-interval") ?? "60s" } } : {}
8304
9629
  };
8305
9630
  }
8306
9631
  /**
@@ -8419,7 +9744,7 @@ async function buildPlan(options) {
8419
9744
  }
8420
9745
  /** Add the generation flags to a command, so `init` and `run` stay in step. */
8421
9746
  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");
9747
+ return cmd.option("--preset <name>", "gateway preset (repeatable; see `seekrit proxy presets`)", (value, acc = []) => [...acc, value]).option("--host <host[=SECRET,…]>", "ad-hoc rule: bare hostname, optionally the secrets it may receive (repeatable)", (value, acc = []) => [...acc, value]).option("--base-url <url>", "upstream base URL for an OpenAI-compatible gateway").option("--secret <NAME>", "override a preset's secret name").option("--prefix <path>", "override a preset's route prefix").option("--agent <slug>", "take the rules from published agent policy (server mode)").option("--agents <slug>", "additional identities this proxy may serve", (v, a = []) => [...a, v]).option("--org <slug>", "organization (for --agent)").option("--mode <reverse|forward|both>", "which data plane(s) to configure", "reverse").option("--listen <addr>", `reverse-proxy address (default: ${PLAN_DEFAULTS.listen})`).option("--forward-listen <addr>", `forward-proxy address (default: ${PLAN_DEFAULTS.forwardListen})`).option("--unmatched <tunnel|deny>", "what to do with an unruled host in forward mode").option("--ca-cert <path>", "interception CA certificate path (forward mode)").option("--ca-key <path>", "interception CA key path (forward mode)").option("--cache", "add a [cache] block so the proxy can start during an outage").option("--cache-max-age <dur>", "how stale a cached resolve may be (implies --cache)").option("--refresh <dur>", "re-resolve/re-fetch interval").option("--control <addr>", "add a [control] listener for per-agent session tickets").option("--tasks", "add a [tasks] block so this proxy honours runs dispatched with `seekrit agents dispatch`").option("--tasks-cache-ttl <dur>", "how long an introspected task is reused (implies --tasks)").option("--activity", "add an [activity] block so this proxy reports aggregate decisions for `seekrit agents review`").option("--activity-interval <dur>", "how often counts are flushed (implies --activity)");
8423
9748
  }
8424
9749
  function registerProxyCommands(program) {
8425
9750
  const proxy = program.command("proxy").description("run and configure the agent egress proxy (`seekrit proxy --help`)");
@@ -8486,10 +9811,10 @@ Next:
8486
9811
  });
8487
9812
  console.log(path);
8488
9813
  const { target } = detectTarget();
8489
- process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.8.0")} (${target})\n`);
9814
+ process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.10.0")} (${target})\n`);
8490
9815
  });
8491
9816
  proxy.command("where").description("show which binary `seekrit proxy run` would use, without fetching it").option("--proxy-version <version>", `version to report (default: ${PROXY_VERSION})`).option("--json", "machine-readable output").action((options) => {
8492
- const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.8.0";
9817
+ const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.10.0";
8493
9818
  const override = process.env.SEEKRIT_PROXY_BIN;
8494
9819
  const { target, exe } = detectTarget();
8495
9820
  const path = override ?? proxyBinaryPath(version, target, exe);
@@ -8510,7 +9835,7 @@ Next:
8510
9835
  process.stdout.write(renderComposeSnippet(plan, {
8511
9836
  service: options.service,
8512
9837
  workload: options.workload,
8513
- image: options.image ?? `seekritdev/proxy:0.8.0`,
9838
+ image: options.image ?? `seekritdev/proxy:0.10.0`,
8514
9839
  publish: Boolean(options.publish)
8515
9840
  }));
8516
9841
  });
@@ -8668,17 +9993,6 @@ function collect$1(value, acc) {
8668
9993
  * Rotated values are never printed here. Read them like any other secret
8669
9994
  * (`seekrit secrets get NAME`), which decrypts locally.
8670
9995
  */
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
9996
  function formatInterval(seconds) {
8683
9997
  if (seconds % 86400 === 0) return `${seconds / 86400}d`;
8684
9998
  if (seconds % 3600 === 0) return `${seconds / 3600}h`;
@@ -10512,6 +11826,7 @@ registerMysqlCommands(program);
10512
11826
  registerRedisCommands(program);
10513
11827
  registerProvisionerCommands(program);
10514
11828
  registerProxyCommands(program);
11829
+ registerAgentCommands(program);
10515
11830
  registerSshCommands(program);
10516
11831
  registerAwsCommands(program);
10517
11832
  registerGcpCommands(program);