@seekrit/cli 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1863 -961
  2. package/package.json +7 -7
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 {
@@ -2158,6 +2252,7 @@ const AUDIT_ACTIONS = [
2158
2252
  "org.member_added",
2159
2253
  "org.member_role_changed",
2160
2254
  "app.access_granted",
2255
+ "app.access_revoked",
2161
2256
  "org.member_removed",
2162
2257
  "org.member_invited",
2163
2258
  "org.invite_revoked",
@@ -2458,9 +2553,18 @@ z.object({
2458
2553
  */
2459
2554
  recoveryWrappedDek: z.string().min(1).nullish()
2460
2555
  });
2461
- z.object({
2462
- /** Opaque versioned ciphertext blob from @seekrit/crypto. */
2463
- 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
+ });
2464
2568
  z.object({ version: z.number().int().positive() });
2465
2569
  z.object({ limit: z.coerce.number().int().min(1).max(200).default(50) });
2466
2570
  z.object({
@@ -5783,7 +5887,7 @@ async function createAgentTaskToken() {
5783
5887
  };
5784
5888
  }
5785
5889
  // package.json
5786
- var version = "2.3.0";
5890
+ var version = "2.5.0";
5787
5891
  // ../../packages/api-client/src/index.ts
5788
5892
  /**
5789
5893
  * The proposer's note travels as a header, not a body field: an unprotected
@@ -5919,6 +6023,9 @@ var SeekritClient = class {
5919
6023
  listMembers(orgId) {
5920
6024
  return this.request("GET", `/v1/orgs/${orgId}/members`);
5921
6025
  }
6026
+ getMemberAccess(orgId) {
6027
+ return this.request("GET", `/v1/orgs/${orgId}/members/access`);
6028
+ }
5922
6029
  updateMemberRole(orgId, userId, role) {
5923
6030
  return this.request("PATCH", `/v1/orgs/${orgId}/members/${userId}`, { role });
5924
6031
  }
@@ -5931,6 +6038,13 @@ var SeekritClient = class {
5931
6038
  grants
5932
6039
  });
5933
6040
  }
6041
+ /**
6042
+ * Stop auto-granting this app's future environments to a member. Keys they
6043
+ * already hold stay; revoke those per environment with `revokeEnvKey`.
6044
+ */
6045
+ revokeAppAccess(orgId, appId, userId) {
6046
+ return this.request("DELETE", `/v1/orgs/${orgId}/apps/${appId}/access/${userId}`);
6047
+ }
5934
6048
  accessRecipients(orgId, target) {
5935
6049
  const path = "appId" in target ? `apps/${target.appId}` : `groups/${target.groupId}`;
5936
6050
  return this.request("GET", `/v1/orgs/${orgId}/${path}/access-recipients`);
@@ -6094,9 +6208,17 @@ var SeekritClient = class {
6094
6208
  *
6095
6209
  * `note` is the proposer's explanation, carried as a header so it is simply
6096
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}.
6097
6215
  */
6098
- setSecret(orgId, envId, name, ciphertext, note) {
6099
- 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));
6100
6222
  }
6101
6223
  /** Queued for review in a protected environment — see {@link setSecret}. */
6102
6224
  deleteSecret(orgId, envId, name, note) {
@@ -7407,835 +7529,785 @@ function registerAccountCommands(program) {
7407
7529
  console.error(`${type} emails ${state}`);
7408
7530
  });
7409
7531
  }
7410
- // src/agents.ts
7411
- /**
7412
- * Agent access governance from the CLI.
7413
- *
7414
- * Policy lived only in the dashboard until now, which was backwards: the people
7415
- * deploying agents work in a terminal and a repo, and a rule set is exactly the
7416
- * kind of thing that wants review and version control. So the loop this module
7417
- * exists for is:
7418
- *
7419
- * seekrit agents policy pull nova -o nova.policy.json # current rules
7420
- * $EDITOR nova.policy.json # commit it, review it
7421
- * seekrit agents policy publish nova -f nova.policy.json
7422
- *
7423
- * **Signing still happens here, on this machine, with the operator's own key**
7424
- * (`getPolicySigner` in `context.ts`), so nothing about the trust argument in
7425
- * `docs/agent-access-governance.md` §1 changes: the API receives an opaque
7426
- * envelope it cannot forge, and a proxy verifies it against thumbprints pinned in
7427
- * its own local file. A service token is refused — an agent that can publish its
7428
- * own policy is not governed by it.
7429
- *
7430
- * Everything else here is read-only, and two commands are deliberately *local*
7431
- * evaluations rather than API calls:
7432
- *
7433
- * - `simulate` runs `evaluatePolicy` from `@seekrit/core`, the mirror of
7434
- * `RuleSet::decide` in the proxy — so a dry run and a real refusal give the
7435
- * same verdict in the same words.
7436
- * - `fetch` asks for the bytes a proxy would get (`GET /v1/agents/:ref/policy`)
7437
- * and re-derives the signer thumbprint from the bundle itself, rather than
7438
- * trusting the field the API echoes beside it.
7439
- */
7440
- /** How many published versions the API returns per page. */
7441
- const POLICY_PAGE = 50;
7442
- /** The `-` sentinel every other seekrit command uses to mean stdin. */
7443
- const STDIN = "-";
7444
- /**
7445
- * Resolve `nova`, or `agt_…`, to an identity.
7446
- *
7447
- * The org-scoped API routes take an id, but nobody types ids — so a slug is
7448
- * resolved from the list. The list is small (agents are a per-deployment thing,
7449
- * not a per-request one), which is why this costs one request rather than
7450
- * needing a lookup route.
7451
- */
7452
- async function resolveAgent(ctx, orgId, ref) {
7453
- const { agents } = await ctx.client.listAgents(orgId);
7454
- const found = agents.find((a) => a.slug === ref || a.id === ref);
7455
- if (!found) {
7456
- const known = agents.map((a) => a.slug).join(", ");
7457
- 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;
7458
7586
  }
7459
- return found;
7460
7587
  }
7461
- /**
7462
- * One published version: the newest by default, or an explicit `--version`.
7463
- *
7464
- * An agent with no published policy is a real state, not an error — a proxy for
7465
- * it fails closed — so the caller decides how to report it.
7466
- */
7467
- async function loadVersion(ctx, orgId, agent, version) {
7468
- const { policies } = await ctx.client.listAgentPolicies(orgId, agent.id);
7469
- if (version === void 0) return policies[0] ?? null;
7470
- const found = policies.find((p) => p.version === version);
7471
- 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"}`);
7472
- return found;
7588
+ function nonEmpty(value) {
7589
+ return typeof value === "string" && value.length > 0;
7473
7590
  }
7474
- /**
7475
- * Read a rule file: either a bare array or `{ "rules": [...] }`, from a path or
7476
- * stdin. Both shapes are accepted because `policy pull` writes the second and
7477
- * hand-written files tend to be the first.
7478
- *
7479
- * Validation is the same zod schema the API and the dashboard use, so a file
7480
- * rejected here would have been rejected there — before anything is signed.
7481
- *
7482
- * Exported for its own test: this is the one place a hand-written file meets the
7483
- * schema, and its error messages are the whole user experience of a typo.
7484
- */
7485
- function parseRuleFile(raw, source) {
7486
- 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) {
7487
7653
  try {
7488
- parsed = JSON.parse(raw);
7654
+ process.kill(pid, 0);
7655
+ return true;
7489
7656
  } catch (err) {
7490
- fail(`${source} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
7657
+ return err.code === "EPERM";
7491
7658
  }
7492
- 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;
7493
- if (!Array.isArray(list)) fail(`${source}: "rules" must be an array`);
7494
- if (list.length === 0) console.error(`note: ${source} has no rules — publishing it denies every request`);
7495
- return list.map((rule, i) => {
7496
- const result = agentPolicyRuleSchema.safeParse(rule);
7497
- if (!result.success) {
7498
- const first = result.error.issues[0];
7499
- fail(`${source}: rule ${i + 1} is invalid — ${first?.path.join(".") || "rule"}: ${first?.message ?? "unknown error"}`);
7500
- }
7501
- 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
7502
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);
7503
7670
  }
7504
- /** `--file path` or `--file -`. */
7505
- async function readRuleSource(file) {
7506
- if (file === STDIN) return {
7507
- raw: await readStdin(),
7508
- label: "stdin"
7509
- };
7671
+ function readIfExists(path) {
7510
7672
  try {
7511
- return {
7512
- raw: readFileSync(file, "utf8"),
7513
- label: file
7514
- };
7673
+ return readFileSync(path, "utf8");
7515
7674
  } catch (err) {
7516
- return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
7675
+ if (err.code === "ENOENT") return null;
7676
+ throw err;
7517
7677
  }
7518
7678
  }
7519
- /**
7520
- * An empty `methods` or `paths` list means *any*: those fields narrow a rule that
7521
- * already matched its host, so absent means unconstrained. A blank column would
7522
- * read as the opposite, so it renders as `any`.
7523
- *
7524
- * Exported for its own test — this is a real invariant of the format, not a
7525
- * cosmetic choice.
7526
- */
7527
- function describeList(values) {
7528
- 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);
7529
7998
  }
7530
- /**
7531
- * `allow` is the field where empty means the **opposite** — no secret may be
7532
- * injected toward this host at all (`Decision::SecretNotAllowed` for every name;
7533
- * see `crates/seekrit-core/src/policy.rs`). Such a rule is useful and
7534
- * intentional: it permits the *request* while granting no credential, which is
7535
- * how you let an agent read a public API through the proxy without handing it a
7536
- * key. Rendering that as `any` would invert the meaning of the one column an
7537
- * operator reviews most carefully, so it gets its own function.
7538
- */
7539
- function describeSecrets(values) {
7540
- 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
+ };
7541
8009
  }
8010
+ // src/changes.ts
7542
8011
  /**
7543
- * The third convention, and the one most likely to be misread: **no ports means
7544
- * HTTPS on 443**, not any port. A blank or `any` here would say the opposite of
7545
- * 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.
7546
8019
  */
7547
- function describePorts(rule) {
7548
- return describePolicyPorts(rule) ?? "https:443";
7549
- }
7550
- function printRules(rules) {
7551
- printTable(rules.map((rule, i) => ({
7552
- n: i + 1,
7553
- rule
7554
- })), [
7555
- col("#", (r) => r.n),
7556
- col("host", (r) => r.rule.host),
7557
- col("ports", (r) => describePorts(r.rule)),
7558
- col("methods", (r) => describeList(r.rule.methods)),
7559
- col("paths", (r) => describeList(r.rule.paths)),
7560
- col("secrets", (r) => describeSecrets(r.rule.allow)),
7561
- col("label", (r) => r.rule.label ?? "-")
7562
- ], "no rules — this policy denies every request");
7563
- }
7564
- /** One rule as a single line, for the publish diff. */
7565
- function ruleLine(rule) {
7566
- const ports = describePolicyPorts(rule);
7567
- const parts = [
7568
- ports ? `${rule.host} [${ports}]` : rule.host,
7569
- describeList(rule.methods),
7570
- describeList(rule.paths),
7571
- `secrets=${describeSecrets(rule.allow)}`
7572
- ];
7573
- return rule.label ? `${parts.join(" ")} (${rule.label})` : parts.join(" ");
7574
- }
7575
- /**
7576
- * The change a publish would make. Rules are compared by *position* because
7577
- * order decides — first match wins — so a reordering is a real change and shows
7578
- * up as one.
7579
- */
7580
- function printPolicyDiff(before, after) {
7581
- const changes = diffPolicyRules(before, after).filter((c) => c.kind !== "unchanged");
7582
- if (changes.length === 0) {
7583
- console.error(after.length === 0 ? "no rules — this policy denies every request" : "no rule changes — publishing would only extend the expiry");
7584
- return;
7585
- }
7586
- for (const change of changes) {
7587
- const n = change.index + 1;
7588
- if (change.kind === "added") console.log(`+ ${n} ${ruleLine(change.after)}`);
7589
- else if (change.kind === "removed") console.log(`- ${n} ${ruleLine(change.before)}`);
7590
- else {
7591
- console.log(`- ${n} ${ruleLine(change.before)}`);
7592
- console.log(`+ ${n} ${ruleLine(change.after)}`);
7593
- }
7594
- }
7595
- }
7596
- /** Seconds until an ISO instant, or a negative number when it has passed. */
7597
- function secondsUntil(iso) {
7598
- return Math.round((new Date(iso).getTime() - Date.now()) / 1e3);
7599
- }
7600
- /** Collect a repeated `--scope NAME` into a list. */
7601
- function collectScope(value, acc = []) {
7602
- acc.push(value);
7603
- return acc;
7604
- }
7605
- /**
7606
- * A task's state, derived rather than stored: `revoked` beats `expired`, so a run
7607
- * somebody killed does not read as one that merely lapsed.
7608
- *
7609
- * Exported for its own test — it mirrors `taskState` in
7610
- * `apps/api/src/lib/agent-tasks.ts`, and the two disagreeing would mean the list
7611
- * screen and the enforcement point describe the same run differently.
7612
- */
7613
- function taskStateOf(task, now) {
7614
- if (task.revokedAt) return "revoked";
7615
- return Date.parse(task.expiresAt) <= now ? "expired" : "active";
7616
- }
7617
- /**
7618
- * A detail view on **stderr**, so a command whose stdout is a credential can
7619
- * still explain itself to a person without corrupting `$(…)`.
7620
- */
7621
- function printFieldsToStderr(fields) {
7622
- const present = fields.filter(([, value]) => value !== null && value !== void 0);
7623
- const width = Math.max(...present.map(([label]) => label.length));
7624
- for (const [label, value] of present) console.error(`${label.padEnd(width)} ${value}`);
7625
- }
7626
- /**
7627
- * Exported for its own test: the expired branch is the one that must be loud.
7628
- *
7629
- * The granularity has to span both users of this — a policy bundle lasting a
7630
- * week and a task lasting fifteen minutes. Rounding a task to the nearest hour
7631
- * printed "in 0h", which is worse than no estimate at all.
7632
- */
7633
- function describeExpiry(iso) {
7634
- const seconds = secondsUntil(iso);
7635
- if (seconds <= 0) return `${iso} (EXPIRED — proxies for this agent fail closed)`;
7636
- const days = Math.floor(seconds / 86400);
7637
- const hours = Math.floor(seconds % 86400 / 3600);
7638
- const minutes = Math.floor(seconds % 3600 / 60);
7639
- if (days > 0) return `${iso} (in ${days}d ${hours}h)`;
7640
- if (hours > 0) return `${iso} (in ${hours}h ${minutes}m)`;
7641
- if (minutes > 0) return `${iso} (in ${minutes}m)`;
7642
- return `${iso} (in ${seconds}s)`;
7643
- }
7644
- function registerAgentCommands(program) {
7645
- const agents = program.command("agents").description("agent access policy — which agent may reach which upstream with which secret");
7646
- agents.command("list", { isDefault: true }).description("list agent identities").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (options) => {
7647
- const ctx = buildContext();
7648
- const org = await resolveOrg(ctx, options.org);
7649
- const { agents: rows } = await ctx.client.listAgents(org.id);
7650
- emit(options, { agents: rows }, () => printTable(rows, [
7651
- col("slug", (a) => a.slug),
7652
- col("name", (a) => a.name),
7653
- col("policy", (a) => a.currentPolicyVersion === 0 ? "none" : `v${a.currentPolicyVersion}`),
7654
- col("enabled", (a) => a.enabled ? "yes" : "no"),
7655
- col("last fetch", (a) => a.lastPolicyFetchAt ?? "never")
7656
- ], "no agent identities — create one with `seekrit agents create`"));
7657
- });
7658
- 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) => {
7659
8023
  const ctx = buildContext();
7660
- const org = await resolveOrg(ctx, options.org);
7661
- const environmentId = options.env ? (await resolveAppEnv(ctx, {
7662
- org: options.org,
7663
- app: options.app,
7664
- env: options.env
7665
- })).envId : void 0;
7666
- const { agent } = await ctx.client.createAgent(org.id, {
7667
- name,
7668
- slug: options.slug,
7669
- ...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)
7670
8031
  });
7671
- emit(options, { agent }, () => {
7672
- console.error(`created ${agent.slug} — no policy published yet, so any proxy for it fails closed`);
7673
- 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");
7674
8040
  });
7675
8041
  });
7676
- 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) => {
7677
8043
  const ctx = buildContext();
7678
- const org = await resolveOrg(ctx, options.org);
7679
- const agent = await resolveAgent(ctx, org.id, ref);
7680
- const { environment } = await ctx.client.getAgent(org.id, agent.id);
7681
- 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);
7682
8046
  emit(options, {
7683
- agent,
7684
- environment,
7685
- policy: live
8047
+ changeRequest,
8048
+ reviews
7686
8049
  }, () => {
7687
8050
  printFields([
7688
- ["name", agent.name],
7689
- ["slug", agent.slug],
7690
- ["id", agent.id],
7691
- ["environment", environment ? environment.slug : "any in the org"],
7692
- ["enabled", agent.enabled ? "yes" : "no"],
7693
- ["policy", live ? `v${live.version}` : "none published"],
7694
- ["signer", live?.signerThumbprint],
7695
- ["expires", live ? describeExpiry(live.expiresAt) : void 0],
7696
- ["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 ?? "-"]
7697
8059
  ]);
7698
- if (live) {
7699
- section(`rules (v${live.version})`);
7700
- printRules(live.rules);
7701
- section("hosts a forward proxy would intercept");
7702
- console.log(policyHosts(live.rules).join("\n") || "none");
7703
- }
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");
7704
8067
  });
7705
8068
  });
7706
- 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) => {
7707
8070
  const ctx = buildContext();
7708
- const org = await resolveOrg(ctx, options.org);
7709
- const agent = await resolveAgent(ctx, org.id, ref);
7710
- await confirmDestructive(options.yes, `Disable ${agent.slug}? Running proxies keep their current bundle until it expires.`);
7711
- await ctx.client.updateAgent(org.id, agent.id, { enabled: false });
7712
- 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
+ });
7713
8078
  });
7714
- 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) => {
7715
8080
  const ctx = buildContext();
7716
- const org = await resolveOrg(ctx, options.org);
7717
- const agent = await resolveAgent(ctx, org.id, ref);
7718
- await ctx.client.updateAgent(org.id, agent.id, { enabled: true });
7719
- 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"));
7720
8083
  });
7721
- 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) => {
7722
8085
  const ctx = buildContext();
7723
- const org = await resolveOrg(ctx, options.org);
7724
- const agent = await resolveAgent(ctx, org.id, ref);
7725
- await confirmDestructive(options.yes, `Delete ${agent.slug} and all ${agent.currentPolicyVersion} published version(s)? The audit_log rows survive.`);
7726
- await ctx.client.deleteAgent(org.id, agent.id);
7727
- 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"));
7728
8088
  });
7729
- /**
7730
- * The trust anchor. This prints a *public* key fingerprint — the security of
7731
- * pinning comes from the operator putting it in a local file the API cannot
7732
- * reach, not from this command being honest. Which is why it prints the TOML
7733
- * line to paste rather than offering to write the config.
7734
- */
7735
- 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) => {
7736
8091
  const ctx = buildContext();
7737
- const org = await resolveOrg(ctx, options.org);
7738
- const { signer } = await ctx.client.getMyPolicySigner(org.id);
7739
- if (!signer) fail("your account has no keypair yet — run `seekrit keys setup`");
7740
- emit(options, { signer }, () => {
7741
- printFields([["thumbprint", signer.thumbprint], ["user", signer.userId]]);
7742
- section("pin it in seekrit-proxy.toml");
7743
- console.log("[policy]");
7744
- console.log(`signers = ["${signer.thumbprint}"]`);
7745
- console.error("");
7746
- 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
+ ]);
7747
8105
  });
7748
8106
  });
7749
- /**
7750
- * The token is printed to **stdout** and everything else to stderr, so the
7751
- * common thing works without parsing:
7752
- *
7753
- * TASK=$(seekrit agents dispatch nova --scope GITHUB_TOKEN --ttl 15m)
7754
- *
7755
- * It is shown exactly once. The API stores only a hash, so a lost token cannot
7756
- * be recovered — dispatch another and revoke the first.
7757
- */
7758
- 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) => {
7759
8108
  const ctx = buildContext();
7760
- const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : 900;
7761
- if (ttlSeconds < 60 || ttlSeconds > 43200) fail(`--ttl must be between 60s and ${TASK_MAX_TTL_SECONDS / 3600}h`);
7762
- const minted = await createAgentTaskToken();
7763
- const { task, header } = await ctx.client.dispatchAgentTask(ref, {
7764
- taskRef: minted.taskRef,
7765
- tokenHash: minted.tokenHash,
7766
- ttlSeconds,
7767
- ...options.scope ? { scopes: options.scope } : {},
7768
- ...options.label ? { label: options.label } : {}
7769
- });
7770
- if (options.json) {
7771
- console.log(JSON.stringify({
7772
- task,
7773
- header,
7774
- token: minted.token
7775
- }, null, 2));
7776
- return;
7777
- }
7778
- console.log(minted.token);
7779
- printFieldsToStderr([
7780
- ["task", task.id],
7781
- ["agent", ref],
7782
- ["scopes", task.scopes ? describeSecrets(task.scopes) : "the agent's full policy"],
7783
- ["policy", `v${task.policyVersion}`],
7784
- ["expires", describeExpiry(task.expiresAt)],
7785
- ["header", header]
7786
- ]);
7787
- });
7788
- 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) => {
7789
- const ctx = buildContext();
7790
- const org = await resolveOrg(ctx, options.org);
7791
- const agent = await resolveAgent(ctx, org.id, ref);
7792
- const { tasks } = await ctx.client.listAgentTasks(org.id, agent.id);
7793
- const now = Date.now();
7794
- const rows = options.all ? tasks : tasks.filter((t) => taskStateOf(t, now) === "active");
7795
- emit(options, { tasks: rows }, () => printTable(rows, [
7796
- col("task", (t) => t.id),
7797
- col("state", (t) => taskStateOf(t, now)),
7798
- col("scopes", (t) => t.scopes ? describeSecrets(t.scopes) : "policy"),
7799
- col("policy", (t) => `v${t.policyVersion}`),
7800
- col("expires", (t) => t.expiresAt),
7801
- col("last seen", (t) => t.lastSeenAt ?? "never"),
7802
- col("label", (t) => t.label ?? "-")
7803
- ], options.all ? "no runs dispatched yet" : "no live runs (try --all)"));
7804
- });
7805
- agents.command("revoke <taskId>").description("end a run's authority now").action(async (taskId) => {
7806
- const { task } = await buildContext().client.revokeAgentTask(taskId);
7807
- 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`));
7808
8116
  });
7809
- registerPolicyCommands(agents.command("policy").description("published policy versions (`seekrit agents policy --help`)"));
7810
- 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) => {
7811
8118
  const ctx = buildContext();
7812
- const org = await resolveOrg(ctx, options.org);
7813
- const agent = await resolveAgent(ctx, org.id, ref);
7814
- const days = Number(options.days ?? 14);
7815
- if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
7816
- const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
7817
- emit(options, {
7818
- activity,
7819
- summary
7820
- }, () => {
7821
- printFields([
7822
- ["window", `${days}d`],
7823
- ["allowed", summary.allowed],
7824
- ["denied", summary.denied],
7825
- ["hosts", summary.hosts],
7826
- ["earliest", summary.from ?? "no activity reported"]
7827
- ]);
7828
- if (activity.length === 0) {
7829
- console.error("");
7830
- console.error("nothing reported — add an [activity] block to the proxy (`seekrit proxy init --activity`)");
7831
- return;
7832
- }
7833
- section("decisions");
7834
- printTable(activity, [
7835
- col("host", (a) => a.host),
7836
- col("method", (a) => a.method),
7837
- col("decision", (a) => a.decision),
7838
- col("rule", (a) => a.ruleIndex === null ? "-" : String(a.ruleIndex + 1)),
7839
- col("count", (a) => a.count),
7840
- col("secrets", (a) => a.secrets ? Object.entries(a.secrets).map(([name, n]) => `${name}×${n}`).join(" ") : "-")
7841
- ]);
7842
- });
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`));
7843
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
+ }
7844
8235
  /**
7845
- * The grant review loop. Proposals are computed *here*, from activity the API
7846
- * served — the API is deliberately not in the business of saying what a policy
7847
- * should be. And `--out` writes a rule file rather than publishing: the change
7848
- * 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.
7849
8240
  */
7850
- 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) => {
7851
- const ctx = buildContext();
7852
- const org = await resolveOrg(ctx, options.org);
7853
- const agent = await resolveAgent(ctx, org.id, ref);
7854
- const days = Number(options.days ?? 14);
7855
- if (!Number.isInteger(days) || days < 1 || days > 90) fail("--days must be 1–90");
7856
- const live = await loadVersion(ctx, org.id, agent);
7857
- if (!live) fail(`${agent.slug} has no published policy to review`);
7858
- const { activity, summary } = await ctx.client.getAgentActivity(org.id, agent.id, days);
7859
- const proposals = reviewPolicy({
7860
- rules: live.rules,
7861
- activity
7862
- });
7863
- emit(options, {
7864
- proposals,
7865
- summary,
7866
- version: live.version
7867
- }, () => {
7868
- console.error(`${agent.slug} — policy v${live.version}, ${summary.allowed + summary.denied} decision(s) over ${days}d`);
7869
- if (activity.length === 0) {
7870
- console.error("");
7871
- console.error("no activity reported yet — nothing to review against");
7872
- return;
7873
- }
7874
- if (proposals.length === 0) {
7875
- console.error("");
7876
- console.error("no changes proposed: every rule is in use and nothing was refused");
7877
- return;
7878
- }
7879
- section("proposals");
7880
- for (const p of proposals) {
7881
- const marker = p.applicable ? "-" : "?";
7882
- console.log(`${marker} ${p.rationale}`);
7883
- }
7884
- const applicable = countApplicable(proposals);
7885
- console.error("");
7886
- 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
7887
8245
  });
7888
- if (options.out) {
7889
- const narrowed = applyProposals(live.rules, proposals);
7890
- writeFileSync(options.out, `${JSON.stringify({ rules: narrowed }, null, 2)}\n`);
7891
- console.error(`wrote ${narrowed.length} rule(s) to ${options.out} — review the diff, then: seekrit agents policy publish ${agent.slug} -f ${options.out}`);
7892
- console.error("widening proposals are never applied: an agent must not earn permissions by retrying");
7893
- }
7894
- });
7895
- 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) => {
7896
- const scheme = options.scheme.toLowerCase();
7897
- if (scheme !== "https" && scheme !== "http") fail("--scheme must be https or http");
7898
- const port = options.port === void 0 ? scheme === "https" ? 443 : 80 : Number(options.port);
7899
- if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--port must be 1-65535");
7900
- const ctx = buildContext();
7901
- const org = await resolveOrg(ctx, options.org);
7902
- const agent = await resolveAgent(ctx, org.id, ref);
7903
- const version = options.version === void 0 ? void 0 : Number(options.version);
7904
- if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
7905
- const published = await loadVersion(ctx, org.id, agent, version);
7906
- if (!published) fail(`${agent.slug} has no published policy — every request is denied`);
7907
- const verdict = evaluatePolicy(published.rules, {
7908
- host: options.host,
7909
- transport: {
7910
- scheme,
7911
- port
7912
- },
7913
- method: options.method,
7914
- path: options.path,
7915
- ...options.secret === void 0 ? {} : { secret: options.secret }
7916
- });
7917
- const reason = describePolicyVerdict(verdict, published.rules);
7918
- emit(options, {
7919
- version: published.version,
7920
- verdict,
7921
- reason
7922
- }, () => {
7923
- const target = `${options.method.toUpperCase()} ${scheme}://${options.host}:${port}${options.path}`;
7924
- console.log(`${verdict.decision === "allow" ? "allow" : "DENY"} ${target}${options.secret ? ` [${options.secret}]` : ""}`);
7925
- console.log(` v${published.version}: ${reason}`);
7926
- });
7927
- if (verdict.decision !== "allow") process.exitCode = 1;
7928
- });
7929
- }
7930
- function registerPolicyCommands(policy) {
7931
- policy.command("list <ref>").description("published versions, newest first").option("--org <slug>", "organization").option("--json", "machine-readable output").action(async (ref, options) => {
7932
- const ctx = buildContext();
7933
- const org = await resolveOrg(ctx, options.org);
7934
- const agent = await resolveAgent(ctx, org.id, ref);
7935
- const { policies } = await ctx.client.listAgentPolicies(org.id, agent.id);
7936
- emit(options, { policies }, () => printTable(policies, [
7937
- col("version", (p) => `v${p.version}`),
7938
- col("rules", (p) => p.ruleCount),
7939
- col("signer", (p) => p.signerThumbprint.slice(0, 12)),
7940
- col("published", (p) => p.publishedAt),
7941
- col("expires", (p) => secondsUntil(p.expiresAt) <= 0 ? `${p.expiresAt} (expired)` : p.expiresAt),
7942
- col("note", (p) => p.rolledBackFromVersion ? `restored v${p.rolledBackFromVersion}` : "-")
7943
- ], "nothing published yet"));
7944
- });
7945
- 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) => {
7946
- const ctx = buildContext();
7947
- const org = await resolveOrg(ctx, options.org);
7948
- const agent = await resolveAgent(ctx, org.id, ref);
7949
- const version = options.version === void 0 ? void 0 : Number(options.version);
7950
- if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
7951
- const published = await loadVersion(ctx, org.id, agent, version);
7952
- if (!published) fail(`${agent.slug} has no published policy`);
7953
- emit(options, { policy: published }, () => {
7954
- printFields([
7955
- ["version", `v${published.version}`],
7956
- ["signer", published.signerThumbprint],
7957
- ["published", published.publishedAt],
7958
- ["expires", describeExpiry(published.expiresAt)]
7959
- ]);
7960
- section("rules");
7961
- printRules(published.rules);
7962
- });
7963
- });
7964
- /**
7965
- * Write the live rules out as the editable source for the next publish. This
7966
- * is the half that makes policy-as-code work: what comes back out is exactly
7967
- * the shape `publish` takes in, so a round trip is a no-op diff.
7968
- */
7969
- 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) => {
7970
- const ctx = buildContext();
7971
- const org = await resolveOrg(ctx, options.org);
7972
- const agent = await resolveAgent(ctx, org.id, ref);
7973
- const version = options.version === void 0 ? void 0 : Number(options.version);
7974
- if (version !== void 0 && !Number.isInteger(version)) fail("--version must be an integer");
7975
- const published = await loadVersion(ctx, org.id, agent, version);
7976
- const rules = published?.rules ?? [];
7977
- const body = `${JSON.stringify({ rules }, null, 2)}\n`;
7978
- if (options.out) {
7979
- writeFileSync(options.out, body);
7980
- 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)`);
7981
- 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;
7982
8259
  }
7983
- process.stdout.write(body);
7984
- });
7985
- 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) => {
7986
- const ctx = buildContext();
7987
- 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.");
7988
- const org = await resolveOrg(ctx, options.org);
7989
- const agent = await resolveAgent(ctx, org.id, ref);
7990
- const ttlSeconds = options.ttl ? parseDurationSeconds(options.ttl, "--ttl") : POLICY_DEFAULT_TTL_SECONDS;
7991
- if (ttlSeconds < 3600 || ttlSeconds > 7776e3) fail(`--ttl must be between ${POLICY_MIN_TTL_SECONDS / 3600}h and ${POLICY_MAX_TTL_SECONDS / 86400}d`);
7992
- const { raw, label } = await readRuleSource(options.file);
7993
- const rules = parseRuleFile(raw, label);
7994
- const live = await loadVersion(ctx, org.id, agent);
7995
- const nextVersion = agent.currentPolicyVersion + 1;
7996
- const from = agent.currentPolicyVersion === 0 ? "nothing" : `v${agent.currentPolicyVersion}`;
7997
- console.error(`${agent.slug}: ${from} → v${nextVersion}`);
7998
- printPolicyDiff(live?.rules ?? [], rules);
7999
- await confirmDestructive(options.yes, `Publish v${nextVersion} for ${agent.slug} (${rules.length} rule(s), valid ${options.ttl ?? "7d"})?`);
8000
- const { signingKey, jwk } = await getPolicySigner(ctx);
8001
- const issuedAt = Math.floor(Date.now() / 1e3);
8002
- const bundle = await signAgentPolicy(signingKey, jwk, {
8003
- v: policyBundleVersionFor(rules),
8004
- org: org.id,
8005
- agent: agent.id,
8006
- agent_slug: agent.slug,
8007
- policy_version: nextVersion,
8008
- issued_at: issuedAt,
8009
- expires_at: issuedAt + ttlSeconds,
8010
- rules
8011
- });
8012
- const { policy: published } = await ctx.client.publishAgentPolicy(org.id, agent.id, bundle);
8013
- emit(options, { policy: published }, () => {
8014
- console.error(`published v${published.version} — signed with ${published.signerThumbprint.slice(0, 12)}…, expires ${published.expiresAt}`);
8015
- console.error("proxies pick it up at their next refresh");
8016
- });
8017
- });
8018
- 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) => {
8019
- const ctx = buildContext();
8020
- const org = await resolveOrg(ctx, options.org);
8021
- const agent = await resolveAgent(ctx, org.id, ref);
8022
- const version = Number(versionArg);
8023
- if (!Number.isInteger(version) || version < 1) fail("version must be a positive integer");
8024
- const source = await loadVersion(ctx, org.id, agent, version);
8025
- if (!source) fail(`no version ${version}`);
8026
- 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`);
8027
- await confirmDestructive(options.yes, `Roll ${agent.slug} back to v${version} (${source.ruleCount} rule(s))?`);
8028
- const { policy: published } = await ctx.client.rollbackAgentPolicy(org.id, agent.id, version);
8029
- console.error(`published v${published.version} carrying v${version}'s bundle`);
8030
- });
8260
+ this.pruneExpired();
8261
+ }
8262
+ /** Drop this entry — used when the API refuses the token. */
8263
+ invalidate() {
8264
+ rmSync(this.path(), { force: true });
8265
+ }
8031
8266
  /**
8032
- * What a proxy actually gets. Useful when a deployment misbehaves and the
8033
- * question is whether the API, the pin, or the rules are at fault — so it
8034
- * re-derives the thumbprint from the bundle rather than reporting the one the
8035
- * 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`.
8036
8270
  */
