@seekrit/cli 2.4.0 → 2.5.1

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 +1873 -982
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7,9 +7,9 @@ import { basename, dirname, join, parse, resolve } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { Writable } from "node:stream";
9
9
  import { createHash, randomBytes } from "node:crypto";
10
+ import { spawn, spawnSync } from "node:child_process";
10
11
  import { connect } from "node:net";
11
12
  import { fileURLToPath } from "node:url";
12
- import { spawn, spawnSync } from "node:child_process";
13
13
  import { request } from "node:http";
14
14
  /** Default lifetime of a published bundle (7 days), in seconds. */
15
15
  const POLICY_DEFAULT_TTL_SECONDS = 604800;
@@ -441,6 +441,100 @@ z.object({
441
441
  */
442
442
  entries: z.array(activityEntrySchema).min(1).max(500)
443
443
  });
444
+ // ../../packages/core/src/agent-login.ts
445
+ /** Where each kind of login is stored unless told otherwise. */
446
+ const AGENT_LOGIN_SECRETS = {
447
+ /** Codex's `auth.json`, as an envelope. */
448
+ codex: "CODEX_LOGIN",
449
+ /** Claude Code's full `.credentials.json`, as an envelope. */
450
+ claude: "CLAUDE_LOGIN",
451
+ /** A `claude setup-token` token — a plain secret, the variable Claude Code reads. */
452
+ claudeToken: "CLAUDE_CODE_OAUTH_TOKEN"
453
+ };
454
+ const AGENT_LOGIN_KIND = "seekrit.agent-login/v1";
455
+ /** The serialized prefix of every version of the envelope (`kind` is always first). */
456
+ const ENVELOPE_PREFIX = "{\"kind\":\"seekrit.agent-login/";
457
+ function encodeAgentLoginEnvelope(envelope) {
458
+ const { agent, content, savedAt, claim } = envelope;
459
+ return JSON.stringify({
460
+ kind: AGENT_LOGIN_KIND,
461
+ agent,
462
+ content,
463
+ savedAt,
464
+ claim
465
+ });
466
+ }
467
+ /**
468
+ * Whether a decrypted secret value is a managed agent login. Such a value is a
469
+ * *file*, never an environment variable: `seekrit run` keeps it out of the
470
+ * child's environment.
471
+ */
472
+ function isAgentLoginValue(value) {
473
+ return value.startsWith(ENVELOPE_PREFIX);
474
+ }
475
+ function decodeAgentLoginEnvelope(value) {
476
+ if (!isAgentLoginValue(value)) return null;
477
+ const parsed = parseObject$1(value);
478
+ if (!parsed || parsed.kind !== "seekrit.agent-login/v1") return null;
479
+ if (!(parsed.agent === "codex" || parsed.agent === "claude")) return null;
480
+ if (typeof parsed.content !== "string") return null;
481
+ const claim = parsed.claim;
482
+ return {
483
+ kind: AGENT_LOGIN_KIND,
484
+ agent: parsed.agent,
485
+ content: parsed.content,
486
+ savedAt: typeof parsed.savedAt === "string" ? parsed.savedAt : "",
487
+ claim: claim && typeof claim.holder === "string" && typeof claim.until === "string" ? claim : null
488
+ };
489
+ }
490
+ function parseObject$1(content) {
491
+ try {
492
+ const parsed = JSON.parse(content);
493
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
494
+ } catch {
495
+ return null;
496
+ }
497
+ }
498
+ function nonEmpty$1(value) {
499
+ return typeof value === "string" && value.length > 0;
500
+ }
501
+ /** Why `content` is not a usable Codex `auth.json`, or null when it is. */
502
+ function codexLoginProblem(content) {
503
+ const auth = parseObject$1(content);
504
+ if (!auth) return "not a JSON object";
505
+ const tokens = auth.tokens;
506
+ if (tokens && nonEmpty$1(tokens.access_token) && nonEmpty$1(tokens.refresh_token)) return null;
507
+ if (nonEmpty$1(auth.OPENAI_API_KEY)) return null;
508
+ return "no ChatGPT tokens and no API key — is this a Codex auth.json?";
509
+ }
510
+ /** Why `content` is not a usable Claude Code `.credentials.json`, or null when it is. */
511
+ function claudeLoginProblem(content) {
512
+ const creds = parseObject$1(content);
513
+ if (!creds) return "not a JSON object";
514
+ const oauth = creds.claudeAiOauth;
515
+ if (!oauth || !nonEmpty$1(oauth.accessToken)) return "no claudeAiOauth.accessToken — is this a Claude Code .credentials.json?";
516
+ return null;
517
+ }
518
+ /** A Claude Code long-lived token (`claude setup-token`) looks like `sk-ant-oat…`. */
519
+ function claudeTokenProblem(token) {
520
+ if (!token) return "no token given";
521
+ if (/\s/.test(token)) return "the token contains whitespace — paste just the token";
522
+ if (!token.startsWith("sk-ant-")) return "that does not look like a Claude token (expected sk-ant-…)";
523
+ return null;
524
+ }
525
+ /** A JWT's payload, without verifying it — for scheduling and display only. */
526
+ function jwtClaims(token) {
527
+ const payload = token.split(".")[1];
528
+ if (!payload) return null;
529
+ try {
530
+ const b64 = payload.replaceAll("-", "+").replaceAll("_", "/");
531
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
532
+ const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
533
+ return parseObject$1(new TextDecoder().decode(bytes));
534
+ } catch {
535
+ return null;
536
+ }
537
+ }
444
538
  // ../../packages/core/src/agent-review.ts
