sentinelayer-cli 0.44.0 → 0.46.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 (51) hide show
  1. package/README.md +10 -5
  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/agents/shared-tools/shell.js +9 -0
  7. package/src/ai/aidenid.js +5 -4
  8. package/src/ai/proxy.js +15 -14
  9. package/src/audit/orchestrator.js +8 -5
  10. package/src/auth/admission-scope.js +5 -3
  11. package/src/auth/credential-destinations.js +221 -0
  12. package/src/auth/gate.js +4 -4
  13. package/src/auth/http.js +22 -5
  14. package/src/auth/service.js +52 -56
  15. package/src/cli.js +24 -6
  16. package/src/commands/ai/provision-governance.js +7 -6
  17. package/src/commands/init.js +25 -16
  18. package/src/commands/scan.js +6 -4
  19. package/src/commands/session.js +33 -45
  20. package/src/commands/spec.js +2 -2
  21. package/src/commands/watch.js +4 -3
  22. package/src/config/service.js +37 -0
  23. package/src/daemon/ast-parser-layer.js +8 -3
  24. package/src/daemon/callgraph-overlay.js +7 -2
  25. package/src/daemon/pulse.js +2 -1
  26. package/src/daemon/watchdog.js +117 -68
  27. package/src/legacy-cli.js +119 -39
  28. package/src/mcp/bridge-inputs.js +223 -0
  29. package/src/mcp/cli-command-tools.js +157 -7
  30. package/src/mcp/registry.js +2 -1
  31. package/src/mcp/session-stdio-server.js +114 -35
  32. package/src/mcp/smoke.js +27 -11
  33. package/src/mcp/token-service.js +7 -9
  34. package/src/memory/retrieval.js +14 -5
  35. package/src/pocket/ring-owner.js +13 -8
  36. package/src/program-lookup.js +54 -0
  37. package/src/review/ai-review.js +2 -2
  38. package/src/review/dd-report-email-client.js +4 -5
  39. package/src/review/local-review.js +11 -5
  40. package/src/session/admission-access.js +13 -10
  41. package/src/session/admission-auth.js +95 -16
  42. package/src/session/admission.js +44 -9
  43. package/src/session/checkpoints.js +11 -10
  44. package/src/session/file-locks.js +19 -18
  45. package/src/session/invitations.js +26 -19
  46. package/src/session/live-source.js +13 -13
  47. package/src/session/route-classes.js +107 -0
  48. package/src/session/sync.js +70 -56
  49. package/src/session/tickets.js +21 -19
  50. package/src/session/title-sync.js +4 -3
  51. package/src/telemetry/sync.js +8 -9
@@ -13,7 +13,14 @@ import { eventMatchesAgent } from "../session/listener.js";
13
13
  import { isSessionControlEvent } from "../session/control-events.js";
14
14
  import { buildObservations } from "../session/recall/observations.js";
15
15
  import { createMemoryService } from "../engram/index.js";