8037
- 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) => {
8038
- const fetched = await buildContext().client.getAgentPolicyBundle(ref);
8039
- if (options.bundle) {
8040
- process.stdout.write(`${fetched.bundle}\n`);
8271
+ pruneExpired() {
8272
+ let names;
8273
+ try {
8274
+ names = readdirSync(this.dir);
8275
+ } catch {
8041
8276
  return;
8042
8277
  }
8043
- const body = parseAgentPolicyUnverified(fetched.bundle);
8044
- const derived = await policySignerThumbprint(body.signer.jwk);
8045
- emit(options, {
8046
- ...fetched,
8047
- derivedThumbprint: derived,
8048
- rules: body.rules
8049
- }, () => {
8050
- printFields([
8051
- ["agent", `${fetched.agent.slug} (${fetched.agent.name})`],
8052
- ["version", `v${fetched.version}`],
8053
- ["expires", describeExpiry(fetched.expiresAt)],
8054
- ["signer (in bundle)", derived],
8055
- ["signer (reported)", fetched.signerThumbprint === derived ? "matches" : `${fetched.signerThumbprint} — DOES NOT MATCH the bundle; a proxy would refuse this`]
8056
- ]);
8057
- section("rules");
8058
- printRules(body.rules);
8059
- console.error("");
8060
- console.error("this command does not verify the signature — only a pinned signer list can, which lives in the proxy's own config");
8061
- });
8062
- });
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);
8063
8292
  }