445
539
  function emptyStats() {
446
540
  return {
@@ -2459,9 +2553,18 @@ z.object({
2459
2553
  */
2460
2554
  recoveryWrappedDek: z.string().min(1).nullish()
2461
2555
  });
2462
- z.object({
2463
- /** Opaque versioned ciphertext blob from @seekrit/crypto. */
2464
- ciphertext: z.string().min(1).max(65536) });
2556
+ z.object({
2557
+ /** Opaque versioned ciphertext blob from @seekrit/crypto. */
2558
+ ciphertext: z.string().min(1).max(65536),
2559
+ /**
2560
+ * Compare-and-swap: write only if the secret is currently at this version
2561
+ * (`0` = only if it does not exist yet), else 412 `precondition_failed`.
2562
+ * Omit for an unconditional write. This is what lets a writer that read,
2563
+ * changed, and wrote back — a refreshed agent login, say — find out that
2564
+ * someone else wrote in between instead of silently erasing their value.
2565
+ */
2566
+ expectedVersion: z.number().int().min(0).optional()
2567
+ });
2465
2568
  z.object({ version: z.number().int().positive() });
2466
2569
  z.object({ limit: z.coerce.number().int().min(1).max(200).default(50) });
2467
2570
  z.object({
@@ -5784,7 +5887,7 @@ async function createAgentTaskToken() {
5784
5887
  };
5785
5888
  }
5786
5889
  // package.json
5787
- var version = "2.4.0";
5890
+ var version = "2.5.1";
5788
5891
  // ../../packages/api-client/src/index.ts
5789
5892
  /**
5790
5893
  * The proposer's note travels as a header, not a body field: an unprotected
@@ -6105,9 +6208,17 @@ var SeekritClient = class {
6105
6208
  *
6106
6209
  * `note` is the proposer's explanation, carried as a header so it is simply
6107
6210
  * ignored by an unprotected environment.
6211
+ *
6212
+ * `expectedVersion` makes the write a compare-and-swap: it lands only if the
6213
+ * secret is still at that version (`0` = does not exist yet), and otherwise
6214
+ * fails with a 412 `precondition_failed` {@link SeekritApiError}.
6108
6215
  */
6109
- setSecret(orgId, envId, name, ciphertext, note) {
6110
- return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, { ciphertext }, changeNoteHeader(note));
6216
+ setSecret(orgId, envId, name, ciphertext, note, options = {}) {
6217
+ const body = options.expectedVersion === void 0 ? { ciphertext } : {
6218
+ ciphertext,
6219
+ expectedVersion: options.expectedVersion
6220
+ };
6221
+ return this.request("PUT", `/v1/orgs/${orgId}/envs/${envId}/secrets/${name}`, body, changeNoteHeader(note));
6111
6222
  }
6112
6223
  /** Queued for review in a protected environment — see {@link setSecret}. */
6113
6224
  deleteSecret(orgId, envId, name, note) {
@@ -7418,852 +7529,802 @@ function registerAccountCommands(program) {
7418
7529
  console.error(`${type} emails ${state}`);
7419
7530
  });
7420
7531
  }
7421
- // src/agents.ts
7422
- /**
7423
- * Agent access governance from the CLI.
7424
- *
7425
- * Policy lived only in the dashboard until now, which was backwards: the people
7426
- * deploying agents work in a terminal and a repo, and a rule set is exactly the
7427
- * kind of thing that wants review and version control. So the loop this module
7428
- * exists for is:
7429
- *
7430
- * seekrit agents policy pull nova -o nova.policy.json # current rules
7431
- * $EDITOR nova.policy.json # commit it, review it
7432
- * seekrit agents policy publish nova -f nova.policy.json
7433
- *
7434
- * **Signing still happens here, on this machine, with the operator's own key**
7435
- * (`getPolicySigner` in `context.ts`), so nothing about the trust argument in
7436
- * `docs/agent-access-governance.md` §1 changes: the API receives an opaque
7437
- * envelope it cannot forge, and a proxy verifies it against thumbprints pinned in
7438
- * its own local file. A service token is refused — an agent that can publish its
7439
- * own policy is not governed by it.
7440
- *
7441
- * Everything else here is read-only, and two commands are deliberately *local*
7442
- * evaluations rather than API calls:
7443
- *
7444
- * - `simulate` runs `evaluatePolicy` from `@seekrit/core`, the mirror of
7445
- * `RuleSet::decide` in the proxy — so a dry run and a real refusal give the
7446
- * same verdict in the same words.
7447
- * - `fetch` asks for the bytes a proxy would get (`GET /v1/agents/:ref/policy`)
7448
- * and re-derives the signer thumbprint from the bundle itself, rather than
7449
- * trusting the field the API echoes beside it.
7450
- */
7451
- /** How many published versions the API returns per page. */
7452
- const POLICY_PAGE = 50;
7453
- /** The `-` sentinel every other seekrit command uses to mean stdin. */
7454
- const STDIN = "-";
7455
- /**
7456
- * Resolve `nova`, or `agt_…`, to an identity.
7457
- *
7458
- * The org-scoped API routes take an id, but nobody types ids — so a slug is
7459
- * resolved from the list. The list is small (agents are a per-deployment thing,
7460
- * not a per-request one), which is why this costs one request rather than
7461
- * needing a lookup route.
7462
- */
7463
- async function resolveAgent(ctx, orgId, ref) {
7464
- const { agents } = await ctx.client.listAgents(orgId);
7465
- const found = agents.find((a) => a.slug === ref || a.id === ref);
7466
- if (!found) {
7467
- const known = agents.map((a) => a.slug).join(", ");
7468
- fail(`no agent "${ref}"${known ? ` — this org has: ${known}` : " in this org"}`);
7532
+ // src/agent-login.ts
7533
+ /**
7534
+ * Managed agent logins: keep a coding agent's *own* sign-in — Codex's
7535
+ * `auth.json`, Claude Code's `.credentials.json` — in seekrit, so any sandbox
7536
+ * started through `seekrit run --agent-login <agent>` is already signed in, and
7537
+ * stays signed in from one sandbox to the next.
7538
+ *
7539
+ * Storing the file is the easy half. The hard half is that both files carry an
7540
+ * OAuth **refresh token that rotates**: every refresh consumes it and issues a
7541
+ * new one. Copy the file into an ephemeral sandbox and the first refresh there
7542
+ * burns the token seekrit is holding, so the *next* sandbox's refresh is
7543
+ * rejected as a reuse and the login is gone. Two sandboxes refreshing at once
7544
+ * end the same way. So this module does three things around the agent:
7545
+ *
7546
+ * - **Write-back.** The local file is watched while the agent runs, and a
7547
+ * refresh is encrypted and stored the moment it lands — compare-and-swap on
7548
+ * the secret's version, so two sandboxes can never silently overwrite each
7549
+ * other's rotation.
7550
+ * - **Pick-up.** A sandbox that is already running polls for a newer copy near
7551
+ * its token's refresh time and writes it to disk. Both agents re-read their
7552
+ * file before refreshing (Codex does a guarded reload and only refreshes if
7553
+ * the file is unchanged; Claude Code invalidates its cache on a new mtime),
7554
+ * so a running agent adopts a sibling's refresh instead of reusing a dead
7555
+ * token.
7556
+ * - **One refresher per launch storm.** A sandbox that starts when the token is
7557
+ * due to refresh takes a short *claim* first, so twenty sandboxes spun up at
7558
+ * once produce one refresh and nineteen waits, not twenty races.
7559
+ *
7560
+ * Nothing here calls a provider's token endpoint: the agent CLI does its own
7561
+ * refresh, exactly as it would on a laptop, and seekrit only moves the file.
7562
+ * The claim lives *inside* the encrypted value, so the API coordinates without
7563
+ * seeing anything but ciphertext and version numbers.
7564
+ *
7565
+ * The residual risk, said plainly: two sandboxes that are both *already
7566
+ * running* when the token comes due can still refresh in the same few seconds,
7567
+ * before either sees the other's copy. A long-lived token (`claude
7568
+ * setup-token`) or an API key has no refresh to race, and is the right choice
7569
+ * for a large parallel fleet.
7570
+ */
7571
+ /**
7572
+ * How long before expiry an agent refreshes on its own. Codex refreshes when
7573
+ * the access token is within five minutes of expiring
7574
+ * (`CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES`); Claude Code uses the same
7575
+ * margin. A login is *due* from that point on.
7576
+ */
7577
+ const REFRESH_LEAD_MS = 3e5;
7578
+ /** Codex's fallback when an access token carries no `exp`: refresh every eight days. */
7579
+ const CODEX_REFRESH_INTERVAL_MS = 6912e5;
7580
+ function parseObject(content) {
7581
+ try {
7582
+ const parsed = JSON.parse(content);
7583
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
7584
+ } catch {
7585
+ return null;
7469
7586
  }
7470
- return found;
7471
7587
  }
7472
- /**
7473
- * One published version: the newest by default, or an explicit `--version`.
7474
- *
7475
- * An agent with no published policy is a real state, not an error — a proxy for
7476
- * it fails closed — so the caller decides how to report it.
7477
- */
7478
- async function loadVersion(ctx, orgId, agent, version) {
7479
- const { policies } = await ctx.client.listAgentPolicies(orgId, agent.id);
7480
- if (version === void 0) return policies[0] ?? null;
7481
- const found = policies.find((p) => p.version === version);
7482
- 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"}`);
7483
- return found;
7588
+ function nonEmpty(value) {
7589
+ return typeof value === "string" && value.length > 0;
7484
7590
  }
7485
- /**
7486
- * Read a rule file: either a bare array or `{ "rules": [...] }`, from a path or
7487
- * stdin. Both shapes are accepted because `policy pull` writes the second and
7488
- * hand-written files tend to be the first.
7489
- *
7490
- * Validation is the same zod schema the API and the dashboard use, so a file
7491
- * rejected here would have been rejected there — before anything is signed.
7492
- *
7493
- * Exported for its own test: this is the one place a hand-written file meets the
7494
- * schema, and its error messages are the whole user experience of a typo.
7495
- */
7496
- function parseRuleFile(raw, source) {
7497
- let parsed;
7591
+ /** The `exp` claim of a JWT, in ms — without verifying it (we only schedule by it). */
7592
+ function jwtExpiryMs(token) {
7593
+ const exp = jwtClaims(token)?.exp;
7594
+ return typeof exp === "number" ? exp * 1e3 : null;
7595
+ }
7596
+ const codexAdapter = {
7597
+ id: "codex",
7598
+ label: "Codex",
7599
+ defaultSecret: AGENT_LOGIN_SECRETS.codex,
7600
+ loginPath(env) {
7601
+ const home = env.CODEX_HOME?.trim() || join(homedir(), ".codex");
7602
+ return join(home, "auth.json");
7603
+ },
7604
+ problem: codexLoginProblem,
7605
+ expiresAt(content) {
7606
+ const auth = parseObject(content);
7607
+ const tokens = auth?.tokens;
7608
+ if (!tokens || !nonEmpty(tokens.access_token)) return null;
7609
+ const exp = jwtExpiryMs(tokens.access_token);
7610
+ if (exp !== null) return exp;
7611
+ const last = nonEmpty(auth?.last_refresh) ? Date.parse(auth.last_refresh) : NaN;
7612
+ return Number.isNaN(last) ? null : last + CODEX_REFRESH_INTERVAL_MS;
7613
+ }
7614
+ };
7615
+ const claudeAdapter = {
7616
+ id: "claude",
7617
+ label: "Claude Code",
7618
+ defaultSecret: AGENT_LOGIN_SECRETS.claude,
7619
+ loginPath(env) {
7620
+ const home = env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude");
7621
+ return join(home, ".credentials.json");
7622
+ },
7623
+ problem: claudeLoginProblem,
7624
+ expiresAt(content) {
7625
+ const oauth = parseObject(content)?.claudeAiOauth;
7626
+ if (!oauth || !nonEmpty(oauth.refreshToken)) return null;
7627
+ return typeof oauth.expiresAt === "number" ? oauth.expiresAt : null;
7628
+ }
7629
+ };
7630
+ const ADAPTERS = {
7631
+ codex: codexAdapter,
7632
+ claude: claudeAdapter
7633
+ };
7634
+ function adapterFor(name) {
7635
+ const adapter = ADAPTERS[name];
7636
+ if (!adapter) throw new Error(`unknown agent "${name}" — expected one of: ${Object.keys(ADAPTERS).join(", ")}`);
7637
+ return adapter;
7638
+ }
7639
+ /** The file beside the login that marks it as ours, and who is using it. */
7640
+ const MARKER = ".seekrit-agent-login.json";
7641
+ const DEFAULTS = {
7642
+ claimTtlMs: 9e4,
7643
+ maxWaitMs: 12e4,
7644
+ localPollMs: 1e3,
7645
+ idleRemotePollMs: 9e5,
7646
+ hotRemotePollMs: 5e3,
7647
+ waitPollMs: 2e3
7648
+ };
7649
+ /** The window around a token's refresh time when siblings poll each other hard. */
7650
+ const HOT_BEFORE_MS = 12e4;
7651
+ const HOT_AFTER_MS = 3e5;
7652
+ function defaultIsAlive(pid) {
7498
7653
  try {
7499
- parsed = JSON.parse(raw);
7654
+ process.kill(pid, 0);
7655
+ return true;
7500
7656
  } catch (err) {
7501
- fail(`${source} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
7657
+ return err.code === "EPERM";
7502
7658
  }
7503
- 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;
7504
- if (!Array.isArray(list)) fail(`${source}: "rules" must be an array`);
7505
- if (list.length === 0) console.error(`note: ${source} has no rules — publishing it denies every request`);
7506
- return list.map((rule, i) => {
7507
- const result = agentPolicyRuleSchema.safeParse(rule);
7508
- if (!result.success) {
7509
- const first = result.error.issues[0];
7510
- fail(`${source}: rule ${i + 1} is invalid — ${first?.path.join(".") || "rule"}: ${first?.message ?? "unknown error"}`);
7511
- }
7512
- return result.data;
7659
+ }
7660
+ /** Write a file so a reader sees the old bytes or the new ones, never half of each. */
7661
+ function writeAtomic(path, content) {
7662
+ mkdirSync(dirname(path), {
7663
+ recursive: true,
7664
+ mode: 448
7513
7665
  });
7666
+ const tmp = `${path}.seekrit-${process.pid}-${randomBytes(4).toString("hex")}`;
7667
+ writeFileSync(tmp, content, { mode: 384 });
7668
+ chmodSync(tmp, 384);
7669
+ renameSync(tmp, path);
7514
7670
  }
7515
- /** `--file path` or `--file -`. */
7516
- async function readRuleSource(file) {
7517
- if (file === STDIN) return {
7518
- raw: await readStdin(),
7519
- label: "stdin"
7520
- };
7671
+ function readIfExists(path) {
7521
7672
  try {
7522
- return {
7523
- raw: readFileSync(file, "utf8"),
7524
- label: file
7525
- };
7673
+ return readFileSync(path, "utf8");
7526
7674
  } catch (err) {
7527
- return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
7675
+ if (err.code === "ENOENT") return null;
7676
+ throw err;
7528
7677
  }
7529
7678
  }
7530
- /**
7531
- * An empty `methods` or `paths` list means *any*: those fields narrow a rule that
7532
- * already matched its host, so absent means unconstrained. A blank column would
7533
- * read as the opposite, so it renders as `any`.
7534
- *
7535
- * Exported for its own test — this is a real invariant of the format, not a
7536
- * cosmetic choice.
7537
- */
7538
- function describeList(values) {
7539
- return values.length === 0 ? "any" : values.join(" ");
7679
+ var AgentLoginSession = class {
7680
+ o;
7681
+ /** The secret version our view of it came from — the CAS precondition. */
7682
+ version = 0;
7683
+ /** The stored envelope at `version`. */
7684
+ envelope = null;
7685
+ /** The login content known to be in seekrit (and, unless the agent moved it, on disk). */
7686
+ synced = "";
7687
+ claimUntil = null;
7688
+ lastRemotePoll = 0;
7689
+ timer;
7690
+ /** One tick at a time: a slow network must not stack overlapping syncs. */
7691
+ busy = null;
7692
+ placed = false;
7693
+ warnedMissing = false;
7694
+ /** The last write-back failure, kept so `stop` can report a refresh it could not save. */
7695
+ pendingError = null;
7696
+ constructor(options) {
7697
+ this.o = {
7698
+ adapter: options.adapter,
7699
+ store: options.store,
7700
+ secretName: options.secretName,
7701
+ path: options.path,
7702
+ now: options.now ?? Date.now,
7703
+ sleep: options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
7704
+ log: options.log ?? ((line) => process.stderr.write(`seekrit: ${line}\n`)),
7705
+ holder: options.holder ?? `${hostname()}:${process.pid}:${randomBytes(4).toString("hex")}`,
7706
+ pid: options.pid ?? process.pid,
7707
+ isAlive: options.isAlive ?? defaultIsAlive,
7708
+ claimTtlMs: options.claimTtlMs ?? DEFAULTS.claimTtlMs,
7709
+ maxWaitMs: options.maxWaitMs ?? DEFAULTS.maxWaitMs,
7710
+ localPollMs: options.localPollMs ?? DEFAULTS.localPollMs,
7711
+ idleRemotePollMs: options.idleRemotePollMs ?? DEFAULTS.idleRemotePollMs,
7712
+ hotRemotePollMs: options.hotRemotePollMs ?? DEFAULTS.hotRemotePollMs
7713
+ };
7714
+ }
7715
+ get markerPath() {
7716
+ return join(dirname(this.o.path), MARKER);
7717
+ }
7718
+ /**
7719
+ * Fetch the login (claiming it first if it is due to refresh), put it on
7720
+ * disk, and start watching. Throws — before touching the disk — if the path
7721
+ * already holds a login seekrit did not put there.
7722
+ */
7723
+ async start() {
7724
+ this.guardUnmanaged();
7725
+ await this.recoverLeftover();
7726
+ await this.acquire();
7727
+ this.registerHolder();
7728
+ writeAtomic(this.o.path, this.synced);
7729
+ this.placed = true;
7730
+ this.lastRemotePoll = this.o.now();
7731
+ this.timer = setInterval(() => void this.tick(), this.o.localPollMs);
7732
+ this.timer.unref?.();
7733
+ }
7734
+ /**
7735
+ * Stop watching, save anything the agent changed since the last tick, drop
7736
+ * our claim, and — if no other run on this machine still uses it — remove
7737
+ * the file. Returns false when a refresh could not be saved: that login now
7738
+ * exists only on this disk, which the caller must say out loud.
7739
+ */
7740
+ async stop() {
7741
+ if (this.timer) clearInterval(this.timer);
7742
+ this.timer = void 0;
7743
+ if (this.busy) await this.busy.catch(() => {});
7744
+ let saved = true;
7745
+ if (this.placed) {
7746
+ try {
7747
+ await this.syncUp();
7748
+ this.pendingError = null;
7749
+ } catch (err) {
7750
+ this.pendingError = err;
7751
+ }
7752
+ if (this.pendingError) {
7753
+ saved = false;
7754
+ this.o.log(`could not save the refreshed ${this.o.adapter.label} login to seekrit (${message$1(this.pendingError)}). It is still at ${this.o.path}: the next \`seekrit run --agent-login ${this.o.adapter.id}\` on this machine saves it, or run \`seekrit agent-login ${this.o.adapter.id} --from ${this.o.path} --move\` once seekrit is reachable. Until then, other sandboxes will be signed out.`);
7755
+ }
7756
+ }
7757
+ await this.releaseClaim().catch(() => {});
7758
+ if (this.placed && saved) this.unregisterHolder();
7759
+ return saved;
7760
+ }
7761
+ /** One watch cycle: push a local refresh, and (on schedule) pull a remote one. */
7762
+ async tick() {
7763
+ if (this.busy) return;
7764
+ this.busy = (async () => {
7765
+ try {
7766
+ await this.syncUp();
7767
+ this.pendingError = null;
7768
+ } catch (err) {
7769
+ if (!this.pendingError) this.o.log(`could not save the refreshed login yet: ${message$1(err)}`);
7770
+ this.pendingError = err;
7771
+ }
7772
+ try {
7773
+ if (this.remotePollDue()) {
7774
+ this.lastRemotePoll = this.o.now();
7775
+ await this.syncDown();
7776
+ }
7777
+ await this.expireClaim();
7778
+ } catch {}
7779
+ })().finally(() => {
7780
+ this.busy = null;
7781
+ });
7782
+ await this.busy;
7783
+ }
7784
+ /**
7785
+ * An earlier run that could not save its refresh (seekrit unreachable at
7786
+ * exit, or the wrapper killed outright) leaves the login on disk, marked as
7787
+ * ours. If that copy is newer than the stored one it is the only live token,
7788
+ * so it goes back to seekrit before anything replaces it.
7789
+ */
7790
+ async recoverLeftover() {
7791
+ if (!existsSync(this.markerPath)) return;
7792
+ const leftover = readIfExists(this.o.path);
7793
+ if (leftover === null || this.o.adapter.problem(leftover) !== null) return;
7794
+ const snapshot = await this.o.store.read();
7795
+ if (!snapshot) return;
7796
+ const stored = this.decode(snapshot.value);
7797
+ if (!this.fresher(leftover, stored.content)) return;
7798
+ const recovered = {
7799
+ kind: AGENT_LOGIN_KIND,
7800
+ agent: this.o.adapter.id,
7801
+ content: leftover,
7802
+ savedAt: new Date(this.o.now()).toISOString(),
7803
+ claim: null
7804
+ };
7805
+ const written = await this.o.store.write(encodeAgentLoginEnvelope(recovered), snapshot.version);
7806
+ if (written.ok) this.o.log(`saved a refreshed ${this.o.adapter.label} login an earlier run left at ${this.o.path} (v${written.version})`);
7807
+ }
7808
+ async acquire() {
7809
+ const deadline = this.o.now() + this.o.maxWaitMs;
7810
+ let announced = false;
7811
+ for (;;) {
7812
+ const snapshot = await this.o.store.read();
7813
+ if (!snapshot) throw new Error(`no ${this.o.adapter.label} login in ${this.o.secretName} — sign in once with \`seekrit agent-login ${this.o.adapter.id}\``);
7814
+ const envelope = this.decode(snapshot.value);
7815
+ const now = this.o.now();
7816
+ if (!this.isDue(envelope.content, now)) return this.adopt(snapshot, envelope);
7817
+ const claim = envelope.claim;
7818
+ if (claim && claim.holder !== this.o.holder && Date.parse(claim.until) > now) {
7819
+ if (now >= deadline) {
7820
+ this.o.log(`another sandbox has been refreshing the ${this.o.adapter.label} login for too long; starting with the current copy`);
7821
+ return this.adopt(snapshot, envelope);
7822
+ }
7823
+ if (!announced) {
7824
+ this.o.log(`waiting for another sandbox to finish refreshing the ${this.o.adapter.label} login…`);
7825
+ announced = true;
7826
+ }
7827
+ await this.o.sleep(DEFAULTS.waitPollMs);
7828
+ continue;
7829
+ }
7830
+ const until = now + this.o.claimTtlMs;
7831
+ const claimed = {
7832
+ ...envelope,
7833
+ claim: {
7834
+ holder: this.o.holder,
7835
+ until: new Date(until).toISOString()
7836
+ }
7837
+ };
7838
+ const written = await this.o.store.write(encodeAgentLoginEnvelope(claimed), snapshot.version);
7839
+ if (!written.ok) continue;
7840
+ this.claimUntil = until;
7841
+ return this.adopt({
7842
+ version: written.version,
7843
+ value: encodeAgentLoginEnvelope(claimed)
7844
+ }, claimed);
7845
+ }
7846
+ }
7847
+ adopt(snapshot, envelope) {
7848
+ this.version = snapshot.version;
7849
+ this.envelope = envelope;
7850
+ this.synced = envelope.content;
7851
+ }
7852
+ decode(value) {
7853
+ const envelope = decodeAgentLoginEnvelope(value);
7854
+ if (!envelope) throw new Error(`${this.o.secretName} is not a managed agent login — store one with \`seekrit agent-login ${this.o.adapter.id}\``);
7855
+ if (envelope.agent !== this.o.adapter.id) throw new Error(`${this.o.secretName} holds a ${ADAPTERS[envelope.agent].label} login, not ${this.o.adapter.label}`);
7856
+ return envelope;
7857
+ }
7858
+ isDue(content, now) {
7859
+ const expiresAt = this.o.adapter.expiresAt(content);
7860
+ return expiresAt !== null && now >= expiresAt - 3e5;
7861
+ }
7862
+ /** Push the local file to seekrit if the agent changed it. */
7863
+ async syncUp() {
7864
+ const local = readIfExists(this.o.path);
7865
+ if (local === null) {
7866
+ if (!this.warnedMissing) {
7867
+ this.o.log(`${this.o.path} was removed; the login in seekrit is left as it is`);
7868
+ this.warnedMissing = true;
7869
+ }
7870
+ return;
7871
+ }
7872
+ if (local === this.synced) return;
7873
+ if (this.o.adapter.problem(local) !== null) return;
7874
+ for (let attempt = 0; attempt < 5; attempt++) {
7875
+ const next = {
7876
+ kind: AGENT_LOGIN_KIND,
7877
+ agent: this.o.adapter.id,
7878
+ content: local,
7879
+ savedAt: new Date(this.o.now()).toISOString(),
7880
+ claim: null
7881
+ };
7882
+ const written = await this.o.store.write(encodeAgentLoginEnvelope(next), this.version);
7883
+ if (written.ok) {
7884
+ this.version = written.version;
7885
+ this.envelope = next;
7886
+ this.synced = local;
7887
+ this.claimUntil = null;
7888
+ this.o.log(`saved the refreshed ${this.o.adapter.label} login to seekrit (v${written.version})`);
7889
+ return;
7890
+ }
7891
+ const snapshot = await this.o.store.read();
7892
+ if (!snapshot) throw new Error(`${this.o.secretName} was deleted while the agent was running`);
7893
+ const remote = this.decode(snapshot.value);
7894
+ if (remote.content === local) {
7895
+ this.adopt(snapshot, remote);
7896
+ return;
7897
+ }
7898
+ if (this.fresher(local, remote.content)) {
7899
+ this.version = snapshot.version;
7900
+ continue;
7901
+ }
7902
+ writeAtomic(this.o.path, remote.content);
7903
+ this.adopt(snapshot, remote);
7904
+ this.o.log(`another sandbox refreshed the ${this.o.adapter.label} login at the same moment; switched to its copy. If ${this.o.adapter.label} reports a sign-in error, run \`seekrit agent-login ${this.o.adapter.id}\` again.`);
7905
+ return;
7906
+ }
7907
+ throw new Error("the stored login kept changing under us");
7908
+ }
7909
+ /** Whether login `a` was refreshed after `b` (later expiry wins; unknown loses). */
7910
+ fresher(a, b) {
7911
+ return (this.o.adapter.expiresAt(a) ?? Number.NEGATIVE_INFINITY) > (this.o.adapter.expiresAt(b) ?? Number.NEGATIVE_INFINITY);
7912
+ }
7913
+ remotePollDue() {
7914
+ const now = this.o.now();
7915
+ const expiresAt = this.o.adapter.expiresAt(this.synced);
7916
+ const dueAt = expiresAt === null ? null : expiresAt - REFRESH_LEAD_MS;
7917
+ const every = dueAt !== null && now >= dueAt - HOT_BEFORE_MS && now <= dueAt + HOT_AFTER_MS ? this.o.hotRemotePollMs : this.o.idleRemotePollMs;
7918
+ return now - this.lastRemotePoll >= every;
7919
+ }
7920
+ /** Bring a newer copy from seekrit onto disk, so the agent adopts it before refreshing. */
7921
+ async syncDown() {
7922
+ const snapshot = await this.o.store.read();
7923
+ if (!snapshot || snapshot.version === this.version) return;
7924
+ const remote = this.decode(snapshot.value);
7925
+ const local = readIfExists(this.o.path);
7926
+ if (local !== null && local !== this.synced) return;
7927
+ if (remote.content !== this.synced && !this.fresher(this.synced, remote.content)) {
7928
+ writeAtomic(this.o.path, remote.content);
7929
+ this.o.log(`picked up a ${this.o.adapter.label} login another sandbox refreshed`);
7930
+ }
7931
+ if (remote.claim?.holder !== this.o.holder) this.claimUntil = null;
7932
+ this.adopt(snapshot, remote);
7933
+ }
7934
+ /** A claim whose agent never refreshed is let go, so waiting siblings stop waiting. */
7935
+ async expireClaim() {
7936
+ if (this.claimUntil === null || this.o.now() < this.claimUntil) return;
7937
+ await this.releaseClaim();
7938
+ this.o.log(`${this.o.adapter.label} did not refresh its login yet. If it reports a sign-in error, run \`seekrit agent-login ${this.o.adapter.id}\` again.`);
7939
+ }
7940
+ async releaseClaim() {
7941
+ if (this.claimUntil === null || !this.envelope) return;
7942
+ this.claimUntil = null;
7943
+ if (this.envelope.claim?.holder !== this.o.holder) return;
7944
+ const released = {
7945
+ ...this.envelope,
7946
+ claim: null
7947
+ };
7948
+ const written = await this.o.store.write(encodeAgentLoginEnvelope(released), this.version);
7949
+ if (written.ok) {
7950
+ this.version = written.version;
7951
+ this.envelope = released;
7952
+ }
7953
+ }
7954
+ /**
7955
+ * Refuse to replace a login seekrit did not put there. On a laptop that is
7956
+ * somebody's personal sign-in, and on a sandbox image it is a decision
7957
+ * someone else made; either way overwriting it is not ours to do.
7958
+ */
7959
+ guardUnmanaged() {
7960
+ if (existsSync(this.o.path) && !existsSync(this.markerPath)) throw new Error(`${this.o.path} already holds a ${this.o.adapter.label} login that seekrit did not put there — move it aside (or sign out) and run again; seekrit will not overwrite a login it does not manage`);
7961
+ }
7962
+ readMarker() {
7963
+ const raw = readIfExists(this.markerPath);
7964
+ if (raw === null) return null;
7965
+ const parsed = parseObject(raw);
7966
+ const holders = Array.isArray(parsed?.holders) ? parsed.holders.filter((pid) => Number.isInteger(pid)) : [];
7967
+ return {
7968
+ agent: this.o.adapter.id,
7969
+ secret: this.o.secretName,
7970
+ holders
7971
+ };
7972
+ }
7973
+ writeMarker(holders) {
7974
+ const marker = {
7975
+ agent: this.o.adapter.id,
7976
+ secret: this.o.secretName,
7977
+ holders
7978
+ };
7979
+ writeAtomic(this.markerPath, `${JSON.stringify(marker, null, 2)}\n`);
7980
+ }
7981
+ /** Several runs on one machine can share a home; the last one out cleans up. */
7982
+ registerHolder() {
7983
+ const live = (this.readMarker()?.holders ?? []).filter((pid) => pid !== this.o.pid && this.o.isAlive(pid));
7984
+ this.writeMarker([...live, this.o.pid]);
7985
+ }
7986
+ unregisterHolder() {
7987
+ const others = (this.readMarker()?.holders ?? []).filter((pid) => pid !== this.o.pid && this.o.isAlive(pid));
7988
+ if (others.length > 0) {
7989
+ this.writeMarker(others);
7990
+ return;
7991
+ }
7992
+ rmSync(this.o.path, { force: true });
7993
+ rmSync(this.markerPath, { force: true });
7994
+ }
7995
+ };
7996
+ function message$1(err) {
7997
+ return err instanceof Error ? err.message : String(err);
7540
7998
  }
7541
- /**
7542
- * `allow` is the field where empty means the **opposite** — no secret may be
7543
- * injected toward this host at all (`Decision::SecretNotAllowed` for every name;
7544
- * see `crates/seekrit-core/src/policy.rs`). Such a rule is useful and
7545
- * intentional: it permits the *request* while granting no credential, which is
7546
- * how you let an agent read a public API through the proxy without handing it a
7547
- * key. Rendering that as `any` would invert the meaning of the one column an
7548
- * operator reviews most carefully, so it gets its own function.
7549
- */
7550
- function describeSecrets(values) {
7551
- return values.length === 0 ? "none" : values.join(" ");
7999
+ /** Parse `--agent-login codex` / `--agent-login claude=MY_SECRET`. */
8000
+ function parseAgentLoginFlag(raw) {
8001
+ const eq = raw.indexOf("=");
8002
+ const adapter = adapterFor((eq === -1 ? raw : raw.slice(0, eq)).trim());
8003
+ const secretName = eq === -1 ? adapter.defaultSecret : raw.slice(eq + 1).trim();
8004
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretName)) throw new Error(`--agent-login ${raw}: "${secretName}" is not a valid secret name`);
8005
+ return {
8006
+ adapter,
8007
+ secretName
8008
+ };
7552
8009
  }
8010
+ // src/changes.ts
7553
8011
  /**
7554
- * The third convention, and the one most likely to be misread: **no ports means
7555
- * HTTPS on 443**, not any port. A blank or `any` here would say the opposite of
7556
- * what the proxy enforces, so the default is spelled out.
8012
+ * Change requests: the review queue in front of protected environments, and the
8013
+ * protection settings that create it.
8014
+ *
8015
+ * Reviewing here is keyless by construction — a change request carries
8016
+ * ciphertext, so the CLI can show a reviewer *what* is changing and who
8017
+ * proposed it without ever holding a DEK. `seekrit changes show` therefore
8018
+ * prints no value, and deliberately does not offer to.
7557
8019
  */
7558
- function describePorts(rule) {
7559
- return describePolicyPorts(rule) ?? "https:443";
7560
- }
7561
- function printRules(rules) {
7562
- printTable(rules.map((rule, i) => ({
7563
- n: i + 1,
7564
- rule
7565
- })), [
7566
- col("#", (r) => r.n),
7567
- col("host", (r) => r.rule.host),
7568
- col("ports", (r) => describePorts(r.rule)),
7569
- col("methods", (r) => describeList(r.rule.methods)),
7570
- col("paths", (r) => describeList(r.rule.paths)),
7571
- col("secrets", (r) => describeSecrets(r.rule.allow)),
7572
- col("label", (r) => r.rule.label ?? "-")
7573
- ], "no rules — this policy denies every request");
7574
- }
7575
- /** One rule as a single line, for the publish diff. */
7576
- function ruleLine(rule) {
7577
- const ports = describePolicyPorts(rule);
7578
- const parts = [
7579
- ports ? `${rule.host} [${ports}]` : rule.host,
7580
- describeList(rule.methods),
7581
- describeList(rule.paths),
7582
- `secrets=${describeSecrets(rule.allow)}`
7583
- ];
7584
- return rule.label ? `${parts.join(" ")} (${rule.label})` : parts.join(" ");
7585
- }
7586
- /**
7587
- * The change a publish would make. Rules are compared by *position* because
7588
- * order decides — first match wins — so a reordering is a real change and shows
7589
- * up as one.
7590
- */
7591
- function printPolicyDiff(before, after) {
7592
- const changes = diffPolicyRules(before, after).filter((c) => c.kind !== "unchanged");
7593
- if (changes.length === 0) {
7594
- console.error(after.length === 0 ? "no rules — this policy denies every request" : "no rule changes — publishing would only extend the expiry");
7595
- return;
7596
- }
7597
- for (const change of changes) {
7598
- const n = change.index + 1;
7599
- if (change.kind === "added") console.log(`+ ${n} ${ruleLine(change.after)}`);
7600
- else if (change.kind === "removed") console.log(`- ${n} ${ruleLine(change.before)}`);
7601
- else {
7602
- console.log(`- ${n} ${ruleLine(change.before)}`);
7603
- console.log(`+ ${n} ${ruleLine(change.after)}`);
7604
- }
7605
- }
7606
- }
7607
- /** Seconds until an ISO instant, or a negative number when it has passed. */
7608
- function secondsUntil(iso) {
7609
- return Math.round((new Date(iso).getTime() - Date.now()) / 1e3);
7610
- }
7611
- /** Collect a repeated `--scope NAME` into a list. */
7612
- function collectScope(value, acc = []) {
7613
- acc.push(value);
7614
- return acc;
7615
- }
7616
- /**
7617
- * A task's state, derived rather than stored: `revoked` beats `expired`, so a run
7618
- * somebody killed does not read as one that merely lapsed.
7619
- *
7620
- * Exported for its own test — it mirrors `taskState` in
7621
- * `apps/api/src/lib/agent-tasks.ts`, and the two disagreeing would mean the list
7622
- * screen and the enforcement point describe the same run differently.
7623
- */
7624
- function taskStateOf(task, now) {
7625
- if (task.revokedAt) return "revoked";
7626
- return Date.parse(task.expiresAt) <= now ? "expired" : "active";
7627
- }
7628
- /**
7629
- * A detail view on **stderr**, so a command whose stdout is a credential can
7630
- * still explain itself to a person without corrupting `$(…)`.
7631
- */
7632
- function printFieldsToStderr(fields) {
7633
- const present = fields.filter(([, value]) => value !== null && value !== void 0);
7634
- const width = Math.max(...present.map(([label]) => label.length));
7635
- for (const [label, value] of present) console.error(`${label.padEnd(width)} ${value}`);
7636
- }
7637
- /**
7638
- * Exported for its own test: the expired branch is the one that must be loud.
7639
- *
7640
- * The granularity has to span both users of this — a policy bundle lasting a
7641
- * week and a task lasting fifteen minutes. Rounding a task to the nearest hour
7642
- * printed "in 0h", which is worse than no estimate at all.
7643
- */
7644
- function describeExpiry(iso) {
7645
- const seconds = secondsUntil(iso);
7646
- if (seconds <= 0) return `${iso} (EXPIRED — proxies for this agent fail closed)`;
7647
- const days = Math.floor(seconds / 86400);
7648
- const hours = Math.floor(seconds % 86400 / 3600);
7649
- const minutes = Math.floor(seconds % 3600 / 60);
7650
- if (days > 0) return `${iso} (in ${days}d ${hours}h)`;
7651
- if (hours > 0) return `${iso} (in ${hours}h ${minutes}m)`;
7652
- if (minutes > 0) return `${iso} (in ${minutes}m)`;
7653
- return `${iso} (in ${seconds}s)`;
7654
- }
7655
- function registerAgentCommands(program) {
7656
- const agents = program.command("agents").description("agent access policy — which agent may reach which upstream with which secret");
7657
- agents.command("list", { isDefault: true }).description("list agent identities").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (options) => {
7658
- const ctx = buildContext();
7659
- const org = await resolveOrg(ctx, options.org);
7660
- const { agents: rows } = await ctx.client.listAgents(org.id);
7661
- emit(options, { agents: rows }, () => printTable(rows, [
7662
- col("slug", (a) => a.slug),
7663
- col("name", (a) => a.name),
7664
- col("policy", (a) => a.currentPolicyVersion === 0 ? "none" : `v${a.currentPolicyVersion}`),
7665
- col("enabled", (a) => a.enabled ? "yes" : "no"),
7666
- col("last fetch", (a) => a.lastPolicyFetchAt ?? "never")
7667
- ], "no agent identities — create one with `seekrit agents create`"));
7668
- });
7669
- 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) => {
8020
+ function registerChangeCommands(program) {
8021
+ const changes = program.command("changes").description("review changes queued for protected environments");
8022
+ changes.command("list", { isDefault: true }).alias("ls").description("list change requests awaiting review").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--status <status>", `filter by status (${CHANGE_REQUEST_STATUSES.join(", ")})`, "open").option("--all", "every status, not just open").option("--limit <n>", "entries to show (max 200)", "50").option("--json", "print the raw API response").action(async (options) => {
7670
8023
  const ctx = buildContext();
7671
- const org = await resolveOrg(ctx, options.org);
7672
- const environmentId = options.env ? (await resolveAppEnv(ctx, {
7673
- org: options.org,
7674
- app: options.app,
7675
- env: options.env
7676
- })).envId : void 0;
7677
- const { agent } = await ctx.client.createAgent(org.id, {
7678
- name,
7679
- slug: options.slug,
7680
- ...environmentId ? { environmentId } : {}
8024
+ const ref = await resolveOrg(ctx, options.org);
8025
+ const status = options.all ? void 0 : parseStatus(options.status);
8026
+ const limit = Number(options.limit);
8027
+ if (!Number.isInteger(limit) || limit < 1) fail("--limit must be a positive whole number");
8028
+ const { changeRequests } = await ctx.client.listChangeRequests(ref.id, {
8029
+ status,
8030
+ limit: Math.min(limit, 200)
7681
8031
  });
7682
- emit(options, { agent }, () => {
7683
- console.error(`created ${agent.slug} — no policy published yet, so any proxy for it fails closed`);
7684
- console.error(`next: seekrit agents policy publish ${agent.slug} -f rules.json`);
8032
+ emit(options, { changeRequests }, () => {
8033
+ printTable(changeRequests, [
8034
+ col("id", (r) => r.id),
8035
+ col("change", (r) => describeChange(r.kind, r.secretName, r.restoreFromVersion)),
8036
+ col("status", (r) => r.status),
8037
+ col("proposed by", (r) => `${r.requestedByType}:${r.requestedById}`),
8038
+ col("when", (r) => r.createdAt)
8039
+ ], options.all ? "no change requests" : "nothing waiting for review");
7685
8040
  });
7686
8041
  });
7687
- agents.command("show <ref>").description("an identity and its live rules").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
8042
+ changes.command("show <id>").description("show one change request and its reviews (never its value)").option("--org <slug>", "organization slug").option("--json", "print the raw API response").action(async (id, options) => {
7688
8043
  const ctx = buildContext();
7689
- const org = await resolveOrg(ctx, options.org);
7690
- const agent = await resolveAgent(ctx, org.id, ref);
7691
- const { environment } = await ctx.client.getAgent(org.id, agent.id);
7692
- const live = await loadVersion(ctx, org.id, agent);
8044
+ const ref = await resolveOrg(ctx, options.org);
8045
+ const { changeRequest, reviews } = await ctx.client.getChangeRequest(ref.id, id);
7693
8046
  emit(options, {
7694
- agent,
7695
- environment,
7696
- policy: live
8047
+ changeRequest,
8048
+ reviews
7697
8049
  }, () => {
7698
8050
  printFields([
7699
- ["name", agent.name],
7700
- ["slug", agent.slug],
7701
- ["id", agent.id],
7702
- ["environment", environment ? environment.slug : "any in the org"],
7703
- ["enabled", agent.enabled ? "yes" : "no"],
7704
- ["policy", live ? `v${live.version}` : "none published"],
7705
- ["signer", live?.signerThumbprint],
7706
- ["expires", live ? describeExpiry(live.expiresAt) : void 0],
7707
- ["last fetch", agent.lastPolicyFetchAt ?? "never"]
8051
+ ["id", changeRequest.id],
8052
+ ["change", describeChange(changeRequest.kind, changeRequest.secretName, changeRequest.restoreFromVersion)],
8053
+ ["environment", changeRequest.environmentId],
8054
+ ["status", changeRequest.status],
8055
+ ["base version", changeRequest.baseVersion ?? "(new secret)"],
8056
+ ["approvals needed", changeRequest.requiredApprovals],
8057
+ ["proposed by", `${changeRequest.requestedByType}:${changeRequest.requestedById}`],
8058
+ ["note", changeRequest.note ?? "-"]
7708
8059
  ]);
7709
- if (live) {
7710
- section(`rules (v${live.version})`);
7711
- printRules(live.rules);
7712
- section("hosts a forward proxy would intercept");
7713
- console.log(policyHosts(live.rules).join("\n") || "none");
7714
- }
8060
+ section("reviews");
8061
+ printTable(reviews, [
8062
+ col("decision", (r) => r.decision),
8063
+ col("reviewer", (r) => r.reviewerId ?? "(removed)"),
8064
+ col("comment", (r) => r.comment ?? "-"),
8065
+ col("when", (r) => r.createdAt)
8066
+ ], "no reviews yet");
7715
8067
  });
7716
8068
  });
7717
- 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) => {
8069
+ changes.command("approve <id>").description("approve a queued change (applies it once it has enough approvals)").option("--org <slug>", "organization slug").option("-m, --comment <text>", "note recorded with your approval").option("--json", "print the raw API response").action(async (id, options) => {
7718
8070
  const ctx = buildContext();
7719
- const org = await resolveOrg(ctx, options.org);
7720
- const agent = await resolveAgent(ctx, org.id, ref);
7721
- await confirmDestructive(options.yes, `Disable ${agent.slug}? Running proxies keep their current bundle until it expires.`);
7722
- await ctx.client.updateAgent(org.id, agent.id, { enabled: false });
7723
- console.error(`${agent.slug} disabled — its next policy refresh is refused`);
8071
+ const ref = await resolveOrg(ctx, options.org);
8072
+ const result = await ctx.client.approveChangeRequest(ref.id, id, options.comment);
8073
+ emit(options, result, () => {
8074
+ if (result.status === "applied") console.error(`approved and applied${result.version ? ` as v${result.version}` : ""}`);
8075
+ else if (result.status === "superseded") console.error(`not applied: ${result.reason ?? "the secret has changed since"}`);
8076
+ else console.error(`approved — ${result.approvals ?? 0}/${result.required ?? "?"} approvals`);
8077
+ });
7724
8078
  });
7725
- agents.command("enable <ref>").description("serve this identity's policy again").option("--org <slug>", "organization").action(async (ref, options) => {
8079
+ changes.command("reject <id>").description("reject a queued change").option("--org <slug>", "organization slug").option("-m, --comment <text>", "note recorded with your rejection").option("--json", "print the raw API response").action(async (id, options) => {
7726
8080
  const ctx = buildContext();
7727
- const org = await resolveOrg(ctx, options.org);
7728
- const agent = await resolveAgent(ctx, org.id, ref);
7729
- await ctx.client.updateAgent(org.id, agent.id, { enabled: true });
7730
- console.error(`${agent.slug} enabled`);
8081
+ const ref = await resolveOrg(ctx, options.org);
8082
+ emit(options, await ctx.client.rejectChangeRequest(ref.id, id, options.comment), () => console.error("rejected"));
7731
8083
  });
7732
- 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) => {
8084
+ changes.command("withdraw <id>").description("take your own queued change off the review queue").option("--org <slug>", "organization slug").option("--json", "print the raw API response").action(async (id, options) => {
7733
8085
  const ctx = buildContext();
7734
- const org = await resolveOrg(ctx, options.org);
7735
- const agent = await resolveAgent(ctx, org.id, ref);
7736
- await confirmDestructive(options.yes, `Delete ${agent.slug} and all ${agent.currentPolicyVersion} published version(s)? The audit_log rows survive.`);
7737
- await ctx.client.deleteAgent(org.id, agent.id);
7738
- console.error(`${agent.slug} deleted`);
8086
+ const ref = await resolveOrg(ctx, options.org);
8087
+ emit(options, await ctx.client.withdrawChangeRequest(ref.id, id), () => console.error("withdrawn"));
7739
8088
  });
7740
- /**
7741
- * The trust anchor. This prints a *public* key fingerprint — the security of
7742
- * pinning comes from the operator putting it in a local file the API cannot
7743
- * reach, not from this command being honest. Which is why it prints the TOML
7744
- * line to paste rather than offering to write the config.
7745
- */
7746
- 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) => {
8089
+ const protection = changes.command("protection").description("require review before an environment's secrets change");
8090
+ protection.command("show", { isDefault: true }).description("show whether an environment is protected").option("--org <slug>", "organization slug").option("--app <slug>", "application slug").option("--group <slug>", "group slug (instead of --app)").option("--env <slug>", "environment slug").option("--json", "print the raw API response").action(async (options) => {
7747
8091
  const ctx = buildContext();
7748
- const org = await resolveOrg(ctx, options.org);
7749
- const { signer } = await ctx.client.getMyPolicySigner(org.id);
7750
- if (!signer) fail("your account has no keypair yet — run `seekrit keys setup`");
7751
- emit(options, { signer }, () => {
7752
- printFields([["thumbprint", signer.thumbprint], ["user", signer.userId]]);
7753
- section("pin it in seekrit-proxy.toml");
7754
- console.log("[policy]");
7755
- console.log(`signers = ["${signer.thumbprint}"]`);
7756
- console.error("");
7757
- console.error("pin a second admin's key too — one pinned signer means one lost passphrase leaves nobody able to publish");
8092
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
8093
+ const result = await ctx.client.getEnvironmentProtection(orgId, envId);
8094
+ emit(options, result, () => {
8095
+ if (!result.protection) {
8096
+ console.log(`${label}: unprotected — writes land immediately`);
8097
+ return;
8098
+ }
8099
+ printFields([
8100
+ ["environment", label],
8101
+ ["approvals required", result.protection.requiredApprovals],
8102
+ ["self-approval", result.protection.allowSelfApproval ? "allowed" : "not allowed"],
8103
+ ["waiting for review", result.openChangeRequests]
8104
+ ]);
7758
8105
  });
7759
8106
  });
7760
- /**
7761
- * The token is printed to **stdout** and everything else to stderr, so the
7762
- * common thing works without parsing:
7763
- *
7764
- * TASK=$(seekrit agents dispatch nova --scope GITHUB_TOKEN --ttl 15m)
7765
- *
7766
- * It is shown exactly once. The API stores only a hash, so a lost token cannot
7767
- * be recovered — dispatch another and revoke the first.
7768
- */
7769
- 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) => {
8107
+ protection.command("on").description("require approval before secrets in this environment change").option("--org <slug>", "organization slug").option("--app <slug>", "application slug").option("--group <slug>", "group slug (instead of --app)").option("--env <slug>", "environment slug").option("--approvals <n>", "approvals a change needs", "1").option("--allow-self-approval", "let the proposer's own approval count").option("--json", "print the raw API response").action(async (options) => {
7770
8108
  const ctx = buildContext();
7771
- const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : 900;
7772
- if (ttlSeconds < 60 || ttlSeconds > 43200) fail(`--ttl must be between 60s and ${TASK_MAX_TTL_SECONDS / 3600}h`);
7773
- const minted = await createAgentTaskToken();
7774
- const { task, header } = await ctx.client.dispatchAgentTask(ref, {
7775
- taskRef: minted.taskRef,
7776
- tokenHash: minted.tokenHash,
7777
- ttlSeconds,
7778
- ...options.scope ? { scopes: options.scope } : {},
7779
- ...options.label ? { label: options.label } : {}
7780
- });
7781
- if (options.json) {
7782
- console.log(JSON.stringify({
7783
- task,
7784
- header,
7785
- token: minted.token
7786
- }, null, 2));
7787
- return;
7788
- }
7789
- console.log(minted.token);
7790
- printFieldsToStderr([
7791
- ["task", task.id],
7792
- ["agent", ref],
7793
- ["scopes", task.scopes ? describeSecrets(task.scopes) : "the agent's full policy"],
7794
- ["policy", `v${task.policyVersion}`],
7795
- ["expires", describeExpiry(task.expiresAt)],
7796
- ["header", header]
7797
- ]);
7798
- });
7799
- 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) => {
7800
- const ctx = buildContext();
7801
- const org = await resolveOrg(ctx, options.org);
7802
- const agent = await resolveAgent(ctx, org.id, ref);
7803
- const { tasks } = await ctx.client.listAgentTasks(org.id, agent.id);
7804
- const now = Date.now();
7805
- const rows = options.all ? tasks : tasks.filter((t) => taskStateOf(t, now) === "active");
7806
- emit(options, { tasks: rows }, () => printTable(rows, [
7807
- col("task", (t) => t.id),
7808
- col("state", (t) => taskStateOf(t, now)),
7809
- col("scopes", (t) => t.scopes ? describeSecrets(t.scopes) : "policy"),
7810
- col("policy", (t) => `v${t.policyVersion}`),
7811
- col("expires", (t) => t.expiresAt),
7812
- col("last seen", (t) => t.lastSeenAt ?? "never"),
7813
- col("label", (t) => t.label ?? "-")
7814
- ], options.all ? "no runs dispatched yet" : "no live runs (try --all)"));
7815
- });
7816
- agents.command("revoke <taskId>").description("end a run's authority now").action(async (taskId) => {
7817
- const { task } = await buildContext().client.revokeAgentTask(taskId);
7818
- console.error(`${task.id} revoked — enforcement points refuse it at their next introspection`);
8109
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
8110
+ const requiredApprovals = Number(options.approvals);
8111
+ if (!Number.isInteger(requiredApprovals) || requiredApprovals < 1) fail("--approvals must be a positive whole number");
8112
+ emit(options, await ctx.client.setEnvironmentProtection(orgId, envId, {
8113
+ requiredApprovals,
8114
+ allowSelfApproval: options.allowSelfApproval ?? false
8115
+ }), () => console.error(`${label} now requires ${requiredApprovals} approval${requiredApprovals === 1 ? "" : "s"} before a secret changes`));
7819
8116
  });
7820
- registerPolicyCommands(agents.command("policy").description("published policy versions (`seekrit agents policy --help`)"));
7821
- 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) => {
8117
+ protection.command("off").description("stop requiring approval (queued changes stay queued)").option("--org <slug>", "organization slug").option("--app <slug>", "application slug").option("--group <slug>", "group slug (instead of --app)").option("--env <slug>", "environment slug").option("--json", "print the raw API response").action(async (options) => {
7822
8118
  const ctx = buildContext();
7823
- const org = await resolveOrg(ctx, options.org);
7824
- const agent = await resolveAgent(ctx, org.id, ref);
7825
- const days = Number(options.days ?? 14);
7826
- if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
7827
- const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
7828
- emit(options, {
7829
- activity,
7830
- summary
7831
- }, () => {
7832
- printFields([
7833
- ["window", `${days}d`],
7834
- ["allowed", summary.allowed],
7835
- ["denied", summary.denied],
7836
- ["hosts", summary.hosts],
7837
- ["earliest", summary.from ?? "no activity reported"]
7838
- ]);
7839
- if (activity.length === 0) {
7840
- console.error("");
7841
- console.error("nothing reported — add an [activity] block to the proxy (`seekrit proxy init --activity`)");
7842
- return;
7843
- }
7844
- section("decisions");
7845
- printTable(activity, [
7846
- col("host", (a) => a.host),
7847
- col("method", (a) => a.method),
7848
- col("decision", (a) => a.decision),
7849
- col("rule", (a) => a.ruleIndex === null ? "-" : String(a.ruleIndex + 1)),
7850
- col("count", (a) => a.count),
7851
- col("secrets", (a) => a.secrets ? Object.entries(a.secrets).map(([name, n]) => `${name}×${n}`).join(" ") : "-")
7852
- ]);
7853
- });
8119
+ const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
8120
+ emit(options, await ctx.client.deleteEnvironmentProtection(orgId, envId), () => console.error(`${label} no longer requires approval — writes land immediately`));
7854
8121
  });
8122
+ }
8123
+ function parseStatus(raw) {
8124
+ if (!raw) return void 0;
8125
+ if (!CHANGE_REQUEST_STATUSES.includes(raw)) fail(`unknown status "${raw}" — one of ${CHANGE_REQUEST_STATUSES.join(", ")}`);
8126
+ return raw;
8127
+ }
8128
+ /**
8129
+ * Report a write that was queued instead of performed. Every write path calls
8130
+ * this: a CLI that printed "set FOO" for a change nobody has approved would be
8131
+ * telling the operator the deploy is ready when it is not.
8132
+ */
8133
+ function reportQueued(cr) {
8134
+ console.error(`queued for review: ${describeChange(cr.kind, cr.secretName, cr.restoreFromVersion)} — needs ${cr.requiredApprovals} approval${cr.requiredApprovals === 1 ? "" : "s"} (${cr.id})`);
8135
+ }
8136
+ /** Domain separators — must match `crates/seekrit-cache`. */
8137
+ const KEY_DOMAIN = "seekrit-lkg-cache-v1";
8138
+ const TOKEN_DOMAIN = "seekrit-token-fp-v1";
8139
+ /** 24h: long enough to ride out an outage, short enough to bound the tail. */
8140
+ const DEFAULT_MAX_AGE_MS = 864e5;
8141
+ /**
8142
+ * Derive the key for one resolve request. Overrides are sorted, so flag order
8143
+ * never splits the cache. Byte-for-byte identical to `CacheKey::new` in
8144
+ * `crates/seekrit-cache`.
8145
+ */
8146
+ function cacheKey(apiUrl, token, branch, overrides = {}) {
8147
+ const pairs = Object.entries(overrides).map(([group, env]) => `${group}:${env}`).sort();
8148
+ return {
8149
+ key: createHash("sha256").update([
8150
+ KEY_DOMAIN,
8151
+ apiUrl.replace(/\/+$/, ""),
8152
+ token,
8153
+ branch ?? "",
8154
+ pairs.join(",")
8155
+ ].join("\n")).digest("hex"),
8156
+ tokenFingerprint: tokenFingerprint(token)
8157
+ };
8158
+ }
8159
+ /** The hex SHA-256 identifying a token without storing it. */
8160
+ function tokenFingerprint(token) {
8161
+ return createHash("sha256").update(`${TOKEN_DOMAIN}\n${token}`).digest("hex");
8162
+ }
8163
+ /**
8164
+ * `$XDG_CACHE_HOME/seekrit`, else `~/.cache/seekrit` — beside the config
8165
+ * directory holding the credential, never a shared temp directory.
8166
+ */
8167
+ function defaultCacheDir() {
8168
+ return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "seekrit");
8169
+ }
8170
+ /** Render an age for a log line, rounded to its largest whole unit. */
8171
+ function humanize(ms) {
8172
+ const secs = Math.floor(ms / 1e3);
8173
+ if (secs < 60) return `${secs}s`;
8174
+ if (secs < 3600) return `${Math.floor(secs / 60)}m`;
8175
+ if (secs < 86400) return `${Math.floor(secs / 3600)}h`;
8176
+ return `${Math.floor(secs / 86400)}d`;
8177
+ }
8178
+ /**
8179
+ * Whether a failed resolve means the API was *unreachable* (the cache may stand
8180
+ * in) rather than *refusing us* (it must not). A refusal is an answer, and
8181
+ * revocation is supposed to take effect the moment it arrives.
8182
+ */
8183
+ function mayFallBack(err) {
8184
+ if (err instanceof SeekritApiError) return err.status >= 500 || err.status === 429;
8185
+ return true;
8186
+ }
8187
+ /** An opened cache, bound to one resolve request. */
8188
+ var LkgCache = class {
8189
+ dir;
8190
+ maxAgeMs;
8191
+ id;
8192
+ constructor(id, options = {}) {
8193
+ this.id = id;
8194
+ this.dir = options.dir ?? defaultCacheDir();
8195
+ this.maxAgeMs = options.maxAgeMs ?? 864e5;
8196
+ }
8197
+ path() {
8198
+ return join(this.dir, `${this.id.key}.json`);
8199
+ }
8200
+ read() {
8201
+ const path = this.path();
8202
+ if (!existsSync(path)) return { kind: "missing" };
8203
+ let envelope;
8204
+ try {
8205
+ envelope = JSON.parse(readFileSync(path, "utf8"));
8206
+ } catch (err) {
8207
+ return {
8208
+ kind: "unusable",
8209
+ reason: `corrupt cache entry: ${message(err)}`
8210
+ };
8211
+ }
8212
+ if (envelope.version !== 1) return {
8213
+ kind: "unusable",
8214
+ reason: `cache entry is format v${envelope.version}, this build reads v1`
8215
+ };
8216
+ if (envelope.tokenFingerprint !== this.id.tokenFingerprint) return {
8217
+ kind: "unusable",
8218
+ reason: "cache entry belongs to a different token"
8219
+ };
8220
+ if (typeof envelope.body !== "string") return {
8221
+ kind: "unusable",
8222
+ reason: "cache entry has no body"
8223
+ };
8224
+ const ageMs = Math.max(0, Date.now() - envelope.fetchedAt * 1e3);
8225
+ if (ageMs >= this.maxAgeMs) return {
8226
+ kind: "expired",
8227
+ ageMs
8228
+ };
8229
+ return {
8230
+ kind: "hit",
8231
+ body: envelope.body,
8232
+ ageMs
8233
+ };
8234
+ }
7855
8235
  /**
7856
- * The grant review loop. Proposals are computed *here*, from activity the API
7857
- * served — the API is deliberately not in the business of saying what a policy
7858
- * should be. And `--out` writes a rule file rather than publishing: the change
7859
- * still goes through `policy publish`, which still needs a human's key.
8236
+ * Record a freshly-fetched response. Written to a temporary file and renamed,
8237
+ * so a concurrent reader sees either the old entry or the new one — never a
8238
+ * half-written file. Throws only on genuinely unexpected I/O; callers treat a
8239
+ * failed write as a warning, never as a failed resolve.
7860
8240
  */
7861
- 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) => {
7862
- const ctx = buildContext();
7863
- const org = await resolveOrg(ctx, options.org);
7864
- const agent = await resolveAgent(ctx, org.id, ref);
7865
- const days = Number(options.days ?? 14);
7866
- if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
7867
- const live = await loadVersion(ctx, org.id, agent);
7868
- if (!live) fail(`${agent.slug} has no published policy to review`);
7869
- const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
7870
- const proposals = reviewPolicy({
7871
- rules: live.rules,
7872
- activity
7873
- });
7874
- emit(options, {
7875
- proposals,
7876
- summary,
7877
- version: live.version
7878
- }, () => {
7879
- console.error(`${agent.slug} — policy v${live.version}, ${summary.allowed + summary.denied} decision(s) over ${days}d`);
7880
- if (activity.length === 0) {
7881
- console.error("");
7882
- console.error("no activity reported yet — nothing to review against");
7883
- return;
7884
- }
7885
- if (proposals.length === 0) {
7886
- console.error("");
7887
- console.error("no changes proposed: every rule is in use and nothing was refused");
7888
- return;
7889
- }
7890
- section("proposals");
7891
- for (const p of proposals) {
7892
- const marker = p.applicable ? "-" : "?";
7893
- console.log(`${marker} ${p.rationale}`);
7894
- }
7895
- const applicable = countApplicable(proposals);
7896
- console.error("");
7897
- console.error(`${applicable} narrowing change(s) can be applied; ${proposals.length - applicable} need a human decision`);
8241
+ write(body) {
8242
+ mkdirSync(this.dir, {
8243
+ recursive: true,
8244
+ mode: 448
7898
8245
  });
7899
- if (options.out) {
7900
- const narrowed = applyProposals(live.rules, proposals);
7901
- writeFileSync(options.out, `${JSON.stringify({ rules: narrowed }, null, 2)}\n`);
7902
- console.error(`wrote ${narrowed.length} rule(s) to ${options.out} — review the diff, then: seekrit agents policy publish ${agent.slug} -f ${options.out}`);
7903
- console.error("widening proposals are never applied: an agent must not earn permissions by retrying");
7904
- }
7905
- });
7906
- 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("--scheme <scheme>", "https or http, as the agent dials it (forward proxy)", "https").option("--port <n>", "port the agent dials (forward proxy; default 443 for https, 80 for http)").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) => {
7907
- const scheme = options.scheme.toLowerCase();
7908
- if (scheme !== "https" && scheme !== "http") fail("--scheme must be https or http");
7909
- const port = options.port === void 0 ? scheme === "https" ? 443 : 80 : Number(options.port);
7910
- if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--port must be 1-65535");
7911
- const ctx = buildContext();
7912
- const org = await resolveOrg(ctx, options.org);
7913
- const agent = await resolveAgent(ctx, org.id, ref);
7914
- const version = options.version === void 0 ? void 0 : Number(options.version);
7915
- if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
7916
- const published = await loadVersion(ctx, org.id, agent, version);
7917
- if (!published) fail(`${agent.slug} has no published policy — every request is denied`);
7918
- const verdict = evaluatePolicy(published.rules, {
7919
- host: options.host,
7920
- transport: {
7921
- scheme,
7922
- port
7923
- },
7924
- method: options.method,
7925
- path: options.path,
7926
- ...options.secret === void 0 ? {} : { secret: options.secret }
7927
- });
7928
- const reason = describePolicyVerdict(verdict, published.rules);
7929
- emit(options, {
7930
- version: published.version,
7931
- verdict,
7932
- reason
7933
- }, () => {
7934
- const target = `${options.method.toUpperCase()} ${scheme}://${options.host}:${port}${options.path}`;
7935
- console.log(`${verdict.decision === "allow" ? "allow" : "DENY"} ${target}${options.secret ? ` [${options.secret}]` : ""}`);
7936
- console.log(` v${published.version}: ${reason}`);
7937
- });
7938
- if (verdict.decision !== "allow") process.exitCode = 1;
7939
- });
7940
- }
7941
- function registerPolicyCommands(policy) {
7942
- policy.command("list <ref>").description("published versions, newest first").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
7943
- const ctx = buildContext();
7944
- const org = await resolveOrg(ctx, options.org);
7945
- const agent = await resolveAgent(ctx, org.id, ref);
7946
- const { policies } = await ctx.client.listAgentPolicies(org.id, agent.id);
7947
- emit(options, { policies }, () => printTable(policies, [
7948
- col("version", (p) => `v${p.version}`),
7949
- col("rules", (p) => p.ruleCount),
7950
- col("signer", (p) => p.signerThumbprint.slice(0, 12)),
7951
- col("published", (p) => p.publishedAt),
7952
- col("expires", (p) => secondsUntil(p.expiresAt) <= 0 ? `${p.expiresAt} (expired)` : p.expiresAt),
7953
- col("note", (p) => p.rolledBackFromVersion ? `restored v${p.rolledBackFromVersion}` : "-")
7954
- ], "nothing published yet"));
7955
- });
7956
- 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) => {
7957
- const ctx = buildContext();
7958
- const org = await resolveOrg(ctx, options.org);
7959
- const agent = await resolveAgent(ctx, org.id, ref);
7960
- const version = options.version === void 0 ? void 0 : Number(options.version);
7961
- if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
7962
- const published = await loadVersion(ctx, org.id, agent, version);
7963
- if (!published) fail(`${agent.slug} has no published policy`);
7964
- emit(options, { policy: published }, () => {
7965
- printFields([
7966
- ["version", `v${published.version}`],
7967
- ["signer", published.signerThumbprint],
7968
- ["published", published.publishedAt],
7969
- ["expires", describeExpiry(published.expiresAt)]
7970
- ]);
7971
- section("rules");
7972
- printRules(published.rules);
7973
- });
7974
- });
7975
- /**
7976
- * Write the live rules out as the editable source for the next publish. This
7977
- * is the half that makes policy-as-code work: what comes back out is exactly
7978
- * the shape `publish` takes in, so a round trip is a no-op diff.
7979
- */
7980
- 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) => {
7981
- const ctx = buildContext();
7982
- const org = await resolveOrg(ctx, options.org);
7983
- const agent = await resolveAgent(ctx, org.id, ref);
7984
- const version = options.version === void 0 ? void 0 : Number(options.version);
7985
- if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
7986
- const published = await loadVersion(ctx, org.id, agent, version);
7987
- const rules = published?.rules ?? [];
7988
- const body = `${JSON.stringify({ rules }, null, 2)}\n`;
7989
- if (options.out) {
7990
- writeFileSync(options.out, body);
7991
- 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)`);
7992
- return;
8246
+ const envelope = {
8247
+ version: 1,
8248
+ fetchedAt: Math.floor(Date.now() / 1e3),
8249
+ tokenFingerprint: this.id.tokenFingerprint,
8250
+ body
8251
+ };
8252
+ const tmp = join(this.dir, `${this.id.key}.${process.pid}.tmp`);
8253
+ try {
8254
+ writeFileSync(tmp, JSON.stringify(envelope), { mode: 384 });
8255
+ renameSync(tmp, this.path());
8256
+ } catch (err) {
8257
+ rmSync(tmp, { force: true });
8258
+ throw err;
7993
8259
  }
7994
- process.stdout.write(body);
7995
- });
7996
- 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) => {
7997
- const ctx = buildContext();
7998
- 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.");
7999
- const org = await resolveOrg(ctx, options.org);
8000
- const agent = await resolveAgent(ctx, org.id, ref);
8001
- const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : POLICY_DEFAULT_TTL_SECONDS;
8002
- if (ttlSeconds < 3600 || ttlSeconds > 7776e3) fail(`--ttl must be between ${POLICY_MIN_TTL_SECONDS / 3600}h and ${POLICY_MAX_TTL_SECONDS / 86400}d`);
8003
- const { raw, label } = await readRuleSource(options.file);
8004
- const rules = parseRuleFile(raw, label);
8005
- const live = await loadVersion(ctx, org.id, agent);
8006
- const nextVersion = agent.currentPolicyVersion + 1;
8007
- const from = agent.currentPolicyVersion === 0 ? "nothing" : `v${agent.currentPolicyVersion}`;
8008
- console.error(`${agent.slug}: ${from} → v${nextVersion}`);
8009
- printPolicyDiff(live?.rules ?? [], rules);
8010
- await confirmDestructive(options.yes, `Publish v${nextVersion} for ${agent.slug} (${rules.length} rule(s), valid ${options.ttl ?? "7d"})?`);
8011
- const { signingKey, jwk } = await getPolicySigner(ctx);
8012
- const issuedAt = Math.floor(Date.now() / 1e3);
8013
- const bundle = await signAgentPolicy(signingKey, jwk, {
8014
- v: policyBundleVersionFor(rules),
8015
- org: org.id,
8016
- agent: agent.id,
8017
- agent_slug: agent.slug,
8018
- policy_version: nextVersion,
8019
- issued_at: issuedAt,
8020
- expires_at: issuedAt + ttlSeconds,
8021
- rules
8022
- });
8023
- const { policy: published } = await ctx.client.publishAgentPolicy(org.id, agent.id, bundle);
8024
- emit(options, { policy: published }, () => {
8025
- console.error(`published v${published.version} — signed with ${published.signerThumbprint.slice(0, 12)}…, expires ${published.expiresAt}`);
8026
- console.error("proxies pick it up at their next refresh");
8027
- });
8028
- });
8029
- 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) => {
8030
- const ctx = buildContext();
8031
- const org = await resolveOrg(ctx, options.org);
8032
- const agent = await resolveAgent(ctx, org.id, ref);
8033
- const version = Number(versionArg);
8034
- if (!Number.isInteger(version) || version < 1) fail("version must be a positive integer");
8035
- const source = await loadVersion(ctx, org.id, agent, version);
8036
- if (!source) fail(`no version ${version}`);
8037
- 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`);
8038
- await confirmDestructive(options.yes, `Roll ${agent.slug} back to v${version} (${source.ruleCount} rule(s))?`);
8039
- const { policy: published } = await ctx.client.rollbackAgentPolicy(org.id, agent.id, version);
8040
- console.error(`published v${published.version} carrying v${version}'s bundle`);
8041
- });
8260
+ this.pruneExpired();
8261
+ }
8262
+ /** Drop this entry — used when the API refuses the token. */
8263
+ invalidate() {
8264
+ rmSync(this.path(), { force: true });
8265
+ }
8042
8266
  /**
8043
- * What a proxy actually gets. Useful when a deployment misbehaves and the
8044
- * question is whether the API, the pin, or the rules are at fault — so it
8045
- * re-derives the thumbprint from the bundle rather than reporting the one the
8046
- * API sends beside it, and says plainly when the two disagree.
8267
+ * Delete entries past their usefulness (a rotated token or a changed
8268
+ * `--with` leaves one nothing will read again). Best-effort and silent, and
8269
+ * it only ever touches files this cache named: `<64 hex>.json`.
8047
8270
  */
