sentinelayer-cli 0.46.0 → 0.46.2

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 (38) hide show
  1. package/bin/create-sentinelayer.js +2 -2
  2. package/bin/sentinelayer-cli.js +2 -2
  3. package/bin/sl.js +2 -2
  4. package/package.json +1 -1
  5. package/src/agents/agent-id-path.js +57 -0
  6. package/src/agents/jules/tools/runtime-audit.js +2 -1
  7. package/src/ai/aidenid.js +11 -10
  8. package/src/ai/client.js +2 -1
  9. package/src/audit/orchestrator.js +7 -1
  10. package/src/audit/registry.js +9 -1
  11. package/src/auth/credential-destinations.js +57 -13
  12. package/src/auth/http.js +4 -0
  13. package/src/auth/service.js +4 -3
  14. package/src/board/evidence-resolvers.js +15 -3
  15. package/src/cli-bootstrap.js +20 -0
  16. package/src/cli.js +3 -1
  17. package/src/commands/ai/identity-lifecycle.js +4 -3
  18. package/src/commands/chat.js +4 -1
  19. package/src/commands/session.js +51 -17
  20. package/src/legacy-cli.js +4 -11
  21. package/src/mcp/session-stdio-server.js +13 -9
  22. package/src/net/gh-api-path.js +68 -0
  23. package/src/net/url-path.js +41 -0
  24. package/src/review/dd-report-email-client.js +2 -3
  25. package/src/scan/gh-secrets.js +4 -1
  26. package/src/session/admission-access.js +17 -8
  27. package/src/session/admission.js +12 -5
  28. package/src/session/agent-registry.js +4 -5
  29. package/src/session/checkpoints.js +4 -3
  30. package/src/session/file-locks.js +8 -6
  31. package/src/session/invitations.js +2 -1
  32. package/src/session/listener-process.js +118 -32
  33. package/src/session/live-source.js +2 -3
  34. package/src/session/paths.js +51 -2
  35. package/src/session/store.js +3 -3
  36. package/src/session/sync.js +34 -26
  37. package/src/session/tickets.js +13 -6
  38. package/src/session/title-sync.js +2 -1