8064
- /** Domain separators — must match `crates/seekrit-cache`. */
8065
- const KEY_DOMAIN = "seekrit-lkg-cache-v1";
8066
- const TOKEN_DOMAIN = "seekrit-token-fp-v1";
8067
- /** 24h: long enough to ride out an outage, short enough to bound the tail. */
8068
- const DEFAULT_MAX_AGE_MS = 864e5;
8069
8293
  /**
8070
- * Derive the key for one resolve request. Overrides are sorted, so flag order
8071
- * never splits the cache. Byte-for-byte identical to `CacheKey::new` in
8072
- * `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.
8073
8301
  */
8074
- function cacheKey(apiUrl, token, branch, overrides = {}) {
8075
- const pairs = Object.entries(overrides).map(([group, env]) => `${group}:${env}`).sort();
8076
- return {
8077
- key: createHash("sha256").update([
8078
- KEY_DOMAIN,
8079
- apiUrl.replace(/\/+$/, ""),
8080
- token,
8081
- branch ?? "",
8082
- pairs.join(",")
8083
- ].join("\n")).digest("hex"),
8084
- tokenFingerprint: tokenFingerprint(token)
8085
- };
8086
- }
8087
- /** The hex SHA-256 identifying a token without storing it. */
8088
- function tokenFingerprint(token) {
8089
- 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;
8090
8306
  }