8048
- 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) => {
8049
- const fetched = await buildContext().client.getAgentPolicyBundle(ref);
8050
- if (options.bundle) {
8051
- process.stdout.write(`${fetched.bundle}\n`);
8271
+ pruneExpired() {
8272
+ let names;
8273
+ try {
8274
+ names = readdirSync(this.dir);
8275
+ } catch {
8052
8276
  return;
8053
8277
  }
8054
- const body = parseAgentPolicyUnverified(fetched.bundle);
8055
- const derived = await policySignerThumbprint(body.signer.jwk);
8056
- emit(options, {
8057
- ...fetched,
8058
- derivedThumbprint: derived,
8059
- rules: body.rules
8060
- }, () => {
8061
- printFields([
8062
- ["agent", `${fetched.agent.slug} (${fetched.agent.name})`],
8063
- ["version", `v${fetched.version}`],
8064
- ["expires", describeExpiry(fetched.expiresAt)],
8065
- ["signer (in bundle)", derived],
8066
- ["signer (reported)", fetched.signerThumbprint === derived ? "matches" : `${fetched.signerThumbprint} — DOES NOT MATCH the bundle; a proxy would refuse this`]
8067
- ]);
8068
- section("rules");
8069
- printRules(body.rules);
8070
- console.error("");
8071
- console.error("this command does not verify the signature — only a pinned signer list can, which lives in the proxy's own config");
8072
- });
8073
- });
8278
+ for (const name of names) {
8279
+ if (!/^[0-9a-f]{64}\.json$/.test(name)) continue;
8280
+ const path = join(this.dir, name);
8281
+ let expired = true;
8282
+ try {
8283
+ const envelope = JSON.parse(readFileSync(path, "utf8"));
8284
+ expired = Math.max(0, Date.now() - envelope.fetchedAt * 1e3) >= this.maxAgeMs;
8285
+ } catch {}
8286
+ if (expired) rmSync(path, { force: true });
8287
+ }
8288
+ }
8289
+ };
8290
+ function message(err) {
8291
+ return err instanceof Error ? err.message : String(err);
8074
8292
  }
