sentinelayer-cli 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +8 -4
  2. package/package.json +1 -1
  3. package/src/agents/jules/tools/aidenid-email.js +7 -9
  4. package/src/agents/jules/tools/frontend-analyze.js +15 -3
  5. package/src/agents/jules/tools/runtime-audit.js +51 -21
  6. package/src/ai/aidenid.js +5 -4
  7. package/src/ai/proxy.js +15 -14
  8. package/src/audit/orchestrator.js +8 -5
  9. package/src/auth/admission-scope.js +5 -3
  10. package/src/auth/credential-destinations.js +221 -0
  11. package/src/auth/gate.js +4 -4
  12. package/src/auth/http.js +22 -5
  13. package/src/auth/service.js +52 -56
  14. package/src/cli.js +14 -6
  15. package/src/commands/ai/provision-governance.js +7 -6
  16. package/src/commands/init.js +25 -16
  17. package/src/commands/scan.js +6 -4
  18. package/src/commands/session.js +33 -45
  19. package/src/commands/spec.js +2 -2
  20. package/src/commands/watch.js +4 -3
  21. package/src/config/service.js +37 -0
  22. package/src/daemon/pulse.js +2 -1
  23. package/src/daemon/watchdog.js +117 -68
  24. package/src/legacy-cli.js +119 -39
  25. package/src/mcp/bridge-inputs.js +223 -0
  26. package/src/mcp/cli-command-tools.js +157 -7
  27. package/src/mcp/registry.js +2 -1
  28. package/src/mcp/session-stdio-server.js +114 -35
  29. package/src/mcp/smoke.js +27 -11
  30. package/src/mcp/token-service.js +7 -9
  31. package/src/memory/retrieval.js +14 -5
  32. package/src/pocket/ring-owner.js +13 -8
  33. package/src/review/ai-review.js +2 -2
  34. package/src/review/dd-report-email-client.js +4 -5
  35. package/src/session/admission-access.js +13 -10
  36. package/src/session/admission-auth.js +95 -16
  37. package/src/session/admission.js +44 -9
  38. package/src/session/checkpoints.js +11 -10
  39. package/src/session/file-locks.js +19 -18
  40. package/src/session/invitations.js +26 -19
  41. package/src/session/live-source.js +13 -13
  42. package/src/session/route-classes.js +107 -0
  43. package/src/session/sync.js +70 -56
  44. package/src/session/tickets.js +21 -19
  45. package/src/session/title-sync.js +4 -3
  46. package/src/telemetry/sync.js +8 -9
package/src/auth/gate.js CHANGED
@@ -7,6 +7,7 @@ import pc from "picocolors";
7
7
  import { resolveActiveAuthSession } from "./service.js";
8
8
  import { resolveAgentAdmissionTarget } from "../session/admission-auth.js";
9
9
  import { authLoginHint } from "../ui/command-hints.js";
10
+ import { isAuthenticated } from "./credential-destinations.js";
10
11
 
