sentinelayer-cli 0.46.5 → 0.48.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 (133) hide show
  1. package/README.md +47 -3
  2. package/package.json +12 -7
  3. package/skills/omar-gate-setup/SKILL.md +103 -0
  4. package/skills/senti-agent-identity/SKILL.md +80 -0
  5. package/skills/senti-judge/SKILL.md +139 -0
  6. package/skills/senti-room-notifications/SKILL.md +73 -0
  7. package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
  8. package/skills/senti-room-notifications/package.json +1 -0
  9. package/skills/senti-room-notifications/test-classifier.js +109 -0
  10. package/skills/senti-room-notifications/test-receipt.js +54 -0
  11. package/skills/sentinelayer-cli/SKILL.md +222 -0
  12. package/src/agents/devtestbot/runner.js +22 -11
  13. package/src/agents/devtestbot/tool.js +24 -11
  14. package/src/agents/jules/fix-cycle.js +7 -1
  15. package/src/agents/jules/tools/runtime-audit.js +17 -12
  16. package/src/agents/shared-tools/dispatch-core.js +10 -5
  17. package/src/ai/aidenid.js +4 -2
  18. package/src/ai/domain-target-store.js +11 -5
  19. package/src/ai/identity-store.js +11 -5
  20. package/src/ai/site-store.js +8 -4
  21. package/src/audit/orchestrator.js +21 -14
  22. package/src/audit/package.js +6 -4
  23. package/src/audit/persona-loop.js +7 -0
  24. package/src/audit/replay.js +4 -2
  25. package/src/auth/gate.js +32 -1
  26. package/src/auth/session-store.js +37 -0
  27. package/src/board/evidence-resolvers.js +3 -4
  28. package/src/cli.js +23 -3
  29. package/src/commands/ai/identity-lifecycle.js +30 -26
  30. package/src/commands/ai/provision-governance.js +32 -24
  31. package/src/commands/ai/shared.js +6 -4
  32. package/src/commands/audit.js +14 -12
  33. package/src/commands/board.js +2 -2
  34. package/src/commands/chat.js +8 -6
  35. package/src/commands/judge.js +125 -0
  36. package/src/commands/legacy-args.js +1 -0
  37. package/src/commands/mcp.js +45 -52
  38. package/src/commands/omargate.js +2 -1
  39. package/src/commands/plugin.js +11 -3
  40. package/src/commands/review.js +9 -4
  41. package/src/commands/scan.js +7 -7
  42. package/src/commands/session.js +516 -30
  43. package/src/commands/skills.js +45 -0
  44. package/src/commands/watch.js +9 -7
  45. package/src/config/output-filesystem.js +156 -0
  46. package/src/config/schema.js +4 -2
  47. package/src/config/service.js +40 -4
  48. package/src/cost/history.js +15 -9
  49. package/src/daemon/artifact-lineage.js +15 -12
  50. package/src/daemon/assignment-ledger.js +31 -25
  51. package/src/daemon/ast-drift.js +7 -3
  52. package/src/daemon/budget-governor.js +26 -20
  53. package/src/daemon/error-worker.js +28 -22
  54. package/src/daemon/fix-cycle.js +5 -4
  55. package/src/daemon/hybrid-mapper.js +19 -16
  56. package/src/daemon/ingest-refresh.js +4 -0
  57. package/src/daemon/jira-lifecycle.js +21 -17
  58. package/src/daemon/operator-control.js +18 -15
  59. package/src/daemon/reliability-lane.js +18 -15
  60. package/src/daemon/scope-engine.js +12 -8
  61. package/src/daemon/watchdog.js +15 -12
  62. package/src/engram/build-state.js +24 -3
  63. package/src/engram/capability-index.js +367 -0
  64. package/src/engram/compile-graph.js +1063 -0
  65. package/src/engram/contracts/action-receipt.js +82 -0
  66. package/src/engram/contracts/capability-index.js +259 -0
  67. package/src/engram/contracts/capability.js +377 -0
  68. package/src/engram/contracts/content-hash.js +8 -0
  69. package/src/engram/contracts/evidence.js +258 -0
  70. package/src/engram/contracts/graph.js +594 -0
  71. package/src/engram/contracts/identity.js +8 -0
  72. package/src/engram/contracts/index.js +149 -0
  73. package/src/engram/contracts/media-asset.js +85 -0
  74. package/src/engram/contracts/money.js +249 -0
  75. package/src/engram/contracts/source-snapshot.js +57 -0
  76. package/src/engram/contracts/task-context-pack.js +114 -0
  77. package/src/engram/contracts/temporal-segment.js +72 -0
  78. package/src/engram/contracts/versions.js +96 -0
  79. package/src/engram/extract-html.js +692 -0
  80. package/src/engram/graph-store.js +877 -0
  81. package/src/engram/index.js +98 -14
  82. package/src/engram/ingest-site.js +132 -0
  83. package/src/engram/live-state.js +1180 -0
  84. package/src/engram/namespace.js +34 -1
  85. package/src/engram/retrieve-site.js +753 -0
  86. package/src/engram/safe-reader.js +597 -0
  87. package/src/engram/source-intake.js +251 -0
  88. package/src/engram/store.js +227 -9
  89. package/src/ingest/engine.js +21 -28
  90. package/src/judge/verdict.js +305 -0
  91. package/src/legacy-cli.js +96 -19
  92. package/src/mcp/bridge-inputs.js +25 -4
  93. package/src/mcp/cli-command-tools.js +70 -0
  94. package/src/mcp/registry.js +17 -13
  95. package/src/mcp/session-stdio-server.js +297 -19
  96. package/src/memory/blackboard.js +5 -4
  97. package/src/net/gh-api-path.js +35 -9
  98. package/src/net/test-egress-guard.js +16 -4
  99. package/src/plugin/manifest.js +12 -8
  100. package/src/review/ai-review.js +110 -10
  101. package/src/review/investor-dd-devtestbot.js +17 -6
  102. package/src/review/investor-dd-orchestrator.js +21 -13
  103. package/src/review/local-review.js +26 -16
  104. package/src/review/omargate-cache.js +5 -5
  105. package/src/review/omargate-orchestrator.js +89 -17
  106. package/src/review/replay.js +9 -4
  107. package/src/review/report.js +22 -13
  108. package/src/review/typesafe-severity.js +201 -0
  109. package/src/session/admission-auth.js +34 -7
  110. package/src/session/admission.js +110 -15
  111. package/src/session/attachment-summary.js +77 -0
  112. package/src/session/edit-summary.js +19 -0
  113. package/src/session/invitations.js +65 -1
  114. package/src/session/mentions.js +17 -9
  115. package/src/session/message-edits.js +5 -4
  116. package/src/session/preview.js +10 -2
  117. package/src/session/recap.js +3 -2
  118. package/src/session/room-files.js +519 -0
  119. package/src/session/route-classes.js +22 -8
  120. package/src/session/setup-guides.js +4 -0
  121. package/src/session/standup.js +192 -0
  122. package/src/session/sync.js +229 -10
  123. package/src/session/tickets.js +41 -0
  124. package/src/session/transcript.js +2 -1
  125. package/src/session/wake/resolve-target.js +7 -3
  126. package/src/skills/listener-runtime.cjs +92 -0
  127. package/src/skills/service.js +207 -0
  128. package/src/swarm/factory.js +5 -5
  129. package/src/swarm/pentest.js +9 -9
  130. package/src/swarm/report.js +4 -4
  131. package/src/swarm/runtime.js +15 -12
  132. package/src/swarm/scenario-dsl.js +7 -5
  133. package/src/telemetry/ledger.js +16 -7
