sentinelayer-cli 0.43.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 (48) hide show
  1. package/README.md +29 -16
  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 +166 -51
  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 +160 -7
  27. package/src/mcp/registry.js +6 -5
  28. package/src/mcp/session-stdio-server.js +157 -8
  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 +41 -10
  36. package/src/session/admission-auth.js +95 -16
  37. package/src/session/admission.js +91 -21
  38. package/src/session/checkpoints.js +11 -10
  39. package/src/session/control-events.js +2 -0
  40. package/src/session/file-locks.js +19 -18
  41. package/src/session/invitations.js +26 -19
  42. package/src/session/live-source.js +13 -13
  43. package/src/session/reactions.js +258 -0
  44. package/src/session/route-classes.js +107 -0
  45. package/src/session/sync.js +110 -66
  46. package/src/session/tickets.js +21 -19
  47. package/src/session/title-sync.js +4 -3
  48. package/src/telemetry/sync.js +8 -9
package/README.md CHANGED
@@ -164,11 +164,13 @@ sl session action <session-id> working_on --target-sequence <n>
164
164
  ```
165
165
 
166
166
  For a room that requires agent admission, the agent declares its goal, requested
167
- actions, and TTL. The CLI creates an agent-held Ed25519 identity, waits for a
168
- human decision, then waits for the minted AIdenID email and verified signed
169
- purpose receipt. Only after that evidence is ready does it claim the approved
170
- grant with proof of possession and join. The private key and scoped credential
171
- are stored locally and are never printed:
167
+ actions, and TTL. The CLI creates an agent-held Ed25519 identity and waits for a
168
+ human decision. When the API reports identity readiness on the approved
169
+ admission, the CLI also waits for the minted AIdenID email and verified signed
170
+ purpose receipt before it claims; when the API does not report it, the approved
171
+ grant is claimed directly. The claim uses proof of possession, then the agent
172
+ joins. The private key and scoped credential are stored locally and are never
173
+ printed:
172
174
 
173
175
  ```bash
174
176
  # Agent: request and wait for the owner's decision (maximum grant TTL is 24h).
@@ -190,20 +192,27 @@ sl session access approve <session-id> <admission-id> \
190
192
  # sl session access revoke <session-id> <admission-id> --reason "work complete"
191
193
  ```
192
194
 
193
- Approval queues the AIdenID email and purpose-receipt work. `session access
194
- list --json` reports the real `email`, `purpose`, and Jev status; it does not
195
- invent successful issuance while those jobs are pending or unavailable, and
196
- the API withholds the claim challenge until both identity artifacts are ready.
195
+ `session access list --json` reports the `email`, `purpose`, and Jev status the
196
+ API returns; the CLI does not invent successful issuance. On an API that gates
197
+ the claim on identity evidence, approval queues the AIdenID email and
198
+ purpose-receipt work and the claim challenge is withheld until both are ready.
199
+ On an API that does not, approval issues the claim challenge directly.
197
200
  The agent must re-run `session join` with the same goal, scope, and TTL after an
198
201
  asynchronous request so it can claim and verify its live session-bound receipt.
199
- New rooms default to fail-closed `required` mode. Existing rooms retain their
200
- stored `legacy` mode until an owner explicitly sets `required`; the mode command
201
- is the activation step for those pre-existing rooms.
202
+ A room's admission mode is whatever the API stored for it; `session access list`
203
+ shows it, and an owner changes it with `session access mode <session-id>
204
+ required` (or `legacy`).
205
+
206
+ The owner actions (`approve`, `deny`, `revoke`, `mode`) run on the owner's own
207
+ session. They are unavailable in an agent context, meaning `SENTINELAYER_AGENT_ID`
208
+ is set or agent admission credentials are stored on the machine; owners use the
209
+ web dashboard from such machines. `session access` commands are not callable
210
+ through the MCP CLI bridge.
202
211
 
203
212
  Sentinelayer includes a deterministic session coordination surface for multi-agent coding loops:
204
213
 
205
214
  - session event stream and replay (`start`, `join`, `say`, `read`, `status`, `leave`, `list`, `kill`)
206
- - low-noise message actions (`react ack|like|dislike`, `action working_on|disregard`, `reply`/`comment`, `actions`) plus one monotonic per-agent read cursor (`view`)
215
+ - low-noise message actions (`react ack|like|dislike|unlike|undislike`, `action working_on|disregard`, `reply`/`comment`, `actions`) plus one monotonic per-agent read cursor (`view`)
207
216
  - agent lifecycle controls (join/ephemeral TTL presence/leave/kill); listener liveness never enters the durable transcript
208
217
  - recap and context briefing for late-joining agents
209
218
  - analytics + lineage artifacts at session closeout
@@ -750,7 +759,7 @@ Use `init-aidenid-adapter` to scaffold a deterministic AIdenID provisioning API
750
759
  Use `token mint` to request a short-lived hosted MCP bearer token from the Sentinelayer API. Text output hides the bearer value; `--json` is the explicit operator path that includes `accessToken`. The command is blocked from the generated CLI MCP bridge.
751
760
  Use `smoke` to verify the hosted MCP resource without printing the bearer. The canonical remote MCP paste URL is `https://api.sentinelayer.com/mcp`; the token audience/resource value is `https://mcp.sentinelayer.com` and is handled by the OAuth/MCP flow. `sl mcp smoke` works today through the CLI direct-mint path; third-party browser connectors such as Claude.ai still require the OAuth browser-flow and connector-registration work to complete before users can authenticate from that product.