11
12
  /**
12
13
  * Auth gate — ensures user is logged in before running any command.
@@ -316,12 +317,11 @@ function isSessionUnexpired(tokenExpiresAt) {
316
317
  // without fixing anything.
317
318
  //
318
319
  // So the gate checks:
319
- // - session.token is present and non-empty
320
+ // - the session carries a credential
320
321
  // - for source === "session", expiry is in the future
321
322
  // - for source === "env" or "config", the downstream API call is the gate
322
323
  function isAuthenticatedSessionValid(session) {
323
- const token = String(session?.token || "").trim();
324
- if (!token) {
324
+ if (!isAuthenticated(session)) {
325
325
  return false;
326
326
  }
327
327
  if (String(session?.source || "").trim() === "session") {
@@ -369,7 +369,7 @@ export async function checkAuthGate(args) {
369
369
  }
370
370
  if (session) {
371
371
  // Session resolved but failed validation (empty token or expired).
372
- const tokenPresent = Boolean(String(session?.token || "").trim());
372
+ const tokenPresent = isAuthenticated(session);
373
373
  if (!tokenPresent) {
374
374
  resolveError = "session_token_missing";
375
375
  } else if (String(session?.source || "").trim() === "session" && !isSessionUnexpired(session?.tokenExpiresAt)) {
package/src/auth/http.js CHANGED
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { installTestEgressGuard } from "../net/test-egress-guard.js";
7
+ import { CredentialDestinationRefused, credentialedRequest, isCredential } from "./credential-destinations.js";
7
8
 
8
9
  // No-op outside tests; see src/net/test-egress-guard.js.
9
10
  installTestEgressGuard();
@@ -20,7 +21,10 @@ export const CIRCUIT_BREAKER_THRESHOLD = 5;
20
21
  export const CIRCUIT_BREAKER_COOLDOWN_MS = 30_000;
21
22
 
22
23
  const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
23
- const CIRCUIT_TRACK_STATUS_CODES = new Set([401, 403, 408, 425, 429, 500, 502, 503, 504]);
24
+ // 401 and 403 are not tracked: they refuse one credential (for example an expired or
25
+ // revoked agent admission) and say nothing about the origin's health, so they must not
26
+ // open the per-origin breaker for every other credential on this machine.
27
+ const CIRCUIT_TRACK_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
24
28
  const circuitStateByScope = new Map();
25
29
 
26
30
  // File-backed cache. Lets cooldown + failure counts survive across CLI
@@ -422,7 +426,8 @@ export function __resetRequestCircuitForTests(scope) {
422
426
  * maxRetries?: number,
423
427
  * retryDelayMs?: number
424
428
  * allowEmptyBody?: boolean
425
- * }} [options]
429
+ * credential?: object
430
+ * }} [options] `credential` (src/auth/credential-destinations.js) is sent with credentialedRequest.
426
431
  * @returns {Promise<any>}
427
432
  */
