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
package/src/cli.js CHANGED
@@ -2,6 +2,7 @@ import process from "node:process";
2
2
  import { Command } from "commander";
3
3
 
4
4
  import { CLI_VERSION, runLegacyCliWithErrorHandling } from "./legacy-cli.js";
5
+ import { restrictProgramLookupToPath } from "./program-lookup.js";
5
6
 
6
7
  const COMMAND_REGISTRARS = {
7
8
  init: {
@@ -242,6 +243,20 @@ export async function buildCliProgram({
242
243
  }
243
244
 
244
245
  export async function runCli(rawArgs = process.argv.slice(2)) {
246
+ // Programs named without a path come from PATH only, never from the working directory, before
247
+ // anything is spawned. On a Windows runtime that cannot guarantee that, nothing runs.
248
+ const programLookup = restrictProgramLookupToPath();
249
+ if (!programLookup.ok) {
250
+ console.error(programLookup.message);
251
+ process.exitCode = 1;
252
+ return;
253
+ }
254
+
255
+ // Where the user's credentials may go is decided once, here, from the real environment and the
256
+ // global config, and frozen: nothing that changes process.env later can move it.
257
+ const { freezeProcessTrustContext } = await import("./auth/credential-destinations.js");
258
+ await freezeProcessTrustContext();
259
+
245
260
  // Normalize slash commands (/omargate → omargate, /audit → audit, etc.)
246
261
  const normalizedArgs = normalizeSlashArgs(rawArgs);
247
262
 
@@ -269,15 +284,18 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
269
284
  // the subcommand (say, post-agent, observe, locks, checkpoints, ...), so no
270
285
  // subcommand, present or future, can reach the human token. A tombstoned
271
286
  // admission refuses here, before any request.
272
- const { assertDispatchMatchesScope, resolveAgentAdmissionTarget, withAgentAdmission } = await import(
273
- "./session/admission-auth.js"
274
- );
287
+ const { assertDispatchMatchesScope, resolveAgentAdmissionTarget, withAgentAdmission } =
288
+ await import("./session/admission-auth.js");
275
289
  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.
290
+ // Execution must match authorization: before ANY action runs, decide on Commander's parsed
291
+ // arguments and options -- the values the action will use. Through the MCP CLI bridge in an
292
+ // agent context the action binds to its session's admission, runs a legacy data-plane route
293
+ // as before, or is refused (src/session/route-classes.js); and the identity it resolves must
294
+ // be the one whose scope is in force.
278
295
  program.hook("preAction", (_hooked, actionCommand) => assertDispatchMatchesScope(actionCommand));
279
296
  if (target) {
280
- await withAgentAdmission(target.sessionId, target.agentId, dispatch);
297
+ // An admission seen above that is gone by the time it is read refuses: never a fallback.
298
+ await withAgentAdmission(target.sessionId, target.agentId, dispatch, { requireStored: true });
281
299
  return;
282
300
  }
283
301
  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 */
@@ -7,6 +7,7 @@ import { spawn as defaultSpawn } from "node:child_process";
7
7
 
8
8
  import pc from "picocolors";
9
9
 
10
+ import { credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
10
11
  import { SentinelayerApiError, requestJsonMutation } from "../auth/http.js";
11
12
  import {
12
13
  buildProvisionEmailPayload,
@@ -15,7 +16,6 @@ import {
15
16
  resolveAidenIdCredentials,
16
17
  } from "../ai/aidenid.js";
17
18
  import { recordProvisionedIdentity } from "../ai/identity-store.js";
18
- import { readStoredSession } from "../auth/session-store.js";
19
19
  import { fetchAidenIdCredentials } from "../auth/service.js";
20
20
  import { resolveActiveAuthSession } from "../auth/service.js";
21
21
  import { resolveOutputRoot } from "../config/service.js";
@@ -1322,19 +1322,11 @@ async function resolveSessionRemoteSyncState({ dashboardUrl } = {}) {
1322
1322
  };
1323
1323
  }
1324
1324
 
1325
- let storedSession = null;
1326
- try {
1327
- storedSession = await readStoredSession();
1328
- } catch {
1329
- storedSession = null;
1330
- }
1331
- const apiUrl =
1332
- normalizeString(storedSession?.apiUrl) ||
1333
- normalizeString(process.env.SENTINELAYER_API_URL) ||
1334
- "https://api.sentinelayer.com";
1335
- const hasToken = Boolean(
1336
- normalizeString(storedSession?.token) || normalizeString(process.env.SENTINELAYER_TOKEN),
1325
+ const auth = await resolveActiveAuthSession({ cwd: process.cwd(), env: process.env, autoRotate: false }).catch(
1326
+ () => null,
1337
1327
  );
1328
+ const apiUrl = normalizeString(auth?.apiUrl) || "https://api.sentinelayer.com";
1329
+ const hasToken = isAuthenticated(auth);
1338
1330
  if (!hasToken) {
1339
1331
  return {
1340
1332
  status: "auth_required",
@@ -1458,12 +1450,12 @@ async function findReusableSessionCandidate({
1458
1450
  // - When `SENTINELAYER_SKIP_REMOTE_SYNC=1` (test bootstrap), short-circuits
1459
1451
  // to `{ ok: true, source: "skipped", session: null }` so unit tests
1460
1452
  // can exercise the local materialization path without a real API.
1461
- async function fetchRemoteSessionDetail(endpoint, headers) {
1453
+ async function fetchRemoteSessionDetail(endpoint, credential) {
1462
1454
  let response;
1463
1455
  let payload;
1464
1456
  try {
1465
1457
  ({ response, payload } = await fetchJsonWithFullTimeout(
1466
- endpoint, { method: "GET", headers, redirect: "error" }, 2_000,
1458
+ endpoint, { method: "GET", credential, redirect: "error" }, 2_000,
1467
1459
  ));
1468
1460
  } catch (err) {
1469
1461
  return {
@@ -1516,7 +1508,7 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1516
1508
  } catch {
1517
1509
  return { ok: false, reason: "no_session" };
1518
1510
  }
1519
- if (!auth || !auth.token) {
1511
+ if (!isAuthenticated(auth)) {
1520
1512
  return { ok: false, reason: "not_authenticated", status: 401 };
1521
1513
  }
1522
1514
  const apiUrl = String(auth.apiUrl || "").replace(/\/+$/, "");
@@ -1524,10 +1516,10 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1524
1516
  return { ok: false, reason: "no_api_url" };
1525
1517
  }
1526
1518
  const endpoint = `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
1527
- const headers = { Authorization: `Bearer ${auth.token}` };
1528
- const firstAttempt = await fetchRemoteSessionDetail(endpoint, headers);
1519
+ const credential = await credentialFor(auth);
1520
+ const firstAttempt = await fetchRemoteSessionDetail(endpoint, credential);
1529
1521
  const detail = firstAttempt.retryable
1530
- ? await fetchRemoteSessionDetail(endpoint, headers)
1522
+ ? await fetchRemoteSessionDetail(endpoint, credential)
1531
1523
  : firstAttempt;
1532
1524
  if (detail.ok) {
1533
1525
  return {
@@ -2782,7 +2774,7 @@ async function resolveAdminApiSession({ targetPath, explicitApiUrl }) {
2782
2774
  explicitApiUrl,
2783
2775
  autoRotate: true,
2784
2776
  });
2785
- if (!session || !session.token) {
2777
+ if (!isAuthenticated(session)) {
2786
2778
  throw new Error(`No active auth token found. Run \`${authLoginHint()}\` first.`);
2787
2779
  }
2788
2780
  return session;
@@ -2802,10 +2794,8 @@ async function postAdminSessionMutation({
2802
2794
  return requestJsonMutation(`${apiUrl}${pathSuffix}`, {
2803
2795
  method: "POST",
2804
2796
  operationName,
2805
- headers: {
2806
- Authorization: `Bearer ${normalizeString(session.token)}`,
2807
- ...headers,
2808
- },
2797
+ credential: await credentialFor(session),
2798
+ headers,
2809
2799
  body,
2810
2800
  });
2811
2801
  }
@@ -3306,7 +3296,7 @@ export function registerSessionCommand(program) {
3306
3296
  env: process.env,
3307
3297
  autoRotate: false,
3308
3298
  });
3309
- if (!session?.token || !session?.apiUrl) {
3299
+ if (!isAuthenticated(session) || !session?.apiUrl) {
3310
3300
  throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
3311
3301
  }
3312
3302
  const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
@@ -3315,7 +3305,7 @@ export function registerSessionCommand(program) {
3315
3305
  {
3316
3306
  method: "POST",
3317
3307
  operationName: "session.set_title",
3318
- headers: { Authorization: `Bearer ${session.token}` },
3308
+ credential: session.credential,
3319
3309
  body: { title: normalizedTitle },
3320
3310
  },
3321
3311
  );
@@ -3356,7 +3346,7 @@ export function registerSessionCommand(program) {
3356
3346
  env: process.env,
3357
3347
  autoRotate: false,
3358
3348
  });
3359
- if (!session?.token || !session?.apiUrl) {
3349
+ if (!isAuthenticated(session) || !session?.apiUrl) {
3360
3350
  throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
3361
3351
  }
3362
3352
  const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
@@ -3365,7 +3355,7 @@ export function registerSessionCommand(program) {
3365
3355
  {
3366
3356
  method: "POST",
3367
3357
  operationName: "session.sweep_empty",
3368
- headers: { Authorization: `Bearer ${session.token}` },
3358
+ credential: session.credential,
3369
3359
  body: {
3370
3360
  cutoffMinutes,
3371
3361
  maxEvents,
@@ -6400,7 +6390,7 @@ export function registerSessionCommand(program) {
6400
6390
  env: process.env,
6401
6391
  autoRotate: false,
6402
6392
  });
6403
- if (!authSession || !authSession.token) {
6393
+ if (!isAuthenticated(authSession)) {
6404
6394
  throw new Error(`Remote session read requires authentication. Run \`${authLoginHint()}\` first.`);
6405
6395
  }
6406
6396
  hydration = await hydrateSessionFromRemote({
@@ -6674,13 +6664,13 @@ export function registerSessionCommand(program) {
6674
6664
  autoRotate: false,
6675
6665
  }).catch(() => null);
6676
6666
  const apiBaseUrl = session?.apiUrl || "";
6677
- const token = session?.token || "";
6667
+ const credential = session?.credential || null;
6678
6668
  try {
6679
6669
  for await (const item of mergeLiveSources({
6680
6670
  sessionId: normalizedSessionId,
6681
6671
  targetPath,
6682
6672
  apiBaseUrl: apiBaseUrl || undefined,
6683
- token: token || undefined,
6673
+ credential: credential || undefined,
6684
6674
  signal: ac.signal,
6685
6675
  })) {
6686
6676
  if (item.event) {
@@ -8603,21 +8593,19 @@ export function registerSessionCommand(program) {
8603
8593
  return;
8604
8594
  }
8605
8595
 
8606
- let storedSession = null;
8607
- try {
8608
- storedSession = await readStoredSession();
8609
- } catch {
8610
- storedSession = null;
8611
- }
8596
+ const storedSession = await resolveActiveAuthSession({
8597
+ cwd: process.cwd(),
8598
+ env: process.env,
8599
+ autoRotate: false,
8600
+ }).catch(() => null);
8612
8601
 
8613
- const fetchCredentials =
8614
- storedSession && storedSession.token
8615
- ? () =>
8616
- fetchAidenIdCredentials({
8617
- apiUrl: storedSession.apiUrl,
8618
- token: storedSession.token,
8619
- })
8620
- : null;
8602
+ const fetchCredentials = isAuthenticated(storedSession)
8603
+ ? () =>
8604
+ fetchAidenIdCredentials({
8605
+ apiUrl: storedSession.apiUrl,
8606
+ auth: storedSession,
8607
+ })
8608
+ : null;
8621
8609
  const credentials = await resolveAidenIdCredentials({
8622
8610
  apiKey: options.apiKey,
8623
8611
  orgId: options.orgId,
@@ -10,7 +10,7 @@ import {
10
10
  resolveProvider,
11
11
  } from "../ai/client.js";
12
12
  import { recordCliLlmSessionUsage, usageNumber } from "../billing/llm-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 { estimateModelCost } from "../cost/tracker.js";
@@ -265,7 +265,7 @@ async function maybeEnhanceSpecWithAi({
265
265
  configModel: config.resolved.defaultModelId,
266
266
  });
267
267
  const explicitApiKey = String(options.apiKey || "").trim();
268
- const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, config.resolved);
268
+ const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, userConfigValues(config));
269
269
 
270
270
  const prompt = buildAiSpecPrompt({
271
271
  baseSpecMarkdown,
@@ -13,6 +13,7 @@ import {
13
13
  } from "../auth/service.js";
14
14
  import { resolveOutputRoot } from "../config/service.js";
15
15
  import { authLoginHint } from "../ui/command-hints.js";
16
+ import { isAuthenticated } from "../auth/credential-destinations.js";
16
17
 
17
18
  const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "cancelled"]);
18
19
 
@@ -369,7 +370,7 @@ export function registerWatchCommand(program) {
369
370
  throw new Error(formatApiError(error));
370
371
  }
371
372
 
372
- if (!session || !session.token) {
373
+ if (!isAuthenticated(session)) {
373
374
  throw new Error(`No active auth token found. Run \`${authLoginHint()}\` first.`);
374
375
  }
375
376
 
@@ -395,7 +396,7 @@ export function registerWatchCommand(program) {
395
396
  while (true) {
396
397
  const response = await listRuntimeRunEvents({
397
398
  apiUrl: session.apiUrl,
398
- authToken: session.token,
399
+ credential: session.credential,
399
400
  runId,
400
401
  afterEventId,
401
402
  });
@@ -428,7 +429,7 @@ export function registerWatchCommand(program) {
428
429
 
429
430
  const statusResponse = await getRuntimeRunStatus({
430
431
  apiUrl: session.apiUrl,
431
- authToken: session.token,
432
+ credential: session.credential,
432
433
  runId,
433
434
  });
434
435
  latestStatus = String(statusResponse?.status || "unknown").trim().toLowerCase();
@@ -67,12 +67,49 @@ function normalizeLayerScope(scope, { allowResolved = true } = {}) {
67
67
  return normalized;
68
68
  }
69
69
 
70
+ // Values a workspace .sentinelayer.yml may not set for the user: where the user's token goes, the
71
+ // token itself, provider keys, and where alerts are sent. They come from the environment (where a
72
+ // variable is named) or the global config only.
73
+ const USER_ONLY_KEYS = Object.freeze({
74
+ apiUrl: "SENTINELAYER_API_URL",
75
+ sentinelayerToken: "SENTINELAYER_TOKEN",
76
+ openaiApiKey: "OPENAI_API_KEY",
77
+ anthropicApiKey: "ANTHROPIC_API_KEY",
78
+ googleApiKey: "GOOGLE_API_KEY",
79
+ "alerts.channels": "",
80
+ });
81
+ const noticedProjectFiles = new Set();
82
+
83
+ function workspaceSets(projectConfig, key) {
84
+ const [head, field] = key.split(".");
85
+ const value = projectConfig?.[head];
86
+ return field ? value?.[field] !== undefined : value !== undefined;
87
+ }
88
+
89
+ // One notice per workspace config that sets any of them, so an ignored value is never silent.
90
+ function noticeIgnoredWorkspaceValues(projectPath, projectConfig) {
91
+ const keys = Object.keys(USER_ONLY_KEYS).filter((key) => workspaceSets(projectConfig, key));
92
+ if (keys.length === 0 || noticedProjectFiles.has(projectPath)) return;
93
+ noticedProjectFiles.add(projectPath);
94
+ const envs = keys.map((key) => USER_ONLY_KEYS[key]).filter(Boolean);
95
+ const instead = envs.length > 0 ? `set ${envs.join(", ")} or the global config` : "set them in the global config";
96
+ process.stderr.write(
97
+ `NOTICE: workspace .sentinelayer.yml ${keys.join(", ")} ignored; ${instead} (~/.sentinelayer/config.yml).\n`,
98
+ );
99
+ }
100
+
101
+ /** The user's own config values (global, then environment), never the workspace's. */
102
+ export function userConfigValues(config) {
103
+ return { ...(config?.layers?.global || {}), ...(config?.layers?.env || {}) };
104
+ }
105
+
70
106
  export async function loadConfig({ cwd = process.cwd(), env = process.env, homeDir } = {}) {
71
107
  const paths = getConfigPaths({ cwd, homeDir });
72
108
  const [globalConfig, projectConfig] = await Promise.all([
73
109
  readConfigFile(paths.global),
74
110
  readConfigFile(paths.project),
75
111
  ]);
112
+ noticeIgnoredWorkspaceValues(paths.project, projectConfig);
76
113
 
77
114
  const envConfig = buildEnvLayer(env);
78
115
  const resolved = configSchema.parse(mergeLayers(globalConfig, projectConfig, envConfig));
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
- import { extname } from "node:path";
2
+ import os from "node:os";
3
+ import { extname, resolve as resolvePath } from "node:path";
3
4
  import { promisify } from "node:util";
4
5
 
5
6
  import { parse } from "@babel/parser";
@@ -149,7 +150,7 @@ async function resolvePythonExecutable() {
149
150
  pythonExecutablePromise = (async () => {
150
151
  for (const executable of PYTHON_EXECUTABLE_CANDIDATES) {
151
152
  try {
152
- await execFileAsync(executable, ["--version"], { timeout: 5000 });
153
+ await execFileAsync(executable, ["--version"], { timeout: 5000, cwd: os.tmpdir() });
153
154
  return executable;
154
155
  } catch {
155
156
  continue;
@@ -193,9 +194,13 @@ async function parsePythonAstSpecifiers(absolutePath = "") {
193
194
  };
194
195
  }
195
196
  try {
196
- const { stdout } = await execFileAsync(executable, ["-c", PYTHON_AST_SCRIPT, absolutePath], {
197
+ // Isolated (-I: no script or working directory on sys.path, no PYTHON* variables) and run from
198
+ // a neutral directory, so a repository's own ast.py or json.py is never imported; the file to
199
+ // parse is passed as an argument.
200
+ const { stdout } = await execFileAsync(executable, ["-I", "-c", PYTHON_AST_SCRIPT, resolvePath(absolutePath)], {
197
201
  timeout: 10000,
198
202
  maxBuffer: 1024 * 1024,
203
+ cwd: os.tmpdir(),
199
204
  });
200
205
  const parsed = JSON.parse(String(stdout || "{}"));
201
206
  const specifiers = Array.isArray(parsed.specifiers) ? parsed.specifiers : [];
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import fsp from "node:fs/promises";
3
+ import os from "node:os";
3
4
  import path, { extname } from "node:path";
4
5
  import { promisify } from "node:util";
5
6
 
@@ -370,7 +371,7 @@ async function resolvePythonExecutable() {
370
371
  pythonExecutablePromise = (async () => {
371
372
  for (const executable of PYTHON_EXECUTABLE_CANDIDATES) {
372
373
  try {
373
- await execFileAsync(executable, ["--version"], { timeout: 5000 });
374
+ await execFileAsync(executable, ["--version"], { timeout: 5000, cwd: os.tmpdir() });
374
375
  return executable;
375
376
  } catch {
376
377
  continue;
@@ -454,9 +455,13 @@ async function parsePythonCallgraph(absolutePath = "", content = "", normalizedL
454
455
  };
455
456
  }
456
457
  try {
457
- const { stdout } = await execFileAsync(executable, ["-c", PYTHON_CALLGRAPH_SCRIPT, absolutePath], {
458
+ // Isolated (-I: no script or working directory on sys.path, no PYTHON* variables) and run from
459
+ // a neutral directory, so a repository's own ast.py or json.py is never imported; the file to
460
+ // parse is passed as an argument.
461
+ const { stdout } = await execFileAsync(executable, ["-I", "-c", PYTHON_CALLGRAPH_SCRIPT, path.resolve(absolutePath)], {
458
462
  timeout: 15000,
459
463
  maxBuffer: 1024 * 1024,
464
+ cwd: os.tmpdir(),
460
465
  });
461
466
  const parsed = JSON.parse(String(stdout || "{}"));
462
467
  return {
@@ -226,7 +226,8 @@ export function buildHealthSummary(agentStates) {
226
226
 
227
227
  /**
228
228
  * Send an alert to configured Slack/Telegram webhooks.
229
- * Reads config from env or .sentinelayer.yml.
229
+ * Channels come from the caller or the environment (SENTINELAYER_SLACK_WEBHOOK_URL,
230
+ * SENTINELAYER_TELEGRAM_BOT_TOKEN, SENTINELAYER_TELEGRAM_CHAT_ID), never a workspace file.
230
231
  * Fails silently — alert delivery must never block agent work.
231
232
  *
232
233
  * @param {object} alert - { headline, body, severity } from buildAlertPayload