@@ -4,11 +4,24 @@ import { setTimeout as sleep } from "node:timers/promises";
4
4
 
5
5
  import { createAgentEvent } from "../events/schema.js";
6
6
  import {
7
+ approvedSelfProvisioningMcpCall,
7
8
  buildCliCommandMcpTools,
8
9
  createCliCommandMcpToolHandlers,
10
+ selfProvisioningMcpInputSchema,
9
11
  } from "./cli-command-tools.js";
10
12
  import { appendToStream, readStream } from "../session/stream.js";
13
+ import { formatAttachmentSummary, withAttachmentSummary } from "../session/attachment-summary.js";
14
+ import { formatEditSummary, withEditSummary } from "../session/edit-summary.js";
11
15
  import { listFileLocks, lockFile, unlockFile } from "../session/file-locks.js";
16
+ import {
17
+ DEFAULT_ROOM_FILES_LIMIT,
18
+ DEFAULT_ROOM_FILES_MAX_PAGES,
19
+ MAX_ROOM_FILES_LIMIT,
20
+ MAX_ROOM_FILES_MAX_PAGES,
21
+ RoomFileError,
22
+ downloadRoomFile,
23
+ listRoomFiles,
24
+ } from "../session/room-files.js";
12
25
  import { resolveSessionDir } from "../session/paths.js";
13
26
  import { eventMatchesAgent } from "../session/listener.js";
14
27
  import { isSessionControlEvent } from "../session/control-events.js";
@@ -18,6 +31,7 @@ import {
18
31
  AdmissionCredentialRefused,
19
32
  assertCanonicalAgentId,
20
33
  canonicalAgentId,
34
+ currentAdmittedAgent,
21
35
  withAgentAdmission,
22
36
  } from "../session/admission-auth.js";
23
37
  import { hasStoredAdmissionCredentials, storedAdmissionAgentsForSession } from "../session/admission.js";
@@ -32,14 +46,21 @@ import {
32
46
  listSessionMessageActions,
33
47
  pollSessionEvents,
34
48
  pollSessionEventsBefore,
49
+ postJudgeVerdictToApi,
35
50
  syncSessionEventToApi,
36
51
  updateSessionReadCursor,
37
52
  } from "../session/sync.js";
53
+ import { extractJudgeVerdictBlock, renderJudgeVerdictMessage, validateJudgeVerdict } from "../judge/verdict.js";
38
54
 
39
55
  export const SESSION_MCP_SERVER_NAME = "sentinelayer-session-mcp";
40
56
  export const SESSION_MCP_PROTOCOL_VERSION = "2025-06-18";
41
57
 
42
58
  const MAX_MESSAGE_CHARS = 16_000;
59
+ // A judge verdict carries a machine-readable block; cutting it would corrupt the verdict, so the
60
+ // judge tool posts whole messages up to this bound (below the server's message limit) or refuses.
61
+ const JUDGE_MESSAGE_MAX_CHARS = 100_000;
62
+ // A 404 with one of these codes is a real "not here"; any other 404 means the route is absent.
63
+ const JUDGE_NOT_FOUND_CODES = new Set(["SESSION_NOT_FOUND", "TARGET_NOT_FOUND"]);
43
64
  const MAX_TOOL_LIMIT = 200;
44
65
  const DEFAULT_TOOL_LIMIT = 50;
45
66
  const SESSION_MCP_CONFIRM_ATTEMPTS = 3;
@@ -172,11 +193,17 @@ function eventAgentId(event = {}) {
172
193
  return normalizeAgentId(event?.agent?.id || event?.agentId || event?.agent_id, "");
173
194
  }