8091
8307
  /**
8092
- * `$XDG_CACHE_HOME/seekrit`, else `~/.cache/seekrit` — beside the config
8093
- * directory holding the credential, never a shared temp directory.
8094
- */
8095
- function defaultCacheDir() {
8096
- return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "seekrit");
8097
- }
8098
- /** Render an age for a log line, rounded to its largest whole unit. */
8099
- function humanize(ms) {
8100
- const secs = Math.floor(ms / 1e3);
8101
- if (secs < 60) return `${secs}s`;
8102
- if (secs < 3600) return `${Math.floor(secs / 60)}m`;
8103
- if (secs < 86400) return `${Math.floor(secs / 3600)}h`;
8104
- return `${Math.floor(secs / 86400)}d`;
8105
- }
8106
- /**
8107
- * Whether a failed resolve means the API was *unreachable* (the cache may stand
8108
- * in) rather than *refusing us* (it must not). A refusal is an answer, and
8109
- * revocation is supposed to take effect the moment it arrives.
8110
- */
8111
- function mayFallBack(err) {
8112
- if (err instanceof SeekritApiError) return err.status >= 500 || err.status === 429;
8113
- return true;
8114
- }
8115
- /** An opened cache, bound to one resolve request. */
8116
- var LkgCache = class {
8117
- dir;
8118
- maxAgeMs;
8119
- id;
8120
- constructor(id, options = {}) {
8121
- this.id = id;
8122
- this.dir = options.dir ?? defaultCacheDir();
8123
- this.maxAgeMs = options.maxAgeMs ?? 864e5;
8124
- }
8125
- path() {
8126
- return join(this.dir, `${this.id.key}.json`);
8127
- }
8128
- read() {
8129
- const path = this.path();
8130
- if (!existsSync(path)) return { kind: "missing" };
8131
- let envelope;
8132
- try {
8133
- envelope = JSON.parse(readFileSync(path, "utf8"));
8134
- } catch (err) {
8135
- return {
8136
- kind: "unusable",
8137
- reason: `corrupt cache entry: ${message(err)}`
8138
- };
8139
- }
8140
- if (envelope.version !== 1) return {
8141
- kind: "unusable",
8142
- reason: `cache entry is format v${envelope.version}, this build reads v1`
8143
- };
8144
- if (envelope.tokenFingerprint !== this.id.tokenFingerprint) return {
8145
- kind: "unusable",
8146
- reason: "cache entry belongs to a different token"
8147
- };
8148
- if (typeof envelope.body !== "string") return {
8149
- kind: "unusable",
8150
- reason: "cache entry has no body"
8151
- };
8152
- const ageMs = Math.max(0, Date.now() - envelope.fetchedAt * 1e3);
8153
- if (ageMs >= this.maxAgeMs) return {
8154
- kind: "expired",
8155
- ageMs
8156
- };
8157
- return {
8158
- kind: "hit",
8159
- body: envelope.body,
8160
- ageMs
8161
- };
8162
- }
8163
- /**
8164
- * Record a freshly-fetched response. Written to a temporary file and renamed,
8165
- * so a concurrent reader sees either the old entry or the new one — never a
8166
- * half-written file. Throws only on genuinely unexpected I/O; callers treat a
8167
- * failed write as a warning, never as a failed resolve.
8168
- */
8169
- write(body) {
8170
- mkdirSync(this.dir, {
8171
- recursive: true,
8172
- mode: 448
8173
- });
8174
- const envelope = {
8175
- version: 1,
8176
- fetchedAt: Math.floor(Date.now() / 1e3),
8177
- tokenFingerprint: this.id.tokenFingerprint,
8178
- body
8179
- };
8180
- const tmp = join(this.dir, `${this.id.key}.${process.pid}.tmp`);
8181
- try {
8182
- writeFileSync(tmp, JSON.stringify(envelope), { mode: 384 });
8183
- renameSync(tmp, this.path());
8184
- } catch (err) {
8185
- rmSync(tmp, { force: true });
8186
- throw err;
8187
- }
8188
- this.pruneExpired();
8189
- }
8190
- /** Drop this entry — used when the API refuses the token. */
8191
- invalidate() {
8192
- rmSync(this.path(), { force: true });
8193
- }
8194
- /**
8195
- * Delete entries past their usefulness (a rotated token or a changed
8196
- * `--with` leaves one nothing will read again). Best-effort and silent, and
8197
- * it only ever touches files this cache named: `<64 hex>.json`.
8198
- */
8199
- pruneExpired() {
8200
- let names;
8201
- try {
8202
- names = readdirSync(this.dir);
8203
- } catch {
8204
- return;
8205
- }
8206
- for (const name of names) {
8207
- if (!/^[0-9a-f]{64}\.json$/.test(name)) continue;
8208
- const path = join(this.dir, name);
8209
- let expired = true;
8210
- try {
8211
- const envelope = JSON.parse(readFileSync(path, "utf8"));
8212
- expired = Math.max(0, Date.now() - envelope.fetchedAt * 1e3) >= this.maxAgeMs;
8213
- } catch {}
8214
- if (expired) rmSync(path, { force: true });
8215
- }
8216
- }
8217
- };
8218
- function message(err) {
8219
- return err instanceof Error ? err.message : String(err);
8220
- }
8221
- /**
8222
- * Fetch + decrypt every secret in a single environment.
8223
- *
8224
- * `${OTHER_SECRET}` references are expanded (see `@seekrit/core`'s
8225
- * `interpolate`) unless `raw` is set. Only this environment's own secrets are in
8226
- * scope here — a reference to a secret inherited from a composed group is left
8227
- * literal, because the group layers aren't fetched. `materializeEnv` is the
8228
- * fully-layered view.
8229
- */
8230
- async function fetchDecryptedSecrets(ctx, orgId, envId, opts = {}) {
8231
- const [dek, { secrets }] = await Promise.all([getDek(ctx, orgId, envId), ctx.client.listSecrets(orgId, envId)]);
8232
- const entries = await Promise.all(secrets.map(async (secret) => [secret.name, await decryptSecret(dek, secret.ciphertext, secretAad(envId, secret.name))]));
8233
- return interpolateValues(Object.fromEntries(entries), !opts.raw).values;
8234
- }
8235
- /**
8236
- * Expand `${OTHER_SECRET}` references in a merged variable set. A cycle becomes
8237
- * the CLI's standard fatal exit — it is a config bug with no correct value to
8238
- * emit. Pass `enabled: false` (`--no-interpolate`) to hand the set back as-is.
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.
8239
8311
  */