8075
- /** Domain separators — must match `crates/seekrit-cache`. */
8076
- const KEY_DOMAIN = "seekrit-lkg-cache-v1";
8077
- const TOKEN_DOMAIN = "seekrit-token-fp-v1";
8078
- /** 24h: long enough to ride out an outage, short enough to bound the tail. */
8079
- const DEFAULT_MAX_AGE_MS = 864e5;
8080
8293
  /**
8081
- * Derive the key for one resolve request. Overrides are sorted, so flag order
8082
- * never splits the cache. Byte-for-byte identical to `CacheKey::new` in
8083
- * `crates/seekrit-cache`.
8294
+ * Fetch + decrypt every secret in a single environment.
8295
+ *
8296
+ * `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
8297
+ * `interpolate`) unless `raw` is set. Only this environment's own secrets are in
8298
+ * scope here — a reference to a secret inherited from a composed group is left
8299
+ * literal, because the group layers aren't fetched. `materializeEnv` is the
8300
+ * fully-layered view.
8084
8301
  */
8085
- function cacheKey(apiUrl, token, branch, overrides = {}) {
8086
- const pairs = Object.entries(overrides).map(([group, env]) => `${group}:${env}`).sort();
8087
- return {
8088
- key: createHash("sha256").update([
8089
- KEY_DOMAIN,
8090
- apiUrl.replace(/\/+$/, ""),
8091
- token,
8092
- branch ?? "",
8093
- pairs.join(",")
8094
- ].join("\n")).digest("hex"),
8095
- tokenFingerprint: tokenFingerprint(token)
8096
- };
8097
- }
8098
- /** The hex SHA-256 identifying a token without storing it. */
8099
- function tokenFingerprint(token) {
8100
- return createHash("sha256").update(`${TOKEN_DOMAIN}\n${token}`).digest("hex");
8302
+ async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
8303
+ const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
8304
+ const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
8305
+ return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
8101
8306
  }