16
- import { assertCanonicalAgentId, canonicalAgentId, withAgentAdmission } from "../session/admission-auth.js";
16
+ import {
17
+ AdmissionCredentialRefused,
18
+ assertCanonicalAgentId,
19
+ canonicalAgentId,
20
+ withAgentAdmission,
21
+ } from "../session/admission-auth.js";
22
+ import { hasStoredAdmissionCredentials, storedAdmissionAgentsForSession } from "../session/admission.js";
23
+ import { sessionRouteClass } from "../session/route-classes.js";
17
24
  import {
18
25
  SESSION_REACTION_TYPES,
19
26
  isSessionReactionType,
@@ -886,43 +893,63 @@ async function persistSessionEvent({
886
893
  };
887
894
  }
888
895
 
889
- // Local MCP tools that act AS an agent: post, raise attention, react, reply, act on a
890
- // message, lease files, or advance that agent's read cursor. Each runs inside the
891
- // agent's admission scope, the same canonical path native `sl session` actor commands
892
- // use. A live stored admission is the only credential its requests carry; an expired,
893
- // malformed or mis-bound one is refused before any request; a credential the API
894
- // refuses (401/403) is final. With no stored admission a tool behaves as before.
895
- export const SESSION_MCP_ACTOR_TOOLS = Object.freeze([
896
- "poll_inbox",
897
- "read_history",
898
- "send_message",
899
- "attention_request",
900
- "session_action",
901
- "session_react",
902
- "session_reply",
903
- "session_lock",
904
- "session_unlock",
905
- ]);
896
+ function refuseOutsideAdmission(reason) {
897
+ throw new AdmissionCredentialRefused(
898
+ `This tool runs for an agent on this machine and needs an admission it can bind to: ${reason}. ` +
899
+ `It will not fall back to other credentials.`,
900
+ { reason: "session_admission_not_bound" },
901
+ );
902
+ }
906
903
 
907
- function actorAgentId(toolName, input = {}) {
908
- const raw =
909
- normalizeString(input.agentId || input.agent_id || input.agent) ||
910
- (toolName === "read_history" ? normalizeString(process.env.SENTINELAYER_AGENT_ID) : "");
911
- if (!raw) return "";
904
+ /**
905
+ * An agent context is a process that acts for an agent: SENTINELAYER_AGENT_ID is set, or
906
+ * this machine stores any agent admission credential (usable or not).
907
+ */
908
+ export async function isAgentContext({ env = process.env } = {}) {
909
+ return Boolean(normalizeString(env.SENTINELAYER_AGENT_ID)) || (await hasStoredAdmissionCredentials());
910
+ }
911
+
912
+ /**
913
+ * The one credential policy for local MCP session tools, by the tool's class in
914
+ * SESSION_ROUTE_CLASSES (src/session/route-classes.js):
915
+ * - exempt-local tools, and every tool outside an agent context: run as before
916
+ * - owner tools: refused in an agent context
917
+ * - the session stores an admission on this machine (in any state): the tool binds to the
918
+ * admission of the agent it names (input agentId, else SENTINELAYER_AGENT_ID) and runs
919
+ * inside its scope; naming no agent, an agent holding none of the session's admissions,
920
+ * or an expired, malformed or mis-bound admission is refused before any request
921
+ * - the session stores none: legacy-data-plane tools run as before; control, owner and
922
+ * unclassified (including newly added) tools are refused before any request
923
+ */
924
+ export async function runInSessionToolContext(name, input = {}, run, { env = process.env } = {}) {
925
+ const routeClass = sessionRouteClass(`mcp:${name}`);
926
+ if (routeClass === "exempt-local") return run(input);
927
+ if (!(await isAgentContext({ env }))) return run(input);
928
+ if (routeClass === "owner") refuseOutsideAdmission("owner actions are not available to agents");
929
+ const sessionId = normalizeString(input.sessionId || input.session_id || input.session);
930
+ if (!sessionId) refuseOutsideAdmission("no session id was given");
931
+ const stored = await storedAdmissionAgentsForSession(sessionId);
932
+ if (stored.length === 0) {
933
+ if (routeClass === "legacy-data-plane") return run(input);
934
+ refuseOutsideAdmission(`session ${sessionId} stores no admission on this machine and "${name}" needs one`);
935
+ }
936
+ const raw = normalizeString(input.agentId || input.agent_id || input.agent) || normalizeString(env.SENTINELAYER_AGENT_ID);
937
+ if (!raw) refuseOutsideAdmission("no agent id was given");
912
938
  // One canonical identity for authorisation and execution (see admission-auth.js).
913
939
  assertCanonicalAgentId(raw, "agentId");
914
- return canonicalAgentId(raw);
940
+ const agentId = canonicalAgentId(raw);
941
+ if (!stored.includes(agentId)) {
942
+ refuseOutsideAdmission(`agent "${agentId}" holds none of the admissions stored for session ${sessionId}`);
943
+ }
944
+ // live: runs inside the admission scope; an expired, malformed or mis-bound one is refused there.
945
+ // The file was seen above, so one removed since then refuses too: never a fallback.
946
+ return withAgentAdmission(sessionId, agentId, () => run(input), { requireStored: true });
915
947
  }
916
948
 
917
- function routeActorToolsThroughAdmission(handlers) {
918
- for (const name of SESSION_MCP_ACTOR_TOOLS) {
949
+ export function routeAgentToolsThroughAdmission(handlers, { env = process.env } = {}) {
950
+ for (const name of Object.keys(handlers)) {
919
951
  const run = handlers[name];
920
- handlers[name] = async (input = {}) => {
921
- const agentId = actorAgentId(name, input);
922
- // No agent named: the tool's own validation reports it (read_history reads as the user).
923
- if (!agentId) return run(input);
924
- return withAgentAdmission(requireSessionId(input), agentId, () => run(input));
925
- };
952
+ handlers[name] = (input = {}) => runInSessionToolContext(name, input, run, { env });
926
953
  }
927
954
  return handlers;
928
955
  }
@@ -1336,7 +1363,7 @@ export function createSessionMcpToolHandlers({
1336
1363
  });
1337
1364
  },
1338
1365
  };
1339
- return routeActorToolsThroughAdmission(handlers);
1366
+ return routeAgentToolsThroughAdmission(handlers);
1340
1367
  }
1341
1368
 
1342
1369
  export const SESSION_MCP_TOOLS = Object.freeze([
@@ -1625,13 +1652,14 @@ export const SESSION_MCP_TOOLS = Object.freeze([
1625
1652
  name: "session_locks",
1626
1653
  title: "List Senti File Locks",
1627
1654
  description:
1628
- "List active authoritative file leases for a session.",
1655
+ "List active authoritative file leases for a session. In an agent context it runs on the agent's admission (agentId, else SENTINELAYER_AGENT_ID).",
1629
1656
  inputSchema: {
1630
1657
  type: "object",
1631
1658
  additionalProperties: false,
1632
1659
  required: ["sessionId"],
1633
1660
  properties: {
1634
1661
  sessionId: { type: "string", minLength: 1 },
1662
+ agentId: { type: "string", minLength: 1 },
1635
1663
  },
1636
1664
  },
1637
1665
  },
@@ -1712,6 +1740,45 @@ function jsonRpcError(id, code, message, data = undefined) {
1712
1740
  });
1713
1741
  }
1714
1742
 
1743
+ // The dispatcher enforces each tool's published policy before its handler runs. A tool that
1744
+ // requires human approval is refused: approval would have to be server-validated and bound to the
1745
+ // call's arguments, and no such approval exists yet. A runtime-blocked tool is refused too. Refused
1746
+ // tools are not advertised by tools/list, and a call to one gets a specific JSON-RPC error.
1747
+ export const MCP_HUMAN_APPROVAL_REQUIRED = Object.freeze({ code: -32001, reason: "human_approval_required" });
1748
+ export const MCP_TOOL_BLOCKED = Object.freeze({ code: -32002, reason: "tool_blocked" });
1749
+
1750
+ function toolRefusal(tool) {
1751
+ const security = isPlainObject(tool?.security) ? tool.security : {};
1752
+ if (security.requires_human_approval === true) return MCP_HUMAN_APPROVAL_REQUIRED.reason;
1753
+ if (tool?.metadata?.bridge === "cli-command" && security.requires_human_approval !== false) {
1754
+ return MCP_HUMAN_APPROVAL_REQUIRED.reason;
1755
+ }
1756
+ if (security.runtime_blocked === true || tool?.metadata?.blocked === true) return MCP_TOOL_BLOCKED.reason;
1757
+ return "";
1758
+ }
1759
+
1760
+ function terminalCommandFor(tool, toolName) {
1761
+ const cliPath = Array.isArray(tool?.metadata?.cliPath) ? tool.metadata.cliPath : toolName.replace(/^sl\./, "").split(".");
1762
+ return `sl ${cliPath.join(" ")}`;
1763
+ }
1764
+
1765
+ function refusalError(id, toolName, tool, reason) {
1766
+ if (reason === MCP_HUMAN_APPROVAL_REQUIRED.reason) {
1767
+ return jsonRpcError(
1768
+ id,
1769
+ MCP_HUMAN_APPROVAL_REQUIRED.code,
1770
+ `Human approval is required for ${toolName}. It is not available over the MCP bridge in this version; run \`${terminalCommandFor(tool, toolName)}\` in a terminal instead.`,
1771
+ { reason, tool: toolName },
1772
+ );
1773
+ }
1774
+ return jsonRpcError(
1775
+ id,
1776
+ MCP_TOOL_BLOCKED.code,
1777
+ `${toolName} is not available over the MCP bridge; run \`${terminalCommandFor(tool, toolName)}\` in a terminal instead.`,
1778
+ { reason, tool: toolName },
1779
+ );
1780
+ }
1781
+
1715
1782
  export async function handleMcpJsonRpcMessage(
1716
1783
  message,
1717
1784
  {
@@ -1761,7 +1828,7 @@ export async function handleMcpJsonRpcMessage(
1761
1828
  if (method === "tools/list") {
1762
1829
  if (isNotification) return null;
1763
1830
  return jsonRpcSuccess(id, {
1764
- tools: tools.map((tool) => toMcpTool(tool)),
1831
+ tools: tools.filter((tool) => !toolRefusal(tool)).map((tool) => toMcpTool(tool)),
1765
1832
  });
1766
1833
  }
1767
1834
 
@@ -1770,6 +1837,18 @@ export async function handleMcpJsonRpcMessage(
1770
1837
  const toolName = normalizeString(message.params?.name);
1771
1838
  const args = isPlainObject(message.params?.arguments) ? message.params.arguments : {};
1772
1839
  const handler = handlers[toolName];
1840
+ const tool = (Array.isArray(tools) ? tools : []).find((candidate) => normalizeString(candidate?.name) === toolName);
1841
+ const refusal = typeof handler === "function" && tool ? toolRefusal(tool) : "";
1842
+ if (refusal) {
1843
+ return refusalError(id, toolName, tool, refusal);
1844
+ }
1845
+ if (typeof handler === "function" && !tool) {
1846
+ // a handler without a published definition has no policy to check: refused
1847
+ return jsonRpcSuccess(
1848
+ id,
1849
+ buildToolResult({ ok: false, reason: "unknown_tool", tool: toolName }, { isError: true }),
1850
+ );
1851
+ }
1773
1852
  if (typeof handler !== "function") {
1774
1853
  return jsonRpcSuccess(
1775
1854
  id,
package/src/mcp/smoke.js CHANGED
@@ -1,5 +1,11 @@
1
1
  import process from "node:process";
2
2
 
3
+ import {
4
+ CredentialDestinationRefused,
5
+ credentialedRequest,
6
+ isCredential,
7
+ userCredential,
8
+ } from "../auth/credential-destinations.js";
3
9
  import { DEFAULT_REQUEST_TIMEOUT_MS } from "../auth/http.js";
4
10
  import { requestHostedMcpAccessToken } from "./token-service.js";
5
11
 
@@ -75,7 +81,7 @@ function summarizeJsonRpcError(error, secrets = []) {
75
81
  async function postJsonRpc({
76
82
  fetchImpl,
77
83
  url,
78
- accessToken,
84
+ credential,
79
85
  payload,
80
86
  timeoutMs,
81
87
  redactionSecrets = [],
@@ -83,15 +89,18 @@ async function postJsonRpc({
83
89
  const controller = new AbortController();
84
90
  const timer = setTimeout(() => controller.abort(), timeoutMs);
85
91
  try {
86
- const response = await fetchImpl(url, {
87
- method: "POST",
88
- headers: {
89
- Authorization: `Bearer ${accessToken}`,
90
- "Content-Type": "application/json",
92
+ // fetchImpl is the transport credentialedRequest calls, after its origin check.
93
+ const response = await credentialedRequest(
94
+ credential,
95
+ url,
96
+ {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify(payload),
100
+ signal: controller.signal,
91
101
  },
92
- body: JSON.stringify(payload),
93
- signal: controller.signal,
94
- });
102
+ { fetchImpl },
103
+ );
95
104
  const text = await response.text().catch(() => "");
96
105
  let json = null;
97
106
  if (text) {
@@ -108,6 +117,7 @@ async function postJsonRpc({
108
117
  detail: json ? "" : redactMcpSmokeText(text, redactionSecrets),
109
118
  };
110
119
  } catch (error) {
120
+ if (error instanceof CredentialDestinationRefused) throw error;
111
121
  const aborted = Boolean(error) && error.name === "AbortError";
112
122
  return {
113
123
  reached: false,
@@ -172,6 +182,12 @@ export async function runHostedMcpSmoke({
172
182
  if (!accessToken) {
173
183
  throw new Error("Hosted MCP token mint succeeded but returned no access token.");
174
184
  }
185
+ // The minted bearer as a credential: the mint's own hosted MCP credential, or the bare token bound
186
+ // to the configured API. Any other credential (an agent's admission, say) is not accepted here.
187
+ if (minted.credential !== undefined && !(isCredential(minted.credential) && minted.credential.source === "hosted_mcp")) {
188
+ throw new TypeError("The hosted MCP mint must return a hosted MCP credential.");
189
+ }
190
+ const credential = minted.credential || (await userCredential(accessToken, { env, homeDir, source: "hosted_mcp" }));
175
191
  const redactionSecrets = [accessToken];
176
192
 
177
193
  const mcpUrl = joinUrl(minted.apiUrl, "/mcp");
@@ -181,7 +197,7 @@ export async function runHostedMcpSmoke({
181
197
  const toolsResponse = await postJsonRpc({
182
198
  fetchImpl,
183
199
  url: mcpUrl,
184
- accessToken,
200
+ credential,
185
201
  timeoutMs: normalizedTimeoutMs,
186
202
  redactionSecrets,
187
203
  payload: {
@@ -247,7 +263,7 @@ export async function runHostedMcpSmoke({
247
263
  const eventsResponse = await postJsonRpc({
248
264
  fetchImpl,
249
265
  url: mcpUrl,
250
- accessToken,
266
+ credential,
251
267
  timeoutMs: normalizedTimeoutMs,
252
268
  redactionSecrets,
253
269
  payload: {
@@ -1,5 +1,6 @@
1
1
  import process from "node:process";
2
2
 
3
+ import { credentialFor, isAuthenticated, userCredential } from "../auth/credential-destinations.js";
3
4
  import { DEFAULT_REQUEST_TIMEOUT_MS, requestJsonMutation } from "../auth/http.js";
4
5
  import {
5
6
  DEFAULT_API_TOKEN_TTL_DAYS,
@@ -39,12 +40,6 @@ function buildApiPath(apiUrl, pathSuffix) {
39
40
  return `${base}/${suffix}`;
40
41
  }
41
42
 
42
- function toAuthHeader(token) {
43
- return {
44
- Authorization: `Bearer ${String(token || "").trim()}`,
45
- };
46
- }
47
-
48
43
  function applyScope(body, { scope = "", scopes } = {}) {
49
44
  const normalizedScope = String(scope || "").trim();
50
45
  const normalizedScopes = Array.isArray(scopes)
@@ -118,7 +113,7 @@ export async function requestHostedMcpAccessToken({
118
113
  tokenTtlDays,
119
114
  homeDir,
120
115
  });
121
- if (!session || !session.token) {
116
+ if (!isAuthenticated(session)) {
122
117
  throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
123
118
  }
124
119
 
@@ -135,17 +130,20 @@ export async function requestHostedMcpAccessToken({
135
130
  {
136
131
  method: "POST",
137
132
  operationName: "mcp-token-mint",
138
- headers: toAuthHeader(session.token),
133
+ credential: await credentialFor(session, { env, homeDir }),
139
134
  body,
140
135
  timeoutMs: normalizedTimeoutMs,
141
136
  }
142
137
  );
138
+ const accessToken = String(response.access_token || "");
143
139
 
144
140
  return {
145
141
  apiUrl: session.apiUrl,
146
142
  authSource: session.source,
147
143
  rotated: Boolean(session.rotated),
148
- accessToken: String(response.access_token || ""),
144
+ accessToken,
145
+ // the minted bearer, bound to the configured API like the token that minted it
146
+ credential: await userCredential(accessToken, { env, homeDir, source: "hosted_mcp" }),
149
147
  tokenType: String(response.token_type || "Bearer"),
150
148
  expiresIn: Number(response.expires_in || 0),
151
149
  expiresAt: String(response.expires_at || ""),
@@ -1,6 +1,8 @@
1
1
  import fsp from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
+ import { credentialedRequest } from "../auth/credential-destinations.js";
5
+
4
6
  function normalizeString(value) {
5
7
  return String(value || "").trim();
6
8
  }
@@ -506,6 +508,7 @@ export async function queryHybridRetriever({
506
508
  provider = "local",
507
509
  apiEndpoint = "",
508
510
  apiKey = "",
511
+ credential = null,
509
512
  fetchImpl = globalThis.fetch,
510
513
  } = {}) {
511
514
  const normalizedProvider = normalizeString(provider).toLowerCase() || "local";
@@ -534,12 +537,18 @@ export async function queryHybridRetriever({
534
537
  }
535
538
 
536
539
  try {
537
- const response = await fetchImpl(String(apiEndpoint), {
540
+ // The user's credential goes only to its own origin (credentialedRequest); a dedicated memory
541
+ // API key is that service's own key, sent to the endpoint configured for it.
542
+ const send = (init) =>
543
+ credential
544
+ ? credentialedRequest(credential, String(apiEndpoint), init, { fetchImpl })
545
+ : fetchImpl(String(apiEndpoint), {
546
+ ...init,
547
+ headers: { ...init.headers, ...(normalizeString(apiKey) ? { authorization: `Bearer ${apiKey}` } : {}) },
548
+ });
549
+ const response = await send({
538
550
  method: "POST",
539
- headers: {
540
- "content-type": "application/json",
541
- ...(normalizeString(apiKey) ? { authorization: `Bearer ${apiKey}` } : {}),
542
- },
551
+ headers: { "content-type": "application/json" },
543
552
  body: JSON.stringify({
544
553
  query: normalizeString(query),
545
554
  limit: Math.max(1, Math.floor(normalizeNumber(limit, 12))),
@@ -1,5 +1,6 @@
1
1
  import process from "node:process";
2
2
 
3
+ import { checkedTransport, credentialFor, gatewayCredential, isAuthenticated } from "../auth/credential-destinations.js";
3
4
  import { requestJsonMutation } from "../auth/http.js";
4
5
  import { resolveActiveAuthSession } from "../auth/service.js";
5
6
 
@@ -16,14 +17,12 @@ function normalizeString(value) {
16
17
 
17
18
  /**
18
19
  * Resolve the pocket-gateway base URL — DISTINCT from the senti apiUrl (do NOT reuse resolveApiUrl; that is the API host).
19
- * Precedence: env SENTI_POCKET_URL -> env POCKET_GATEWAY_URL -> a config value if the caller passed one. Absent -> "".
20
+ * Precedence: env SENTI_POCKET_URL -> a config value if the caller passed one. Absent -> "". The user's token is only
21
+ * sent to the SENTI_POCKET_URL origin (src/auth/credential-destinations.js).
20
22
  * A present value MUST be an http(s) URL (fail-closed on a bogus scheme). Trailing slashes trimmed.
21
23
  */
22
24
  export function resolvePocketGatewayUrl({ env = process.env, configUrl = "" } = {}) {
23
- const raw =
24
- normalizeString(env.SENTI_POCKET_URL) ||
25
- normalizeString(env.POCKET_GATEWAY_URL) ||
26
- normalizeString(configUrl);
25
+ const raw = normalizeString(env.SENTI_POCKET_URL) || normalizeString(configUrl);
27
26
  if (!raw) return "";
28
27
  if (!/^https?:\/\//i.test(raw)) throw new Error("pocket gateway URL must be an http(s) URL");
29
28
  return raw.replace(/\/+$/, "");
@@ -74,9 +73,15 @@ export async function ringOwner(
74
73
  }
75
74
 
76
75
  const auth = await resolveAuthSession({ cwd, env, autoRotate: false });
77
- if (!auth?.token) {
76
+ if (!isAuthenticated(auth)) {
78
77
  throw new Error("Not authenticated. Run `sl auth login` first.");
79
78
  }
79
+ // The user's token, bound to the gateway SENTI_POCKET_URL names; a --gateway-url on any other
80
+ // origin is refused when the request is sent.
81
+ const credential = await gatewayCredential(await credentialFor(auth, { env }), { env });
82
+ if (!credential) {
83
+ throw new Error("pocket gateway URL not configured. Set SENTI_POCKET_URL to the pocket-gateway base URL.");
84
+ }
80
85
 
81
86
  const context = { sessionId: sid };
82
87
  if (normalizeString(whatWeNeed)) context.whatWeNeed = normalizeString(whatWeNeed);
@@ -88,10 +93,10 @@ export async function ringOwner(
88
93
 
89
94
  // requestJsonMutation auto-derives a transport Idempotency-Key from operationName (retry-safe at the wire); the
90
95
  // gateway's own ring-level dedupe (PR-B4) keys on body.idempotencyKey OR a content hash, so an identical retry rings once.
91
- return requestMutation(`${gw}/dial/ring-owner`, {
96
+ return checkedTransport(requestMutation)(`${gw}/dial/ring-owner`, {
92
97
  method: "POST",
93
98
  operationName: "pocket.ring_owner",
94
- headers: { Authorization: `Bearer ${auth.token}` },
99
+ credential,
95
100
  body,
96
101
  });
97
102
  }
@@ -0,0 +1,54 @@
1
+ import process from "node:process";
2
+
3
+ // Windows looks up a program named without a path in the working directory before PATH. The CLI
4
+ // runs git, gh, python, npm and other tools by name, often from inside a repository, so it turns
5
+ // that lookup off with NoDefaultCurrentDirectoryInExePath. Node.js spawns through libuv, which
6
+ // honours that variable from libuv 1.48.0 (Node.js 22); an older libuv ignores it. On such a
7
+ // runtime the CLI does not run at all on Windows, rather than run with the lookup on.
8
+ export const MIN_WINDOWS_LIBUV = Object.freeze([1, 48, 0]);
9
+
10
+ function parseVersion(value) {
11
+ const parts = String(value || "").split(".");
12
+ if (parts.length < 2 || parts.length > 3) {
13
+ return null;
14
+ }
15
+ const numbers = parts.map((part) => (/^\d+$/.test(part) ? Number(part) : NaN));
16
+ return numbers.every(Number.isInteger) ? [...numbers, 0].slice(0, 3) : null;
17
+ }
18
+
19
+ function atLeast(version, minimum) {
20
+ for (let index = 0; index < minimum.length; index += 1) {
21
+ if (version[index] !== minimum[index]) {
22
+ return version[index] > minimum[index];
23
+ }
24
+ }
25
+ return true;
26
+ }
27
+
28
+ /**
29
+ * Turn off working-directory program lookup for this process and the children that inherit its
30
+ * environment. Returns { ok: false, message } instead when this is Windows and the runtime would
31
+ * ignore the setting.
32
+ */
33
+ export function restrictProgramLookupToPath({
34
+ platform = process.platform,
35
+ libuv = process.versions.uv,
36
+ nodeVersion = process.version,
37
+ env = process.env,
38
+ } = {}) {
39
+ if (platform !== "win32") {
40
+ return { ok: true };
41
+ }
42
+ const version = parseVersion(libuv);
43
+ if (!version || !atLeast(version, MIN_WINDOWS_LIBUV)) {
44
+ return {
45
+ ok: false,
46
+ message:
47
+ `On Windows, sentinelayer-cli needs Node.js 22 or later (libuv ${MIN_WINDOWS_LIBUV.join(".")} or later). ` +
48
+ `This is Node.js ${nodeVersion} with libuv ${libuv || "unknown"}, which looks up programs in the current ` +
49
+ "directory before PATH. Upgrade Node.js and run the command again.",
50
+ };
51
+ }
52
+ env.NoDefaultCurrentDirectoryInExePath = "1";
53
+ return { ok: true };
54
+ }
@@ -10,7 +10,7 @@ import {
10
10
  import { computeProviderCost } from "../billing/price-book.js";
11
11
  import { buildBillingRunId, buildCallIdempotencyKey, sanitizeBillingMetadata } from "../billing/ledger-entry.js";
12
12
  import { recordSessionUsage } from "../billing/session-usage.js";
13
- import { loadConfig } from "../config/service.js";
13
+ import { loadConfig, userConfigValues } from "../config/service.js";
14
14
  import { evaluateBudget } from "../cost/budget.js";
15
15
  import { appendCostEntry, summarizeCostHistory } from "../cost/history.js";
16
16
  import { estimateTokens } from "../cost/tokenizer.js";
@@ -534,7 +534,7 @@ export async function runAiReviewLayer({
534
534
  configModel: config.resolved.defaultModelId || DEFAULT_REVIEW_AI_MODEL,
535
535
  });
536
536
  const explicitApiKey = normalizeString(apiKey);
537
- const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, config.resolved);
537
+ const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, userConfigValues(config));
538
538
 
539
539
  const prompt = buildAiReviewPrompt({
540
540
  targetPath: normalizedTargetPath,
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
+ import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
3
4
  import { resolveActiveAuthSession } from "../auth/service.js";
4
5
  import { requestJson } from "../auth/http.js";
5
6
 
@@ -97,7 +98,7 @@ export async function sendDdReportEmail({
97
98
  });
98
99
  }
99
100
 
100
- if (!session || !session.token) {
101
+ if (!isAuthenticated(session)) {
101
102
  return errorResult({
102
103
  runId: normalizedRunId,
103
104
  to: normalizedTo,
@@ -116,11 +117,9 @@ export async function sendDdReportEmail({
116
117
  });
117
118
 
118
119
  try {
119
- const response = await requestJsonImpl(endpoint, {
120
+ const response = await checkedTransport(requestJsonImpl)(endpoint, {
120
121
  method: "POST",
121
- headers: {
122
- Authorization: `Bearer ${session.token}`,
123
- },
122
+ credential: await credentialFor(session),
124
123
  idempotencyKey,
125
124
  body: { to: normalizedTo },
126
125
  timeoutMs,
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import fsp from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
+ import { buildScrubbedEnv } from "../agents/shared-tools/shell.js";
6
7
  import { resolveOutputRoot } from "../config/service.js";
7
8
  import { resolveCodebaseIngest } from "../ingest/engine.js";
8
9
  import { runSpecBindingChecks } from "./spec-binding.js";
@@ -844,6 +845,13 @@ function buildStaticChecks(ingest = {}) {
844
845
  ];
845
846
  }
846
847
 
848
+ // The repository's own scripts run here (lint, typecheck, format check, tests). Running its code is
849
+ // the point; handing it this machine's credentials is not. SentinelLayer, provider, GitHub, cloud
850
+ // and other secret-looking variables are removed from its environment.
851
+ export function staticCheckEnv(env = process.env) {
852
+ return { ...buildScrubbedEnv(env), CI: "1", FORCE_COLOR: "0" };
853
+ }
854
+
847
855
  async function executeStaticCheck({ check, targetPath, runDir } = {}) {
848
856
  const checksDir = path.join(runDir, "checks");
849
857
  await fsp.mkdir(checksDir, { recursive: true });
@@ -853,11 +861,7 @@ async function executeStaticCheck({ check, targetPath, runDir } = {}) {
853
861
  cwd: targetPath,
854
862
  encoding: "utf-8",
855
863
  timeout: STATIC_CHECK_TIMEOUT_MS,
856
- env: {
857
- ...process.env,
858
- CI: "1",
859
- FORCE_COLOR: "0",
860
- },
864
+ env: staticCheckEnv(),
861
865
  });
862
866
 
863
867
  const durationMs = Date.now() - startedAt;
@@ -927,6 +931,8 @@ async function executeStaticCheck({ check, targetPath, runDir } = {}) {
927
931
  };
928
932
  }
929
933
 
934
+ export const __localReviewForTests = Object.freeze({ executeStaticCheck });
935
+
930
936
  async function runStaticAnalysisLayer({ targetPath, ingest, runDir, maxFindings = MAX_FINDINGS } = {}) {
931
937
  const checks = buildStaticChecks(ingest);
932
938
  const results = [];
@@ -8,6 +8,7 @@
8
8
 
9
9
  import process from "node:process";
10
10
 
11
+ import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
11
12
  import { requestJson, requestJsonMutation } from "../auth/http.js";
12
13
  import { resolveActiveAuthSession } from "../auth/service.js";
13
14
  import { hasStoredAdmissionCredentials } from "./admission.js";
@@ -74,11 +75,11 @@ async function authContext(targetPath, resolveAuthSession) {
74
75
  env: process.env,
75
76
  autoRotate: false,
76
77
  });
77
- if (!auth?.token || !auth?.apiUrl) {
78
+ if (!isAuthenticated(auth) || !auth?.apiUrl) {
78
79
  throw new Error("Not authenticated. Run `sl auth login` first.");
79
80
  }
80
81
  return {
81
- token: auth.token,
82
+ credential: await credentialFor(auth),
82
83
  apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, ""),
83
84
  };
84
85
  }
@@ -109,12 +110,13 @@ async function mutateAdmission(
109
110
  ) {
110
111
  const key = boundedIdempotencyKey(idempotencyKey, operationName);
111
112
  const auth = await authContext(targetPath, resolveAuthSession);
112
- const result = await requestMutation(admissionsUrl(auth.apiUrl, sessionId, suffix), {
113
+ const result = await checkedTransport(requestMutation)(admissionsUrl(auth.apiUrl, sessionId, suffix), {
113
114
  method: "POST",
114
115
  operationName,
115
116
  idempotencyKey: key,
117
+ credential: auth.credential,
116
118
  headers: createSessionMutationHeaders({
117
- bearerToken: auth.token,
119
+ credential: auth.credential,
118
120
  sessionId,
119
121
  routeId,
120
122
  idempotencyKey: key,
@@ -143,9 +145,9 @@ export async function listSessionAdmissions(
143
145
  }
144
146
  const auth = await authContext(targetPath, resolveAuthSession);
145
147
  const query = normalizedStatus ? `?status=${encodeURIComponent(normalizedStatus)}` : "";
146
- return requestRead(`${admissionsUrl(auth.apiUrl, sid)}${query}`, {
148
+ return checkedTransport(requestRead)(`${admissionsUrl(auth.apiUrl, sid)}${query}`, {
147
149
  method: "GET",
148
- headers: { Authorization: `Bearer ${auth.token}` },
150
+ credential: auth.credential,
149
151
  });
150
152
  }
151
153
 
@@ -161,9 +163,9 @@ export async function getSessionAdmission(
161
163
  const sid = required(sessionId, "session id");
162
164
  const aid = required(admissionId, "admission id");
163
165
  const auth = await authContext(targetPath, resolveAuthSession);
164
- return requestRead(admissionsUrl(auth.apiUrl, sid, `/${encodeURIComponent(aid)}`), {
166
+ return checkedTransport(requestRead)(admissionsUrl(auth.apiUrl, sid, `/${encodeURIComponent(aid)}`), {
165
167
  method: "GET",
166
- headers: { Authorization: `Bearer ${auth.token}` },
168
+ credential: auth.credential,
167
169
  });
168
170
  }
169
171
 
@@ -264,14 +266,15 @@ export async function setSessionAdmissionMode(
264
266
  await assertOwnerAccessContext();
265
267
  const key = boundedIdempotencyKey(idempotencyKey, "session.admission_mode");
266
268
  const auth = await authContext(targetPath, resolveAuthSession);
267
- const result = await requestMutation(
269
+ const result = await checkedTransport(requestMutation)(
268
270
  `${auth.apiUrl}/api/v1/sessions/${encodeURIComponent(sid)}/admission-mode`,
269
271
  {
270
272
  method: "POST",
271
273
  operationName: "session.admission_mode",
272
274
  idempotencyKey: key,
275
+ credential: auth.credential,
273
276
  headers: createSessionMutationHeaders({
274
- bearerToken: auth.token,
277
+ credential: auth.credential,
275
278
  sessionId: sid,
276
279
  routeId: ROUTES.mode,
277
280
  idempotencyKey: key,