8240
8312
  function interpolateValues(values, enabled = true) {
8241
8313
  if (!enabled) return {
@@ -8320,6 +8392,7 @@ async function materializeEnv(ctx, opts) {
8320
8392
  const privateKey = await getPrivateKey(ctx);
8321
8393
  const values = {};
8322
8394
  const provenance = {};
8395
+ const origin = {};
8323
8396
  for (const layer of layers) {
8324
8397
  const dek = await unwrapDek(layer.wrappedDek, privateKey);
8325
8398
  let label;
@@ -8329,14 +8402,32 @@ async function materializeEnv(ctx, opts) {
8329
8402
  for (const secret of layer.secrets) {
8330
8403
  values[secret.name] = await decryptSecret(dek, secret.ciphertext, secretAad(layer.environmentId, secret.name));
8331
8404
  provenance[secret.name] = label;
8405
+ origin[secret.name] = {
8406
+ envId: layer.environmentId,
8407
+ dek
8408
+ };
8332
8409
  }
8333
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
+ }
8334
8424
  const loadedEnvFiles = overlayEnvFiles(values, provenance, opts.envFiles);
8335
8425
  return {
8336
8426
  ...interpolateValues(values, opts.interpolate !== false),
8337
8427
  provenance,
8338
8428
  scope,
8339
- loadedEnvFiles
8429
+ loadedEnvFiles,
8430
+ agentLogins
8340
8431
  };
8341
8432
  }
8342
8433
  /**
@@ -8368,50 +8459,962 @@ async function resolveWithCache(ctx, query, cache) {
8368
8459
  warn(`${errorMessage(err)} — using cached secrets fetched ${humanize(found.ageMs)} ago`);
8369
8460
  return JSON.parse(found.body);
8370
8461
  }
8371
- if (found.kind === "expired") warn(`cached secrets are ${humanize(found.ageMs)} old, past --cache-max-age`);
8372
- else if (found.kind === "unusable") warn(`ignoring the cached secrets: ${found.reason}`);
8373
- throw err;
8374
- }
8375
- }
8376
- function warn(text) {
8377
- process.stderr.write(`seekrit: ${text}\n`);
8378
- }
8379
- function errorMessage(err) {
8380
- return err instanceof Error ? err.message : String(err);
8381
- }
8382
- /**
8383
- * Overlay `.env` files onto an existing value/provenance set (later files win).
8384
- * Missing files are skipped. Returns the files that were actually loaded. Used
8385
- * both by {@link materializeEnv} and by `seekrit run`'s degraded path, where
8386
- * managed secrets are unavailable but `.env` should still apply.
8387
- */
8388
- function overlayEnvFiles(values, provenance, envFiles) {
8389
- const loaded = [];
8390
- for (const file of envFiles) {
8391
- if (!existsSync(file)) continue;
8392
- loaded.push(file);
8393
- for (const [name, value] of Object.entries(parseDotenv(readFileSync(file, "utf8")))) {
8394
- values[name] = value;
8395
- 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;
8396
9397
  }
8397
- }
8398
- return loaded;
8399
- }
8400
- /**
8401
- * Print a name → source table to stderr (never the secret values). Names whose
8402
- * value had references expanded are marked, and dangling references are called
8403
- * out afterwards — a typo'd `${NAME}` is otherwise invisible, since it is
8404
- * deliberately passed through as literal text.
8405
- */
8406
- function printExplain(provenance, refs = {}) {
8407
- const interpolated = new Set(refs.interpolated ?? []);
8408
- const names = Object.keys(provenance).sort();
8409
- const width = names.reduce((w, n) => Math.max(w, n.length), 0);
8410
- for (const name of names) {
8411
- const marker = interpolated.has(name) ? " (interpolated)" : "";
8412
- process.stderr.write(`${name.padEnd(width)} ${provenance[name]}${marker}\n`);
8413
- }
8414
- 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
+ });
8415
9418
  }
8416
9419
  // src/env-diff.ts
8417
9420
  /**
@@ -10787,132 +11790,6 @@ function registerBranchCommands(program) {
10787
11790
  console.error(`deleted branch ${app.slug}#${target.slug}`);
10788
11791
  });
10789
11792
  }
10790
- // src/changes.ts
10791
- /**
10792
- * Change requests: the review queue in front of protected environments, and the
10793
- * protection settings that create it.
10794
- *
10795
- * Reviewing here is keyless by construction — a change request carries
10796
- * ciphertext, so the CLI can show a reviewer *what* is changing and who
10797
- * proposed it without ever holding a DEK. `seekrit changes show` therefore
10798
- * prints no value, and deliberately does not offer to.
10799
- */
10800
- function registerChangeCommands(program) {
10801
- const changes = program.command("changes").description("review changes queued for protected environments");
10802
- 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) => {
10803
- const ctx = buildContext();
10804
- const ref = await resolveOrg(ctx, options.org);
10805
- const status = options.all ? void 0 : parseStatus(options.status);
10806
- const limit = Number(options.limit);
10807
- if (!Number.isInteger(limit) || limit < 1) fail("--limit must be a positive whole number");
10808
- const { changeRequests } = await ctx.client.listChangeRequests(ref.id, {
10809
- status,
10810
- limit: Math.min(limit, 200)
10811
- });
10812
- emit(options, { changeRequests }, () => {
10813
- printTable(changeRequests, [
10814
- col("id", (r) => r.id),
10815
- col("change", (r) => describeChange(r.kind, r.secretName, r.restoreFromVersion)),
10816
- col("status", (r) => r.status),
10817
- col("proposed by", (r) => `${r.requestedByType}:${r.requestedById}`),
10818
- col("when", (r) => r.createdAt)
10819
- ], options.all ? "no change requests" : "nothing waiting for review");
10820
- });
10821
- });
10822
- 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) => {
10823
- const ctx = buildContext();
10824
- const ref = await resolveOrg(ctx, options.org);
10825
- const { changeRequest, reviews } = await ctx.client.getChangeRequest(ref.id, id);
10826
- emit(options, {
10827
- changeRequest,
10828
- reviews
10829
- }, () => {
10830
- printFields([
10831
- ["id", changeRequest.id],
10832
- ["change", describeChange(changeRequest.kind, changeRequest.secretName, changeRequest.restoreFromVersion)],
10833
- ["environment", changeRequest.environmentId],
10834
- ["status", changeRequest.status],
10835
- ["base version", changeRequest.baseVersion ?? "(new secret)"],
10836
- ["approvals needed", changeRequest.requiredApprovals],
10837
- ["proposed by", `${changeRequest.requestedByType}:${changeRequest.requestedById}`],
10838
- ["note", changeRequest.note ?? "-"]
10839
- ]);
10840
- section("reviews");
10841
- printTable(reviews, [
10842
- col("decision", (r) => r.decision),
10843
- col("reviewer", (r) => r.reviewerId ?? "(removed)"),
10844
- col("comment", (r) => r.comment ?? "-"),
10845
- col("when", (r) => r.createdAt)
10846
- ], "no reviews yet");
10847
- });
10848
- });
10849
- 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) => {
10850
- const ctx = buildContext();
10851
- const ref = await resolveOrg(ctx, options.org);
10852
- const result = await ctx.client.approveChangeRequest(ref.id, id, options.comment);
10853
- emit(options, result, () => {
10854
- if (result.status === "applied") console.error(`approved and applied${result.version ? ` as v${result.version}` : ""}`);
10855
- else if (result.status === "superseded") console.error(`not applied: ${result.reason ?? "the secret has changed since"}`);
10856
- else console.error(`approved — ${result.approvals ?? 0}/${result.required ?? "?"} approvals`);
10857
- });
10858
- });
10859
- 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) => {
10860
- const ctx = buildContext();
10861
- const ref = await resolveOrg(ctx, options.org);
10862
- emit(options, await ctx.client.rejectChangeRequest(ref.id, id, options.comment), () => console.error("rejected"));
10863
- });
10864
- 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) => {
10865
- const ctx = buildContext();
10866
- const ref = await resolveOrg(ctx, options.org);
10867
- emit(options, await ctx.client.withdrawChangeRequest(ref.id, id), () => console.error("withdrawn"));
10868
- });
10869
- const protection = changes.command("protection").description("require review before an environment's secrets change");
10870
- 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) => {
10871
- const ctx = buildContext();
10872
- const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
10873
- const result = await ctx.client.getEnvironmentProtection(orgId, envId);
10874
- emit(options, result, () => {
10875
- if (!result.protection) {
10876
- console.log(`${label}: unprotected — writes land immediately`);
10877
- return;
10878
- }
10879
- printFields([
10880
- ["environment", label],
10881
- ["approvals required", result.protection.requiredApprovals],
10882
- ["self-approval", result.protection.allowSelfApproval ? "allowed" : "not allowed"],
10883
- ["waiting for review", result.openChangeRequests]
10884
- ]);
10885
- });
10886
- });
10887
- 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) => {
10888
- const ctx = buildContext();
10889
- const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
10890
- const requiredApprovals = Number(options.approvals);
10891
- if (!Number.isInteger(requiredApprovals) || requiredApprovals < 1) fail("--approvals must be a positive whole number");
10892
- emit(options, await ctx.client.setEnvironmentProtection(orgId, envId, {
10893
- requiredApprovals,
10894
- allowSelfApproval: options.allowSelfApproval ?? false
10895
- }), () => console.error(`${label} now requires ${requiredApprovals} approval${requiredApprovals === 1 ? "" : "s"} before a secret changes`));
10896
- });
10897
- 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) => {
10898
- const ctx = buildContext();
10899
- const { orgId, envId, label } = await resolveEnvTarget(ctx, options);
10900
- emit(options, await ctx.client.deleteEnvironmentProtection(orgId, envId), () => console.error(`${label} no longer requires approval — writes land immediately`));
10901
- });
10902
- }
10903
- function parseStatus(raw) {
10904
- if (!raw) return void 0;
10905
- if (!CHANGE_REQUEST_STATUSES.includes(raw)) fail(`unknown status "${raw}" — one of ${CHANGE_REQUEST_STATUSES.join(", ")}`);
10906
- return raw;
10907
- }
10908
- /**
10909
- * Report a write that was queued instead of performed. Every write path calls
10910
- * this: a CLI that printed "set FOO" for a change nobody has approved would be
10911
- * telling the operator the deploy is ready when it is not.
10912
- */
10913
- function reportQueued(cr) {
10914
- console.error(`queued for review: ${describeChange(cr.kind, cr.secretName, cr.restoreFromVersion)} — needs ${cr.requiredApprovals} approval${cr.requiredApprovals === 1 ? "" : "s"} (${cr.id})`);
10915
- }
10916
11793
  /** Plugin id and integration id declared by `@seekrit/openclaw-plugin`. */