752
761
  Use `init-session` plus `server run` for the local stdio Senti MCP server. It exposes session inbox, durable posts, actions/replies/reactions, API-authoritative file leases, and attention requests to local MCP clients. File-lease lifecycle operations never write session events; install edit preflights with `sl session guard-install` and remove them with `sl session guard-uninstall` before any CLI rollback. Hosted Claude-web/ChatGPT connectors need a separate HTTPS/OAuth service and are not shipped by the local stdio server. See [docs/mcp.md](docs/mcp.md) and [docs/file-leases.md](docs/file-leases.md).
753
- Use `init-cli` to generate a bridge registry for every `sl` leaf command from Commander metadata. This exposes the full CLI surface as `sl.<command.path>` tool schemas, but execution still requires a bridge-capable MCP host with OAuth/session-seat policy and human approval enforcement. The hosted connector target contract is documented in [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md).
762
+ Use `init-cli` to generate a bridge registry for every `sl` leaf command from Commander metadata. This exposes the full CLI surface as `sl.<command.path>` tool schemas, but execution still requires a bridge-capable MCP host with OAuth/session-seat policy and human approval enforcement. The MCP bridge now enforces the human-approval requirement on CLI tools; they are unavailable over MCP until approval is supported. Senti session tools are unaffected. The local stdio server does not list the `sl.*` tools, and a call to one returns a JSON-RPC error (code `-32001`, `data.reason` `human_approval_required`) that says to run the `sl` command in a terminal. A file or directory input to a bridged command must stay inside the workspace (no absolute path, no `..` or symlink out of it). The hosted connector target contract is documented in [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md).
754
763
 
755
764
  MCP operator verification:
756
765
 
@@ -761,7 +770,7 @@ MCP operator verification:
761
770
  `sl mcp smoke --session <senti-session-id> --json`. The smoke mints the
762
771
  short-lived bearer in memory, calls `/mcp`, and prints only redacted proof.