428
433
  export async function requestJson(
@@ -430,6 +435,7 @@ export async function requestJson(
430
435
  {
431
436
  method = "GET",
432
437
  headers = {},
438
+ credential = null,
433
439
  body,
434
440
  idempotencyKey = null,
435
441
  allowNonIdempotent = false,
@@ -439,6 +445,13 @@ export async function requestJson(
439
445
  allowEmptyBody = false,
440
446
  } = {}
441
447
  ) {
448
+ if (credential && !isCredential(credential)) {
449
+ throw new TypeError("requestJson: credential must come from src/auth/credential-destinations.js.");
450
+ }
451
+ // Without a credential, nothing here attaches one: a caller-built Authorization header is refused.
452
+ if (Object.keys(headers || {}).some((name) => /^(?:proxy-)?authorization$/i.test(name))) {
453
+ throw new TypeError("requestJson: send a credential with { credential }, not an Authorization header.");
454
+ }
442
455
  const normalizedMethod = String(method || "GET").trim().toUpperCase();
443
456
  const explicitIdempotencyKey = String(idempotencyKey || "").trim() || null;
444
457
  const existingIdempotencyKey = explicitIdempotencyKey || resolveIdempotencyKey(headers);
@@ -492,13 +505,14 @@ export async function requestJson(
492
505
  }, normalizedTimeoutMs);
493
506
 
494
507
  try {
495
- const response = await fetch(String(url), {
508
+ const init = {
496
509
  method: normalizedMethod,
497
510
  headers: outgoingHeaders,
498
511
  body: body === undefined ? undefined : JSON.stringify(body),
499
512
  redirect: "error",
500
513
  signal: controller.signal,
501
- });
514
+ };
515
+ const response = credential ? await credentialedRequest(credential, url, init) : await fetch(String(url), init);
502
516
 
503
517
  const rawBody = await response.text();
504
518
  const trimmedBody = rawBody.trim();
@@ -575,7 +589,8 @@ export async function requestJson(
575
589
  await sleep(delayMs);
576
590
  continue;
577
591
  } catch (error) {
578
- if (error instanceof SentinelayerApiError) {
592
+ // A refused destination is final: nothing was sent, so no retry and no breaker count.
593
+ if (error instanceof SentinelayerApiError || error instanceof CredentialDestinationRefused) {
579
594
  throw error;
580
595
  }
581
596
 
@@ -647,6 +662,7 @@ export async function requestJsonMutation(
647
662
  {
648
663
  method = "POST",
649
664
  headers = {},
665
+ credential = null,
650
666
  body,
651
667
  idempotencyKey = null,
652
668
  operationName,
@@ -675,6 +691,7 @@ export async function requestJsonMutation(
675
691
  return requestJson(url, {
676
692
  method: normalizedMethod,
677
693
  headers,
694
+ credential,
678
695
  body,
679
696
  idempotencyKey: resolvedIdempotencyKey,
680
697
  timeoutMs,
@@ -6,6 +6,14 @@ import open from "open";
6
6
 
7
7
  import { loadConfig } from "../config/service.js";
8
8
  import { scopedAdmissionAuth } from "./admission-scope.js";
9
+ import {
10
+ DEFAULT_API_URL,
11
+ assertTrustedApiUrl,
12
+ credentialFor,
13
+ isAuthenticated,
14
+ resolveTrustContext,
15
+ userCredential,
16
+ } from "./credential-destinations.js";
9
17
  import { SentinelayerApiError, requestJson, requestJsonMutation } from "./http.js";
10
18
  import {
11
19
  clearStoredSession,
@@ -15,7 +23,6 @@ import {
15
23
  } from "./session-store.js";
16
24
  import { authLoginHint } from "../ui/command-hints.js";
17
25
 
18
- const DEFAULT_API_URL = "https://api.sentinelayer.com";
19
26
  /** Default maximum wall-clock wait for browser-based CLI auth approval (ms). */
20
27
  export const DEFAULT_AUTH_TIMEOUT_MS = 10 * 60 * 1000;
21
28
  /** Default lifetime for issued API tokens used by CLI sessions (days). */
@@ -57,12 +64,6 @@ function normalizePositiveNumber(rawValue, field, fallbackValue) {
57
64
  return normalized;
58
65
  }
59
66
 
60
- function toAuthHeader(token) {
61
- return {
62
- Authorization: `Bearer ${String(token || "").trim()}`,
63
- };
64
- }
65
-
66
67
  function normalizeUser(user = {}) {
67
68
  return {
68
69
  id: String(user.id || "").trim(),
@@ -171,7 +172,6 @@ function isNearExpiry(tokenExpiresAt, thresholdDays) {
171
172
  * @returns {Promise<string>}
172
173
  */
173
174
  export async function resolveApiUrl({
174
- cwd = process.cwd(),
175
175
  env = process.env,
176
176
  explicitApiUrl = "",
177
177
  homeDir,
@@ -180,19 +180,9 @@ export async function resolveApiUrl({
180
180
  if (overrideUrl) {
181
181
  return normalizeApiUrl(overrideUrl);
182
182
  }
183
-
184
- const envUrl = String(env.SENTINELAYER_API_URL || "").trim();
185
- if (envUrl) {
186
- return normalizeApiUrl(envUrl);
187
- }
188
-
189
- const config = await loadConfig({ cwd, env, homeDir });
190
- const configuredApiUrl = String(config.resolved.apiUrl || "").trim();
191
- if (configuredApiUrl) {
192
- return normalizeApiUrl(configuredApiUrl);
193
- }
194
-
195
- return normalizeApiUrl(DEFAULT_API_URL);
183
+ // The trust context's API (SENTINELAYER_API_URL, else the global config, else the default). A
184
+ // workspace .sentinelayer.yml does not choose the API.
185
+ return normalizeApiUrl((await resolveTrustContext({ env, homeDir })).apiOrigin);
196
186
  }
197
187
 
198
188
  async function startCliAuthSession({ apiUrl, challenge, ide, cliVersion, flowRequestId }) {
@@ -379,20 +369,21 @@ async function pollCliAuthSession({
379
369
  });
380
370
  }
381
371
 
382
- async function fetchCurrentUser({ apiUrl, token, flowRequestId }) {
372
+ async function fetchCurrentUser({ apiUrl, credential, flowRequestId }) {
383
373
  return requestAuthJson(
384
374
  flowRequestId,
385
375
  buildApiPath(apiUrl, "/api/v1/auth/me"),
386
376
  {
387
377
  method: "GET",
388
- headers: withFlowRequestHeaders(toAuthHeader(token), flowRequestId),
378
+ credential,
379
+ headers: withFlowRequestHeaders({}, flowRequestId),
389
380
  }
390
381
  );
391
382
  }
392
383
 
393
384
  async function issueApiToken({
394
385
  apiUrl,
395
- authToken,
386
+ credential,
396
387
  tokenLabel,
397
388
  tokenTtlDays,
398
389
  flowRequestId,
@@ -406,12 +397,8 @@ async function issueApiToken({
406
397
  {
407
398
  method: "POST",
408
399
  operationName: "issue-token",
409
- headers: withFlowRequestHeaders(
410
- {
411
- ...toAuthHeader(authToken),
412
- },
413
- flowRequestId
414
- ),
400
+ credential,
401
+ headers: withFlowRequestHeaders({}, flowRequestId),
415
402
  body: {
416
403
  label: String(tokenLabel || "").trim() || defaultTokenLabel(),
417
404
  scope: "cli",
@@ -422,7 +409,7 @@ async function issueApiToken({
422
409
  );
423
410
  }
424
411
 
425
- async function revokeApiToken({ apiUrl, authToken, tokenId }) {
412
+ async function revokeApiToken({ apiUrl, credential, tokenId }) {
426
413
  const normalizedTokenId = String(tokenId || "").trim();
427
414
  if (!normalizedTokenId) {
428
415
  return false;
@@ -430,9 +417,7 @@ async function revokeApiToken({ apiUrl, authToken, tokenId }) {
430
417
  await requestJsonMutation(buildApiPath(apiUrl, `/api/v1/auth/api-tokens/${encodeURIComponent(normalizedTokenId)}`), {
431
418
  method: "DELETE",
432
419
  operationName: "revoke-token",
433
- headers: {
434
- ...toAuthHeader(authToken),
435
- },
420
+ credential,
436
421
  });
437
422
  return true;
438
423
  }
@@ -443,6 +428,7 @@ async function rotateStoredApiTokenIfNeeded({
443
428
  tokenLabel,
444
429
  tokenTtlDays,
445
430
  homeDir,
431
+ context,
446
432
  }) {
447
433
  if (!session || !session.token || !session.tokenExpiresAt) {
448
434
  return { session, rotated: false };
@@ -453,7 +439,7 @@ async function rotateStoredApiTokenIfNeeded({
453
439
 
454
440
  const issued = await issueApiToken({
455
441
  apiUrl: session.apiUrl,
456
- authToken: session.token,
442
+ credential: await userCredential(session.token, { context, source: "session" }),
457
443
  tokenLabel,
458
444
  tokenTtlDays,
459
445
  });
@@ -475,7 +461,7 @@ async function rotateStoredApiTokenIfNeeded({
475
461
  try {
476
462
  await revokeApiToken({
477
463
  apiUrl: session.apiUrl,
478
- authToken: nextSession.token,
464
+ credential: await userCredential(nextSession.token, { context, source: "session" }),
479
465
  tokenId: session.tokenId,
480
466
  });
481
467
  } catch (error) {
@@ -536,6 +522,10 @@ export async function loginAndPersistSession({
536
522
  homeDir,
537
523
  } = {}) {
538
524
  const apiUrl = await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir });
525
+ // The token this flow issues is the user's, so the API that issues it must be the configured
526
+ // one: refuse any other before the browser step. Resolution and trust share env and homeDir.
527
+ const context = await resolveTrustContext({ env, homeDir });
528
+ await assertTrustedApiUrl(apiUrl, { context });
539
529
  const challenge = generateChallenge();
540
530
  const flowRequestId = createFlowRequestId();
541
531
  const session = await startCliAuthSession({
@@ -574,13 +564,14 @@ export async function loginAndPersistSession({
574
564
  requestId: flowRequestId || null,
575
565
  });
576
566
  }
567
+ const approvalCredential = await userCredential(approvalToken, { context, source: "login_approval" });
577
568
 
578
569
  const user = normalizeUser(
579
- approval.user || (await fetchCurrentUser({ apiUrl, token: approvalToken, flowRequestId }))
570
+ approval.user || (await fetchCurrentUser({ apiUrl, credential: approvalCredential, flowRequestId }))
580
571
  );
581
572
  const issuedApiToken = await issueApiToken({
582
573
  apiUrl,
583
- authToken: approvalToken,
574
+ credential: approvalCredential,
584
575
  tokenLabel,
585
576
  tokenTtlDays,
586
577
  flowRequestId,
@@ -641,7 +632,7 @@ export async function loginAndPersistSession({
641
632
  * }} [options]
642
633
  * @returns {Promise<null | {
643
634
  * apiUrl: string,
644
- * token: string,
635
+ * credential: object,
645
636
  * source: "env" | "config" | "session",
646
637
  * user: {
647
638
  * id: string,
@@ -674,12 +665,15 @@ export async function resolveActiveAuthSession({
674
665
  if (scoped !== undefined) return scoped;
675
666
 
676
667
  const apiUrl = await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir });
668
+ // Each token below is bound to the configured API by the one trust context; `apiUrl` may name
669
+ // another origin (--api-url, a project config), and then a request carrying it is refused.
670
+ const context = await resolveTrustContext({ env, homeDir });
677
671
 
678
672
  const envToken = String(env.SENTINELAYER_TOKEN || "").trim();
679
673
  if (envToken) {
680
674
  return {
681
675
  apiUrl,
682
- token: envToken,
676
+ credential: await userCredential(envToken, { context, source: "env" }),
683
677
  source: "env",
684
678
  user: null,
685
679
  aidenid: null,
@@ -692,12 +686,13 @@ export async function resolveActiveAuthSession({
692
686
  };
693
687
  }
694
688
 
689
+ // The user's own config only: a workspace .sentinelayer.yml does not choose whose token is used.
695
690
  const config = await loadConfig({ cwd, env, homeDir });
696
- const configuredToken = String(config.resolved.sentinelayerToken || "").trim();
691
+ const configuredToken = String(config.layers.global.sentinelayerToken || "").trim();
697
692
  if (configuredToken) {
698
693
  return {
699
694
  apiUrl,
700
- token: configuredToken,
695
+ credential: await userCredential(configuredToken, { context, source: "config" }),
701
696
  source: "config",
702
697
  user: null,
703
698
  aidenid: null,
@@ -725,6 +720,7 @@ export async function resolveActiveAuthSession({
725
720
  tokenLabel,
726
721
  tokenTtlDays,
727
722
  homeDir,
723
+ context,
728
724
  });
729
725
  active = rotateResult.session;
730
726
  rotated = rotateResult.rotated;
@@ -743,7 +739,7 @@ export async function resolveActiveAuthSession({
743
739
 
744
740
  return {
745
741
  apiUrl,
746
- token: active.token,
742
+ credential: await userCredential(active.token, { context, source: "session" }),
747
743
  source: "session",
748
744
  user: normalizeUser(active.user || {}),
749
745
  aidenid: active.aidenid || null,
@@ -829,7 +825,7 @@ export async function getAuthStatus({
829
825
  let remoteError = null;
830
826
  if (checkRemote) {
831
827
  try {
832
- remoteUser = normalizeUser(await fetchCurrentUser({ apiUrl: session.apiUrl, token: session.token }));
828
+ remoteUser = normalizeUser(await fetchCurrentUser({ apiUrl: session.apiUrl, credential: session.credential }));
833
829
  } catch (error) {
834
830
  remoteError =
835
831
  error instanceof SentinelayerApiError
@@ -938,7 +934,7 @@ export async function revokeAuthToken({
938
934
  autoRotate: false,
939
935
  homeDir,
940
936
  });
941
- if (!active || !active.token) {
937
+ if (!isAuthenticated(active)) {
942
938
  throw new SentinelayerApiError(`No active auth token found. Run \`${authLoginHint()}\` first.`, {
943
939
  status: 401,
944
940
  code: "AUTH_REQUIRED",
@@ -954,7 +950,7 @@ export async function revokeAuthToken({
954
950
 
955
951
  await revokeApiToken({
956
952
  apiUrl: active.apiUrl,
957
- authToken: active.token,
953
+ credential: active.credential,
958
954
  tokenId: targetTokenId,
959
955
  });
960
956
 
@@ -1020,7 +1016,7 @@ export async function logoutSession({
1020
1016
  try {
1021
1017
  await revokeApiToken({
1022
1018
  apiUrl: await resolveApiUrl({ cwd, env, explicitApiUrl, homeDir }),
1023
- authToken: stored.token,
1019
+ credential: await userCredential(stored.token, { env, homeDir, source: "session" }),
1024
1020
  tokenId: stored.tokenId,
1025
1021
  });
1026
1022
  revokedRemote = true;
@@ -1043,15 +1039,15 @@ export async function logoutSession({
1043
1039
  *
1044
1040
  * @param {{
1045
1041
  * apiUrl: string,
1046
- * authToken: string,
1042
+ * credential: object,
1047
1043
  * runId: string,
1048
1044
  * afterEventId?: string | null
1049
- * }} [options]
1045
+ * }} [options] credential: the resolved auth's `credential` (src/auth/credential-destinations.js)
1050
1046
  * @returns {Promise<any>}
1051
1047
  */
1052
1048
  export async function listRuntimeRunEvents({
1053
1049
  apiUrl,
1054
- authToken,
1050
+ credential,
1055
1051
  runId,
1056
1052
  afterEventId = null,
1057
1053
  } = {}) {
@@ -1062,7 +1058,7 @@ export async function listRuntimeRunEvents({
1062
1058
  buildApiPath(apiUrl, `/api/v1/runtime/runs/${encodeURIComponent(String(runId || ""))}/events/list${query}`),
1063
1059
  {
1064
1060
  method: "GET",
1065
- headers: toAuthHeader(authToken),
1061
+ credential,
1066
1062
  }
1067
1063
  );
1068
1064
  }
@@ -1070,7 +1066,7 @@ export async function listRuntimeRunEvents({
1070
1066
  /**
1071
1067
  * Fetch runtime run status snapshot from the Sentinelayer API.
1072
1068
  *
1073
- * @param {{ apiUrl: string, authToken: string, runId: string }} [options]
1069
+ * @param {{ apiUrl: string, credential: object, runId: string }} [options]
1074
1070
  * @returns {Promise<any>}
1075
1071
  */
1076
1072
  /**
@@ -1079,13 +1075,13 @@ export async function listRuntimeRunEvents({
1079
1075
  */
1080
1076
  export async function fetchAidenIdCredentials({
1081
1077
  apiUrl = DEFAULT_API_URL,
1082
- token = "",
1078
+ auth = null,
1083
1079
  } = {}) {
1084
1080
  const response = await requestJson(
1085
1081
  buildApiPath(apiUrl, "/api/v1/aidenid/credentials"),
1086
1082
  {
1087
1083
  method: "GET",
1088
- headers: toAuthHeader(token),
1084
+ credential: await credentialFor(auth),
1089
1085
  }
1090
1086
  );
1091
1087
  return {
@@ -1099,14 +1095,14 @@ export async function fetchAidenIdCredentials({
1099
1095
 
1100
1096
  export async function getRuntimeRunStatus({
1101
1097
  apiUrl,
1102
- authToken,
1098
+ credential,
1103
1099
  runId,
1104
1100
  } = {}) {
1105
1101
  return requestJson(
1106
1102
  buildApiPath(apiUrl, `/api/v1/runtime/runs/${encodeURIComponent(String(runId || ""))}/status`),
1107
1103
  {
1108
1104
  method: "GET",
1109
- headers: toAuthHeader(authToken),
1105
+ credential,
1110
1106
  }
1111
1107
  );
1112
1108
  }
package/src/cli.js CHANGED
@@ -242,6 +242,11 @@ export async function buildCliProgram({
242
242
  }
243
243
 
244
244
  export async function runCli(rawArgs = process.argv.slice(2)) {
245
+ // Where the user's credentials may go is decided once, here, from the real environment and the
246
+ // global config, and frozen: nothing that changes process.env later can move it.
247
+ const { freezeProcessTrustContext } = await import("./auth/credential-destinations.js");
248
+ await freezeProcessTrustContext();
249
+
245
250
  // Normalize slash commands (/omargate → omargate, /audit → audit, etc.)
246
251
  const normalizedArgs = normalizeSlashArgs(rawArgs);
247
252
 
@@ -269,15 +274,18 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
269
274
  // the subcommand (say, post-agent, observe, locks, checkpoints, ...), so no
270
275
  // subcommand, present or future, can reach the human token. A tombstoned
271
276
  // admission refuses here, before any request.
272
- const { assertDispatchMatchesScope, resolveAgentAdmissionTarget, withAgentAdmission } = await import(
273
- "./session/admission-auth.js"
274
- );
277
+ const { assertDispatchMatchesScope, resolveAgentAdmissionTarget, withAgentAdmission } =
278
+ await import("./session/admission-auth.js");
275
279
  const target = await resolveAgentAdmissionTarget(normalizedArgs);
276
- // Execution must match authorization: before ANY action runs, re-derive the identity
277
- // from Commander's resolved options and compare it with the scope in force.
280
+ // Execution must match authorization: before ANY action runs, decide on Commander's parsed
281
+ // arguments and options -- the values the action will use. Through the MCP CLI bridge in an
282
+ // agent context the action binds to its session's admission, runs a legacy data-plane route
283
+ // as before, or is refused (src/session/route-classes.js); and the identity it resolves must
284
+ // be the one whose scope is in force.
278
285
  program.hook("preAction", (_hooked, actionCommand) => assertDispatchMatchesScope(actionCommand));
279
286
  if (target) {
280
- await withAgentAdmission(target.sessionId, target.agentId, dispatch);
287
+ // An admission seen above that is gone by the time it is read refuses: never a fallback.
288
+ await withAgentAdmission(target.sessionId, target.agentId, dispatch, { requireStored: true });
281
289
  return;
282
290
  }
283
291
  await dispatch();
@@ -25,8 +25,7 @@ import {
25
25
  import { listSites, recordTemporarySite } from "../../ai/site-store.js";
26
26
  import { getIdentityById, recordProvisionedIdentity } from "../../ai/identity-store.js";
27
27
  import { resolveOutputRoot } from "../../config/service.js";
28
- import { readStoredSession } from "../../auth/session-store.js";
29
- import { fetchAidenIdCredentials } from "../../auth/service.js";
28
+ import { fetchAidenIdCredentials, resolveActiveAuthSession } from "../../auth/service.js";
30
29
  import {
31
30
  buildCurlPreview,
32
31
  normalizeIdempotencyKey,
@@ -37,6 +36,7 @@ import {
37
36
  stableTimestampForFile,
38
37
  writeArtifact,
39
38
  } from "./shared.js";
39
+ import { isAuthenticated } from "../../auth/credential-destinations.js";
40
40
 
41
41
  function addProvisionEmailOptions(cmd) {
42
42
  return cmd
@@ -92,12 +92,13 @@ async function provisionEmailAction(options, command) {
92
92
  payload,
93
93
  });
94
94
 
95
- let session = null;
96
- try { session = await readStoredSession(); } catch { /* no session */ }
95
+ const session = await resolveActiveAuthSession({ cwd: process.cwd(), env: process.env, autoRotate: false }).catch(
96
+ () => null,
97
+ );
97
98
 
98
99
  const makeFetcher = () => {
99
- if (!session || !session.token) return null;
100
- return () => fetchAidenIdCredentials({ apiUrl: session.apiUrl, token: session.token });
100
+ if (!isAuthenticated(session)) return null;
101
+ return () => fetchAidenIdCredentials({ apiUrl: session.apiUrl, auth: session });
101
102
  };
102
103
 
103
104
  const resolvedCredentials = await resolveAidenIdCredentials({
@@ -1,25 +1,26 @@
1
1
  import process from "node:process";
2
2
 
3
- import { loadConfig } from "../config/service.js";
3
+ import { loadConfig, userConfigValues } from "../config/service.js";
4
4
 
5
- function applyConfigEnvDefaults(resolvedConfig) {
6
- if (!process.env.SENTINELAYER_API_URL && resolvedConfig.apiUrl) {
7
- process.env.SENTINELAYER_API_URL = resolvedConfig.apiUrl;
8
- }
5
+ // Config values the scaffold flow reads from the environment. The API it talks to and the token
6
+ // it sends are never set here: they come from the trust context and the auth service
7
+ // (src/auth/credential-destinations.js), so a workspace .sentinelayer.yml cannot choose them,
8
+ // and child processes inherit neither.
9
+ // Provider keys come from the user's own config (global, or the environment), never the workspace.
10
+ function applyConfigEnvDefaults(config) {
11
+ const resolvedConfig = config.resolved;
12
+ const userValues = userConfigValues(config);
9
13
  if (!process.env.SENTINELAYER_WEB_URL && resolvedConfig.webUrl) {
10
- process.env.SENTINELAYER_WEB_URL = resolvedConfig.webUrl;
11
- }
12
- if (!process.env.SENTINELAYER_TOKEN && resolvedConfig.sentinelayerToken) {
13
- process.env.SENTINELAYER_TOKEN = resolvedConfig.sentinelayerToken;
14
+ process.env.SENTINELAYER_WEB_URL = resolvedConfig.webUrl; // printed as a link only
14
15
  }
15
- if (!process.env.OPENAI_API_KEY && resolvedConfig.openaiApiKey) {
16
- process.env.OPENAI_API_KEY = resolvedConfig.openaiApiKey;
16
+ if (!process.env.OPENAI_API_KEY && userValues.openaiApiKey) {
17
+ process.env.OPENAI_API_KEY = userValues.openaiApiKey;
17
18
  }
18
- if (!process.env.ANTHROPIC_API_KEY && resolvedConfig.anthropicApiKey) {
19
- process.env.ANTHROPIC_API_KEY = resolvedConfig.anthropicApiKey;
19
+ if (!process.env.ANTHROPIC_API_KEY && userValues.anthropicApiKey) {
20
+ process.env.ANTHROPIC_API_KEY = userValues.anthropicApiKey;
20
21
  }
21
- if (!process.env.GOOGLE_API_KEY && resolvedConfig.googleApiKey) {
22
- process.env.GOOGLE_API_KEY = resolvedConfig.googleApiKey;
22
+ if (!process.env.GOOGLE_API_KEY && userValues.googleApiKey) {
23
+ process.env.GOOGLE_API_KEY = userValues.googleApiKey;
23
24
  }
24
25
  }
25
26
 
@@ -30,9 +31,11 @@ export function registerInitCommand(program, invokeLegacy) {
30
31
  .option("--non-interactive", "Disable prompts and require interview payload")
31
32
  .option("--interview-file <path>", "Load interview JSON from file")
32
33
  .option("--skip-browser-open", "Do not auto-open browser during auth")
34
+ .option("--inject-secret", "Set the project token as a GitHub Actions secret on this directory's git remote")
35
+ .option("--inject-openai-key", "Also set OPENAI_API_KEY from your environment as a GitHub Actions secret there")
33
36
  .action(async (projectName, options) => {
34
37
  const config = await loadConfig();
35
- applyConfigEnvDefaults(config.resolved);
38
+ applyConfigEnvDefaults(config);
36
39
 
37
40
  const legacyArgs = [];
38
41
  const normalizedProjectName = String(projectName || "").trim();
@@ -49,6 +52,12 @@ export function registerInitCommand(program, invokeLegacy) {
49
52
  if (options.skipBrowserOpen) {
50
53
  legacyArgs.push("--skip-browser-open");
51
54
  }
55
+ if (options.injectSecret) {
56
+ legacyArgs.push("--inject-secret");
57
+ }
58
+ if (options.injectOpenaiKey) {
59
+ legacyArgs.push("--inject-openai-key");
60
+ }
52
61
 
53
62
  await invokeLegacy(legacyArgs);
54
63
  });
@@ -12,7 +12,7 @@ import {
12
12
  resolveProvider,
13
13
  } from "../ai/client.js";
14
14
  import { recordCliLlmSessionUsage, usageNumber } from "../billing/llm-session-usage.js";
15
- import { loadConfig, resolveOutputRoot } from "../config/service.js";
15
+ import { loadConfig, resolveOutputRoot, userConfigValues } from "../config/service.js";
16
16
  import { evaluateBudget } from "../cost/budget.js";
17
17
  import { appendCostEntry, summarizeCostHistory } from "../cost/history.js";
18
18
  import { estimateModelCost } from "../cost/tracker.js";
@@ -34,6 +34,7 @@ import { detectRepoSlug, setupSecrets } from "../scan/gh-secrets.js";
34
34
  import { appendRunEvent, deriveStopClassFromBudget } from "../telemetry/ledger.js";
35
35
  import { resolveActiveAuthSession } from "../auth/service.js";
36
36
  import { authLoginHint } from "../ui/command-hints.js";
37
+ import { exportCredentialToken, isAuthenticated } from "../auth/credential-destinations.js";
37
38
 
38
39
  const LEGACY_SCAN_WORKFLOW_PATH = ".github/workflows/security-review.yml";
39
40
 
@@ -578,7 +579,7 @@ export function registerScanCommand(program) {
578
579
  configModel: config.resolved.defaultModelId,
579
580
  });
580
581
  const explicitApiKey = String(options.apiKey || "").trim();
581
- const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, config.resolved);
582
+ const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, userConfigValues(config));
582
583
 
583
584
  const prompt = buildAiPreScanPrompt({
584
585
  targetPath,
@@ -856,8 +857,9 @@ export function registerScanCommand(program) {
856
857
  env: process.env,
857
858
  autoRotate: false,
858
859
  });
859
- if (session && session.token) {
860
- tokenValue = session.token;
860
+ if (isAuthenticated(session)) {
861
+ // the one reviewed export of the token: to a GitHub Actions secret, through gh
862
+ tokenValue = exportCredentialToken(session.credential, { purpose: "github-actions-secret" });
861
863
  }
862
864
  } catch {
863
865
  /* no active auth session */