@@ -12,6 +12,7 @@ import {
12
12
  import { checkedTransport, credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
13
13
  import { resolveActiveAuthSession } from "../auth/service.js";
14
14
  import { resolveSessionPaths } from "./paths.js";
15
+ import { urlPathSegment } from "../net/url-path.js";
15
16
 
16
17
  const FILE_LEASE_CAPABILITY_SCHEMA_VERSION = "1.0.0";
17
18
  const DEFAULT_FILE_LOCK_TTL_SECONDS = 300;
@@ -490,11 +491,12 @@ async function resolveLeaseApi({
490
491
  }
491
492
 
492
493
  function leaseCollectionUrl(apiUrl, sessionId) {
493
- return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/file-leases`;
494
+ return `${apiUrl}/api/v1/sessions/${urlPathSegment(sessionId, { label: "sessionId" })}/file-leases`;
494
495
  }
495
496
 
496
- function leaseMemberUrl(apiUrl, sessionId, leaseId, action) {
497
- return `${leaseCollectionUrl(apiUrl, sessionId)}/${encodeURIComponent(leaseId)}/${action}`;
497
+ /** One lease's URL: the raw lease id is encoded here; `route` is a fixed route ("/renew"). */
498
+ function leaseMemberUrl(apiUrl, sessionId, leaseId, route) {
499
+ return `${leaseCollectionUrl(apiUrl, sessionId)}/${urlPathSegment(leaseId, { label: "leaseId" })}${route}`;
498
500
  }
499
501
 
500
502
  async function listRemoteLeases(
@@ -794,7 +796,7 @@ export async function lockFile(
794
796
  let compensated = false;
795
797
  try {
796
798
  const release = await checkedTransport(requestMutation)(
797
- leaseMemberUrl(apiUrl, normalizedSessionId, lease.leaseId, "release"),
799
+ leaseMemberUrl(apiUrl, normalizedSessionId, lease.leaseId, "/release"),
798
800
  {
799
801
  method: "POST",
800
802
  operationName: "session-file-lease-acquire-compensation",
@@ -850,7 +852,7 @@ async function releaseCapabilityClaim(
850
852
  let response;
851
853
  try {
852
854
  response = await checkedTransport(requestMutation)(
853
- leaseMemberUrl(apiUrl, sessionId, claim.leaseId, "release"),
855
+ leaseMemberUrl(apiUrl, sessionId, claim.leaseId, "/release"),
854
856
  {
855
857
  method: "POST",
856
858
  operationName: "session-file-lease-release",
@@ -1010,7 +1012,7 @@ export async function renewFileLease(
1010
1012
 
1011
1013
  const { apiUrl, credential } = await resolveLeaseApi({ targetPath, resolveAuthSession });
1012
1014
  const response = await checkedTransport(requestMutation)(
1013
- leaseMemberUrl(apiUrl, normalizedSessionId, claim.leaseId, "renew"),
1015
+ leaseMemberUrl(apiUrl, normalizedSessionId, claim.leaseId, "/renew"),
1014
1016
  {
1015
1017
  method: "POST",
1016
1018
  operationName: "session-file-lease-renew",
@@ -8,6 +8,7 @@ import { requestJsonMutation } from "../auth/http.js";
8
8
  import { resolveActiveAuthSession } from "../auth/service.js";
9
9
  import { renderCoordinationBulletList } from "./coordination-guidance.js";
10
10
  import { resolveSessionPaths } from "./paths.js";
11
+ import { urlPathSegment } from "../net/url-path.js";
11
12
 
12
13
  export const SESSION_INVITATION_ACCEPT_ROUTE_ID =
13
14
  "POST /api/v1/sessions/{session_id}/invitations/accept";
@@ -128,7 +129,7 @@ export async function acceptSessionInvitation(
128
129
  if (normalizedAgentId) body.agentId = normalizedAgentId;
129
130
 
130
131
  const result = await checkedTransport(requestMutation)(
131
- `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/invitations/accept`,
132
+ `${apiUrl}/api/v1/sessions/${urlPathSegment(normalizedSessionId, { label: "sessionId" })}/invitations/accept`,
132
133
  {
133
134
  method: "POST",
134
135
  operationName: "session.invitation_accept",
@@ -7,12 +7,18 @@ import { setTimeout as sleep } from "node:timers/promises";
7
7
  import { promisify } from "node:util";
8
8
 
9
9
  import { isProcessAlive } from "./daemon-spawn.js";
10
- import { resolveSessionPaths } from "./paths.js";
10
+ import { normalizeSessionId, resolveSessionChildDir, resolveSessionPaths } from "./paths.js";
11
11
 
12
12
  const LISTENER_DIR_NAME = "listeners";
13
13
  const GLOBAL_LISTENER_DIR_NAME = "session-listeners";
14
14
  const execFileAsync = promisify(execFile);
15
15
 
16
+ // Time allowed for one scan of the process table. A busy machine can need longer than the
17
+ // first limit, so a caller that must not guess (the one-listener check in `session listen`)
18
+ // retries once with the longer one before it reports the check as incomplete.
19
+ export const LISTENER_PROCESS_SCAN_TIMEOUT_MS = 3_000;
20
+ export const LISTENER_PROCESS_SCAN_RETRY_TIMEOUT_MS = 10_000;
21
+
16
22
  function normalizeString(value) {
17
23
  return String(value || "").trim();
18
24
  }
@@ -88,7 +94,7 @@ function normalizeProcessRows(rows = []) {
88
94
  .filter((row) => Number.isInteger(row.pid) && row.pid > 0 && row.commandLine);
89
95
  }
90
96
 
91
- async function listWindowsProcesses() {
97
+ async function listWindowsProcesses({ timeoutMs }) {
92
98
  const command = [
93
99
  "$ErrorActionPreference = 'Stop';",
94
100
  "$rows = Get-CimInstance Win32_Process |",
@@ -99,7 +105,7 @@ async function listWindowsProcesses() {
99
105
  const { stdout } = await execFileAsync(
100
106
  "powershell.exe",
101
107
  ["-NoProfile", "-NonInteractive", "-Command", command],
102
- { windowsHide: true, timeout: 3000, maxBuffer: 10 * 1024 * 1024 },
108
+ { windowsHide: true, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
103
109
  );
104
110
  const text = normalizeString(stdout);
105
111
  if (!text) return [];
@@ -107,9 +113,9 @@ async function listWindowsProcesses() {
107
113
  return normalizeProcessRows(Array.isArray(parsed) ? parsed : [parsed]);
108
114
  }
109
115
 
110
- async function listPosixProcesses() {
116
+ async function listPosixProcesses({ timeoutMs }) {
111
117
  const { stdout } = await execFileAsync("ps", ["-eo", "pid=,command="], {
112
- timeout: 3000,
118
+ timeout: timeoutMs,
113
119
  maxBuffer: 10 * 1024 * 1024,
114
120
  });
115
121
  return String(stdout || "")
@@ -121,30 +127,73 @@ async function listPosixProcesses() {
121
127
  .filter(Boolean);
122
128
  }
123
129
 
124
- async function listProcessTable() {
130
+ // Resolves to the process rows, or rejects when the table cannot be read (a timeout, a
131
+ // missing tool, unreadable output). It never turns a failed read into an empty table.
132
+ async function listProcessTable({ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS } = {}) {
133
+ return process.platform === "win32"
134
+ ? listWindowsProcesses({ timeoutMs })
135
+ : listPosixProcesses({ timeoutMs });
136
+ }
137
+
138
+ // The probes `session listen` uses; replaced only through setListenerProcessProbesForTests.
139
+ let activeListProcessTable = listProcessTable;
140
+ let activeReadProcessCommandLine = readProcessCommandLine;
141
+
142
+ /**
143
+ * Test seam: replace the process-table scan and the per-pid command-line read that the
144
+ * listener commands use. Call with no argument to restore the real probes.
145
+ */
146
+ export function setListenerProcessProbesForTests({ listProcesses = null, readCommandLine = null } = {}) {
147
+ activeListProcessTable = listProcesses || listProcessTable;
148
+ activeReadProcessCommandLine = readCommandLine || readProcessCommandLine;
149
+ }
150
+
151
+ function processScanFailureReason(error) {
152
+ const code = normalizeString(error?.code);
153
+ // execFile kills the scan at its timeout and reports it as killed with no code.
154
+ if (code === "ETIMEDOUT" || (!code && error?.killed)) return "process_scan_timeout";
155
+ if (error instanceof SyntaxError) return "process_scan_unreadable";
156
+ return code ? `process_scan_failed:${code}` : "process_scan_failed";
157
+ }
158
+
159
+ // One scan of the process table: { ok: true, rows } or { ok: false, reason }.
160
+ async function scanProcessTable(listProcesses, timeoutMs) {
161
+ let rows;
125
162
  try {
126
- return process.platform === "win32"
127
- ? await listWindowsProcesses()
128
- : await listPosixProcesses();
129
- } catch {
130
- return [];
163
+ rows = await listProcesses({ timeoutMs });
164
+ } catch (error) {
165
+ return { ok: false, reason: processScanFailureReason(error) };
166
+ }
167
+ if (!Array.isArray(rows)) {
168
+ return { ok: false, reason: "process_scan_unreadable" };
131
169
  }
170
+ return { ok: true, rows: normalizeProcessRows(rows) };
132
171
  }
133
172
 
173
+ /**
174
+ * Listener processes for this session and agent: { ok: true, matches } when the process
175
+ * table was read, { ok: false, reason } when it could not be. A failed scan has no
176
+ * `matches`, so it can never be read as "no listener".
177
+ */
134
178
  export async function listMatchingListenerProcesses(
135
179
  sessionId,
136
180
  agentId,
137
181
  {
138
182
  excludePid = process.pid,
139
- _listProcesses = listProcessTable,
183
+ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
184
+ _listProcesses = activeListProcessTable,
140
185
  } = {},
141
186
  ) {
142
- const rows = normalizeProcessRows(await _listProcesses());
187
+ const scan = await scanProcessTable(_listProcesses, timeoutMs);
188
+ if (!scan.ok) return scan;
143
189
  const excluded = Number(excludePid);
144
- return rows.filter((row) =>
145
- row.pid !== excluded &&
146
- listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
147
- );
190
+ return {
191
+ ok: true,
192
+ matches: scan.rows.filter((row) =>
193
+ row.pid !== excluded &&
194
+ listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
195
+ ),
196
+ };
148
197
  }
149
198
 
150
199
  export async function summarizeLocalListenerProcesses(
@@ -152,16 +201,20 @@ export async function summarizeLocalListenerProcesses(
152
201
  agentIds = [],
153
202
  {
154
203
  excludePid = process.pid,
155
- _listProcesses = listProcessTable,
204
+ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
205
+ _listProcesses = activeListProcessTable,
156
206
  } = {},
157
207
  ) {
158
208
  const normalizedSessionId = normalizeString(sessionId);
159
209
  const uniqueAgentIds = [...new Set((Array.isArray(agentIds) ? agentIds : [])
160
210
  .map((agentId) => normalizeString(agentId))
161
211
  .filter(Boolean))];
162
- const rows = normalizeProcessRows(await _listProcesses());
212
+ const scan = await scanProcessTable(_listProcesses, timeoutMs);
213
+ if (!scan.ok) {
214
+ return { ok: false, reason: scan.reason, sessionId: normalizedSessionId };
215
+ }
163
216
  const excluded = Number(excludePid);
164
- const candidates = rows.filter((row) => row.pid !== excluded);
217
+ const candidates = scan.rows.filter((row) => row.pid !== excluded);
165
218
  const agents = uniqueAgentIds.map((agentId) => {
166
219
  const matches = candidates
167
220
  .filter((row) => listenerCommandLineMatches(row.commandLine, {
@@ -178,6 +231,7 @@ export async function summarizeLocalListenerProcesses(
178
231
  };
179
232
  });
180
233
  return {
234
+ ok: true,
181
235
  sessionId: normalizedSessionId,
182
236
  processCount: agents.reduce((sum, agent) => sum + agent.processCount, 0),
183
237
  duplicateProcessCount: agents.reduce((sum, agent) => sum + agent.duplicateProcessCount, 0),
@@ -244,13 +298,12 @@ export function resolveGlobalListenerPidPath(
244
298
  agentId,
245
299
  { homeDir = os.homedir() } = {},
246
300
  ) {
247
- return path.join(
248
- path.resolve(String(homeDir || os.homedir())),
249
- ".sentinelayer",
250
- GLOBAL_LISTENER_DIR_NAME,
251
- normalizeListenerProcessKey(sessionId),
252
- `${normalizeListenerProcessKey(agentId)}.json`,
301
+ // The id is held to the session-id rule as given; its directory is the lower-cased key.
302
+ const sessionDir = resolveSessionChildDir(
303
+ path.join(path.resolve(String(homeDir || os.homedir())), ".sentinelayer", GLOBAL_LISTENER_DIR_NAME),
304
+ normalizeListenerProcessKey(normalizeSessionId(sessionId)),
253
305
  );
306
+ return path.join(sessionDir, `${normalizeListenerProcessKey(agentId)}.json`);
254
307
  }
255
308
 
256
309
  async function readPidRecordAtPath(pidPath) {
@@ -368,12 +421,13 @@ async function listenerStatusFromRecord(
368
421
  sessionId,
369
422
  agentId,
370
423
  recordScope,
371
- _readProcessCommandLine = readProcessCommandLine,
424
+ _readProcessCommandLine = activeReadProcessCommandLine,
372
425
  } = {},
373
426
  ) {
374
427
  const alive = isProcessAlive(record.pid);
375
428
  if (!alive) {
376
429
  return {
430
+ state: "not_running",
377
431
  running: false,
378
432
  pid: null,
379
433
  stale: true,
@@ -385,6 +439,7 @@ async function listenerStatusFromRecord(
385
439
  const commandLine = await _readProcessCommandLine(record.pid);
386
440
  if (!listenerCommandLineMatches(commandLine, { sessionId, agentId })) {
387
441
  return {
442
+ state: "not_running",
388
443
  running: false,
389
444
  pid: null,
390
445
  stale: true,
@@ -397,6 +452,7 @@ async function listenerStatusFromRecord(
397
452
  };
398
453
  }
399
454
  return {
455
+ state: "running",
400
456
  running: true,
401
457
  pid: Number(record.pid),
402
458
  stale: false,
@@ -407,14 +463,21 @@ async function listenerStatusFromRecord(
407
463
  };
408
464
  }
409
465
 
466
+ /**
467
+ * Whether a listener for this session and agent is running on this machine. `state` is
468
+ * "running", "not_running" or "unknown". A live pid record whose command line matches is
469
+ * "running" without a scan. Otherwise the process table decides, and when it cannot be read
470
+ * the answer is "unknown" (`running: null`, with `reason`), never "not_running".
471
+ */
410
472
  export async function getListenerProcessStatus(
411
473
  sessionId,
412
474
  agentId,
413
475
  {
414
476
  targetPath = process.cwd(),
415
477
  homeDir = os.homedir(),
416
- _readProcessCommandLine = readProcessCommandLine,
417
- _listProcesses = listProcessTable,
478
+ scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
479
+ _readProcessCommandLine = activeReadProcessCommandLine,
480
+ _listProcesses = activeListProcessTable,
418
481
  } = {},
419
482
  ) {
420
483
  const records = [
@@ -441,11 +504,24 @@ export async function getListenerProcessStatus(
441
504
  }
442
505
  staleStatuses.push(status);
443
506
  }
444
- const matchingProcesses = await listMatchingListenerProcesses(sessionId, agentId, {
507
+ const scan = await listMatchingListenerProcesses(sessionId, agentId, {
508
+ timeoutMs: scanTimeoutMs,
445
509
  _listProcesses,
446
510
  });
511
+ if (!scan.ok) {
512
+ return {
513
+ state: "unknown",
514
+ running: null,
515
+ pid: null,
516
+ reason: scan.reason,
517
+ record: staleStatuses[0]?.record || null,
518
+ listenerKey: normalizeListenerProcessKey(agentId),
519
+ };
520
+ }
521
+ const matchingProcesses = scan.matches;
447
522
  if (matchingProcesses.length > 0) {
448
523
  return {
524
+ state: "running",
449
525
  running: true,
450
526
  pid: matchingProcesses[0].pid,
451
527
  stale: false,
@@ -459,6 +535,7 @@ export async function getListenerProcessStatus(
459
535
  }
460
536
  if (staleStatuses.length === 0) {
461
537
  return {
538
+ state: "not_running",
462
539
  running: false,
463
540
  pid: null,
464
541
  stale: false,
@@ -469,6 +546,10 @@ export async function getListenerProcessStatus(
469
546
  return staleStatuses[0];
470
547
  }
471
548
 
549
+ /**
550
+ * Stops every listener process the scan finds for this session and agent. When the process
551
+ * table cannot be read it stops nothing and returns { ok: false, reason }.
552
+ */
472
553
  export async function stopMatchingListenerProcesses(
473
554
  sessionId,
474
555
  agentId,
@@ -476,13 +557,17 @@ export async function stopMatchingListenerProcesses(
476
557
  excludePid = process.pid,
477
558
  timeoutMs = 3000,
478
559
  pollIntervalMs = 100,
479
- _listProcesses = listProcessTable,
560
+ scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
561
+ _listProcesses = activeListProcessTable,
480
562
  } = {},
481
563
  ) {
482
- const matches = await listMatchingListenerProcesses(sessionId, agentId, {
564
+ const scan = await listMatchingListenerProcesses(sessionId, agentId, {
483
565
  excludePid,
566
+ timeoutMs: scanTimeoutMs,
484
567
  _listProcesses,
485
568
  });
569
+ if (!scan.ok) return scan;
570
+ const matches = scan.matches;
486
571
  const results = [];
487
572
  for (const match of matches) {
488
573
  const result = await requestListenerProcessStop(match.pid, {
@@ -497,6 +582,7 @@ export async function stopMatchingListenerProcesses(
497
582
  }
498
583
  const failed = results.filter((result) => !result.stopped);
499
584
  return {
585
+ ok: true,
500
586
  matches,
501
587
  results,
502
588
  stoppedCount: results.filter((result) => result.stopped).length,
@@ -27,6 +27,7 @@ import {
27
27
  sessionEventIdentityKeys,
28
28
  sessionEventUpgradesExisting,
29
29
  } from "./event-identity.js";
30
+ import { urlPathSegment } from "../net/url-path.js";
30
31
 
31
32
  const DEFAULT_RECONNECT_BACKOFF_MS = 2_000;
32
33
  const MAX_RECONNECT_BACKOFF_MS = 30_000;
@@ -162,9 +163,7 @@ export async function* watchRemoteStream({
162
163
  reconnectBackoffMs = DEFAULT_RECONNECT_BACKOFF_MS,
163
164
  } = {}) {
164
165
  if (!apiBaseUrl || !sessionId || !credential) return;
165
- const endpoint = `${apiBaseUrl.replace(/\/+$/, "")}/api/v1/sessions/${encodeURIComponent(
166
- sessionId,
167
- )}/stream`;
166
+ const endpoint = `${apiBaseUrl.replace(/\/+$/, "")}/api/v1/sessions/${urlPathSegment(sessionId, { label: "sessionId" })}/stream`;
168
167
  let backoff = reconnectBackoffMs;
169
168
  // Highest server sequence actually DELIVERED to the consumer. SSE is a live push with no
170
169
  // backlog: reconnecting without it silently drops everything published while we were away,
@@ -1,20 +1,69 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
3
 
4
- function normalizeSessionId(sessionId) {
4
+ // A session id names exactly one directory directly under <target>/.sentinelayer/sessions/.
5
+ // Every id in use fits: randomUUID() ids, run-derived billing ids such as
6
+ // omargate-<ms>-<hex>-<persona>-swarm-<n>-ai, chat ids such as 20261010-123456-k3j9x2, and API ids.
7
+ // No trailing '.': Windows drops it, so "abc." and "abc" would name the same directory.
8
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9_-])?$/;
9
+ // Windows device names, with or without an extension, are refused on every platform.
10
+ const RESERVED_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
11
+
12
+ export const SESSION_ID_RULE =
13
+ "sessionId must be 1-128 characters: letters, digits, '.', '_' or '-', starting with a letter or digit and not ending with '.'.";
14
+ export const SESSION_ID_RESERVED_RULE = "sessionId must not be a reserved device name.";
15
+ export const SESSION_ID_SEGMENT_RULE = "sessionId must name one directory directly under the sessions root.";
16
+
17
+ function sessionIdProblem(id) {
18
+ if (!SESSION_ID_PATTERN.test(id)) return SESSION_ID_RULE;
19
+ if (RESERVED_DEVICE_NAME.test(id)) return SESSION_ID_RESERVED_RULE;
20
+ return "";
21
+ }
22
+
23
+ /** True when `value` is a session id as stored on disk (no trimming). */
24
+ export function isValidSessionId(value) {
25
+ return typeof value === "string" && !sessionIdProblem(value);
26
+ }
27
+
28
+ /** The trimmed session id, or an error when it is empty, outside the allowlist or a device name. */
29
+ export function normalizeSessionId(sessionId) {
5
30
  const normalized = String(sessionId || "").trim();
6
31
  if (!normalized) {
7
32
  throw new Error("sessionId is required.");
8
33
  }
34
+ const problem = sessionIdProblem(normalized);
35
+ if (problem) {
36
+ throw new Error(problem);
37
+ }
9
38
  return normalized;
10
39
  }
11
40
 
41
+ /**
42
+ * `<root>/<sessionId>`, refused unless the result is exactly one directory directly under `root`
43
+ * and the id passes normalizeSessionId. The one-directory check runs first, on the raw id, so it
44
+ * holds by itself whatever the allowlist admits.
45
+ */
46
+ export function resolveSessionChildDir(root, sessionId) {
47
+ const resolvedRoot = path.resolve(String(root || "."));
48
+ const raw = String(sessionId || "").trim();
49
+ if (!raw) {
50
+ throw new Error("sessionId is required.");
51
+ }
52
+ const dir = path.join(resolvedRoot, raw);
53
+ const relative = path.relative(resolvedRoot, dir);
54
+ if (!relative || relative === ".." || /[\\/]/.test(relative) || path.isAbsolute(relative)) {
55
+ throw new Error(SESSION_ID_SEGMENT_RULE);
56
+ }
57
+ normalizeSessionId(raw);
58
+ return dir;
59
+ }
60
+
12
61
  export function resolveSessionsRoot({ targetPath = process.cwd() } = {}) {
13
62
  return path.join(path.resolve(String(targetPath || ".")), ".sentinelayer", "sessions");
14
63
  }
15
64
 
16
65
  export function resolveSessionDir(sessionId, { targetPath = process.cwd() } = {}) {
17
- return path.join(resolveSessionsRoot({ targetPath }), normalizeSessionId(sessionId));
66
+ return resolveSessionChildDir(resolveSessionsRoot({ targetPath }), sessionId);
18
67
  }
19
68
 
20
69
  export function resolveSessionPaths(sessionId, { targetPath = process.cwd() } = {}) {
@@ -6,7 +6,7 @@ import process from "node:process";
6
6
  import { buildArtifactLineageIndex, verifyArtifactChain } from "../daemon/artifact-lineage.js";
7
7
  import { readSessionCodebaseContext } from "./codebase-context.js";
8
8
  import { computeSessionAnalytics } from "./analytics.js";
9
- import { resolveSessionPaths, resolveSessionsRoot } from "./paths.js";
9
+ import { isValidSessionId, resolveSessionPaths, resolveSessionsRoot } from "./paths.js";
10
10
  import { appendToStream, withSessionStreamLock } from "./stream.js";
11
11
 
12
12
  const SESSION_SCHEMA_VERSION = "1.0.0";
@@ -637,7 +637,7 @@ export async function listActiveSessions({ targetPath = process.cwd() } = {}) {
637
637
 
638
638
  const sessions = [];
639
639
  for (const entry of entries) {
640
- if (!entry.isDirectory()) continue;
640
+ if (!entry.isDirectory() || !isValidSessionId(entry.name)) continue;
641
641
  const loaded = await loadMetadata(entry.name, { targetPath: resolvedTargetPath });
642
642
  if (!loaded) continue;
643
643
  if (isExpired(loaded.metadata)) continue;
@@ -677,7 +677,7 @@ export async function listAllSessions({ targetPath = process.cwd() } = {}) {
677
677
 
678
678
  const sessions = [];
679
679
  for (const entry of entries) {
680
- if (!entry.isDirectory()) continue;
680
+ if (!entry.isDirectory() || !isValidSessionId(entry.name)) continue;
681
681
  const loaded = await loadMetadata(entry.name, { targetPath: resolvedTargetPath });
682
682
  if (!loaded) continue;
683
683