8102
8307
  /**
8103
- * `$XDG_CACHE_HOME/seekrit`, else `~/.cache/seekrit` — beside the config
8104
- * directory holding the credential, never a shared temp directory.
8308
+ * Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
8309
+ * the CLI's standard fatal exit — it is a config bug with no correct value to
8310
+ * emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
8105
8311
  */
8106
- function defaultCacheDir() {
8107
- return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "seekrit");
8108
- }
8109
- /** Render an age for a log line, rounded to its largest whole unit. */
8110
- function humanize(ms) {
8111
- const secs = Math.floor(ms / 1e3);
8112
- if (secs < 60) return `${secs}s`;
8113
- if (secs < 3600) return `${Math.floor(secs / 60)}m`;
8114
- if (secs < 86400) return `${Math.floor(secs / 3600)}h`;
8115
- return `${Math.floor(secs / 86400)}d`;
8116
- }
8117
- /**
8118
- * Whether a failed resolve means the API was *unreachable* (the cache may stand
8119
- * in) rather than *refusing us* (it must not). A refusal is an answer, and
8120
- * revocation is supposed to take effect the moment it arrives.
8121
- */
8122
- function mayFallBack(err) {
8123
- if (err instanceof SeekritApiError) return err.status >= 500 || err.status === 429;
8124
- return true;
8125
- }
8126
- /** An opened cache, bound to one resolve request. */
8127
- var LkgCache = class {
8128
- dir;
8129
- maxAgeMs;
8130
- id;
8131
- constructor(id, options = {}) {
8132
- this.id = id;
8133
- this.dir = options.dir ?? defaultCacheDir();
8134
- this.maxAgeMs = options.maxAgeMs ?? 864e5;
8135
- }
8136
- path() {
8137
- return join(this.dir, `${this.id.key}.json`);
8138
- }
8139
- read() {
8140
- const path = this.path();
8141
- if (!existsSync(path)) return { kind: "missing" };
8142
- let envelope;
8143
- try {
8144
- envelope = JSON.parse(readFileSync(path, "utf8"));
8145
- } catch (err) {
8146
- return {
8147
- kind: "unusable",
8148
- reason: `corrupt cache entry: ${message(err)}`
8149
- };
8150
- }
8151
- if (envelope.version !== 1) return {
8152
- kind: "unusable",
8153
- reason: `cache entry is format v${envelope.version}, this build reads v1`
8154
- };
8155
- if (envelope.tokenFingerprint !== this.id.tokenFingerprint) return {
8156
- kind: "unusable",
8157
- reason: "cache entry belongs to a different token"
8158
- };
8159
- if (typeof envelope.body !== "string") return {
8160
- kind: "unusable",
8161
- reason: "cache entry has no body"
8162
- };
8163
- const ageMs = Math.max(0, Date.now() - envelope.fetchedAt * 1e3);
8164
- if (ageMs >= this.maxAgeMs) return {
8165
- kind: "expired",
8166
- ageMs
8167
- };
8168
- return {
8169
- kind: "hit",
8170
- body: envelope.body,
8171
- ageMs
8172
- };
8173
- }
8174
- /**
8175
- * Record a freshly-fetched response. Written to a temporary file and renamed,
8176
- * so a concurrent reader sees either the old entry or the new one — never a
8177
- * half-written file. Throws only on genuinely unexpected I/O; callers treat a
8178
- * failed write as a warning, never as a failed resolve.
8179
- */
8180
- write(body) {
8181
- mkdirSync(this.dir, {
8182
- recursive: true,
8183
- mode: 448
8184
- });
8185
- const envelope = {
8186
- version: 1,
8187
- fetchedAt: Math.floor(Date.now() / 1e3),
8188
- tokenFingerprint: this.id.tokenFingerprint,
8189
- body
8190
- };
8191
- const tmp = join(this.dir, `${this.id.key}.${process.pid}.tmp`);
8192
- try {
8193
- writeFileSync(tmp, JSON.stringify(envelope), { mode: 384 });
8194
- renameSync(tmp, this.path());
8195
- } catch (err) {
8196
- rmSync(tmp, { force: true });
8197
- throw err;
8198
- }
8199
- this.pruneExpired();
8200
- }
8201
- /** Drop this entry — used when the API refuses the token. */
8202
- invalidate() {
8203
- rmSync(this.path(), { force: true });
8204
- }
8205
- /**
8206
- * Delete entries past their usefulness (a rotated token or a changed
8207
- * `--with` leaves one nothing will read again). Best-effort and silent, and
8208
- * it only ever touches files this cache named: `<64 hex>.json`.
8209
- */
8210
- pruneExpired() {
8211
- let names;
8212
- try {
8213
- names = readdirSync(this.dir);
8214
- } catch {
8215
- return;
8216
- }
8217
- for (const name of names) {
8218
- if (!/^[0-9a-f]{64}\.json$/.test(name)) continue;
8219
- const path = join(this.dir, name);
8220
- let expired = true;
8221
- try {
8222
- const envelope = JSON.parse(readFileSync(path, "utf8"));
8223
- expired = Math.max(0, Date.now() - envelope.fetchedAt * 1e3) >= this.maxAgeMs;
8224
- } catch {}
8225
- if (expired) rmSync(path, { force: true });
8226
- }
8227
- }
8228
- };
8229
- function message(err) {
8230
- return err instanceof Error ? err.message : String(err);
8231
- }
8232
- /**
8233
- * Fetch + decrypt every secret in a single environment.
8234
- *
8235
- * `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
8236
- * `interpolate`) unless `raw` is set. Only this environment's own secrets are in
8237
- * scope here — a reference to a secret inherited from a composed group is left
8238
- * literal, because the group layers aren't fetched. `materializeEnv` is the
8239
- * fully-layered view.
8240
- */
8241
- async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
8242
- const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
8243
- const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
8244
- return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
8245
- }
8246
- /**
8247
- * Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
8248
- * the CLI's standard fatal exit — it is a config bug with no correct value to
8249
- * emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
8250
- */
8251
- function interpolateValues(values, enabled = true) {
8252
- if (!enabled) return {
8253
- values,
8254
- interpolated: [],
8255
- unresolvedRefs: []
8256
- };
8257
- try {
8258
- const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
8259
- return {
8260
- values: expandedValues,
8261
- interpolated: expanded,
8262
- unresolvedRefs: unresolved
8263
- };
8264
- } catch (err) {
8265
- return fail(err instanceof Error ? err.message : String(err));
8266
- }
8312
+ function interpolateValues(values, enabled = true) {
8313
+ if (!enabled) return {
8314
+ values,
8315
+ interpolated: [],
8316
+ unresolvedRefs: []
8317
+ };
8318
+ try {
8319
+ const { values: expandedValues, expanded, unresolved } = interpolateSecrets(values);
8320
+ return {
8321
+ values: expandedValues,
8322
+ interpolated: expanded,
8323
+ unresolvedRefs: unresolved
8324
+ };
8325
+ } catch (err) {
8326
+ return fail(err instanceof Error ? err.message : String(err));
8327
+ }
8267
8328
  }
8268
8329
  /**
8269
8330
  * Decrypt one historical version of a secret. Ciphertext is bound to
@@ -8331,6 +8392,7 @@ async function materializeEnv(ctx, opts) {
8331
8392
  const privateKey = await getPrivateKey(ctx);
8332
8393
  const values = {};
8333
8394
  const provenance = {};
8395
+ const origin = {};
8334
8396
  for (const layer of layers) {
8335
8397
  const dek = await unwrapDek(layer.wrappedDek, privateKey);
8336
8398
  let label;
@@ -8340,14 +8402,32 @@ async function materializeEnv(ctx, opts) {
8340
8402
  for (const secret of layer.secrets) {
8341
8403
  values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
8342
8404
  provenance[secret.name] = label;
8405
+ origin[secret.name] = {
8406
+ envId: layer.environmentId,
8407
+ dek
8408
+ };
8343
8409
  }
8344
8410
  }
8411
+ const agentLogins = {};
8412
+ for (const [name, value] of Object.entries(values)) {
8413
+ const from = origin[name];
8414
+ if (!from || !isAgentLoginValue(value)) continue;
8415
+ agentLogins[name] = {
8416
+ orgId: scope.orgId,
8417
+ envId: from.envId,
8418
+ dek: from.dek,
8419
+ value
8420
+ };
8421
+ delete values[name];
8422
+ delete provenance[name];
8423
+ }
8345
8424
  const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
8346
8425
  return {
8347
8426
  ...interpolateValues(values, opts.interpolate !== false),
8348
8427
  provenance,
8349
8428
  scope,
8350
- loadedEnvFiles
8429
+ loadedEnvFiles,
8430
+ agentLogins
8351
8431
  };
8352
8432
  }
8353
8433
  /**
@@ -8379,50 +8459,962 @@ async function resolveWithCache(ctx, query, cache) {
8379
8459
  warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
8380
8460
  return JSON.parse(found.body);
8381
8461
  }
8382
- if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
8383
- else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
8384
- throw err;
8385
- }
8386
- }
8387
- function warn(text) {
8388
- process.stderr.write(`seekrit: ${text}\n`);
8389
- }
8390
- function errorMessage(err) {
8391
- return err instanceof Error ? err.message : String(err);
8392
- }
8393
- /**
8394
- * Overlay `.env` files onto an existing value/provenance set (later files win).
8395
- * Missing files are skipped. Returns the files that were actually loaded. Used
8396
- * both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
8397
- * managed secrets are unavailable but `.env` should still apply.
8398
- */
8399
- function overlayEnvFiles(values, provenance, envFiles) {
8400
- const loaded = [];
8401
- for (const file of envFiles) {
8402
- if (!existsSync(file)) continue;
8403
- loaded.push(file);
8404
- for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
8405
- values[name] = value;
8406
- provenance[name] = `dotenv:${file}`;
8462
+ if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
8463
+ else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
8464
+ throw err;
8465
+ }
8466
+ }
8467
+ function warn(text) {
8468
+ process.stderr.write(`seekrit: ${text}\n`);
8469
+ }
8470
+ function errorMessage(err) {
8471
+ return err instanceof Error ? err.message : String(err);
8472
+ }
8473
+ /**
8474
+ * Overlay `.env` files onto an existing value/provenance set (later files win).
8475
+ * Missing files are skipped. Returns the files that were actually loaded. Used
8476
+ * both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
8477
+ * managed secrets are unavailable but `.env` should still apply.
8478
+ */
8479
+ function overlayEnvFiles(values, provenance, envFiles) {
8480
+ const loaded = [];
8481
+ for (const file of envFiles) {
8482
+ if (!existsSync(file)) continue;
8483
+ loaded.push(file);
8484
+ for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
8485
+ values[name] = value;
8486
+ provenance[name] = `dotenv:${file}`;
8487
+ }
8488
+ }
8489
+ return loaded;
8490
+ }
8491
+ /**
8492
+ * Print a name → source table to stderr (never the secret values). Names whose
8493
+ * value had references expanded are marked, and dangling references are called
8494
+ * out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
8495
+ * deliberately passed through as literal text.
8496
+ */
8497
+ function printExplain(provenance, refs = {}) {
8498
+ const interpolated = new Set(refs.interpolated ?? []);
8499
+ const names = Object.keys(provenance).sort();
8500
+ const width = names.reduce((w, n) => Math.max(w, n.length), 0);
8501
+ for (const name of names) {
8502
+ const marker = interpolated.has(name) ? " (interpolated)" : "";
8503
+ process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
8504
+ }
8505
+ if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
8506
+ }
8507
+ // src/agent-login-command.ts
8508
+ /**
8509
+ * `seekrit agent-login …` and the `seekrit run --agent-login` wiring: the CLI
8510
+ * and network half of managed agent logins. The coordination logic — claims,
8511
+ * write-back, pick-up — is transport-free in `agent-login.ts`; this file gives
8512
+ * it a seekrit secret to work against and a command line to be driven from.
8513
+ */
8514
+ /** The variable Claude Code reads a `claude setup-token` token from. */
8515
+ const CLAUDE_TOKEN_VAR = AGENT_LOGIN_SECRETS.claudeToken;
8516
+ /**
8517
+ * One secret, read and written through the API with the environment's data key
8518
+ * held in memory. `read` takes the newest version from the history endpoint,
8519
+ * which is the only read that names a version — and the version is what a
8520
+ * compare-and-swap is conditioned on.
8521
+ */
8522
+ var SecretLoginStore = class {
8523
+ ctx;
8524
+ orgId;
8525
+ envId;
8526
+ name;
8527
+ dek;
8528
+ constructor(ctx, orgId, envId, name, dek) {
8529
+ this.ctx = ctx;
8530
+ this.orgId = orgId;
8531
+ this.envId = envId;
8532
+ this.name = name;
8533
+ this.dek = dek;
8534
+ }
8535
+ async read() {
8536
+ try {
8537
+ const { versions } = await this.ctx.client.listSecretVersions(this.orgId, this.envId, this.name, { limit: 1 });
8538
+ const newest = versions[0];
8539
+ if (!newest) return null;
8540
+ const value = await decryptSecret(this.dek, newest.ciphertext, secretAad(this.envId, this.name));
8541
+ return {
8542
+ version: newest.version,
8543
+ value
8544
+ };
8545
+ } catch (err) {
8546
+ if (err instanceof SeekritApiError && err.status === 404) return null;
8547
+ throw err;
8548
+ }
8549
+ }
8550
+ async write(value, expectedVersion) {
8551
+ const ciphertext = await encryptSecret(this.dek, value, secretAad(this.envId, this.name));
8552
+ try {
8553
+ const result = await this.ctx.client.setSecret(this.orgId, this.envId, this.name, ciphertext, void 0, { expectedVersion });
8554
+ if (result.queued) throw new Error(`${this.name} is in an environment that requires review for every write`);
8555
+ return {
8556
+ ok: true,
8557
+ version: result.secret.version
8558
+ };
8559
+ } catch (err) {
8560
+ if (err instanceof SeekritApiError && err.status === 412) return { ok: false };
8561
+ throw err;
8562
+ }
8563
+ }
8564
+ };
8565
+ /**
8566
+ * A login has to be written back the moment it refreshes, so it cannot live in
8567
+ * an environment where every write waits for a reviewer: the refresh would sit
8568
+ * in a change request while the old token is already spent. Refuse up front,
8569
+ * rather than opening a change request per refresh.
8570
+ */
8571
+ async function assertWritable(ctx, orgId, envId, label) {
8572
+ let protectedEnv = false;
8573
+ try {
8574
+ protectedEnv = (await ctx.client.getEnvironmentProtection(orgId, envId)).protection !== null;
8575
+ } catch {}
8576
+ if (protectedEnv) fail(`${label} requires review for every write, so a refreshed login could never be saved back — keep agent logins in an environment without approvals`);
8577
+ }
8578
+ /**
8579
+ * Turn the `--agent-login` flags of one `seekrit run` into sessions, against
8580
+ * the logins the resolve found. Fails before anything is launched when a login
8581
+ * is missing, so an agent never starts half signed in.
8582
+ */
8583
+ async function prepareAgentLogins(requests, found, ctx, scopeLabel) {
8584
+ const seen = /* @__PURE__ */ new Set();
8585
+ const ready = [];
8586
+ for (const request of requests) {
8587
+ if (seen.has(request.adapter.id)) fail(`--agent-login ${request.adapter.id} given twice`);
8588
+ seen.add(request.adapter.id);
8589
+ if (!ctx) fail(`--agent-login ${request.adapter.id} needs seekrit, which could not be reached (see above)`);
8590
+ const login = found[request.secretName];
8591
+ if (!login) fail(`no ${request.adapter.label} login named ${request.secretName} in ${scopeLabel} — sign in once with \`seekrit agent-login ${request.adapter.id}\``);
8592
+ const envelope = decodeAgentLoginEnvelope(login.value);
8593
+ if (envelope && envelope.agent !== request.adapter.id) fail(`${request.secretName} holds a ${envelope.agent} login, not ${request.adapter.id}`);
8594
+ await assertWritable(ctx, login.orgId, login.envId, request.secretName);
8595
+ if (request.adapter.id === "claude" && process.platform === "darwin") process.stderr.write("seekrit: Claude Code on macOS reads its login from the Keychain, not ~/.claude/.credentials.json — --agent-login claude takes effect on Linux (sandboxes, containers, CI)\n");
8596
+ ready.push({
8597
+ ...request,
8598
+ login
8599
+ });
8600
+ }
8601
+ return (env) => ready.map(({ adapter, secretName, login }) => new AgentLoginSession({
8602
+ adapter,
8603
+ secretName,
8604
+ path: adapter.loginPath(env),
8605
+ store: new SecretLoginStore(ctx, login.orgId, login.envId, secretName, login.dek)
8606
+ }));
8607
+ }
8608
+ function withTargetFlags(cmd) {
8609
+ return cmd.option("--org <slug>", "organization slug").option("--app <slug>", "application slug (defaults to seekrit.json)").option("--group <slug>", "target a group environment instead of an app").option("--env <slug>", "environment slug (a service token infers its own)");
8610
+ }
8611
+ /** Fail with the reason a file is not a login this agent can use. */
8612
+ function readLoginFile(adapter, path) {
8613
+ if (!existsSync(path)) fail(`no such file: ${path}`);
8614
+ const content = readFileSync(path, "utf8");
8615
+ const problem = adapter.problem(content);
8616
+ if (problem) fail(`${path} is not a usable ${adapter.label} login: ${problem}`);
8617
+ return content;
8618
+ }
8619
+ /** Encrypt a login file into its envelope and store it in the target environment. */
8620
+ async function storeLoginFile(ctx, target, adapter, name, content) {
8621
+ await assertWritable(ctx, target.orgId, target.envId, target.label);
8622
+ const envelope = encodeAgentLoginEnvelope({
8623
+ agent: adapter.id,
8624
+ content,
8625
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
8626
+ claim: null
8627
+ });
8628
+ const queued = await encryptAndSetSecret(ctx, target.orgId, target.envId, name, envelope);
8629
+ if (queued) {
8630
+ reportQueued(queued);
8631
+ fail(`${target.label} requires review for every write — keep agent logins elsewhere`);
8632
+ }
8633
+ console.error(`stored the ${adapter.label} login in ${target.label} as ${name}`);
8634
+ }
8635
+ /** After `--from`: the source and the stored copy share one refresh token. */
8636
+ function finishFrom(adapter, from, move) {
8637
+ if (move) {
8638
+ rmSync(from, { force: true });
8639
+ console.error(`removed ${from}`);
8640
+ return;
8641
+ }
8642
+ console.error(`warning: ${from} and the copy in seekrit share one refresh token — whichever refreshes first signs the other out. Delete it (or re-run with --move), or sign in fresh with \`seekrit agent-login ${adapter.id}\`.`);
8643
+ }
8644
+ function requireBinary(bin, install) {
8645
+ if (spawnSync(bin, ["--version"], { stdio: "ignore" }).error) fail(`\`${bin}\` is not installed (${install})`);
8646
+ }
8647
+ function usage(adapter, name) {
8648
+ return `run it anywhere with: seekrit run --agent-login ${name === adapter.defaultSecret ? adapter.id : `${adapter.id}=${name}`} -- ${adapter.id === "codex" ? "codex exec \"…\"" : "claude -p \"…\""}`;
8649
+ }
8650
+ function registerAgentLoginCommands(program) {
8651
+ const group = program.command("agent-login").description("keep a coding agent's own sign-in (Codex, Claude Code) in seekrit, so every sandbox starts signed in");
8652
+ withTargetFlags(group.command("codex").description("sign Codex in once and store the login (refreshes are saved back on every run)").option("--name <secret>", "secret to store it under", codexAdapter.defaultSecret).option("--device-auth", "sign in with a device code (on a machine with no browser)").option("--from <path>", "store an existing auth.json instead of signing in").option("--move", "with --from: delete the file once it is stored")).action(async (options) => {
8653
+ const adapter = codexAdapter;
8654
+ const name = options.name ?? adapter.defaultSecret;
8655
+ const ctx = buildContext();
8656
+ const target = await resolveEnvTarget(ctx, options);
8657
+ await assertWritable(ctx, target.orgId, target.envId, target.label);
8658
+ let content;
8659
+ if (options.from) content = readLoginFile(adapter, options.from);
8660
+ else {
8661
+ requireBinary("codex", "npm i -g @openai/codex");
8662
+ const home = mkdtempSync(join(tmpdir(), "seekrit-codex-"));
8663
+ try {
8664
+ const args = [
8665
+ "login",
8666
+ "-c",
8667
+ "cli_auth_credentials_store=\"file\""
8668
+ ];
8669
+ if (options.deviceAuth) args.push("--device-auth");
8670
+ if (spawnSync("codex", args, {
8671
+ stdio: "inherit",
8672
+ env: {
8673
+ ...process.env,
8674
+ CODEX_HOME: home
8675
+ }
8676
+ }).status !== 0) fail("`codex login` did not complete — nothing was stored");
8677
+ content = readLoginFile(adapter, join(home, "auth.json"));
8678
+ } finally {
8679
+ rmSync(home, {
8680
+ recursive: true,
8681
+ force: true
8682
+ });
8683
+ }
8684
+ }
8685
+ await storeLoginFile(ctx, target, adapter, name, content);
8686
+ if (options.from) finishFrom(adapter, options.from, options.move);
8687
+ console.error(usage(adapter, name));
8688
+ });
8689
+ withTargetFlags(group.command("claude").description("store a long-lived Claude Code token (`claude setup-token`), or a full login with --from").option("--name <secret>", `secret to store it under (default ${CLAUDE_TOKEN_VAR}, or ${claudeAdapter.defaultSecret} with --from)`).option("--token-stdin", "read the token from stdin instead of running `claude setup-token`").option("--from <path>", "store a full login (.credentials.json) instead — refreshes are saved back on every run").option("--move", "with --from: delete the file once it is stored")).action(async (options) => {
8690
+ const ctx = buildContext();
8691
+ const target = await resolveEnvTarget(ctx, options);
8692
+ if (options.from) {
8693
+ const adapter = claudeAdapter;
8694
+ const name = options.name ?? adapter.defaultSecret;
8695
+ await storeLoginFile(ctx, target, adapter, name, readLoginFile(adapter, options.from));
8696
+ finishFrom(adapter, options.from, options.move);
8697
+ console.error(usage(adapter, name));
8698
+ return;
8699
+ }
8700
+ const name = options.name ?? CLAUDE_TOKEN_VAR;
8701
+ let token;
8702
+ if (options.tokenStdin) token = (await readStdin()).trim();
8703
+ else {
8704
+ requireBinary("claude", "https://claude.com/claude-code");
8705
+ if (!process.stdin.isTTY) fail("pasting the token needs a terminal — pipe it in with --token-stdin instead");
8706
+ if (spawnSync("claude", ["setup-token"], { stdio: "inherit" }).status !== 0) fail("`claude setup-token` did not complete — nothing was stored");
8707
+ token = (await promptHidden("Paste the token `claude setup-token` printed: ")).trim();
8708
+ }
8709
+ const problem = claudeTokenProblem(token);
8710
+ if (problem) fail(`${problem} — nothing was stored`);
8711
+ const queued = await encryptAndSetSecret(ctx, target.orgId, target.envId, name, token);
8712
+ if (queued) {
8713
+ reportQueued(queued);
8714
+ return;
8715
+ }
8716
+ console.error(`stored the Claude Code token in ${target.label} as ${name}`);
8717
+ console.error(name === CLAUDE_TOKEN_VAR ? "run it anywhere with: seekrit run -- claude -p \"…\"" : `Claude Code reads ${CLAUDE_TOKEN_VAR}; name the secret that, or map it in your environment`);
8718
+ });
8719
+ withTargetFlags(group.command("status").description("show the agent logins stored in an environment and when each next refreshes").option("--json", "print as JSON (metadata only — never tokens)")).action(async (options) => {
8720
+ const ctx = buildContext();
8721
+ const target = await resolveEnvTarget(ctx, options);
8722
+ const [{ secrets }, dek] = await Promise.all([ctx.client.listSecrets(target.orgId, target.envId), getDek(ctx, target.orgId, target.envId)]);
8723
+ const now = Date.now();
8724
+ const rows = [];
8725
+ for (const secret of secrets) {
8726
+ if (secret.name === CLAUDE_TOKEN_VAR) {
8727
+ rows.push({
8728
+ name: secret.name,
8729
+ agent: "claude",
8730
+ kind: "long-lived token",
8731
+ version: secret.version,
8732
+ accessExpires: null,
8733
+ refreshing: false,
8734
+ updatedAt: secret.updatedAt
8735
+ });
8736
+ continue;
8737
+ }
8738
+ const envelope = decodeAgentLoginEnvelope(await decryptSecret(dek, secret.ciphertext, secretAad(target.envId, secret.name)));
8739
+ if (!envelope) continue;
8740
+ const expires = (envelope.agent === "codex" ? codexAdapter : claudeAdapter).expiresAt(envelope.content);
8741
+ rows.push({
8742
+ name: secret.name,
8743
+ agent: envelope.agent,
8744
+ kind: "login file",
8745
+ version: secret.version,
8746
+ accessExpires: expires === null ? null : new Date(expires).toISOString(),
8747
+ refreshing: envelope.claim !== null && Date.parse(envelope.claim.until) > now,
8748
+ updatedAt: secret.updatedAt
8749
+ });
8750
+ }
8751
+ emit(options, { logins: rows }, () => printTable(rows, [
8752
+ col("name", (r) => r.name),
8753
+ col("agent", (r) => r.agent),
8754
+ col("kind", (r) => r.kind),
8755
+ col("version", (r) => `v${r.version}`),
8756
+ col("next refresh", (r) => {
8757
+ if (r.refreshing) return "refreshing now";
8758
+ if (r.accessExpires === null) return "never";
8759
+ const due = Date.parse(r.accessExpires) - REFRESH_LEAD_MS;
8760
+ return due <= now ? "on next run" : new Date(due).toISOString();
8761
+ })
8762
+ ], `no agent logins in ${target.label} — add one with \`seekrit agent-login codex\` or \`seekrit agent-login claude\``));
8763
+ });
8764
+ }
8765
+ // src/agents.ts
8766
+ /**
8767
+ * Agent access governance from the CLI.
8768
+ *
8769
+ * Policy lived only in the dashboard until now, which was backwards: the people
8770
+ * deploying agents work in a terminal and a repo, and a rule set is exactly the
8771
+ * kind of thing that wants review and version control. So the loop this module
8772
+ * exists for is:
8773
+ *
8774
+ * seekrit agents policy pull nova -o nova.policy.json # current rules
8775
+ * $EDITOR nova.policy.json # commit it, review it
8776
+ * seekrit agents policy publish nova -f nova.policy.json
8777
+ *
8778
+ * **Signing still happens here, on this machine, with the operator's own key**
8779
+ * (`getPolicySigner` in `context.ts`), so nothing about the trust argument in
8780
+ * `docs/agent-access-governance.md` §1 changes: the API receives an opaque
8781
+ * envelope it cannot forge, and a proxy verifies it against thumbprints pinned in
8782
+ * its own local file. A service token is refused — an agent that can publish its
8783
+ * own policy is not governed by it.
8784
+ *
8785
+ * Everything else here is read-only, and two commands are deliberately *local*
8786
+ * evaluations rather than API calls:
8787
+ *
8788
+ * - `simulate` runs `evaluatePolicy` from `@seekrit/core`, the mirror of
8789
+ * `RuleSet::decide` in the proxy — so a dry run and a real refusal give the
8790
+ * same verdict in the same words.
8791
+ * - `fetch` asks for the bytes a proxy would get (`GET /v1/agents/:ref/policy`)
8792
+ * and re-derives the signer thumbprint from the bundle itself, rather than
8793
+ * trusting the field the API echoes beside it.
8794
+ */
8795
+ /** How many published versions the API returns per page. */
8796
+ const POLICY_PAGE = 50;
8797
+ /** The `-` sentinel every other seekrit command uses to mean stdin. */
8798
+ const STDIN = "-";
8799
+ /**
8800
+ * Resolve `nova`, or `agt_…`, to an identity.
8801
+ *
8802
+ * The org-scoped API routes take an id, but nobody types ids — so a slug is
8803
+ * resolved from the list. The list is small (agents are a per-deployment thing,
8804
+ * not a per-request one), which is why this costs one request rather than
8805
+ * needing a lookup route.
8806
+ */
8807
+ async function resolveAgent(ctx, orgId, ref) {
8808
+ const { agents } = await ctx.client.listAgents(orgId);
8809
+ const found = agents.find((a) => a.slug === ref || a.id === ref);
8810
+ if (!found) {
8811
+ const known = agents.map((a) => a.slug).join(", ");
8812
+ fail(`no agent "${ref}"${known ? ` — this org has: ${known}` : " in this org"}`);
8813
+ }
8814
+ return found;
8815
+ }
8816
+ /**
8817
+ * One published version: the newest by default, or an explicit `--version`.
8818
+ *
8819
+ * An agent with no published policy is a real state, not an error — a proxy for
8820
+ * it fails closed — so the caller decides how to report it.
8821
+ */
8822
+ async function loadVersion(ctx, orgId, agent, version) {
8823
+ const { policies } = await ctx.client.listAgentPolicies(orgId, agent.id);
8824
+ if (version === void 0) return policies[0] ?? null;
8825
+ const found = policies.find((p) => p.version === version);
8826
+ 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"}`);
8827
+ return found;
8828
+ }
8829
+ /**
8830
+ * Read a rule file: either a bare array or `{ "rules": [...] }`, from a path or
8831
+ * stdin. Both shapes are accepted because `policy pull` writes the second and
8832
+ * hand-written files tend to be the first.
8833
+ *
8834
+ * Validation is the same zod schema the API and the dashboard use, so a file
8835
+ * rejected here would have been rejected there — before anything is signed.
8836
+ *
8837
+ * Exported for its own test: this is the one place a hand-written file meets the
8838
+ * schema, and its error messages are the whole user experience of a typo.
8839
+ */
8840
+ function parseRuleFile(raw, source) {
8841
+ let parsed;
8842
+ try {
8843
+ parsed = JSON.parse(raw);
8844
+ } catch (err) {
8845
+ fail(`${source} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
8846
+ }
8847
+ 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;
8848
+ if (!Array.isArray(list)) fail(`${source}: "rules" must be an array`);
8849
+ if (list.length === 0) console.error(`note: ${source} has no rules — publishing it denies every request`);
8850
+ return list.map((rule, i) => {
8851
+ const result = agentPolicyRuleSchema.safeParse(rule);
8852
+ if (!result.success) {
8853
+ const first = result.error.issues[0];
8854
+ fail(`${source}: rule ${i + 1} is invalid — ${first?.path.join(".") || "rule"}: ${first?.message ?? "unknown error"}`);
8855
+ }
8856
+ return result.data;
8857
+ });
8858
+ }
8859
+ /** `--file path` or `--file -`. */
8860
+ async function readRuleSource(file) {
8861
+ if (file === STDIN) return {
8862
+ raw: await readStdin(),
8863
+ label: "stdin"
8864
+ };
8865
+ try {
8866
+ return {
8867
+ raw: readFileSync(file, "utf8"),
8868
+ label: file
8869
+ };
8870
+ } catch (err) {
8871
+ return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
8872
+ }
8873
+ }
8874
+ /**
8875
+ * An empty `methods` or `paths` list means *any*: those fields narrow a rule that
8876
+ * already matched its host, so absent means unconstrained. A blank column would
8877
+ * read as the opposite, so it renders as `any`.
8878
+ *
8879
+ * Exported for its own test — this is a real invariant of the format, not a
8880
+ * cosmetic choice.
8881
+ */
8882
+ function describeList(values) {
8883
+ return values.length === 0 ? "any" : values.join(" ");
8884
+ }
8885
+ /**
8886
+ * `allow` is the field where empty means the **opposite** — no secret may be
8887
+ * injected toward this host at all (`Decision::SecretNotAllowed` for every name;
8888
+ * see `crates/seekrit-core/src/policy.rs`). Such a rule is useful and
8889
+ * intentional: it permits the *request* while granting no credential, which is
8890
+ * how you let an agent read a public API through the proxy without handing it a
8891
+ * key. Rendering that as `any` would invert the meaning of the one column an
8892
+ * operator reviews most carefully, so it gets its own function.
8893
+ */
8894
+ function describeSecrets(values) {
8895
+ return values.length === 0 ? "none" : values.join(" ");
8896
+ }
8897
+ /**
8898
+ * The third convention, and the one most likely to be misread: **no ports means
8899
+ * HTTPS on 443**, not any port. A blank or `any` here would say the opposite of
8900
+ * what the proxy enforces, so the default is spelled out.
8901
+ */
8902
+ function describePorts(rule) {
8903
+ return describePolicyPorts(rule) ?? "https:443";
8904
+ }
8905
+ function printRules(rules) {
8906
+ printTable(rules.map((rule, i) => ({
8907
+ n: i + 1,
8908
+ rule
8909
+ })), [
8910
+ col("#", (r) => r.n),
8911
+ col("host", (r) => r.rule.host),
8912
+ col("ports", (r) => describePorts(r.rule)),
8913
+ col("methods", (r) => describeList(r.rule.methods)),
8914
+ col("paths", (r) => describeList(r.rule.paths)),
8915
+ col("secrets", (r) => describeSecrets(r.rule.allow)),
8916
+ col("label", (r) => r.rule.label ?? "-")
8917
+ ], "no rules — this policy denies every request");
8918
+ }
8919
+ /** One rule as a single line, for the publish diff. */
8920
+ function ruleLine(rule) {
8921
+ const ports = describePolicyPorts(rule);
8922
+ const parts = [
8923
+ ports ? `${rule.host} [${ports}]` : rule.host,
8924
+ describeList(rule.methods),
8925
+ describeList(rule.paths),
8926
+ `secrets=${describeSecrets(rule.allow)}`
8927
+ ];
8928
+ return rule.label ? `${parts.join(" ")} (${rule.label})` : parts.join(" ");
8929
+ }
8930
+ /**
8931
+ * The change a publish would make. Rules are compared by *position* because
8932
+ * order decides — first match wins — so a reordering is a real change and shows
8933
+ * up as one.
8934
+ */
8935
+ function printPolicyDiff(before, after) {
8936
+ const changes = diffPolicyRules(before, after).filter((c) => c.kind !== "unchanged");
8937
+ if (changes.length === 0) {
8938
+ console.error(after.length === 0 ? "no rules — this policy denies every request" : "no rule changes — publishing would only extend the expiry");
8939
+ return;
8940
+ }
8941
+ for (const change of changes) {
8942
+ const n = change.index + 1;
8943
+ if (change.kind === "added") console.log(`+ ${n} ${ruleLine(change.after)}`);
8944
+ else if (change.kind === "removed") console.log(`- ${n} ${ruleLine(change.before)}`);
8945
+ else {
8946
+ console.log(`- ${n} ${ruleLine(change.before)}`);
8947
+ console.log(`+ ${n} ${ruleLine(change.after)}`);
8948
+ }
8949
+ }
8950
+ }
8951
+ /** Seconds until an ISO instant, or a negative number when it has passed. */
8952
+ function secondsUntil(iso) {
8953
+ return Math.round((new Date(iso).getTime() - Date.now()) / 1e3);
8954
+ }
8955
+ /** Collect a repeated `--scope NAME` into a list. */
8956
+ function collectScope(value, acc = []) {
8957
+ acc.push(value);
8958
+ return acc;
8959
+ }
8960
+ /**
8961
+ * A task's state, derived rather than stored: `revoked` beats `expired`, so a run
8962
+ * somebody killed does not read as one that merely lapsed.
8963
+ *
8964
+ * Exported for its own test — it mirrors `taskState` in
8965
+ * `apps/api/src/lib/agent-tasks.ts`, and the two disagreeing would mean the list
8966
+ * screen and the enforcement point describe the same run differently.
8967
+ */
8968
+ function taskStateOf(task, now) {
8969
+ if (task.revokedAt) return "revoked";
8970
+ return Date.parse(task.expiresAt) <= now ? "expired" : "active";
8971
+ }
8972
+ /**
8973
+ * A detail view on **stderr**, so a command whose stdout is a credential can
8974
+ * still explain itself to a person without corrupting `$(…)`.
8975
+ */
8976
+ function printFieldsToStderr(fields) {
8977
+ const present = fields.filter(([, value]) => value !== null && value !== void 0);
8978
+ const width = Math.max(...present.map(([label]) => label.length));
8979
+ for (const [label, value] of present) console.error(`${label.padEnd(width)} ${value}`);
8980
+ }
8981
+ /**
8982
+ * Exported for its own test: the expired branch is the one that must be loud.
8983
+ *
8984
+ * The granularity has to span both users of this — a policy bundle lasting a
8985
+ * week and a task lasting fifteen minutes. Rounding a task to the nearest hour
8986
+ * printed "in 0h", which is worse than no estimate at all.
8987
+ */
8988
+ function describeExpiry(iso) {
8989
+ const seconds = secondsUntil(iso);
8990
+ if (seconds <= 0) return `${iso} (EXPIRED — proxies for this agent fail closed)`;
8991
+ const days = Math.floor(seconds / 86400);
8992
+ const hours = Math.floor(seconds % 86400 / 3600);
8993
+ const minutes = Math.floor(seconds % 3600 / 60);
8994
+ if (days > 0) return `${iso} (in ${days}d ${hours}h)`;
8995
+ if (hours > 0) return `${iso} (in ${hours}h ${minutes}m)`;
8996
+ if (minutes > 0) return `${iso} (in ${minutes}m)`;
8997
+ return `${iso} (in ${seconds}s)`;
8998
+ }
8999
+ function registerAgentCommands(program) {
9000
+ const agents = program.command("agents").description("agent access policy — which agent may reach which upstream with which secret");
9001
+ agents.command("list", { isDefault: true }).description("list agent identities").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (options) => {
9002
+ const ctx = buildContext();
9003
+ const org = await resolveOrg(ctx, options.org);
9004
+ const { agents: rows } = await ctx.client.listAgents(org.id);
9005
+ emit(options, { agents: rows }, () => printTable(rows, [
9006
+ col("slug", (a) => a.slug),
9007
+ col("name", (a) => a.name),
9008
+ col("policy", (a) => a.currentPolicyVersion === 0 ? "none" : `v${a.currentPolicyVersion}`),
9009
+ col("enabled", (a) => a.enabled ? "yes" : "no"),
9010
+ col("last fetch", (a) => a.lastPolicyFetchAt ?? "never")
9011
+ ], "no agent identities — create one with `seekrit agents create`"));
9012
+ });
9013
+ 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) => {
9014
+ const ctx = buildContext();
9015
+ const org = await resolveOrg(ctx, options.org);
9016
+ const environmentId = options.env ? (await resolveAppEnv(ctx, {
9017
+ org: options.org,
9018
+ app: options.app,
9019
+ env: options.env
9020
+ })).envId : void 0;
9021
+ const { agent } = await ctx.client.createAgent(org.id, {
9022
+ name,
9023
+ slug: options.slug,
9024
+ ...environmentId ? { environmentId } : {}
9025
+ });
9026
+ emit(options, { agent }, () => {
9027
+ console.error(`created ${agent.slug} — no policy published yet, so any proxy for it fails closed`);
9028
+ console.error(`next: seekrit agents policy publish ${agent.slug} -f rules.json`);
9029
+ });
9030
+ });
9031
+ agents.command("show <ref>").description("an identity and its live rules").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
9032
+ const ctx = buildContext();
9033
+ const org = await resolveOrg(ctx, options.org);
9034
+ const agent = await resolveAgent(ctx, org.id, ref);
9035
+ const { environment } = await ctx.client.getAgent(org.id, agent.id);
9036
+ const live = await loadVersion(ctx, org.id, agent);
9037
+ emit(options, {
9038
+ agent,
9039
+ environment,
9040
+ policy: live
9041
+ }, () => {
9042
+ printFields([
9043
+ ["name", agent.name],
9044
+ ["slug", agent.slug],
9045
+ ["id", agent.id],
9046
+ ["environment", environment ? environment.slug : "any in the org"],
9047
+ ["enabled", agent.enabled ? "yes" : "no"],
9048
+ ["policy", live ? `v${live.version}` : "none published"],
9049
+ ["signer", live?.signerThumbprint],
9050
+ ["expires", live ? describeExpiry(live.expiresAt) : void 0],
9051
+ ["last fetch", agent.lastPolicyFetchAt ?? "never"]
9052
+ ]);
9053
+ if (live) {
9054
+ section(`rules (v${live.version})`);
9055
+ printRules(live.rules);
9056
+ section("hosts a forward proxy would intercept");
9057
+ console.log(policyHosts(live.rules).join("\n") || "none");
9058
+ }
9059
+ });
9060
+ });
9061
+ 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) => {
9062
+ const ctx = buildContext();
9063
+ const org = await resolveOrg(ctx, options.org);
9064
+ const agent = await resolveAgent(ctx, org.id, ref);
9065
+ await confirmDestructive(options.yes, `Disable ${agent.slug}? Running proxies keep their current bundle until it expires.`);
9066
+ await ctx.client.updateAgent(org.id, agent.id, { enabled: false });
9067
+ console.error(`${agent.slug} disabled — its next policy refresh is refused`);
9068
+ });
9069
+ agents.command("enable <ref>").description("serve this identity's policy again").option("--org <slug>", "organization").action(async (ref, options) => {
9070
+ const ctx = buildContext();
9071
+ const org = await resolveOrg(ctx, options.org);
9072
+ const agent = await resolveAgent(ctx, org.id, ref);
9073
+ await ctx.client.updateAgent(org.id, agent.id, { enabled: true });
9074
+ console.error(`${agent.slug} enabled`);
9075
+ });
9076
+ 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) => {
9077
+ const ctx = buildContext();
9078
+ const org = await resolveOrg(ctx, options.org);
9079
+ const agent = await resolveAgent(ctx, org.id, ref);
9080
+ await confirmDestructive(options.yes, `Delete ${agent.slug} and all ${agent.currentPolicyVersion} published version(s)? The audit_log rows survive.`);
9081
+ await ctx.client.deleteAgent(org.id, agent.id);
9082
+ console.error(`${agent.slug} deleted`);
9083
+ });
9084
+ /**
9085
+ * The trust anchor. This prints a *public* key fingerprint — the security of
9086
+ * pinning comes from the operator putting it in a local file the API cannot
9087
+ * reach, not from this command being honest. Which is why it prints the TOML
9088
+ * line to paste rather than offering to write the config.
9089
+ */
9090
+ 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) => {
9091
+ const ctx = buildContext();
9092
+ const org = await resolveOrg(ctx, options.org);
9093
+ const { signer } = await ctx.client.getMyPolicySigner(org.id);
9094
+ if (!signer) fail("your account has no keypair yet — run `seekrit keys setup`");
9095
+ emit(options, { signer }, () => {
9096
+ printFields([["thumbprint", signer.thumbprint], ["user", signer.userId]]);
9097
+ section("pin it in seekrit-proxy.toml");
9098
+ console.log("[policy]");
9099
+ console.log(`signers = ["${signer.thumbprint}"]`);
9100
+ console.error("");
9101
+ console.error("pin a second admin's key too — one pinned signer means one lost passphrase leaves nobody able to publish");
9102
+ });
9103
+ });
9104
+ /**
9105
+ * The token is printed to **stdout** and everything else to stderr, so the
9106
+ * common thing works without parsing:
9107
+ *
9108
+ * TASK=$(seekrit agents dispatch nova --scope GITHUB_TOKEN --ttl 15m)
9109
+ *
9110
+ * It is shown exactly once. The API stores only a hash, so a lost token cannot
9111
+ * be recovered — dispatch another and revoke the first.
9112
+ */
9113
+ 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) => {
9114
+ const ctx = buildContext();
9115
+ const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : 900;
9116
+ if (ttlSeconds < 60 || ttlSeconds > 43200) fail(`--ttl must be between 60s and ${TASK_MAX_TTL_SECONDS / 3600}h`);
9117
+ const minted = await createAgentTaskToken();
9118
+ const { task, header } = await ctx.client.dispatchAgentTask(ref, {
9119
+ taskRef: minted.taskRef,
9120
+ tokenHash: minted.tokenHash,
9121
+ ttlSeconds,
9122
+ ...options.scope ? { scopes: options.scope } : {},
9123
+ ...options.label ? { label: options.label } : {}
9124
+ });
9125
+ if (options.json) {
9126
+ console.log(JSON.stringify({
9127
+ task,
9128
+ header,
9129
+ token: minted.token
9130
+ }, null, 2));
9131
+ return;
9132
+ }
9133
+ console.log(minted.token);
9134
+ printFieldsToStderr([
9135
+ ["task", task.id],
9136
+ ["agent", ref],
9137
+ ["scopes", task.scopes ? describeSecrets(task.scopes) : "the agent's full policy"],
9138
+ ["policy", `v${task.policyVersion}`],
9139
+ ["expires", describeExpiry(task.expiresAt)],
9140
+ ["header", header]
9141
+ ]);
9142
+ });
9143
+ 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) => {
9144
+ const ctx = buildContext();
9145
+ const org = await resolveOrg(ctx, options.org);
9146
+ const agent = await resolveAgent(ctx, org.id, ref);
9147
+ const { tasks } = await ctx.client.listAgentTasks(org.id, agent.id);
9148
+ const now = Date.now();
9149
+ const rows = options.all ? tasks : tasks.filter((t) => taskStateOf(t, now) === "active");
9150
+ emit(options, { tasks: rows }, () => printTable(rows, [
9151
+ col("task", (t) => t.id),
9152
+ col("state", (t) => taskStateOf(t, now)),
9153
+ col("scopes", (t) => t.scopes ? describeSecrets(t.scopes) : "policy"),
9154
+ col("policy", (t) => `v${t.policyVersion}`),
9155
+ col("expires", (t) => t.expiresAt),
9156
+ col("last seen", (t) => t.lastSeenAt ?? "never"),
9157
+ col("label", (t) => t.label ?? "-")
9158
+ ], options.all ? "no runs dispatched yet" : "no live runs (try --all)"));
9159
+ });
9160
+ agents.command("revoke <taskId>").description("end a run's authority now").action(async (taskId) => {
9161
+ const { task } = await buildContext().client.revokeAgentTask(taskId);
9162
+ console.error(`${task.id} revoked — enforcement points refuse it at their next introspection`);
9163
+ });
9164
+ registerPolicyCommands(agents.command("policy").description("published policy versions (`seekrit agents policy --help`)"));
9165
+ 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) => {
9166
+ const ctx = buildContext();
9167
+ const org = await resolveOrg(ctx, options.org);
9168
+ const agent = await resolveAgent(ctx, org.id, ref);
9169
+ const days = Number(options.days ?? 14);
9170
+ if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
9171
+ const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
9172
+ emit(options, {
9173
+ activity,
9174
+ summary
9175
+ }, () => {
9176
+ printFields([
9177
+ ["window", `${days}d`],
9178
+ ["allowed", summary.allowed],
9179
+ ["denied", summary.denied],
9180
+ ["hosts", summary.hosts],
9181
+ ["earliest", summary.from ?? "no activity reported"]
9182
+ ]);
9183
+ if (activity.length === 0) {
9184
+ console.error("");
9185
+ console.error("nothing reported — add an [activity] block to the proxy (`seekrit proxy init --activity`)");
9186
+ return;
9187
+ }
9188
+ section("decisions");
9189
+ printTable(activity, [
9190
+ col("host", (a) => a.host),
9191
+ col("method", (a) => a.method),
9192
+ col("decision", (a) => a.decision),
9193
+ col("rule", (a) => a.ruleIndex === null ? "-" : String(a.ruleIndex + 1)),
9194
+ col("count", (a) => a.count),
9195
+ col("secrets", (a) => a.secrets ? Object.entries(a.secrets).map(([name, n]) => `${name}×${n}`).join(" ") : "-")
9196
+ ]);
9197
+ });
9198
+ });
9199
+ /**
9200
+ * The grant review loop. Proposals are computed *here*, from activity the API
9201
+ * served — the API is deliberately not in the business of saying what a policy
9202
+ * should be. And `--out` writes a rule file rather than publishing: the change
9203
+ * still goes through `policy publish`, which still needs a human's key.
9204
+ */
9205
+ 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) => {
9206
+ const ctx = buildContext();
9207
+ const org = await resolveOrg(ctx, options.org);
9208
+ const agent = await resolveAgent(ctx, org.id, ref);
9209
+ const days = Number(options.days ?? 14);
9210
+ if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
9211
+ const live = await loadVersion(ctx, org.id, agent);
9212
+ if (!live) fail(`${agent.slug} has no published policy to review`);
9213
+ const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
9214
+ const proposals = reviewPolicy({
9215
+ rules: live.rules,
9216
+ activity
9217
+ });
9218
+ emit(options, {
9219
+ proposals,
9220
+ summary,
9221
+ version: live.version
9222
+ }, () => {
9223
+ console.error(`${agent.slug} — policy v${live.version}, ${summary.allowed + summary.denied} decision(s) over ${days}d`);
9224
+ if (activity.length === 0) {
9225
+ console.error("");
9226
+ console.error("no activity reported yet — nothing to review against");
9227
+ return;
9228
+ }
9229
+ if (proposals.length === 0) {
9230
+ console.error("");
9231
+ console.error("no changes proposed: every rule is in use and nothing was refused");
9232
+ return;
9233
+ }
9234
+ section("proposals");
9235
+ for (const p of proposals) {
9236
+ const marker = p.applicable ? "-" : "?";
9237
+ console.log(`${marker} ${p.rationale}`);
9238
+ }
9239
+ const applicable = countApplicable(proposals);
9240
+ console.error("");
9241
+ console.error(`${applicable} narrowing change(s) can be applied; ${proposals.length - applicable} need a human decision`);
9242
+ });
9243
+ if (options.out) {
9244
+ const narrowed = applyProposals(live.rules, proposals);
9245
+ writeFileSync(options.out, `${JSON.stringify({ rules: narrowed }, null, 2)}\n`);
9246
+ console.error(`wrote ${narrowed.length} rule(s) to ${options.out} — review the diff, then: seekrit agents policy publish ${agent.slug} -f ${options.out}`);
9247
+ console.error("widening proposals are never applied: an agent must not earn permissions by retrying");
9248
+ }
9249
+ });
9250
+ 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("--scheme <scheme>", "https or http, as the agent dials it (forward proxy)", "https").option("--port <n>", "port the agent dials (forward proxy; default 443 for https, 80 for http)").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) => {
9251
+ const scheme = options.scheme.toLowerCase();
9252
+ if (scheme !== "https" && scheme !== "http") fail("--scheme must be https or http");
9253
+ const port = options.port === void 0 ? scheme === "https" ? 443 : 80 : Number(options.port);
9254
+ if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--port must be 1-65535");
9255
+ const ctx = buildContext();
9256
+ const org = await resolveOrg(ctx, options.org);
9257
+ const agent = await resolveAgent(ctx, org.id, ref);
9258
+ const version = options.version === void 0 ? void 0 : Number(options.version);
9259
+ if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
9260
+ const published = await loadVersion(ctx, org.id, agent, version);
9261
+ if (!published) fail(`${agent.slug} has no published policy — every request is denied`);
9262
+ const verdict = evaluatePolicy(published.rules, {
9263
+ host: options.host,
9264
+ transport: {
9265
+ scheme,
9266
+ port
9267
+ },
9268
+ method: options.method,
9269
+ path: options.path,
9270
+ ...options.secret === void 0 ? {} : { secret: options.secret }
9271
+ });
9272
+ const reason = describePolicyVerdict(verdict, published.rules);
9273
+ emit(options, {
9274
+ version: published.version,
9275
+ verdict,
9276
+ reason
9277
+ }, () => {
9278
+ const target = `${options.method.toUpperCase()} ${scheme}://${options.host}:${port}${options.path}`;
9279
+ console.log(`${verdict.decision === "allow" ? "allow" : "DENY"} ${target}${options.secret ? ` [${options.secret}]` : ""}`);
9280
+ console.log(` v${published.version}: ${reason}`);
9281
+ });
9282
+ if (verdict.decision !== "allow") process.exitCode = 1;
9283
+ });
9284
+ }
9285
+ function registerPolicyCommands(policy) {
9286
+ policy.command("list <ref>").description("published versions, newest first").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
9287
+ const ctx = buildContext();
9288
+ const org = await resolveOrg(ctx, options.org);
9289
+ const agent = await resolveAgent(ctx, org.id, ref);
9290
+ const { policies } = await ctx.client.listAgentPolicies(org.id, agent.id);
9291
+ emit(options, { policies }, () => printTable(policies, [
9292
+ col("version", (p) => `v${p.version}`),
9293
+ col("rules", (p) => p.ruleCount),
9294
+ col("signer", (p) => p.signerThumbprint.slice(0, 12)),
9295
+ col("published", (p) => p.publishedAt),
9296
+ col("expires", (p) => secondsUntil(p.expiresAt) <= 0 ? `${p.expiresAt} (expired)` : p.expiresAt),
9297
+ col("note", (p) => p.rolledBackFromVersion ? `restored v${p.rolledBackFromVersion}` : "-")
9298
+ ], "nothing published yet"));
9299
+ });
9300
+ 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) => {
9301
+ const ctx = buildContext();
9302
+ const org = await resolveOrg(ctx, options.org);
9303
+ const agent = await resolveAgent(ctx, org.id, ref);
9304
+ const version = options.version === void 0 ? void 0 : Number(options.version);
9305
+ if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
9306
+ const published = await loadVersion(ctx, org.id, agent, version);
9307
+ if (!published) fail(`${agent.slug} has no published policy`);
9308
+ emit(options, { policy: published }, () => {
9309
+ printFields([
9310
+ ["version", `v${published.version}`],
9311
+ ["signer", published.signerThumbprint],
9312
+ ["published", published.publishedAt],
9313
+ ["expires", describeExpiry(published.expiresAt)]
9314
+ ]);
9315
+ section("rules");
9316
+ printRules(published.rules);
9317
+ });
9318
+ });
9319
+ /**
9320
+ * Write the live rules out as the editable source for the next publish. This
9321
+ * is the half that makes policy-as-code work: what comes back out is exactly
9322
+ * the shape `publish` takes in, so a round trip is a no-op diff.
9323
+ */
9324
+ 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) => {
9325
+ const ctx = buildContext();
9326
+ const org = await resolveOrg(ctx, options.org);
9327
+ const agent = await resolveAgent(ctx, org.id, ref);
9328
+ const version = options.version === void 0 ? void 0 : Number(options.version);
9329
+ if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
9330
+ const published = await loadVersion(ctx, org.id, agent, version);
9331
+ const rules = published?.rules ?? [];
9332
+ const body = `${JSON.stringify({ rules }, null, 2)}\n`;
9333
+ if (options.out) {
9334
+ writeFileSync(options.out, body);
9335
+ 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)`);
9336
+ return;
9337
+ }
9338
+ process.stdout.write(body);
9339
+ });
9340
+ 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) => {
9341
+ const ctx = buildContext();
9342
+ 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.");
9343
+ const org = await resolveOrg(ctx, options.org);
9344
+ const agent = await resolveAgent(ctx, org.id, ref);
9345
+ const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : POLICY_DEFAULT_TTL_SECONDS;
9346
+ if (ttlSeconds < 3600 || ttlSeconds > 7776e3) fail(`--ttl must be between ${POLICY_MIN_TTL_SECONDS / 3600}h and ${POLICY_MAX_TTL_SECONDS / 86400}d`);
9347
+ const { raw, label } = await readRuleSource(options.file);
9348
+ const rules = parseRuleFile(raw, label);
9349
+ const live = await loadVersion(ctx, org.id, agent);
9350
+ const nextVersion = agent.currentPolicyVersion + 1;
9351
+ const from = agent.currentPolicyVersion === 0 ? "nothing" : `v${agent.currentPolicyVersion}`;
9352
+ console.error(`${agent.slug}: ${from} → v${nextVersion}`);
9353
+ printPolicyDiff(live?.rules ?? [], rules);
9354
+ await confirmDestructive(options.yes, `Publish v${nextVersion} for ${agent.slug} (${rules.length} rule(s), valid ${options.ttl ?? "7d"})?`);
9355
+ const { signingKey, jwk } = await getPolicySigner(ctx);
9356
+ const issuedAt = Math.floor(Date.now() / 1e3);
9357
+ const bundle = await signAgentPolicy(signingKey, jwk, {
9358
+ v: policyBundleVersionFor(rules),
9359
+ org: org.id,
9360
+ agent: agent.id,
9361
+ agent_slug: agent.slug,
9362
+ policy_version: nextVersion,
9363
+ issued_at: issuedAt,
9364
+ expires_at: issuedAt + ttlSeconds,
9365
+ rules
9366
+ });
9367
+ const { policy: published } = await ctx.client.publishAgentPolicy(org.id, agent.id, bundle);
9368
+ emit(options, { policy: published }, () => {
9369
+ console.error(`published v${published.version} — signed with ${published.signerThumbprint.slice(0, 12)}…, expires ${published.expiresAt}`);
9370
+ console.error("proxies pick it up at their next refresh");
9371
+ });
9372
+ });
9373
+ 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) => {
9374
+ const ctx = buildContext();
9375
+ const org = await resolveOrg(ctx, options.org);
9376
+ const agent = await resolveAgent(ctx, org.id, ref);
9377
+ const version = Number(versionArg);
9378
+ if (!Number.isInteger(version) || version < 1) fail("version must be a positive integer");
9379
+ const source = await loadVersion(ctx, org.id, agent, version);
9380
+ if (!source) fail(`no version ${version}`);
9381
+ 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`);
9382
+ await confirmDestructive(options.yes, `Roll ${agent.slug} back to v${version} (${source.ruleCount} rule(s))?`);
9383
+ const { policy: published } = await ctx.client.rollbackAgentPolicy(org.id, agent.id, version);
9384
+ console.error(`published v${published.version} carrying v${version}'s bundle`);
9385
+ });
9386
+ /**
9387
+ * What a proxy actually gets. Useful when a deployment misbehaves and the
9388
+ * question is whether the API, the pin, or the rules are at fault — so it
9389
+ * re-derives the thumbprint from the bundle rather than reporting the one the
9390
+ * API sends beside it, and says plainly when the two disagree.
9391
+ */
9392
+ 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) => {
9393
+ const fetched = await buildContext().client.getAgentPolicyBundle(ref);
9394
+ if (options.bundle) {
9395
+ process.stdout.write(`${fetched.bundle}\n`);
9396
+ return;
8407
9397
  }
8408
- }
8409
- return loaded;
8410
- }
8411
- /**
8412
- * Print a name → source table to stderr (never the secret values). Names whose
8413
- * value had references expanded are marked, and dangling references are called
8414
- * out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
8415
- * deliberately passed through as literal text.
8416
- */
8417
- function printExplain(provenance, refs = {}) {
8418
- const interpolated = new Set(refs.interpolated ?? []);
8419
- const names = Object.keys(provenance).sort();
8420
- const width = names.reduce((w, n) => Math.max(w, n.length), 0);
8421
- for (const name of names) {
8422
- const marker = interpolated.has(name) ? " (interpolated)" : "";
8423
- process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
8424
- }
8425
- if (refs.unresolved?.length) process.stderr.write(`\nunresolved reference(s), left as literal text: ${refs.unresolved.join(", ")}\n`);
9398
+ const body = parseAgentPolicyUnverified(fetched.bundle);
9399
+ const derived = await policySignerThumbprint(body.signer.jwk);
9400
+ emit(options, {
9401
+ ...fetched,
9402
+ derivedThumbprint: derived,
9403
+ rules: body.rules
9404
+ }, () => {
9405
+ printFields([
9406
+ ["agent", `${fetched.agent.slug} (${fetched.agent.name})`],
9407
+ ["version", `v${fetched.version}`],
9408
+ ["expires", describeExpiry(fetched.expiresAt)],
9409
+ ["signer (in bundle)", derived],
9410
+ ["signer (reported)", fetched.signerThumbprint === derived ? "matches" : `${fetched.signerThumbprint} — DOES NOT MATCH the bundle; a proxy would refuse this`]
9411
+ ]);
9412
+ section("rules");
9413
+ printRules(body.rules);
9414
+ console.error("");
9415
+ console.error("this command does not verify the signature — only a pinned signer list can, which lives in the proxy's own config");
9416
+ });
9417
+ });
8426
9418
  }