763
772
  3. Kill switch: set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1` and restart the
764
- MCP host if a CLI bridge or bearer credential is exposed.
773
+ MCP host if a CLI bridge or bearer credential is exposed. In this version the MCP server already refuses every `sl.*` call for human approval, so the switch does not change MCP responses; it is defence in depth for the CLI bridge handler and for approved execution in a future version.
765
774
 
766
775
  Architecture references: local stdio runtime and incident response live in
767
776
  [docs/mcp.md](docs/mcp.md); hosted OAuth/session-seat requirements live in
@@ -808,7 +817,7 @@ Architecture decisions:
808
817
  If a hosted MCP bearer or bridge command output is exposed:
809
818
 
810
819
  1. Set `SENTINELAYER_MCP_CLI_BRIDGE_DISABLED=1`.
811
- 2. Restart the MCP host process so the generated CLI bridge cannot execute.
820
+ 2. Restart the MCP host process. In this version the MCP server already refuses every `sl.*` call for human approval, so the switch does not change MCP responses; it is defence in depth for the CLI bridge handler and for approved execution in a future version.
812
821
  3. Run `sentinelayer-cli auth revoke` or revoke the requesting session from the
813
822
  dashboard, then re-authenticate with `sentinelayer-cli auth login`.
814
823
  4. Remove the exposed value from local logs/transcripts where possible.
@@ -903,6 +912,10 @@ gh secret list --repo <owner/repo>
903
912
  - `SENTINELAYER_DISABLE_KEYRING=1` (force file-based credential storage)
904
913
  - `AIDENID_API_KEY`, `AIDENID_ORG_ID`, `AIDENID_PROJECT_ID` (used by `sl ai provision-email --execute`)
905
914
 
915
+ When the CLI sends your SentinelLayer token over HTTP, it sends it only to the API named by `SENTINELAYER_API_URL` (else by `apiUrl` in `~/.sentinelayer/config.yml`, else the default API), or to the pocket gateway named by `SENTI_POCKET_URL`. `POCKET_GATEWAY_URL` is no longer read. The CLI decides this once, when it starts. Custom API origins must be configured: an `--api-url` or `--gateway-url` that names another origin is refused for any request that would carry the token. A project `.sentinelayer.yml` chooses neither the API nor the token: its `apiUrl` and `sentinelayerToken` are not used, nor are its provider keys (`openaiApiKey`, `anthropicApiKey`, `googleApiKey`), which come from the environment or the global config. Watchdog alert channels (`alerts.channels`) also come from the global config only; a channel's `${NAME}` placeholders are filled only from `SLACK_WEBHOOK_URL`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` or `SENTINELAYER_ALERT_*` (any other name, or an unset one, refuses the channel), and Slack alerts go only to `https://hooks.slack.com/`. When a project config sets any of these, the CLI prints one notice saying what to set instead. Requests that carry the token do not follow redirects.
916
+
917
+ The token leaves the CLI in other ways only through operator commands, none of which is available through the MCP bridge: `sl scan setup-secrets` writes it to a GitHub Actions secret with the `gh` CLI (or to the file named by `SENTINELAYER_SECRET_SINK_FILE`, used in tests), and `sl mcp token mint --json` prints a short-lived hosted MCP bearer minted from it. The project token that `sl init` receives for a new scaffold (not your login token) is written to the project's `.env`, its `.sentinelayer/config.json` lockfile and, only when you pass `--inject-secret` (or set `injectSecret` in the interview), a GitHub Actions secret on this directory's own git remote, after `gh` confirms you can write to it. `OPENAI_API_KEY` from your environment is set there only with `--inject-openai-key`. Neither is available through the MCP bridge. Other services' keys (model providers, AIdenID, a memory or embedding service) are sent to their own endpoints.
918
+
906
919
  ## Layered config (PR 0.2)
907
920
 
908
921
  The CLI supports layered config resolution:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -11,8 +11,8 @@ import {
11
11
  resolveAidenIdCredentials,
12
12
  normalizeAidenIdApiUrl,
13
13
  } from "../../../ai/aidenid.js";
14
- import { readStoredSession } from "../../../auth/session-store.js";
15
14
  import { fetchAidenIdCredentials } from "../../../auth/service.js";
15
+ import { isAuthenticated } from "../../../auth/credential-destinations.js";
16
16
 
