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