10917
11794
  const PLUGIN_ID = "seekrit";
10918
11795
  const PLUGIN_INTEGRATION_ID = "seekrit";
@@ -16937,10 +17814,13 @@ async function materializeForRun(options) {
16937
17814
  const ctx = tryBuildContext(dotenvVars);
16938
17815
  if (!ctx) throw new Error("no credentials found (set SEEKRIT_TOKEN / SEEKRIT_DEV_USER or run `seekrit login`)");
16939
17816
  const branch = options.branch ?? process.env.SEEKRIT_BRANCH ?? dotenvVars.SEEKRIT_BRANCH;
16940
- return await materialize(ctx, {
16941
- ...options,
16942
- branch
16943
- }, dotenvVars);
17817
+ return {
17818
+ ...await materialize(ctx, {
17819
+ ...options,
17820
+ branch
17821
+ }, dotenvVars),
17822
+ ctx
17823
+ };
16944
17824
  } catch (err) {
16945
17825
  console.error(`seekrit: continuing without seekrit-managed secrets: ${describeError(err)}`);
16946
17826
  const values = {};
@@ -16948,17 +17828,35 @@ async function materializeForRun(options) {
16948
17828
  overlayEnvFiles(values, provenance, envFiles);
16949
17829
  return {
16950
17830
  ...interpolateValues(values, options.interpolate !== false),
16951
- provenance
17831
+ provenance,
17832
+ agentLogins: {},
17833
+ scope: void 0,
17834
+ ctx: void 0
16952
17835
  };
16953
17836
  }
16954
17837
  }