17
17
  /**
18
18
  * AIdenID email tool definition for agent dispatch.
@@ -57,16 +57,14 @@ export const AIDENID_EMAIL_TOOL = {
57
57
  export async function executeAidenIdEmailTool(input, ctx = {}) {
58
58
  const operation = String(input.operation || "").trim();
59
59
 
60
- let session = null;
61
- try {
62
- session = await readStoredSession();
63
- } catch {
64
- // no session
65
- }
60
+ const { resolveActiveAuthSession } = await import("../../../auth/service.js");
61
+ const session = await resolveActiveAuthSession({ cwd: process.cwd(), env: process.env, autoRotate: false }).catch(
62
+ () => null,
63
+ );
66
64
 
67
65
  const makeFetcher = () => {
68
- if (!session || !session.token) return null;
69
- return () => fetchAidenIdCredentials({ apiUrl: session.apiUrl, token: session.token });
66
+ if (!isAuthenticated(session)) return null;
67
+ return () => fetchAidenIdCredentials({ apiUrl: session.apiUrl, auth: session });
70
68
  };
71
69
 
72
70
  const credentials = await resolveAidenIdCredentials({
@@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process";
4
4
  import { glob as globTool } from "./glob.js";
5
5
  import { grep as grepTool } from "./grep.js";
6
6
  import { fileRead } from "./file-read.js";
7
+ import { buildScrubbedEnv } from "../../shared-tools/shell.js";
7
8
 
8
9
  /**
9
10
  * FrontendAnalyze — 24 deterministic operations for the Jules Tanaka persona.
@@ -346,11 +347,22 @@ function checkErrorBoundaries(rootPath) {
346
347
  };
347
348
  }
348
349
 
350
+ // npm runs in the repository with credentials removed from its environment, so the repository's
351
+ // .npmrc cannot place them in a request.
352
+ function npmAuditInvocation(rootPath, env = process.env) {
353
+ return {
354
+ file: "npm",
355
+ args: ["audit", "--json", "--production"],
356
+ options: { cwd: rootPath, encoding: "utf-8", timeout: 30000, stdio: ["pipe", "pipe", "pipe"], env: buildScrubbedEnv(env) },
357
+ };
358
+ }
359
+
360
+ export const __frontendAnalyzeForTests = Object.freeze({ npmAuditInvocation });
361
+
349
362
  function auditNpmDeps(rootPath) {
350
363
  try {
351
- const output = execFileSync("npm", ["audit", "--json", "--production"], {
352
- cwd: rootPath, encoding: "utf-8", timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
353
- });
364
+ const invocation = npmAuditInvocation(rootPath);
365
+ const output = execFileSync(invocation.file, invocation.args, invocation.options);
354
366
  const audit = JSON.parse(output);
355
367
  return {
356
368
  vulnerabilities: audit.metadata?.vulnerabilities || {},
@@ -3,7 +3,9 @@ import path from "node:path";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import { randomUUID } from "node:crypto";
6
+ import { credentialedRequest, isAuthenticated } from "../../../auth/credential-destinations.js";
6
7
  import { assertPermittedAuditTarget } from "./url-policy.js";
8
+ import { buildScrubbedEnv } from "../../shared-tools/shell.js";
7
9
 
8
10
  /**
9
11
  * Jules Tanaka — Runtime Audit Tool
@@ -46,6 +48,37 @@ const RUNTIME_DISPATCH = {
46
48
  check_dom_stats: checkDomStats,
47
49
  };
48
50
 
51
+ /**
52
+ * How Lighthouse runs. No shell: the target URL can come from the repository (package.json,
53
+ * vercel.json, .env files), and a shell would expand variables and operators inside it. Credentials
54
+ * are removed from npx's environment, so a repository .npmrc cannot place them in a request.
55
+ * On Windows, where npx is a .cmd file, npm's npx script runs under this Node.js instead.
56
+ */
57
+ function lighthouseInvocation(
58
+ targetUrl,
59
+ outputPath,
60
+ { platform = process.platform, execPath = process.execPath, exists = fs.existsSync, env = process.env } = {},
61
+ ) {
62
+ const args = [
63
+ "--yes", "lighthouse@12", targetUrl,
64
+ "--output", "json", "--output-path", outputPath,
65
+ "--chrome-flags=--headless --no-sandbox --disable-gpu", "--quiet",
66
+ ];
67
+ const options = {
68
+ encoding: "utf-8",
69
+ timeout: LIGHTHOUSE_TIMEOUT_MS,
70
+ stdio: ["pipe", "pipe", "pipe"],
71
+ env: buildScrubbedEnv(env),
72
+ };
73
+ if (platform !== "win32") {
74
+ return { file: "npx", args, options };
75
+ }
76
+ const npxCli = path.join(path.dirname(execPath), "node_modules", "npm", "bin", "npx-cli.js");
77
+ return exists(npxCli) ? { file: execPath, args: [npxCli, ...args], options } : null;
78
+ }
79
+
80
+ export const __runtimeAuditForTests = Object.freeze({ lighthouseInvocation, detectDeployedUrl });
81
+
49
82
  /**
50
83
  * Run Lighthouse via npx (no install required).
51
84
  * Returns performance, accessibility, best-practices, SEO scores + key metrics.
@@ -74,16 +107,11 @@ async function lighthouseScan(input) {
74
107
  const outputDir = path.dirname(outputPath);
75
108
  if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
76
109
 
77
- execFileSync("npx", [
78
- "--yes", "lighthouse@12", targetUrl,
79
- "--output", "json", "--output-path", outputPath,
80
- "--chrome-flags=--headless --no-sandbox --disable-gpu", "--quiet",
81
- ], {
82
- encoding: "utf-8",
83
- timeout: LIGHTHOUSE_TIMEOUT_MS,
84
- stdio: ["pipe", "pipe", "pipe"],
85
- shell: true, // Windows: npx is npx.cmd, needs shell resolution
86
- });
110
+ const invocation = lighthouseInvocation(targetUrl, outputPath);
111
+ if (!invocation) {
112
+ return { available: false, reason: "npx is not available next to this Node.js" };
113
+ }
114
+ execFileSync(invocation.file, invocation.args, invocation.options);
87
115
 
88
116
  if (!fs.existsSync(outputPath)) {
89
117
  return { available: false, reason: "Lighthouse produced no output" };
@@ -437,7 +465,7 @@ async function callScannerApi(url) {
437
465
  });
438
466
  } catch { /* session read failed */ }