174
195
 
196
+ // A judge verdict is read whole by the coder: the numbered comments and the block are the point.
197
+ function carriesJudgeVerdict(payload) {
198
+ return isPlainObject(payload.judgeVerdict) || extractJudgeVerdictBlock(payload.message).status !== "none";
199
+ }
200
+
175
201
  function safePayload(payload = {}) {
176
202
  if (!isPlainObject(payload)) return {};
177
203
  const next = { ...payload };
178
204
  if (typeof next.message === "string") {
179
- const truncated = truncateText(next.message, MAX_MESSAGE_CHARS);
205
+ const limit = carriesJudgeVerdict(next) ? JUDGE_MESSAGE_MAX_CHARS : MAX_MESSAGE_CHARS;
206
+ const truncated = truncateText(next.message, limit);
180
207
  next.message = truncated.text;
181
208
  if (truncated.truncated) next.truncated = true;
182
209
  }
@@ -200,6 +227,8 @@ function summarizeSessionEvent(event = {}) {
200
227
  })
201
228
  : undefined,
202
229
  payload: safePayload(event.payload),
230
+ attachmentSummary: formatAttachmentSummary(event) || undefined,
231
+ editSummary: formatEditSummary(event) || undefined,
203
232
  eventId: normalizeString(event.eventId),
204
233
  idempotencyToken: normalizeString(event.idempotencyToken),
205
234
  });