16955
- 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) => {
16956
17839
  const [cmd, ...args] = commandParts;
16957
17840
  if (!cmd) fail("no command given");
16958
17841
  const leaseOptions = parseRunLeaseOptions(options.lease, options.leaseTtl);
16959
- 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 = [];
16960
17851
  const leases = leaseOptions ? await prepareRunLeases(leaseOptions, options.org, options.envFile) : void 0;
16961
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
+ }
16962
17860
  const leased = await leases?.mint(interrupted) ?? {};
16963
17861
  if (options.explain) {
16964
17862
  for (const name of Object.keys(values)) if (process.env[name] !== void 0 && process.env[name] !== values[name]) provenance[name] = "env";
@@ -16980,7 +17878,10 @@ program.command("run").description("run a command with decrypted secrets injecte
16980
17878
  ...env,
16981
17879
  ...leased
16982
17880
  };
16983
- }, () => 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
+ });
16984
17885
  if (result.signal) process.kill(process.pid, result.signal);
16985
17886
  else process.exit(result.code);
16986
17887
  });
@@ -17091,6 +17992,7 @@ registerRedisCommands(program);
17091
17992
  registerProvisionerCommands(program);
17092
17993
  registerProxyCommands(program);
17093
17994
  registerAgentCommands(program);
17995
+ registerAgentLoginCommands(program);
17094
17996
  registerPaperclipCommands(program);
17095
17997
  registerNomadCommands(program);
17096
17998
  registerOpenclawCommands(program);