8427
9419
  // src/env-diff.ts
8428
9420
  /**
@@ -10798,132 +11790,6 @@ function registerBranchCommands(program) {
10798
11790
  console.error(`deleted branch ${app.slug}#${target.slug}`);
10799
11791
  });
10800
11792
  }
10801
- // src/changes.ts
10802
- /**
10803
- * Change requests: the review queue in front of protected environments, and the
10804
- * protection settings that create it.
10805
- *
10806
- * Reviewing here is keyless by construction — a change request carries
10807
- * ciphertext, so the CLI can show a reviewer *what* is changing and who
10808
- * proposed it without ever holding a DEK. `seekrit changes show` therefore
10809
- * prints no value, and deliberately does not offer to.
10810
- */
10811
- function registerChangeCommands(program) {
10812
- const changes = program.command("changes").description("review changes queued for protected environments");
10813
- changes.command("list", { isDefault: true }).alias("ls").description("list change requests awaiting review").option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--status <status>", `filter by status (${CHANGE_REQUEST_STATUSES.join(", ")})`, "open").option("--all", "every status, not just open").option("--limit <n>", "entries to show (max 200)", "50").option("--json", "print the raw API response").action(async (options) => {
10814
- const ctx = buildContext();
10815
- const ref = await resolveOrg(ctx, options.org);
10816
- const status = options.all ? void 0 : parseStatus(options.status);
10817
- const limit = Number(options.limit);
10818
- if (!Number.isInteger(limit) || limit < 1) fail("--limit must be a positive whole number");
10819
- const { changeRequests } = await ctx.client.listChangeRequests(ref.id, {
10820
- status,
10821
- limit: Math.min(limit, 200)
10822
- });
10823
- emit(options, { changeRequests }, () => {
10824
- printTable(changeRequests, [
10825
- col("id", (r) => r.id),
10826
- col("change", (r) => describeChange(r.kind, r.secretName, r.restoreFromVersion)),
10827
- col("status", (r) => r.status),
10828
- col("proposed by", (r) => `${r.requestedByType}:${r.requestedById}`),
10829
- col("when", (r) => r.createdAt)
10830
- ], options.all ? "no change requests" : "nothing waiting for review");
10831
- });
10832
- });
10833
- changes.command("show <id>").description("show one change request and its reviews (never its value)").option("--org <slug>", "organization slug").option("--json", "print the raw API response").action(async (id, options) => {
10834
- const ctx = buildContext();
10835
- const ref = await resolveOrg(ctx, options.org);
10836
- const { changeRequest, reviews } = await ctx.client.getChangeRequest(ref.id, id);
10837
- emit(options, {
10838
- changeRequest,
10839
- reviews
10840
- }, () => {
10841
- printFields([
10842
- ["id", changeRequest.id],
10843
- ["change", describeChange(changeRequest.kind, changeRequest.secretName, changeRequest.restoreFromVersion)],
10844
- ["environment", changeRequest.environmentId],
10845
- ["status", changeRequest.status],
10846
- ["base version", changeRequest.baseVersion ?? "(new secret)"],
10847
- ["approvals needed", changeRequest.requiredApprovals],
10848
- ["proposed by", `${changeRequest.requestedByType}:${changeRequest.requestedById}`],
10849
- ["note", changeRequest.note ?? "-"]
10850
- ]);
10851
- section("reviews");
10852
- printTable(reviews, [
10853
- col("decision", (r) => r.decision),
10854
- col("reviewer", (r) => r.reviewerId ?? "(removed)"),
10855
- col("comment", (r) => r.comment ?? "-"),
10856
- col("when", (r) => r.createdAt)
10857
- ], "no reviews yet");
10858
- });
10859
- });
10860
- changes.command("approve <id>").description("approve a queued change (applies it once it has enough approvals)").option("--org <slug>", "organization slug").option("-m, --comment <text>", "note recorded with your approval").option("--json", "print the raw API response").action(async (id, options) => {
10861
- const ctx = buildContext();
10862
- const ref = await resolveOrg(ctx, options.org);
10863
- const result = await ctx.client.approveChangeRequest(ref.id, id, options.comment);
10864
- emit(options, result, () => {
10865
- if (result.status === "applied") console.error(`approved and applied${result.version ? ` as v${result.version}` : ""}`);
10866
- else if (result.status === "superseded") console.error(`not applied: ${result.reason ?? "the secret has changed since"}`);
10867
- else console.error(`approved — ${result.approvals ?? 0}/${result.required ?? "?"} approvals`);
10868
- });
10869
- });
10870
- changes.command("reject <id>").description("reject a queued change").option("--org <slug>", "organization slug").option("-m, --comment <text>", "note recorded with your rejection").option("--json", "print the raw API response").action(async (id, options) => {
10871
- const ctx = buildContext();
10872
- const ref = await resolveOrg(ctx, options.org);
10873
- emit(options, await ctx.client.rejectChangeRequest(ref.id, id, options.comment), () => console.error("rejected"));
10874
- });
10875
- changes.command("withdraw <id>").description("take your own queued change off the review queue").option("--org <slug>", "organization slug").option("--json", "print the raw API response").action(async (id, options) => {
10876
- const ctx = buildContext();
10877
- const ref = await resolveOrg(ctx, options.org);
10878
- emit(options, await ctx.client.withdrawChangeRequest(ref.id, id), () => console.error("withdrawn"));
10879
- });
10880
- const protection = changes.command("protection").description("require review before an environment's secrets change");
10881
- protection.command("show", { isDefault: true }).description("show whether an environment is protected").option("--org <slug>", "organization slug").option("--app <slug>", "application slug").option("--group <slug>", "group slug (instead of --app)").option("--env <slug>", "environment slug").option("--json", "print the raw API response").action(async (options) => {
10882
- const ctx = buildContext();
10883
- const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
10884
- const result = await ctx.client.getEnvironmentProtection(orgId, envId);
10885
- emit(options, result, () => {
10886
- if (!result.protection) {
10887
- console.log(`${label}: unprotected — writes land immediately`);
10888
- return;
10889
- }
10890
- printFields([
10891
- ["environment", label],
10892
- ["approvals required", result.protection.requiredApprovals],
10893
- ["self-approval", result.protection.allowSelfApproval ? "allowed" : "not allowed"],
10894
- ["waiting for review", result.openChangeRequests]
10895
- ]);
10896
- });
10897
- });
10898
- protection.command("on").description("require approval before secrets in this environment change").option("--org <slug>", "organization slug").option("--app <slug>", "application slug").option("--group <slug>", "group slug (instead of --app)").option("--env <slug>", "environment slug").option("--approvals <n>", "approvals a change needs", "1").option("--allow-self-approval", "let the proposer's own approval count").option("--json", "print the raw API response").action(async (options) => {
10899
- const ctx = buildContext();
10900
- const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
10901
- const requiredApprovals = Number(options.approvals);
10902
- if (!Number.isInteger(requiredApprovals) || requiredApprovals < 1) fail("--approvals must be a positive whole number");
10903
- emit(options, await ctx.client.setEnvironmentProtection(orgId, envId, {
10904
- requiredApprovals,
10905
- allowSelfApproval: options.allowSelfApproval ?? false
10906
- }), () => console.error(`${label} now requires ${requiredApprovals} approval${requiredApprovals === 1 ? "" : "s"} before a secret changes`));
10907
- });
10908
- protection.command("off").description("stop requiring approval (queued changes stay queued)").option("--org <slug>", "organization slug").option("--app <slug>", "application slug").option("--group <slug>", "group slug (instead of --app)").option("--env <slug>", "environment slug").option("--json", "print the raw API response").action(async (options) => {
10909
- const ctx = buildContext();
10910
- const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
10911
- emit(options, await ctx.client.deleteEnvironmentProtection(orgId, envId), () => console.error(`${label} no longer requires approval — writes land immediately`));
10912
- });
10913
- }
10914
- function parseStatus(raw) {
10915
- if (!raw) return void 0;
10916
- if (!CHANGE_REQUEST_STATUSES.includes(raw)) fail(`unknown status "${raw}" — one of ${CHANGE_REQUEST_STATUSES.join(", ")}`);
10917
- return raw;
10918
- }
10919
- /**
10920
- * Report a write that was queued instead of performed. Every write path calls
10921
- * this: a CLI that printed "set FOO" for a change nobody has approved would be
10922
- * telling the operator the deploy is ready when it is not.
10923
- */
10924
- function reportQueued(cr) {
10925
- console.error(`queued for review: ${describeChange(cr.kind, cr.secretName, cr.restoreFromVersion)} — needs ${cr.requiredApprovals} approval${cr.requiredApprovals === 1 ? "" : "s"} (${cr.id})`);
10926
- }
10927
11793
  /** Plugin id and integration id declared by `@seekrit/openclaw-plugin`. */