@@ -680,8 +709,9 @@ function buildSessionMessageEvent({
680
709
  nowIso = new Date().toISOString(),
681
710
  uuid = randomUUID,
682
711
  extraPayload = {},
712
+ maxChars = MAX_MESSAGE_CHARS,
683
713
  } = {}) {
684
- const messageShape = truncateText(message);
714
+ const messageShape = truncateText(message, maxChars);
685
715
  if (!messageShape.text) {
686
716
  throw new Error("message is required.");
687
717
  }
@@ -927,7 +957,7 @@ export async function isAgentContext({ env = process.env } = {}) {
927
957
  */
928
958
  export async function runInSessionToolContext(name, input = {}, run, { env = process.env } = {}) {
929
959
  const routeClass = sessionRouteClass(`mcp:${name}`);
930
- if (routeClass === "exempt-local") return run(input);
960
+ if (routeClass === "exempt-local" || routeClass === "self-provisioning") return run(input);
931
961
  if (!(await isAgentContext({ env }))) return run(input);
932
962
  if (routeClass === "owner") refuseOutsideAdmission("owner actions are not available to agents");
933
963
  const sessionId = normalizeString(input.sessionId || input.session_id || input.session);
@@ -968,7 +998,11 @@ export function createSessionMcpToolHandlers({
968
998
  lockFileFn = lockFile,
969
999
  unlockFileFn = unlockFile,
970
1000
  listFileLocksFn = listFileLocks,
1001
+ listRoomFilesFn = listRoomFiles,
1002
+ downloadRoomFileFn = downloadRoomFile,
971
1003
  syncSessionEventToApiFn = syncSessionEventToApi,
1004
+ postJudgeVerdictToApiFn = postJudgeVerdictToApi,
1005
+ currentAdmittedAgentFn = currentAdmittedAgent,
972
1006
  appendToStreamFn = appendToStream,
973
1007
  sleepFn = sleep,
974
1008
  uuidFn = randomUUID,
@@ -1070,7 +1104,7 @@ export function createSessionMcpToolHandlers({
1070
1104
  count: Array.isArray(actionResult.actions) ? actionResult.actions.length : 0,
1071
1105
  }
1072
1106
  : null,
1073
- events: includeRaw ? events : events.map((event) => summarizeSessionEvent(event)),
1107
+ events: events.map((event) => includeRaw ? withEditSummary(withAttachmentSummary(event)) : summarizeSessionEvent(event)),
1074
1108
  };
1075
1109
  },
1076
1110
 
@@ -1163,7 +1197,7 @@ export function createSessionMcpToolHandlers({
1163
1197
  count: Array.isArray(actionResult.actions) ? actionResult.actions.length : 0,
1164
1198
  }
1165
1199
  : null,
1166
- events: includeRaw ? events : events.map((event) => summarizeSessionEvent(event)),
1200
+ events: events.map((event) => includeRaw ? withEditSummary(withAttachmentSummary(event)) : summarizeSessionEvent(event)),
1167
1201
  };
1168
1202
  },
1169
1203
 
@@ -1195,6 +1229,105 @@ export function createSessionMcpToolHandlers({
1195
1229
  });
1196
1230
  },
1197
1231
 
1232
+ async judge_verdict(input = {}) {
1233
+ const sessionId = requireSessionId(input, { targetPath });
1234
+ const agentId = requireAgentId(input);
1235
+ const via = normalizeString(input.via || "auto").toLowerCase();
1236
+ if (!["auto", "api", "room"].includes(via)) {
1237
+ throw new Error('via must be "auto", "api" or "room".');
1238
+ }
1239
+ const checked = validateJudgeVerdict(input.verdict);
1240
+ if (!checked.ok) {
1241
+ return { ok: false, reason: "invalid_verdict", errors: checked.errors, sessionId, agentId };
1242
+ }
1243
+ const verdict = checked.verdict;
1244
+ if (normalizeAgentId(verdict.coder, "") === agentId) {
1245
+ return { ok: false, reason: "self_judgement", sessionId, agentId, coder: verdict.coder };
1246
+ }
1247
+ const escalateTo = normalizeRecipients(input.escalateTo || input.escalate_to);
1248
+ // The API route mentions the room owner on escalation; naming other people needs the room message,
1249
+ // which carries their mentions. So an escalation to named handles never goes through the route.
1250
+ const namedEscalation = verdict.escalate && escalateTo.length > 0;
1251
+ if (namedEscalation && via === "api") {
1252
+ return { ok: false, reason: "escalate_to_needs_room_transport", sessionId, agentId, escalateTo };
1253
+ }
1254
+ // The route records whoever the credential is. Only this agent's own admission makes that the agent;
1255
+ // under the person's token the route would record the person. The room message is checked against the
1256
+ // agent's grant instead, so without the admission the verdict goes as a room message.
1257
+ const admitted = currentAdmittedAgentFn();
1258
+ const agentCredential = Boolean(admitted && admitted.sessionId === sessionId &&
1259
+ normalizeAgentId(admitted.agentId, "") === agentId);
1260
+ if (via === "api" && !agentCredential) {
1261
+ return { ok: false, reason: "api_needs_agent_credential", sessionId, agentId };
1262
+ }
1263
+ const message = renderJudgeVerdictMessage(verdict, { escalateTo });
1264
+ if (message.length > JUDGE_MESSAGE_MAX_CHARS) {
1265
+ return { ok: false, reason: "verdict_message_too_long", sessionId, agentId, limit: JUDGE_MESSAGE_MAX_CHARS };
1266
+ }
1267
+ const dryRun = Boolean(input.dryRun || input.dry_run);
1268
+ if (dryRun) {
1269
+ return { ok: true, dryRun: true, sessionId, agentId, via, verdict, message };
1270
+ }
1271
+
1272
+ let api = null;
1273
+ if (via !== "room" && !namedEscalation && agentCredential) {
1274
+ api = await postJudgeVerdictToApiFn(sessionId, verdict, { targetPath });
1275
+ const apiSummary = { status: api?.status || 0, code: api?.code || "", reason: api?.reason || "" };
1276
+ if (api?.ok) {
1277
+ return { ok: true, transport: "api", sessionId, agentId, verdict, api: { ...apiSummary, body: api.body } };
1278
+ }
1279
+ // Fall back to the room post only when the route itself is absent (an older server, or
1280
+ // the judge loop switched off there): a refusal or a lost response is reported as is.
1281
+ const routeAbsent = api?.status === 404 && !JUDGE_NOT_FOUND_CODES.has(api.code);
1282
+ if (via === "api" || !routeAbsent) {
1283
+ return {
1284
+ ok: false,
1285
+ transport: "api",
1286
+ reason: apiSummary.code || apiSummary.reason || "api_failed",
1287
+ sessionId,
1288
+ agentId,
1289
+ api: { ...apiSummary, body: api?.body || null },
1290
+ };
1291
+ }
1292
+ }
1293
+
1294
+ const agent = buildAgentEnvelope(agentId, { ...input, role: normalizeString(input.role) || "reviewer" });
1295
+ const idempotencyKey = normalizeString(input.idempotencyKey || input.idempotency_key) ||
1296
+ `judge-${shortSha256(JSON.stringify([sessionId, agentId, verdict.target.type, verdict.target.ref, verdict.round]))}`;
1297
+ const event = buildSessionMessageEvent({
1298
+ event: "session_message",
1299
+ sessionId,
1300
+ agent,
1301
+ message,
1302
+ recipients: [normalizeAgentId(verdict.coder, verdict.coder)],
1303
+ idempotencyKey,
1304
+ nowIso: now(),
1305
+ uuid: uuidFn,
1306
+ maxChars: JUDGE_MESSAGE_MAX_CHARS,
1307
+ });
1308
+ const posted = await persistSessionEvent({
1309
+ sessionId,
1310
+ event,
1311
+ targetPath,
1312
+ dryRun: false,
1313
+ syncSessionEventToApiFn,
1314
+ pollSessionEventsBeforeFn,
1315
+ pollSessionEventsFn,
1316
+ appendToStreamFn,
1317
+ sleepFn,
1318
+ });
1319
+ return {
1320
+ ...posted,
1321
+ transport: "room",
1322
+ ...(api ? { fellBackFrom: "api_route_absent" } : {}),
1323
+ ...(via === "auto" && namedEscalation ? { roomBecause: "escalate_to_named_handles" } : {}),
1324
+ ...(via === "auto" && !namedEscalation && !agentCredential ? { roomBecause: "no_agent_credential" } : {}),
1325
+ sessionId,
1326
+ agentId,
1327
+ verdict,
1328
+ };
1329
+ },
1330
+
1198
1331
  async session_action(input = {}) {
1199
1332
  return runSessionAction({
1200
1333
  input,
@@ -1307,6 +1440,57 @@ export function createSessionMcpToolHandlers({
1307
1440
  };
1308
1441
  },
1309
1442
 
1443
+ async session_files_list(input = {}) {
1444
+ const sessionId = requireSessionId(input, { targetPath });
1445
+ const result = await listRoomFilesFn(sessionId, {
1446
+ targetPath,
1447
+ limit: input.limit,
1448
+ maxPages: input.maxPages ?? input.max_pages,
1449
+ });
1450
+ return {
1451
+ ok: Boolean(result?.ok),
1452
+ reason: normalizeString(result?.reason),
1453
+ sessionId,
1454
+ count: Number(result?.count) || 0,
1455
+ complete: Boolean(result?.complete),
1456
+ pages: Number(result?.pages) || 0,
1457
+ scannedEvents: Number(result?.scannedEvents) || 0,
1458
+ files: Array.isArray(result?.files) ? result.files : [],
1459
+ };
1460
+ },
1461
+
1462
+ // Writes the file into this server's workspace and returns where; never the bytes.
1463
+ async session_file_get(input = {}) {
1464
+ const sessionId = requireSessionId(input, { targetPath });
1465
+ const attachmentId = normalizeString(input.attachmentId || input.attachment_id);
1466
+ try {
1467
+ const result = await downloadRoomFileFn(sessionId, attachmentId, {
1468
+ targetPath,
1469
+ out: normalizeString(input.out),
1470
+ force: Boolean(input.force),
1471
+ workspaceOnly: true,
1472
+ });
1473
+ return {
1474
+ ok: true,
1475
+ reason: "",
1476
+ sessionId,
1477
+ attachmentId,
1478
+ path: result.path,
1479
+ bytes: result.bytes,
1480
+ sha256: result.sha256,
1481
+ verified: Boolean(result.verified),
1482
+ overwritten: Boolean(result.overwritten),
1483
+ linkRefetched: Boolean(result.linkRefetched),
1484
+ attachment: result.attachment,
1485
+ };
1486
+ } catch (error) {
1487
+ if (error instanceof RoomFileError) {
1488
+ return { ok: false, reason: error.code, message: error.message, sessionId, attachmentId };
1489
+ }
1490
+ throw error;
1491
+ }
1492
+ },
1493
+
1310
1494
  async attention_request(input = {}) {
1311
1495
  const sessionId = requireSessionId(input, { targetPath });
1312
1496
  const agentId = requireAgentId(input);
@@ -1516,6 +1700,34 @@ export const SESSION_MCP_TOOLS = Object.freeze([
1516
1700
  },
1517
1701
  },
1518
1702
  },
1703
+ {
1704
+ name: "judge_verdict",
1705
+ title: "Post Judge Verdict",
1706
+ description:
1707
+ "Send a judge-verdict back to the coding agent: ACCEPT, or CHANGES with numbered file:line comments (severity P0-P3, one-line fix). Validates the verdict first. When the agent acts under its own admission for the room and the server has the judge-verdicts route, the route records it; otherwise it posts a room message that mentions the coder and carries the verdict block (the server checks the agent's grant on that post). via=room always posts the message; dryRun returns the rendered message.",
1708
+ inputSchema: {
1709
+ type: "object",
1710
+ additionalProperties: false,
1711
+ required: ["sessionId", "agentId", "verdict"],
1712
+ properties: {
1713
+ sessionId: { type: "string", minLength: 1 },
1714
+ agentId: { type: "string", minLength: 1 },
1715
+ verdict: { type: "object" },
1716
+ via: { type: "string", enum: ["auto", "api", "room"], default: "auto" },
1717
+ escalateTo: {
1718
+ anyOf: [
1719
+ { type: "string" },
1720
+ { type: "array", items: { type: "string" } },
1721
+ ],
1722
+ },
1723
+ model: { type: "string" },
1724
+ role: { type: "string" },
1725
+ displayName: { type: "string" },
1726
+ idempotencyKey: { type: "string" },
1727
+ dryRun: { type: "boolean", default: false },
1728
+ },
1729
+ },
1730
+ },
1519
1731
  {
1520
1732
  name: "session_action",
1521
1733
  title: "Record Senti Session Action",
@@ -1667,6 +1879,50 @@ export const SESSION_MCP_TOOLS = Object.freeze([
1667
1879
  },
1668
1880
  },
1669
1881
  },
1882
+ {
1883
+ name: "session_files_list",
1884
+ title: "List Senti Room Files",
1885
+ description:
1886
+ "List the files published in a session room, newest first: file name, size, content type, uploader, upload time, the message that published it, and the attachmentId that session_file_get downloads.",
1887
+ inputSchema: {
1888
+ type: "object",
1889
+ additionalProperties: false,
1890
+ required: ["sessionId"],
1891
+ properties: {
1892
+ sessionId: { type: "string", minLength: 1 },
1893
+ agentId: { type: "string", minLength: 1 },
1894
+ limit: { type: "integer", minimum: 1, maximum: MAX_ROOM_FILES_LIMIT, default: DEFAULT_ROOM_FILES_LIMIT },
1895
+ maxPages: {
1896
+ type: "integer",
1897
+ minimum: 1,
1898
+ maximum: MAX_ROOM_FILES_MAX_PAGES,
1899
+ default: DEFAULT_ROOM_FILES_MAX_PAGES,
1900
+ description: "Most pages of room history to read, 200 events each.",
1901
+ },
1902
+ },
1903
+ },
1904
+ },
1905
+ {
1906
+ name: "session_file_get",
1907
+ title: "Download Senti Room File",
1908
+ description:
1909
+ "Download one published room file into the workspace: streamed to disk, checked against the server's sha256, and written atomically. Returns the saved path and the file's metadata, never its bytes. Refuses to overwrite an existing file unless force is true, and refuses an out path outside the workspace. workspaceOnly checks the destination folder when the call starts; it does not protect against another local process renaming or relinking folders inside the workspace during the download.",
1910
+ inputSchema: {
1911
+ type: "object",
1912
+ additionalProperties: false,
1913
+ required: ["sessionId", "attachmentId"],
1914
+ properties: {
1915
+ sessionId: { type: "string", minLength: 1 },
1916
+ agentId: { type: "string", minLength: 1 },
1917
+ attachmentId: { type: "string", pattern: "^[A-Za-z0-9._:-]{8,120}$" },
1918
+ out: {
1919
+ type: "string",
1920
+ description: "File or directory inside the workspace; default: the server's file name in the workspace root.",
1921
+ },
1922
+ force: { type: "boolean", default: false },
1923
+ },
1924
+ },
1925
+ },
1670
1926
  {
1671
1927
  name: "attention_request",
1672
1928
  title: "Request Senti Attention",
@@ -1698,15 +1954,26 @@ export const SESSION_MCP_TOOLS = Object.freeze([
1698
1954
  },
1699
1955
  ]);
1700
1956
 
1701
- function toMcpTool(tool) {
1957
+ function toMcpTool(tool, { selfProvisioning = false } = {}) {
1958
+ const security = selfProvisioning
1959
+ ? { ...tool.security, requires_human_approval: false, runtime_blocked: false }
1960
+ : tool.security;
1961
+ if (selfProvisioning) delete security.runtime_block_reason;
1962
+ const selfProvisioningDetail = tool.name === "sl.session.join"
1963
+ ? "A goal requests room-owner admission approval; use wait:false to return the pending URL. Cancellation affects only this agent's pending request."
1964
+ : "It unregisters only this agent.";
1702
1965
  return cleanObject({
1703
1966
  name: tool.name,
1704
1967
  title: tool.title,
1705
- description: tool.description,
1706
- inputSchema: tool.inputSchema,
1968
+ description: selfProvisioning
1969
+ ? `${tool.description} Only the host's caller agent may use this tool. ${selfProvisioningDetail}`
1970
+ : tool.description,
1971
+ inputSchema: selfProvisioning ? selfProvisioningMcpInputSchema(tool) : tool.inputSchema,
1707
1972
  annotations: tool.annotations,
1708
- security: tool.security,
1709
- metadata: tool.metadata,
1973
+ security,
1974
+ metadata: selfProvisioning
1975
+ ? { ...tool.metadata, approvalRequired: false, approvalPolicy: "caller_bound_self_provisioning" }
1976
+ : tool.metadata,
1710
1977
  });
1711
1978
  }
1712
1979
 
@@ -1744,14 +2011,15 @@ function jsonRpcError(id, code, message, data = undefined) {
1744
2011
  });
1745
2012
  }
1746
2013
 
1747
- // The dispatcher enforces each tool's published policy before its handler runs. A tool that
1748
- // requires human approval is refused: approval would have to be server-validated and bound to the
1749
- // call's arguments, and no such approval exists yet. A runtime-blocked tool is refused too. Refused
1750
- // tools are not advertised by tools/list, and a call to one gets a specific JSON-RPC error.
2014
+ // The dispatcher enforces each tool's published policy before its handler runs. Ordinary CLI
2015
+ // tools still require human approval, which the bridge cannot validate. The two self-provisioning
2016
+ // commands have a narrowly validated caller-bound policy; every call checks its actual arguments.
2017
+ // Refused tools are not advertised by tools/list, and a call gets a specific JSON-RPC error.
1751
2018
  export const MCP_HUMAN_APPROVAL_REQUIRED = Object.freeze({ code: -32001, reason: "human_approval_required" });
1752
2019
  export const MCP_TOOL_BLOCKED = Object.freeze({ code: -32002, reason: "tool_blocked" });
1753
2020
 
1754
- function toolRefusal(tool) {
2021
+ function toolRefusal(tool, input, { env = process.env, forListing = false } = {}) {
2022
+ if (approvedSelfProvisioningMcpCall(tool, input, { env, forListing })) return "";
1755
2023
  const security = isPlainObject(tool?.security) ? tool.security : {};
1756
2024
  if (security.requires_human_approval === true) return MCP_HUMAN_APPROVAL_REQUIRED.reason;
1757
2025
  if (tool?.metadata?.bridge === "cli-command" && security.requires_human_approval !== false) {
@@ -1789,6 +2057,7 @@ export async function handleMcpJsonRpcMessage(
1789
2057
  targetPath = process.cwd(),
1790
2058
  handlers = createSessionMcpToolHandlers({ targetPath }),
1791
2059
  tools = SESSION_MCP_TOOLS,
2060
+ env = process.env,
1792
2061
  } = {}
1793
2062
  ) {
1794
2063
  if (!isPlainObject(message)) {
@@ -1821,7 +2090,7 @@ export async function handleMcpJsonRpcMessage(
1821
2090
  version: "0.20.0",
1822
2091
  },
1823
2092
  instructions:
1824
- "Use native Senti tools for coordination actions and generated sl.* bridge tools for the rest of SentinelLayer CLI. Use poll_inbox for addressed wake-style delivery, read_history for recent/older transcript grounding, session_action/session_react/session_reply/session_lock/session_unlock/session_locks for low-noise session work, send_message for durable posts, and generated command tools such as sl.auth.status or sl.review.scan for ordinary CLI commands.",
2093
+ "Use native Senti tools for coordination actions and generated sl.* bridge tools for the rest of SentinelLayer CLI. Use poll_inbox for addressed wake-style delivery, read_history for recent/older transcript grounding, session_action/session_react/session_reply/session_lock/session_unlock/session_locks for low-noise session work, send_message for durable posts, judge_verdict to send a review verdict back to a coding agent, and generated command tools such as sl.auth.status or sl.review.scan for ordinary CLI commands.",
1825
2094
  });
1826
2095
  }
1827
2096
 
@@ -1832,7 +2101,10 @@ export async function handleMcpJsonRpcMessage(
1832
2101
  if (method === "tools/list") {
1833
2102
  if (isNotification) return null;
1834
2103
  return jsonRpcSuccess(id, {
1835
- tools: tools.filter((tool) => !toolRefusal(tool)).map((tool) => toMcpTool(tool)),
2104
+ tools: tools.filter((tool) => !toolRefusal(tool, undefined, { env, forListing: true }))
2105
+ .map((tool) => toMcpTool(tool, {
2106
+ selfProvisioning: approvedSelfProvisioningMcpCall(tool, undefined, { env, forListing: true }),
2107
+ })),
1836
2108
  });
1837
2109
  }
1838
2110
 
@@ -1842,7 +2114,7 @@ export async function handleMcpJsonRpcMessage(
1842
2114
  const args = isPlainObject(message.params?.arguments) ? message.params.arguments : {};
1843
2115
  const handler = handlers[toolName];
1844
2116
  const tool = (Array.isArray(tools) ? tools : []).find((candidate) => normalizeString(candidate?.name) === toolName);
1845
- const refusal = typeof handler === "function" && tool ? toolRefusal(tool) : "";
2117
+ const refusal = typeof handler === "function" && tool ? toolRefusal(tool, args, { env }) : "";
1846
2118
  if (refusal) {
1847
2119
  return refusalError(id, toolName, tool, refusal);
1848
2120
  }
@@ -1889,6 +2161,7 @@ export async function handleMcpJsonRpcMessage(
1889
2161
 
1890
2162
  export async function createSessionMcpRuntime({
1891
2163
  targetPath = process.cwd(),
2164
+ env = process.env,
1892
2165
  sessionHandlers = createSessionMcpToolHandlers({ targetPath }),
1893
2166
  sessionTools = SESSION_MCP_TOOLS,
1894
2167
  buildCliCommandMcpToolsFn = buildCliCommandMcpTools,
@@ -1899,9 +2172,14 @@ export async function createSessionMcpRuntime({
1899
2172
  tools: [...sessionTools, ...commandTools],
1900
2173
  handlers: {
1901
2174
  ...sessionHandlers,
1902
- ...createCliCommandMcpToolHandlersFn(commandTools, { targetPath }),
2175
+ ...createCliCommandMcpToolHandlersFn(commandTools, {
2176
+ targetPath,
2177
+ env,
2178
+ approve: (tool, input) => approvedSelfProvisioningMcpCall(tool, input, { env }),
2179
+ }),
1903
2180
  },
1904
2181
  commandToolCount: commandTools.length,
2182
+ env,
1905
2183
  };
1906
2184
  }
1907
2185
 
@@ -1,4 +1,4 @@
1
- import fsp from "node:fs/promises";
1
+ import { requireOutputFileSystem } from "../config/output-filesystem.js";
2
2
  import path from "node:path";
3
3
 
4
4
  function normalizeString(value) {
@@ -274,13 +274,14 @@ export function summarizeBlackboard(blackboard) {
274
274
  };
275
275
  }
276
276
 
277
- export async function writeBlackboardArtifact(blackboard, { outputRoot = "" } = {}) {
277
+ export async function writeBlackboardArtifact(blackboard, { outputRoot = "", outputFs } = {}) {
278
+ requireOutputFileSystem(outputFs);
278
279
  if (!blackboard || !Array.isArray(blackboard.entries)) {
279
280
  throw new Error("Invalid blackboard state.");
280
281
  }
281
282
  const resolvedOutputRoot = path.resolve(String(outputRoot || "."));
282
283
  const memoryDirectory = path.join(resolvedOutputRoot, "memory");
283
- await fsp.mkdir(memoryDirectory, { recursive: true });
284
+ await outputFs.mkdir(memoryDirectory, { recursive: true });
284
285
  const runId = normalizeString(blackboard.runId) || "run";
285
286
  const artifactPath = path.join(memoryDirectory, `blackboard-${runId}.json`);
286
287
  const summary = summarizeBlackboard(blackboard);
@@ -293,7 +294,7 @@ export async function writeBlackboardArtifact(blackboard, { outputRoot = "" } =
293
294
  entries: blackboard.entries,
294
295
  queryEvents: blackboard.queryEvents,
295
296
  };
296
- await fsp.writeFile(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
297
+ await outputFs.writeFile(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
297
298
  return {
298
299
  artifactPath,
299
300
  summary,
@@ -4,11 +4,12 @@
4
4
  // (repos/<owner>/<repo>/...) and passes --repo values that gh turns into paths of its own, so each
5
5
  // value is checked for its role before any gh process starts:
6
6
  //
7
- // ghRepoSlug exactly "owner/repo". The owner starts with a letter or digit, then letters,
8
- // digits, "-" or "_" (Enterprise Managed User names end in "_shortcode"), at most
9
- // 39 characters. The repository is letters, digits, ".", "_" or "-", at most 100
10
- // characters, and not "." or "..".
11
- // ghCommitSha 7 to 40 hexadecimal characters.
7
+ // ghRepoSlug exactly "owner/repo" for gh's --repo option. The owner starts with a letter or
8
+ // digit, then letters, digits, "-" or "_" (Enterprise Managed User names end in
9
+ // "_shortcode"), at most 39 characters. The repository is letters, digits, ".",
10
+ // "_" or "-", at most 100 characters, and not "." or "..".
11
+ // ghRepoApiPath the structural repos/<owner>/<repo> path, with owner and repo encoded separately.
12
+ // ghCommitSha 7 to 64 hexadecimal characters, encoded as one path segment (SHA-1 or SHA-256).
12
13
  // ghRefSegment a git branch, tag or ref name under git's ref-name rules, not starting with "-",
13
14
  // percent-encoded as ONE path segment. A branch name may contain "/", and git also
14
15
  // allows "#", "%", "{" and other characters that mean something in a URL or to gh;
@@ -21,10 +22,18 @@
21
22
 
22
23
  const OWNER = /^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
23
24
  const REPO = /^[A-Za-z0-9._-]{1,100}$/;
24
- const COMMIT_SHA = /^[0-9A-Fa-f]{7,40}$/;
25
+ const COMMIT_SHA = /^[0-9A-Fa-f]{7,64}$/;
25
26
  // Control characters, space, and the other characters git does not allow in a ref name.
26
27
  const REF_FORBIDDEN = /[\u0000-\u0020\u007f-\u009f~^:?*[\\]/;
27
28
 
29
+ // encodeURIComponent intentionally leaves five RFC 3986 reserved characters alone.
30
+ // Encode those too so a validated value is always an opaque path segment.
31
+ function encodeGhPathSegment(value) {
32
+ return encodeURIComponent(value).replace(/[!'()*]/g, (character) =>
33
+ `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
34
+ );
35
+ }
36
+
28
37
  /** True when `value` is exactly "owner/repo" as GitHub names them. */
29
38
  export function isGhRepoSlug(value) {
30
39
  if (typeof value !== "string") return false;
@@ -51,12 +60,18 @@ export function ghRepoSlug(value, { label = "repo" } = {}) {
51
60
  return value;
52
61
  }
53
62
 
63
+ /** A `repos/<owner>/<repo>` API path with only its structural separators left raw. */
64
+ export function ghRepoApiPath(value, { label = "repo" } = {}) {
65
+ const [owner, repo] = ghRepoSlug(value, { label }).split("/");
66
+ return `repos/${encodeGhPathSegment(owner)}/${encodeGhPathSegment(repo)}`;
67
+ }
68
+
54
69
  /** `value` as a commit SHA path segment, or throws. */
55
70
  export function ghCommitSha(value, { label = "sha" } = {}) {
56
71
  if (typeof value !== "string" || !COMMIT_SHA.test(value)) {
57
- throw new Error(`${label} must be a commit SHA of 7 to 40 hexadecimal characters.`);
72
+ throw new Error(`${label} must be a commit SHA of 7 to 64 hexadecimal characters.`);
58
73
  }
59
- return value;
74
+ return encodeGhPathSegment(value);
60
75
  }
61
76
 
62
77
  /** `value` (a git branch, tag or ref name) percent-encoded as one path segment, or throws. */
@@ -64,5 +79,16 @@ export function ghRefSegment(value, { label = "ref" } = {}) {
64
79
  if (!isGitRefName(value)) {
65
80
  throw new Error(`${label} must be a valid git branch, tag or ref name.`);
66
81
  }
67
- return encodeURIComponent(value);
82
+ return encodeGhPathSegment(value);
83
+ }
84
+
85
+ /** GitHub compare endpoint with a raw structural `...` between encoded base and head values. */
86
+ export function ghCompareApiPath({ repo, branch, sha }) {
87
+ const basehead = `${ghRefSegment(branch, { label: "branch" })}...${ghCommitSha(sha)}`;
88
+ return `${ghRepoApiPath(repo)}/compare/${basehead}`;
89
+ }
90
+
91
+ /** GitHub check-runs endpoint with `ref` encoded as exactly one path segment. */
92
+ export function ghCheckRunsApiPath({ repo, ref }) {
93
+ return `${ghRepoApiPath(repo)}/commits/${ghRefSegment(ref)}/check-runs`;
68
94
  }
@@ -103,8 +103,20 @@ export function installTestEgressGuard({ env = process.env, force = false } = {}
103
103
  if (typeof nativeFetch !== "function" || typeof NativeRequest !== "function") return false;
104
104
  if (nativeFetch[GUARD_MARKER]) return true; // idempotent
105
105
 
106
- async function followChecked(thisArg, request, hops) {
107
- const response = await nativeFetch.call(thisArg, new NativeRequest(request, { redirect: "manual" }));
106
+ const requestOwners = new WeakMap();
107
+ async function dispatch(thisArg, request, ancestors = []) {
108
+ const response = await nativeFetch.call(thisArg, request);
109
+ // Undici follows cloned Requests through weak AbortController references. Keep
110
+ // the owners alive through body consumption, including after headers arrive.
111
+ const owners = [...ancestors, request];
112
+ requestOwners.set(response, owners);
113
+ if (response.body) requestOwners.set(response.body, owners);
114
+ return response;
115
+ }
116
+
117
+ async function followChecked(thisArg, request, hops, ancestors = []) {
118
+ const owners = [...ancestors, request];
119
+ const response = await dispatch(thisArg, new NativeRequest(request, { redirect: "manual" }), owners);
108
120
  const location = response.headers.get("location");
109
121
  if (!REDIRECT_STATUSES.has(response.status) || location === null) return response;
110
122
  let next;
@@ -134,7 +146,7 @@ export function installTestEgressGuard({ env = process.env, force = false } = {}
134
146
  signal: request.signal,
135
147
  redirect: "manual",
136
148
  });
137
- return followChecked(thisArg, nextRequest, hops + 1);
149
+ return followChecked(thisArg, nextRequest, hops + 1, owners);
138
150
  }
139
151
 
140
152
  const guarded = function guardedTestFetch(input, init) {
@@ -145,7 +157,7 @@ export function installTestEgressGuard({ env = process.env, force = false } = {}
145
157
  return Promise.reject(error); // the same TypeError native fetch would raise
146
158
  }
147
159
  if (!isAllowedTestDestination(request.url)) return Promise.reject(block(request.url));
148
- if (request.redirect !== "follow") return nativeFetch.call(this, request);
160
+ if (request.redirect !== "follow") return dispatch(this, request);
149
161
  return followChecked(this, request, 0);
150
162
  };
151
163
  Object.defineProperty(guarded, GUARD_MARKER, { value: true });