439
467
 
440
- if (!session || !session.token) {
468
+ if (!isAuthenticated(session)) {
441
469
  return { available: false, reason: "Not authenticated — run sl auth login" };
442
470
  }
443
471
 
@@ -445,14 +473,16 @@ async function callScannerApi(url) {
445
473
  const scanEndpoint = apiUrl + "/api/v1/scan/url";
446
474
 
447
475
  // Submit scan
448
- const submitResponse = await fetchWithTimeout(scanEndpoint, {
449
- method: "POST",
450
- headers: {
451
- "Content-Type": "application/json",
452
- "Authorization": "Bearer " + session.token,
476
+ const submitResponse = await credentialedRequest(
477
+ session.credential,
478
+ scanEndpoint,
479
+ {
480
+ method: "POST",
481
+ headers: { "Content-Type": "application/json" },
482
+ body: JSON.stringify({ url, scan_type: "lighthouse" }),
453
483
  },
454
- body: JSON.stringify({ url, scan_type: "lighthouse" }),
455
- }, 15000);
484
+ { fetchImpl: (target, init) => fetchWithTimeout(target, init, 15000) },
485
+ );
456
486
 
457
487
  if (!submitResponse.ok) {
458
488
  return { available: false, reason: "Scanner API returned " + submitResponse.status };
@@ -469,9 +499,9 @@ async function callScannerApi(url) {
469
499
  for (let attempt = 0; attempt < 30; attempt++) {
470
500
  await new Promise(r => setTimeout(r, 3000));
471
501
  try {
472
- const pollResponse = await fetchWithTimeout(pollUrl, {
473
- headers: { "Authorization": "Bearer " + session.token },
474
- }, 10000);
502
+ const pollResponse = await credentialedRequest(session.credential, pollUrl, {}, {
503
+ fetchImpl: (target, init) => fetchWithTimeout(target, init, 10000),
504
+ });
475
505
  if (!pollResponse.ok) continue;
476
506
  const pollData = await pollResponse.json();
477
507
  if (pollData.status === "completed" || pollData.status === "complete") {
package/src/ai/aidenid.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import process from "node:process";
2
2
  import { authLoginHint } from "../ui/command-hints.js";
3
+ import { isAuthenticated } from "../auth/credential-destinations.js";
3
4
 
4
5
  export const DEFAULT_AIDENID_API_URL = "https://api.aidenid.com";
5
6
 
@@ -179,14 +180,14 @@ async function resolveSessionCredentialContext({
179
180
  env,
180
181
  autoRotate: false,
181
182
  });
182
- if (activeSession && activeSession.token) {
183
+ if (isAuthenticated(activeSession)) {
183
184
  resolvedSession = activeSession;
184
185
  }
185
- if (resolvedSession && resolvedSession.token && (!resolvedFetcher || activeSession?.token)) {
186
+ if (isAuthenticated(resolvedSession) && (!resolvedFetcher || isAuthenticated(activeSession))) {
186
187
  resolvedFetcher = () =>
187
188
  fetchAidenIdCredentials({
188
189
  apiUrl: resolvedSession.apiUrl || "https://api.sentinelayer.com",
189
- token: resolvedSession.token,
190
+ auth: resolvedSession,
190
191
  });
191
192
  }
192
193
  } catch {
@@ -230,7 +231,7 @@ export async function resolveAidenIdCredentials(
230
231
  const activeSession = sessionContext.session;
231
232
  const activeFetchCredentials = sessionContext.fetchCredentials;
232
233
  const sessionAidenId = activeSession && activeSession.aidenid ? activeSession.aidenid : null;
233
- const hasSessionToken = Boolean(String(activeSession && activeSession.token ? activeSession.token : "").trim());
234
+ const hasSessionToken = isAuthenticated(activeSession);
234
235
 
235
236
  let resolvedApiKey = String(apiKey || env.AIDENID_API_KEY || "").trim();
236
237
  let resolvedOrgId = String(orgId || env.AIDENID_ORG_ID || (sessionAidenId && sessionAidenId.orgId) || "").trim();
package/src/ai/proxy.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * Response: { content, usage: { model, provider, tokens_in, tokens_out, cost_usd, latency_ms } }
9
9
  */
10
10
 
11
+ import { CredentialDestinationRefused, credentialedRequest, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
11
12
  import { resolveActiveAuthSession } from "../auth/service.js";
12
13
  import { authLoginHint } from "../ui/command-hints.js";
13
14
 
@@ -318,7 +319,7 @@ function buildProxyError(status, parsed) {
318
319
  * @param {number} [options.maxTokens] - Max output tokens (default: 4096)
319
320
  * @param {number} [options.temperature] - Temperature (default: 0.1)
320
321
  * @param {string} [options.apiUrl] - Override API URL
321
- * @param {string} [options.token] - Override Bearer token
322
+ * @param {string} [options.token] - The user's token, if the caller holds it: bound to the configured API
322
323
  * @param {string} [options.sessionId] - Optional Senti session id for server-side usage metering
323
324
  * @param {string} [options.agentId] - Optional session agent id for server-side usage metering
324
325
  * @param {string} [options.action] - Optional metered action, defaults server-side when omitted
@@ -346,23 +347,24 @@ export async function invokeViaProxy({
346
347
  metadata = null,
347
348
  fetchImpl = fetch,
348
349
  } = {}) {
349
- // Resolve credentials from session if not provided
350
+ // Resolve credentials from session if not provided. A passed token is bound to the configured
351
+ // API (src/auth/credential-destinations.js), so an apiUrl on any other origin is refused.
350
352
  let resolvedApiUrl = String(apiUrl || "").trim();
351
- let resolvedToken = String(token || "").trim();
353
+ let credential = String(token || "").trim() ? await credentialFor({ token }) : null;
352
354
 
353
- if (!resolvedApiUrl || !resolvedToken) {
355
+ if (!resolvedApiUrl || !credential) {
354
356
  const session = await resolveActiveAuthSession({
355
357
  cwd: process.cwd(),
356
358
  env: process.env,
357
359
  autoRotate: false,
358
360
  });
359
- if (!session || !session.token) {
361
+ if (!isAuthenticated(session)) {
360
362
  throw new Error(
361
363
  `SentinelLayer LLM proxy requires authentication. Run '${authLoginHint()}' first.`
362
364
  );
363
365
  }
364
366
  if (!resolvedApiUrl) resolvedApiUrl = String(session.apiUrl || "https://api.sentinelayer.com").trim();
365
- if (!resolvedToken) resolvedToken = String(session.token).trim();
367
+ if (!credential) credential = await credentialFor(session);
366
368
  }
367
369
 
368
370
  const url = `${resolvedApiUrl.replace(/\/+$/, "")}/api/v1/proxy/llm`;
@@ -393,7 +395,6 @@ export async function invokeViaProxy({
393
395
 
394
396
  const headers = {
395
397
  "Content-Type": "application/json",
396
- Authorization: `Bearer ${resolvedToken}`,
397
398
  Accept: "application/json",
398
399
  };
399
400
  if (normalizedUsageIdempotencyKey) {
@@ -410,12 +411,12 @@ export async function invokeViaProxy({
410
411
  const controller = new AbortController();
411
412
  const timeoutHandle = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS);
412
413
  try {
413
- response = await fetchImpl(url, {
414
- method: "POST",
415
- headers,
416
- body,
417
- signal: controller.signal,
418
- });
414
+ response = await credentialedRequest(
415
+ credential,
416
+ url,
417
+ { method: "POST", headers, body, signal: controller.signal },
418
+ { fetchImpl },
419
+ );
419
420
  } finally {
420
421
  clearTimeout(timeoutHandle);
421
422
  }
@@ -436,7 +437,7 @@ export async function invokeViaProxy({
436
437
 
437
438
  throw buildProxyError(response.status, parsedError);
438
439
  } catch (err) {
439
- if (err instanceof SentinelayerProxyError) {
440
+ if (err instanceof SentinelayerProxyError || err instanceof CredentialDestinationRefused) {
440
441
  throw err;
441
442
  }
442
443
  lastError = err;
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
+ import { userCredential } from "../auth/credential-destinations.js";
5
6
  import { resolveOutputRoot } from "../config/service.js";
6
7
  import { createAgentEvent } from "../events/schema.js";
7
8
  import { resolveCodebaseIngest } from "../ingest/engine.js";
@@ -566,11 +567,12 @@ export async function runAuditOrchestrator({
566
567
  }
567
568
  const memoryProvider = resolveMemoryProvider(process.env);
568
569
  const memoryApiEndpoint = normalizeString(process.env.SENTINELAYER_MEMORY_API_ENDPOINT);
569
- const memoryApiKey = normalizeString(
570
- process.env.SENTINELAYER_MEMORY_API_KEY ||
571
- process.env.SENTINELAYER_TOKEN ||
572
- process.env.SENTINELAYER_API_TOKEN
573
- );
570
+ // The memory service's own key, if configured; otherwise the user's token as a credential, which
571
+ // reaches the memory endpoint only if it is on the configured API's origin.
572
+ const memoryApiKey = normalizeString(process.env.SENTINELAYER_MEMORY_API_KEY);
573
+ const memoryCredential = memoryApiKey
574
+ ? null
575
+ : await userCredential(process.env.SENTINELAYER_TOKEN || process.env.SENTINELAYER_API_TOKEN, { source: "env" });
574
576
  const sharedMemoryCorpus = await buildSharedMemoryCorpus({
575
577
  outputRoot,
576
578
  targetPath: normalizedTargetPath,
@@ -628,6 +630,7 @@ export async function runAuditOrchestrator({
628
630
  provider: memoryProvider,
629
631
  apiEndpoint: memoryApiEndpoint,
630
632
  apiKey: memoryApiKey,
633
+ credential: memoryCredential,
631
634
  });
632
635
  sharedMemoryQueries.push({
633
636
  agentId: agent.id,
@@ -8,11 +8,13 @@
8
8
  // from inside the scope, so no human-authenticated subrequest can hide behind a
9
9
  // scoped main request.
10
10
  //
11
- // It imports nothing from this codebase, so the auth service can depend on it
12
- // without an import cycle.
11
+ // It imports only the credential module, so the auth service can depend on it without an
12
+ // import cycle.
13
13
 
14
14
  import { AsyncLocalStorage } from "node:async_hooks";
15
15
 
16
+ import { admissionCredential } from "./credential-destinations.js";
17
+
16
18
  export const admittedAgentScope = new AsyncLocalStorage();
17
19
 
18
20
  /**
@@ -26,7 +28,7 @@ export function scopedAdmissionAuth() {
26
28
  if (!(credential.expiresAt * 1000 > Date.now())) return null;
27
29
  return {
28
30
  apiUrl: credential.apiUrl,
29
- token: credential.token,
31
+ credential: admissionCredential(credential), // bound to the API that issued it; its token stays inside
30
32
  source: "session_admission",
31
33
  user: null,
32
34
  admissionId: credential.admissionId,