10928
11794
  const PLUGIN_ID = "seekrit";
10929
11795
  const PLUGIN_INTEGRATION_ID = "seekrit";
@@ -12912,7 +13778,7 @@ function collect$5(value, acc) {
12912
13778
  * release-please-config.json), so the pin follows the crate without anyone
12913
13779
  * remembering to move it.
12914
13780
  */
12915
- const PROXY_VERSION = "0.15.0";
13781
+ const PROXY_VERSION = "0.16.0";
12916
13782
  const BIN = "seekrit-proxy";
12917
13783
  /**
12918
13784
  * Host → Rust target triple.
@@ -12948,7 +13814,7 @@ function versionPrefix(version) {
12948
13814
  return version.startsWith("v") ? version : `v${version}`;
12949
13815
  }
12950
13816
  function resolveVersion(explicit) {
12951
- return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.15.0";
13817
+ return explicit ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.16.0";
12952
13818
  }
12953
13819
  function resolveBaseUrl(explicit) {
12954
13820
  return (explicit ?? process.env.SEEKRIT_PROXY_BASE_URL ?? "https://proxy.seekrit.dev").replace(/\/+$/, "");
@@ -14931,10 +15797,10 @@ Next:
14931
15797
  });
14932
15798
  console.log(path);
14933
15799
  const { target } = detectTarget();
14934
- process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.15.0")} (${target})\n`);
15800
+ process.stderr.write(`seekrit-proxy ${versionPrefix(options.proxyVersion ?? "0.16.0")} (${target})\n`);
14935
15801
  });
14936
15802
  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) => {
14937
- const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.15.0";
15803
+ const version = options.proxyVersion ?? process.env.SEEKRIT_PROXY_VERSION ?? "0.16.0";
14938
15804
  const override = process.env.SEEKRIT_PROXY_BIN;
14939
15805
  const { target, exe } = detectTarget();
14940
15806
  const path = override ?? proxyBinaryPath(version, target, exe);
@@ -14993,7 +15859,7 @@ Next:
14993
15859
  process.stdout.write(renderComposeSnippet(plan, {
14994
15860
  service: options.service,
14995
15861
  workload: options.workload,
14996
- image: options.image ?? `seekritdev/proxy:0.15.0`,
15862
+ image: options.image ?? `seekritdev/proxy:0.16.0`,
14997
15863
  publish: Boolean(options.publish)
14998
15864
  }));
14999
15865
  });
@@ -16948,10 +17814,13 @@ async function materializeForRun(options) {
16948
17814
  const ctx = tryBuildContext(dotenvVars);
16949
17815
  if (!ctx) throw new Error("no credentials found (set SEEKRIT_TOKEN / SEEKRIT_DEV_USER or run `seekrit login`)");
16950
17816
  const branch = options.branch ?? process.env.SEEKRIT_BRANCH ?? dotenvVars.SEEKRIT_BRANCH;
16951
- return await materialize(ctx, {
16952
- ...options,
16953
- branch
16954
- }, dotenvVars);
17817
+ return {
17818
+ ...await materialize(ctx, {
17819
+ ...options,
17820
+ branch
17821
+ }, dotenvVars),
17822
+ ctx
17823
+ };
16955
17824
  } catch (err) {
16956
17825
  console.error(`seekrit: continuing without seekrit-managed secrets: ${describeError(err)}`);
16957
17826
  const values = {};
@@ -16959,17 +17828,35 @@ async function materializeForRun(options) {
16959
17828
  overlayEnvFiles(values, provenance, envFiles);
16960
17829
  return {
16961
17830
  ...interpolateValues(values, options.interpolate !== false),
16962
- provenance
17831
+ provenance,
17832
+ agentLogins: {},
17833
+ scope: void 0,
17834
+ ctx: void 0
16963
17835
  };
16964
17836
  }
16965
17837
  }
16966
- program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--lease <VAR=target>", "inject a temporary Postgres connection URL (repeatable)", collectList).option("--lease-ttl <duration>", "maximum lease lifetime, 1m–7d (default: 30m)").option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--cache", "keep a last-known-good copy of the encrypted response and fall back to it when the API is unreachable (off by default; SEEKRIT_CACHE=1)").option("--cache-dir <path>", "where to keep it (default: $XDG_CACHE_HOME/seekrit)").option("--cache-max-age <duration>", "how stale a cached copy may be and still be used, e.g. 15m, 24h, 7d (default: 24h)").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
17838
+ program.command("run").description("run a command with decrypted secrets injected (process env > .env > app > group)").passThroughOptions().option("--org <slug>", "organization slug (defaults to seekrit.json, or your only org)").option("--app <slug>", "application slug (token path infers this)").option("--env <slug>", "environment slug (token path infers this)").option("--branch <slug>", "read a branch of that environment (or $SEEKRIT_BRANCH)").option("--with <group=env>", "override one group’s slice for this run", collectKv).option("--lease <VAR=target>", "inject a temporary Postgres connection URL (repeatable)", collectList).option("--lease-ttl <duration>", "maximum lease lifetime, 1m–7d (default: 30m)").option("--agent-login <agent[=SECRET]>", "sign codex or claude in from a login stored with `seekrit agent-login`, and save its refreshes back (repeatable)", collectList).option("--env-file <path>", "a .env file to overlay (repeatable; default .env)", collectList).option("--explain", "print where each variable resolved from (to stderr)").option("--no-interpolate", "leave ${OTHER_SECRET} references as literal text").option("--cache", "keep a last-known-good copy of the encrypted response and fall back to it when the API is unreachable (off by default; SEEKRIT_CACHE=1)").option("--cache-dir <path>", "where to keep it (default: $XDG_CACHE_HOME/seekrit)").option("--cache-max-age <duration>", "how stale a cached copy may be and still be used, e.g. 15m, 24h, 7d (default: 24h)").argument("<command...>", "command to run (prefix with -- to pass flags)").action(async (commandParts, options) => {
16967
17839
  const [cmd, ...args] = commandParts;
16968
17840
  if (!cmd) fail("no command given");
16969
17841
  const leaseOptions = parseRunLeaseOptions(options.lease, options.leaseTtl);
16970
- const { values, provenance, interpolated, unresolvedRefs } = await materializeForRun(options);
17842
+ let loginRequests = [];
17843
+ try {
17844
+ loginRequests = (options.agentLogin ?? []).map(parseAgentLoginFlag);
17845
+ } catch (err) {
17846
+ fail(err instanceof Error ? err.message : String(err));
17847
+ }
17848
+ const { values, provenance, interpolated, unresolvedRefs, agentLogins, scope, ctx } = await materializeForRun(options);
17849
+ const loginSessionsFor = loginRequests.length > 0 ? await prepareAgentLogins(loginRequests, agentLogins, ctx, scope ? `${scope.appSlug}/${scope.envSlug}` : "this environment") : void 0;
17850
+ let loginSessions = [];
16971
17851
  const leases = leaseOptions ? await prepareRunLeases(leaseOptions, options.org, options.envFile) : void 0;
16972
17852
  const result = await runCommand(cmd, args, async (interrupted) => {
17853
+ if (loginSessionsFor) {
17854
+ loginSessions = loginSessionsFor({
17855
+ ...values,
17856
+ ...process.env
17857
+ });
17858
+ for (const session of loginSessions) await session.start();
17859
+ }
16973
17860
  const leased = await leases?.mint(interrupted) ?? {};
16974
17861
  if (options.explain) {
16975
17862
  for (const name of Object.keys(values)) if (process.env[name] !== void 0 && process.env[name] !== values[name]) provenance[name] = "env";
@@ -16991,7 +17878,10 @@ program.command("run").description("run a command with decrypted secrets injecte
16991
17878
  ...env,
16992
17879
  ...leased
16993
17880
  };
16994
- }, () => leases?.revoke() ?? Promise.resolve(true));
17881
+ }, async () => {
17882
+ const [saved, revoked] = await Promise.all([Promise.all(loginSessions.map((session) => session.stop())).then((all) => all.every(Boolean)), leases?.revoke() ?? Promise.resolve(true)]);
17883
+ return saved && revoked;
17884
+ });
16995
17885
  if (result.signal) process.kill(process.pid, result.signal);
16996
17886
  else process.exit(result.code);
16997
17887
  });
@@ -17102,6 +17992,7 @@ registerRedisCommands(program);
17102
17992
  registerProvisionerCommands(program);
17103
17993
  registerProxyCommands(program);
17104
17994
  registerAgentCommands(program);
17995
+ registerAgentLoginCommands(program);
17105
17996
  registerPaperclipCommands(program);
17106
17997
  registerNomadCommands(program);
17107
17998
  registerOpenclawCommands(program);